* feat(browser): inspect retained HAR and mitmproxy capture evidence Add independent offline format adapters, inline CLI/MCP evidence, bounded owned decoder processes, and real producer verification. Refs #882 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): preserve capture boundaries during cancellation and decoding Honor cancellation during owned-command cleanup, retain opaque HAR extension content, and clean verifier resources on every failure path. Refs #882 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): exclude sensitive capture fields across representations Normalize known native transport URLs for both bytes and strings, including backups. Exclude explicitly sensitive text fields with null so redaction markers cannot reintroduce declared literals. Cover both provider identities in catalog conformance. Refs #882 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(browser): exclude sensitive capture identities and bound diagnostics * fix(browser): validate excluded HAR subtrees before projection * fix(browser): distinguish capture input limits from decoder failures * fix(browser): preserve decoder failure categories * fix(browser): validate excluded native credential values * test(browser): report capture verifier assertions directly * fix(browser): classify native representation failures precisely * fix(browser): exclude URL userinfo after header whitespace * fix(browser): preserve capture evidence and failure boundaries * fix(browser): exclude sensitive literals from capture limitations * test(browser): check limitation values without JSON syntax * fix(browser): preserve acquisition integrity and exclude selection diagnostics * fix(browser): preserve capture acquisition and declaration boundaries * fix(mcp): project sensitive capture argument validation * fix: preserve capture bytes and prelaunch cancellation * fix(browser): exclude sensitive HAR record ancestry * fix(browser): preserve transport URL and parameter exclusions * fix(browser): preserve numeric redaction and canonical capture provenance * fix(browser): cover derived capture values and ownership preparation deadlines * fix(browser): exclude explicitly sensitive artifact digests * chore: regenerate generated metadata * fix(browser): reject lossy HAR member normalization * test(browser): assert the advertised unsupported-profile outcome * fix(browser): preserve JSON visitor nesting failure locations * fix(browser): exclude explicitly sensitive native source ranges --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
203 lines
6.9 KiB
TypeScript
203 lines
6.9 KiB
TypeScript
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
|
|
import { excludeCaptureCoordinates } from "../domain/webNetworkCaptureCoordinates.js";
|
|
import { redactExplicitText } from "../domain/explicitSensitiveValues.js";
|
|
import { isAbsolute } from "node:path";
|
|
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
|
import type { WebNetworkCapturePort } from "./WebNetworkCapturePort.js";
|
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
|
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
|
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import {
|
|
inspectWebNetworkCaptureInputSchema,
|
|
webNetworkCaptureSchema,
|
|
} from "../domain/webNetworkCapture.js";
|
|
|
|
const OPERATION = "inspect_web_network_capture";
|
|
|
|
/** Shared CLI/MCP historical inspection preserves caller selection and producer record identity. */
|
|
export class WebNetworkCaptureService {
|
|
constructor(readonly port: WebNetworkCapturePort) {}
|
|
|
|
/** Return selected complete records inline, without inventing live browser attribution. */
|
|
async inspect(
|
|
rawInput: unknown,
|
|
options?: ExecutionOptions,
|
|
): Promise<Result<Evidence, AnalysisError>> {
|
|
if (options?.signal?.aborted)
|
|
return err(new AnalysisCancelledError(OPERATION));
|
|
const declarations =
|
|
inspectWebNetworkCaptureInputSchema.shape.sensitive_values.safeParse(
|
|
typeof rawInput === "object" &&
|
|
rawInput !== null &&
|
|
"sensitive_values" in rawInput
|
|
? rawInput.sensitive_values
|
|
: undefined,
|
|
);
|
|
const failure = (error: AnalysisError): Result<never, AnalysisError> =>
|
|
err(
|
|
redactExplicitFailure(
|
|
error,
|
|
declarations.success ? declarations.data : [],
|
|
),
|
|
);
|
|
const parsed = inspectWebNetworkCaptureInputSchema.safeParse(rawInput);
|
|
if (!parsed.success)
|
|
return failure(
|
|
new AnalysisInputError(
|
|
OPERATION,
|
|
{ cause: parsed.error },
|
|
projectInputIssues(parsed.error.issues, rawInput),
|
|
),
|
|
);
|
|
const input = parsed.data;
|
|
if (!isAbsolute(input.capture_path))
|
|
return failure(
|
|
new AnalysisInputError(OPERATION, undefined, [
|
|
{
|
|
path: ["capture_path"],
|
|
reason: "invalid_format",
|
|
message: "Expected an absolute filesystem path on this host.",
|
|
},
|
|
]),
|
|
);
|
|
if (
|
|
input.record_ordinals !== undefined &&
|
|
new Set(input.record_ordinals).size !== input.record_ordinals.length
|
|
)
|
|
return failure(
|
|
new AnalysisInputError(OPERATION, undefined, [
|
|
{
|
|
path: ["record_ordinals"],
|
|
reason: "invalid_format",
|
|
message: "Each requested producer ordinal must occur once.",
|
|
},
|
|
]),
|
|
);
|
|
const loaded = await this.port.inspect(input, options);
|
|
if (!loaded.ok) return failure(loaded.error);
|
|
if (options?.signal?.aborted)
|
|
return failure(new AnalysisCancelledError(OPERATION));
|
|
const report = webNetworkCaptureSchema.safeParse(loaded.value);
|
|
if (!report.success)
|
|
return failure(
|
|
new AnalysisOutputError(
|
|
OPERATION,
|
|
"Capture adapter returned malformed historical evidence.",
|
|
),
|
|
);
|
|
const value = report.data;
|
|
const artifactSha256 = value.artifact.sha256;
|
|
if (artifactSha256 === null)
|
|
return failure(
|
|
new AnalysisOutputError(
|
|
OPERATION,
|
|
"Capture adapter omitted its observed artifact digest before explicit projection.",
|
|
),
|
|
);
|
|
if (
|
|
value.artifact.path !== input.capture_path ||
|
|
value.format !== input.format ||
|
|
value.total_records !== value.records.length ||
|
|
value.records.some((record, index) => record.ordinal !== index)
|
|
)
|
|
return failure(
|
|
new AnalysisOutputError(
|
|
OPERATION,
|
|
"Capture adapter changed the selected artifact, format or original record sequence.",
|
|
),
|
|
);
|
|
const safeValue = excludeCaptureCoordinates(value, input.sensitive_values);
|
|
const redactText = (text: string): string =>
|
|
redactExplicitText(text, input.sensitive_values);
|
|
const selected =
|
|
input.record_ordinals ?? value.records.map(({ ordinal }) => ordinal);
|
|
const records = [];
|
|
for (const ordinal of selected) {
|
|
const record = safeValue.records[ordinal];
|
|
if (record === undefined)
|
|
return failure(
|
|
new AnalysisInputError(OPERATION, undefined, [
|
|
{
|
|
path: ["record_ordinals"],
|
|
reason: "invalid_format",
|
|
message: `Producer ordinal ${ordinal} is outside the retained capture (${value.total_records} records).`,
|
|
},
|
|
]),
|
|
);
|
|
records.push({
|
|
...record,
|
|
limitations: record.limitations.map(redactText),
|
|
});
|
|
}
|
|
const safePath = input.sensitive_values.some((literal) =>
|
|
input.capture_path.includes(literal),
|
|
)
|
|
? ""
|
|
: input.capture_path;
|
|
const safeSha256 = input.sensitive_values.some((literal) =>
|
|
artifactSha256.includes(literal),
|
|
)
|
|
? null
|
|
: artifactSha256;
|
|
const subjectUnavailableReason = redactText(
|
|
"The explicitly sensitive artifact digest is excluded; its cryptographic identity is unknown in this Evidence.",
|
|
);
|
|
const limitations = [
|
|
...safeValue.limitations,
|
|
...(safePath === ""
|
|
? [
|
|
"The explicitly sensitive artifact path is excluded; its size remains available.",
|
|
]
|
|
: []),
|
|
].map(redactText);
|
|
const parameters = jsonObjectSchema.parse({
|
|
capture_path: safePath,
|
|
...(input.sensitive_values.some((literal) =>
|
|
input.format.includes(literal),
|
|
)
|
|
? {}
|
|
: { format: input.format }),
|
|
record_ordinals: selected,
|
|
sensitive_value_count: input.sensitive_values.length,
|
|
});
|
|
return ok(
|
|
createEvidence(
|
|
safeSha256 === null
|
|
? undefined
|
|
: {
|
|
path: safePath,
|
|
sha256: safeSha256,
|
|
format: "file",
|
|
},
|
|
value.decoder,
|
|
{
|
|
operation: OPERATION,
|
|
parameters,
|
|
result: {
|
|
...safeValue,
|
|
artifact: { ...value.artifact, path: safePath, sha256: safeSha256 },
|
|
records,
|
|
limitations:
|
|
safeSha256 === null
|
|
? [...limitations, subjectUnavailableReason]
|
|
: limitations,
|
|
},
|
|
confidence: "observed",
|
|
authority: "historical-reference",
|
|
limitations,
|
|
...(safeSha256 === null ? { subjectUnavailableReason } : {}),
|
|
locations:
|
|
safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|