@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
@@ -1,35 +1,62 @@
1
- // `agentic-security fleet init|scan|status|resume|query`.
1
+ // `agentic-security fleet init|scan|status|resume|query|explore`.
2
2
  //
3
- // Reuses the existing posture/fleet.js (runFleet: bounded concurrency, resumable checkpoint, per-repo isolation,
4
- // honest rollup) for the scanning half — this module adds only the manifest-driven entry point and the
5
- // cross-repository dependency graph (fleet-graph.js) that runFleet has no notion of.
3
+ // The scanning half is the job fabric: a manifest becomes one idempotent job per repository in a local SQLite store, workers lease
4
+ // them, every scan runs in a supervised child under a hard timeout, and `resume` continues a run after anything stops it. The
5
+ // per-repository entry and the roll-up are the existing `posture/fleet.js` ones (counts, owners, scan health, delta), so a fleet
6
+ // report reads as it always has, with the organization's health and the enterprise graph added on top.
7
+ //
8
+ // Exit codes: 0 the organization is GREEN, 1 it is not (a required repository has no result, or a result is partial), 4 usage.
6
9
  import fs from 'node:fs';
7
10
  import path from 'node:path';
11
+ import { createHash } from 'node:crypto';
8
12
  import { loadFleetManifest, starterManifestYaml } from './fleet-manifest.js';
9
13
  import { buildFleetDependencyGraph, blastRadius } from './fleet-graph.js';
10
- import { runFleet, rollupFleet, renderFleetSummary } from '../fleet.js';
14
+ import { rollupFleet, renderFleetSummary } from '../fleet.js';
11
15
  import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
16
+ import { createSqliteJobStore } from './job-store.js';
17
+ import { planRun, runJobs, loadArtifacts, reclaimDeadWorkers, childScan } from './job-runner.js';
18
+ import { computeOrgHealth, renderOrgHealth } from './org-health.js';
19
+ import { discoverEdges, splitDeclared } from './discover-edges.js';
20
+ import { buildEnterpriseGraph, finalizeGraph, validateEnterpriseGraph, attackPaths, vulnerableSet, componentBlastRadius, renderBlastRadius, INTERNET } from './enterprise-graph.js';
12
21
 
13
22
  const GRAPH_FILE = 'fleet-graph.json';
14
- const STATE_FILE = 'fleet-state.json';
23
+ const ENTERPRISE_FILE = path.join('fleet', 'enterprise-graph.json');
24
+ const DB_FILE = path.join('fleet', 'fleet.db');
25
+ const ROLLUP_FILE = 'fleet-rollup.json';
26
+ const sha = (s) => createHash('sha256').update(s).digest('hex');
15
27
 
16
- function graphPath(root) { return statePath(root, GRAPH_FILE); }
17
- function statePathFor(root) { return statePath(root, STATE_FILE); }
28
+ const usage = () => { console.error('usage: fleet init | edges [--undeclared] | scan <manifest.yaml> [--workers N] [--retries N] [--timeout-seconds N | --timeout-ms N] [--no-cache] | status [run-id] | resume <run-id|manifest.yaml> [--retry-failed] | query (--depends-on <id> | --blast-radius <component> [--version v] | --internet-to <data-class> [--involving-vulnerable auth|<repo>] | "<question>") | explore [--out file.html]'); return 4; };
29
+ const isManifestArg = (s) => /\.ya?ml$/i.test(String(s ?? ''));
30
+ const manifestArg = (args) => { const m = args._[2]; return m && !String(m).startsWith('--') ? m : 'fleet.yaml'; };
31
+ const dbPath = (root) => statePath(root, DB_FILE);
18
32
 
19
33
  export async function cmdFleet(args, root) {
20
34
  const sub = args._[1];
21
35
  if (sub === 'init') return cmdInit(args, root);
22
36
  if (sub === 'scan') return cmdScan(args, root);
23
37
  if (sub === 'status') return cmdStatus(args, root);
24
- if (sub === 'resume') return cmdScan(args, root, { resumeOnly: true });
38
+ if (sub === 'resume') return cmdResume(args, root);
25
39
  if (sub === 'query') return cmdQuery(args, root);
26
- console.error('usage: fleet init | scan <manifest.yaml> | status <manifest.yaml> | resume <manifest.yaml> | query --depends-on <repo-id> [<manifest.yaml>]');
27
- return 4;
40
+ if (sub === 'explore') return cmdExplore(args, root);
41
+ if (sub === 'edges') return cmdEdges(args, root);
42
+ return usage();
28
43
  }
29
44
 
