fix: resolve validation and artifact edge cases
This commit is contained in:
parent
6a3fd5b4ce
commit
ef7f2f9374
|
|
@ -215,7 +215,7 @@ export const createArtifactEdges = (
|
|||
occurrence.artifact_id === null
|
||||
)
|
||||
continue;
|
||||
const mappedSource = occurrence.logical_path.endsWith(".map")
|
||||
const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map")
|
||||
? byPath.get(occurrence.logical_path.slice(0, -".map".length))
|
||||
: undefined;
|
||||
const parentArtifactId =
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import writeFileAtomic from "write-file-atomic";
|
|||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceFileError } from "../domain/errors.js";
|
||||
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
|
|
@ -32,8 +33,9 @@ export const readBoundedJson = async (
|
|||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
||||
}
|
||||
const limits = validateJsonLimits(decoded, policy);
|
||||
return limits.ok ? ok(decoded) : limits;
|
||||
return isJsonWithinLimits(decoded, policy)
|
||||
? ok(decoded)
|
||||
: err(new EvidenceFileError("read", "too-large"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "io", { cause }));
|
||||
}
|
||||
|
|
@ -96,35 +98,6 @@ const isApproved = async (
|
|||
return false;
|
||||
};
|
||||
|
||||
const validateJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Result<null, EvidenceFileError> => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
||||
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
||||
import {
|
||||
parseInvestigationWorkspace,
|
||||
|
|
@ -132,7 +133,7 @@ const readWorkspaceFile = async (
|
|||
new InvestigationWorkspaceError("read", "invalid-json", { cause }),
|
||||
);
|
||||
}
|
||||
if (!withinJsonLimits(decoded, policy))
|
||||
if (!isJsonWithinLimits(decoded, policy))
|
||||
return err(new InvestigationWorkspaceError("read", "too-large"));
|
||||
try {
|
||||
return ok(parseInvestigationWorkspace(decoded));
|
||||
|
|
@ -241,34 +242,6 @@ const validateNextRevision = (
|
|||
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
};
|
||||
|
||||
const withinJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): boolean => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return false;
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return false;
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength) return false;
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
errorCode(cause) === "ENOENT";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
/** Structural limits applied while traversing an unknown JSON value. */
|
||||
export interface JsonLimits {
|
||||
readonly maxDepth: number;
|
||||
readonly maxStringLength: number;
|
||||
readonly maxNodes: number;
|
||||
}
|
||||
|
||||
/** Return whether every node, key, and string fits within the supplied limits. */
|
||||
export const isJsonWithinLimits = (
|
||||
root: unknown,
|
||||
limits: JsonLimits,
|
||||
): boolean => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > limits.maxNodes || current.depth > limits.maxDepth)
|
||||
return false;
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > limits.maxStringLength
|
||||
)
|
||||
return false;
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > limits.maxStringLength) return false;
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
|
@ -165,10 +165,7 @@ export const evaluatePermission = (
|
|||
reason:
|
||||
coveringGrant !== undefined && isExpired(coveringGrant, now)
|
||||
? "grant_expired"
|
||||
: candidates.length === 0 &&
|
||||
policy.grants.some(
|
||||
(grant) => grant.capability === normalized.capability,
|
||||
)
|
||||
: coveringGrant !== undefined
|
||||
? "grant_revoked_or_consumed"
|
||||
: "grant_required",
|
||||
missing: missingFromScopes(normalized, candidates),
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe("artifact graph provider", () => {
|
|||
machO,
|
||||
);
|
||||
await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n");
|
||||
await writeFile(join(app, "Contents", "Resources", "main.js.map"), "{}\n");
|
||||
await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n");
|
||||
await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside"));
|
||||
const client = new ArtifactProvider().createClient(
|
||||
target(app, "directory"),
|
||||
|
|
@ -88,7 +88,7 @@ describe("artifact graph provider", () => {
|
|||
path.endsWith("main.js"),
|
||||
);
|
||||
const sourceMap = left.occurrences.items.find(({ logical_path: path }) =>
|
||||
path.endsWith("main.js.map"),
|
||||
path.endsWith("main.js.MAP"),
|
||||
);
|
||||
expect(left.edges.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
|
|
|||
|
|
@ -239,6 +239,27 @@ describe("persistent cross-version investigation workspace", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("enforces shared JSON depth limits while reading workspaces", async () => {
|
||||
const { path, input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{ inputRoots: [directory] },
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
|
||||
await expect(
|
||||
readInvestigationWorkspace(path, {
|
||||
...policy(directory),
|
||||
maxDepth: 1,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvestigationWorkspaceError", reason: "too-large" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes from a comparison checkpoint without recomputing its identity", async () => {
|
||||
const { input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isJsonWithinLimits } from "../src/domain/jsonLimits.js";
|
||||
|
||||
const limits = {
|
||||
maxDepth: 3,
|
||||
maxStringLength: 8,
|
||||
maxNodes: 6,
|
||||
};
|
||||
|
||||
describe("JSON limits", () => {
|
||||
it("traverses nested object and array values", () => {
|
||||
expect(isJsonWithinLimits({ one: ["two", { three: 3 }] }, limits)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isJsonWithinLimits({ one: ["two", { three: "too-long-value" }] }, limits),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces depth, node, and object-key limits", () => {
|
||||
expect(
|
||||
isJsonWithinLimits(
|
||||
{ one: { two: { three: null } } },
|
||||
{
|
||||
...limits,
|
||||
maxDepth: 2,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isJsonWithinLimits([1, 2, 3], { ...limits, maxNodes: 3 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isJsonWithinLimits({ "too-long-key": true }, limits)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -176,6 +176,42 @@ describe("permission policy", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reports a missing scope when revoked grants cover a different root", () => {
|
||||
const ceiling = {
|
||||
capability: "evidence_write" as const,
|
||||
roots: ["/workspace"],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none" as const,
|
||||
mount: false,
|
||||
};
|
||||
const granted = grantPermission(createPermissionPolicy([ceiling]), {
|
||||
...ceiling,
|
||||
roots: ["/workspace/first"],
|
||||
grant_id: "revoked-other-root",
|
||||
lifetime: "project",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
});
|
||||
expect(granted.ok).toBe(true);
|
||||
if (!granted.ok) return;
|
||||
|
||||
expect(
|
||||
evaluatePermission(
|
||||
revokePermission(granted.value, "revoked-other-root"),
|
||||
{
|
||||
...ceiling,
|
||||
roots: ["/workspace/second"],
|
||||
operation_identity: "write:report.json",
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "grant_required",
|
||||
missing: { roots: ["/workspace/second"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("expires grants, removes session authority on disconnect, and never combines partial grants", () => {
|
||||
const ceiling = {
|
||||
capability: "process_capture" as const,
|
||||
|
|
|
|||
Loading…
Reference in New Issue