@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. package/src/sast/pipeline.js +34 -1
@@ -80,6 +80,21 @@ export async function proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_
80
80
  if (!poc?.code) {
81
81
  return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
82
82
  }
83
+ // X-009: language witnesses (generated Nix shell scripts; Haskell programs when a toolchain exists). The proof is the
84
+ // same marker file; the SCOPE is recorded so a configuration/generated-script proof is never read as runtime
85
+ // exploitation of a deployed system.
86
+ if (poc.lang === 'nix-shell' || poc.lang === 'haskell') {
87
+ const W = await import('../language/witness.js');
88
+ const r = poc.lang === 'haskell' ? W.runHaskellWitness(poc.code, { timeoutMs, force }) : W.runNixScriptWitness(poc.witness, { timeoutMs, force });
89
+ const scope = poc.lang === 'nix-shell'
90
+ ? { proofKind: 'generated-script-execution', doesNotProve: 'that a deployed host evaluates this configuration or that the interpolated option is attacker-controlled there' }
91
+ : { proofKind: 'source-witness-execution', doesNotProve: 'that the deployed application receives attacker input on this path' };
92
+ return attachProofTier(finding, _evidence({
93
+ tier: r.status === 'reproduced' ? 'execution-proven' : r.status === 'not-reproduced' ? 'proof-failed' : proofTierOf(finding),
94
+ backend: r.backend || detectBackend(), ran: !!r.ran, observed: r.observed || null, reason: r.reason || null, exitCode: r.exitCode ?? null, timedOut: !!r.timedOut,
95
+ witnessStatus: r.status, ...scope,
96
+ }));
97
+ }
83
98
  if (poc.lang !== 'js') {
84
99
  return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: `unsupported poc language: ${poc.lang}` }));
85
100
  }
@@ -90,6 +90,13 @@ export function classifyFinding(finding, fileContents) {
90
90
  if (finding.sanitizerMismatch) {
91
91
  return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
92
92
  }
93
+ // Haskell and Nix findings are judged on the IR / effective configuration, never on adjacent text: the taint
94
+ // engine already refuses a flow that a DOMINATING, context-matched guard covers (language/haskell-guards.js), and
95
+ // a NixOS value is decided by option precedence. A sanitizer-looking word near the sink (a comment, a helper that
96
+ // is not on the path, a control for a different family) is not control evidence, so text proximity never blocks.
97
+ if (/\.(?:l?hs|nix)$/i.test(String(finding.file || ''))) {
98
+ return { verdict: 'survived', reasons: ['judged on the IR/effective configuration: adjacent text is not control evidence'] };
99
+ }
93
100
  const window = _pathWindow(finding, fileContents);
94
101
  if (!window || !window.trim()) {
95
102
  return { verdict: 'unproven', reasons: ['no source context available to attempt falsification'] };
@@ -10,6 +10,8 @@
10
10
  //
11
11
  // One definition, no cycle, both callers import from here.
12
12
 
13
+ import { LANGUAGE_FAMILY_ALIASES } from '../language/compliance-map.js';
14
+
13
15
  export const COMPLIANCE_FAMILY_ALIAS = {
14
16
  // ASVS spells it `sqli`; every detector emits `sql-injection` (or a
15
17
  // language-prefixed variant, which the suffix rule below does NOT cover
@@ -45,3 +47,8 @@ export function resolveFamilyKeys(fam, availableKeys) {
45
47
  return out;
46
48
  }
47
49
 
50
+ // Haskell/Nix producers emit finer families than the frameworks name; those are evidence for the framework family
51
+ // (language/compliance-map.js). Merged without disturbing any existing alias.
52
+ for (const [fam, extra] of Object.entries(LANGUAGE_FAMILY_ALIASES)) {
53
+ COMPLIANCE_FAMILY_ALIAS[fam] = [...new Set([...(COMPLIANCE_FAMILY_ALIAS[fam] || []), ...extra])];
54
+ }
@@ -356,6 +356,46 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
356
356
  });
357
357
  }
358
358
 
