fix(evidence): exclude sensitive publication identities

This commit is contained in:
morluto committed 2026-10-27 03:12:22 +08:00
1 parent 5da50ab3f4
commit f68064e46c
12 files changed
+277 -53

No files matched your search

+9 -7
View File
@@ -507,13 +507,15 @@ const runAnalysis = async (
);
if (written.ok) publishedSnapshot = written.value;
else {
if (written.error instanceof EvidenceFileError)
publishedSnapshot = written.error.published;
const recovered =
written.error instanceof EvidenceFileError &&
written.error.published !== undefined &&
(await session.closeFileResources()).ok;
if (!recovered)
const published =
written.error instanceof EvidenceFileError
? written.error.published
: undefined;
if (published?.path !== undefined)
publishedSnapshot = { path: published.path, bytes: published.bytes };
const released =
published !== undefined && (await session.closeFileResources()).ok;
if (!released || published?.path === undefined)
return err(written.error.retainPartialObservation(evidence));
}
}
+5 -2
View File
@@ -208,9 +208,12 @@ const persistEvidenceBundleCommand = async (
if (
!written.error.cleanupIncomplete &&
written.error instanceof EvidenceFileError &&
written.error.published
written.error.published?.path !== undefined
) {
return ok(written.error.published);
return ok({
path: written.error.published.path,
bytes: written.error.published.bytes,
});
}
return written;
};
+25 -16
View File
@@ -9,6 +9,7 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
import { err, ok, type Result } from "../domain/result.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
@@ -48,8 +49,8 @@ interface CliEvidenceDelivery {
readonly evidenceOutput: string | undefined;
/** Derived workflows publish their selected source closure with the result. */
readonly sourceEvidence?: readonly Evidence[];
/** Invocation-specific exclusions also cover errors from final service cleanup. */
readonly redactFailure?: (error: AnalysisError) => AnalysisError;
/** Invocation exclusions cover final cleanup and publication paths/diagnostics. */
readonly sensitiveValues?: readonly string[];
}
export const cliEvidenceDeliveryInputError = (
@@ -130,6 +131,8 @@ export const deliverCliAnalysisEvidence = async (
detail,
evidenceOutput,
} = input;
const redactFailure = (error: AnalysisError) =>
redactExplicitFailure(error, input.sensitiveValues ?? []);
const inputError = cliEvidenceDeliveryInputError(input);
if (inputError !== undefined)
return finishAnalysisCommand(
@@ -139,9 +142,7 @@ export const deliverCliAnalysisEvidence = async (
operation,
signal,
undefined,
input.redactFailure === undefined
? {}
: { redactFailure: input.redactFailure },
{ redactFailure },
);
const complete = await finishAnalysisCommand(
run,
@@ -150,9 +151,7 @@ export const deliverCliAnalysisEvidence = async (
operation,
signal,
undefined,
input.redactFailure === undefined
? {}
: { redactFailure: input.redactFailure },
{ redactFailure },
);
return publishCliAnalysisEvidence(complete, {
providerId,
@@ -160,6 +159,9 @@ export const deliverCliAnalysisEvidence = async (
signal,
detail,
evidenceOutput,
...(input.sensitiveValues === undefined
? {}
: { sensitiveValues: input.sensitiveValues }),
...(input.view === undefined ? {} : { view: input.view }),
...(input.sourceEvidence === undefined
? {}
@@ -173,12 +175,16 @@ export const publishCliAnalysisEvidence = async (
input: CliEvidenceDelivery,
): Promise<JsonValue> => {
const { operation, signal, detail, evidenceOutput } = input;
const projectFailure = (error: AnalysisError) =>
projectAnalysisError(
redactExplicitFailure(error, input.sensitiveValues ?? []),
);
if (!isEvidence(value)) return value;
const complete = parseEvidence(value);
const inputError = cliEvidenceDeliveryInputError(input);
if (inputError !== undefined) {
inputError.retainPartialObservation(complete);
return projectAnalysisError(inputError);
return projectFailure(inputError);
}
if (evidenceOutput === undefined) return complete;
// Project before publication so a view failure cannot commit an
@@ -191,9 +197,7 @@ export const publishCliAnalysisEvidence = async (
})
: ok(complete);
if (!delivered.ok)
return projectAnalysisError(
delivered.error.retainPartialObservation(complete),
);
return projectFailure(delivered.error.retainPartialObservation(complete));
// This projection answers the same direct invocation as its parent. Later
// inspect_analysis_view calls own their own input and do not inherit it.
const selected =
@@ -201,8 +205,7 @@ export const publishCliAnalysisEvidence = async (
? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path)
: delivered.value;
const inspectionSources = analysisEvidenceSources(complete);
if (!inspectionSources.ok)
return projectAnalysisError(inspectionSources.error);
if (!inspectionSources.ok) return projectFailure(inspectionSources.error);
const published = await publishEvidenceDelivery({
bundle: createEvidenceBundle([
...new Map(
@@ -217,12 +220,18 @@ export const publishCliAnalysisEvidence = async (
operation,
signal,
});
if (!published.ok) return projectAnalysisError(published.error);
if (!published.ok) return projectFailure(published.error);
return detail === "summary"
? {
summary: published.value.delivered,
evidence_bundle: {
path: published.value.evidenceBundle.path,
...(input.sensitiveValues?.some(
(literal) =>
literal !== "" &&
published.value.evidenceBundle.path.includes(literal),
)
? {}
: { path: published.value.evidenceBundle.path }),
bytes: published.value.evidenceBundle.bytes,
},
}
+1 -3
View File
@@ -17,7 +17,6 @@ import {
import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { explicitSensitiveValueOptions } from "./explicitSensitiveArguments.js";
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
/** Expose offline historical capture inspection with the same selection contract as MCP. */
export const registerWebNetworkCaptureCommands = (
@@ -117,8 +116,7 @@ export const registerWebNetworkCaptureCommands = (
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
redactFailure: (error) =>
redactExplicitFailure(error, options.sensitiveValue ?? []),
sensitiveValues: options.sensitiveValue ?? [],
});
},
),
+3 -1
View File
@@ -70,7 +70,9 @@ export const analysisErrorRemediationAction = (
if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
return "Review details.reason, then retry the same analysis after the condition clears.";
if (error instanceof EvidenceFileError && error.published !== undefined)
return "Use the published file at details.published_path and resolve the reported staging cleanup failure.";
return error.published.path === undefined
? "Resolve the reported staging cleanup failure. Publication completed; its path identity is excluded."
: "Use the published file at details.published_path and resolve the reported staging cleanup failure.";
if (
error instanceof ProviderSelectionError &&
error.remediationAction !== undefined
+3 -1
View File
@@ -425,7 +425,9 @@ const artifactStateErrorDetails = (
: {
published: true,
bytes: error.published.bytes,
published_path: error.published.path,
...(error.published.path === undefined
? {}
: { published_path: error.published.path }),
}),
};
return undefined;
+8 -2
View File
@@ -10,6 +10,12 @@ export interface PublishedEvidenceFile {
readonly bytes: number;
}
/** Publication completion survives an explicitly excluded path identity. */
export interface PublishedEvidenceFileObservation {
readonly path?: string;
readonly bytes: number;
}
/** Evidence identity, schema, or bundle manifests failed integrity checks. */
export class EvidenceIntegrityError extends AnalysisError {
readonly _tag = "EvidenceIntegrityError";
@@ -51,7 +57,7 @@ export class EvidenceFileError extends AnalysisError {
/** Caller-selected path that failed, resolved against the working directory. */
readonly path: string | undefined;
/** Present only when a write committed before cleanup failed. */
readonly published: PublishedEvidenceFile | undefined;
readonly published: PublishedEvidenceFileObservation | undefined;
/** Schema issues relative to the selected file's JSON root, never the request envelope. */
readonly contentIssues: readonly AnalysisInputIssue[] | undefined;
@@ -67,7 +73,7 @@ export class EvidenceFileError extends AnalysisError {
| "io",
options?: AnalysisErrorOptions & {
readonly path?: string;
readonly published?: PublishedEvidenceFile;
readonly published?: PublishedEvidenceFileObservation;
readonly userMessage?: string;
readonly contentIssues?: readonly AnalysisInputIssue[];
},
+25 -2
View File
@@ -1,6 +1,6 @@
import { AnalysisAccessDeniedError } from "./analysisErrorCore.js";
import { expect, it } from "vitest";
import { EvidenceFileError } from "./evidenceErrors.js";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
@@ -9,6 +9,7 @@ import {
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import { expect, it } from "vitest";
import { projectAnalysisError } from "./analysisErrorProjection.js";
import { ProviderCleanupError } from "./providerCleanupError.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
@@ -284,3 +285,25 @@ it("does not reinterpret an unknown argument name as a JSON pointer", () => {
throw new Error("Input error required");
expect(result.issues[0]?.path).toEqual([]);
});
it("preserves completed publication and live cleanup while excluding marked file identities", () => {
const original = new EvidenceFileError("write", "io", {
path: "/private-marker/output.json",
published: { path: "/private-marker/output.json", bytes: 42 },
cleanup: {
reason: "private-marker staging failed",
resources: ["/private-marker/stage"],
},
});
const redacted = redactExplicitFailure(original, ["private-marker"]);
const projected = projectAnalysisError(redacted);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: { published: true, bytes: 42 },
});
expect(projected.details).not.toHaveProperty("path");
expect(projected.details).not.toHaveProperty("published_path");
expect(JSON.stringify(projected)).not.toContain("private-marker");
original.resolveCleanup();
expect(redacted.cleanupIncomplete).toBe(false);
});
+42 -15
View File
@@ -1,8 +1,10 @@
import { EvidenceFileError } from "./evidenceErrors.js";
import type { AnalysisError } from "./analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisInputError,
type AnalysisInputIssue,
AnalysisOutputError,
AnalysisCapabilityUnavailableError,
AnalysisResourceConstraintError,
@@ -128,6 +130,18 @@ const redactFailure = (
if (typeof value === "object" && value !== null) return diagnostics(value);
return value;
};
const issueProjection = (issue: AnalysisInputIssue): AnalysisInputIssue => ({
...issue,
// Unknown arguments are literal names, not producer JSON pointers.
path: path(issue.path, issue.reason === "unknown_argument"),
...(issue.message === undefined ? {} : { message: text(issue.message) }),
...(issue.expected === undefined
? {}
: { expected: visit(issue.expected) }),
...(issue.observed === undefined
? {}
: { observed: visit(issue.observed) }),
});
const context = {
cause: error,
...(error.limit === undefined ? {} : { limit: error.limit }),
@@ -157,6 +171,33 @@ const redactFailure = (
},
}),
};
if (error instanceof EvidenceFileError) {
const identity = (value: string | undefined): string | undefined =>
value !== undefined &&
values.some((literal) => literal !== "" && value.includes(literal))
? undefined
: value;
const retainedPath = identity(error.path);
const publishedPath = identity(error.published?.path);
return new EvidenceFileError(error.operation, error.reason, {
...context,
...(retainedPath === undefined ? {} : { path: retainedPath }),
...(error.userMessage === undefined
? {}
: { userMessage: text(error.userMessage) }),
...(error.published === undefined
? {}
: {
published: {
bytes: error.published.bytes,
...(publishedPath === undefined ? {} : { path: publishedPath }),
},
}),
...(error.contentIssues === undefined
? {}
: { contentIssues: error.contentIssues.map(issueProjection) }),
});
}
if (error instanceof AnalysisCancelledError)
return new AnalysisCancelledError(error.operation, {
...context,
@@ -190,21 +231,7 @@ const redactFailure = (
return new AnalysisInputError(
error.operation,
context,
error.issues.map((issue) => ({
...issue,
// Unknown-argument paths contain literal field names, including names
// beginning with '/'; they are not producer JSON-pointer coordinates.
path: path(issue.path, issue.reason === "unknown_argument"),
...(issue.message === undefined
? {}
: { message: text(issue.message) }),
...(issue.expected === undefined
? {}
: { expected: visit(issue.expected) }),
...(issue.observed === undefined
? {}
: { observed: visit(issue.observed) }),
})),
error.issues.map(issueProjection),
);
if (error instanceof AnalysisOutputError)
return new AnalysisOutputError(
@@ -1,7 +1,8 @@
import { EvidenceFileError } from "../../src/domain/evidenceErrors.js";
import { err, ok as resultOk } from "../../src/domain/result.js";
import { dirname, join } from "node:path";
import { parseConfig } from "../../src/config/parseConfig.js";
import { ok as resultOk } from "../../src/domain/result.js";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
runDirectAnalysis,
runProviderAnalysis,
@@ -323,3 +324,47 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
),
).not.toHaveProperty("code");
});
it("retries snapshot file cleanup before final close even when a published identity is excluded", async () => {
const [path] = await createBinarySessionTargets();
const profile = createAnalysisProfile(
{ id: "fixture", name: "Fixture analysis provider", version: "1" },
{ fixture: true },
);
const factories = recordingFactories(undefined, profile);
const events: string[] = [];
const publication = new EvidenceFileError("write", "io", {
published: { bytes: 42 },
});
const dependencies: DirectAnalysisDependencies = {
...factories.dependencies,
createBinarySession: async (...args) => {
const session = await factories.dependencies.createBinarySession(...args);
vi.spyOn(session, "writeAnalysisSnapshot").mockResolvedValue(
err(publication),
);
const closeResources = session.closeFileResources.bind(session);
vi.spyOn(session, "closeFileResources").mockImplementation(async () => {
events.push("file-cleanup");
return closeResources();
});
const close = session.close.bind(session);
vi.spyOn(session, "close").mockImplementation(async () => {
events.push("session-close");
return close();
});
return session;
},
};
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ snapshotPath: join(dirname(path), "excluded.snapshot") },
);
expect(events[0]).toBe("file-cleanup");
expect(events).toContain("session-close");
expect(result).toMatchObject({ details: { published: true, bytes: 42 } });
expect(result).not.toHaveProperty("details.published_path");
});
@@ -0,0 +1,108 @@
import { createHash } from "node:crypto";
import { writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { WebNetworkCaptureService } from "../../src/application/WebNetworkCaptureService.js";
import { decodeHarCapture } from "../../src/browser/history/HarCapture.js";
import { deliverCliAnalysisEvidence } from "../../src/cli/analysisEvidenceDelivery.js";
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
import { ok } from "../../src/domain/result.js";
import { historicalHar } from "../fixtures/historicalHar.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
it("excludes marked output identity from late CLI publication failure while retaining capture Evidence", async () => {
const root = await createTestTempDirectory("rea-capture-delivery-");
const outputPath = join(root, "private-publication-marker.json");
await writeFile(outputPath, "existing caller file");
const bytes = JSON.stringify(historicalHar());
const values = ["private-publication-marker"];
const input = {
capture_path: join(root, "capture.har"),
format: "har" as const,
sensitive_values: values,
};
const service = new WebNetworkCaptureService({
inspect: async () =>
ok({
...decodeHarCapture(bytes, values),
artifact: {
path: input.capture_path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: Buffer.byteLength(bytes),
},
format: "har" as const,
runtime_attribution: "unknown" as const,
limitations: [],
}),
});
const output = analysisErrorProjectionSchema.parse(
await deliverCliAnalysisEvidence({
run: () => service.inspect(input),
service,
providerId: "web-network-capture",
operation: "inspect_web_network_capture",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
sensitiveValues: values,
}),
);
expect(output.code).toBe("invalid_request");
expect(output.details).toHaveProperty(
"partial_observation.normalized_result.artifact.path",
input.capture_path,
);
expect(JSON.stringify(output)).not.toContain(values[0]);
expect(await readFile(outputPath, "utf8")).toBe("existing caller file");
});
it.each([true, false])(
"publishes summary bytes while excluding only a marked receipt identity: %s",
async (marked) => {
const root = await createTestTempDirectory("rea-capture-publication-");
const outputPath = join(root, "private-publication-marker.json");
const values = marked ? ["private-publication-marker"] : [];
const bytes = JSON.stringify(historicalHar());
const input = {
capture_path: join(root, "capture.har"),
format: "har" as const,
sensitive_values: values,
};
const service = new WebNetworkCaptureService({
inspect: async () =>
ok({
...decodeHarCapture(bytes, values),
artifact: {
path: input.capture_path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: Buffer.byteLength(bytes),
},
format: "har" as const,
runtime_attribution: "unknown" as const,
limitations: [],
}),
});
const output = await deliverCliAnalysisEvidence({
run: () => service.inspect(input),
service,
providerId: "web-network-capture",
operation: "inspect_web_network_capture",
signal: new AbortController().signal,
detail: "summary",
evidenceOutput: outputPath,
sensitiveValues: values,
});
const persisted = await readFile(outputPath);
expect(output).toMatchObject({
evidence_bundle: { bytes: persisted.length },
});
if (marked) {
expect(output).not.toHaveProperty("evidence_bundle.path");
expect(JSON.stringify(output)).not.toContain(values[0]);
} else expect(output).toHaveProperty("evidence_bundle.path", outputPath);
expect(JSON.parse(persisted.toString())).toHaveProperty(
"records.0.operation",
"inspect_web_network_capture",
);
},
);
@@ -46,7 +46,6 @@ import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../../src/domain/analysisErrorCleanup.js";
import { err } from "../../src/domain/result.js";
import { deliverCliAnalysisEvidence } from "../../src/cli/analysisEvidenceDelivery.js";
import { redactExplicitFailure } from "../../src/domain/explicitSensitiveFailure.js";
const services: WebNetworkCaptureService[] = [];
afterEach(async () => {
@@ -149,7 +148,7 @@ it("redacts repeated selected-file close failures at final CLI delivery and reta
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: undefined,
redactFailure: (error) => redactExplicitFailure(error, values),
sensitiveValues: values,
}),
);
expect(output.code).toBe("cleanup_incomplete");