fix(evidence): reject NUL bytes in subject and snapshot paths (#1873)

Evidence subject local_path and analysis-snapshot subject path accepted
any string, so a caller-supplied inline Evidence record could carry a NUL
byte into the observation and bundle output even though every other
selected-path boundary already refuses it. Both fields now use the shared
NUL-free local-path schema: the snapshot path keeps its nonempty rule,
and the Evidence local_path stays free to be empty because network
capture Evidence uses an empty path as a deliberate sentinel.

localPath.ts now exports the NUL-free base schema separately so the
nonempty rule is composed rather than duplicated; the advertised pattern
and minLength for existing localPathStringSchema consumers are unchanged.

Validation: new unit cases reject a NUL local_path and snapshot path while
admitting an empty Evidence path and an ordinary path; domain suite
126 files / 1704 tests pass, evidence bundle and retained-evidence MCP
boundaries pass, localToolContracts NUL harness passes, toolSchemaValidity
passes with a regenerated catalog, typecheck, oxlint, oxfmt.
This commit is contained in:
Dery Ferdika
2026-10-11 23:45:37 +08:00
committed by GitHub
parent efbc25fbea
commit fa9a7e5ec0
5 changed files with 58 additions and 5 deletions
+31
View File
@@ -113,6 +113,37 @@ describe("analysis snapshot contract", () => {
expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe("dos-mz");
});
it("rejects a snapshot subject path containing NUL", () => {
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const entry = {
query_id: `query_${"0".repeat(64)}`,
operation: "analyze_function",
parameters: {},
execution: {
result: null,
raw_result: null,
provider: binding.provider,
limitations: [],
locations: [],
subject: {
path: "a\u0000b",
sha256: "a".repeat(64),
format: "elf" as const,
architecture: null,
},
},
};
expect(
analysisSnapshotSchema.safeParse({
target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET),
binding,
entries: [entry],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([]),
}).success,
).toBe(false);
});
it("accepts snapshots with more than ten thousand analysis entries", () => {
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const entry = {
+2 -1
View File
@@ -7,6 +7,7 @@ import {
compareUnicodeCodePoints,
compositeKey,
} from "./unicodeCodePointOrder.js";
import { localPathStringSchema } from "./localPath.js";
import {
analysisProfileSchema,
@@ -65,7 +66,7 @@ const formatSchema = z.enum([
]);
const kindSchema = z.enum(["executable", "database", "archive", "artifact"]);
const subjectSchema = z.object({
path: z.string().min(1),
path: localPathStringSchema,
sha256: digestSchema,
format: z.enum([
...formatSchema.options,
+16
View File
@@ -291,6 +291,22 @@ describe("analysis evidence identity", () => {
});
});
it("rejects a subject local path containing NUL while admitting an empty path", () => {
const evidence = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: {},
result: true,
});
expect(evidence.subject).not.toBeNull();
const withPath = (path: string) => ({
...evidence,
subject: { ...evidence.subject, local_path: path },
});
expect(evidenceSchema.safeParse(withPath("/tmp/fixture")).success).toBe(true);
expect(evidenceSchema.safeParse(withPath("")).success).toBe(true);
expect(evidenceSchema.safeParse(withPath("a\u0000b")).success).toBe(false);
});
it("binds an explicit unknown-subject reason into the semantic Evidence identity", () => {
const reason = "Caller excluded the observed digest.";
const observation = { operation: "health", parameters: {}, result: true };
+2 -1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { digestCanonicalValue } from "./canonicalDigest.js";
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
import { nulFreeStringSchema } from "./localPath.js";
import {
analysisProfileSchema,
@@ -65,7 +66,7 @@ const subjectSchema = z.object({
"entitlements",
]),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
local_path: z.string(),
local_path: nulFreeStringSchema,
});
/** Source location attached to an evidence observation. */
/** Source-location schema shared by evidence-bearing persistence formats. */
+7 -3
View File
@@ -2,7 +2,9 @@ import { isAbsolute, relative, sep } from "node:path";
import { z } from "zod";
/**
* Preserve selected filesystem names while rejecting OS-invalid NUL bytes.
* Reject OS-invalid NUL bytes in a local filesystem name. This is not a path
* shape check: an empty or relative value is admitted here and narrowed by the
* callers that need a nonempty or absolute path.
*
* The pattern spells NUL as `\x00` rather than `\u0000`. Both accept exactly the
* same strings. The hex form also avoids the short `\0` spelling rejected by
@@ -10,11 +12,13 @@ import { z } from "zod";
* one advertised pattern cannot compile. See `offsetDateTimeSchema` for the
* same hex-escape convention.
*/
export const localPathStringSchema = z
export const nulFreeStringSchema = z
.string()
.min(1)
.regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL");
/** A selected local filesystem path: nonempty and free of NUL bytes. */
export const localPathStringSchema = nulFreeStringSchema.min(1);
/** Test lexical containment on the host platform; callers must resolve symlinks first. */
export const isPathWithinRoot = (root: string, path: string): boolean => {
const value = relative(root, path);