@clear-capabilities/agentic-security-scanner 0.175.0 → 0.176.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/agentic-security.js +199 -28
  3. package/dist/2066.index.js +850 -0
  4. package/dist/2199.index.js +1 -1
  5. package/dist/2584.index.js +3332 -0
  6. package/dist/2923.index.js +13 -0
  7. package/dist/2946.index.js +81 -0
  8. package/dist/3196.index.js +27 -0
  9. package/dist/3727.index.js +292 -0
  10. package/dist/4168.index.js +509 -0
  11. package/dist/4281.index.js +993 -0
  12. package/dist/4978bec8fdf5c733dc85.json +283 -0
  13. package/dist/5099.index.js +96 -0
  14. package/dist/5616.index.js +1471 -0
  15. package/dist/5620.index.js +2370 -0
  16. package/dist/5817.index.js +714 -0
  17. package/dist/6333.index.js +98 -0
  18. package/dist/6655.index.js +3221 -0
  19. package/dist/7838.index.js +446 -974
  20. package/dist/818.index.js +4 -3
  21. package/dist/83.index.js +159 -0
  22. package/dist/8551.index.js +82 -0
  23. package/dist/8633.index.js +93 -0
  24. package/dist/8714.index.js +76 -76
  25. package/dist/8825.index.js +84 -0
  26. package/dist/932.index.js +186 -0
  27. package/dist/9560.index.js +358 -19
  28. package/dist/9800.index.js +6 -1
  29. package/dist/agentic-security.mjs +10 -10
  30. package/dist/agentic-security.mjs.sha256 +1 -1
  31. package/dist/df1e8683e7eda7b2b79c.js +29 -0
  32. package/package.json +5 -4
  33. package/src/artifact/CLAUDE.md +4 -0
  34. package/src/artifact/artifact-evidence.js +63 -0
  35. package/src/artifact/cosign.js +61 -0
  36. package/src/artifact/engine-source.js +72 -0
  37. package/src/artifact/oci-layout.js +47 -0
  38. package/src/artifact/registry.js +213 -0
  39. package/src/capabilities/tool-registry.js +2 -0
  40. package/src/lineage/CLAUDE.md +4 -0
  41. package/src/lineage/identity-token.js +11 -0
  42. package/src/lineage/observation-adapters.js +13 -0
  43. package/src/lineage/runtime-edge-states.js +139 -0
  44. package/src/lineage/runtime-explorer-view.js +82 -0
  45. package/src/lineage/runtime-observation.js +11 -0
  46. package/src/mcp/CLAUDE.md +3 -1
  47. package/src/mcp/proof-tools.js +132 -0
  48. package/src/mcp/server.js +1 -1
  49. package/src/mcp/tools.js +3 -1
  50. package/src/posture/CLAUDE.md +23 -0
  51. package/src/posture/artifact-registry.js +4 -0
  52. package/src/posture/dnr/attack-facts.js +31 -0
  53. package/src/posture/dnr/cli.js +138 -0
  54. package/src/posture/dnr/detection-ir.js +123 -0
  55. package/src/posture/dnr/exposure-window.js +134 -0
  56. package/src/posture/dnr/hunt.js +84 -0
  57. package/src/posture/dnr/log-reader.js +189 -0
  58. package/src/posture/dnr/matchers.js +90 -0
  59. package/src/posture/dnr/outputs.js +262 -0
  60. package/src/posture/dnr/payload-classes.js +120 -0
  61. package/src/posture/dnr/redact.js +25 -0
  62. package/src/posture/dnr/store.js +57 -0
  63. package/src/posture/dnr/validate.js +64 -0
  64. package/src/posture/dnr/view.js +51 -0
  65. package/src/posture/evaluation/lab/arm-ollama.js +88 -0
  66. package/src/posture/evaluation/lab/arms.js +142 -0
  67. package/src/posture/evaluation/lab/cli.js +135 -0
  68. package/src/posture/evaluation/lab/dataset.js +139 -0
  69. package/src/posture/evaluation/lab/materialize.js +44 -0
  70. package/src/posture/evaluation/lab/neutralize.js +144 -0
  71. package/src/posture/evaluation/lab/report.js +176 -0
  72. package/src/posture/evaluation/lab/run.js +225 -0
  73. package/src/posture/evaluation/lab/score.js +135 -0
  74. package/src/posture/evaluation/lab/scoreboard.js +48 -0
  75. package/src/posture/evaluation/lab/stats.js +41 -0
  76. package/src/posture/evaluation/lab/store.js +53 -0
  77. package/src/posture/fleet/cli.js +248 -63
  78. package/src/posture/fleet/discover-edges.js +135 -0
  79. package/src/posture/fleet/enterprise-graph.js +148 -0
  80. package/src/posture/fleet/fleet-graph.js +9 -4
  81. package/src/posture/fleet/fleet-manifest.js +50 -5
  82. package/src/posture/fleet/job-runner.js +153 -0
  83. package/src/posture/fleet/job-store.js +136 -0
  84. package/src/posture/fleet/org-health.js +56 -0
  85. package/src/posture/fleet/scan-cache.js +112 -0
  86. package/src/posture/fleet/scan-child.js +29 -0
  87. package/src/posture/proof-fabric/cli.js +34 -6
  88. package/src/posture/proof-fabric/container.js +57 -0
  89. package/src/posture/proof-fabric/fix-proof.js +2 -1
  90. package/src/posture/proof-fabric/generate-python.js +129 -0
  91. package/src/posture/proof-fabric/generate.js +213 -0
  92. package/src/posture/proof-fabric/reproducer.js +3 -0
  93. package/src/posture/proof-fabric/runner.js +20 -11
  94. package/src/posture/proof-fabric/siblings.js +77 -0
  95. package/src/posture/routing/policy-router.js +103 -0
  96. package/src/posture/rule-compiler/cli.js +64 -6
  97. package/src/posture/rule-compiler/corpus.js +10 -2
  98. package/src/posture/rule-compiler/mutation.js +72 -1
  99. package/src/posture/rule-compiler/promotion.js +21 -5
  100. package/src/posture/rule-compiler/proposal.js +132 -0
  101. package/src/posture/rule-compiler/store.js +16 -0
