fix: close P1 acceptance audit gaps
This commit is contained in:
+501
-137
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import {
|
||||
closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, openSync, realpathSync,
|
||||
accessSync, closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, openSync, realpathSync, statSync,
|
||||
} from "node:fs";
|
||||
import {
|
||||
access, chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
import { Socket, isIP } from "node:net";
|
||||
import {
|
||||
basename, dirname, isAbsolute, join, relative, resolve, sep,
|
||||
@@ -15,9 +15,16 @@ import {
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const require = createRequire(import.meta.url);
|
||||
const mutableChildProcess = require("node:child_process");
|
||||
const mutableDgram = require("node:dgram");
|
||||
const mutableDns = require("node:dns");
|
||||
const mutableWorkerThreads = require("node:worker_threads");
|
||||
let commandEventSink;
|
||||
let activeCommandCheckId;
|
||||
let activeExecutablePolicy;
|
||||
let integrationOwner;
|
||||
let productionSurfaceOwner;
|
||||
const RUN_ID = /^p1-[0-9a-f]{32}$/;
|
||||
const HEX40 = /^[0-9a-f]{40}$/;
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
@@ -37,7 +44,18 @@ const TOPOLOGY = [
|
||||
"exports/raw", "exports/extracted", "rendered", "logs",
|
||||
];
|
||||
const ZIP_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"];
|
||||
const MAX_OUTPUT = 1024 * 1024;
|
||||
const MAX_OUTPUT = 16 * 1024 * 1024;
|
||||
const PYTHON_LOCK_HOLDER_PROGRAM = [
|
||||
"import fcntl, os, sys",
|
||||
"fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT | getattr(os, 'O_NOFOLLOW', 0), 0o600)",
|
||||
"try:",
|
||||
" fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)",
|
||||
"except BlockingIOError:",
|
||||
" sys.exit(73)",
|
||||
"sys.stdout.write('locked\\n')",
|
||||
"sys.stdout.flush()",
|
||||
"sys.stdin.buffer.read()",
|
||||
].join("\n");
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
|
||||
|
||||
@@ -83,9 +101,12 @@ async function atomicWrite(path, bytes, mode = 0o600) {
|
||||
const directory = openSync(dirname(path), fsConstants.O_RDONLY);
|
||||
try { fsyncSync(directory); } finally { closeSync(directory); }
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
await rm(staging, { force: true });
|
||||
throw error;
|
||||
let failure = error;
|
||||
if (handle) {
|
||||
try { await handle.close(); } catch (closeError) { failure = closeError; }
|
||||
}
|
||||
try { await rm(staging, { force: true }); } catch (cleanupError) { failure = cleanupError; }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
function exactOwnedResources(run) {
|
||||
@@ -119,10 +140,11 @@ function ownership(run, listeners = run.listeners) {
|
||||
};
|
||||
}
|
||||
async function writeOwnership(run, listenerUpdate) {
|
||||
if (listenerUpdate) {
|
||||
run.listeners = run.listeners.map((listener) => listener.name === listenerUpdate.name ? listenerUpdate : listener);
|
||||
}
|
||||
await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownership(run), null, 2)}\n`);
|
||||
const listeners = listenerUpdate
|
||||
? run.listeners.map((listener) => listener.name === listenerUpdate.name ? listenerUpdate : listener)
|
||||
: run.listeners;
|
||||
await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownership(run, listeners), null, 2)}\n`);
|
||||
run.listeners = listeners;
|
||||
}
|
||||
export async function createOwnedRun({ repositoryRoot, runId, nonce, now, pid } = {}) {
|
||||
const repo = canonicalRoot(repositoryRoot);
|
||||
@@ -149,7 +171,7 @@ function strictOwnership(value, run, expectedNonce) {
|
||||
&& value.listeners.every((listener, index) => {
|
||||
const expectedName = ["primary", "contextual"][index];
|
||||
const common = listener?.name === expectedName && listener.kind === "fastify" && listener.host === "127.0.0.1"
|
||||
&& listener.requestedPort === 0 && listener.pid === process.pid && ["not_started", "listening", "closed"].includes(listener.state);
|
||||
&& listener.requestedPort === 0 && listener.pid === process.pid && ["not_started", "listening", "closed", "close_failed"].includes(listener.state);
|
||||
return common && (listener.state === "not_started"
|
||||
? !("actualPort" in listener)
|
||||
: Number.isInteger(listener.actualPort) && listener.actualPort >= 1 && listener.actualPort <= 65535);
|
||||
@@ -191,22 +213,236 @@ export async function finalizeOwnedRun({ run, success, keep }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveTrustedSystemExecutableSync(name) {
|
||||
const candidates = process.platform === "win32"
|
||||
? []
|
||||
: [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
accessSync(candidate, fsConstants.X_OK);
|
||||
const canonical = realpathSync(candidate);
|
||||
if (statSync(canonical).isFile()) return canonical;
|
||||
} catch { /* try the next fixed trusted executable location */ }
|
||||
}
|
||||
throw new Error(`cannot resolve trusted system executable: ${name}`);
|
||||
}
|
||||
|
||||
export async function resolveProductionExecutables({ repositoryRoot, thtBin } = {}) {
|
||||
const repo = canonicalRoot(repositoryRoot);
|
||||
const gitPath = resolveTrustedSystemExecutableSync("git");
|
||||
const pythonPath = resolveTrustedSystemExecutableSync("python3");
|
||||
const expectedThtRoot = join(repo, "harness", ".venv");
|
||||
const candidateTht = thtBin ?? join(expectedThtRoot, "bin", "tht");
|
||||
if (!isAbsolute(candidateTht)) throw new Error("THT executable must be absolute");
|
||||
const thtPath = realpathSync(candidateTht);
|
||||
const thtRelative = relative(expectedThtRoot, thtPath);
|
||||
if (thtRelative.startsWith("..") || isAbsolute(thtRelative)) throw new Error("THT executable leaves the repository virtual environment");
|
||||
await access(thtPath, fsConstants.X_OK);
|
||||
return { gitPath, pythonPath, thtPath };
|
||||
}
|
||||
|
||||
const GIT_VERBS = new Set([
|
||||
"--version", "add", "cat-file", "checkout", "clean", "clone", "commit", "config", "fetch", "for-each-ref",
|
||||
"init", "ls-tree", "merge", "merge-base", "push", "remote", "reset", "rev-list", "rev-parse", "show",
|
||||
"show-ref", "status", "symbolic-ref", "write-tree",
|
||||
]);
|
||||
function gitVerb(argv) {
|
||||
if (argv[0] === "--version") return "--version";
|
||||
let index = 0;
|
||||
while (index < argv.length) {
|
||||
if (["-C", "--git-dir", "--work-tree", "-c"].includes(argv[index])) { index += 2; continue; }
|
||||
if (argv[index].startsWith("--git-dir=") || argv[index].startsWith("--work-tree=")) { index += 1; continue; }
|
||||
return argv[index];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function validateGitInvocation(argv) {
|
||||
const verb = gitVerb(argv);
|
||||
if (!verb || !GIT_VERBS.has(verb)) throw new Error("Git command is prohibited");
|
||||
if (argv.some((value) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^[^/\s]+@[^:\s]+:/.test(value))) {
|
||||
throw new Error("Git network URL is prohibited");
|
||||
}
|
||||
return verb;
|
||||
}
|
||||
function boundedChildEnvironment(value, expected) {
|
||||
const environment = value ?? process.env;
|
||||
if (!environment || typeof environment !== "object" || Array.isArray(environment)) throw new Error("child environment is invalid");
|
||||
const allowedExtra = new Set([
|
||||
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
|
||||
"THT_AUTH_USER_ID", "THT_AUTH_USERNAME", "THT_AUTH_IS_ADMIN", "THT_DWH_API_KEY", "THT_VEC_API_KEY",
|
||||
"THT_VEC_WRITE_API_KEY", "THT_CA", "THT_SSL_CA",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(environment)) {
|
||||
if (typeof value !== "string" || (!(key in expected) && !allowedExtra.has(key))) throw new Error("child environment exceeds acceptance bounds");
|
||||
}
|
||||
for (const [key, value] of Object.entries(expected)) if (environment[key] !== value) throw new Error("child environment changed acceptance bounds");
|
||||
return environment;
|
||||
}
|
||||
function safeChildEvent(events, { surface = "child_process", api, executable, argv = [], outcome, detail, bounds }) {
|
||||
events.push({
|
||||
surface, api, executable: executable ? basename(executable) : undefined,
|
||||
argvLabels: argv.map((value) => isAbsolute(value) || value.includes(sep) ? "[path]" : /^[a-z]+:\/\//i.test(value) ? "[url]" : value.length > 80 ? "[value]" : value),
|
||||
outcome, ...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}), ...(detail ? { detail } : {}), ...(bounds ? { bounds } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, runRoot, environment, originalFetch = globalThis.fetch }) {
|
||||
if (productionSurfaceOwner) throw new Error("production surface guard is already active");
|
||||
for (const value of [gitPath, pythonPath, thtPath, runRoot]) if (!isAbsolute(value)) throw new Error("production guard paths must be absolute");
|
||||
const token = Symbol("p1-production-surface");
|
||||
productionSurfaceOwner = token;
|
||||
const events = [];
|
||||
const originals = {
|
||||
execFile: mutableChildProcess.execFile, spawn: mutableChildProcess.spawn,
|
||||
exec: mutableChildProcess.exec, execSync: mutableChildProcess.execSync, execFileSync: mutableChildProcess.execFileSync,
|
||||
spawnSync: mutableChildProcess.spawnSync, fork: mutableChildProcess.fork,
|
||||
createSocket: mutableDgram.createSocket, Worker: mutableWorkerThreads.Worker,
|
||||
dns: new Map(), dnsPromises: new Map(), dlopen: process.dlopen,
|
||||
};
|
||||
const resolveChild = (executable, argv) => {
|
||||
const canonical = executable === "git" ? gitPath : executable === "python3" ? pythonPath : executable;
|
||||
if (canonical === gitPath) return { executable: gitPath, kind: "git", verb: validateGitInvocation(argv) };
|
||||
if (canonical === thtPath) return { executable: thtPath, kind: "tht" };
|
||||
if (canonical === pythonPath) {
|
||||
if (argv.length !== 3 || argv[0] !== "-c" || argv[1] !== PYTHON_LOCK_HOLDER_PROGRAM
|
||||
|| !isAbsolute(argv[2]) || relative(runRoot, argv[2]).startsWith("..") || basename(argv[2]) !== "repository.lock") {
|
||||
throw new Error("child command is prohibited");
|
||||
}
|
||||
return { executable: pythonPath, kind: "python-lock-holder" };
|
||||
}
|
||||
throw new Error("child command is prohibited");
|
||||
};
|
||||
const rejectChild = (api, args) => {
|
||||
safeChildEvent(events, { api, executable: typeof args[0] === "string" ? args[0] : undefined, outcome: "REJECTED" });
|
||||
throw new Error("child command is prohibited");
|
||||
};
|
||||
const guardedExecFile = function guardedExecFile(executable, argv, options, callback) {
|
||||
if (!Array.isArray(argv)) return rejectChild("execFile", [executable]);
|
||||
if (typeof options === "function") { callback = options; options = {}; }
|
||||
options ??= {};
|
||||
let resolved;
|
||||
try { resolved = resolveChild(executable, argv); boundedChildEnvironment(options.env, environment); }
|
||||
catch (error) { safeChildEvent(events, { api: "execFile", executable, argv, outcome: "REJECTED" }); throw error; }
|
||||
const bounded = { ...options, env: options.env ?? environment, timeout: Math.min(options.timeout ?? 30_000, 300_000), maxBuffer: Math.min(options.maxBuffer ?? MAX_OUTPUT, MAX_OUTPUT), shell: false };
|
||||
safeChildEvent(events, { api: "execFile", executable: resolved.executable, argv, outcome: "STARTED", detail: resolved.kind,
|
||||
bounds: { timeoutMs: bounded.timeout, maxOutputBytes: bounded.maxBuffer, environment: "owned" } });
|
||||
return originals.execFile(resolved.executable, argv, bounded, (error, stdout, stderr) => {
|
||||
safeChildEvent(events, { api: "execFile", executable: resolved.executable, argv, outcome: error ? "FAIL" : "PASS", detail: resolved.kind });
|
||||
callback?.(error, stdout, stderr);
|
||||
});
|
||||
};
|
||||
Object.defineProperty(guardedExecFile, promisify.custom, { value: (executable, argv, options) => new Promise((resolvePromise, reject) => {
|
||||
guardedExecFile(executable, argv, options, (error, stdout, stderr) => error ? reject(Object.assign(error, { stdout, stderr })) : resolvePromise({ stdout, stderr }));
|
||||
}) });
|
||||
const guardedSpawn = function guardedSpawn(executable, argv, options = {}) {
|
||||
if (!Array.isArray(argv)) return rejectChild("spawn", [executable]);
|
||||
let resolved;
|
||||
try { resolved = resolveChild(executable, argv); boundedChildEnvironment(options.env, environment); }
|
||||
catch (error) { safeChildEvent(events, { api: "spawn", executable, argv, outcome: "REJECTED" }); throw error; }
|
||||
const bounded = { ...options, env: options.env ?? environment, shell: false };
|
||||
safeChildEvent(events, { api: "spawn", executable: resolved.executable, argv, outcome: "STARTED", detail: resolved.kind,
|
||||
bounds: { timeoutMs: 300_000, maxOutputBytes: MAX_OUTPUT, environment: "owned" } });
|
||||
const child = originals.spawn(resolved.executable, argv, bounded);
|
||||
let bytes = 0;
|
||||
const count = (chunk) => { bytes += chunk.length; if (bytes > MAX_OUTPUT) child.kill("SIGKILL"); };
|
||||
child.stdout?.on("data", count); child.stderr?.on("data", count);
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 300_000); timer.unref();
|
||||
child.once("exit", (code) => { clearTimeout(timer); safeChildEvent(events, { api: "spawn", executable: resolved.executable, argv, outcome: code === 0 ? "PASS" : "FAIL", detail: resolved.kind }); });
|
||||
child.once("error", () => { clearTimeout(timer); });
|
||||
return child;
|
||||
};
|
||||
const owned = { execFile: guardedExecFile, spawn: guardedSpawn, child: new Map(), dns: new Map(), dnsPromises: new Map() };
|
||||
mutableChildProcess.execFile = guardedExecFile;
|
||||
mutableChildProcess.spawn = guardedSpawn;
|
||||
for (const api of ["exec", "execSync", "execFileSync", "spawnSync", "fork"]) {
|
||||
const wrapper = (...args) => rejectChild(api, args); owned.child.set(api, wrapper); mutableChildProcess[api] = wrapper;
|
||||
}
|
||||
const guardedCreateSocket = (..._args) => { safeChildEvent(events, { surface: "dgram", api: "createSocket", outcome: "REJECTED" }); throw new Error("prohibited production surface: dgram"); };
|
||||
class ProhibitedWorker { constructor() { safeChildEvent(events, { surface: "worker_threads", api: "Worker", outcome: "REJECTED" }); throw new Error("prohibited production surface: worker_threads"); } }
|
||||
mutableDgram.createSocket = guardedCreateSocket;
|
||||
mutableWorkerThreads.Worker = ProhibitedWorker;
|
||||
for (const name of ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "resolveCaa", "resolveCname", "resolveMx", "resolveNaptr", "resolveNs", "resolvePtr", "resolveSoa", "resolveSrv", "resolveTxt", "reverse", "Resolver"]) {
|
||||
if (typeof mutableDns[name] !== "function") continue;
|
||||
originals.dns.set(name, mutableDns[name]);
|
||||
const original = originals.dns.get(name);
|
||||
const wrapper = (...args) => {
|
||||
if (name === "lookup" && ["127.0.0.1", "::1"].includes(args[0])) {
|
||||
safeChildEvent(events, { surface: "dns", api: name, outcome: "PASS", detail: "owned-loopback-literal" });
|
||||
return original(...args);
|
||||
}
|
||||
safeChildEvent(events, { surface: "dns", api: name, outcome: "REJECTED" });
|
||||
throw new Error("prohibited production surface: dns");
|
||||
};
|
||||
owned.dns.set(name, wrapper); mutableDns[name] = wrapper;
|
||||
}
|
||||
for (const [name, value] of Object.entries(mutableDns.promises ?? {})) if (typeof value === "function") {
|
||||
originals.dnsPromises.set(name, value);
|
||||
const original = originals.dnsPromises.get(name);
|
||||
const wrapper = async (...args) => {
|
||||
if (name === "lookup" && ["127.0.0.1", "::1"].includes(args[0])) {
|
||||
safeChildEvent(events, { surface: "dns", api: `promises.${name}`, outcome: "PASS", detail: "owned-loopback-literal" });
|
||||
return await original(...args);
|
||||
}
|
||||
safeChildEvent(events, { surface: "dns", api: `promises.${name}`, outcome: "REJECTED" });
|
||||
throw new Error("prohibited production surface: dns");
|
||||
};
|
||||
owned.dnsPromises.set(name, wrapper); mutableDns.promises[name] = wrapper;
|
||||
}
|
||||
const guardedDlopen = (..._args) => { safeChildEvent(events, { surface: "native_addon", api: "dlopen", outcome: "REJECTED" }); throw new Error("prohibited production surface: native addon"); };
|
||||
process.dlopen = guardedDlopen;
|
||||
syncBuiltinESMExports();
|
||||
const network = installNetworkGuard(originalFetch);
|
||||
activeExecutablePolicy = { gitPath, pythonPath, thtPath };
|
||||
let restored = false;
|
||||
return {
|
||||
events, externalAttempts: network.externalAttempts,
|
||||
addOwnedOrigin: network.addOwnedOrigin, hasOwnedOrigin: network.hasOwnedOrigin,
|
||||
restore() {
|
||||
if (restored) throw new Error("production surface guard restored twice");
|
||||
restored = true;
|
||||
let tampered = productionSurfaceOwner !== token;
|
||||
const ownsToken = productionSurfaceOwner === token;
|
||||
const restoreOwned = (target, key, wrapper, original) => {
|
||||
if (target[key] !== wrapper) tampered = true;
|
||||
if (ownsToken) target[key] = original;
|
||||
};
|
||||
const errors = [];
|
||||
try { network.restore(); } catch (error) { errors.push(error); }
|
||||
restoreOwned(mutableChildProcess, "execFile", guardedExecFile, originals.execFile);
|
||||
restoreOwned(mutableChildProcess, "spawn", guardedSpawn, originals.spawn);
|
||||
for (const [api, wrapper] of owned.child) restoreOwned(mutableChildProcess, api, wrapper, originals[api]);
|
||||
restoreOwned(mutableDgram, "createSocket", guardedCreateSocket, originals.createSocket);
|
||||
restoreOwned(mutableWorkerThreads, "Worker", ProhibitedWorker, originals.Worker);
|
||||
for (const [name, wrapper] of owned.dns) restoreOwned(mutableDns, name, wrapper, originals.dns.get(name));
|
||||
for (const [name, wrapper] of owned.dnsPromises) restoreOwned(mutableDns.promises, name, wrapper, originals.dnsPromises.get(name));
|
||||
restoreOwned(process, "dlopen", guardedDlopen, originals.dlopen); syncBuiltinESMExports();
|
||||
if (productionSurfaceOwner === token) productionSurfaceOwner = undefined;
|
||||
if (activeExecutablePolicy?.gitPath === gitPath) activeExecutablePolicy = undefined;
|
||||
if (tampered || errors.length) throw new Error("production surface guard ownership restoration failed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCommand(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) throw new Error("command requires an options object");
|
||||
const allowed = new Set(["executable", "argv", "cwd", "env", "timeoutMs", "stdin", "maxOutputBytes"]);
|
||||
for (const key of Object.keys(options)) if (!allowed.has(key)) throw new Error(`unsupported command option ${key}`);
|
||||
const { executable, argv, cwd, env, timeoutMs = 30_000, stdin, maxOutputBytes = MAX_OUTPUT } = options;
|
||||
if (typeof executable !== "string" || executable.length === 0 || /[;&|`$><\n\r]/.test(executable)) throw new Error("command executable is invalid");
|
||||
const allowlistedExecutable = executable === "git" || (isAbsolute(executable) && basename(executable) === "tht");
|
||||
if (!allowlistedExecutable) throw new Error("command executable is not allowlisted");
|
||||
if (typeof executable !== "string" || !isAbsolute(executable) || /[;&|`$><\n\r]/.test(executable)) throw new Error("command executable is invalid");
|
||||
let canonical;
|
||||
try { canonical = realpathSync(executable); } catch { throw new Error("command executable is not allowlisted"); }
|
||||
const allowedGit = activeExecutablePolicy?.gitPath ?? resolveTrustedSystemExecutableSync("git");
|
||||
const allowedTht = activeExecutablePolicy?.thtPath;
|
||||
if (canonical !== allowedGit && canonical !== allowedTht) throw new Error("command executable is not allowlisted");
|
||||
if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw new Error("command argv must be a string array");
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) throw new Error("command timeout is invalid");
|
||||
if (canonical === allowedGit) validateGitInvocation(argv);
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000 || !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > MAX_OUTPUT) throw new Error("command bounds are invalid");
|
||||
return await new Promise((resolvePromise, reject) => {
|
||||
const child = execFile(executable, argv, { cwd, env, timeout: timeoutMs, maxBuffer: maxOutputBytes, encoding: "utf8" }, (error, stdout, stderr) => {
|
||||
const child = mutableChildProcess.execFile(canonical, argv, { cwd, env, timeout: timeoutMs, maxBuffer: maxOutputBytes, encoding: "utf8", shell: false }, (error, stdout, stderr) => {
|
||||
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
||||
const result = { code, stdout: stdout ?? "", stderr: stderr ?? "" };
|
||||
if (commandEventSink) commandEventSink.push({
|
||||
executable: basename(executable),
|
||||
executable: basename(canonical),
|
||||
argvLabels: argv.map((value) => isAbsolute(value) || value.includes(sep) ? "[path]" : /^[a-z]+:\/\//i.test(value) ? "[url]" : value.length > 80 ? "[value]" : value),
|
||||
outcome: error ? "FAIL" : "PASS",
|
||||
...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}),
|
||||
@@ -217,7 +453,7 @@ export async function runCommand(options) {
|
||||
if (stdin !== undefined) { child.stdin.end(stdin); }
|
||||
});
|
||||
}
|
||||
async function git(argv, options = {}) { return await runCommand({ executable: "git", argv, ...options }); }
|
||||
async function git(argv, options = {}) { return await runCommand({ executable: activeExecutablePolicy?.gitPath ?? resolveTrustedSystemExecutableSync("git"), argv, ...options }); }
|
||||
async function tht(executable, argv, options = {}) { return await runCommand({ executable, argv, ...options }); }
|
||||
function safeArtifactPath(path) {
|
||||
if (typeof path !== "string" || !SAFE_RELATIVE.test(path) || path.startsWith(".") || path.includes("//")) throw new Error("unsafe artifact path");
|
||||
@@ -282,8 +518,8 @@ async function walkFiles(root, current = root, out = []) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function gitObjectFindings(runRoot, forbiddenValues, expectedGitRepositories) {
|
||||
const findings = [];
|
||||
async function gitObjectScan(runRoot, forbiddenValues, expectedGitRepositories) {
|
||||
const findings = []; const repositories = [];
|
||||
for (const rel of expectedGitRepositories) {
|
||||
const directory = join(runRoot, rel);
|
||||
if (!existsSync(directory)) throw new Error(`Git secret scan failed closed: missing expected Git repository: ${rel}`);
|
||||
@@ -292,6 +528,7 @@ async function gitObjectFindings(runRoot, forbiddenValues, expectedGitRepositori
|
||||
try {
|
||||
objects = (await git([...args, "rev-list", "--objects", "--all"])).stdout.trim().split("\n").filter(Boolean);
|
||||
} catch { throw new Error(`Git secret scan failed closed during enumeration: ${basename(directory)}`); }
|
||||
let blobCount = 0;
|
||||
for (const line of objects) {
|
||||
const oid = line.split(" ", 1)[0];
|
||||
let type; let bytes;
|
||||
@@ -299,21 +536,26 @@ async function gitObjectFindings(runRoot, forbiddenValues, expectedGitRepositori
|
||||
type = (await git([...args, "cat-file", "-t", oid])).stdout.trim();
|
||||
if (!/^(blob|tree|commit|tag)$/.test(type)) throw new Error("invalid object type");
|
||||
if (type !== "blob") continue;
|
||||
blobCount += 1;
|
||||
bytes = Buffer.from((await git([...args, "cat-file", "blob", oid], { maxOutputBytes: 16 * 1024 * 1024 })).stdout);
|
||||
} catch { throw new Error(`Git secret scan failed closed during object inspection: ${basename(directory)}:${oid}`); }
|
||||
if (containsAny(bytes, forbiddenValues)) findings.push({ path: `git-object:${basename(directory)}:${oid}` });
|
||||
if (containsAny(bytes, forbiddenValues)) findings.push({ path: `git-object:${rel}:${oid}` });
|
||||
}
|
||||
repositories.push({ path: rel, objectCount: objects.length, blobCount });
|
||||
}
|
||||
return findings;
|
||||
return { findings, repositories };
|
||||
}
|
||||
export async function scanSecrets({ runRoot, forbiddenValues, virtualFiles = [], expectedGitRepositories = ["remote.git", "author"] }) {
|
||||
export async function scanSecretsDetailed({ runRoot, forbiddenValues, virtualFiles = [], expectedGitRepositories = ["remote.git", "author"] }) {
|
||||
const values = forbiddenValues.filter((value) => typeof value === "string" && value.length >= 8);
|
||||
const findings = [];
|
||||
for (const file of await walkFiles(runRoot)) if (containsAny(await readFile(file.path), values)) findings.push({ path: file.rel });
|
||||
for (const file of virtualFiles) if (containsAny(Buffer.from(file.bytes), values)) findings.push({ path: file.path });
|
||||
findings.push(...await gitObjectFindings(runRoot, values, expectedGitRepositories));
|
||||
return findings;
|
||||
const gitScan = await gitObjectScan(runRoot, values, expectedGitRepositories);
|
||||
findings.push(...gitScan.findings);
|
||||
return { findings, repositories: gitScan.repositories };
|
||||
}
|
||||
export async function scanSecrets(options) { return (await scanSecretsDetailed(options)).findings; }
|
||||
|
||||
export function negativeRequestEvidence(caseLabel, expectedInputField) {
|
||||
if (!/^[a-z0-9-]+$/.test(caseLabel) || !/^[a-z_]+(?:\.[a-z_]+)*$/.test(expectedInputField)) throw new Error("unsafe negative-case evidence");
|
||||
return { case: caseLabel, expectedInputField };
|
||||
@@ -360,7 +602,7 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) {
|
||||
const normalized = host === "localhost" || host === "::1" ? "127.0.0.1" : host;
|
||||
return isIP(normalized) !== 0 && normalized === "127.0.0.1" && ownedOrigins.has(`http://127.0.0.1:${port}`);
|
||||
};
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const guardedFetch = async (input, init) => {
|
||||
const candidate = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
|
||||
if (!ownedOrigins.has(candidate.origin)) {
|
||||
externalAttempts.push({ transport: "fetch", protocol: candidate.protocol, loopback: candidate.hostname === "127.0.0.1" });
|
||||
@@ -368,7 +610,7 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) {
|
||||
}
|
||||
return await fetchImplementation(input, init);
|
||||
};
|
||||
Socket.prototype.connect = function guardedSocketConnect(...args) {
|
||||
const guardedSocketConnect = function guardedSocketConnect(...args) {
|
||||
const destination = socketDestination(args);
|
||||
if (!("host" in destination) || !isOwned(destination.host, destination.port)) {
|
||||
externalAttempts.push({ transport: "socket", loopback: destination.host === "127.0.0.1" });
|
||||
@@ -376,16 +618,20 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) {
|
||||
}
|
||||
return originalConnect.apply(this, args);
|
||||
};
|
||||
globalThis.fetch = guardedFetch;
|
||||
Socket.prototype.connect = guardedSocketConnect;
|
||||
let restored = false;
|
||||
return {
|
||||
externalAttempts,
|
||||
addOwnedOrigin(value) { ownedOrigins.add(loopbackOrigin(value)); },
|
||||
hasOwnedOrigin(value) { return ownedOrigins.has(loopbackOrigin(value)); },
|
||||
restore() {
|
||||
if (restored) return;
|
||||
if (restored) throw new Error("network guard restored twice");
|
||||
restored = true;
|
||||
const tampered = globalThis.fetch !== guardedFetch || Socket.prototype.connect !== guardedSocketConnect;
|
||||
globalThis.fetch = originalFetch;
|
||||
Socket.prototype.connect = originalConnect;
|
||||
if (tampered) throw new Error("network guard ownership changed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -535,7 +781,7 @@ async function startProductionBackend(ctx, { name, env, runtimeConfigPath }) {
|
||||
try {
|
||||
address = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
} catch (error) {
|
||||
await app.close().catch(() => {});
|
||||
try { await app.close(); } catch { throw new Error("production listener start and close both failed"); }
|
||||
throw error;
|
||||
}
|
||||
const url = new URL(address);
|
||||
@@ -567,7 +813,10 @@ export function exportArchiveEvidencePath(requestId) {
|
||||
return `exports/raw/${requestId}.zip`;
|
||||
}
|
||||
function trackArtifact(ctx, artifact) {
|
||||
if (ctx.activeArtifacts && !ctx.activeArtifacts.some(({ path }) => path === artifact.path)) ctx.activeArtifacts.push(artifact);
|
||||
if (ctx.activeArtifacts) {
|
||||
if (ctx.activeArtifacts.some(({ path }) => path === artifact.path)) throw new Error("artifact path is duplicated within check");
|
||||
ctx.activeArtifacts.push(artifact);
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
@@ -632,7 +881,7 @@ async function snapshotDigest(path) {
|
||||
for (const file of files) result[file.rel] = sha256(await readFile(file.path));
|
||||
return result;
|
||||
}
|
||||
const SAFE_AMBIENT_ENV = Object.freeze(["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TZ", "NODE_EXTRA_CA_CERTS"]);
|
||||
const SAFE_AMBIENT_ENV = Object.freeze(["LANG", "LC_ALL", "TZ"]);
|
||||
export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = {}) {
|
||||
const safe = {};
|
||||
for (const key of SAFE_AMBIENT_ENV) if (typeof ambient[key] === "string") safe[key] = ambient[key];
|
||||
@@ -644,11 +893,24 @@ export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = {
|
||||
}
|
||||
|
||||
async function setupContext(run, repositoryRoot, env, ctx = {}) {
|
||||
const thtBin = realpathSync(env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht"));
|
||||
const executables = await resolveProductionExecutables({
|
||||
repositoryRoot, thtBin: env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht"),
|
||||
});
|
||||
const harnessDir = realpathSync(join(repositoryRoot, "harness"));
|
||||
const gitTracePath = join(run.root, "logs", "production-git-trace.jsonl");
|
||||
const ownedHome = join(run.root, "installation", "runtime", "acceptance-home");
|
||||
const ownedTmp = join(run.root, "installation", "runtime", "tmp");
|
||||
await mkdir(ownedHome, { recursive: true, mode: 0o700 });
|
||||
await mkdir(ownedTmp, { recursive: true, mode: 0o700 });
|
||||
const executablePath = [...new Set([dirname(executables.gitPath), dirname(executables.pythonPath), dirname(executables.thtPath)])].join(":");
|
||||
const fixtureEnv = {
|
||||
HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: thtBin,
|
||||
PATH: executablePath, HOME: ownedHome, TMPDIR: ownedTmp,
|
||||
GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_ALLOW_PROTOCOL: "file", GIT_PROTOCOL_FROM_USER: "0",
|
||||
GIT_CONFIG_COUNT: "3", GIT_CONFIG_KEY_0: "commit.gpgSign", GIT_CONFIG_VALUE_0: "false",
|
||||
GIT_CONFIG_KEY_1: "tag.gpgSign", GIT_CONFIG_VALUE_1: "false",
|
||||
GIT_CONFIG_KEY_2: "credential.helper", GIT_CONFIG_VALUE_2: "",
|
||||
HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: executables.thtPath,
|
||||
THT_HARNESS_DIR: harnessDir, THT_DATA_ROOT: join(run.root, "installation", "data"),
|
||||
SETTINGS_FILE: join(run.root, "installation", "data", "settings.json"),
|
||||
MAINTENANCE_STATE_FILE: join(run.root, "installation", "data", "maintenance.json"),
|
||||
@@ -662,7 +924,7 @@ async function setupContext(run, repositoryRoot, env, ctx = {}) {
|
||||
Object.assign(ctx, {
|
||||
run, repositoryRoot, descriptors: descriptors(), forbiddenValues: ctx.forbiddenValues ?? [],
|
||||
env: buildSafeEnvironment({ ambient: env, fixture: fixtureEnv }),
|
||||
httpRequests: [], services: [], gitTracePath,
|
||||
httpRequests: [], services: [], gitTracePath, executables,
|
||||
expectedGitRepositories: ["remote.git", "author"],
|
||||
});
|
||||
await createTopology(run);
|
||||
@@ -773,7 +1035,11 @@ async function traceSize(path) {
|
||||
}
|
||||
function uniqueArtifacts(artifacts) {
|
||||
const seen = new Set();
|
||||
return artifacts.filter((artifact) => !seen.has(artifact.path) && seen.add(artifact.path));
|
||||
for (const artifact of artifacts) {
|
||||
if (seen.has(artifact.path)) throw new Error("artifact path is duplicated within check");
|
||||
seen.add(artifact.path);
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
async function commandsObservedForCheck(ctx, checkId, traceBefore) {
|
||||
const commands = new Set((commandEventSink ?? []).filter((event) => event.checkId === checkId).map((event) => event.executable));
|
||||
@@ -832,9 +1098,27 @@ async function assertNoP1ScopeEntrypoints(ctx) {
|
||||
const packageJson = JSON.parse(await readFile(join(ctx.repositoryRoot, "backend", "package.json"), "utf8"));
|
||||
const entrypointBytes = JSON.stringify({ main: packageJson.main, bin: packageJson.bin, exports: packageJson.exports, scripts: packageJson.scripts });
|
||||
const forbiddenPackageEntrypoints = /(?:acquire|preprocess)Evidence|Evidence(?:Adapter|Acquisition|Preprocessor)/i.test(entrypointBytes);
|
||||
const auditedSurfaceFiles = [];
|
||||
for (const group of ["workspaces", "routes"]) {
|
||||
const root = join(ctx.repositoryRoot, "backend", "dist", group);
|
||||
for (const name of (await readdir(root)).filter((value) => value.endsWith(".js")).sort()) {
|
||||
auditedSurfaceFiles.push({ path: `${group}/${name}`, source: await readFile(join(root, name), "utf8") });
|
||||
}
|
||||
}
|
||||
const prohibitedProcessSurfaces = auditedSurfaceFiles.filter(({ source }) => /node:(?:dgram|worker_threads|dns)|\.node(?:["']|$)|process\.dlopen|node-gyp|bindings\s*\(/.test(source));
|
||||
const networkAdapterModules = auditedSurfaceFiles.filter(({ source }) => /node:(?:net|http|https)|globalThis\.fetch|\bfetch\s*\(/.test(source)).map(({ path }) => path);
|
||||
const childProcessModules = auditedSurfaceFiles.filter(({ source }) => /node:child_process/.test(source)).map(({ path }) => path);
|
||||
assert(JSON.stringify(networkAdapterModules) === JSON.stringify(["workspaces/diagnostics.js"])
|
||||
&& JSON.stringify(childProcessModules) === JSON.stringify(["workspaces/diagnostics.js", "workspaces/git-repository.js"])
|
||||
&& prohibitedProcessSurfaces.length === 0,
|
||||
"unexpected production process/network adapter entrypoint surface present");
|
||||
assert(forbiddenModuleNames.length === 0 && forbiddenExports.length === 0 && forbiddenRoutes.length === 0 && !forbiddenPackageEntrypoints,
|
||||
"prohibited P1 adapter/acquisition/preprocessing entrypoint surface present");
|
||||
return { moduleFilesAudited: modules.sort(), routeAppsAudited: routeSurfaces.map(({ name }) => name), packageEntrypointsAudited: true };
|
||||
return {
|
||||
moduleFilesAudited: modules.sort(), routeAppsAudited: routeSurfaces.map(({ name }) => name), packageEntrypointsAudited: true,
|
||||
productionSurfaceFilesAudited: auditedSurfaceFiles.map(({ path }) => path), networkAdapterModules,
|
||||
childProcessModules, workerDgramDnsNativeEntrypoints: [],
|
||||
};
|
||||
}
|
||||
|
||||
function productionChecks(ctx) {
|
||||
@@ -941,7 +1225,6 @@ function productionChecks(ctx) {
|
||||
const currentSnapshot = await currentSnapshotManifest(ctx, current.commit); ctx.currentManifest = currentSnapshot.manifest;
|
||||
return { commands: ["git"], artifacts: [
|
||||
await evidence(ctx.run, "logs/content-only-revision.json", { oldCommit: ctx.oldRevision.commit, newCommit: current.commit, descriptorBlob: current.blob, oldFilesystemRoot: ctx.oldFilesystemRoot, newFilesystemRoot: newRoot, oldSnapshotImmutable: true }),
|
||||
await fileArtifact(ctx.run.root, relative(ctx.run.root, currentSnapshot.path)), await fileArtifact(ctx.run.root, relative(ctx.run.root, current.snapshotPath)),
|
||||
] };
|
||||
} },
|
||||
{ id: "snapshot_and_docs", run: async () => {
|
||||
@@ -950,16 +1233,17 @@ function productionChecks(ctx) {
|
||||
const extracted = join(ctx.run.root, "exports", "extracted", id);
|
||||
for (const name of ZIP_FILES) {
|
||||
assert((await lstat(join(extracted, name))).isFile(), `missing extracted ${name}`);
|
||||
artifacts.push(await fileArtifact(ctx.run.root, `exports/extracted/${id}/${name}`));
|
||||
await access(join(extracted, name), fsConstants.R_OK);
|
||||
}
|
||||
const revision = revisionFromManifest(ctx.currentManifest, id);
|
||||
for (const suffix of [".yaml", ".env.example", ".md", "snapshot.json"]) {
|
||||
const file = suffix === "snapshot.json" ? join(dirname(revision.snapshotPath), suffix) : join(dirname(revision.snapshotPath), `${id}${suffix}`);
|
||||
for (const suffix of [".yaml", ".env.example", ".md"]) {
|
||||
const file = join(dirname(revision.snapshotPath), `${id}${suffix}`);
|
||||
assert(existsSync(file), `snapshot artifact absent ${file}`);
|
||||
artifacts.push(await fileArtifact(ctx.run.root, relative(ctx.run.root, file)));
|
||||
}
|
||||
assert(!existsSync(join(dirname(revision.snapshotPath), "workspace-content")), "snapshot materialized source tree");
|
||||
}
|
||||
artifacts.push(await fileArtifact(ctx.run.root, relative(ctx.run.root, join(dirname(ctx.currentRevision.snapshotPath), "snapshot.json"))));
|
||||
artifacts.unshift(await evidence(ctx.run, "logs/snapshot-and-docs.json", { exactBundleFiles: ZIP_FILES, generatedDocs: true, immutableSnapshots: true, derivedFromManifest: true }));
|
||||
return { commands: [], artifacts };
|
||||
} },
|
||||
@@ -1113,17 +1397,30 @@ function productionChecks(ctx) {
|
||||
const gitTraceProof = await assertProductionGitTrace(ctx);
|
||||
assert(present.length === 0 && prohibitedRoutesCalled.length === 0 && prohibitedCommands.length === 0, "prohibited P1 scope operation observed");
|
||||
assert(ctx.networkGuard.externalAttempts.length === 0, "external network connection attempted");
|
||||
const rejectedSurfaces = ctx.networkGuard.events.filter(({ outcome }) => outcome === "REJECTED");
|
||||
const rejectedChildren = rejectedSurfaces.filter(({ surface }) => surface === "child_process");
|
||||
const childKinds = new Set(ctx.networkGuard.events.filter(({ surface }) => surface === "child_process").map(({ detail }) => detail).filter(Boolean));
|
||||
assert(rejectedSurfaces.length === 0 && rejectedChildren.length === 0 && ["git", "python-lock-holder", "tht"].every((kind) => childKinds.has(kind)),
|
||||
"production child allowlist evidence is incomplete");
|
||||
assert(ctx.services.length === 2 && ctx.services.every(({ baseUrl }) => ctx.networkGuard.hasOwnedOrigin(baseUrl)), "listener was not an owned loopback origin");
|
||||
return await log("no-p1-scope-artifacts", {
|
||||
absentArtifacts: forbidden, moduleEntrypointSurfaceAbsent: true, routeEntrypointSurfaceAbsent: true,
|
||||
...surfaceAudit, productionGitTrace: gitTraceProof, networkGuardInstalledBeforeProduction: true, externalNetworkAttempts: [],
|
||||
exactProductionChildApi: true, productionChildKinds: [...childKinds].sort(), rejectedProductionChildren: [],
|
||||
ownedLoopbackOrigins: ctx.services.map(({ name }) => name),
|
||||
});
|
||||
} },
|
||||
{ id: "secret_scan", run: async () => {
|
||||
const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues });
|
||||
assert(findings.length === 0, "secret canary found outside exclusion");
|
||||
return await log("secret-scan", { scanned: true, gitEnumerationFailClosed: true, excluded: "fixture-secrets", findings: [] });
|
||||
const scan = await scanSecretsDetailed({
|
||||
runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues,
|
||||
expectedGitRepositories: ctx.expectedGitRepositories,
|
||||
});
|
||||
assert(scan.findings.length === 0, "secret canary found outside exclusion");
|
||||
ctx.gitSecretScanFrozen = true;
|
||||
return await log("secret-scan", {
|
||||
scanned: true, gitEnumerationFailClosed: true, excluded: "fixture-secrets",
|
||||
expectedGitRepositories: [...ctx.expectedGitRepositories], repositories: scan.repositories, findings: [],
|
||||
});
|
||||
} },
|
||||
{ id: "cleanup_confinement", run: async () => {
|
||||
const fakeRepo = join(ctx.run.root, "installation", "runtime", "cleanup-test");
|
||||
@@ -1161,11 +1458,22 @@ function completeFailedResults(results, firstError = "Acceptance setup failed sa
|
||||
return completed;
|
||||
}
|
||||
|
||||
function deduplicateResultArtifacts(results) {
|
||||
function assertUniqueResultArtifacts(results) {
|
||||
const seen = new Set();
|
||||
for (const result of results) result.artifacts = result.artifacts.filter(({ path }) => !seen.has(path) && seen.add(path));
|
||||
for (const result of results) for (const artifact of result.artifacts) {
|
||||
if (seen.has(artifact.path)) throw new Error("artifact path is duplicated across checks");
|
||||
seen.add(artifact.path);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
async function verifyDeclaredArtifacts(runRoot, results) {
|
||||
assertUniqueResultArtifacts(results);
|
||||
for (const result of results) for (const artifact of result.artifacts) {
|
||||
safeArtifactPath(artifact.path);
|
||||
const current = sha256(await readFile(join(runRoot, artifact.path)));
|
||||
if (current !== artifact.sha256) throw new Error("declared artifact hash mismatch");
|
||||
}
|
||||
}
|
||||
function minimalFailClosedReport(run, keep) {
|
||||
const checks = CHECK_IDS.map((id, index) => failedCheck(
|
||||
id, nowIso(), index === 0 ? "Acceptance audit failed closed." : "Not executed after fail-closed audit.",
|
||||
@@ -1189,99 +1497,155 @@ function attachResultArtifact(results, checkId, artifact) {
|
||||
result.artifacts.push(artifact);
|
||||
}
|
||||
|
||||
export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, failAt = env.P1_ACCEPTANCE_FAIL_AT, checks, setup, announce } = {}) {
|
||||
const savedEnv = { ...process.env };
|
||||
let run; let ctx; let results = []; let fatal;
|
||||
try {
|
||||
run = await createOwnedRun({ repositoryRoot });
|
||||
ctx = {
|
||||
run, repositoryRoot, forbiddenValues: [], expectedGitRepositories: [], services: [],
|
||||
originalFetch: globalThis.fetch,
|
||||
};
|
||||
commandEventSink = [];
|
||||
const selectedSetup = setup ?? (checks === undefined ? setupContext : undefined);
|
||||
if (selectedSetup) {
|
||||
const configured = await selectedSetup(run, repositoryRoot, env, ctx);
|
||||
if (configured && configured !== ctx) Object.assign(ctx, configured);
|
||||
}
|
||||
if (checks === undefined) {
|
||||
if (!ctx.env) throw new Error("acceptance setup returned no environment");
|
||||
replaceProcessEnvironment(ctx.env);
|
||||
ctx.networkGuard = installNetworkGuard(ctx.originalFetch);
|
||||
checks = productionChecks(ctx);
|
||||
} else if (ctx.env) {
|
||||
replaceProcessEnvironment(ctx.env);
|
||||
}
|
||||
results = await executeChecks({ checks, failAt });
|
||||
} catch (error) {
|
||||
fatal = error;
|
||||
if (run) results = completeFailedResults(results);
|
||||
} finally {
|
||||
if (ctx?.services) {
|
||||
for (const service of [...ctx.services].reverse()) {
|
||||
await service.app.close().catch(() => {});
|
||||
await writeOwnership(run, {
|
||||
name: service.name, kind: "fastify", host: "127.0.0.1", requestedPort: 0,
|
||||
actualPort: Number(new URL(service.baseUrl).port), pid: process.pid, state: "closed",
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
ctx?.networkGuard?.restore();
|
||||
replaceProcessEnvironment(savedEnv);
|
||||
}
|
||||
if (!run) throw fatal;
|
||||
results = deduplicateResultArtifacts(completeFailedResults(results));
|
||||
|
||||
let auditFailed = false;
|
||||
const commandEvents = commandEventSink ?? [];
|
||||
commandEventSink = undefined;
|
||||
activeCommandCheckId = undefined;
|
||||
try {
|
||||
const commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, events: commandEvents }, ctx.forbiddenValues);
|
||||
attachResultArtifact(results, "preflight", commandArtifact);
|
||||
if (ctx.gitTracePath) {
|
||||
const gitTraceBytes = await readFile(ctx.gitTracePath);
|
||||
for (const line of gitTraceBytes.toString("utf8").split("\n").filter(Boolean)) JSON.parse(line);
|
||||
attachResultArtifact(results, "no_p1_scope_artifacts", await fileArtifact(run.root, relative(run.root, ctx.gitTracePath)));
|
||||
}
|
||||
} catch {
|
||||
auditFailed = true;
|
||||
}
|
||||
deduplicateResultArtifacts(results);
|
||||
|
||||
let report = {
|
||||
schemaVersion: 1, runId: run.runId, startedAt: run.startedAt, finishedAt: nowIso(),
|
||||
command: `p1-acceptance integration${keep ? " --keep" : ""}`,
|
||||
overall: !fatal && deriveOverall(results) === "PASS" ? "PASS" : "FAIL", checks: results,
|
||||
};
|
||||
let bytes;
|
||||
try {
|
||||
bytes = reportBytes(report);
|
||||
const findings = await scanSecrets({
|
||||
runRoot: run.root,
|
||||
forbiddenValues: ctx.forbiddenValues,
|
||||
expectedGitRepositories: ctx.expectedGitRepositories,
|
||||
virtualFiles: [{ path: "report.json", bytes: bytes.json }, { path: "report.md", bytes: bytes.markdown }],
|
||||
});
|
||||
if (findings.length > 0) auditFailed = true;
|
||||
} catch {
|
||||
auditFailed = true;
|
||||
}
|
||||
if (auditFailed) {
|
||||
report = minimalFailClosedReport(run, keep);
|
||||
bytes = reportBytes(report);
|
||||
if (containsAny(bytes.json, ctx.forbiddenValues) || containsAny(bytes.markdown, ctx.forbiddenValues)) {
|
||||
throw new Error("sanitized fail-closed report unexpectedly contains a forbidden value");
|
||||
}
|
||||
}
|
||||
await atomicWrite(join(run.root, "report.json"), bytes.json);
|
||||
await atomicWrite(join(run.root, "report.md"), bytes.markdown);
|
||||
const finalSuccess = report.overall === "PASS";
|
||||
if (announce) await announce({ report, runRoot: run.root, keep });
|
||||
const removed = await finalizeOwnedRun({ run, success: finalSuccess, keep });
|
||||
return { exitCode: finalSuccess ? 0 : 1, runRoot: run.root, retained: !removed, report };
|
||||
async function listenerRefuses(baseUrl, timeoutMs = 1_000) {
|
||||
const url = new URL(baseUrl);
|
||||
return await new Promise((resolvePromise) => {
|
||||
const socket = new Socket(); let settled = false;
|
||||
const finish = (refuses) => { if (settled) return; settled = true; socket.destroy(); resolvePromise(refuses); };
|
||||
const timer = setTimeout(() => finish(false), timeoutMs); timer.unref();
|
||||
socket.once("connect", () => { clearTimeout(timer); finish(false); });
|
||||
socket.once("error", () => { clearTimeout(timer); finish(true); });
|
||||
try { socket.connect({ host: "127.0.0.1", port: Number(url.port) }); } catch { clearTimeout(timer); finish(true); }
|
||||
});
|
||||
}
|
||||
function environmentMatches(expected) {
|
||||
const currentKeys = Object.keys(process.env).sort(); const expectedKeys = Object.keys(expected).sort();
|
||||
return JSON.stringify(currentKeys) === JSON.stringify(expectedKeys)
|
||||
&& expectedKeys.every((key) => process.env[key] === expected[key]);
|
||||
}
|
||||
|
||||
export async function runIntegration({
|
||||
repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env,
|
||||
failAt = env.P1_ACCEPTANCE_FAIL_AT, checks, setup, announce, ownershipWriter = writeOwnership,
|
||||
} = {}) {
|
||||
if (integrationOwner) throw new Error("P1 acceptance integration is already active");
|
||||
const acquisitionToken = Symbol("p1-integration-owner");
|
||||
integrationOwner = acquisitionToken;
|
||||
const savedEnv = { ...process.env };
|
||||
let installedEnv; let run; let ctx; let results = []; let fatal; let auditFailed = false;
|
||||
try {
|
||||
try {
|
||||
run = await createOwnedRun({ repositoryRoot });
|
||||
ctx = {
|
||||
run, repositoryRoot, forbiddenValues: [], expectedGitRepositories: [], services: [],
|
||||
originalFetch: globalThis.fetch,
|
||||
};
|
||||
commandEventSink = [];
|
||||
const selectedSetup = setup ?? (checks === undefined ? setupContext : undefined);
|
||||
if (selectedSetup) {
|
||||
const configured = await selectedSetup(run, repositoryRoot, env, ctx);
|
||||
if (configured && configured !== ctx) Object.assign(ctx, configured);
|
||||
}
|
||||
if (checks === undefined) {
|
||||
if (!ctx.env || !ctx.executables) throw new Error("acceptance setup returned no bounded environment");
|
||||
installedEnv = { ...ctx.env };
|
||||
replaceProcessEnvironment(installedEnv);
|
||||
ctx.networkGuard = installProductionSurfaceGuard({
|
||||
...ctx.executables, runRoot: run.root, environment: installedEnv, originalFetch: ctx.originalFetch,
|
||||
});
|
||||
checks = productionChecks(ctx);
|
||||
} else if (ctx.env) {
|
||||
installedEnv = { ...ctx.env };
|
||||
replaceProcessEnvironment(installedEnv);
|
||||
}
|
||||
results = await executeChecks({ checks, failAt });
|
||||
} catch (error) {
|
||||
fatal = error;
|
||||
if (run) results = completeFailedResults(results);
|
||||
} finally {
|
||||
if (ctx?.services) {
|
||||
for (const service of [...ctx.services].reverse()) {
|
||||
const actualPort = Number(new URL(service.baseUrl).port);
|
||||
let closed = false; let closeError;
|
||||
try {
|
||||
await service.app.close();
|
||||
closed = await listenerRefuses(service.baseUrl);
|
||||
if (!closed) closeError = new Error("listener still accepts connections after close");
|
||||
} catch (error) { closeError = error; }
|
||||
const state = closed ? "closed" : "close_failed";
|
||||
try {
|
||||
await ownershipWriter(run, {
|
||||
name: service.name, kind: "fastify", host: "127.0.0.1", requestedPort: 0,
|
||||
actualPort, pid: process.pid, state,
|
||||
});
|
||||
} catch (error) { closeError ??= error; }
|
||||
if (closeError) { fatal ??= closeError; auditFailed = true; }
|
||||
}
|
||||
}
|
||||
try { ctx?.networkGuard?.restore(); } catch (error) { fatal ??= error; auditFailed = true; }
|
||||
if (installedEnv && !environmentMatches(installedEnv)) { fatal ??= new Error("acceptance environment ownership changed"); auditFailed = true; }
|
||||
try { replaceProcessEnvironment(savedEnv); } catch (error) { fatal ??= error; auditFailed = true; }
|
||||
if (!environmentMatches(savedEnv)) { fatal ??= new Error("acceptance environment restoration failed"); auditFailed = true; }
|
||||
}
|
||||
if (!run) throw fatal;
|
||||
results = completeFailedResults(results);
|
||||
|
||||
const commandEvents = commandEventSink ?? [];
|
||||
commandEventSink = undefined;
|
||||
activeCommandCheckId = undefined;
|
||||
try {
|
||||
assertUniqueResultArtifacts(results);
|
||||
const commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, events: commandEvents }, ctx.forbiddenValues);
|
||||
attachResultArtifact(results, "preflight", commandArtifact);
|
||||
if (ctx.networkGuard) {
|
||||
const executablePolicy = {};
|
||||
for (const [name, executablePath] of Object.entries(ctx.executables)) {
|
||||
executablePolicy[name] = { path: executablePath, sha256: sha256(await readFile(executablePath)) };
|
||||
}
|
||||
const childArtifact = await evidence(run, "logs/production-child-events.json", {
|
||||
executablePolicy, environmentPolicy: {
|
||||
PATH: ctx.env.PATH, HOME: ctx.env.HOME, TMPDIR: ctx.env.TMPDIR,
|
||||
gitGlobalConfigDisabled: true, gitSystemConfigDisabled: true, gitPromptsHelpersSigningDisabled: true,
|
||||
gitAllowedProtocol: "file", maxOutputBytes: MAX_OUTPUT, maxTimeoutMs: 300_000,
|
||||
},
|
||||
eventCount: ctx.networkGuard.events.length, events: ctx.networkGuard.events,
|
||||
}, ctx.forbiddenValues);
|
||||
attachResultArtifact(results, "no_p1_scope_artifacts", childArtifact);
|
||||
}
|
||||
if (ctx.gitTracePath) {
|
||||
const gitTraceBytes = await readFile(ctx.gitTracePath);
|
||||
for (const line of gitTraceBytes.toString("utf8").split("\n").filter(Boolean)) JSON.parse(line);
|
||||
attachResultArtifact(results, "no_p1_scope_artifacts", await fileArtifact(run.root, relative(run.root, ctx.gitTracePath)));
|
||||
}
|
||||
assertUniqueResultArtifacts(results);
|
||||
if (ctx.executables && !ctx.gitSecretScanFrozen) throw new Error("expected Git secret scan was not frozen inside secret_scan");
|
||||
} catch { auditFailed = true; }
|
||||
|
||||
let report = {
|
||||
schemaVersion: 1, runId: run.runId, startedAt: run.startedAt, finishedAt: nowIso(),
|
||||
command: `p1-acceptance integration${keep ? " --keep" : ""}`,
|
||||
overall: !fatal && !auditFailed && deriveOverall(results) === "PASS" ? "PASS" : "FAIL", checks: results,
|
||||
};
|
||||
let bytes;
|
||||
try {
|
||||
bytes = reportBytes(report);
|
||||
const findings = await scanSecrets({
|
||||
runRoot: run.root, forbiddenValues: ctx.forbiddenValues, expectedGitRepositories: [],
|
||||
virtualFiles: [{ path: "report.json", bytes: bytes.json }, { path: "report.md", bytes: bytes.markdown }],
|
||||
});
|
||||
if (findings.length > 0) throw new Error("final filesystem or virtual secret scan failed");
|
||||
await verifyDeclaredArtifacts(run.root, results);
|
||||
} catch { auditFailed = true; }
|
||||
if (auditFailed) {
|
||||
report = minimalFailClosedReport(run, keep);
|
||||
bytes = reportBytes(report);
|
||||
if (containsAny(bytes.json, ctx.forbiddenValues) || containsAny(bytes.markdown, ctx.forbiddenValues)) {
|
||||
throw new Error("sanitized fail-closed report unexpectedly contains a forbidden value");
|
||||
}
|
||||
}
|
||||
await atomicWrite(join(run.root, "report.json"), bytes.json);
|
||||
await atomicWrite(join(run.root, "report.md"), bytes.markdown);
|
||||
const finalSuccess = report.overall === "PASS";
|
||||
if (announce) await announce({ report, runRoot: run.root, keep });
|
||||
const removed = await finalizeOwnedRun({ run, success: finalSuccess, keep });
|
||||
return { exitCode: finalSuccess ? 0 : 1, runRoot: run.root, retained: !removed, report };
|
||||
} finally {
|
||||
commandEventSink = undefined;
|
||||
activeCommandCheckId = undefined;
|
||||
if (integrationOwner === acquisitionToken) integrationOwner = undefined;
|
||||
else if (integrationOwner !== undefined) throw new Error("P1 acceptance integration ownership changed");
|
||||
}
|
||||
}
|
||||
export async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
if (argv.length < 1 || argv[0] !== "integration" || argv.length > 2 || (argv.length === 2 && argv[1] !== "--keep")) {
|
||||
console.error("usage: p1-acceptance integration [--keep]"); return 2;
|
||||
|
||||
Reference in New Issue
Block a user