mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user