@@ -0,0 +1,56 @@
1
+ // Organization-level scan health (PRD 4, requirement 9). Pure.
2
+ //
3
+ // Repositories requested: 1,250
4
+ // Complete: 1,228 Partial: 11 Failed: 6 Timed out: 5 Not run: ...
5
+ //
6
+ // The one rule: an organization is not GREEN while a required repository has no usable result. A repository that was requested but
7
+ // has no job, was cancelled, is still queued or leased, failed or timed out is counted under its own heading and can never disappear
8
+ // from the total. `partial` (the scan finished but the engine itself reported a degraded scan) keeps the organization out of GREEN
9
+ // as well, because a partial scan is not evidence of a clean repository.
10
+ //
11
+ // green every required repository has a complete result and no optional repository has a problem
12
+ // degraded every required repository has a result, but some result is partial, or an optional repository has a problem
13
+ // red at least one required repository has no result
14
+ import { isTerminal } from './job-store.js';
15
+
16
+ export const ASSURANCE = Object.freeze(['green', 'degraded', 'red']);
17
+
18
+ export function computeOrgHealth({ manifest, jobs }) {
19
+ const byRepo = new Map(jobs.map((j) => [j.repoId, j]));
20
+ const counts = { requested: manifest.repositories.length, complete: 0, partial: 0, failed: 0, timedOut: 0, cancelled: 0, notRun: 0 };
21
+ const repos = [];
22
+ for (const r of manifest.repositories) {
23
+ const j = byRepo.get(r.id);
24
+ let state;
25
+ if (!j) state = 'not-run';
26
+ else if (j.status === 'succeeded') state = 'complete';
27
+ else if (j.status === 'partial') state = 'partial';
28
+ else if (j.status === 'failed') state = 'failed';
29
+ else if (j.status === 'timed_out') state = 'timed-out';
30
+ else if (j.status === 'cancelled') state = 'cancelled';
31
+ else state = 'not-run'; // queued or leased: no result yet
32
+ counts[{ complete: 'complete', partial: 'partial', failed: 'failed', 'timed-out': 'timedOut', cancelled: 'cancelled', 'not-run': 'notRun' }[state]]++;
33
+ repos.push({ id: r.id, required: r.required, state, attempts: j?.attempts ?? 0, error: j?.error ?? null, cached: !!j?.summary?.cached, findings: j?.summary?.total ?? null, jobStatus: j?.status ?? null, finished: j ? isTerminal(j.status) : false });
34
+ }
35
+ const noResult = (x) => !['complete', 'partial'].includes(x.state);
36
+ const requiredMissing = repos.filter((x) => x.required && noResult(x));
37
+ const requiredPartial = repos.filter((x) => x.required && x.state === 'partial');
38
+ const optionalProblems = repos.filter((x) => !x.required && x.state !== 'complete');
39
+ const assurance = requiredMissing.length ? 'red' : (requiredPartial.length || optionalProblems.length) ? 'degraded' : 'green';
40
+ const reasons = [
41
+ ...(requiredMissing.length ? [`${requiredMissing.length} required ${requiredMissing.length === 1 ? 'repository has' : 'repositories have'} no result: ${requiredMissing.slice(0, 8).map((x) => `${x.id} (${x.state})`).join(', ')}${requiredMissing.length > 8 ? ', ...' : ''}`] : []),
42
+ ...(requiredPartial.length ? [`${requiredPartial.length} required ${requiredPartial.length === 1 ? 'repository was' : 'repositories were'} only partially scanned: ${requiredPartial.map((x) => x.id).join(', ')}`] : []),
43
+ ...(optionalProblems.length ? [`${optionalProblems.length} optional ${optionalProblems.length === 1 ? 'repository has' : 'repositories have'} a problem: ${optionalProblems.map((x) => `${x.id} (${x.state})`).join(', ')}`] : []),
44
+ ];
45
+ return { counts, assurance, reasons, repositories: repos };
46
+ }
47
+
48
+ const n = (x) => x.toLocaleString('en-US');
49
+ export function renderOrgHealth(h) {
50
+ const c = h.counts;
51
+ const L = [`Repositories requested: ${n(c.requested)}`, `Complete: ${n(c.complete)}`, `Partial: ${n(c.partial)}`, `Failed: ${n(c.failed)}`, `Timed out: ${n(c.timedOut)}`];
52
+ if (c.cancelled) L.push(`Cancelled: ${n(c.cancelled)}`);
53
+ if (c.notRun) L.push(`Not run: ${n(c.notRun)}`);
54
+ L.push('', `Assurance: ${h.assurance.toUpperCase()}`, ...h.reasons.map((r) => ` ${r}`));
55
+ return L.join('\n');
56
+ }
@@ -0,0 +1,112 @@
1
+ // The content-addressed scan cache (PRD 4, requirement 5). A repository's scan result is reused only when EVERYTHING that could
2
+ // change it is unchanged: the commit (and any uncommitted change), the lockfiles, the scan configuration and the scanner itself.
3
+ //
4
+ // key = sha256( repository fingerprint, lockfile digests, configuration, scanner identity )
5
+ //
6
+ // Anything that cannot be determined makes the key `null`, and a null key means "do not cache": an unknown input must never be
7
+ // treated as an unchanged one. The scanner identity is the package version plus the SHA-256 recorded for the shipped bundle, so a
8
+ // change to scanner logic changes the key. A source checkout with no built bundle has no such identity and therefore never reuses
9
+ // a cached result.
10
+ //
11
+ // A cached record carries its own digest; one that was edited, truncated or does not match its key is a miss, never a hit.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { STATE_DIR_NAME } from '../state-dir.js';
18
+
19
+ const LOCKFILES = Object.freeze(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'go.sum', 'Cargo.lock', 'Pipfile.lock', 'poetry.lock', 'requirements.txt', 'Gemfile.lock', 'composer.lock', 'flake.lock', 'cabal.project.freeze', 'stack.yaml.lock']);
20
+ const SKIP = new Set(['.git', 'node_modules', STATE_DIR_NAME]);
21
+ const sha = (s) => createHash('sha256').update(s).digest('hex');
22
+ const MAX_FILES = 5000; const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_TOTAL_BYTES = 128 * 1024 * 1024;
23
+
24
+ function git(repo, args, timeout = 10000) {
25
+ const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
26
+ return r.error || r.status !== 0 ? null : r.stdout;
27
+ }
28
+
29
+ function treeDigest(root) {
30
+ const h = createHash('sha256'); let files = 0; let total = 0; let complete = true;
31
+ const walk = (d, rel) => {
32
+ let ents = [];
33
+ try { ents = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { complete = false; return; }
34
+ for (const e of ents) {
35
+ if (!complete) return;
36
+ if (SKIP.has(e.name)) continue;
37
+ const p = path.join(d, e.name); const r = rel ? `${rel}/${e.name}` : e.name;
38
+ if (e.isSymbolicLink()) { h.update(`L ${r}\0`); continue; }
39
+ if (e.isDirectory()) { walk(p, r); continue; }
40
+ if (!e.isFile()) continue;
41
+ if (++files > MAX_FILES) { complete = false; return; }
42
+ let st; try { st = fs.statSync(p); } catch { complete = false; return; }
43
+ total += st.size;
44
+ if (total > MAX_TOTAL_BYTES) { complete = false; return; }
45
+ if (st.size > MAX_FILE_BYTES) { h.update(`B ${r} ${st.size}\0`); continue; }
46
+ try { h.update(`F ${r}\0${sha(fs.readFileSync(p))}\0`); } catch { complete = false; return; }
47
+ }
48
+ };
49
+ walk(root, '');
50
+ return complete ? h.digest('hex') : null;
51
+ }
52
+
53
+ /** What identifies this repository's content right now, or null parts where it cannot be determined. */
54
+ export function repoFingerprint(repoPath) {
55
+ const head = git(repoPath, ['rev-parse', 'HEAD']);
56
+ const commit = head ? head.trim() : null;
57
+ let content = null; let dirty = null;
58
+ if (commit) {
59
+ const status = git(repoPath, ['status', '--porcelain=v1', '-uall']);
60
+ dirty = status == null ? null : status.trim().length > 0;
61
+ if (dirty === false) content = `git:${commit}`;
62
+ else if (dirty === true) { const t = treeDigest(repoPath); content = t ? `git+worktree:${commit}:${t}` : null; }
63
+ } else { const t = treeDigest(repoPath); content = t ? `tree:${t}` : null; }
64
+ const lock = {};
65
+ for (const f of LOCKFILES) { try { const st = fs.statSync(path.join(repoPath, f)); if (st.isFile() && st.size <= 32 * 1024 * 1024) lock[f] = sha(fs.readFileSync(path.join(repoPath, f))); } catch { /* absent */ } }
66
+ return { commit, dirty, content, lockfiles: lock };
67
+ }
68
+
69
+ /** Package version + the bundle's recorded hash. Null when the scanner cannot be identified (a source checkout), which disables the cache. */
70
+ export function scannerIdentity() {
71
+ try {
72
+ const here = path.dirname(fileURLToPath(import.meta.url));
73
+ for (const base of [path.join(here, '..', '..', '..'), path.join(here, '..', '..', '..', '..')]) {
74
+ const pkg = path.join(base, 'package.json');
75
+ const sidecar = path.join(base, 'dist', 'agentic-security.mjs.sha256');
76
+ if (fs.existsSync(pkg) && fs.existsSync(sidecar)) {
77
+ const version = JSON.parse(fs.readFileSync(pkg, 'utf8')).version;
78
+ const digest = fs.readFileSync(sidecar, 'utf8').trim().split(/\s+/)[0];
79
+ if (version && /^[0-9a-f]{64}$/i.test(digest)) return `${version}+${digest}`;
80
+ }
81
+ }
82
+ } catch { /* unknown */ }
83
+ return null;
84
+ }
85
+
86
+ export function cacheKey({ fingerprint, config, identity }) {
87
+ if (!fingerprint?.content || !identity) return null;
88
+ return sha(JSON.stringify({ c: fingerprint.content, l: fingerprint.lockfiles, cfg: config, id: identity }));
89
+ }
90
+
91
+ export function readCache(dir, key) {
92
+ if (!key || !/^[0-9a-f]{64}$/.test(key)) return null;
93
+ try {
94
+ const doc = JSON.parse(fs.readFileSync(path.join(dir, `${key}.json`), 'utf8'));
95
+ if (doc.key !== key) return null;
96
+ const { digest, ...rest } = doc;
97
+ return digest === sha(JSON.stringify(rest)) ? doc : null;
98
+ } catch { return null; }
99
+ }
100
+
101
+ export function writeCache(dir, key, payload) {
102
+ if (!key || !/^[0-9a-f]{64}$/.test(key)) return false;
103
+ try {
104
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
105
+ const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, ...payload };
106
+ const doc = { ...body, digest: sha(JSON.stringify(body)) };
107
+ const tmp = path.join(dir, `.${key}.${process.pid}.tmp`);
108
+ fs.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
109
+ fs.renameSync(tmp, path.join(dir, `${key}.json`));
110
+ return true;
111
+ } catch { return false; }
112
+ }
@@ -0,0 +1,29 @@
1
+ // Child-process entry for ONE repository's scan inside a fleet run (PRD 4, requirement 4: one hung repository must not stop the
2
+ // fleet). `node scan-child.js <repoPath>`. It exists as its own process so the controller can kill a scan that does not finish,
3
+ // together with everything the scan started; an in-process scan cannot be interrupted.
4
+ //
5
+ // It runs the scan through the existing `runFleet` for a single repository, so the per-repository entry (finding counts, owners
6
+ // from CODEOWNERS, scan health, policy drift, provenance debt) is exactly what a fleet entry has always been, and prints that entry
7
+ // plus a slim copy of the findings between markers. Scan state writes are disabled (`AGENTIC_SECURITY_NO_STATE=1`, set by the
8
+ // controller): scanning a repository for the fleet must not change that repository.
9
+ import { runFleet } from '../fleet.js';
10
+ import { runScan } from '../../runScan.js';
11
+
12
+ export const BEGIN = '@@FLEET-SCAN-BEGIN@@';
13
+ export const END = '@@FLEET-SCAN-END@@';
14
+ const MAX_FINDINGS = 5000;
15
+
16
+ const slim = (f) => ({ id: f.id ?? null, stableId: f.stableId ?? null, severity: f.severity ?? null, cwe: f.cwe ?? null, family: f.family ?? null, vuln: f.vuln ?? null, file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, parser: f.parser ?? null });
17
+
18
+ async function main(repoPath) {
19
+ let captured = null;
20
+ const res = await runFleet({ repos: [repoPath], concurrency: 1, resume: false, runScan: async (p) => { const r = await runScan(p, { provenance: false }); captured = r.scan; return r; } });
21
+ const entry = res.results?.[0];
22
+ if (!entry) throw new Error('the scan produced no entry');
23
+ const findings = (captured?.findings ?? []).slice(0, MAX_FINDINGS).map(slim);
24
+ process.stdout.write(`${BEGIN}${JSON.stringify({ entry, findings, findingsTruncated: (captured?.findings?.length ?? 0) > MAX_FINDINGS })}${END}`);
25
+ }
26
+
27
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
28
+ main(process.argv[2]).then(() => process.exit(0), (e) => { process.stderr.write(String(e?.stack || e).slice(0, 4000)); process.exit(1); });
29
+ }
@@ -10,6 +10,7 @@ import { saveProof, latestProof, listProofs, findingKey, scrubDeep } from './sto
10
10
  import { selectEnvironment, hostTools, languageSupport } from './runner.js';
