From fa9a7e5ec026c5a4e8426f0f36c2c4555be9c9f0 Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:45:37 +0700 Subject: [PATCH] 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. --- src/domain/analysisSnapshot.test.ts | 31 +++++++++++++++++++++++++++++ src/domain/analysisSnapshot.ts | 3 ++- src/domain/evidence.test.ts | 16 +++++++++++++++ src/domain/evidence.ts | 3 ++- src/domain/localPath.ts | 10 +++++++--- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 00f34684f5..85580248e5 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -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 = { diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index a60b705247..e839b1e97d 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -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, diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 9a129d7b69..fc2f199216 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -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 }; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8aa6d4c654..4e6fbe372c 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -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. */ diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index d3d2001896..789803a210 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -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);