mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
* refactor(errors): require a reason for every analysis cancellation Add cancelledBy, the one constructor for AnalysisCancelledError. Its reason argument is required: an aborted signal, an earlier cancellation, a reason carried from an inner failure, or an OwnerCancellation when REA closed the work's owner itself. Closed adb, apktool, firmware, sqlite, gdb, web-bundle analyzer and binary-session record owners now report OwnerCancellation.ownerClosed instead of an absent reason. Firmware's predecessor wait returns its cancellation as a Result, removing an unreachable reason-less fallback. An architecture guard rejects new direct constructions, with a counted per-file baseline that can only shrink. * refactor(errors): share one aborted-signal predicate with cancelledBy * refactor(process): return a departing caller's reason from a shared startup wait * refactor(providers): build decompiler-provider cancellations with a required reason (#10587) Migrate every AnalysisCancelledError construction under src/ghidra, src/ida, src/cutter, src/jeb and src/kuna to cancelledBy and lower the guard baseline from 671 to 572. Closed Kuna target commands report OwnerCancellation.ownerClosed instead of an absent reason, and Kuna and JEB close abort their lifetimes with that typed reason. * fix(errors): read the reason of any signal-shaped cancellation source * test(ci): cover the counted-baseline comparison behind the architecture guard
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
import { join, relative, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { compareCountedBaseline } from "./counted-baseline.mjs";
|
|
|
|
// Architecture guards for the strict-identity / best-effort-inference split.
|
|
// - Composite dedup keys must not NUL-join variable-length or free-form
|
|
// MIDDLE fields: ["a\0b"] collapses with ["a", "b"] and drops a finding.
|
|
// The text check below only flags one spelling; it is not a complete audit.
|
|
// - Transparent wrappers unwrap in exactly one place
|
|
// (javascriptAstValues.unwrapJavaScriptExpression); direct isTS* unwrapping
|
|
// elsewhere drifts (the `satisfies` class of bug).
|
|
|
|
const root = fileURLToPath(new URL("../../", import.meta.url));
|
|
|
|
const listFiles = async (dir) => {
|
|
const output = [];
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) output.push(...(await listFiles(path)));
|
|
else if (
|
|
entry.name.endsWith(".ts") &&
|
|
!entry.name.endsWith(".test.ts") &&
|
|
!entry.name.endsWith(".spec.ts") &&
|
|
!entry.name.endsWith(".fixture.ts")
|
|
)
|
|
output.push(path);
|
|
}
|
|
return output;
|
|
};
|
|
|
|
const stripComments = (content) =>
|
|
content
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("//"))
|
|
.join("\n")
|
|
.replace(/\/\*[\s\S]*?\*\//gu, "");
|
|
|
|
const sources = await listFiles(join(root, "src"));
|
|
const files = await Promise.all(
|
|
sources.map(async (path) => ({
|
|
path: relative(root, path).split(sep).join("/"),
|
|
content: stripComments(await readFile(path, "utf8")),
|
|
})),
|
|
);
|
|
const failures = [];
|
|
const check = (label, selected, pattern, allow = []) => {
|
|
const offenders = selected.filter(
|
|
(file) => pattern.test(file.content) && !allow.includes(file.path),
|
|
);
|
|
if (offenders.length > 0)
|
|
failures.push(
|
|
`${label}:\n${offenders.map((file) => ` ${file.path}`).join("\n")}`,
|
|
);
|
|
};
|
|
|
|
// Prerequisite inspection lifetime is an application contract shared by adapters.
|
|
check(
|
|
"provider-owned inspection lifetime (use src/application/ProviderInspectionScope)",
|
|
files,
|
|
/providers\/ProviderInspectionScope\.js/,
|
|
);
|
|
|
|
check("NUL-joined list key (use compositeKey)", files, /\.join\(["']\\0["']\)/);
|
|
|
|
check(
|
|
"direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)",
|
|
files,
|
|
/isTSAsExpression|isTSSatisfiesExpression|isTSNonNullExpression|isTSTypeAssertion|isTSInstantiationExpression|isParenthesizedExpression/,
|
|
["src/domain/javascript/javascriptAstValues.ts"],
|
|
);
|
|
|
|
// A producer that pins both the soft and hard ceiling of every resource takes
|
|
// the vector from one owner. prlimitCeilingArguments is the command-less prefix
|
|
// of prlimitArgumentVector; both live in src/process/LinuxResourceLimits.ts.
|
|
// Re-audited 2026-10-22 with util-linux 2.39.3: a bare value sets both soft and
|
|
// hard, the same as soft:hard when the numbers are equal. `soft:` leaves the
|
|
// inherited hard ceiling unchanged.
|
|
// FirmwareCommand and WakaruCommand call prlimitArgumentVector and keep their
|
|
// own file-size and CPU budgets (120 CPU seconds; firmware file size is the
|
|
// call output budget or the 8 MiB report allowance; Wakaru file size is the
|
|
// 128 MiB output allowance). Their 1 GiB address space is
|
|
// BOUNDED_PRODUCER_LIMITS.address_space_bytes. PcapNativeBound calls
|
|
// prlimitCeilingArguments because PcapCommand appends the producer command.
|
|
// Its CPU ceiling stays 300 seconds and it has no resident-input budget;
|
|
// address space and file size use those shared fields.
|
|
// selectEvmWorkerLimits stays a separate selector: Result failures, unit
|
|
// checks, and a refusal when soft exceeds hard. It must not call
|
|
// clampLinuxResourceLimits.
|
|
check(
|
|
"inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)",
|
|
files,
|
|
/--core=0:0/,
|
|
["src/process/LinuxResourceLimits.ts"],
|
|
);
|
|
|
|
check(
|
|
"bare prlimit value (a bare value sets both ceilings; use prlimitArgumentVector)",
|
|
files,
|
|
/--core=0(?!:)/,
|
|
);
|
|
|
|
// 2026-10-22: soft-only `resource:` options retain the inherited hard ceiling.
|
|
// EVMole budgets differ (3 GiB address space, 16 MiB file, 30 CPU seconds), so
|
|
// this lane must not call prlimitArgumentVector.
|
|
check(
|
|
"soft-only prlimit ceilings (paired producers use prlimitArgumentVector)",
|
|
files,
|
|
/--core=0:(?!0)/,
|
|
["src/evm/EvmoleInterfaceProvider.ts"],
|
|
);
|
|
|
|
// Resident input is the input_bytes field of BOUNDED_PRODUCER_LIMITS. CamelCase
|
|
// inputBytes values with a different policy (an overridable pwntools admission,
|
|
// historical capture intake) are not this budget.
|
|
check(
|
|
"inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/domain/producerResourceLimits)",
|
|
files,
|
|
/input_bytes:\s*32 \* 1024 \* 1024/,
|
|
["src/domain/producerResourceLimits.ts"],
|
|
);
|
|
|
|
// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free
|
|
// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes
|
|
// NUL), with free-form source strings only in final position. New backtick-\0
|
|
// keys must either meet that shape (and extend this list with justification)
|
|
// or use compositeKey.
|
|
check(
|
|
"new backtick-NUL composite key (audit against the rule above)",
|
|
files,
|
|
/`[^`\n]*\\0/,
|
|
[
|
|
// Bundle fingerprint: fixed packaged paths (no NUL/newline) and 64 hex
|
|
// digest characters per record, never guide contents. Preserve v1 locators.
|
|
"src/application/GuidanceAccess.ts",
|
|
"src/application/javascript/ElectronBoundaryAnalysis.ts",
|
|
"src/application/javascript/ElectronBoundaryGraphContext.ts",
|
|
"src/browser/CdpCaptureCompleteness.ts",
|
|
"src/browser/CdpCaptureEventHandlers.ts",
|
|
"src/browser/CdpPageCapture.ts",
|
|
"src/browser/WebSourceMapFetcher.ts",
|
|
"src/domain/javascript/electronStaticAnalysisBrowser.ts",
|
|
"src/domain/javascript/electronStaticAnalysisIpc.ts",
|
|
"src/domain/javascript/javascriptApplicationChangeGraph.ts",
|
|
"src/domain/javascript/javascriptApplicationVersionItems.ts",
|
|
"src/domain/javascript/javascriptApplicationVersionKeys.ts",
|
|
"src/domain/javascript/javascriptExportShapeSelection.ts",
|
|
"src/domain/javascript/javascriptFeatureSeed.ts",
|
|
"src/domain/javascript/javascriptFeatureTraversal.ts",
|
|
"src/domain/javascript/javascriptNativeHandoff.ts",
|
|
"src/domain/javascript/javascriptRuntimeReconciliationMatching.ts",
|
|
"src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts",
|
|
"src/domain/javascript/javascriptStaticAnalysisBundler.ts",
|
|
"src/domain/javascript/javascriptStaticAnalysisCalls.ts",
|
|
"src/domain/javascript/javascriptStaticAnalysisFindings.ts",
|
|
"src/domain/localPath.ts",
|
|
"src/domain/process/processTraceEvaluation.ts",
|
|
"src/domain/process/processTraceSpecification.ts",
|
|
"src/domain/unicodeCodePointOrder.ts",
|
|
"src/domain/webBundleAnalyzerInspection.ts",
|
|
],
|
|
);
|
|
|
|
// Counted baselines record the remaining copies of a pattern that has one
|
|
// owner. A file may not gain copies and a baseline may not grow; when copies
|
|
// are removed, `--shrink-baselines` lowers the recorded counts (and never
|
|
// raises them) so the next removal cannot be undone silently.
|
|
const baselinePath = join(
|
|
root,
|
|
"scripts/verify/architecture-guard-baselines.json",
|
|
);
|
|
const baselines = JSON.parse(await readFile(baselinePath, "utf8"));
|
|
const shrinkBaselines = process.argv.includes("--shrink-baselines");
|
|
let baselinesShrunk = false;
|
|
const checkCounted = (label, key, selected, pattern, owners) => {
|
|
const counts = new Map();
|
|
for (const file of selected) {
|
|
if (owners.includes(file.path)) continue;
|
|
const count = file.content.match(pattern)?.length ?? 0;
|
|
if (count > 0) counts.set(file.path, count);
|
|
}
|
|
const compared = compareCountedBaseline(
|
|
counts,
|
|
baselines[key] ?? {},
|
|
shrinkBaselines,
|
|
);
|
|
const lines = (differences) =>
|
|
differences
|
|
.map(
|
|
({ path, count, allowed }) =>
|
|
` ${path}: ${count} (baseline ${allowed})`,
|
|
)
|
|
.join("\n");
|
|
if (compared.grown.length > 0)
|
|
failures.push(
|
|
`${label}; new copies are not allowed:\n${lines(compared.grown)}`,
|
|
);
|
|
if (compared.baseline !== (baselines[key] ?? {})) {
|
|
baselines[key] = compared.baseline;
|
|
baselinesShrunk = true;
|
|
}
|
|
if (compared.stale.length > 0)
|
|
failures.push(
|
|
`${label}; copies were removed, so lower the baseline with \`node scripts/verify/architecture-guards.mjs --shrink-baselines\`:\n${lines(compared.stale)}`,
|
|
);
|
|
};
|
|
|
|
// Cancellation reasons are required by `cancelledBy` in analysisErrorCore; a
|
|
// direct construction can omit or replace the caller's reason.
|
|
checkCounted(
|
|
"direct AnalysisCancelledError construction (use cancelledBy from src/domain/analysisErrorCore)",
|
|
"analysis-cancelled-constructor",
|
|
files,
|
|
/new AnalysisCancelledError\(/gu,
|
|
["src/domain/analysisErrorCore.ts"],
|
|
);
|
|
|
|
// Byte-format parsers read untrusted input. Bounds, truncation and failure
|
|
// kind belong to BinaryReader (src/native/metadata/BinaryContainer.ts) and
|
|
// the ParseFailure algebra (src/domain/parseFailure.ts), not to each parser.
|
|
const PARSER_FOLDERS = [
|
|
"src/android/",
|
|
"src/apkSignature/",
|
|
"src/artifacts/",
|
|
"src/dotnet/",
|
|
"src/electron/",
|
|
"src/erlang/",
|
|
"src/evm/",
|
|
"src/firmware/",
|
|
"src/flutter/",
|
|
"src/go/",
|
|
"src/gpu/",
|
|
"src/il2cpp/",
|
|
"src/model/",
|
|
"src/native/",
|
|
"src/python/",
|
|
"src/rust/",
|
|
"src/v8/",
|
|
"src/wasm/",
|
|
...[
|
|
"android",
|
|
"apkSignature",
|
|
"apple",
|
|
"electron",
|
|
"erlang",
|
|
"evm",
|
|
"firmware",
|
|
"flutter",
|
|
"go",
|
|
"gpu",
|
|
"il2cpp",
|
|
"managed",
|
|
"model",
|
|
"native",
|
|
"python",
|
|
"rust",
|
|
"v8",
|
|
"wasm",
|
|
"windows",
|
|
].map((folder) => `src/domain/${folder}/`),
|
|
];
|
|
const parserFiles = files.filter((file) =>
|
|
PARSER_FOLDERS.some((folder) => file.path.startsWith(folder)),
|
|
);
|
|
|
|
// An unchecked Buffer or DataView read throws a bare RangeError past the end,
|
|
// which a catch site cannot tell from a host stack or string limit.
|
|
checkCounted(
|
|
"raw Buffer or DataView numeric read (use BinaryReader from src/native/metadata/BinaryContainer)",
|
|
"raw-buffer-read",
|
|
files,
|
|
/\.(?:read(?:Big)?U?Int(?:8|16|32|64)?(?:LE|BE)?|readBigUint64(?:LE|BE)|readUint(?:8|16|32)(?:LE|BE)?|read(?:Float|Double)(?:LE|BE)|get(?:Big)?(?:Uint|Int)(?:8|16|32|64)|getFloat(?:32|64))\(/gu,
|
|
["src/native/metadata/BinaryContainer.ts"],
|
|
);
|
|
|
|
checkCounted(
|
|
"plain Error, TypeError or RangeError thrown by a parser (throw a ParseFailureError from src/domain/parseFailure)",
|
|
"parser-plain-throw",
|
|
parserFiles,
|
|
/throw new (?:Error|TypeError|RangeError)\(/gu,
|
|
[],
|
|
);
|
|
|
|
// JSON.parse keeps the last of repeated member names without saying so; a
|
|
// parser of artifact content must state its duplicate-member policy
|
|
// (src/domain/jsonDuplicateMember.ts finds a repeated name).
|
|
checkCounted(
|
|
"plain JSON.parse of parser input (state the duplicate-member policy; see src/domain/jsonDuplicateMember)",
|
|
"parser-json-parse",
|
|
parserFiles,
|
|
/JSON\.parse\(/gu,
|
|
[],
|
|
);
|
|
|
|
if (baselinesShrunk) {
|
|
const sorted = Object.fromEntries(
|
|
Object.entries(baselines).map(([key, counts]) => [
|
|
key,
|
|
Object.fromEntries(
|
|
Object.entries(counts).sort(([left], [right]) =>
|
|
left < right ? -1 : left > right ? 1 : 0,
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
await writeFile(baselinePath, `${JSON.stringify(sorted, null, 2)}\n`);
|
|
console.log(`Lowered counted baselines in ${relative(root, baselinePath)}.`);
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
// CI runs this file before `npm ci`. The numeric-limit parser needs
|
|
// typescript6, so that check waits until dependencies exist. `npm run lint`
|
|
// runs it after install.
|
|
const typescript6 = new URL(
|
|
"../../node_modules/typescript6/package.json",
|
|
import.meta.url,
|
|
);
|
|
if (!existsSync(typescript6)) {
|
|
console.log(
|
|
"Numeric REA_ limit check waits until dependencies are installed.",
|
|
);
|
|
} else {
|
|
const numericLimits = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
|
|
{ stdio: "inherit" },
|
|
);
|
|
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
|
|
}
|
|
console.log(`Architecture guards passed across ${files.length} source files.`);
|