@clear-capabilities/agentic-security-scanner 0.166.0 → 0.168.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/bin/agentic-security.js +40 -4
- package/dist/2199.index.js +348 -0
- package/dist/3736.index.js +2 -1
- package/dist/3740.index.js +722 -0
- package/dist/4113.index.js +1 -1
- package/dist/4142.index.js +929 -0
- package/dist/5738.index.js +240 -0
- package/dist/6932.index.js +1190 -0
- package/dist/7838.index.js +1 -1
- package/dist/808.index.js +1330 -0
- package/dist/818.index.js +93 -0
- package/dist/9800.index.js +245 -0
- package/dist/9999.index.js +2 -2
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/posture/artifact-registry.js +6 -0
- package/src/posture/fleet/cli.js +103 -0
- package/src/posture/fleet/fleet-graph.js +142 -0
- package/src/posture/fleet/fleet-manifest.js +77 -0
- package/src/posture/proof-fabric/cli.js +115 -0
- package/src/posture/proof-fabric/fix-proof.js +73 -0
- package/src/posture/proof-fabric/oracles.js +101 -0
- package/src/posture/proof-fabric/orchestrator.js +129 -0
- package/src/posture/proof-fabric/reproducer.js +108 -0
- package/src/posture/proof-fabric/runner.js +243 -0
- package/src/posture/proof-fabric/states.js +53 -0
- package/src/posture/proof-fabric/store.js +83 -0
- package/src/posture/rule-compiler/cli.js +141 -0
- package/src/posture/rule-compiler/corpus.js +61 -0
- package/src/posture/rule-compiler/eligibility.js +25 -0
- package/src/posture/rule-compiler/ir.js +87 -0
- package/src/posture/rule-compiler/lifecycle.js +57 -0
- package/src/posture/rule-compiler/mutation.js +60 -0
- package/src/posture/rule-compiler/promotion.js +47 -0
- package/src/posture/rule-compiler/store.js +96 -0
- package/src/sandbox/backend-userspace.js +7 -1
- package/src/sandbox/trust-boundary.js +2 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Deterministic security oracles. A model may SELECT an oracle and propose its parameters; it may never decide that the
|
|
2
|
+
// oracle succeeded. Each oracle is a pure function of an Observation gathered by the TRUSTED runner (never by the model):
|
|
3
|
+
//
|
|
4
|
+
// Observation = {
|
|
5
|
+
// exitCode, signal, timedOut, stdout, stderr,
|
|
6
|
+
// canaries: { <name>: { exists, digestBefore, digestAfter } } // checked by the runner on disk, after the run
|
|
7
|
+
// callbacks: [{ nonce }] // hits recorded by the runner's loopback listener
|
|
8
|
+
// nonce // per-run random token the target cannot predict
|
|
9
|
+
// }
|
|
10
|
+
//
|
|
11
|
+
// An oracle returns { fired, reason }. `fired:false` with `inconclusive:true` means the observation could not decide
|
|
12
|
+
// (for example a timeout); it is never treated as "not exploitable".
|
|
13
|
+
export const ORACLE_TYPES = Object.freeze([
|
|
14
|
+
'crash_sanitizer', 'command_execution', 'unauthorized_http_access', 'unauthorized_db_read', 'database_mutation',
|
|
15
|
+
'filesystem_mutation', 'outbound_callback', 'authentication_bypass', 'arbitrary_assertion',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const SANITIZER_RE = /(AddressSanitizer|UndefinedBehaviorSanitizer|stack-buffer-overflow|heap-buffer-overflow|SEGV on unknown address|stack smashing detected|buffer overflow detected)/i;
|
|
19
|
+
const CRASH_SIGNALS = new Set(['SIGSEGV', 'SIGABRT', 'SIGBUS', 'SIGILL', 'SIGFPE']);
|
|
20
|
+
|
|
21
|
+
const out = (fired, reason, extra = {}) => ({ fired, reason, ...extra });
|
|
22
|
+
const inconclusive = (reason) => ({ fired: false, inconclusive: true, reason });
|
|
23
|
+
const has = (obs, text) => typeof text === 'string' && text.length > 0 && `${obs.stdout || ''}${obs.stderr || ''}`.includes(text);
|
|
24
|
+
|
|
25
|
+
const IMPL = {
|
|
26
|
+
crash_sanitizer(obs) {
|
|
27
|
+
if (SANITIZER_RE.test(obs.stderr || '')) return out(true, 'sanitizer or runtime memory-safety report on stderr');
|
|
28
|
+
if (obs.signal && CRASH_SIGNALS.has(obs.signal)) return out(true, `process terminated by ${obs.signal}`);
|
|
29
|
+
if (obs.timedOut) return inconclusive('run timed out before a crash could be judged');
|
|
30
|
+
return out(false, 'no crash signal and no sanitizer report');
|
|
31
|
+
},
|
|
32
|
+
// The injected payload asks the shell/interpreter to create a file named after the per-run nonce inside the
|
|
33
|
+
// watched canary directory. The target cannot create it unless attacker data reached a command.
|
|
34
|
+
command_execution(obs, p) {
|
|
35
|
+
const c = obs.canaries?.[p.canary || 'command'];
|
|
36
|
+
if (!c) return inconclusive('command canary was not armed by the runner');
|
|
37
|
+
if (c.exists && !c.existedBefore) return out(true, 'nonce-named canary file created by an injected command');
|
|
38
|
+
if (obs.timedOut) return inconclusive('run timed out before the canary could be judged');
|
|
39
|
+
return out(false, 'canary file not created');
|
|
40
|
+
},
|
|
41
|
+
// A secret planted behind authorization (never shown to the exploit client). Seeing the nonce-bearing secret in the
|
|
42
|
+
// client's output while the actor held no credential is the violation.
|
|
43
|
+
unauthorized_http_access(obs, p) {
|
|
44
|
+
const secret = obs.secrets?.[p.secret || 'http'];
|
|
45
|
+
if (!secret) return inconclusive('protected secret was not armed by the runner');
|
|
46
|
+
if (has(obs, secret)) return out(true, 'protected resource content returned to an unauthenticated actor');
|
|
47
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
48
|
+
return out(false, 'protected content not observed');
|
|
49
|
+
},
|
|
50
|
+
unauthorized_db_read(obs, p) {
|
|
51
|
+
const secret = obs.secrets?.[p.secret || 'db'];
|
|
52
|
+
if (!secret) return inconclusive('database canary row was not armed by the runner');
|
|
53
|
+
if (has(obs, secret)) return out(true, 'row the actor may not read was returned');
|
|
54
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
55
|
+
return out(false, 'canary row value not observed');
|
|
56
|
+
},
|
|
57
|
+
database_mutation(obs, p) {
|
|
58
|
+
const c = obs.canaries?.[p.canary || 'db'];
|
|
59
|
+
if (!c) return inconclusive('database canary was not armed by the runner');
|
|
60
|
+
if (c.digestBefore && c.digestAfter && c.digestBefore !== c.digestAfter) return out(true, 'protected database state changed');
|
|
61
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
62
|
+
return out(false, 'protected database state unchanged');
|
|
63
|
+
},
|
|
64
|
+
filesystem_mutation(obs, p) {
|
|
65
|
+
const c = obs.canaries?.[p.canary || 'fs'];
|
|
66
|
+
if (!c) return inconclusive('filesystem canary was not armed by the runner');
|
|
67
|
+
if ((c.exists && !c.existedBefore) || (c.digestBefore && c.digestAfter && c.digestBefore !== c.digestAfter)) return out(true, 'file outside the permitted area was created or modified');
|
|
68
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
69
|
+
return out(false, 'protected path unchanged');
|
|
70
|
+
},
|
|
71
|
+
outbound_callback(obs) {
|
|
72
|
+
if (obs.callbackUnavailable) return { fired: false, unsupported: true, reason: 'loopback callback listener is not available on this backend' };
|
|
73
|
+
const hit = (obs.callbacks || []).some((c) => c && c.nonce && c.nonce === obs.nonce);
|
|
74
|
+
if (hit) return out(true, 'nonce-bearing request reached the runner-controlled listener');
|
|
75
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
76
|
+
return out(false, 'no nonce-bearing callback received');
|
|
77
|
+
},
|
|
78
|
+
authentication_bypass(obs, p) {
|
|
79
|
+
const secret = obs.secrets?.[p.secret || 'auth'];
|
|
80
|
+
if (!secret) return inconclusive('protected secret was not armed by the runner');
|
|
81
|
+
if (has(obs, secret)) return out(true, 'privileged content returned without valid credentials');
|
|
82
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
83
|
+
return out(false, 'privileged content not observed without credentials');
|
|
84
|
+
},
|
|
85
|
+
// Declarative only: no code, no regex supplied by a model. Operators are a closed set.
|
|
86
|
+
arbitrary_assertion(obs, p) {
|
|
87
|
+
const a = p.assert;
|
|
88
|
+
if (!a || typeof a !== 'object') return inconclusive('assertion missing');
|
|
89
|
+
if (obs.timedOut) return inconclusive('run timed out');
|
|
90
|
+
if ('exitCode' in a) return a.exitCode === obs.exitCode ? out(true, `exit code was ${obs.exitCode}`) : out(false, `exit code was ${obs.exitCode}`);
|
|
91
|
+
if ('stdoutIncludes' in a) return has({ stdout: obs.stdout }, String(a.stdoutIncludes)) ? out(true, 'stdout contained the asserted text') : out(false, 'stdout did not contain the asserted text');
|
|
92
|
+
if ('stderrIncludes' in a) return has({ stderr: obs.stderr }, String(a.stderrIncludes)) ? out(true, 'stderr contained the asserted text') : out(false, 'stderr did not contain the asserted text');
|
|
93
|
+
return inconclusive('unsupported assertion operator');
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function evaluateOracle(spec, observation) {
|
|
98
|
+
if (!spec || !ORACLE_TYPES.includes(spec.type)) return { fired: false, unsupported: true, reason: `unknown oracle type: ${String(spec?.type)}` };
|
|
99
|
+
if (!observation || typeof observation !== 'object') return inconclusive('no observation');
|
|
100
|
+
try { return IMPL[spec.type](observation, spec.params || {}); } catch (e) { return inconclusive(`oracle error: ${e.message}`); }
|
|
101
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Proof orchestration: finding + revision + reproducer + policy -> structured evidence.
|
|
2
|
+
//
|
|
3
|
+
// Rules that make this safe to trust:
|
|
4
|
+
// * The model may propose a reproducer and select an oracle. The oracle (oracles.js) judges. Never a model.
|
|
5
|
+
// * An attempt that did not complete (timeout, build failure, blocked, unsupported, error) is NOT a negative and NOT a
|
|
6
|
+
// positive: it is degraded evidence, it never advances the state, and it makes the result INCOMPLETE.
|
|
7
|
+
// * EXPLOIT_CONFIRMED needs `required` successes in `attempts` completed attempts AND a pass from an independent
|
|
8
|
+
// verifier that receives only {revision, reproducer, findingId, oracle} in a fresh environment.
|
|
9
|
+
// * "Not reproduced" is reported as such; it is never reported as safe.
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { advance, fromProofTier, toProofTier, stateRank as stateRankOf } from './states.js';
|
|
12
|
+
import { runAttempt } from './runner.js';
|
|
13
|
+
import { validateReproducer, reproducerDigest, verifierView, sha256, canonicalize } from './reproducer.js';
|
|
14
|
+
import { proofTierOf } from '../proof-tier.js';
|
|
15
|
+
|
|
16
|
+
const PROOF_POLICY_DEFAULTS = Object.freeze({ attempts: 3, required: 3, independentReplay: true, timeoutMs: 20_000, cpus: 1, memoryMb: 512, network: 'none', requireContainer: false });
|
|
17
|
+
const RESULT_SCHEMA = 'agentic-security/proof-result';
|
|
18
|
+
|
|
19
|
+
function normalizePolicy(p = {}) {
|
|
20
|
+
const x = { ...PROOF_POLICY_DEFAULTS, ...p };
|
|
21
|
+
x.attempts = Math.max(1, Math.min(10, Math.trunc(Number(x.attempts)) || PROOF_POLICY_DEFAULTS.attempts));
|
|
22
|
+
x.required = Math.max(1, Math.min(x.attempts, Math.trunc(Number(x.required)) || x.attempts));
|
|
23
|
+
x.timeoutMs = Math.max(1000, Math.min(120_000, Number(x.timeoutMs) || PROOF_POLICY_DEFAULTS.timeoutMs));
|
|
24
|
+
return x;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const summarize = (a) => ({ status: a.status, fired: a.oracle?.fired === true, inconclusive: a.oracle?.inconclusive === true, reason: a.oracle?.reason ?? a.reasons?.[0] ?? null, durationMs: a.durationMs, degraded: a.degraded });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Independent verifier. Takes a verifierView ONLY; any extra property (a model's reasoning, prior results) is rejected so
|
|
31
|
+
* the isolation of its inputs is checkable rather than a convention.
|
|
32
|
+
*/
|
|
33
|
+
export async function independentVerify(view, opts = {}) {
|
|
34
|
+
const allowed = new Set(['revision', 'reproducer', 'findingId', 'oracle']);
|
|
35
|
+
const extra = Object.keys(view || {}).filter((k) => !allowed.has(k));
|
|
36
|
+
if (extra.length) return { status: 'rejected', reason: `verifier input must be exactly revision, reproducer, findingId, oracle; got extra: ${extra.join(', ')}` };
|
|
37
|
+
const attempt = await (opts.attemptFn ?? runAttempt)(structuredClone(view.reproducer), { ...opts, preferContainer: opts.preferContainer });
|
|
38
|
+
return { status: attempt.status === 'completed' ? (attempt.oracle?.fired ? 'confirmed' : 'not-reproduced') : 'incomplete', attempt: summarize(attempt), environment: attempt.environment ?? null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} a
|
|
43
|
+
* @param {object} a.finding the finding (id/stableId, severity, proofTier ...)
|
|
44
|
+
* @param {string} [a.revision] commit the proof applies to
|
|
45
|
+
* @param {object} a.reproducer serialized reproducer
|
|
46
|
+
* @param {object} [a.policy]
|
|
47
|
+
* @param {object} [a.deps] test seams: { attemptFn }
|
|
48
|
+
*/
|
|
49
|
+
export async function proveFinding({ finding, revision = null, reproducer, policy = {}, signal, deps = {}, sourceState = null, sourceRoot = null, overlay = null } = {}) {
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
const pol = normalizePolicy(policy);
|
|
52
|
+
const attemptFn = deps.attemptFn ?? runAttempt;
|
|
53
|
+
const findingId = finding?.stableId ?? finding?.id ?? reproducer?.findingId ?? 'unknown';
|
|
54
|
+
const degradation = [];
|
|
55
|
+
let state = sourceState ?? fromProofTier(proofTierOf(finding || {}));
|
|
56
|
+
if (state === 'REPRODUCED') state = 'STATICALLY_CORROBORATED'; // an old single-run marker proof must be re-earned here
|
|
57
|
+
const startState = state;
|
|
58
|
+
|
|
59
|
+
const result = (extra) => ({
|
|
60
|
+
schema: RESULT_SCHEMA, version: 1, findingId, revision, startState, state, staticCorroboration: stateRankOf(startState) >= 1, tier: toProofTier(state),
|
|
61
|
+
oracle: reproducer?.oracle?.type ?? null, policy: pol,
|
|
62
|
+
reproducerDigest: reproducer ? safeDigest(reproducer) : null,
|
|
63
|
+
degradation, timing: { proofMs: Date.now() - started }, ...extra,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const errs = reproducer ? validateReproducer(reproducer) : ['no reproducer supplied'];
|
|
67
|
+
if (errs.length) { degradation.push('invalid-reproducer'); return result({ completeness: 'INCOMPLETE', outcome: 'invalid-reproducer', reasons: errs, attempts: { required: pol.required, run: 0, successes: 0, incomplete: 0 }, independentReplay: { status: 'not-run' } }); }
|
|
68
|
+
|
|
69
|
+
// A valid reproducer is itself the REPRODUCER_GENERATED rung. Static corroboration is recorded, not required: an exploit
|
|
70
|
+
// that reproduces under an independent oracle is deterministic corroboration in its own right, and a pattern-only finding
|
|
71
|
+
// must not be unable to earn proof merely because the taint engine never saw it. The record says which it was.
|
|
72
|
+
const staticCorroboration = stateRankOf(startState) >= stateRankOf('STATICALLY_CORROBORATED');
|
|
73
|
+
if (!staticCorroboration) degradation.push('no-static-corroboration');
|
|
74
|
+
state = 'REPRODUCER_GENERATED';
|
|
75
|
+
|
|
76
|
+
const attempts = [];
|
|
77
|
+
for (let i = 0; i < pol.attempts; i++) {
|
|
78
|
+
if (signal?.aborted) { degradation.push('cancelled'); break; }
|
|
79
|
+
const a = await attemptFn(structuredClone(reproducer), { limits: { timeoutMs: pol.timeoutMs }, signal, requireContainer: pol.requireContainer, preferContainer: pol.preferContainer, force: pol.force, sourceRoot, overlay });
|
|
80
|
+
attempts.push(a);
|
|
81
|
+
// Stop early once the threshold can no longer be met only if every attempt is a decisive negative; incomplete runs continue.
|
|
82
|
+
}
|
|
83
|
+
const completed = attempts.filter((a) => a.status === 'completed' && !a.oracle?.inconclusive);
|
|
84
|
+
const successes = completed.filter((a) => a.oracle?.fired).length;
|
|
85
|
+
const incomplete = attempts.length - completed.length;
|
|
86
|
+
for (const a of attempts) for (const d of a.degraded || []) if (!degradation.includes(d)) degradation.push(d);
|
|
87
|
+
if (incomplete) degradation.push(`incomplete-attempts:${incomplete}`);
|
|
88
|
+
const env = attempts.find((a) => a.environment)?.environment ?? null;
|
|
89
|
+
const base = {
|
|
90
|
+
environment: env && { mode: env.mode, assurance: env.assurance, backend: env.backend ?? null, note: env.note ?? null },
|
|
91
|
+
build: attempts.some((a) => a.status === 'build-failed') ? 'failed' : attempts.length ? 'ok' : 'not-run',
|
|
92
|
+
attempts: { required: pol.required, run: attempts.length, successes, incomplete, detail: attempts.map(summarize) },
|
|
93
|
+
artifactHashes: { reproducer: safeDigest(reproducer), observations: sha256(canonicalize(attempts.map((a) => a.observation ?? null))) },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (successes > 0) {
|
|
97
|
+
const r = advance(state, 'REPRODUCED'); if (r.ok) state = r.state;
|
|
98
|
+
}
|
|
99
|
+
if (!attempts.length) return result({ ...base, completeness: 'INCOMPLETE', outcome: 'not-run', independentReplay: { status: 'not-run' } });
|
|
100
|
+
if (successes === 0) {
|
|
101
|
+
const decisive = completed.length === attempts.length;
|
|
102
|
+
return result({ ...base, completeness: decisive ? 'FULL' : 'INCOMPLETE', outcome: decisive ? 'not-reproduced' : 'inconclusive', note: 'not reproduced is not proof of safety', independentReplay: { status: 'not-run' } });
|
|
103
|
+
}
|
|
104
|
+
if (successes < pol.required || incomplete) {
|
|
105
|
+
return result({ ...base, completeness: incomplete ? 'INCOMPLETE' : 'DEGRADED', outcome: 'reproduced-below-threshold', independentReplay: { status: 'not-run' } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let independent = { status: 'skipped' };
|
|
109
|
+
if (pol.independentReplay) {
|
|
110
|
+
independent = await independentVerify(verifierView({ revision, reproducer, findingId, oracle: reproducer.oracle }), { attemptFn, limits: { timeoutMs: pol.timeoutMs }, signal, requireContainer: pol.requireContainer, force: pol.force, sourceRoot, overlay });
|
|
111
|
+
if (independent.status !== 'confirmed') degradation.push(`independent-replay:${independent.status}`);
|
|
112
|
+
} else degradation.push('independent-replay-disabled');
|
|
113
|
+
if (independent.status === 'confirmed') {
|
|
114
|
+
const r = advance(state, 'EXPLOIT_CONFIRMED'); if (r.ok) state = r.state;
|
|
115
|
+
}
|
|
116
|
+
const full = state === 'EXPLOIT_CONFIRMED' && base.environment?.assurance !== 'none';
|
|
117
|
+
return result({ ...base, independentReplay: independent, completeness: full ? (base.environment.assurance === 'lower' ? 'DEGRADED' : 'FULL') : 'INCOMPLETE', outcome: full ? 'exploit-confirmed' : 'reproduced-unverified' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const safeDigest = (r) => { try { return reproducerDigest(r); } catch { return null; } };
|
|
121
|
+
|
|
122
|
+
/** Severity policy: optional, never on by default. Returns the finding unchanged unless the policy asks to demote. */
|
|
123
|
+
export function applySeverityPolicy(finding, evidenceState, policy) {
|
|
124
|
+
const req = policy?.criticalRequires;
|
|
125
|
+
if (!Array.isArray(req) || !req.length || finding?.severity !== 'critical') return finding;
|
|
126
|
+
const ok = req.some((s) => stateRankOf(evidenceState ?? 'CANDIDATE') >= stateRankOf(s));
|
|
127
|
+
return ok ? finding : { ...finding, severity: 'high', severityPolicy: { demotedFrom: 'critical', reason: `critical requires ${req.join(' or ')}; evidence state is ${evidenceState ?? 'CANDIDATE'}` } };
|
|
128
|
+
}
|
|
129
|
+
export const _newRunId = () => randomBytes(6).toString('hex');
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Serialized reproducer: the ONLY thing an independent verifier receives (besides revision, finding id and oracle).
|
|
2
|
+
// It carries no reasoning, no model output and no prior results. Everything per-run (nonce, secrets, canary paths,
|
|
3
|
+
// callback URL) is a placeholder substituted fresh by the trusted runner, so a replay can never reuse a stale value
|
|
4
|
+
// and a target cannot learn the values in advance.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { ORACLE_TYPES } from './oracles.js';
|
|
7
|
+
|
|
8
|
+
const REPRODUCER_SCHEMA = 'agentic-security/reproducer';
|
|
9
|
+
const REPRODUCER_VERSION = 1;
|
|
10
|
+
export const LANGUAGES = Object.freeze(['javascript', 'typescript', 'python', 'java', 'c', 'cpp', 'csharp']);
|
|
11
|
+
export const MAX_FILES = 64;
|
|
12
|
+
export const MAX_FILE_BYTES = 256 * 1024;
|
|
13
|
+
const MAX_TOTAL_BYTES = 1024 * 1024;
|
|
14
|
+
const PLACEHOLDER_RE = /\{\{([A-Z_]+)(?::([a-z0-9_-]{1,32}))?\}\}/g;
|
|
15
|
+
const ALLOWED_PLACEHOLDERS = new Set(['NONCE', 'SECRET', 'CANARY', 'CALLBACK_URL', 'CALLBACK_PORT', 'ROOT', 'NODE', 'PYTHON', 'JAVA', 'JAVAC', 'CC']);
|
|
16
|
+
|
|
17
|
+
export function isSafeRelPath(rel) {
|
|
18
|
+
if (typeof rel !== 'string' || !rel || rel.length > 200) return false;
|
|
19
|
+
if (rel.startsWith('/') || rel.includes('\0') || rel.includes('\\')) return false;
|
|
20
|
+
if (['canary', 'tmp'].includes(rel.split('/')[0])) return false;
|
|
21
|
+
return rel.split('/').every((seg) => seg && seg !== '.' && seg !== '..' && !seg.startsWith('__oracle_'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Canonical JSON (sorted keys) so identical reproducers hash identically regardless of key order. */
|
|
25
|
+
export function canonicalize(v) {
|
|
26
|
+
if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`;
|
|
27
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(v[k])}`).join(',')}}`;
|
|
28
|
+
return JSON.stringify(v);
|
|
29
|
+
}
|
|
30
|
+
export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
31
|
+
export const reproducerDigest = (r) => sha256(canonicalize(r));
|
|
32
|
+
|
|
33
|
+
export function validateReproducer(r) {
|
|
34
|
+
const errs = [];
|
|
35
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return ['reproducer must be an object'];
|
|
36
|
+
if (r.schema !== REPRODUCER_SCHEMA) errs.push('unknown schema');
|
|
37
|
+
if (r.version !== REPRODUCER_VERSION) errs.push('unsupported reproducer version');
|
|
38
|
+
if (typeof r.findingId !== 'string' || !r.findingId || r.findingId.length > 200) errs.push('findingId required');
|
|
39
|
+
if (!LANGUAGES.includes(r.lang)) errs.push(`unsupported language: ${String(r.lang)}`);
|
|
40
|
+
if (!r.oracle || !ORACLE_TYPES.includes(r.oracle.type)) errs.push(`unknown oracle: ${String(r.oracle?.type)}`);
|
|
41
|
+
const files = r.files ?? {};
|
|
42
|
+
if (typeof files !== 'object' || Array.isArray(files)) errs.push('files must be an object');
|
|
43
|
+
else {
|
|
44
|
+
const names = Object.keys(files);
|
|
45
|
+
if (names.length > MAX_FILES) errs.push(`file count must be at most ${MAX_FILES}`);
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const n of names) {
|
|
48
|
+
if (!isSafeRelPath(n)) { errs.push(`unsafe file path: ${n}`); continue; }
|
|
49
|
+
if (typeof files[n] !== 'string') { errs.push(`file ${n} must be a string`); continue; }
|
|
50
|
+
const b = Buffer.byteLength(files[n]);
|
|
51
|
+
total += b;
|
|
52
|
+
if (b > MAX_FILE_BYTES) errs.push(`file ${n} exceeds ${MAX_FILE_BYTES} bytes`);
|
|
53
|
+
}
|
|
54
|
+
if (total > MAX_TOTAL_BYTES) errs.push('reproducer exceeds total size limit');
|
|
55
|
+
}
|
|
56
|
+
const sourceNames = r.sources && typeof r.sources === 'object' && !Array.isArray(r.sources) ? Object.keys(r.sources) : [];
|
|
57
|
+
if (Object.keys(files).length + sourceNames.length === 0) errs.push('reproducer must declare at least one file or source');
|
|
58
|
+
for (const key of ['build', 'run']) {
|
|
59
|
+
const a = r[key];
|
|
60
|
+
if (a == null && key === 'build') continue;
|
|
61
|
+
if (!Array.isArray(a) || !a.length || !a.every((x) => typeof x === 'string' && x.length < 2000 && !x.includes('\0'))) errs.push(`${key} must be a non-empty argv array of strings (no shell string)`);
|
|
62
|
+
}
|
|
63
|
+
for (const text of [...Object.values(files || {}), ...(r.build || []), ...(r.run || [])]) {
|
|
64
|
+
if (typeof text !== 'string') continue;
|
|
65
|
+
for (const m of text.matchAll(PLACEHOLDER_RE)) if (!ALLOWED_PLACEHOLDERS.has(m[1])) errs.push(`unknown placeholder {{${m[1]}}}`);
|
|
66
|
+
}
|
|
67
|
+
if (r.watch != null) {
|
|
68
|
+
if (typeof r.watch !== 'object' || Array.isArray(r.watch)) errs.push('watch must be an object');
|
|
69
|
+
else for (const [k, v] of Object.entries(r.watch)) if (!/^[a-z0-9_-]{1,32}$/.test(k) || !isSafeRelPath(v)) errs.push(`bad watch entry: ${k}`);
|
|
70
|
+
}
|
|
71
|
+
if (r.env != null) {
|
|
72
|
+
if (typeof r.env !== 'object' || Array.isArray(r.env)) errs.push('env must be an object');
|
|
73
|
+
else for (const [k, v] of Object.entries(r.env)) {
|
|
74
|
+
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(k)) errs.push(`bad env var name: ${k}`);
|
|
75
|
+
if (typeof v !== 'string' || v.length > 4096) errs.push(`env value for ${k} must be a short string`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (r.sources != null) {
|
|
79
|
+
if (typeof r.sources !== 'object' || Array.isArray(r.sources)) errs.push('sources must be an object');
|
|
80
|
+
else for (const [dest, src] of Object.entries(r.sources)) if (!isSafeRelPath(dest) || !isSafeRelPath(src)) errs.push(`bad sources entry: ${dest}`);
|
|
81
|
+
}
|
|
82
|
+
if (r.network != null && !['none', 'callback'].includes(r.network)) errs.push('network must be none or callback');
|
|
83
|
+
return errs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The view handed to an independent verifier: exactly the reproducer, plus identifiers. Nothing else. */
|
|
87
|
+
export function verifierView({ revision, reproducer, findingId, oracle }) {
|
|
88
|
+
return Object.freeze({ revision: revision ?? null, findingId, oracle, reproducer: structuredClone(reproducer) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function substitute(text, ctx) {
|
|
92
|
+
return text.replace(PLACEHOLDER_RE, (whole, name, arg) => {
|
|
93
|
+
switch (name) {
|
|
94
|
+
case 'NONCE': return ctx.nonce;
|
|
95
|
+
case 'SECRET': return ctx.secret(arg || 'default');
|
|
96
|
+
case 'CANARY': return ctx.canary(arg || 'default');
|
|
97
|
+
case 'CALLBACK_URL': return ctx.callbackUrl ?? whole;
|
|
98
|
+
case 'CALLBACK_PORT': return String(ctx.callbackPort ?? '');
|
|
99
|
+
case 'ROOT': return ctx.root;
|
|
100
|
+
case 'NODE': return ctx.tools.node;
|
|
101
|
+
case 'PYTHON': return ctx.tools.python ?? whole;
|
|
102
|
+
case 'JAVA': return ctx.tools.java ?? whole;
|
|
103
|
+
case 'JAVAC': return ctx.tools.javac ?? whole;
|
|
104
|
+
case 'CC': return ctx.tools.cc ?? whole;
|
|
105
|
+
default: return whole;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Hermetic execution of one reproducer attempt.
|
|
2
|
+
//
|
|
3
|
+
// Isolation preference: container (when a working runtime and a pre-pulled image exist) > local confined sandbox >
|
|
4
|
+
// unavailable. The local mode is LOWER assurance and says so in every result; it is never silently substituted for a
|
|
5
|
+
// container where a policy requires one (`requireContainer`). Nothing here ever executes target code unconfined.
|
|
6
|
+
//
|
|
7
|
+
// Defaults: outbound network denied; only a runner-controlled loopback callback listener may be reachable, and only when
|
|
8
|
+
// the reproducer asks for it; no privileged containers; bounded CPU, memory, disk, process count and wall time.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
13
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
14
|
+
import { runInBoundary } from '../../sandbox/trust-boundary.js';
|
|
15
|
+
import { detectBackend } from '../../sandbox/index.js';
|
|
16
|
+
import { evaluateOracle } from './oracles.js';
|
|
17
|
+
import { validateReproducer, substitute } from './reproducer.js';
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
20
|
+
timeoutMs: 20_000, buildTimeoutMs: 60_000, graceMs: 1000, maxOutputBytes: 256 * 1024,
|
|
21
|
+
cpus: 1, memoryMb: 512, pids: 128, diskMb: 64,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const CONTAINER_IMAGES = Object.freeze({
|
|
25
|
+
javascript: 'node:24-slim', typescript: 'node:24-slim', python: 'python:3-slim', java: 'eclipse-temurin:21',
|
|
26
|
+
c: 'gcc:latest', cpp: 'gcc:latest', csharp: 'mcr.microsoft.com/dotnet/sdk:8.0',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const SIGNALS = { 134: 'SIGABRT', 135: 'SIGBUS', 136: 'SIGFPE', 132: 'SIGILL', 139: 'SIGSEGV' };
|
|
30
|
+
|
|
31
|
+
function findTool(name, pathEnv = process.env.PATH || '') {
|
|
32
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
33
|
+
if (!dir) continue;
|
|
34
|
+
const p = path.join(dir, name);
|
|
35
|
+
try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return fs.realpathSync(p); } catch { /* next */ }
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hostTools() {
|
|
41
|
+
return { node: process.execPath, python: findTool('python3'), java: findTool('java'), javac: findTool('javac'), cc: findTool('cc') || findTool('gcc'), dotnet: findTool('dotnet') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Language -> tools it needs. Missing tools make the language `unsupported` with a typed reason, never a pass. */
|
|
45
|
+
export function languageSupport(lang, tools = hostTools()) {
|
|
46
|
+
const need = { javascript: ['node'], typescript: ['node'], python: ['python'], java: ['java', 'javac'], c: ['cc'], cpp: ['cc'], csharp: ['dotnet'] }[lang];
|
|
47
|
+
if (!need) return { supported: false, reason: `unsupported language: ${lang}` };
|
|
48
|
+
const missing = need.filter((t) => !tools[t]);
|
|
49
|
+
return missing.length ? { supported: false, reason: `missing toolchain: ${missing.join(', ')}` } : { supported: true, reason: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pick the execution environment. `docker` is injectable for tests. A container needs a responsive daemon AND the image
|
|
54
|
+
* already present: nothing is ever pulled (no network during proof).
|
|
55
|
+
*/
|
|
56
|
+
export function selectEnvironment({ lang, preferContainer = true, requireContainer = false, docker = null, tools = hostTools(), force } = {}) {
|
|
57
|
+
const dockerBin = docker?.bin ?? findTool('docker');
|
|
58
|
+
if (preferContainer && dockerBin) {
|
|
59
|
+
const image = docker?.images?.[lang] ?? CONTAINER_IMAGES[lang];
|
|
60
|
+
const probe = (docker?.probe ?? defaultDockerProbe)(dockerBin, image);
|
|
61
|
+
if (probe.ok) return { mode: 'container', assurance: 'high', runtime: dockerBin, image, reason: null };
|
|
62
|
+
if (requireContainer) return { mode: 'unavailable', assurance: 'none', reason: `container required but unavailable: ${probe.reason}` };
|
|
63
|
+
const local = localEnv(force);
|
|
64
|
+
return { ...local, containerSkipped: probe.reason };
|
|
65
|
+
}
|
|
66
|
+
if (requireContainer) return { mode: 'unavailable', assurance: 'none', reason: 'container required but no container runtime found' };
|
|
67
|
+
return localEnv(force);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function localEnv(force) {
|
|
71
|
+
const backend = detectBackend({ force });
|
|
72
|
+
if (backend === 'disabled') return { mode: 'unavailable', assurance: 'none', reason: 'no confinement backend works on this host; refusing to execute' };
|
|
73
|
+
return { mode: 'local-confined', assurance: 'lower', backend, reason: null, note: 'lower-assurance local confinement: not a container boundary' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultDockerProbe(bin, image) {
|
|
77
|
+
const info = spawnSync(bin, ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
78
|
+
if (info.status !== 0) return { ok: false, reason: 'container daemon not responding' };
|
|
79
|
+
const img = spawnSync(bin, ['image', 'inspect', image], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
|
|
80
|
+
return img.status === 0 ? { ok: true } : { ok: false, reason: `image ${image} is not present locally (proofs never pull)` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const digestFile = (p) => { try { return createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } catch { return null; } };
|
|
86
|
+
|
|
87
|
+
const LISTENER_SRC = `
|
|
88
|
+
const http = require('node:http'); const fs = require('node:fs');
|
|
89
|
+
const out = process.argv[1];
|
|
90
|
+
const s = http.createServer((req, res) => {
|
|
91
|
+
const m = /^\\/([A-Za-z0-9_-]{8,64})/.exec(req.url || '');
|
|
92
|
+
if (m) fs.appendFileSync(out, JSON.stringify({ nonce: m[1] }) + '\\n');
|
|
93
|
+
req.resume(); res.end('ok');
|
|
94
|
+
});
|
|
95
|
+
s.on('error', () => process.exit(2));
|
|
96
|
+
s.listen(0, '127.0.0.1', () => { process.stdout.write(String(s.address().port) + '\\n'); });
|
|
97
|
+
setTimeout(() => process.exit(0), 120000).unref();
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
async function startListener(dir) {
|
|
101
|
+
const hits = path.join(dir, 'hits.jsonl');
|
|
102
|
+
fs.writeFileSync(hits, '');
|
|
103
|
+
const child = spawn(process.execPath, ['-e', LISTENER_SRC, hits], { stdio: ['ignore', 'pipe', 'ignore'], detached: false });
|
|
104
|
+
const port = await new Promise((resolve) => {
|
|
105
|
+
let buf = '';
|
|
106
|
+
const t = setTimeout(() => resolve(null), 3000);
|
|
107
|
+
child.stdout.on('data', (d) => { buf += d; const n = Number(buf.trim()); if (n > 0) { clearTimeout(t); resolve(n); } });
|
|
108
|
+
child.on('exit', () => { clearTimeout(t); resolve(null); });
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
port, child,
|
|
112
|
+
hits: () => { try { return fs.readFileSync(hits, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } },
|
|
113
|
+
stop: () => { try { child.kill('SIGKILL'); } catch { /* gone */ } },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function trimOut(s, max) { s = String(s ?? ''); return s.length > max ? `${s.slice(0, max)}\n[truncated]` : s; }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run one attempt. Never throws. Result:
|
|
121
|
+
* { status, executed, environment, observation, oracle, durationMs, reasons[], degraded[], nonceDigest }
|
|
122
|
+
* status: 'completed' | 'build-failed' | 'blocked' | 'unsupported' | 'invalid' | 'error'
|
|
123
|
+
* Only `completed` carries an oracle verdict; everything else is incomplete evidence.
|
|
124
|
+
*/
|
|
125
|
+
export async function runAttempt(reproducer, opts = {}) {
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
|
|
128
|
+
const done = (o) => ({ executed: false, observation: null, oracle: null, degraded: [], reasons: [], ...o, durationMs: Date.now() - started });
|
|
129
|
+
|
|
130
|
+
const errs = validateReproducer(reproducer);
|
|
131
|
+
if (errs.length) return done({ status: 'invalid', reasons: errs });
|
|
132
|
+
const tools = opts.tools ?? hostTools();
|
|
133
|
+
const sup = languageSupport(reproducer.lang, tools);
|
|
134
|
+
if (!sup.supported) return done({ status: 'unsupported', reasons: [sup.reason] });
|
|
135
|
+
const env = opts.environment ?? selectEnvironment({ lang: reproducer.lang, requireContainer: opts.requireContainer, preferContainer: opts.preferContainer ?? true, force: opts.force });
|
|
136
|
+
if (env.mode === 'unavailable') return done({ status: 'blocked', environment: env, reasons: [env.reason] });
|
|
137
|
+
if (env.mode === 'container') {
|
|
138
|
+
return done({ status: 'blocked', environment: env, reasons: ['container execution is selected but the container runner is not enabled in this build; use requireContainer:false for local-confined proof'] });
|
|
139
|
+
}
|
|
140
|
+
if (reproducer.network === 'callback' && env.backend !== 'userspace') {
|
|
141
|
+
return done({ status: 'unsupported', environment: env, reasons: [`loopback callback needs the userspace backend; this host uses ${env.backend}`] });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const nonce = randomBytes(12).toString('hex');
|
|
145
|
+
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'proof-fabric-')));
|
|
146
|
+
const root = path.join(base, 'root');
|
|
147
|
+
const aux = path.join(base, 'aux');
|
|
148
|
+
fs.mkdirSync(root, { recursive: true });
|
|
149
|
+
fs.mkdirSync(path.join(root, 'canary'));
|
|
150
|
+
fs.mkdirSync(path.join(root, 'tmp'));
|
|
151
|
+
fs.mkdirSync(aux);
|
|
152
|
+
let listener = null;
|
|
153
|
+
try {
|
|
154
|
+
const secrets = {};
|
|
155
|
+
const canaries = {};
|
|
156
|
+
const ctx = {
|
|
157
|
+
nonce, root, tools,
|
|
158
|
+
secret: (n) => (secrets[n] ??= `SECRET-${nonce}-${n}`),
|
|
159
|
+
canary: (n) => { canaries[n] ??= { path: path.join(root, 'canary', `${nonce}-${n}`) }; return canaries[n].path; },
|
|
160
|
+
};
|
|
161
|
+
if (reproducer.network === 'callback') {
|
|
162
|
+
listener = await startListener(aux);
|
|
163
|
+
if (!listener.port) return done({ status: 'error', environment: env, reasons: ['could not start the loopback callback listener'] });
|
|
164
|
+
ctx.callbackPort = listener.port;
|
|
165
|
+
ctx.callbackUrl = `http://127.0.0.1:${listener.port}/${nonce}`;
|
|
166
|
+
}
|
|
167
|
+
// Repository sources imported at proof time (so a patch can be overlaid). Read-only, size-bounded, confined to sourceRoot.
|
|
168
|
+
const imported = {};
|
|
169
|
+
if (reproducer.sources) {
|
|
170
|
+
if (!opts.sourceRoot) return done({ status: 'invalid', environment: env, reasons: ['reproducer imports repository sources but no sourceRoot was given'] });
|
|
171
|
+
const realRoot = fs.realpathSync(opts.sourceRoot);
|
|
172
|
+
for (const [dest, src] of Object.entries(reproducer.sources)) {
|
|
173
|
+
let text;
|
|
174
|
+
if (opts.overlay && Object.hasOwn(opts.overlay, src)) text = String(opts.overlay[src]);
|
|
175
|
+
else {
|
|
176
|
+
const abs = path.resolve(realRoot, src);
|
|
177
|
+
let real; try { real = fs.realpathSync(abs); } catch { return done({ status: 'invalid', environment: env, reasons: [`source not found: ${src}`] }); }
|
|
178
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return done({ status: 'invalid', environment: env, reasons: [`source escapes the repository: ${src}`] });
|
|
179
|
+
const st = fs.statSync(real);
|
|
180
|
+
if (!st.isFile() || st.size > 256 * 1024) return done({ status: 'invalid', environment: env, reasons: [`source is not a regular file within the size limit: ${src}`] });
|
|
181
|
+
text = fs.readFileSync(real, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
imported[dest] = text;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const [rel, text] of Object.entries({ ...imported, ...reproducer.files })) {
|
|
187
|
+
const dest = path.join(root, rel);
|
|
188
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
189
|
+
fs.writeFileSync(dest, substitute(text, ctx));
|
|
190
|
+
}
|
|
191
|
+
const argv = (a) => a.map((x) => substitute(x, ctx));
|
|
192
|
+
const watch = {};
|
|
193
|
+
for (const [name, rel] of Object.entries(reproducer.watch || {})) {
|
|
194
|
+
const p = path.join(root, rel);
|
|
195
|
+
watch[name] = { path: p, existedBefore: fs.existsSync(p), digestBefore: digestFile(p) };
|
|
196
|
+
}
|
|
197
|
+
const runEnv = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin', HOME: root, TMPDIR: path.join(root, 'tmp'), LANG: 'C.UTF-8', CI: '1' };
|
|
198
|
+
for (const [k, v] of Object.entries(reproducer.env || {})) runEnv[k] = substitute(v, ctx);
|
|
199
|
+
const boundary = { root, timeoutMs: limits.timeoutMs, graceMs: limits.graceMs, maxOutputBytes: limits.maxOutputBytes, env: runEnv, force: opts.force, signal: opts.signal, controlProbes: opts.controlProbes };
|
|
200
|
+
|
|
201
|
+
const degraded = [];
|
|
202
|
+
if (env.assurance === 'lower') degraded.push('lower-assurance-local-confinement');
|
|
203
|
+
|
|
204
|
+
if (reproducer.build) {
|
|
205
|
+
const b = await runInBoundary(argv(reproducer.build), { ...boundary, timeoutMs: limits.buildTimeoutMs, cwd: undefined });
|
|
206
|
+
if (b.blocked) return done({ status: 'blocked', environment: env, reasons: b.reasons, degraded });
|
|
207
|
+
if (b.timedOut || b.exitCode !== 0) return done({ status: 'build-failed', executed: true, environment: env, reasons: [b.timedOut ? 'build timed out' : `build exited ${b.exitCode}`], degraded, observation: { buildStderr: trimOut(b.targetOutput?.stderr, 2000) } });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const r = await runInBoundary(argv(reproducer.run), { ...boundary, ...(listener ? { networkProxyPort: listener.port } : {}) });
|
|
211
|
+
if (r.blocked) return done({ status: 'blocked', environment: env, reasons: r.reasons, degraded });
|
|
212
|
+
if (!r.executed) return done({ status: 'error', environment: env, reasons: [`target did not execute (${r.status})`], degraded });
|
|
213
|
+
|
|
214
|
+
const exitCode = r.exitCode ?? null;
|
|
215
|
+
const observation = {
|
|
216
|
+
exitCode, signal: SIGNALS[exitCode] ?? null, timedOut: !!r.timedOut,
|
|
217
|
+
stdout: trimOut(r.targetOutput?.stdout, limits.maxOutputBytes), stderr: trimOut(r.targetOutput?.stderr, limits.maxOutputBytes),
|
|
218
|
+
nonce, secrets, canaries: {}, callbacks: listener ? listener.hits() : [],
|
|
219
|
+
};
|
|
220
|
+
for (const [n, c] of Object.entries(canaries)) observation.canaries[n] = { exists: fs.existsSync(c.path), existedBefore: false };
|
|
221
|
+
for (const [n, w] of Object.entries(watch)) observation.canaries[n] = { exists: fs.existsSync(w.path), existedBefore: w.existedBefore, digestBefore: w.digestBefore, digestAfter: digestFile(w.path) };
|
|
222
|
+
if (r.outputCapped) degraded.push('output-capped');
|
|
223
|
+
if (r.timedOut) degraded.push('timed-out');
|
|
224
|
+
if (r.termination?.survivors?.length) degraded.push('surviving-processes');
|
|
225
|
+
|
|
226
|
+
const oracle = evaluateOracle(reproducer.oracle, observation);
|
|
227
|
+
// Raw secrets/nonce must not leave this function: persisted evidence carries digests only.
|
|
228
|
+
const safeObs = { ...observation, secrets: Object.fromEntries(Object.keys(secrets).map((k) => [k, '[redacted]'])), nonce: '[redacted]', callbacks: observation.callbacks.length };
|
|
229
|
+
safeObs.stdout = scrub(safeObs.stdout, nonce); safeObs.stderr = scrub(safeObs.stderr, nonce);
|
|
230
|
+
return done({
|
|
231
|
+
status: oracle.unsupported ? 'unsupported' : 'completed', executed: true, environment: { ...env, backend: r.backend ?? env.backend },
|
|
232
|
+
observation: safeObs, oracle, degraded, reasons: oracle.unsupported ? [oracle.reason] : [], controls: r.controls,
|
|
233
|
+
nonceDigest: createHash('sha256').update(nonce).digest('hex').slice(0, 16),
|
|
234
|
+
});
|
|
235
|
+
} catch (e) {
|
|
236
|
+
return done({ status: 'error', environment: env, reasons: [`runner error: ${e.message}`] });
|
|
237
|
+
} finally {
|
|
238
|
+
if (listener) listener.stop();
|
|
239
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const scrub = (s, nonce) => String(s).split(nonce).join('[nonce]');
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Evidence state ladder for the Universal Exploit Proof Fabric.
|
|
2
|
+
//
|
|
3
|
+
// Evidence state is SEPARATE from severity and confidence. It extends, never replaces, the vocabulary that already
|
|
4
|
+
// exists: `proof-tier.js` (execution-proven / proof-failed / taint-proven / unproven) and the fix-verify legs
|
|
5
|
+
// (`verifiedFull`, `degradedLegs`). `toProofTier` / `fromProofTier` are the explicit bridges.
|
|
6
|
+
//
|
|
7
|
+
// Transitions are guarded: a state can only be reached from the state directly before it (or by dropping back), so no
|
|
8
|
+
// caller can jump a finding to EXPLOIT_CONFIRMED without passing REPRODUCED, or to FIX_VERIFIED without
|
|
9
|
+
// FIX_REPLAY_BLOCKED. A degraded or incomplete run NEVER advances a state.
|
|
10
|
+
export const EVIDENCE_STATES = Object.freeze([
|
|
11
|
+
'CANDIDATE',
|
|
12
|
+
'STATICALLY_CORROBORATED',
|
|
13
|
+
'REPRODUCER_GENERATED',
|
|
14
|
+
'REPRODUCED',
|
|
15
|
+
'EXPLOIT_CONFIRMED',
|
|
16
|
+
'FIX_REPLAY_BLOCKED',
|
|
17
|
+
'FIX_VERIFIED',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const stateRank = (s) => EVIDENCE_STATES.indexOf(s);
|
|
21
|
+
|
|
22
|
+
const REQUIRES_PREDECESSOR = Object.freeze(Object.fromEntries(EVIDENCE_STATES.map((s, i) => [s, i === 0 ? null : EVIDENCE_STATES[i - 1]])));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Advance `from` to `to`. Returns { ok, state, reason }. Moving up requires `to` to be exactly the next rung; moving
|
|
26
|
+
* down or staying is always allowed (evidence can be lost, never invented). Unknown states are rejected.
|
|
27
|
+
*/
|
|
28
|
+
export function advance(from, to) {
|
|
29
|
+
if (!EVIDENCE_STATES.includes(from)) return { ok: false, state: 'CANDIDATE', reason: `unknown current state: ${String(from)}` };
|
|
30
|
+
if (!EVIDENCE_STATES.includes(to)) return { ok: false, state: from, reason: `unknown target state: ${String(to)}` };
|
|
31
|
+
if (stateRank(to) <= stateRank(from)) return { ok: true, state: to, reason: null };
|
|
32
|
+
if (REQUIRES_PREDECESSOR[to] !== from) return { ok: false, state: from, reason: `cannot reach ${to} from ${from}: ${REQUIRES_PREDECESSOR[to]} is required first` };
|
|
33
|
+
return { ok: true, state: to, reason: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Bridges to the older vocabulary so existing consumers keep working.
|
|
37
|
+
export function toProofTier(state) {
|
|
38
|
+
const r = stateRank(state);
|
|
39
|
+
if (r >= stateRank('EXPLOIT_CONFIRMED')) return 'execution-proven';
|
|
40
|
+
if (r >= stateRank('STATICALLY_CORROBORATED')) return 'taint-proven';
|
|
41
|
+
return 'unproven';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function fromProofTier(tier) {
|
|
45
|
+
if (tier === 'taint-proven') return 'STATICALLY_CORROBORATED';
|
|
46
|
+
// execution-proven under the older single-run marker path is one run, not the 3/3 + independent replay the fabric
|
|
47
|
+
// requires, so it maps to REPRODUCED and never to EXPLOIT_CONFIRMED.
|
|
48
|
+
if (tier === 'execution-proven') return 'REPRODUCED';
|
|
49
|
+
return 'CANDIDATE';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Completeness vocabulary shared with fix-verify: FULL only when nothing was skipped.
|
|
53
|
+
export const COMPLETENESS = Object.freeze(['FULL', 'DEGRADED', 'INCOMPLETE']);
|