2026-10-14 14:46:39 +08:00
import { analysisViewInputError } from "./analysisViewErrors.js" ;
2026-10-14 08:31:29 +08:00
import { createHash } from "node:crypto" ;
2026-10-13 05:51:14 +03:00
import { z } from "zod" ;
import type { AnalysisError } from "../analysisErrorBase.js" ;
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js" ;
import type { JsonValue } from "../jsonValue.js" ;
import { err , ok , type Result } from "../result.js" ;
import {
webNetworkCaptureRecordSchema ,
webNetworkCaptureBinaryDescriptorSchema ,
webNetworkCaptureSchema ,
2026-10-13 00:24:05 -03:00
webNetworkCaptureArtifactSchema ,
webNetworkCaptureDecoderSchema ,
WEB_NETWORK_CAPTURE_FORMATS ,
2026-10-13 05:51:14 +03:00
type WebNetworkCaptureRecord ,
2026-10-13 00:24:05 -03:00
type WebNetworkCapture ,
2026-10-13 05:51:14 +03:00
} from "../webNetworkCapture.js" ;
2026-10-14 14:46:39 +08:00
import type {
AnalysisViewParent ,
AnalysisViewRequest ,
UnsignedAnalysisView ,
2026-10-13 05:51:14 +03:00
} from "./analysisView.js" ;
import {
completeWithinViewCoverage ,
pageViewCoverage ,
} from "./analysisViewCoverage.js" ;
2026-10-13 00:24:05 -03:00
import {
qlogSummarySchema ,
summarizeQlogCapture ,
projectQlogCapturePage ,
} from "./qlogCaptureView.js" ;
2026-10-13 05:51:14 +03:00
/** Offsets page the parent's retained record order, preserving producer ordinals. */
export const captureViewRequestSchema = z . strictObject ({
kind : z.literal ( "capture" ),
detail : z.enum ([ "preview" , "complete" ]). default ( "preview" ),
offset : z.number (). int (). nonnegative (). default ( 0 ),
limit : z.number (). int (). positive (). default ( 64 ),
});
const previewSchema = webNetworkCaptureRecordSchema . extend ({
binary_fields : z.array ( webNetworkCaptureBinaryDescriptorSchema ),
});
export const captureViewItemsSchema = z . array (
z . union ([ webNetworkCaptureRecordSchema , previewSchema ]),
);
/** Count/identity overview; bodies and the archive member inventory stay in the parent. */
export const captureSummarySchema = z . strictObject ({
2026-10-13 00:24:05 -03:00
artifact : webNetworkCaptureArtifactSchema ,
decoder : webNetworkCaptureDecoderSchema ,
format : z.enum ( WEB_NETWORK_CAPTURE_FORMATS ),
2026-10-13 05:51:14 +03:00
total_records : z.number (). int (). nonnegative (),
retained_records : z.number (). int (). nonnegative (),
runtime_attribution : z.literal ( "unknown" ),
2026-10-13 00:24:05 -03:00
qlog : qlogSummarySchema.optional (),
2026-10-13 05:51:14 +03:00
});
const object = (
value : JsonValue ,
) : Readonly < Record < string , JsonValue >> | null =>
value !== null && typeof value === "object" && ! Array . isArray ( value )
? value
: null ;
2026-10-14 08:31:29 +08:00
const HEADLINE_FIELDS = [
"method" ,
"url" ,
"httpVersion" ,
"http_version" ,
"status" ,
"statusText" ,
"reason" ,
];
// Native mitmproxy flows report the request target in parts and the status
// as status_code. They are previewed only for mitmproxy records and only when
// each value has its field's well-formed shape, which also bounds the
// serialized size of every record (at most ~4 KiB for an escape-heavy path).
// The path must be origin or asterisk form without control characters and
// without any "@": native redaction misses absolute and ambiguous spellings,
// and URL parsing can fail before it recognises their userinfo, so a preview
// fails closed on the marker every userinfo needs. Host and authority never
// carry a userinfo marker either.
// Target fields come only from the request and the status only from the
// response, and integer slots require the producer's own integer type.
// Excluded values stay in complete record pages.
const MITMPROXY_HEADLINE_FIELDS : Readonly < Record < string , readonly string [] >> = {
request : [ "scheme" , "host" , "port" , "authority" , "path" ],
response : [ "status_code" ],
};
const MITMPROXY_PATH_MAX_LENGTH = 2 * 1024 ;
const integerIn = ( value : JsonValue , min : number , max : number ) : boolean =>
typeof value === "number" &&
Number . isInteger ( value ) &&
value >= min &&
value <= max ;
/** True when a code unit is C0, DEL or (optionally) a space. */
const hasControl = ( value : string , includeSpace : boolean ) : boolean => {
for ( let index = 0 ; index < value . length ; index += 1 ) {
const code = value . charCodeAt ( index );
if ( code < 0x20 || code === 0x7f || ( includeSpace && code === 0x20 ))
return true ;
}
return false ;
};
// An unpaired surrogate has no UTF-8 form, so no producer reported it as text.
const LONE_SURROGATE = /\p{Cs}/u;
const hostLike = ( value : JsonValue , maxLength : number ) : boolean =>
typeof value === "string" &&
value . length <= maxLength &&
! hasControl ( value , true ) &&
! /[@/\\]/u.test(value) &&
! LONE_SURROGATE . test ( value );
type NumericSidecar = WebNetworkCaptureRecord [ "numeric_literals" ][ number ];
type BinaryField = WebNetworkCaptureRecord [ "binary_fields" ][ number ];
const MITMPROXY_INTEGER_FIELDS = new Set ([ "port" , "status_code" ]);
/** Retained native producer bytes whose content, length and digest are all the text's UTF-8. */
const describesText = ( descriptor : BinaryField , text : string ) : boolean => {
if (
descriptor . representation !== "producer-bytes" ||
descriptor . state !== "retained"
)
return false ;
// An unpaired surrogate has no UTF-8 form; Buffer.from would invent
// replacement-character bytes that no producer value could have had.
const utf8 = Buffer . from ( text , "utf8" );
return (
utf8 . toString ( "utf8" ) === text &&
descriptor . bytes === utf8 . length &&
descriptor . content_base64 === utf8 . toString ( "base64" ) &&
descriptor . sha256 === createHash ( "sha256" ). update ( utf8 ). digest ( "hex" )
);
};
/**
* Integer slots need exactly one integer sidecar whose literal is the value's
* canonical decimal, and no byte descriptor. String slots need no sidecar and
* at most one byte descriptor, which must describe the value's own UTF-8.
* Selecting a field also selects its sidecars, so this keeps every copied
* literal small and consistent.
*/
const representationMatches = (
field : string ,
value : JsonValue ,
sidecars : readonly NumericSidecar [],
bytes : readonly BinaryField [],
) : boolean => {
if ( MITMPROXY_INTEGER_FIELDS . has ( field ))
return (
bytes . length === 0 &&
typeof value === "number" &&
sidecars . length === 1 &&
sidecars [ 0 ] ? . producer_type === "integer" &&
sidecars [ 0 ]. literal === String ( value )
);
if ( sidecars . length !== 0 || bytes . length > 1 ) return false ;
const [ descriptor ] = bytes ;
return (
descriptor === undefined ||
( typeof value === "string" && describesText ( descriptor , value ))
);
};
const headlineShape = ( field : string , value : JsonValue ) : boolean => {
switch ( field ) {
case "scheme" :
return (
typeof value === "string" &&
/^[A-Za-z][A-Za-z0-9+.-]{0,31}$/u.test(value)
);
case "host" :
return hostLike ( value , 255 );
case "authority" :
return hostLike ( value , 262 );
case "port" :
return integerIn ( value , 0 , 65 _535 );
case "status_code" :
return integerIn ( value , 100 , 999 );
case "path" :
return (
typeof value === "string" &&
value . length <= MITMPROXY_PATH_MAX_LENGTH &&
! hasControl ( value , false ) &&
! value . includes ( "@" ) &&
! LONE_SURROGATE . test ( value ) &&
( value === "*" ||
( value . startsWith ( "/" ) &&
! value . startsWith ( "//" ) &&
! value . startsWith ( "/\\" )))
);
default :
return false ;
}
};
/** Strict ancestors of a JSON pointer, from the root down. */
const ancestorsOf = ( pointer : string ) : string [] => {
const ancestors : string [] = [];
for (
let at = pointer . indexOf ( "/" );
at !== - 1 ;
at = pointer . indexOf ( "/" , at + 1 )
)
ancestors . push ( pointer . slice ( 0 , at ));
return ancestors ;
};
/** Add a pointer's strict ancestors, stopping at one already present (its own ancestors are too). */
const addAncestors = ( pointer : string , into : Set < string >) : void => {
for (
let at = pointer . lastIndexOf ( "/" );
at !== - 1 ;
at = at === 0 ? - 1 : pointer.lastIndexOf ( "/" , at - 1 )
) {
const ancestor = pointer . slice ( 0 , at );
if ( into . has ( ancestor )) return ;
into . add ( ancestor );
}
};
const groupByPointer = < T extends { readonly pointer : string }>(
facets : readonly T [],
) : Map < string , T [] > => {
const groups = new Map < string , T [] >();
for ( const facet of facets ) {
const group = groups . get ( facet . pointer );
if ( group === undefined ) groups . set ( facet . pointer , [ facet ]);
else group . push ( facet );
}
return groups ;
};
/** A record's facet coordinates, indexed once for every headline check. */
type FacetIndex = {
readonly sidecars : ReadonlyMap < string , readonly NumericSidecar [] >;
readonly bytes : ReadonlyMap < string , readonly BinaryField [] >;
/** The producer held a number or bytes here. */
readonly scalars : ReadonlySet < string >;
/** A facet lies beneath: the producer held a container here. */
readonly containers : ReadonlySet < string >;
/**
* Withheld by a value redaction or a redacted byte descriptor; either facet
* withholds the value even when the other one is missing.
*/
readonly withheld : ReadonlySet < string >;
/** The parent of an omitted property name; it hides no sibling values. */
readonly omittedNames : ReadonlySet < string >;
};
const indexFacets = ( record : WebNetworkCaptureRecord ) : FacetIndex => {
const containers = new Set < string >();
const withheld = new Set < string >();
const omittedNames = new Set < string >();
for ( const { pointer } of record . numeric_literals )
addAncestors ( pointer , containers );
for ( const { pointer , state } of record . binary_fields ) {
addAncestors ( pointer , containers );
if ( state === "redacted" ) withheld . add ( pointer );
}
for ( const redaction of record . redactions ) {
addAncestors ( redaction . pointer , containers );
( "scope" in redaction ? omittedNames : withheld ). add ( redaction . pointer );
}
const sidecars = groupByPointer ( record . numeric_literals );
const bytes = groupByPointer ( record . binary_fields );
return {
sidecars ,
bytes ,
scalars : new Set ([... sidecars . keys (), ... bytes . keys ()]),
containers ,
withheld ,
omittedNames ,
};
};
/**
* A value the record marks as redacted, or that lies under a redacted value,
* is never promoted even if it is still present (imported or caller-supplied
* Evidence). A scalar is also inconsistent when a numeric sidecar or byte
* descriptor sits on an ancestor (the producer held a scalar there) or any
* facet sits beneath it (the producer held a container at it).
*/
const usableScalar = ( index : FacetIndex , pointer : string ) : boolean => {
const ancestors = ancestorsOf ( pointer );
return (
! index . omittedNames . has ( pointer ) &&
! index . containers . has ( pointer ) &&
! index . withheld . has ( pointer ) &&
! ancestors . some (
( ancestor ) => index . withheld . has ( ancestor ) || index . scalars . has ( ancestor ),
)
);
};
/**
* The container format alone does not make a flow HTTP; its own type does,
* when the producer supplied that type as text rather than a number or bytes.
*/
const textualFlowType = ( index : FacetIndex ) : boolean =>
usableScalar ( index , "/type" ) && ! index . scalars . has ( "/type" );
/** Shape checks run first, so the representation check only sees bounded values. */
const mitmproxyHeadline = (
index : FacetIndex ,
pointer : string ,
field : string ,
value : JsonValue ,
) : boolean =>
usableScalar ( index , pointer ) &&
headlineShape ( field , value ) &&
representationMatches (
field ,
value ,
index . sidecars . get ( pointer ) ?? [],
index . bytes . get ( pointer ) ?? [],
);
/** Copy one message side's headlines; mitmproxy fields need the record's facet index. */
const selectHeadlines = (
fields : Readonly < Record < string , JsonValue >>,
key : string ,
facets : FacetIndex | null ,
selectedPointers : Set < string >,
) : Record < string , JsonValue > => {
const selected : Record < string , JsonValue > = {};
const candidates = [
... HEADLINE_FIELDS . map (( field ) => [ field , null ] as const ),
...( facets === null ? [] : ( MITMPROXY_HEADLINE_FIELDS [ key ] ?? [])). map (
( field ) => [ field , facets ] as const ,
),
];
for ( const [ field , index ] of candidates ) {
const value = fields [ field ];
if (
! Object . hasOwn ( fields , field ) ||
value === undefined ||
( index !== null &&
! mitmproxyHeadline ( index , `/ ${ key } / ${ field } ` , field , value ))
)
continue ;
selected [ field ] = value ;
selectedPointers . add ( `/ ${ key } / ${ field } ` );
}
return selected ;
};
2026-10-13 05:51:14 +03:00
/** Select already parsed producer facts without interpreting HTTP or payload bytes again. */
2026-10-14 08:31:29 +08:00
const preview = ( record : WebNetworkCaptureRecord , format : string ) => {
2026-10-13 05:51:14 +03:00
const source = object ( record . reported );
const reported : Record < string , JsonValue > = {};
const selectedPointers = new Set < string >();
if ( source !== null ) {
2026-10-14 08:31:29 +08:00
const index =
format === "mitmproxy" && source . type === "http"
? indexFacets ( record )
: null ;
const mitmproxyFacets =
index !== null && textualFlowType ( index ) ? index : null ;
2026-10-13 05:51:14 +03:00
for ( const key of [ "session_id" , "name" , "time" , "startedDateTime" , "type" ])
if ( Object . hasOwn ( source , key ) && source [ key ] !== undefined ) {
reported [ key ] = source [ key ];
selectedPointers . add ( `/ ${ key } ` );
}
for ( const key of [ "request" , "response" ]) {
const value = source [ key ];
if ( ! Object . hasOwn ( source , key ) || value === undefined ) continue ;
const fields = object ( value );
if ( fields === null ) {
// Preserve a reported null/scalar instead of inventing HTTP fields.
reported [ key ] = value ;
continue ;
}
2026-10-14 08:31:29 +08:00
reported [ key ] = selectHeadlines (
fields ,
key ,
mitmproxyFacets ,
selectedPointers ,
);
2026-10-13 05:51:14 +03:00
}
}
return {
... record ,
reported : source === null ? record.reported : reported ,
binary_fields : record.binary_fields.map (
({ content_base64 : _content , ... field }) => field ,
),
numeric_literals :
source === null
? record.numeric_literals
: record.numeric_literals.filter (({ pointer }) =>
selectedPointers . has ( pointer ),
),
};
};
2026-10-13 00:24:05 -03:00
/** Authenticate the retained report and its subject at one owning boundary. */
const parseCaptureParent = (
2026-10-13 05:51:14 +03:00
parent : AnalysisViewParent ,
2026-10-13 00:24:05 -03:00
) : Result < WebNetworkCapture , AnalysisError > => {
2026-10-13 05:51:14 +03:00
const parsed = webNetworkCaptureSchema . safeParse ( parent . normalizedResult );
if ( ! parsed . success )
return err (
analysisInputErrorFromIssues (
"inspect_analysis_view" ,
parsed . error . issues . map (( issue ) => ({
... issue ,
path : [ "source" , "normalized_result" , ... issue . path ],
})),
{ source : { normalized_result : parent.normalizedResult } },
{ cause : parsed.error },
),
);
const report = parsed . data ;
if (
report . artifact . sha256 !== null &&
( parent . subject == null ||
parent . subject . local_path !== report . artifact . path ||
parent . subject . digest . sha256 !== report . artifact . sha256 )
)
return err (
analysisViewInputError ([
{
path : [ "source" , "subject" ],
reason : "invalid_value" ,
message :
"Capture parent subject must identify the report's exact path and SHA-256." ,
},
]),
);
2026-10-13 00:24:05 -03:00
return ok ( report );
};
/** Page immutable retained facts; no capture file, provider or network is consulted. */
export const projectWebNetworkCaptureView = (
parent : AnalysisViewParent ,
view : AnalysisViewRequest ,
) : Result < UnsignedAnalysisView , AnalysisError > => {
if (
view . kind !== "summary" &&
view . kind !== "capture" &&
view . kind !== "qlog"
)
return err (
analysisViewInputError ([
{
path : [ "view" ],
reason : "invalid_value" ,
message :
2026-10-19 17:29:37 -07:00
"Historical captures support summary, capture record pages, qlog fact pages and admitted api-surface, environment-manifest or environment-state views." ,
2026-10-13 00:24:05 -03:00
},
]),
);
const parsed = parseCaptureParent ( parent );
if ( ! parsed . ok ) return parsed ;
const report = parsed . value ;
2026-10-13 05:51:14 +03:00
const shared = {
parent_evidence_id : parent.evidenceId ,
parent_operation : "inspect_web_network_capture" as const ,
parent_digest : parent.evidenceId.slice ( 3 ),
artifact : { path : report.artifact.path , sha256 : report.artifact.sha256 },
limitations : [
... new Set ([
... parent . limitations ,
... report . limitations ,
2026-10-13 00:24:05 -03:00
"Capture record pages cover the records selected into the parent Evidence, in that selected order; total_records counts the original decoded capture." ,
2026-10-13 05:51:14 +03:00
]),
],
unknowns : [
"Historical records do not establish live runtime attribution or deployment authenticity." ,
...( report . artifact . sha256 === null
? [ "The explicitly excluded artifact digest remains unknown." ]
: []),
],
};
if ( view . kind === "summary" )
return ok ({
kind : "summary" ,
view ,
... shared ,
summary : {
artifact : report.artifact ,
decoder : report.decoder ,
format : report.format ,
total_records : report.total_records ,
retained_records : report.records.length ,
runtime_attribution : report.runtime_attribution ,
2026-10-13 00:24:05 -03:00
...( report . format === "qlog"
? { qlog : summarizeQlogCapture ( report . qlog ) }
: {}),
2026-10-13 05:51:14 +03:00
},
coverage : completeWithinViewCoverage (
report . records . length ,
report . records . length ,
),
});
2026-10-13 00:24:05 -03:00
if ( view . kind === "qlog" ) {
if ( report . format !== "qlog" )
return err (
analysisViewInputError ([
{
path : [ "view" , "kind" ],
reason : "invalid_value" ,
message : "Qlog fact views require retained qlog Evidence." ,
},
]),
);
const projected = projectQlogCapturePage ( report . qlog , view );
return projected . ok
? ok ({
kind : "qlog" ,
view ,
... shared ,
... projected . value ,
limitations : [
... shared . limitations ,
"Qlog fact pages retain all-recorded-events-before-selection scope. Exhausting a page does not establish network completeness, body recovery, retransmission or actual loss." ,
],
})
: projected ;
}
2026-10-13 05:51:14 +03:00
const records = report . records . slice ( view . offset , view . offset + view . limit );
return ok ({
kind : "capture" ,
view ,
... shared ,
items : captureViewItemsSchema.parse (
2026-10-14 08:31:29 +08:00
view . detail === "complete"
? records
: records.map (( record ) => preview ( record , report . format )),
2026-10-13 05:51:14 +03:00
),
coverage : pageViewCoverage (
view . offset ,
records . length ,
report . records . length ,
),
limitations : [
... shared . limitations ,
...( view . detail === "preview"
? [
"Preview selects recorded headline fields and byte descriptors; headers, XML, bodies, message data and other fields remain in complete record pages. Missing preview fields do not establish their absence from the producer record." ,
]
: []),
],
});
};