11
11
  import { validateReproducer } from './reproducer.js';
12
12
  import { LANGUAGES } from './reproducer.js';
13
+ import { generateReproducer } from './generate.js';
13
14
 
14
15
  function loadFinding(root, id) {
15
16
  const p = statePath(root, 'last-scan.json');
@@ -18,7 +19,17 @@ function loadFinding(root, id) {
18
19
  return (last.findings || []).find((f) => f.id === id || f.stableId === id) ?? null;
19
20
  }
20
21
 
21
- export function loadReproducerFor(root, id, flagPath) { return loadReproducer(root, id, flagPath); }
22
+ /**
23
+ * The reproducer for a finding: the one the person or agent supplied (or stored), else one built from the finding's own evidence.
24
+ * A generated reproducer is a proposal; it earns nothing until it is executed, and the result says it was generated.
25
+ */
26
+ export function resolveReproducer(root, finding, flagPath) {
27
+ const supplied = loadReproducer(root, finding.stableId ?? finding.id, flagPath) ?? (finding.stableId && finding.id !== finding.stableId ? loadReproducer(root, finding.id, flagPath) : null);
28
+ if (supplied) return { ...supplied, generated: null };
29
+ const g = generateReproducer({ finding, root });
30
+ if (!g.ok) return { reproducer: null, reason: g.reason };
31
+ return { reproducer: g.reproducer, file: null, generated: g.basis };
32
+ }
22
33
 
23
34
  function loadReproducer(root, id, flagPath) {
24
35
  const candidates = [flagPath && path.resolve(String(flagPath)), statePath(root, 'reproducers', `${findingKey(id)}.json`)].filter(Boolean);
@@ -49,8 +60,9 @@ export async function cmdProve(args, root, { json = false } = {}) {
49
60
  if (!id) { console.error('usage: triage exploit <finding-id> --prove [--reproducer file] [--attempts N] [--required N]'); return 4; }
50
61
  const finding = loadFinding(root, id);
51
62
  if (!finding) { console.error(`Finding ${id} not found in last scan.`); return 4; }
52
- const loaded = loadReproducer(root, id, args.flags.reproducer);
53
- if (!loaded) { console.error(`No reproducer for ${id}. Supply one with --reproducer <file> or place it at .agentic-security/reproducers/. Nothing was executed.`); return 4; }
63
+ const loaded = resolveReproducer(root, finding, args.flags.reproducer);
64
+ if (!loaded.reproducer) { console.error(`No reproducer for ${id}, and none could be generated from the finding: ${loaded.reason}. Supply one with --reproducer <file> or place it at .agentic-security/reproducers/. Nothing was executed.`); return 4; }
65
+ if (loaded.generated) console.error(`Generated a reproducer from the finding (${loaded.generated.shape}, ${loaded.generated.class}). It is a proposal: it counts only if executing it confirms the exploit.`);
54
66
  const errs = validateReproducer(loaded.reproducer);
55
67
  if (errs.length) { console.error(`Invalid reproducer: ${errs.join('; ')}`); return 4; }
56
68
  const policy = {};
@@ -62,8 +74,8 @@ export async function cmdProve(args, root, { json = false } = {}) {
62
74
  const onSig = () => ac.abort();
63
75
  process.once('SIGINT', onSig);
64
76
  let result;
65
- try { result = await proveFinding({ finding, reproducer: loaded.reproducer, revision: args.flags.revision ?? null, policy, signal: ac.signal }); } finally { process.off('SIGINT', onSig); }
66
- const saved = saveProof(root, { ...result, reproducer: loaded.reproducer });
77
+ try { result = await proveFinding({ finding, reproducer: loaded.reproducer, revision: args.flags.revision ?? null, policy, signal: ac.signal, sourceRoot: root }); } finally { process.off('SIGINT', onSig); }
78
+ const saved = saveProof(root, { ...result, reproducer: loaded.reproducer, ...(loaded.generated ? { generatedFrom: loaded.generated } : {}) });
67
79
  // Printed output gets the same secret-scrubbing pass as what is persisted (defense in depth: a free-text reason/note
68
80
  // field that captured something from stdout/stderr should never reach a terminal unredacted either).
69
81
  if (json || args.flags.json) console.log(JSON.stringify(scrubDeep({ ...result, stored: saved.ok ? saved.file : null }), null, 2));
@@ -74,8 +86,19 @@ export async function cmdProve(args, root, { json = false } = {}) {
74
86
  export async function cmdProof(args, root) {
75
87
  const sub = args._[1];
76
88
  if (sub === 'doctor') return doctor(args);
89
+ if (sub === 'generate') {
90
+ const gid = String(args._[2] ?? '');
91
+ const finding = gid ? loadFinding(root, gid) : null;
92
+ if (!finding) { console.error(gid ? `Finding ${gid} not found in last scan.` : 'usage: proof generate <finding-id> [--out file]'); return 4; }
93
+ const g = generateReproducer({ finding, root });
94
+ if (!g.ok) { console.error(`No reproducer could be generated: ${g.reason}`); return 1; }
95
+ const text = JSON.stringify({ ...g.reproducer, findingId: gid }, null, 2);
96
+ if (args.flags.out) { fs.writeFileSync(path.resolve(String(args.flags.out)), `${text}\n`); console.log(`Wrote ${path.resolve(String(args.flags.out))} (${g.basis.shape}, ${g.basis.class}). Nothing was executed.`); } else console.log(text);
97
+ for (const a of g.basis.assumptions) console.error(`assumption: ${a}`);
98
+ return 0;
99
+ }
77
100
  const id = String(args._[2] ?? '');
78
- if (!id) { console.error('usage: proof show <finding-id> | proof replay <finding-id> | proof doctor'); return 4; }
101
+ if (!id) { console.error('usage: proof show <finding-id> | proof replay <finding-id> | proof generate <finding-id> | proof doctor'); return 4; }
79
102
  if (sub === 'show') {
80
103
  const all = listProofs(root, id);
81
104
  if (!all.length) { console.error(`No stored proof for ${id}.`); return 4; }
@@ -84,6 +107,11 @@ export async function cmdProof(args, root) {
84
107
  const good = all.find((p) => p.record && !p.tampered);
85
108
  if (!good) { console.error('No intact proof record.'); return 1; }
86
109
  if (args.flags.json) console.log(JSON.stringify(good.record, null, 2)); else console.log(formatResult(good.record));
110
+ // the detection generated from this finding's proof (detection-and-response loop), when there is one
111
+ try {
112
+ const det = (await import('../dnr/store.js')).latestRecord(root, id, 'detection');
113
+ if (det && !args.flags.json) console.log(`Detection: ${det.record.ir?.id ?? 'unknown'} (${det.record.validation?.status ?? 'not validated'}${det.record.enabled ? ', enabled' : ''})`);
114
+ } catch { /* the proof output does not depend on it */ }
87
115
  void latest;
88
116
  return 0;
89
117
  }
@@ -0,0 +1,57 @@
1
+ // Container execution for the proof fabric (PRD 1, requirement 2). The runner prefers a container when a responsive runtime and a
2
+ // locally present image exist, and it never pulls an image: nothing is fetched during a proof.
3
+ //
4
+ // What the container is allowed to be, by construction (`containerArgs`, pure and tested flag by flag):
5
+ // * no network at all (`--network none`); a reproducer that needs the loopback callback listener cannot run in a container and says so;
6
+ // * not privileged, no capabilities (`--cap-drop ALL`), no privilege escalation (`no-new-privileges`), a read-only root filesystem;
7
+ // * bounded CPU, memory (swap = memory), process count and a small writable /tmp;
8
+ // * one bind mount: the proof's own scratch directory, as `/work`. Nothing of the host's home, repository or credentials;
9
+ // * the host user's uid and gid, never root;
10
+ // * the environment is exactly the reproducer's, plus a minimal PATH/HOME.
11
+ // The container is named, and removed (`rm -f`) on every exit path including timeout and cancellation, because killing the `docker`
12
+ // client does not by itself stop the container.
13
+ //
14
+ // HONEST LIMIT: this has been exercised against a fake engine that records the arguments and runs the command locally, which proves
15
+ // the plumbing and the flags. It has not been run against a real Docker or Podman daemon in this repository's test environment.
16
+ import { spawnSync } from 'node:child_process';
17
+ import { superviseSpawn } from '../../sandbox/supervise.js';
18
+ import { scrubEnv } from '../../sandbox/trust-domains.js';
19
+
20
+ export const CONTAINER_ROOT = '/work';
21
+ // the interpreter names inside the image; the host paths the runner uses locally mean nothing in there
22
+ export const CONTAINER_TOOLS = Object.freeze({ node: 'node', python: 'python3', java: 'java', javac: 'javac', cc: 'gcc', dotnet: 'dotnet' });
23
+
24
+ export function containerArgs({ image, name, hostRoot, argv, env = {}, limits, user = null }) {
25
+ if (!image || !name || !hostRoot || !Array.isArray(argv) || !argv.length) throw new Error('container run needs image, name, root and argv');
26
+ const uid = user ?? (typeof process.getuid === 'function' ? `${process.getuid()}:${process.getgid()}` : '65534:65534');
27
+ const args = [
28
+ 'run', '--rm', '--pull', 'never', '--name', name,
29
+ '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
30
+ '--pids-limit', String(limits.pids), '--memory', `${limits.memoryMb}m`, '--memory-swap', `${limits.memoryMb}m`, '--cpus', String(limits.cpus),
31
+ '--tmpfs', `/tmp:rw,nosuid,size=${limits.diskMb}m`, '--user', uid,
32
+ '-v', `${hostRoot}:${CONTAINER_ROOT}:rw`, '--workdir', CONTAINER_ROOT,
33
+ '-e', 'HOME=/work', '-e', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', '-e', 'CI=1',
34
+ ];
35
+ for (const [k, v] of Object.entries(env)) args.push('-e', `${k}=${v}`);
36
+ args.push(image, ...argv);
37
+ return args;
38
+ }
39
+
40
+ /** Run one command in a container. Returns the same shape `runInBoundary` does for the fields the runner reads. Never throws. */
41
+ export async function runInContainer({ runtime, image, name, hostRoot, argv, env, limits, timeoutMs, signal, maxOutputBytes, user }) {
42
+ const args = containerArgs({ image, name, hostRoot, argv, env, limits, user });
43
+ const cleanup = () => { try { spawnSync(runtime, ['rm', '-f', name], { timeout: 10_000, killSignal: 'SIGKILL', stdio: 'ignore' }); } catch { /* best effort */ } };
44
+ let r;
45
+ try {
46
+ r = await superviseSpawn(runtime, args, { cwd: hostRoot, // the client needs its own configuration (DOCKER_HOST, contexts, XDG paths); credential-looking names are dropped
47
+ env: scrubEnv(process.env), timeoutMs, signal, maxOutputBytes, graceMs: 1000 });
48
+ } finally { cleanup(); }
49
+ if (r.spawnError) return { blocked: true, reasons: [`could not start the container runtime: ${r.spawnError}`] };
50
+ if (r.cancelled) return { blocked: true, reasons: ['cancelled'] };
51
+ // a runtime failure (image gone, daemon died) is exit 125/126/127 from the client before the target ran: that is not the target's verdict
52
+ if (!r.timedOut && [125, 126, 127].includes(r.exitCode)) return { blocked: true, reasons: [`the container runtime could not run the command (exit ${r.exitCode}): ${String(r.stderr).trim().split('\n').pop()?.slice(0, 160) ?? ''}`] };
53
+ return {
54
+ executed: true, status: 'completed', blocked: false, exitCode: r.exitCode, timedOut: r.timedOut, outputCapped: r.outputCapped,
55
+ targetOutput: { stdout: r.stdout, stderr: r.stderr }, termination: r.termination, backend: 'container',
56
+ };
57
+ }
@@ -12,7 +12,7 @@ import { proveFinding } from './orchestrator.js';
12
12
  import { runAttempt } from './runner.js';
13
13
  import { reproducerDigest, sha256, canonicalize } from './reproducer.js';
14
14
 
15
- export async function verifyFixWithProof({ finding, reproducer, files, scanRoot, revision = null, policy = {}, staticVerification = null, siblings = null, signal, deps = {} } = {}) {
15
+ export async function verifyFixWithProof({ finding, reproducer, files, scanRoot, revision = null, policy = {}, staticVerification = null, siblings = null, siblingSearchTruncated = [], signal, deps = {} } = {}) {
16
16
  const t0 = Date.now();
17
17
  const attemptFn = deps.attemptFn ?? runAttempt;
18
18
  const patchHash = sha256(canonicalize(files || {}));
@@ -52,6 +52,7 @@ export async function verifyFixWithProof({ finding, reproducer, files, scanRoot,
52
52
  const siblingCount = Array.isArray(siblings) ? siblings.length : null;
53
53
  if (siblingCount === null) degraded.push('sibling-search-not-run');
54
54
  else if (siblingCount > 0) degraded.push(`sibling-exposures:${siblingCount}`);
55
+ if (siblingCount !== null && siblingSearchTruncated?.length) degraded.push('sibling-search-incomplete');
55
56
 
56
57
  const full = degraded.length === 0;
57
58
  // Same split as fix-verify: `ok` (nothing that ran failed) earns FIX_VERIFIED; `completeness` says whether anything was skipped.
@@ -0,0 +1,129 @@
1
+ // Reproducer generation for Python (PRD 1), the second language of `generate.js`. Same contract: a DETERMINISTIC proposal built
2
+ // from the flagged file alone, then run through the ordinary runner, oracle, 3-of-3 repetition and independent replay. A generated
3
+ // reproducer is a proposal that execution either confirms or does not.
4
+ //
5
+ // Python is parsed by Python itself (`python3 -I -c`, the standard `ast` module, source on stdin, JSON on stdout) with a timeout, so
6
+ // the shape rules follow the real grammar. Two shapes, as in JavaScript:
7
+ // script the file reads `sys.argv[1]`: it is run with the payload as its argument (whether the flagged line is reached is then
8
+ // decided by execution, not assumed);
9
+ // function a module-level `def` around the flagged line: a generated harness imports the file (never as `__main__`, so its main
10
+ // guard does not run) and calls the function with the payload in every parameter.
11
+ // Only the standard library and sibling `.py` files in the flagged file's directory can be satisfied; any other import is refused,
12
+ // with the package named. Anything outside returns `{ ok: false }` with the reason, and nothing is executed.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { validateReproducer } from './reproducer.js';
17
+
18
+ const MAX_SOURCE = 256 * 1024;
19
+ const MAX_FILES = 8;
20
+ const fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
21
+
22
+ const ANALYZER = `
23
+ import ast, json, sys
24
+ src = sys.stdin.read()
25
+ try:
26
+ tree = ast.parse(src)
27
+ except SyntaxError as e:
28
+ print(json.dumps({"error": "syntax"})); sys.exit(0)
29
+ line = int(sys.argv[1])
30
+ imports = []
31
+ for n in ast.walk(tree):
32
+ if isinstance(n, ast.Import):
33
+ for a in n.names: imports.append({"module": a.name, "level": 0})
34
+ elif isinstance(n, ast.ImportFrom):
35
+ imports.append({"module": n.module or "", "level": n.level or 0, "names": [a.name for a in n.names]})
36
+ fns = []
37
+ for n in ast.walk(tree):
38
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.lineno <= line <= (n.end_lineno or n.lineno):
39
+ args = [a.arg for a in n.args.posonlyargs + n.args.args + n.args.kwonlyargs]
40
+ fns.append({"name": n.name, "start": n.lineno, "end": n.end_lineno, "args": args, "async": isinstance(n, ast.AsyncFunctionDef), "decorated": bool(n.decorator_list), "toplevel": n in tree.body, "defaults": len(n.args.defaults)})
41
+ fns.sort(key=lambda f: f["end"] - f["start"])
42
+ main_guard = any(isinstance(n, ast.If) and "__name__" in ast.dump(n.test) for n in tree.body)
43
+ print(json.dumps({"imports": imports, "functions": fns, "mainGuard": main_guard, "stdlib": sorted(sys.stdlib_module_names) if hasattr(sys, "stdlib_module_names") else None}))
44
+ `;
45
+
46
+ function analyze(text, line) {
47
+ const r = spawnSync('python3', ['-I', '-c', ANALYZER, String(line)], { input: text, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
48
+ if (r.error || r.status !== 0) return null;
49
+ try { return JSON.parse(r.stdout); } catch { return null; }
50
+ }
51
+
52
+ function readInside(root, rel) {
53
+ let real; try { real = fs.realpathSync(path.resolve(root, rel)); } catch { return null; }
54
+ const base = fs.realpathSync(root);
55
+ if (!(real === base || real.startsWith(base + path.sep))) return null;
56
+ const st = fs.statSync(real);
57
+ return st.isFile() && st.size <= MAX_SOURCE ? { real, text: fs.readFileSync(real, 'utf8') } : null;
58
+ }
59
+
60
+ const HARNESS = (entry, name, args) => `# Generated by the proof fabric from the finding's own evidence. It imports the flagged module (never as __main__) and calls the function.
61
+ import importlib.util, sys, asyncio, inspect
62
+ payload = sys.argv[1] if len(sys.argv) > 1 else ""
63
+ spec = importlib.util.spec_from_file_location("target", ${JSON.stringify(entry)})
64
+ mod = importlib.util.module_from_spec(spec)
65
+ sys.modules["target"] = mod
66
+ spec.loader.exec_module(mod)
67
+ fn = getattr(mod, ${JSON.stringify(name)}, None)
68
+ if not callable(fn):
69
+ print("the function is not defined at module level", file=sys.stderr); sys.exit(3)
70
+ try:
71
+ res = fn(${args.join(', ')})
72
+ if inspect.isawaitable(res): asyncio.run(asyncio.wait_for(res, 5))
73
+ except Exception as e:
74
+ print("target raised:", str(e)[:200], file=sys.stderr)
75
+ `;
76
+
77
+ /** @param {{finding: object, root: string, cls: {name: string, payload: string, oracle: object, network?: string, watch?: object, needsPy?: RegExp}}} o */
78
+ export function generatePythonReproducer({ finding, root, cls }) {
79
+ const rel = String(finding.file);
80
+ const src = readInside(root, rel);
81
+ if (!src) return fail('the flagged file is missing, outside the repository, or too large');
82
+ const info = analyze(src.text, finding.line);
83
+ if (!info || info.error) return fail('the flagged file could not be parsed by Python (is python3 installed and is the file valid Python 3?)');
84
+ const lines = src.text.split('\n');
85
+ const sinkLine = lines[finding.line - 1] ?? '';
86
+ const around = lines.slice(Math.max(0, finding.line - 3), finding.line + 2).join('\n');
87
+ if (cls.needsPy && !cls.needsPy.test(sinkLine) && !cls.needsPy.test(around)) return fail(`${cls.name}: the flagged code is not a write, and this class is only proven for write sinks`);
88
+
89
+ const stdlib = new Set(info.stdlib ?? []);
90
+ if (!info.stdlib) return fail('this Python is too old to list its standard library (3.10 or newer is needed), so imports cannot be classified');
91
+ const dir = path.dirname(src.real); const rootReal = fs.realpathSync(root);
92
+ const sources = {}; const external = [];
93
+ for (const imp of info.imports) {
94
+ const top = imp.module.split('.')[0];
95
+ if (imp.level === 0 && stdlib.has(top)) continue;
96
+ const sibling = imp.level <= 1 ? path.join(dir, `${top || (imp.names?.[0] ?? '')}.py`) : null;
97
+ if (sibling && fs.existsSync(sibling)) {
98
+ const dep = readInside(root, path.relative(rootReal, fs.realpathSync(sibling)));
99
+ if (!dep) return fail(`"${top}" is too large or outside the repository`);
100
+ const destRel = path.relative(dir, dep.real);
101
+ if (!sources[destRel]) { if (Object.keys(sources).length >= MAX_FILES) return fail(`the file pulls in more than ${MAX_FILES} repository files`); sources[destRel] = path.relative(rootReal, dep.real); }
102
+ continue;
103
+ }
104
+ external.push(top || '.'.repeat(imp.level));
105
+ }
106
+ if (external.length) return fail(`the flagged code needs ${[...new Set(external)].slice(0, 4).join(', ')}, which is not available in the proof environment; only the standard library and sibling files in the same directory can be satisfied`);
107
+
108
+ const entry = 'target.py';
109
+ sources[entry] = rel;
110
+ const assumptions = [];
111
+ const base = { schema: 'agentic-security/reproducer', version: 1, findingId: String(finding.stableId ?? finding.id ?? 'generated'), lang: 'python', oracle: cls.oracle, network: cls.network ?? 'none', ...(cls.watch ? { watch: cls.watch } : {}) };
112
+ if (cls.watch) assumptions.push('the write target is assumed to be one directory below the working directory (the payload is ../escaped.txt); if the code uses another base, the proof will not confirm it');
113
+
114
+ if (/sys\.argv\s*\[\s*1\s*\]/.test(src.text)) {
115
+ const reproducer = { ...base, sources, run: ['{{PYTHON}}', entry, cls.payload] };
116
+ const errs = validateReproducer(reproducer);
117
+ return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: cls.name, entry: rel, line: finding.line, assumptions } };
118
+ }
119
+ const fn = info.functions.find((f) => f.toplevel);
120
+ if (!fn) return fail('the flagged line is not inside a module-level function, and the file does not read sys.argv[1]');
121
+ if (fn.decorated) return fail(`${fn.name} is decorated (a web framework route, a cache, a wrapper): calling it directly would not match how it is reached, and the framework is not available`);
122
+ if (!fn.args.length) return fail('the function around the flagged line takes no parameters, so the payload has no way in');
123
+ if (fn.async) assumptions.push('the function is a coroutine; the harness awaits it for at most 5 seconds');
124
+ assumptions.push('the payload is passed in every parameter (a Python parameter has no request-like stand-in), which is an approximation of how the function is really called');
125
+ const harness = HARNESS(entry, fn.name, fn.args.map(() => 'payload'));
126
+ const reproducer = { ...base, sources, files: { 'harness.py': harness }, run: ['{{PYTHON}}', 'harness.py', cls.payload] };
127
+ const errs = validateReproducer(reproducer);
128
+ return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'function', class: cls.name, entry: rel, line: finding.line, function: `${fn.name}(${fn.args.join(', ')})`, assumptions } };
129
+ }