Add Cloudflare's security-audit skill
Vendored from cloudflare/security-audit-skill under .agents/skills, pinned in skills-lock.json and linked into .claude/skills. Also adds the project's shared permission allow-rules in .claude/settings.json. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
This commit is contained in:
872
.agents/skills/security-audit/validate-coverage-ledger.cjs
Executable file
872
.agents/skills/security-audit/validate-coverage-ledger.cjs
Executable file
@@ -0,0 +1,872 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Validates coverage-ledger.json and its canonical coverage IDs.
|
||||
* Usage: node validate-coverage-ledger.cjs <path-to-coverage-ledger.json>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { TextDecoder } = require("node:util");
|
||||
|
||||
// Conservative bounds apply before JSON.parse and again to the parsed document.
|
||||
const LIMITS = Object.freeze({
|
||||
inputBytes: 5 * 1024 * 1024,
|
||||
units: 10000,
|
||||
collectionItems: 1000,
|
||||
objectFields: 1000,
|
||||
nestingDepth: 64,
|
||||
preflightValues: 500000,
|
||||
validationErrors: 100,
|
||||
});
|
||||
const MAX_INPUT_BYTES = LIMITS.inputBytes;
|
||||
const MAX_UNITS = LIMITS.units;
|
||||
const MAX_LIST_ITEMS = LIMITS.collectionItems;
|
||||
const MAX_TEXT_LENGTH = 4096;
|
||||
const REQUIRED_FIELDS = [
|
||||
"coverage_id",
|
||||
"canonical_refs",
|
||||
"surface",
|
||||
"boundary",
|
||||
"subsystem",
|
||||
"attack_class",
|
||||
"starting_paths",
|
||||
"ordinary_attack_class_block",
|
||||
"selected_companion_blocks",
|
||||
"excluded_blocks",
|
||||
"prior_status",
|
||||
"attempts",
|
||||
"wave",
|
||||
"status",
|
||||
"agent_id",
|
||||
"reviewed_paths",
|
||||
"local_checks",
|
||||
"result_fingerprints",
|
||||
"unresolved",
|
||||
];
|
||||
const REF_FIELDS = ["surface", "boundary", "subsystem", "attack_class"];
|
||||
const STATUSES = new Set([
|
||||
"planned",
|
||||
"not_applicable",
|
||||
"out_of_scope",
|
||||
"in_progress",
|
||||
"covered",
|
||||
"candidate",
|
||||
"blocked",
|
||||
"deferred",
|
||||
]);
|
||||
const ATTEMPT_STATUSES = new Set(["covered", "candidate", "blocked"]);
|
||||
const ATTEMPT_FIELDS = [
|
||||
"wave",
|
||||
"status",
|
||||
"agent_id",
|
||||
"reviewed_paths",
|
||||
"local_checks",
|
||||
"result_fingerprints",
|
||||
"unresolved",
|
||||
"reassignment_reason",
|
||||
];
|
||||
const PRIOR_STATUSES = new Set([
|
||||
"new",
|
||||
"prior_confirmed_same_source",
|
||||
"prior_confirmed_changed_source",
|
||||
"prior_needs_validation",
|
||||
"prior_deferred",
|
||||
"prior_blocked",
|
||||
"prior_out_of_scope",
|
||||
"prior_covered_same_source",
|
||||
"prior_covered_changed_source",
|
||||
"prior_rejected_claim_changed",
|
||||
"none",
|
||||
]);
|
||||
const FINGERPRINT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/;
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
const WINDOWS_RESERVED_AGENT_ID = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
|
||||
const VISIBLE_CONTENT = /[^\p{White_Space}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
||||
const PATH_FORBIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
|
||||
const WINDOWS_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/iu;
|
||||
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
const UNSAFE_DIAGNOSTIC_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
|
||||
const MAX_DIAGNOSTIC_STRING_LENGTH = 256;
|
||||
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
class JsonStructureError extends Error {}
|
||||
class SafeInputError extends Error {}
|
||||
|
||||
function escapeUnsafeDiagnosticCharacters(value) {
|
||||
return String(value).replace(UNSAFE_DIAGNOSTIC_CHARACTER, (character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint <= 0xffff
|
||||
? `\\u${codePoint.toString(16).padStart(4, "0")}`
|
||||
: `\\u{${codePoint.toString(16)}}`;
|
||||
});
|
||||
}
|
||||
|
||||
function safeQuote(value) {
|
||||
let serialized;
|
||||
if (typeof value === "string") {
|
||||
const clipped = value.length > MAX_DIAGNOSTIC_STRING_LENGTH
|
||||
? `${value.slice(0, MAX_DIAGNOSTIC_STRING_LENGTH)}...`
|
||||
: value;
|
||||
serialized = JSON.stringify(clipped);
|
||||
} else if (value === null || typeof value === "boolean") {
|
||||
serialized = String(value);
|
||||
} else if (typeof value === "number" && Number.isFinite(value)) {
|
||||
serialized = String(value);
|
||||
} else {
|
||||
serialized = `"<${Array.isArray(value) ? "array" : typeof value}>"`;
|
||||
}
|
||||
return escapeUnsafeDiagnosticCharacters(serialized);
|
||||
}
|
||||
|
||||
function createErrorList() {
|
||||
const errors = [];
|
||||
Object.defineProperty(errors, "push", {
|
||||
value(...messages) {
|
||||
const remaining = LIMITS.validationErrors - this.length;
|
||||
if (remaining > 0) {
|
||||
Array.prototype.push.apply(this, messages.slice(0, remaining).map(escapeUnsafeDiagnosticCharacters));
|
||||
}
|
||||
return this.length;
|
||||
},
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function preflightJsonText(contents) {
|
||||
const containers = [];
|
||||
let rootState = "value";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let totalValues = 0;
|
||||
|
||||
function fail(message) {
|
||||
throw new JsonStructureError(`input ${message}`);
|
||||
}
|
||||
|
||||
function currentContainer() {
|
||||
return containers[containers.length - 1];
|
||||
}
|
||||
|
||||
function countValue() {
|
||||
totalValues++;
|
||||
if (totalValues > LIMITS.preflightValues) {
|
||||
fail(`exceeds ${LIMITS.preflightValues} total value limit`);
|
||||
}
|
||||
}
|
||||
|
||||
function beginValue() {
|
||||
const container = currentContainer();
|
||||
if (!container) {
|
||||
if (rootState !== "value") fail("has malformed JSON structure");
|
||||
rootState = "end";
|
||||
} else if (container.type === "array") {
|
||||
if (container.state !== "firstValueOrEnd" && container.state !== "value") {
|
||||
fail("has malformed JSON array structure");
|
||||
}
|
||||
container.items++;
|
||||
const limit = container.topLevel ? MAX_UNITS : LIMITS.collectionItems;
|
||||
if (container.items > limit) {
|
||||
fail(container.topLevel
|
||||
? `exceeds ${limit} top-level unit limit`
|
||||
: `exceeds ${limit} item array limit`);
|
||||
}
|
||||
container.state = "commaOrEnd";
|
||||
} else {
|
||||
if (container.state !== "value") fail("has malformed JSON object structure");
|
||||
container.state = "commaOrEnd";
|
||||
}
|
||||
countValue();
|
||||
}
|
||||
|
||||
function beginString() {
|
||||
const container = currentContainer();
|
||||
if (container && container.type === "object" &&
|
||||
(container.state === "firstKeyOrEnd" || container.state === "key")) {
|
||||
container.fields++;
|
||||
if (container.fields > LIMITS.objectFields) {
|
||||
fail(`exceeds ${LIMITS.objectFields} field object limit`);
|
||||
}
|
||||
container.state = "colon";
|
||||
} else {
|
||||
beginValue();
|
||||
}
|
||||
inString = true;
|
||||
}
|
||||
|
||||
function beginContainer(type) {
|
||||
beginValue();
|
||||
if (containers.length >= LIMITS.nestingDepth) {
|
||||
fail(`exceeds nesting depth limit ${LIMITS.nestingDepth}`);
|
||||
}
|
||||
containers.push(type === "array"
|
||||
? { type, state: "firstValueOrEnd", items: 0, topLevel: containers.length === 0 }
|
||||
: { type, state: "firstKeyOrEnd", fields: 0 });
|
||||
}
|
||||
|
||||
function closeContainer(type) {
|
||||
const container = currentContainer();
|
||||
if (!container || container.type !== type) fail("has mismatched JSON containers");
|
||||
const canClose = type === "array"
|
||||
? container.state === "firstValueOrEnd" || container.state === "commaOrEnd"
|
||||
: container.state === "firstKeyOrEnd" || container.state === "commaOrEnd";
|
||||
if (!canClose) fail(`has malformed JSON ${type} structure`);
|
||||
containers.pop();
|
||||
}
|
||||
|
||||
function isWhitespace(character) {
|
||||
return character === " " || character === "\t" || character === "\r" || character === "\n";
|
||||
}
|
||||
|
||||
function isTokenDelimiter(character) {
|
||||
return isWhitespace(character) || character === "," || character === ":" ||
|
||||
character === "[" || character === "]" || character === "{" ||
|
||||
character === "}" || character === "\"";
|
||||
}
|
||||
|
||||
for (let index = 0; index < contents.length; index++) {
|
||||
const character = contents[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === "\"") {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isWhitespace(character)) continue;
|
||||
|
||||
if (character === "\"") {
|
||||
beginString();
|
||||
} else if (character === "[") {
|
||||
beginContainer("array");
|
||||
} else if (character === "{") {
|
||||
beginContainer("object");
|
||||
} else if (character === "]") {
|
||||
closeContainer("array");
|
||||
} else if (character === "}") {
|
||||
closeContainer("object");
|
||||
} else if (character === ":") {
|
||||
const container = currentContainer();
|
||||
if (!container || container.type !== "object" || container.state !== "colon") {
|
||||
fail("has malformed JSON object structure");
|
||||
}
|
||||
container.state = "value";
|
||||
} else if (character === ",") {
|
||||
const container = currentContainer();
|
||||
if (!container || container.state !== "commaOrEnd") fail("has malformed JSON collection structure");
|
||||
container.state = container.type === "array" ? "value" : "key";
|
||||
} else {
|
||||
beginValue();
|
||||
while (index + 1 < contents.length && !isTokenDelimiter(contents[index + 1])) index++;
|
||||
}
|
||||
}
|
||||
|
||||
if (inString) fail("has an unterminated JSON string");
|
||||
if (containers.length > 0) fail("has truncated JSON structure");
|
||||
if (rootState !== "end") fail("has no JSON value");
|
||||
}
|
||||
|
||||
function preflightDocument(root) {
|
||||
const errors = createErrorList();
|
||||
const stack = [{ value: root, depth: 0, location: "$" }];
|
||||
let visited = 0;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { value, depth, location } = stack.pop();
|
||||
visited++;
|
||||
if (visited > LIMITS.preflightValues) {
|
||||
errors.push(`$: exceeds ${LIMITS.preflightValues} total values`);
|
||||
return errors;
|
||||
}
|
||||
if (value === null || typeof value !== "object") continue;
|
||||
if (depth >= LIMITS.nestingDepth) {
|
||||
errors.push(`${location}: exceeds nesting depth limit ${LIMITS.nestingDepth}`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const limit = location === "$" ? MAX_UNITS : MAX_LIST_ITEMS;
|
||||
if (value.length > limit) {
|
||||
errors.push(`${location}: exceeds ${limit} entries`);
|
||||
return errors;
|
||||
}
|
||||
for (let index = value.length - 1; index >= 0; index--) {
|
||||
stack.push({ value: value[index], depth: depth + 1, location: `${location}[${index}]` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > LIMITS.objectFields) {
|
||||
errors.push(`${location}: exceeds ${LIMITS.objectFields} object fields`);
|
||||
return errors;
|
||||
}
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index];
|
||||
stack.push({ value: value[key], depth: depth + 1, location: `${location}{${index}}` });
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function hasValidUnicodeScalarValues(value) {
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const first = value.charCodeAt(index++);
|
||||
if (first >= 0xd800 && first <= 0xdbff) {
|
||||
if (index >= value.length) return false;
|
||||
const second = value.charCodeAt(index++);
|
||||
if (second < 0xdc00 || second > 0xdfff) return false;
|
||||
} else if (first >= 0xdc00 && first <= 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasVisibleProse(value) {
|
||||
return hasValidUnicodeScalarValues(value) && VISIBLE_CONTENT.test(value);
|
||||
}
|
||||
|
||||
function isVisibleText(value, maxLength = MAX_TEXT_LENGTH) {
|
||||
return typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= maxLength &&
|
||||
value.trim() === value &&
|
||||
hasVisibleProse(value);
|
||||
}
|
||||
|
||||
function isCanonicalRef(value) {
|
||||
return isVisibleText(value, 1024) &&
|
||||
!PATH_FORBIDDEN_CHARACTER.test(value) &&
|
||||
value.normalize("NFC") === value;
|
||||
}
|
||||
|
||||
function encodeCanonicalRef(value) {
|
||||
if (!isCanonicalRef(value)) throw new TypeError("invalid canonical reference");
|
||||
let encoded = "";
|
||||
for (const byte of Buffer.from(value, "utf8")) {
|
||||
const unreserved =
|
||||
(byte >= 0x41 && byte <= 0x5a) ||
|
||||
(byte >= 0x61 && byte <= 0x7a) ||
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
byte === 0x2d || byte === 0x2e || byte === 0x5f || byte === 0x7e;
|
||||
encoded += unreserved ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function canonicalCoverageId(refs) {
|
||||
if (!isObject(refs)) throw new TypeError("canonical_refs must be an object");
|
||||
const fields = hasOwn(refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS;
|
||||
if (Object.keys(refs).length !== fields.length || fields.some((field) => !hasOwn(refs, field))) {
|
||||
throw new TypeError("canonical_refs has missing or unexpected fields");
|
||||
}
|
||||
return fields.map((field) => encodeCanonicalRef(refs[field])).join("::");
|
||||
}
|
||||
|
||||
function isSafeRelativePath(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || !hasValidUnicodeScalarValues(value) || value.trim() !== value || PATH_FORBIDDEN_CHARACTER.test(value) || value.includes("\\") || value.includes(":")) return false;
|
||||
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("~")) return false;
|
||||
return value.split("/").every((segment) =>
|
||||
segment !== "" &&
|
||||
segment !== "." &&
|
||||
segment !== ".." &&
|
||||
!/[ .]$/u.test(segment) &&
|
||||
!WINDOWS_RESERVED_COMPONENT.test(segment));
|
||||
}
|
||||
|
||||
function isSafeAgentId(value) {
|
||||
return typeof value === "string" &&
|
||||
AGENT_ID_PATTERN.test(value) &&
|
||||
!WINDOWS_RESERVED_AGENT_ID.test(value);
|
||||
}
|
||||
|
||||
function isOwnedArtifactPath(value, agentId) {
|
||||
if (!isSafeAgentId(agentId) || !isSafeRelativePath(value)) return false;
|
||||
const prefix = `agents/${agentId}/artifacts/`;
|
||||
return value.startsWith(prefix) && value.length > prefix.length;
|
||||
}
|
||||
|
||||
function validateStringArray(value, location, errors, options = {}) {
|
||||
const { allowEmpty = true, fingerprint = false, pathValue = false } = options;
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${location}: expected array`);
|
||||
return;
|
||||
}
|
||||
if (!allowEmpty && value.length === 0) errors.push(`${location}: must not be empty`);
|
||||
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||
const seen = new Set();
|
||||
value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => {
|
||||
const entryLocation = `${location}[${index}]`;
|
||||
const valid = pathValue ? isSafeRelativePath(entry) : isVisibleText(entry);
|
||||
if (!valid) errors.push(`${entryLocation}: invalid ${pathValue ? "repository-relative path" : "text"}`);
|
||||
if (fingerprint && typeof entry === "string" && !FINGERPRINT_PATTERN.test(entry)) {
|
||||
errors.push(`${entryLocation}: invalid fingerprint`);
|
||||
}
|
||||
if (typeof entry === "string" && seen.has(entry)) errors.push(`${entryLocation}: duplicate entry`);
|
||||
if (typeof entry === "string") seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function validateChecks(value, location, errors) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${location}: expected array`);
|
||||
return;
|
||||
}
|
||||
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||
value.slice(0, MAX_LIST_ITEMS).forEach((check, index) => {
|
||||
const base = `${location}[${index}]`;
|
||||
if (!isObject(check)) {
|
||||
errors.push(`${base}: expected object`);
|
||||
return;
|
||||
}
|
||||
for (const field of ["agent_id", "reviewed_paths", "invariant", "method", "result", "artifact"]) {
|
||||
if (!hasOwn(check, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`);
|
||||
}
|
||||
if (!isSafeAgentId(check.agent_id)) errors.push(`${base}.agent_id: expected a canonical lowercase agent ID`);
|
||||
validateStringArray(check.reviewed_paths, `${base}.reviewed_paths`, errors, { allowEmpty: false, pathValue: true });
|
||||
if (!isVisibleText(check.invariant)) errors.push(`${base}.invariant: invalid text`);
|
||||
if (check.method !== "source" && check.method !== "local") errors.push(`${base}.method: expected "source" or "local"`);
|
||||
if (!isVisibleText(check.result)) errors.push(`${base}.result: invalid text`);
|
||||
if (check.method === "source" && check.artifact !== null) {
|
||||
errors.push(`${base}.artifact: source-only check must use null`);
|
||||
} else if (check.method === "local") {
|
||||
if (!isOwnedArtifactPath(check.artifact, check.agent_id)) {
|
||||
errors.push(`${base}.artifact: local check requires an artifact owned by agent ${safeQuote(isSafeAgentId(check.agent_id) ? check.agent_id : "<agent-id>")}`);
|
||||
}
|
||||
} else if (check.method !== "source" && check.method !== "local" && check.artifact !== null && !isSafeRelativePath(check.artifact)) {
|
||||
errors.push(`${base}.artifact: expected null or a safe output-relative path`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateReviewedPathOwnership(unit, base, errors) {
|
||||
if (!Array.isArray(unit.reviewed_paths) || !Array.isArray(unit.local_checks)) return;
|
||||
const aggregatePaths = new Set(unit.reviewed_paths.filter((value) => typeof value === "string"));
|
||||
const ownedPaths = new Set();
|
||||
for (const check of unit.local_checks) {
|
||||
if (!isObject(check) || !Array.isArray(check.reviewed_paths)) continue;
|
||||
for (const reviewedPath of check.reviewed_paths) {
|
||||
if (typeof reviewedPath === "string") ownedPaths.add(reviewedPath);
|
||||
}
|
||||
}
|
||||
for (const reviewedPath of aggregatePaths) {
|
||||
if (!ownedPaths.has(reviewedPath)) errors.push(`${base}.reviewed_paths: ${safeQuote(reviewedPath)} has no check owner`);
|
||||
}
|
||||
for (const reviewedPath of ownedPaths) {
|
||||
if (!aggregatePaths.has(reviewedPath)) errors.push(`${base}.local_checks: owned path ${safeQuote(reviewedPath)} is absent from aggregate reviewed_paths`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateExcludedBlocks(value, location, errors) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${location}: expected array`);
|
||||
return;
|
||||
}
|
||||
if (value.length > MAX_LIST_ITEMS) errors.push(`${location}: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||
const seen = new Set();
|
||||
value.slice(0, MAX_LIST_ITEMS).forEach((entry, index) => {
|
||||
const base = `${location}[${index}]`;
|
||||
if (!isObject(entry)) {
|
||||
errors.push(`${base}: expected object`);
|
||||
return;
|
||||
}
|
||||
if (!isVisibleText(entry.block)) errors.push(`${base}.block: invalid text`);
|
||||
if (!isVisibleText(entry.reason)) errors.push(`${base}.reason: invalid text`);
|
||||
if (typeof entry.block === "string" && seen.has(entry.block)) errors.push(`${base}.block: duplicate entry`);
|
||||
if (typeof entry.block === "string") seen.add(entry.block);
|
||||
});
|
||||
}
|
||||
|
||||
function semanticKey(unit) {
|
||||
return JSON.stringify([
|
||||
unit.surface,
|
||||
unit.boundary,
|
||||
unit.subsystem,
|
||||
unit.attack_class,
|
||||
hasOwn(unit, "lifecycle") ? unit.lifecycle : null,
|
||||
]);
|
||||
}
|
||||
|
||||
function hasValidSemanticFields(unit) {
|
||||
return ["surface", "boundary", "subsystem", "attack_class"].every((field) => isVisibleText(unit[field])) &&
|
||||
(!hasOwn(unit, "lifecycle") || isVisibleText(unit.lifecycle));
|
||||
}
|
||||
|
||||
function requireEmptyArray(unit, field, base, errors) {
|
||||
if (Array.isArray(unit[field]) && unit[field].length > 0) {
|
||||
errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} must keep this array empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonemptyArray(unit, field, base, errors) {
|
||||
if (!Array.isArray(unit[field]) || unit[field].length === 0) {
|
||||
errors.push(`${base}.${field}: unit with status ${safeQuote(unit.status)} requires entries`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStateInvariants(unit, base, errors) {
|
||||
const emptyEvidence = () => {
|
||||
requireEmptyArray(unit, "reviewed_paths", base, errors);
|
||||
requireEmptyArray(unit, "local_checks", base, errors);
|
||||
};
|
||||
const requireOwner = () => {
|
||||
if (!isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} requires a canonical lowercase agent ID`);
|
||||
};
|
||||
|
||||
if (unit.status !== "candidate") requireEmptyArray(unit, "result_fingerprints", base, errors);
|
||||
|
||||
switch (unit.status) {
|
||||
case "planned":
|
||||
if (unit.agent_id !== null) errors.push(`${base}.agent_id: planned unit must be unassigned`);
|
||||
emptyEvidence();
|
||||
requireEmptyArray(unit, "unresolved", base, errors);
|
||||
break;
|
||||
case "not_applicable":
|
||||
case "out_of_scope":
|
||||
case "deferred":
|
||||
if (unit.agent_id !== null) errors.push(`${base}.agent_id: unit with status ${safeQuote(unit.status)} must be unassigned`);
|
||||
emptyEvidence();
|
||||
requireNonemptyArray(unit, "unresolved", base, errors);
|
||||
break;
|
||||
case "in_progress":
|
||||
requireOwner();
|
||||
emptyEvidence();
|
||||
requireEmptyArray(unit, "unresolved", base, errors);
|
||||
break;
|
||||
case "blocked":
|
||||
requireOwner();
|
||||
requireNonemptyArray(unit, "reviewed_paths", base, errors);
|
||||
requireNonemptyArray(unit, "local_checks", base, errors);
|
||||
requireNonemptyArray(unit, "unresolved", base, errors);
|
||||
break;
|
||||
case "covered":
|
||||
requireOwner();
|
||||
requireNonemptyArray(unit, "reviewed_paths", base, errors);
|
||||
requireNonemptyArray(unit, "local_checks", base, errors);
|
||||
requireEmptyArray(unit, "unresolved", base, errors);
|
||||
break;
|
||||
case "candidate":
|
||||
requireOwner();
|
||||
requireNonemptyArray(unit, "reviewed_paths", base, errors);
|
||||
requireNonemptyArray(unit, "local_checks", base, errors);
|
||||
requireNonemptyArray(unit, "result_fingerprints", base, errors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAttempts(value, unit, base, errors) {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${base}.attempts: expected array`);
|
||||
return;
|
||||
}
|
||||
if (value.length > MAX_LIST_ITEMS) errors.push(`${base}.attempts: exceeds ${MAX_LIST_ITEMS} entries`);
|
||||
|
||||
const priorOwners = new Set();
|
||||
const priorArtifacts = new Set();
|
||||
let previousWave = 0;
|
||||
value.slice(0, MAX_LIST_ITEMS).forEach((attempt, index) => {
|
||||
const attemptBase = `${base}.attempts[${index}]`;
|
||||
if (!isObject(attempt)) {
|
||||
errors.push(`${attemptBase}: expected object`);
|
||||
return;
|
||||
}
|
||||
for (const field of ATTEMPT_FIELDS) {
|
||||
if (!hasOwn(attempt, field)) errors.push(`${attemptBase}: missing required field ${safeQuote(field)}`);
|
||||
}
|
||||
if (!Number.isInteger(attempt.wave) || attempt.wave < 1) {
|
||||
errors.push(`${attemptBase}.wave: expected a positive integer`);
|
||||
} else {
|
||||
if (attempt.wave <= previousWave) errors.push(`${attemptBase}.wave: archived attempt waves must be strictly increasing`);
|
||||
if (Number.isInteger(unit.wave) && attempt.wave >= unit.wave) {
|
||||
errors.push(`${attemptBase}.wave: archived attempt wave must precede current wave ${safeQuote(unit.wave)}`);
|
||||
}
|
||||
previousWave = attempt.wave;
|
||||
}
|
||||
if (!ATTEMPT_STATUSES.has(attempt.status)) {
|
||||
errors.push(`${attemptBase}.status: expected "covered", "candidate", or "blocked"`);
|
||||
}
|
||||
let hasFreshOwner = false;
|
||||
if (!isSafeAgentId(attempt.agent_id)) {
|
||||
errors.push(`${attemptBase}.agent_id: archived attempt requires a canonical lowercase agent ID`);
|
||||
} else if (priorOwners.has(attempt.agent_id)) {
|
||||
errors.push(`${attemptBase}.agent_id: assignment owner must be fresh for each attempt`);
|
||||
} else {
|
||||
hasFreshOwner = true;
|
||||
}
|
||||
validateStringArray(attempt.reviewed_paths, `${attemptBase}.reviewed_paths`, errors, { pathValue: true });
|
||||
validateChecks(attempt.local_checks, `${attemptBase}.local_checks`, errors);
|
||||
validateReviewedPathOwnership(attempt, attemptBase, errors);
|
||||
validateStringArray(attempt.result_fingerprints, `${attemptBase}.result_fingerprints`, errors, { fingerprint: true });
|
||||
validateStringArray(attempt.unresolved, `${attemptBase}.unresolved`, errors);
|
||||
if (!isVisibleText(attempt.reassignment_reason)) errors.push(`${attemptBase}.reassignment_reason: invalid text`);
|
||||
validateStateInvariants(attempt, attemptBase, errors);
|
||||
|
||||
if (Array.isArray(attempt.local_checks)) {
|
||||
attempt.local_checks.forEach((check, checkIndex) => {
|
||||
if (!isObject(check)) return;
|
||||
if (priorOwners.has(check.agent_id)) {
|
||||
errors.push(`${attemptBase}.local_checks[${checkIndex}].agent_id: prior assignment owner evidence must remain in its earlier attempt`);
|
||||
}
|
||||
if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) {
|
||||
errors.push(`${attemptBase}.local_checks[${checkIndex}].artifact: artifact from an earlier attempt cannot be reused`);
|
||||
}
|
||||
if (check.method === "local" && typeof check.artifact === "string") priorArtifacts.add(check.artifact);
|
||||
});
|
||||
}
|
||||
if (hasFreshOwner) priorOwners.add(attempt.agent_id);
|
||||
});
|
||||
|
||||
if (isSafeAgentId(unit.agent_id) && priorOwners.has(unit.agent_id)) {
|
||||
errors.push(`${base}.agent_id: current assignment owner must be fresh after reassignment`);
|
||||
}
|
||||
if (Array.isArray(unit.local_checks)) {
|
||||
unit.local_checks.forEach((check, index) => {
|
||||
if (!isObject(check)) return;
|
||||
if (priorOwners.has(check.agent_id)) {
|
||||
errors.push(`${base}.local_checks[${index}].agent_id: prior assignment owner evidence must remain in its archived attempt`);
|
||||
}
|
||||
if (typeof check.artifact === "string" && priorArtifacts.has(check.artifact)) {
|
||||
errors.push(`${base}.local_checks[${index}].artifact: artifact from an archived attempt cannot be reused`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectUnitErrors(unit, index) {
|
||||
const errors = createErrorList();
|
||||
const base = `$[${index}]`;
|
||||
if (!isObject(unit)) return [`${base}: expected object`];
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!hasOwn(unit, field)) errors.push(`${base}: missing required field ${safeQuote(field)}`);
|
||||
}
|
||||
for (const field of ["surface", "boundary", "subsystem", "attack_class"]) {
|
||||
if (!isVisibleText(unit[field])) errors.push(`${base}.${field}: invalid text`);
|
||||
}
|
||||
if (hasOwn(unit, "lifecycle") && !isVisibleText(unit.lifecycle)) errors.push(`${base}.lifecycle: invalid text`);
|
||||
|
||||
let expectedId = null;
|
||||
if (!isObject(unit.canonical_refs)) {
|
||||
errors.push(`${base}.canonical_refs: expected object`);
|
||||
} else {
|
||||
const expectedFields = hasOwn(unit.canonical_refs, "lifecycle") ? [...REF_FIELDS, "lifecycle"] : REF_FIELDS;
|
||||
for (const field of expectedFields) {
|
||||
if (!hasOwn(unit.canonical_refs, field)) {
|
||||
errors.push(`${base}.canonical_refs: missing required field ${safeQuote(field)}`);
|
||||
} else if (!isCanonicalRef(unit.canonical_refs[field])) {
|
||||
errors.push(`${base}.canonical_refs.${field}: invalid canonical reference`);
|
||||
}
|
||||
}
|
||||
if (Object.keys(unit.canonical_refs).some((field) => !expectedFields.includes(field))) {
|
||||
errors.push(`${base}.canonical_refs: contains unexpected fields`);
|
||||
}
|
||||
if (hasOwn(unit, "lifecycle") !== hasOwn(unit.canonical_refs, "lifecycle")) {
|
||||
errors.push(`${base}: lifecycle and canonical_refs.lifecycle must appear together`);
|
||||
}
|
||||
try {
|
||||
expectedId = canonicalCoverageId(unit.canonical_refs);
|
||||
} catch {
|
||||
// The specific reference errors above are more useful.
|
||||
}
|
||||
}
|
||||
if (!isVisibleText(unit.coverage_id, 65536)) {
|
||||
errors.push(`${base}.coverage_id: invalid text`);
|
||||
} else if (expectedId !== null && unit.coverage_id !== expectedId) {
|
||||
errors.push(`${base}.coverage_id: expected canonical ID ${safeQuote(expectedId)}`);
|
||||
}
|
||||
|
||||
validateStringArray(unit.starting_paths, `${base}.starting_paths`, errors, { allowEmpty: false, pathValue: true });
|
||||
if (unit.ordinary_attack_class_block !== null && !isVisibleText(unit.ordinary_attack_class_block)) {
|
||||
errors.push(`${base}.ordinary_attack_class_block: expected null or non-empty text`);
|
||||
}
|
||||
validateStringArray(unit.selected_companion_blocks, `${base}.selected_companion_blocks`, errors);
|
||||
validateExcludedBlocks(unit.excluded_blocks, `${base}.excluded_blocks`, errors);
|
||||
if (Array.isArray(unit.selected_companion_blocks) && Array.isArray(unit.excluded_blocks)) {
|
||||
const selected = new Set(unit.selected_companion_blocks);
|
||||
unit.excluded_blocks.forEach((entry, blockIndex) => {
|
||||
if (isObject(entry) && selected.has(entry.block)) {
|
||||
errors.push(`${base}.excluded_blocks[${blockIndex}].block: block is also selected`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!PRIOR_STATUSES.has(unit.prior_status)) errors.push(`${base}.prior_status: invalid value ${safeQuote(unit.prior_status)}`);
|
||||
if (!STATUSES.has(unit.status)) errors.push(`${base}.status: invalid value ${safeQuote(unit.status)}`);
|
||||
if (!Number.isInteger(unit.wave) || unit.wave < 1) errors.push(`${base}.wave: expected a positive integer`);
|
||||
if (unit.agent_id !== null && !isSafeAgentId(unit.agent_id)) errors.push(`${base}.agent_id: expected null or a safe agent ID`);
|
||||
|
||||
validateAttempts(unit.attempts, unit, base, errors);
|
||||
validateStringArray(unit.reviewed_paths, `${base}.reviewed_paths`, errors, { pathValue: true });
|
||||
validateChecks(unit.local_checks, `${base}.local_checks`, errors);
|
||||
validateReviewedPathOwnership(unit, base, errors);
|
||||
validateStringArray(unit.result_fingerprints, `${base}.result_fingerprints`, errors, { fingerprint: true });
|
||||
validateStringArray(unit.unresolved, `${base}.unresolved`, errors);
|
||||
|
||||
validateStateInvariants(unit, base, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function readFileWithinLimit(file) {
|
||||
const noFollow = fs.constants.O_NOFOLLOW;
|
||||
const nonBlock = fs.constants.O_NONBLOCK;
|
||||
if (!Number.isInteger(noFollow) || noFollow === 0 || !Number.isInteger(nonBlock) || nonBlock === 0) {
|
||||
// Node exposes no race-safe fallback on these platforms, so reject all inputs.
|
||||
throw new SafeInputError("OS no-follow and nonblocking input protection is unavailable");
|
||||
}
|
||||
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
|
||||
} catch (error) {
|
||||
if (error && (error.code === "ELOOP" || error.code === "EMLINK")) throw new SafeInputError("input must not be a symlink");
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile()) throw new SafeInputError("input must be a regular file");
|
||||
if (stat.size > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`);
|
||||
|
||||
const chunks = [];
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||
let bytesRead = 0;
|
||||
while (true) {
|
||||
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
||||
if (count === 0) break;
|
||||
bytesRead += count;
|
||||
if (bytesRead > MAX_INPUT_BYTES) throw new SafeInputError(`input exceeds ${MAX_INPUT_BYTES} byte limit`);
|
||||
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
||||
}
|
||||
try {
|
||||
return UTF8_DECODER.decode(Buffer.concat(chunks, bytesRead));
|
||||
} catch {
|
||||
throw new SafeInputError("input is not valid UTF-8");
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDocument(ledger) {
|
||||
const errors = createErrorList();
|
||||
if (!Array.isArray(ledger)) {
|
||||
errors.push("$: expected a top-level array");
|
||||
return errors;
|
||||
}
|
||||
if (ledger.length > MAX_UNITS) {
|
||||
errors.push(`$: exceeds ${MAX_UNITS} coverage units`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
errors.push(...preflightDocument(ledger));
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
const ids = new Map();
|
||||
const semantics = new Map();
|
||||
let previousId = null;
|
||||
for (let index = 0; index < ledger.length && errors.length < LIMITS.validationErrors; index++) {
|
||||
const unit = ledger[index];
|
||||
errors.push(...collectUnitErrors(unit, index));
|
||||
if (errors.length >= LIMITS.validationErrors) break;
|
||||
if (!isObject(unit) || typeof unit.coverage_id !== "string") continue;
|
||||
|
||||
const key = hasValidSemanticFields(unit) ? semanticKey(unit) : null;
|
||||
if (ids.has(unit.coverage_id)) {
|
||||
const previous = ids.get(unit.coverage_id);
|
||||
const qualifier = key !== null && previous.key !== null && previous.key !== key
|
||||
? "canonical identity collision with different semantic fields"
|
||||
: "duplicate coverage ID";
|
||||
errors.push(`$[${index}].coverage_id: ${qualifier} at $[${previous.index}]`);
|
||||
} else {
|
||||
ids.set(unit.coverage_id, { index, key });
|
||||
}
|
||||
if (key !== null && semantics.has(key) && semantics.get(key).id !== unit.coverage_id) {
|
||||
const previous = semantics.get(key);
|
||||
errors.push(`$[${index}].canonical_refs: semantic tuple already uses coverage ID ${safeQuote(previous.id)} at $[${previous.index}]`);
|
||||
} else if (key !== null) {
|
||||
semantics.set(key, { id: unit.coverage_id, index });
|
||||
}
|
||||
if (previousId !== null && previousId > unit.coverage_id) {
|
||||
errors.push(`$[${index}].coverage_id: units must be sorted lexicographically`);
|
||||
}
|
||||
previousId = unit.coverage_id;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function run(file) {
|
||||
if (!file) {
|
||||
console.error("Usage: node validate-coverage-ledger.cjs <path-to-coverage-ledger.json>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
let contents;
|
||||
try {
|
||||
contents = readFileWithinLimit(file);
|
||||
} catch (error) {
|
||||
const reason = error instanceof SafeInputError ? error.message : "input could not be opened or read safely";
|
||||
console.error(`Failed to read coverage ledger: ${reason}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let ledger;
|
||||
try {
|
||||
preflightJsonText(contents);
|
||||
} catch (error) {
|
||||
const reason = error instanceof JsonStructureError ? error.message : "invalid JSON structure";
|
||||
console.error(`Failed to parse coverage ledger: ${reason}`);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
ledger = JSON.parse(contents);
|
||||
} catch {
|
||||
console.error("Failed to parse coverage ledger: invalid JSON syntax");
|
||||
return 1;
|
||||
}
|
||||
|
||||
let errors;
|
||||
try {
|
||||
errors = validateDocument(ledger);
|
||||
} catch {
|
||||
console.error("Failed to validate coverage ledger: unexpected validation error");
|
||||
return 1;
|
||||
}
|
||||
for (const message of errors) console.error("ERROR:", message);
|
||||
if (errors.length > 0) {
|
||||
const cap = errors.length === LIMITS.validationErrors ? `; output capped at ${LIMITS.validationErrors}` : "";
|
||||
console.error(`FAIL: ${errors.length} validation error(s)${cap}`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`PASS: ${ledger.length} coverage units valid`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LIMITS,
|
||||
PATH_FORBIDDEN_CHARACTER,
|
||||
UNSAFE_DIAGNOSTIC_CHARACTER,
|
||||
VISIBLE_CONTENT,
|
||||
WINDOWS_RESERVED_COMPONENT,
|
||||
canonicalCoverageId,
|
||||
encodeCanonicalRef,
|
||||
hasVisibleProse,
|
||||
isSafeAgentId,
|
||||
isSafeRelativePath,
|
||||
preflightJsonText,
|
||||
readFileWithinLimit,
|
||||
safeQuote,
|
||||
validateDocument,
|
||||
};
|
||||
|
||||
if (require.main === module) process.exit(run(process.argv[2]));
|
||||
Reference in New Issue
Block a user