359
+ /**
360
+ * Records a fix that a language lifecycle (Haskell, Nix) has ALREADY written, with its own backup, so one ledger
361
+ * serves every ecosystem: `agentic-security undo` reverts it, the attempt budget counts it, and its tier (FULL,
362
+ * MITIGATION, WORKAROUND) and the gates it passed are kept on the entry beside the timestamp. No second write happens
363
+ * here; `backup.original` is the file the lifecycle saved before it wrote.
364
+ */
365
+ export async function recordExternalFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fixLabel = null, verification = null, backup = null, source = 'language-lifecycle', findingProvenance = null }) {
366
+ return _withLogLock(scanRoot, async () => {
367
+ if (!ensure(scanRoot)) return null;
368
+ const log = readLog(scanRoot);
369
+ const priorAttempts = _countPriorAttempts(log, stableId || null, findingId);
370
+ const bakAbs = backup && backup.dir ? path.join(backup.dir, 'original') : null;
371
+ const entry = {
372
+ id: `fix-${Date.now().toString(36)}-${sha(file + findingId).slice(0, 6)}`,
373
+ findingId, stableId: stableId || null, ruleId: ruleId || null, vuln: vuln || null, file, fileExisted: true,
374
+ backupPath: bakAbs ? path.relative(scanRoot, bakAbs) : null,
375
+ originalSha: sha(originalContent), newSha: sha(newContent),
376
+ appliedAt: new Date().toISOString(), status: 'applied', reverted: false, attemptOrdinal: priorAttempts + 1,
377
+ provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, new Date().toISOString()),
378
+ fixLabel, verification, source, languageBackupId: backup ? backup.id : null,
379
+ };
380
+ log.push(entry);
381
+ await _writeLogAndSync(scanRoot, log);
382
+ return entry;
383
+ });
384
+ }
385
+
386
+ /** Marks the history entry written for a language-lifecycle backup as reverted (sync: the lifecycle's undo is sync). */
387
+ export function markRevertedByBackup(scanRoot, backupId) {
388
+ try {
389
+ const fp = logPath(scanRoot);
390
+ if (!fs.existsSync(fp)) return false;
391
+ const log = JSON.parse(fs.readFileSync(fp, 'utf8'));
392
+ let hit = false;
393
+ for (const e of log) if (e && e.languageBackupId === backupId && !e.reverted) { e.reverted = true; e.revertedAt = new Date().toISOString(); hit = true; }
394
+ if (hit) { const tmp = `${fp}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(log, null, 2)); fs.renameSync(tmp, fp); }
395
+ return hit;
396
+ } catch { return false; }
397
+ }
398
+
359
399
  async function _writeAndSync(fp, content) {
360
400
  await fsp.mkdir(path.dirname(fp), { recursive: true });
361
401
  const handle = await fsp.open(fp, 'w');
@@ -35,8 +35,17 @@ export async function verifyPatch({
35
35
  depFileContents = {},
36
36
  } = {}) {
37
37
  if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
38
- const fileContents = { ...files };
38
+ let fileContents = { ...files };
39
39
  let scan;
40
+ // Haskell/Nix findings come from the deep (IR) layer and depend on imported modules: verifying a patch without
41
+ // either would never see the original finding at all, so a patch that fixes nothing would read "verified". The
42
+ // patched files are scanned deep, together with their import closure and manifests read from disk.
43
+ let deep;
44
+ if (Object.keys(fileContents).some((f) => /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(f))) {
45
+ const { withLanguageContext } = await import('../language/context.js');
46
+ const lc = withLanguageContext(scanRoot, fileContents, depFileContents);
47
+ fileContents = lc.fileContents; depFileContents = lc.depFileContents; deep = true;
48
+ }
40
49
  try {
41
50
  // `provenance:false` is REQUIRED here, not an optimisation. This scan is
42
51
  // deliberately scoped to just the patched file(s), so its finding set is a
@@ -46,7 +55,7 @@ export async function verifyPatch({
46
55
  // would mass-mark the rest of the project as remediated, then
47
56
  // `reintroduced` on the next real scan. The patched content is also not
48
57
  // committed, so there is no history to resolve provenance against anyway.
49
- scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false }, () => {});
58
+ scan = await runFullScan({ fileContents, depFileContents, scanRoot, provenance: false, ...(deep ? { deep: true } : {}) }, () => {});
50
59
  } catch (e) {
51
60
  return { ok: false, reason: 'rescan-failed', error: e.message };
52
61
  }
@@ -56,8 +65,11 @@ export async function verifyPatch({
56
65
  if (stillHasOriginal) {
57
66
  return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
58
67
  }
68
+ // With language context in play, files that were only read for context carry findings that predate the patch;
69
+ // only findings in the PATCHED files can be introduced by it.
70
+ const patchedSet = new Set(Object.keys(files));
59
71
  const introducedHighOrAbove = findings.filter(f =>
60
- (SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
72
+ (SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium && (!deep || patchedSet.has(f.file)));
61
73
  // Don't count findings on lines outside the patched files — but our
62
74
  // fileContents map IS the patched files, so every finding is in-scope.
63
75
  return {
@@ -233,6 +233,30 @@ export function getBlobAtCommit(scanRoot, sha, file) {
233
233
  return r.ok ? r.stdout : null;
234
234
  }
235
235
 
236
+ /** Files in the tree at `sha` (relative to scanRoot) whose path matches `re`, capped. Empty on any failure. */
237
+ export function listFilesAtCommit(scanRoot, sha, re, { max = 400 } = {}) {
238
+ if (!_isSha(sha) || !(re instanceof RegExp)) return [];
239
+ const r = _run(scanRoot, ['ls-tree', '-r', '--name-only', sha, '--', '.']);
240
+ if (!r.ok) return [];
241
+ return r.stdout.split('\n').map((x) => x.trim()).filter((x) => x && re.test(x)).slice(0, max);
242
+ }
243
+
244
+ /**
245
+ * Commits, oldest first, that touched ANY of `files`. Used when a finding is decided by several files (an import
246
+ * chain, a module graph) so the commit that changed any of them is a candidate origin. No rename following.
247
+ */
248
+ export function candidateCommitsForFiles(scanRoot, files, { since } = {}) {
249
+ const rels = (files || []).map((f) => _relPath(scanRoot, f)).filter(Boolean);
250
+ if (!rels.length) return [];
251
+ if (since && !_isSafeRevision(since)) return [];
252
+ const args = ['log', '--format=%H', '--reverse'];
253
+ if (since) args.push(`${since}..HEAD`);
254
+ args.push('--', ...rels);
255
+ const r = _run(scanRoot, args);
256
+ if (!r.ok) return [];
257
+ return [...new Set(r.stdout.split('\n').map((x) => x.trim()).filter(Boolean))];
258
+ }
259
+
236
260
  export function candidateCommitsForLine(scanRoot, file, line, { since } = {}) {
237
261
  const rel = _relPath(scanRoot, file);
238
262
  if (!rel) return [];
@@ -23,7 +23,7 @@
23
23
  // that disambiguates them; see the branch below for exactly how each is
24
24
  // handled.
25
25
 
26
- import { candidateCommitsForLine, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
26
+ import { candidateCommitsForLine, candidateCommitsForFiles, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
27
27
  import { replayAt } from './predicate-replay.js';
28
28
  import { PROVENANCE_METHOD } from './schema.js';
29
29
  import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
@@ -38,6 +38,11 @@ function relevantFiles(finding) {
38
38
  if (Array.isArray(finding.pathSteps)) {
39
39
  for (const step of finding.pathSteps) if (step.file) files.add(step.file);
40
40
  }
41
+ // Haskell/Nix findings are decided by more than the line they point at: a taint chain crosses modules, and a
42
+ // NixOS option is decided by imported modules. Replaying only the anchor file would attribute the finding to the
43
+ // commit that last touched that one file, so the files that decided it are replayed together.
44
+ if (Array.isArray(finding.chain)) for (const step of finding.chain) if (step && typeof step.file === 'string' && step.file) files.add(step.file);
45
+ if (Array.isArray(finding.controlFiles)) for (const f of finding.controlFiles) if (typeof f === 'string' && f) files.add(f);
41
46
  return [...files];
42
47
  }
43
48
 
@@ -173,7 +178,14 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
173
178
  return { status: 'not_available', reason: 'missing-file-line-or-stableId', commitsConsidered: 0 };
174
179
  }
175
180
 
176
- const candidates = candidateCommitsForLine(scanRoot, file, line, { since });
181
+ let candidates = candidateCommitsForLine(scanRoot, file, line, { since });
182
+ // Haskell/Nix: more than the anchor line decides a finding (an import chain, a module graph), so the commits that
183
+ // touched ANY of the deciding files are candidates too, in chronological order.
184
+ const decidingFiles = relevantFiles(finding);
185
+ if (/\.nix$/i.test(file) || (decidingFiles.length > 1 && /\.l?hs$/i.test(file))) {
186
+ const wide = candidateCommitsForFiles(scanRoot, decidingFiles, { since });
187
+ if (wide.length) candidates = [...new Set([...wide, ...candidates])];
188
+ }
177
189
  if (candidates.length === 0) {
178
190
  return { status: 'not_available', reason: 'no-candidate-commits', commitsConsidered: 0 };
179
191
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { runFullScan, _snapshotSuppressionLog, _restoreSuppressionLog } from '../../engine.js';
13
13
  import { computeStableId } from '../stable-id.js';
14
- import { getBlobAtCommit } from './git-evidence.js';
14
+ import { getBlobAtCommit, listFilesAtCommit } from './git-evidence.js';
15
15
 
16
16
  // Task 11 concurrency fix: `coordinator.js` resolves several findings' origins
17
17
  // CONCURRENTLY (its own comment: "the scheduler runs these four at a time"),
@@ -59,6 +59,12 @@ export function _runExclusive(fn) {
59
59
 
60
60
  export async function replayAt(scanRoot, sha, files, targetStableId) {
61
61
  const fileContents = {};
62
+ // A NixOS option is decided by every module the configuration imports, including ones deleted later, so a Nix
63
+ // finding is replayed against ALL Nix sources and the lock file as they existed at that commit.
64
+ if (files.some((f) => /\.nix$/i.test(f))) {
65
+ const all = listFilesAtCommit(scanRoot, sha, /(?:\.nix|(?:^|\/)flake\.lock)$/i);
66
+ files = [...new Set([...files, ...all])];
67
+ }
62
68
  for (const f of files) {
63
69
  const content = getBlobAtCommit(scanRoot, sha, f);
64
70
  if (content != null) fileContents[f] = content;
@@ -100,7 +106,10 @@ export async function replayAt(scanRoot, sha, files, targetStableId) {
100
106
  // identical computeStableId output with and without annotators, over
101
107
  // a real scan) before this was wired in — see the FR-PROV-029 commit
102
108
  // message for methodology.
103
- return await runFullScan({ fileContents, scanRoot, provenance: false, skipAnnotators: true }, () => {});
109
+ // Haskell/Nix findings come from the IR-taint layer, which only runs in deep mode: a replay that skips it
110
+ // can never see the finding at ANY commit, so origin resolution would end "never confirmed" for every one.
111
+ const _langDeep = Object.keys(fileContents).some((f) => /\.(?:l?hs|nix)$/i.test(f)) ? { deep: true } : {};
112
+ return await runFullScan({ fileContents, scanRoot, provenance: false, skipAnnotators: true, ..._langDeep }, () => {});
104
113
  } finally {
105
114
  _restoreSuppressionLog(_suppSnapshot);
106
115
  }
@@ -1,6 +1,7 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import { candidateCommitsForFile, getFirstParent, getBlobAtCommit, commitMeta } from './git-evidence.js';
3
3
  import { PROVENANCE_METHOD } from './schema.js';
4
+ import { compareVersions } from '../../language/haskell-manifests.js';
4
5
 
5
6
  function parseSemver(v) {
6
7
  const m = String(v || '').replace(/^[^\d]*/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
@@ -13,7 +14,17 @@ function cmpSemver(a, b) {
13
14
  return 0;
14
15
  }
15
16
 
16
- export function versionInRange(version, range) {
17
+ // Hackage versions follow the PVP: any number of components, compared numerically component by component. The
18
+ // three-component semver comparison below would call 2.1.2.1 and 2.1.2.9 equal.
19
+ function pvpInRange(version, range) {
20
+ if (!version) return false;
21
+ if (range.introduced && compareVersions(version, range.introduced) < 0) return false;
22
+ if (range.fixed && compareVersions(version, range.fixed) >= 0) return false;
23
+ return true;
24
+ }
25
+
26
+ export function versionInRange(version, range, ecosystem = null) {
27
+ if (ecosystem && String(ecosystem).toLowerCase() === 'hackage') return pvpInRange(version, range);
17
28
  const v = parseSemver(version);
18
29
  if (!v) return false;
19
30
  if (range.introduced) {
@@ -42,6 +53,12 @@ function extractDeclaredVersion(blobText, depName, filePath) {
42
53
  return null;
43
54
  }
44
55
  }
56
+ // Hackage: the version is exact only where a freeze, a Stack lock or an extra-deps pin states it.
57
+ const esc = String(depName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ if (base === 'cabal.project.freeze') { const m = new RegExp(`any\\.${esc}\\s*==\\s*([0-9][0-9.]*)`).exec(blobText); return m ? m[1] : null; }
59
+ if (base === 'stack.yaml.lock') { const m = new RegExp(`hackage:\\s*${esc}-([0-9][0-9.]*)(?:@|\\s|$)`).exec(blobText); return m ? m[1] : null; }
60
+ if (base === 'stack.yaml') { const m = new RegExp(`^\\s*-\\s*${esc}-([0-9][0-9.]*)\\s*$`, 'm').exec(blobText); return m ? m[1] : null; }
61
+ if (/\.cabal$/i.test(base)) { const m = new RegExp(`(?:^|[\\s,:])${esc}\\s*==\\s*([0-9][0-9.]*)(?![\\w.*])`, 'm').exec(blobText); return m ? m[1] : null; }
45
62
  if (/^requirements(?:[._-][\w.-]+)?\.txt$/i.test(base)) {
46
63
  for (const line of blobText.split('\n')) {
47
64
  const m = line.trim().match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
@@ -98,7 +115,7 @@ export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadli
98
115
  const blob = getBlobAtCommit(scanRoot, sha, file);
99
116
  if (blob == null) continue;
100
117
  const declaredVersion = extractDeclaredVersion(blob, scaEntry.name, file);
101
- if (!declaredVersion || !versionInRange(declaredVersion, range)) continue;
118
+ if (!declaredVersion || !versionInRange(declaredVersion, range, scaEntry.ecosystem)) continue;
102
119
 
103
120
  const parent = getFirstParent(scanRoot, sha);
104
121
  if (!parent) {
@@ -111,7 +128,7 @@ export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadli
111
128
 
112
129
  const parentBlob = getBlobAtCommit(scanRoot, parent, file);
113
130
  const parentVersion = parentBlob ? extractDeclaredVersion(parentBlob, scaEntry.name, file) : null;
114
- const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range);
131
+ const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range, scaEntry.ecosystem);
115
132
  if (!parentOutOfRange) {
116
133
  // Parent was already in the vulnerable range too. If the declared value
117
134
  // actually changed here, this is the ambiguous "still-vulnerable bump"
@@ -21,7 +21,7 @@
21
21
  // module produces the diff on the next scan.
22
22
 
23
23
  import * as fs from 'node:fs';
24
- import { statePath, stateWritesEnabled } from './state-dir.js';
24
+ import { statePath, stateWritesEnabled, safeWriteState } from './state-dir.js';
25
25
  import * as path from 'node:path';
26
26
  import * as crypto from 'node:crypto';
27
27
  import { execFileSync } from 'node:child_process';
@@ -52,6 +52,9 @@ function _gitHead(scanRoot) {
52
52
  }
53
53
 
54
54
  function _snapshotKey(component) {
55
+ // Language components carry an identity that survives a version change but keeps two resolved builds apart
56
+ // (a flag variant, a second store hash); everything else keeps the ecosystem:name key it always had.
57
+ if (component.identityKey) return component.identityKey;
55
58
  return `${component.ecosystem || 'unknown'}:${component.name || ''}`;
56
59
  }
57
60
 
@@ -65,7 +68,6 @@ export function persistSbom(scanRoot, components) {
65
68
  // empty `sbom-history/` in the scanned tree — invisible to `git status`,
66
69
  // because git does not track empty directories, and therefore exactly the
67
70
  // kind of mutation that passes a clean-status check while still being one.
68
- if (stateWritesEnabled()) { try { fs.mkdirSync(dir, { recursive: true }); } catch {} }
69
71
  const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
70
72
  const snap = {
71
73
  sha, ts: new Date().toISOString(),
@@ -73,14 +75,13 @@ export function persistSbom(scanRoot, components) {
73
75
  components: components.map(c => ({
74
76
  ecosystem: c.ecosystem, name: c.name, version: c.version,
75
77
  purl: c.purl, scope: c.scope, isUnpinned: !!c.isUnpinned,
76
- sha256: c.sha256 || c.integrity || null,
78
+ ...(c.identityKey ? { identityKey: c.identityKey, bomRef: c.bomRef || null } : {}),
79
+ sha256: c.sha256 || c.integrity || ((c.hashes || []).find((h) => h.alg === 'SHA-256') || {}).content || null,
77
80
  })),
78
81
  };
79
82
  // NON_MUTATING_SCAN_PRD S1 — history is a feature, but not at the cost of
80
83
  // mutating a tree the caller only asked us to read.
81
- if (stateWritesEnabled()) {
82
- try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
83
- }
84
+ if (stateWritesEnabled()) safeWriteState(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); // refuses a symlinked state directory or file
84
85
  return snap;
85
86
  }
86
87
 
@@ -60,27 +60,49 @@ function _documentUuid(seed) {
60
60
  return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
61
61
  }
62
62
 
63
+ // The bom-ref a vulnerability points at: the component's own ref when it has one (language components do), else the
64
+ // derived one. A different resolved build of the same package is a different component and keeps its own ref.
65
+ function _vulnTargetRef(components, s) {
66
+ const hit = components.find(c => c.bomRef && c.name === s.name && c.version === s.version && (c.ecosystem || '') === (s.ecosystem || ''));
67
+ return hit ? hit.bomRef : _bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version });
68
+ }
69
+
63
70
  export function toCycloneDX(scan, meta = {}) {
64
- const components = scan.components || [];
71
+ const components = [...(scan.components || []), ...((scan.languageBom && scan.languageBom.components) || [])];
72
+ scan = scan.languageBom ? { ...scan, bomRoot: scan.languageBom.root || scan.bomRoot, bomDependencies: scan.languageBom.dependencies, bomCompositions: scan.languageBom.compositions, bomProperties: scan.languageBom.properties } : scan;
65
73
  const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
66
74
  const serialNumber = `urn:uuid:${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
67
75
 
76
+ // Language components (Hackage, Nix) carry their own identity, scope, hashes and build provenance; every other
77
+ // component keeps exactly the shape it always had. A version is written only when one is known (a null or empty
78
+ // version is not a valid CycloneDX string), and nothing here invents a license.
79
+ const refOf = (c) => c.bomRef || _bomRef(c);
68
80
  const cdxComponents = components.map(c => ({
69
- type: 'library',
70
- 'bom-ref': _bomRef(c),
81
+ type: c.cdxType || 'library',
82
+ 'bom-ref': refOf(c),
71
83
  name: c.name,
72
- version: c.version,
73
- purl: _purl(c),
84
+ ...(c.version ? { version: c.version } : (c.bomRef ? {} : { version: c.version })),
85
+ ...(c.purl || !c.bomRef ? { purl: _purl(c) } : {}),
74
86
  ...(c.license ? { licenses: [{ license: { id: c.license } }] } : {}),
75
- ...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
87
+ ...(c.cdxScope ? { scope: c.cdxScope } : (c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {})),
88
+ ...(Array.isArray(c.hashes) && c.hashes.length ? { hashes: c.hashes } : {}),
89
+ ...(Array.isArray(c.externalReferences) && c.externalReferences.length ? { externalReferences: c.externalReferences } : {}),
90
+ ...(Array.isArray(c.properties) && c.properties.length ? { properties: c.properties } : {}),
76
91
  }));
92
+ const knownRefs = new Set([...cdxComponents.map(x => x['bom-ref']), ...(scan.bomRoot ? [scan.bomRoot['bom-ref']] : [])]);
93
+ const cdxDependencies = (scan.bomDependencies || [])
94
+ .filter(d => knownRefs.has(d.ref))
95
+ .map(d => ({ ref: d.ref, dependsOn: (d.dependsOn || []).filter(r => knownRefs.has(r)) }));
96
+ const cdxCompositions = (scan.bomCompositions || [])
97
+ .map(c => ({ aggregate: c.aggregate, dependencies: (c.dependencies || []).filter(r => knownRefs.has(r)) }))
98
+ .filter(c => c.dependencies.length);
77
99
 
78
100
  const vulnerabilities = supplyChain.map((s, i) => ({
79
101
  // The last-resort id was crypto.randomUUID(), which reintroduced
80
102
  // per-run drift for any advisory carrying neither an osvId nor an
81
103
  // advisory string. Index within the (already deterministically sorted)
82
104
  // supplyChain array identifies it just as well and is reproducible.
83
- 'bom-ref': `${_bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version })}#${s.osvId || s.advisory || `unidentified-${i}`}`,
105
+ 'bom-ref': `${_vulnTargetRef(components, s)}#${s.osvId || s.advisory || `unidentified-${i}`}`,
84
106
  id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
85
107
  source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
86
108
  references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
@@ -89,7 +111,7 @@ export function toCycloneDX(scan, meta = {}) {
89
111
  ...(s.cvssVector ? [{ vector: s.cvssVector, method: 'CVSSv3' }] : []),
90
112
  ],
91
113
  description: s.description || s.advisory || '',
92
- affects: [{ ref: _bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version }) }],
114
+ affects: [{ ref: _vulnTargetRef(components, s) }],
93
115
  properties: [
94
116
  ...(s.epssScore != null ? [{ name: 'epss:score', value: String(s.epssScore) }] : []),
95
117
  ...(s.epssPercentile != null ? [{ name: 'epss:percentile', value: String(s.epssPercentile) }] : []),
@@ -105,34 +127,53 @@ export function toCycloneDX(scan, meta = {}) {
105
127
  metadata: {
106
128
  timestamp: meta.startedAt || new Date().toISOString(),
107
129
  tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
108
- component: { type: 'application', name: 'scan-target', version: '1.0.0' },
130
+ component: scan.bomRoot ? { ...scan.bomRoot } : { type: 'application', name: 'scan-target', version: '1.0.0' },
131
+ ...(Array.isArray(scan.bomProperties) && scan.bomProperties.length ? { properties: scan.bomProperties } : {}),
109
132
  },
110
133
  components: cdxComponents,
134
+ ...(cdxDependencies.length ? { dependencies: cdxDependencies } : {}),
135
+ ...(cdxCompositions.length ? { compositions: cdxCompositions } : {}),
111
136
  ...(vulnerabilities.length ? { vulnerabilities } : {}),
112
137
  };
113
138
  }
114
139
 
115
140
  export function toSPDX(scan, meta = {}) {
116
- const components = scan.components || [];
141
+ const components = [...(scan.components || []), ...((scan.languageBom && scan.languageBom.components) || [])];
142
+ scan = scan.languageBom ? { ...scan, bomRoot: scan.languageBom.root || scan.bomRoot, bomDependencies: scan.languageBom.dependencies } : scan;
117
143
  const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
118
144
  const docNamespace = `https://agentic-security.local/spdx/${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
119
- const ts = meta.startedAt || new Date().toISOString();
145
+ // SPDX 2.3 `created` is YYYY-MM-DDThh:mm:ssZ: fractional seconds are not part of the format.
146
+ const ts = (meta.startedAt || new Date().toISOString()).replace(/\.\d+(Z|[+-]\d{2}:\d{2})$/, (_m, z) => (z === 'Z' ? 'Z' : z));
120
147
 
121
- const packages = components.map((c, i) => ({
122
- SPDXID: `SPDXRef-Package-${i}`,
123
- name: c.name,
124
- versionInfo: c.version,
125
- downloadLocation: 'NOASSERTION',
126
- filesAnalyzed: false,
127
- licenseConcluded: c.license || 'NOASSERTION',
128
- licenseDeclared: c.license || 'NOASSERTION',
129
- copyrightText: 'NOASSERTION',
130
- externalRefs: [{
131
- referenceCategory: 'PACKAGE-MANAGER',
132
- referenceType: 'purl',
133
- referenceLocator: _purl(c),
134
- }],
135
- }));
148
+ const spdxIdOfRef = new Map();
149
+ // The described application itself is a package, so dependency edges that start at it have an element to attach to.
150
+ const rootPackage = scan.bomRoot && scan.bomRoot['bom-ref'] ? [{
151
+ SPDXID: 'SPDXRef-Package-root', name: scan.bomRoot.name, ...(scan.bomRoot.version ? { versionInfo: scan.bomRoot.version } : {}),
152
+ downloadLocation: 'NOASSERTION', filesAnalyzed: false, licenseConcluded: 'NOASSERTION', licenseDeclared: 'NOASSERTION', copyrightText: 'NOASSERTION',
153
+ primaryPackagePurpose: 'APPLICATION', externalRefs: [],
154
+ }] : [];
155
+ if (rootPackage.length) spdxIdOfRef.set(scan.bomRoot['bom-ref'], 'SPDXRef-Package-root');
156
+ const packages = components.map((c, i) => {
157
+ const id = `SPDXRef-Package-${i}`;
158
+ if (c.bomRef) spdxIdOfRef.set(c.bomRef, id);
159
+ return {
160
+ SPDXID: id,
161
+ name: c.name,
162
+ ...((c.version || !c.bomRef) ? { versionInfo: c.version } : {}),
163
+ downloadLocation: 'NOASSERTION',
164
+ filesAnalyzed: false,
165
+ ...(Array.isArray(c.hashes) && c.hashes.length ? { checksums: c.hashes.map(h => ({ algorithm: String(h.alg).replace('-', ''), checksumValue: h.content })) } : {}),
166
+ licenseConcluded: c.license || 'NOASSERTION',
167
+ licenseDeclared: c.license || 'NOASSERTION',
168
+ copyrightText: 'NOASSERTION',
169
+ ...(c.bomRef ? { primaryPackagePurpose: 'LIBRARY' } : {}),
170
+ externalRefs: [...((c.purl || !c.bomRef) ? [{
171
+ referenceCategory: 'PACKAGE-MANAGER',
172
+ referenceType: 'purl',
173
+ referenceLocator: _purl(c),
174
+ }] : [])],
175
+ };
176
+ });
136
177
 
137
178
  // SPDX expresses CVEs as external refs on the package, not separate elements
138
179
  const cveByName = {};
@@ -162,11 +203,23 @@ export function toSPDX(scan, meta = {}) {
162
203
  created: ts,
163
204
  creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
164
205
  },
165
- packages,
166
- relationships: packages.map(p => ({
167
- spdxElementId: 'SPDXRef-DOCUMENT',
168
- relatedSpdxElement: p.SPDXID,
169
- relationshipType: 'DESCRIBES',
170
- })),
206
+ packages: [...rootPackage, ...packages],
207
+ relationships: [
208
+ ...[...rootPackage, ...packages].map(p => ({
209
+ spdxElementId: 'SPDXRef-DOCUMENT',
210
+ relatedSpdxElement: p.SPDXID,
211
+ relationshipType: 'DESCRIBES',
212
+ })),
213
+ // Dependency edges between language components. Runtime edges are DEPENDS_ON; a build- or test-only target is
214
+ // expressed with the SPDX 2.3 *_DEPENDENCY_OF relationship so the scope survives the conversion.
215
+ ...(scan.bomDependencies || []).flatMap(d => (d.dependsOn || []).map(to => {
216
+ const a = spdxIdOfRef.get(d.ref); const b = spdxIdOfRef.get(to);
217
+ if (!a || !b) return null;
218
+ const kind = (d.kinds || {})[to] || 'runtime';
219
+ if (kind === 'test') return { spdxElementId: b, relatedSpdxElement: a, relationshipType: 'TEST_DEPENDENCY_OF' };
220
+ if (kind === 'build') return { spdxElementId: b, relatedSpdxElement: a, relationshipType: 'BUILD_DEPENDENCY_OF' };
221
+ return { spdxElementId: a, relatedSpdxElement: b, relationshipType: 'DEPENDS_ON' };
222
+ })).filter(Boolean),
223
+ ],
171
224
  };
172
225
  }
@@ -41,6 +41,7 @@ import * as path from 'node:path';
41
41
  import * as yaml from '../util/yaml.js';
42
42
 
43
43
  import { safeWriteState, stateDir, statePath } from './state-dir.js';
44
+ import { BUILD_TRUST_TYPE } from '../language/contracts.js';
44
45
  const DEFAULT_POLICY = {
45
46
  acceptRisk: [],
46
47
  sla: {},
@@ -75,6 +76,7 @@ function _normalizeAccept(entry) {
75
76
  return {
76
77
  cve: entry.cve ? String(entry.cve).toUpperCase() : null,
77
78
  package: entry.package ? String(entry.package).toLowerCase() : null,
79
+ rule: entry.rule ? String(entry.rule) : null,
78
80
  version: entry.version ? String(entry.version) : null,
79
81
  ecosystem: entry.ecosystem ? String(entry.ecosystem).toLowerCase() : null,
80
82
  reason: entry.reason || '',
@@ -125,6 +127,13 @@ export function matchAcceptRisk(finding, policy, today = new Date()) {
125
127
  const pkgName = String(finding.name || '').toLowerCase();
126
128
  for (const entry of policy.acceptRisk) {
127
129
  if (!_accepted(entry, today)) continue;
130
+ // A `rule:` entry targets a build-trust policy finding (no CVE, no version) by its rule id.
131
+ if (entry.rule) {
132
+ if (finding.type === BUILD_TRUST_TYPE && finding.rule === entry.rule
133
+ && (!entry.package || pkgName === entry.package)
134
+ && (!entry.ecosystem || entry.ecosystem === finding.ecosystem)) return entry;
135
+ continue;
136
+ }
128
137
  if (entry.cve && cves.includes(entry.cve)) return entry;
129
138
  if (entry.package && pkgName === entry.package) {
130
139
  if (entry.version && finding.version && entry.version !== finding.version) continue;
@@ -144,7 +153,7 @@ export function applyScaPolicy(findings, policy, scanTime = new Date()) {
144
153
  const stats = { suppressed: 0, slaTagged: 0, frozen: 0 };
145
154
  if (!policy || !Array.isArray(findings)) return stats;
146
155
  for (const f of findings) {
147
- if (!f || f.type !== 'vulnerable_dep') continue;
156
+ if (!f || (f.type !== 'vulnerable_dep' && f.type !== BUILD_TRUST_TYPE)) continue;
148
157
  // Accept-risk suppression
149
158
  const acceptance = matchAcceptRisk(f, policy, scanTime);
150
159
  if (acceptance) {
@@ -13,6 +13,8 @@
13
13
  // Verdict enum (must match the agent + downstream /fix --sca):
14
14
  // AUTO_MERGE_PATCH · WAIT_FOR_PATCH · MANUAL_REVIEW · ACCEPT_RISK · WONT_FIX
15
15
 
16
+ import { BUILD_TRUST_TYPE } from '../language/contracts.js';
17
+
16
18
  export const SCA_VERDICTS = ['AUTO_MERGE_PATCH', 'WAIT_FOR_PATCH', 'MANUAL_REVIEW', 'ACCEPT_RISK', 'WONT_FIX'];
17
19
 
18
20
  const REACHABLE_TIERS = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
@@ -64,6 +66,11 @@ export function computeScaVerdict(sc, opts = {}) {
64
66
  if (sc.suppressed || sc.suppressionReason) {
65
67
  return { verdict: 'ACCEPT_RISK', reason: sc.suppressionReason || 'matched existing accept-risk in sca-policy.yml' };
66
68
  }
69
+ // 1b. Nix build-trust policy findings have no advisory, fixed version or upgrade
70
+ // path: the remedy is a configuration or pin change, which is always a human call.
71
+ if (sc.type === BUILD_TRUST_TYPE) {
72
+ return { verdict: 'MANUAL_REVIEW', reason: `${sc.rule}: ${sc.remediation || 'review the Nix build-trust setting'}` };
73
+ }
67
74
  // 2. No fixed version exists → nothing to upgrade yet.
68
75
  if (!fixed.length) {
69
76
  return { verdict: 'WAIT_FOR_PATCH', reason: `no fixed version published${sc.osvId ? ` for ${sc.osvId}` : ''}` };
@@ -116,7 +123,7 @@ export function annotateScaVerdicts(supplyChain, opts = {}) {
116
123
  if (!Array.isArray(supplyChain)) return supplyChain;
117
124
  const counts = { AUTO_MERGE_PATCH: 0, WAIT_FOR_PATCH: 0, MANUAL_REVIEW: 0, ACCEPT_RISK: 0, WONT_FIX: 0 };
118
125
  for (const sc of supplyChain) {
119
- if (!sc || sc.type !== 'vulnerable_dep') continue;
126
+ if (!sc || (sc.type !== 'vulnerable_dep' && sc.type !== BUILD_TRUST_TYPE)) continue;
120
127
  const { verdict, reason, expiryDays } = computeScaVerdict(sc, opts);
121
128
  sc.scaVerdict = verdict;
122
129
  sc.scaVerdictReason = reason;