rea/src/application/JavaScriptReplayPlanning.ts

320 lines
9.6 KiB
TypeScript

import canonicalize from "canonicalize";
import { createHash } from "node:crypto";
import type { JsonValue } from "../domain/jsonValue.js";
import type {
ControlledReplayInput,
ReplayExecutionResult,
ReplayPlan,
} from "../domain/javascriptReplay.js";
const REPLAY_POLICY_VERSION = "linux-bwrap-systemd-v1" as const;
const REPLAY_POLICY_DOCUMENT = Object.freeze({
version: REPLAY_POLICY_VERSION,
namespaces: ["user", "pid", "network", "ipc", "uts", "cgroup"],
capabilities: "drop-all",
network: "none",
filesystem: "minimal-read-only-runtime-plus-private-tmpfs",
seccomp: "rea-linux-x86_64-v1",
resource_manager: "systemd-user-cgroup-v2",
nested_user_namespaces: false,
});
export interface ReplaySourceBytes {
readonly canonicalPath: string;
readonly bytes: Uint8Array;
}
export interface ReplayExecutableIdentity {
readonly path: string;
readonly version: string;
readonly sha256: string;
}
export interface ReplayRuntimeFileIdentity {
readonly sourcePath: string;
readonly destinationPath: string;
readonly sha256: string;
}
/** Complete executable and source authority for enabled JavaScript replay. */
export interface EnabledJavaScriptReplayPolicy {
readonly status: "enabled";
readonly roots: readonly [string, ...string[]];
readonly nodePath: string;
readonly bubblewrapPath: string;
readonly systemdRunPath: string;
readonly systemctlPath: string;
readonly shellPath: string;
}
/** Parsed JavaScript replay configuration. */
export type JavaScriptReplayPolicy =
| { readonly status: "disabled" }
| EnabledJavaScriptReplayPolicy;
export interface JavaScriptReplayHost {
readonly readSource: (
path: string,
maximumBytes: number,
) => Promise<ReplaySourceBytes>;
readonly identifyExecutable: (
path: string,
versionArguments: readonly string[],
) => Promise<ReplayExecutableIdentity>;
readonly identifyWorker: () => Promise<ReplayExecutableIdentity>;
readonly identifyRuntimeClosure: (
nodePath: string,
) => Promise<readonly ReplayRuntimeFileIdentity[]>;
readonly seccompDigest: () => string;
readonly probe: (policy: EnabledJavaScriptReplayPolicy) => Promise<void>;
}
export interface PreparedReplayPlan {
readonly publicPlan: ReplayPlan;
readonly leftSources: Readonly<Record<string, string>>;
readonly rightSources?: Readonly<Record<string, string>>;
}
export interface JavaScriptReplayRunner {
readonly execute: (
prepared: PreparedReplayPlan,
policy: EnabledJavaScriptReplayPolicy,
signal?: AbortSignal,
) => Promise<ReplayExecutionResult>;
}
/** Read, normalize, and content-address every input to one replay experiment. */
export const prepareReplayPlan = async (
input: ControlledReplayInput,
policy: EnabledJavaScriptReplayPolicy,
host: JavaScriptReplayHost,
): Promise<PreparedReplayPlan> => {
const cases = replayCases(input).map((item) => ({
...item,
sha256: digestJson(item.arguments),
}));
if (
new TextEncoder().encode(
JSON.stringify(cases.map(({ arguments: args }) => args)),
).byteLength > input.limits.input_bytes
)
throw new RangeError("Replay case inputs exceed the aggregate byte limit");
await host.probe(policy);
const [
runtime,
bubblewrap,
systemdRun,
systemctl,
shell,
worker,
runtimeFiles,
left,
right,
] = await Promise.all([
host.identifyExecutable(policy.nodePath, ["--version"]),
host.identifyExecutable(policy.bubblewrapPath, ["--version"]),
host.identifyExecutable(policy.systemdRunPath, ["--version"]),
host.identifyExecutable(policy.systemctlPath, ["--version"]),
host.identifyExecutable(policy.shellPath, ["--version"]),
host.identifyWorker(),
host.identifyRuntimeClosure(policy.nodePath),
prepareSide(input.left, input.limits.module_bytes, host),
input.right === undefined
? Promise.resolve(undefined)
: prepareSide(input.right, input.limits.module_bytes, host),
]);
const withoutDigest = {
schema_version: 1 as const,
policy_version: REPLAY_POLICY_VERSION,
policy_sha256: digestJson(REPLAY_POLICY_DOCUMENT),
network: "none" as const,
filesystem: {
host_writes: false as const,
private_tmpfs_bytes: input.limits.tmpfs_bytes,
},
runtime: {
executable: runtime,
worker,
read_only_files: runtimeFiles.map((item) => ({
source_path: item.sourcePath,
destination_path: item.destinationPath,
sha256: item.sha256,
})),
},
sandbox: {
bubblewrap,
systemd_run: systemdRun,
systemctl,
shell,
seccomp_sha256: host.seccompDigest(),
},
left: left.commitment,
...(right === undefined ? {} : { right: right.commitment }),
cases,
determinism: input.determinism,
limits: input.limits,
effects: [
"Execute selected extracted modules in a disposable Linux OS sandbox",
"Create and remove one private cgroup and mount namespace",
"Retain bounded return, exception, termination, and diagnostic evidence",
"Perform no host filesystem writes and expose no host or external network",
...(input.reproducer_export === undefined
? []
: ["Write one separately approved reproducer after sandbox cleanup"]),
],
reproducer_export:
input.reproducer_export === undefined
? null
: {
path: input.reproducer_export.path,
include_sources: input.reproducer_export.include_sources,
authority: "evidence_write" as const,
},
};
const publicPlan: ReplayPlan = {
...withoutDigest,
plan_digest: digestJson(withoutDigest),
};
return {
publicPlan,
leftSources: left.sources,
...(right === undefined ? {} : { rightSources: right.sources }),
};
};
const prepareSide = async (
side: ControlledReplayInput["left"],
maximumBytes: number,
host: JavaScriptReplayHost,
) => {
const aliases = new Set(side.modules.map(({ alias }) => alias));
if (aliases.size !== side.modules.length || !aliases.has(side.entry_alias))
throw new TypeError("Replay manifest aliases or entry alias are invalid");
for (const module of side.modules)
for (const dependency of Object.values(module.dependencies))
if (!aliases.has(dependency))
throw new TypeError(
`Undeclared replay dependency alias: ${dependency}`,
);
const loaded: {
readonly module: (typeof side.modules)[number];
readonly source: ReplaySourceBytes;
}[] = [];
let totalBytes = 0;
for (const module of side.modules) {
const remaining = maximumBytes - totalBytes;
if (remaining <= 0)
throw new RangeError("Replay module bytes exceed the aggregate limit");
const source = await host.readSource(module.path, remaining);
totalBytes += source.bytes.byteLength;
loaded.push({ module, source });
}
return {
commitment: {
modules: loaded.map(({ module, source }) => ({
alias: module.alias,
canonical_path: source.canonicalPath,
format: module.format,
role: module.role,
byte_count: source.bytes.byteLength,
sha256: digestBytes(source.bytes),
dependencies: module.dependencies,
})),
entry_alias: side.entry_alias,
entry_export: side.entry_export,
},
sources: Object.fromEntries(
loaded.map(({ module, source }) => [
module.alias,
new TextDecoder("utf-8", { fatal: true }).decode(source.bytes),
]),
),
};
};
const replayCases = (
input: ControlledReplayInput,
): readonly { readonly case_id: string; readonly arguments: JsonValue[] }[] => {
const explicit = input.cases.map((item) => ({
case_id: item.case_id,
arguments: item.arguments,
}));
if (input.generator === undefined) return uniqueCases(explicit);
let state = input.generator.seed || 0x9e37_79b9;
const next = (): number => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return state >>> 0;
};
const corpus = boundaryCorpus(input.generator.preset);
const generated: {
readonly case_id: string;
readonly arguments: JsonValue[];
}[] = Array.from({ length: input.generator.count }, (_, index) => ({
case_id: `generated-${input.generator?.preset ?? "boundary"}-${String(index)}`,
arguments: [corpus[next() % corpus.length] ?? ""],
}));
return uniqueCases([...explicit, ...generated]);
};
const uniqueCases = <Case extends { readonly case_id: string }>(
cases: readonly Case[],
): readonly Case[] => {
const ids = new Set<string>();
for (const item of cases) {
if (ids.has(item.case_id))
throw new TypeError(`Duplicate replay case ID: ${item.case_id}`);
ids.add(item.case_id);
}
return cases;
};
const boundaryCorpus = (
preset: NonNullable<ControlledReplayInput["generator"]>["preset"],
): readonly JsonValue[] => {
if (preset === "sanitizer-boundaries")
return [
"",
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"javascript:alert(1)",
"<b>ok</b>",
"\u0000",
];
if (preset === "clipboard-boundaries")
return [
"",
"plain",
"a\r\nb",
"a\nb",
"\t",
"😀",
"text/html",
"text/plain",
];
return [
"",
" ",
"\n",
"# heading",
"**bold**",
"[x](javascript:y)",
"`code`",
"😀",
"\u0000",
"a".repeat(1024),
];
};
export const digestBytes = (value: Uint8Array): string =>
createHash("sha256").update(value).digest("hex");
export const digestJson = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Replay commitment is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};