30
- function manifestArg(args) {
31
- const m = args._[2];
32
- return m && !String(m).startsWith('--') ? m : 'fleet.yaml';
45
+ /** `fleet edges [--undeclared] [--json]`: every edge of the last enterprise graph with the evidence it rests on; --undeclared keeps the ones found only in code. */
46
+ async function cmdEdges(args, root) {
47
+ const f = statePath(root, ENTERPRISE_FILE);
48
+ if (!fs.existsSync(f)) { console.error('No enterprise graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
49
+ const g = JSON.parse(fs.readFileSync(f, 'utf8'));
50
+ const edges = g.edges.filter((e) => e.type !== 'exposes' && (!args.flags.undeclared || e.discovered));
51
+ if (args.flags.json) { console.log(JSON.stringify({ discovery: g.discovery ?? null, edges }, null, 2)); return 0; }
52
+ if (!edges.length) { console.log(args.flags.undeclared ? 'No edge was found in code that the manifest does not declare.' : 'The graph has no edges.'); }
53
+ for (const e of edges) {
54
+ const where = e.evidence.filter((x) => x.source === 'discovered-code-literal').map((x) => `${x.file}:${x.line}`);
55
+ console.log(`${e.from} -[${e.type}]-> ${e.to} ${e.discovered ? 'FOUND ONLY IN CODE' : where.length ? 'declared, and seen in code' : 'declared'}${where.length ? ` (${where.join(', ')})` : ''}`);
56
+ }
57
+ if (g.discovery?.limitations) console.log(`\nLimits: ${g.discovery.limitations.join(' ')}`);
58
+ else if (g.discovery?.note) console.log(`\nEdge discovery: ${g.discovery.note}.`);
59
+ return 0;
33
60
  }
34
61
 
35
62
  async function cmdInit(args, root) {
@@ -40,64 +67,222 @@ async function cmdInit(args, root) {
40
67
  return 0;
41
68
  }
42
69
 
43
- async function cmdScan(args, root, { resumeOnly = false } = {}) {
70
+ function overrides(manifest, args) {
71
+ const m = { ...manifest };
72
+ const num = (v, lo, hi) => { const n = Number(v); return Number.isInteger(n) && n >= lo && n <= hi ? n : null; };
73
+ if (args.flags.workers !== undefined) { const n = num(args.flags.workers, 1, 32); if (n == null) return { error: '--workers must be an integer from 1 to 32' }; m.workers = n; }
74
+ if (args.flags.retries !== undefined) { const n = num(args.flags.retries, 0, 5); if (n == null) return { error: '--retries must be an integer from 0 to 5' }; m.retries = n; }
75
+ if (args.flags['timeout-ms'] !== undefined) { const n = num(args.flags['timeout-ms'], 50, 86400000); if (n == null) return { error: '--timeout-ms must be an integer from 50 to 86400000' }; m.perRepoTimeoutMs = n; }
76
+ if (args.flags['timeout-seconds'] !== undefined) { const n = num(args.flags['timeout-seconds'], 1, 86400); if (n == null) return { error: '--timeout-seconds must be an integer from 1 to 86400' }; m.perRepoTimeoutMs = n * 1000; }
77
+ return { manifest: m };
78
+ }
79
+
80
+ function entriesOf(artifacts, manifest) {
81
+ return manifest.repositories.filter((r) => artifacts.has(r.id)).map((r) => ({ ...artifacts.get(r.id).entry, repo: r.id }));
82
+ }
83
+
84
+ async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
85
+ const { artifacts, problems } = loadArtifacts({ store, runId, root });
86
+ const jobs = store.listJobs(runId);
87
+ const health = computeOrgHealth({ manifest, jobs });
88
+ for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
89
+ if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
90
+
91
+ let previous = null;
92
+ const rollupFile = statePath(root, ROLLUP_FILE);
93
+ try { previous = JSON.parse(fs.readFileSync(rollupFile, 'utf8')); } catch { /* first run */ }
94
+ const rollup = rollupFleet(entriesOf(artifacts, manifest).concat(health.repositories.filter((r) => !artifacts.has(r.id) && r.state !== 'complete' && r.state !== 'partial').map((r) => ({ repo: r.id, ok: false, error: r.error || r.state, total: null, bySeverity: null, ids: [], scannedAt: new Date().toISOString(), scanHealth: null, policyDrift: null, provenanceDebt: null, mttr: null }))), previous);
95
+ safeWriteState(rollupFile, JSON.stringify(rollup, null, 2));
96
+
97
+ const dep = buildFleetDependencyGraph(manifest.repositories);
98
+ safeWriteState(statePath(root, GRAPH_FILE), JSON.stringify({ organization: manifest.organization, builtAt: new Date().toISOString(), ...dep }, null, 2));
99
+ // edges the code implies; skipped with --no-discover. A failure here never fails the run: the graph is then declared-only and says so.
100
+ let discovered = null; let discoveryNote = args.flags['no-discover'] ? 'skipped (--no-discover)' : null;
101
+ if (!args.flags['no-discover']) { try { discovered = discoverEdges({ manifest }); } catch (e) { discoveryNote = `failed (${String(e.message).slice(0, 80)}); the graph holds declared edges only`; } }
102
+ const split = discovered ? splitDeclared(discovered, manifest) : null;
103
+ const graph = finalizeGraph(buildEnterpriseGraph({ manifest, dependencyGraph: dep, discovered }));
104
+ const findingsByRepo = Object.fromEntries([...artifacts].map(([id, a]) => [id, a.findings ?? []]));
105
+ safeWriteState(statePath(root, ENTERPRISE_FILE), JSON.stringify({ ...graph, runId, manifestFile, findingsByRepo, discovery: discovered ? { scanned: discovered.scanned, limitations: discovered.limitations, undeclared: split.undeclared.length, confirmed: split.confirmed.length } : { note: discoveryNote }, builtAt: new Date().toISOString() }));
106
+
107
+ if (args.flags.json) console.log(JSON.stringify({ runId, health, rollup }, null, 2));
108
+ else {
109
+ console.log(renderFleetSummary(rollup));
110
+ console.log(`\nRun: ${runId}\n${renderOrgHealth(health)}`);
111
+ if (split) console.log(`\nEdges found in code: ${split.undeclared.length} not in the manifest, ${split.confirmed.length} matching a declared edge. ${split.undeclared.length ? 'List them with: agentic-security fleet edges --undeclared' : ''}`.trimEnd());
112
+ else if (discoveryNote) console.log(`\nEdge discovery ${discoveryNote}.`);
113
+ if (interrupted) console.log(`\nInterrupted. Continue with: agentic-security fleet resume ${runId}`);
114
+ else if (health.counts.failed || health.counts.timedOut || health.counts.notRun) console.log(`\nRetry the failed and timed-out repositories with: agentic-security fleet resume ${runId} --retry-failed`);
115
+ }
116
+ return health.assurance === 'green' && !interrupted ? 0 : 1;
117
+ }
118
+
119
+ async function runAll({ args, root, store, runId, manifest, manifestFile }) {
120
+ const ac = new AbortController(); const onSig = () => ac.abort(); process.once('SIGINT', onSig); process.once('SIGTERM', onSig);
121
+ try {
122
+ await runJobs({ manifest, store, runId, root, scan: childScan, signal: ac.signal, useCache: !args.flags['no-cache'], onEvent: (e) => {
123
+ if (args.flags.json) return;
124
+ if (e.type === 'done') console.error(`[fleet] done: ${e.repo}${e.cached ? ' (cached)' : ''}${e.status === 'partial' ? ' (partial)' : ''}`);
125
+ else if (e.type === 'failed') console.error(`[fleet] ${e.timedOut ? 'TIMED OUT' : 'FAILED'}: ${e.repo} (${e.error})${e.next === 'queued' ? ', will retry' : ''}`);
126
+ else if (e.type === 'fenced') console.error(`[fleet] ${e.repo}: result discarded, another worker holds the job`);
127
+ } });
128
+ return await finishRun({ args, root, store, runId, manifest, manifestFile, interrupted: ac.signal.aborted });
129
+ } finally { process.off('SIGINT', onSig); process.off('SIGTERM', onSig); }
130
+ }
131
+
132
+ async function cmdScan(args, root) {
44
133
  const manifestFile = path.resolve(root, manifestArg(args));
45
134
  const loaded = loadFleetManifest(manifestFile);
46
135
  if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
47
- const { manifest } = loaded;
48
-
136
+ const o = overrides(loaded.manifest, args);
137
+ if (o.error) { console.error(o.error); return 4; }
138
+ const { manifest } = o;
49
139
  if (!ensureStateDir(root)) { console.error('No safe state directory for this project.'); return 4; }
50
- const prevRollupFile = statePath(root, 'fleet-rollup.json');
51
- let previous = null;
52
- try { previous = JSON.parse(fs.readFileSync(prevRollupFile, 'utf8')); } catch { /* first run */ }
53
-
54
- const { runScan: runScanFn } = await import('../../runScan.js');
55
- // runScanFn resolves to { scan, meta } already — runFleet's worker expects exactly that shape from runScan, so
56
- // this must NOT wrap `scan` a second time (an earlier draft did, and every repo silently reported 0 findings:
57
- // `_summarise` reads `scan.findings` defensively rather than throwing on the wrong shape).
58
- const runScan = (repoPath) => runScanFn(repoPath, { provenance: false });
59
-
60
- console.error(`[fleet] scanning ${manifest.repositories.length} repository(ies) for ${manifest.organization} (${manifest.workers} worker(s))...`);
61
- const result = await runFleet({
62
- repos: manifest.repositories.map((r) => r.id), concurrency: manifest.workers,
63
- stateFile: statePathFor(root), resume: true, previous,
64
- runScan: async (repoId) => runScan(manifest.repositories.find((r) => r.id === repoId).path),
65
- onProgress: (entry) => { console.error(`[fleet] ${entry.ok ? 'done' : 'FAILED'}: ${entry.repo}${entry.ok ? ` (${entry.total} finding(s))` : ` — ${entry.error}`}`); },
66
- });
67
- if (resumeOnly && result.skipped.length === manifest.repositories.length) console.error('[fleet] nothing to resume — every repository was already complete.');
68
-
69
- safeWriteState(prevRollupFile, JSON.stringify(result.rollup, null, 2));
70
- const graph = buildFleetDependencyGraph(manifest.repositories);
71
- safeWriteState(graphPath(root), JSON.stringify({ organization: manifest.organization, builtAt: new Date().toISOString(), ...graph }, null, 2));
72
-
73
- console.log(renderFleetSummary(result.rollup));
74
- console.log(`\nRepositories requested: ${manifest.repositories.length}`);
75
- console.log(`Complete: ${result.results.filter((r) => r.ok).length + result.skipped.length}`);
76
- console.log(`Failed: ${result.results.filter((r) => !r.ok).length}`);
77
- const anyFailed = result.results.some((r) => !r.ok);
78
- return anyFailed ? 1 : 0;
140
+ const store = await createSqliteJobStore({ file: dbPath(root) });
141
+ try {
142
+ const manifestHash = sha(fs.readFileSync(manifestFile));
143
+ console.error(`[fleet] planning ${manifest.repositories.length} repository(ies) for ${manifest.organization} (${manifest.workers} worker(s), ${manifest.retries} retr${manifest.retries === 1 ? 'y' : 'ies'}, ${Math.round(manifest.perRepoTimeoutMs / 1000)}s per repository)...`);
144
+ const plan = planRun({ manifest, manifestHash, store, meta: { manifestFile } });
145
+ return await runAll({ args, root, store, runId: plan.runId, manifest, manifestFile });
146
+ } finally { store.close(); }
147
+ }
148
+
149
+ async function cmdResume(args, root) {
150
+ const target = String(args._[2] ?? '');
151
+ if (!target) return usage();
152
+ const dbFile = dbPath(root);
153
+ if (!fs.existsSync(dbFile)) { console.error('No fleet run exists in this project. Start one with: agentic-security fleet scan <manifest.yaml>'); return 4; }
154
+ const store = await createSqliteJobStore({ file: dbFile });
155
+ try {
156
+ let run;
157
+ if (isManifestArg(target)) {
158
+ const hash = sha(fs.readFileSync(path.resolve(root, target)));
159
+ run = store.listRuns().find((r) => r.manifestHash === hash && store.listJobs(r.runId).some((j) => ['queued', 'leased'].includes(j.status)));
160
+ if (!run) { console.error('Nothing to resume for that manifest: no run of it has unfinished work. Use `fleet resume <run-id> --retry-failed` to retry failed repositories.'); return 4; }
161
+ } else run = store.getRun(target);
162
+ if (!run) { console.error(`No run ${target}. Runs: ${store.listRuns().slice(0, 5).map((r) => r.runId).join(', ') || 'none'}`); return 4; }
163
+ const full = store.getRun(run.runId);
164
+ const manifestFile = path.resolve(root, args.flags.manifest ?? full.meta?.manifestFile ?? (isManifestArg(target) ? target : 'fleet.yaml'));
165
+ const loaded = loadFleetManifest(manifestFile);
166
+ if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
167
+ const hash = sha(fs.readFileSync(manifestFile));
168
+ if (hash !== full.manifestHash) { console.error(`The manifest ${path.basename(manifestFile)} has changed since run ${full.runId} was planned. A resumed run must scan what it planned; start a new scan instead.`); return 4; }
169
+ const o = overrides(loaded.manifest, args);
170
+ if (o.error) { console.error(o.error); return 4; }
171
+ const freed = reclaimDeadWorkers(store, full.runId) + store.reclaimExpired(full.runId, { now: Date.now() });
172
+ const requeued = args.flags['retry-failed'] ? store.requeueTerminal(full.runId, ['failed', 'timed_out']) : 0;
173
+ // a limit given on the command line applies to the work still to do, including the work just re-queued
174
+ if (args.flags['timeout-ms'] !== undefined || args.flags['timeout-seconds'] !== undefined) store.retime(full.runId, o.manifest.perRepoTimeoutMs);
175
+ const open = store.listJobs(full.runId).filter((j) => ['queued', 'leased'].includes(j.status)).length;
176
+ console.error(`[fleet] resuming ${full.runId}: ${open} unfinished, ${freed} lease(s) recovered${requeued ? `, ${requeued} failed repository(ies) re-queued` : ''}; finished results are reused.`);
177
+ return await runAll({ args, root, store, runId: full.runId, manifest: o.manifest, manifestFile });
178
+ } finally { store.close(); }
79
179
  }
80
180
 
81
181
  async function cmdStatus(args, root) {
82
- const f = statePath(root, 'fleet-rollup.json');
83
- if (!fs.existsSync(f)) { console.error('No fleet scan has been run yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
84
- const rollup = JSON.parse(fs.readFileSync(f, 'utf8'));
85
- if (args.flags.json) console.log(JSON.stringify(rollup, null, 2));
86
- else console.log(renderFleetSummary(rollup));
87
- return 0;
182
+ const dbFile = dbPath(root);
183
+ if (!fs.existsSync(dbFile)) {
184
+ const f = statePath(root, ROLLUP_FILE);
185
+ if (!fs.existsSync(f)) { console.error('No fleet scan has been run yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
186
+ const rollup = JSON.parse(fs.readFileSync(f, 'utf8'));
187
+ console.log(args.flags.json ? JSON.stringify(rollup, null, 2) : renderFleetSummary(rollup));
188
+ return 0;
189
+ }
190
+ const store = await createSqliteJobStore({ file: dbFile });
191
+ try {
192
+ const arg = args._[2];
193
+ const run = arg && !isManifestArg(arg) ? store.getRun(String(arg)) : store.listRuns()[0] && store.getRun(store.listRuns()[0].runId);
194
+ if (!run) { console.error(arg && !isManifestArg(arg) ? `No run ${arg}.` : 'No fleet run exists yet.'); return 4; }
195
+ const meta = run.meta ?? {};
196
+ const manifest = loadFleetManifest(path.resolve(root, meta.manifestFile ?? 'fleet.yaml'));
197
+ const jobs = store.listJobs(run.runId);
198
+ const m = manifest.ok ? manifest.manifest : { repositories: jobs.map((j) => ({ id: j.repoId, required: j.required })) };
199
+ const health = computeOrgHealth({ manifest: m, jobs });
200
+ if (args.flags.json) { console.log(JSON.stringify({ runId: run.runId, health, jobs: jobs.map((j) => ({ repo: j.repoId, status: j.status, attempts: j.attempts, worker: j.worker, leaseExpiresAt: j.leaseExpiresAt, error: j.error })) }, null, 2)); return 0; }
201
+ console.log(`Run: ${run.runId} (${run.organization})\n${renderOrgHealth(health)}`);
202
+ const live = jobs.filter((j) => j.status === 'leased');
203
+ if (live.length) console.log(`\nIn progress: ${live.map((j) => `${j.repoId} (attempt ${j.attempts}, lease until ${new Date(j.leaseExpiresAt).toISOString()})`).join(', ')}`);
204
+ const f = statePath(root, ROLLUP_FILE);
205
+ if (fs.existsSync(f)) console.log(`\n${renderFleetSummary(JSON.parse(fs.readFileSync(f, 'utf8')))}`);
206
+ return 0;
207
+ } finally { store.close(); }
208
+ }
209
+
210
+ // "Show every Internet-facing path to PCI data involving vulnerable authentication code" is recognised as a fixed shape and mapped
211
+ // to the structured query below. Anything else is refused with the flags to use; this is not a natural-language engine.
212
+ export function parseFleetQuestion(text) {
213
+ const m = /^\s*show\s+every\s+internet-facing\s+path\s+to\s+([a-z][a-z0-9-]*)\s+data(?:\s+involving\s+vulnerable\s+([a-z][a-z0-9-]*)\s+(?:code|components?|librar(?:y|ies)))?\s*\.?\s*$/i.exec(String(text ?? ''));
214
+ if (!m) return null;
215
+ const word = m[2]?.toLowerCase() ?? null;
216
+ return { toDataClass: m[1].toLowerCase(), involving: word ? { family: word === 'authentication' || word === 'authorization' ? 'auth' : word } : null };
217
+ }
218
+
219
+ function readEnterprise(root) {
220
+ const f = statePath(root, ENTERPRISE_FILE);
221
+ if (!fs.existsSync(f)) return null;
222
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; }
88
223
  }
89
224
 
90
225
  async function cmdQuery(args, root) {
91
- const target = args.flags['depends-on'];
92
- if (!target) { console.error('usage: fleet query --depends-on <repo-id>'); return 4; }
93
- const f = graphPath(root);
94
- if (!fs.existsSync(f)) { console.error('No fleet dependency graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
95
- const graph = JSON.parse(fs.readFileSync(f, 'utf8'));
96
- if (!graph.nodes.some((n) => n.id === target)) { console.error(`${target} is not a repository in the last fleet scan.`); return 4; }
97
- const radius = blastRadius(graph, target);
98
- if (args.flags.json) { console.log(JSON.stringify({ target, blastRadius: radius }, null, 2)); return 0; }
99
- if (!radius.length) { console.log(`No fleet repository depends on ${target} (by declared package name).`); return 0; }
100
- console.log(`Repositories depending on ${target} (directly or transitively):`);
101
- for (const r of radius) console.log(` ${r.id} (depth ${r.depth}, via ${r.path.slice(1).join(' -> ')})`);
226
+ const flags = args.flags;
227
+ const question = args._[2] && !isManifestArg(args._[2]) ? String(args._[2]) : null;
228
+ const parsed = question ? parseFleetQuestion(question) : null;
229
+ if (question && !parsed) { console.error(`Not a recognised question. This command answers one shape: "Show every Internet-facing path to <data class> data [involving vulnerable <authentication|family> code]". For anything else use --depends-on, --blast-radius, or --internet-to with --involving-vulnerable.`); return 4; }
230
+
231
+ if (flags['depends-on']) {
232
+ const f = statePath(root, GRAPH_FILE);
233
+ if (!fs.existsSync(f)) { console.error('No fleet dependency graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
234
+ const graph = JSON.parse(fs.readFileSync(f, 'utf8'));
235
+ const target = String(flags['depends-on']);
236
+ if (!graph.nodes.some((n) => n.id === target)) { console.error(`${target} is not a repository in the last fleet scan.`); return 4; }
237
+ const radius = blastRadius(graph, target);
238
+ if (flags.json) { console.log(JSON.stringify({ target, blastRadius: radius }, null, 2)); return 0; }
239
+ if (!radius.length) { console.log(`No fleet repository depends on ${target} (by declared package name).`); return 0; }
240
+ console.log(`Repositories depending on ${target} (directly or transitively):`);
241
+ for (const r of radius) console.log(` ${r.id} (depth ${r.depth}, via ${r.path.slice(1).join(' -> ')})`);
242
+ return 0;
243
+ }
244
+
245
+ const ent = readEnterprise(root);
246
+ if (!ent) { console.error('No enterprise graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
247
+ const bad = validateEnterpriseGraph(ent);
248
+ if (bad.length) { console.error(`The stored enterprise graph is inconsistent (${bad[0]}). Re-run the scan.`); return 1; }
249
+ const findings = new Map(Object.entries(ent.findingsByRepo ?? {}));
250
+
251
+ if (flags['blast-radius']) {
252
+ const r = componentBlastRadius({ graph: ent, component: String(flags['blast-radius']), version: flags.version ? String(flags.version) : null });
253
+ if (!r) { console.error(`${flags['blast-radius']} is not in the last fleet scan.`); return 4; }
254
+ console.log(flags.json ? JSON.stringify(r, null, 2) : renderBlastRadius(r));
255
+ return 0;
256
+ }
257
+
258
+ const toClass = parsed?.toDataClass ?? flags['internet-to'];
259
+ if (toClass) {
260
+ const sel = parsed?.involving ?? (flags['involving-vulnerable'] ? (/^[a-z][a-z0-9-]*$/.test(String(flags['involving-vulnerable'])) && ent.nodes.some((n) => n.id === flags['involving-vulnerable']) ? { repo: String(flags['involving-vulnerable']) } : { family: String(flags['involving-vulnerable']) }) : null);
261
+ const involving = sel ? vulnerableSet({ graph: ent, findingsByRepo: findings, selector: sel }) : null;
262
+ const res = attackPaths({ graph: ent, toDataClass: String(toClass).toLowerCase(), involving });
263
+ if (flags.json) { console.log(JSON.stringify({ ...res, involving: involving ? { direct: [...involving.direct], all: [...involving.all], why: Object.fromEntries(involving.why) } : null }, null, 2)); return 0; }
264
+ if (!res.targets.length) { console.log(`No node in the fleet manifest is marked as holding ${toClass} data, so there is nothing to reach.`); return 0; }
265
+ if (!res.paths.length) console.log(`No Internet-facing path to ${toClass} data${involving ? ' involving that vulnerable code' : ''} was found in the declared graph.${res.truncated ? ' (search bound reached)' : ''}`);
266
+ for (const p of res.paths) {
267
+ console.log(p.nodes.map((n) => (n === INTERNET ? 'Internet' : n)).join(' -> '));
268
+ if (p.discoveredEdges?.length) console.log(` rests on edge(s) found only in code, not declared: ${p.discoveredEdges.join(', ')}`);
269
+ if (p.involving.length) console.log(` involves vulnerable code: ${p.involving.map((n) => `${n} (${involving.why.get(n)})`).join(', ')}`);
270
+ }
271
+ if (res.paths.length) console.log(`\n${res.paths.length} path(s). Reach edges are as declared in the fleet manifest, plus any marked above as found only in code.${res.truncated ? ' The search bound was reached; more may exist.' : ''}`);
272
+ return 0;
273
+ }
274
+ return usage();
275
+ }
276
+
277
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
278
+ async function cmdExplore(args, root) {
279
+ const ent = readEnterprise(root);
280
+ if (!ent) { console.error('No enterprise graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
281
+ const rows = ent.edges.map((e) => `<tr><td>${esc(e.from)}</td><td>${esc(e.type)}${e.reach ? '' : ' (dependency)'}</td><td>${esc(e.to)}</td><td>${e.evidence.map((x) => esc(`${x.source}: ${x.repo ?? ''} ${x.field ?? x.declared ?? ''} ${x.value ?? x.spec ?? ''}`.trim())).join('<br>')}</td></tr>`).join('\n');
282
+ const nodes = ent.nodes.map((n) => `<tr><td>${esc(n.id)}</td><td>${esc(n.type)}</td><td>${n.internetFacing ? 'yes' : ''}</td><td>${esc(n.environment ?? '')}</td><td>${esc(n.dataClasses.join(', '))}</td><td>${esc(n.trustZone ?? '')}</td></tr>`).join('\n');
283
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Fleet graph</title><style>body{font:14px/1.5 system-ui,sans-serif;max-width:70rem;margin:2rem auto;padding:0 1rem}table{border-collapse:collapse;margin:.5rem 0 2rem;width:100%}td,th{border:1px solid #8884;padding:.25rem .6rem;text-align:left;vertical-align:top}</style></head><body><h1>${esc(ent.organization)} fleet graph</h1><p>Run ${esc(ent.runId)}. Reach edges are as declared in the fleet manifest; dependency edges come from package manifests. Every edge lists its evidence.</p><h2>Nodes</h2><table><tr><th>id</th><th>type</th><th>internet-facing</th><th>environment</th><th>data</th><th>trust zone</th></tr>${nodes}</table><h2>Edges</h2><table><tr><th>from</th><th>type</th><th>to</th><th>evidence</th></tr>${rows}</table></body></html>\n`;
284
+ const out = path.resolve(root, String(args.flags.out ?? 'fleet-graph.html'));
285
+ fs.writeFileSync(out, html);
286
+ console.log(`Wrote ${out} (${ent.nodes.length} nodes, ${ent.edges.length} edges). A static page, no server.`);
102
287
  return 0;
103
288
  }
@@ -0,0 +1,135 @@
1
+ // Edges the code implies but the manifest does not declare (PRD 4: the enterprise graph should not be only what someone wrote down).
2
+ //
3
+ // What this does: reads the text of each repository's source and configuration files and looks for a few exact, checkable
4
+ // patterns that name ANOTHER node of the manifest:
5
+ // * a URL literal whose host IS a node's id or alias, or whose first label is (payments-api matches payments-api.svc.cluster.local);
6
+ // * a queue/topic resource's name or alias as a string literal on a line that publishes to it, or on one that consumes from it;
7
+ // * a database resource's name or alias on a line that opens a connection.
8
+ // Each hit becomes an edge of type `calls`, `produces`, `consumes` or `connects-to` with the file and line that produced it, tagged
9
+ // `source: discovered-code-literal`, and is reported separately from declared edges: a discovered edge never replaces or hides a
10
+ // declared one, and a query result says which of its edges were only discovered.
11
+ //
12
+ // What it cannot do, and says so: it matches whole tokens, not substrings (so `orders` never matches `reorders`); it sees literals, not
13
+ // values built at run time, environment-supplied URLs or service-mesh routing; it cannot tell the direction of a database use; it
14
+ // skips comment lines but does not parse code, so a literal in a string that is never executed still counts. A discovered edge is a
15
+ // lead with evidence, not a fact. It is bounded in files, bytes and time.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { STATE_DIR_NAME } from '../state-dir.js';
19
+
20
+ const DISCOVERY_LIMITS = Object.freeze({ maxFilesPerRepo: 2000, maxFileBytes: 512 * 1024, maxTotalBytes: 64 * 1024 * 1024, maxEvidencePerEdge: 3 });
21
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.venv', 'venv', '__pycache__', 'target', STATE_DIR_NAME, 'coverage', '.next', 'dist-newstyle', '.stack-work']);
22
+ const TEXT_EXT = /\.(?:js|mjs|cjs|jsx|ts|tsx|py|java|kt|go|rb|php|cs|rs|scala|ya?ml|json|toml|properties|env|tf|conf|ini|hs|nix)$/i;
23
+ const COMMENT = /^\s*(?:\/\/|#|\*|\/\*|--|;)/;
24
+ const URL_RE = /\b[a-z][a-z0-9+.-]{1,12}:\/\/[^\s'"`<>)\]}]{1,300}/gi;
25
+ const PUBLISH = /\b(?:send_?message|publish|produce|send_?to_?queue|enqueue|put_?record|xadd|lpush|rpush|sendmessage|sendtoqueue|emit_?to)\b/i;
26
+ const CONSUME = /\b(?:consume|subscribe|receive_?message|poll|on_?message|get_?messages?|basic_?consume|xread|brpop|blpop|receivemessage)\b/i;
27
+ const CONNECT = /(?:\b(?:connect|create_?connection|createpool|new\s+pool|mongoclient|drivermanager|sqlalchemy|create_engine|database_url|dbname)\b|jdbc:|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/)/i;
28
+
29
+ /** The names a node answers to: its id plus declared aliases, lower-case. */
30
+ function namesOf(node) { return [node.id, ...(node.aliases ?? [])].map((x) => String(x).toLowerCase()); }
31
+
32
+ function* sourceFiles(root, limits) {
33
+ let count = 0; let bytes = 0;
34
+ const stack = ['']; const rootReal = fs.realpathSync(root);
35
+ while (stack.length) {
36
+ const rel = stack.pop();
37
+ let ents; try { ents = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
38
+ for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1))) {
39
+ if (e.isSymbolicLink()) continue;
40
+ const r = rel ? `${rel}/${e.name}` : e.name;
41
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(r); continue; }
42
+ if (!e.isFile() || !TEXT_EXT.test(e.name)) continue;
43
+ let st; try { st = fs.statSync(path.join(root, r)); } catch { continue; }
44
+ if (st.size > limits.maxFileBytes) continue;
45
+ if (++count > limits.maxFilesPerRepo || (bytes += st.size) > limits.maxTotalBytes) return;
46
+ let real; try { real = fs.realpathSync(path.join(root, r)); } catch { continue; }
47
+ if (!real.startsWith(rootReal + path.sep)) continue;
48
+ yield { rel: r, abs: real };
49
+ }
50
+ }
51
+ }
52
+
53
+ const stringLiterals = (line) => [...line.matchAll(/(['"`])((?:\\.|(?!\1).){1,200})\1/g)].map((m) => m[2]);
54
+ const tokenIn = (haystackLiteral, name) => new RegExp(`(?:^|[^a-z0-9_-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:$|[^a-z0-9_-])`, 'i').test(haystackLiteral);
55
+
56
+ /**
57
+ * @param {{manifest: object, limits?: object}} o
58
+ * @returns {{edges: Array<{type: string, from: string, to: string, evidence: object[]}>, scanned: Record<string, {files: number, truncated: boolean}>, limitations: string[]}}
59
+ */
60
+ export function discoverEdges({ manifest, limits = DISCOVERY_LIMITS }) {
61
+ const L = { ...DISCOVERY_LIMITS, ...limits };
62
+ const repoNodes = manifest.repositories.map((r) => ({ id: r.id, aliases: r.aliases ?? [], kind: 'repo' }));
63
+ const resNodes = (manifest.resources ?? []).map((r) => ({ id: r.id, aliases: r.aliases ?? [], kind: r.kind }));
64
+ const found = new Map(); const scanned = {};
65
+ const add = (type, from, to, ev) => {
66
+ const key = `${type}\u0000${from}\u0000${to}`;
67
+ const e = found.get(key) ?? { type, from, to, evidence: [] };
68
+ if (e.evidence.length < L.maxEvidencePerEdge) e.evidence.push({ source: 'discovered-code-literal', repo: from, declared: false, ...ev });
69
+ found.set(key, e);
70
+ };
71
+ for (const repo of manifest.repositories) {
72
+ let files = 0; let truncated = false;
73
+ const targets = [...repoNodes.filter((n) => n.id !== repo.id), ...resNodes];
74
+ const it = sourceFiles(repo.path, L);
75
+ for (;;) {
76
+ const nx = it.next();
77
+ if (nx.done) break;
78
+ files++;
79
+ let text; try { text = fs.readFileSync(nx.value.abs, 'utf8'); } catch { continue; }
80
+ if (text.includes('\0')) continue;
81
+ const lines = text.split('\n');
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ if (line.length > 2000 || COMMENT.test(line)) continue;
85
+ // 1. URL literals naming a repository or gateway
86
+ for (const m of line.matchAll(URL_RE)) {
87
+ let host; try { host = new URL(m[0].replace(/[.,;]+$/, '')).hostname.toLowerCase(); } catch { continue; }
88
+ const first = host.split('.')[0];
89
+ for (const n of targets) {
90
+ if (n.kind !== 'repo' && n.kind !== 'gateway' && n.kind !== 'external-provider') continue;
91
+ if (namesOf(n).some((nm) => nm === host || nm === first)) add(n.kind === 'external-provider' ? 'uses-provider' : 'calls', repo.id, n.id, { file: nx.value.rel, line: i + 1, host });
92
+ }
93
+ for (const n of targets) if (n.kind === 'database' && namesOf(n).some((nm) => nm === host || nm === host.split('.')[0]) && CONNECT.test(m[0])) add('connects-to', repo.id, n.id, { file: nx.value.rel, line: i + 1, host });
94
+ }
95
+ // 2. queue and database names as literals on a publishing / consuming / connecting line
96
+ const lits = stringLiterals(line);
97
+ if (!lits.length) continue;
98
+ for (const n of targets) {
99
+ if (n.kind === 'queue') {
100
+ if (!lits.some((l) => namesOf(n).some((nm) => tokenIn(l, nm)))) continue;
101
+ if (PUBLISH.test(line)) add('produces', repo.id, n.id, { file: nx.value.rel, line: i + 1, name: n.id });
102
+ else if (CONSUME.test(line)) add('consumes', n.id, repo.id, { file: nx.value.rel, line: i + 1, name: n.id });
103
+ } else if (n.kind === 'database' && CONNECT.test(line) && lits.some((l) => namesOf(n).some((nm) => tokenIn(l, nm)))) add('connects-to', repo.id, n.id, { file: nx.value.rel, line: i + 1, name: n.id });
104
+ }
105
+ }
106
+ }
107
+ if (files >= L.maxFilesPerRepo) truncated = true;
108
+ scanned[repo.id] = { files, truncated };
109
+ }
110
+ return {
111
+ edges: [...found.values()].sort((a, b) => (a.type + a.from + a.to < b.type + b.from + b.to ? -1 : 1)),
112
+ scanned,
113
+ limitations: [
114
+ 'Only string and URL literals are seen: a URL built at run time, read from the environment or routed by a mesh is not found.',
115
+ 'Names match as whole tokens; an alias must be declared in the manifest for a node whose code name differs from its id.',
116
+ 'The direction of a database use is not known, so those edges are `connects-to`.',
117
+ 'A discovered edge is a lead with file and line evidence, not a confirmed call.',
118
+ ],
119
+ };
120
+ }
121
+
122
+ /** Discovered edges whose (type, from, to) the manifest already declares, and those it does not. */
123
+ export function splitDeclared(discovered, manifest) {
124
+ const declared = new Set();
125
+ for (const r of manifest.repositories) {
126
+ for (const t of r.calls) declared.add(`calls\u0000${r.id}\u0000${t}`);
127
+ for (const t of r.produces) declared.add(`produces\u0000${r.id}\u0000${t}`);
128
+ for (const t of r.consumes) declared.add(`consumes\u0000${t}\u0000${r.id}`);
129
+ for (const t of r.providers) declared.add(`uses-provider\u0000${r.id}\u0000${t}`);
130
+ for (const t of [...r.reads, ...r.writes]) declared.add(`connects-to\u0000${r.id}\u0000${t}`);
131
+ }
132
+ const undeclared = []; const confirmed = [];
133
+ for (const e of discovered.edges) (declared.has(`${e.type}\u0000${e.from}\u0000${e.to}`) ? confirmed : undeclared).push(e);
134
+ return { undeclared, confirmed };
135
+ }