@clear-capabilities/agentic-security-scanner 0.153.1 → 0.154.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 +67 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +18 -3
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +140 -19
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/code-injection-multilang.js +15 -0
  135. package/src/sast/go-extended.js +11 -0
  136. package/src/sast/pipeline.js +34 -1
@@ -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;
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
16
+ import { providerInfo, scanLanguageSecretConcat, scanDependencyUrlCredentials } from '../language/secrets.js';
16
17
 
17
18
  // Pull the post-image (added) lines out of a unified diff: lines starting with
18
19
  // a single '+' (not the '+++' file header). Returns reconstructed text.
@@ -24,25 +25,42 @@ export function extractAddedLines(diffText) {
24
25
  return out.join('\n');
25
26
  }
26
27
 
28
+ // Split a unified diff into per-file sections with the POST-IMAGE line number of every added line, so a
29
+ // finding can name the file and line it was committed at (the combined text alone loses both).
30
+ export function splitDiffByFile(diffText) {
31
+ const files = [];
32
+ let cur = null; let next = 0;
33
+ for (const line of String(diffText || '').split('\n')) {
34
+ const h = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
35
+ if (h) { cur = { path: h[2], added: [] }; files.push(cur); next = 0; continue; }
36
+ if (!cur) continue;
37
+ const hh = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
38
+ if (hh) { next = parseInt(hh[1], 10); continue; }
39
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
40
+ if (line.startsWith('+')) { cur.added.push({ text: line.slice(1), line: next }); next++; }
41
+ else if (!line.startsWith('-') && !line.startsWith('\\')) next++;
42
+ }
43
+ return files;
44
+ }
45
+
27
46
  // Pure: run the injected credential detector over a commit's added lines.
28
- // detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
29
- export function scanHistoryDiff(diffText, commit, detectFn) {
47
+ // detectFn has the scanCredentials(fp, raw) shape and returns Finding[]. `extra` are additional detectors
48
+ // ((path, text) => Finding[]) run against each file's added lines WITH the real path, so extension-gated
49
+ // detectors (Haskell/Nix split-secret) apply to history too.
50
+ export function scanHistoryDiff(diffText, commit, detectFn, extra = []) {
30
51
  if (typeof detectFn !== 'function') return [];
31
- const added = extractAddedLines(diffText);
32
- if (!added.trim()) return [];
33
- let findings = [];
34
- try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
35
- return findings.map((f) => {
36
- const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
37
- return {
38
- ...f,
52
+ const sections = splitDiffByFile(diffText);
53
+ const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
54
+ const wrap = (f, sec, addedLines) => {
55
+ const hit = Number.isInteger(f.line) && f.line >= 1 ? addedLines[f.line - 1] : null;
56
+ const prov = f.rotation ? {} : providerInfo(f._rawProviderValue || '');
57
+ const out = {
58
+ ...f, ...prov,
39
59
  id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
40
- file: `git-history@${commit}`,
41
- line: 0,
42
- commit,
43
- _historical: true,
60
+ file: `git-history@${commit}`, line: 0, commit, _historical: true,
61
+ ...(sec ? { sourceFile: sec.path, sourceLine: hit ? hit.line : null } : {}),
44
62
  vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
45
- description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
63
+ description: `${f.description || 'A credential was committed.'} Found in commit ${commit}${sec ? ` (${sec.path}${hit ? `:${hit.line}` : ''})` : ''}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
46
64
  remediation,
47
65
  // report/index.js's _remediationOf checks `.fix` before `.remediation`
48
66
  // — the underlying detector already set `.fix` to a generic "remove
@@ -51,15 +69,41 @@ export function scanHistoryDiff(diffText, commit, detectFn) {
51
69
  // insufficient") in every report format.
52
70
  fix: remediation,
53
71
  };
54
- });
72
+ delete out._rawProviderValue;
73
+ return out;
74
+ };
75
+ const results = [];
76
+ if (!sections.length) {
77
+ const added = extractAddedLines(diffText);
78
+ if (!added.trim()) return [];
79
+ let findings = [];
80
+ try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
81
+ return findings.map((f) => wrap(f, null, []));
82
+ }
83
+ for (const sec of sections) {
84
+ if (!sec.added.length) continue;
85
+ const text = sec.added.map((a) => a.text).join('\n');
86
+ if (!text.trim()) continue;
87
+ let findings = [];
88
+ try { findings = detectFn(`git-history@${commit}`, text) || []; } catch { findings = []; }
89
+ for (const f of findings) results.push(wrap(f, sec, sec.added));
90
+ for (const ex of extra || []) {
91
+ let more = [];
92
+ try { more = ex(sec.path, text) || []; } catch { more = []; }
93
+ for (const f of more) results.push(wrap(f, sec, sec.added));
94
+ }
95
+ }
96
+ return results;
55
97
  }
56
98
 
99
+ const defaultExtra = [scanLanguageSecretConcat, scanDependencyUrlCredentials];
100
+
57
101
  /**
58
102
  * Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
59
103
  * [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
60
104
  * that recurs across commits to its earliest sighting.
61
105
  */
62
- export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
106
+ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000, extraDetectors = null } = {}) {
63
107
  if (!scanRoot || typeof detectFn !== 'function') return [];
64
108
  let out;
65
109
  try {
@@ -78,7 +122,7 @@ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs
78
122
  const seen = new Set();
79
123
  for (let i = 1; i < parts.length; i += 2) {
80
124
  const sha = (parts[i] || '').slice(0, 12);
81
- for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
125
+ for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn, extraDetectors || defaultExtra)) {
82
126
  const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
83
127
  if (seen.has(key)) continue;
84
128
  seen.add(key);
@@ -10,6 +10,7 @@
10
10
  // that need stability use `stableId`.
11
11
 
12
12
  import * as crypto from 'node:crypto';
13
+ import { haskellComments, nixComments } from '../language/pragma.js';
13
14
 
14
15
  function normalizeSnippet(s) {
15
16
  if (!s || typeof s !== 'string') return '';
@@ -53,12 +54,30 @@ function ruleId(f) {
53
54
  return `vuln:${(f.vuln || '').slice(0, 40)}`;
54
55
  }
55
56
 
57
+ // Haskell and Nix findings are identified by their code, not by how it is laid out: comments, blank lines,
58
+ // indentation and spacing between tokens never rotate an id. (The generic normaliser keeps spacing next to
59
+ // punctuation, so `f (x)` and `f ( x )` would differ.) Only whitespace and comments are removed; every token stays.
60
+ function normalizeLanguageSnippet(file, s) {
61
+ if (!s || typeof s !== 'string') return '';
62
+ const isNix = /\.nix$/i.test(String(file || ''));
63
+ const spans = isNix ? nixComments(s) : haskellComments(s);
64
+ let out = ''; let at = 0;
65
+ for (const c of spans) { out += s.slice(at, c.start); at = c.end; }
66
+ out += s.slice(at);
67
+ return out.toLowerCase().replace(/"(?:[^"\\]|\\.)*"/g, '"_S_"').replace(/\s+/g, '');
68
+ }
69
+
56
70
  export function computeStableId(f) {
57
71
  const rid = ruleId(f);
58
- const sink = normalizeSnippet(f.sink?.snippet || f.snippet || '').slice(0, 200);
72
+ const isLang = /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(String(f.file || f.sink?.file || ''));
73
+ const sink = isLang
74
+ ? normalizeLanguageSnippet(f.file || f.sink?.file, f.sink?.snippet || f.snippet || '').slice(0, 200)
75
+ : normalizeSnippet(f.sink?.snippet || f.snippet || '').slice(0, 200);
59
76
  const shape = pathShape(f);
60
77
  const fileHint = basenameLike(f.file || f.sink?.file);
61
- const material = `${rid}|${sink}|${shape}|${fileHint}`;
78
+ // Haskell taint findings carry a site key (a fingerprint of the sink argument), so two sinks of one rule
79
+ // on one line keep distinct ids. Absent for every other finding, so their ids are unchanged.
80
+ const material = f.siteKey ? `${rid}|${sink}|${shape}|${fileHint}|${f.siteKey}` : `${rid}|${sink}|${shape}|${fileHint}`;
62
81
  return crypto.createHash('sha256').update(material).digest('hex').slice(0, 16);
63
82
  }
64
83
 
@@ -33,8 +33,11 @@ const PROJECT_MARKERS = [
33
33
  'build.gradle',
34
34
  'composer.json',
35
35
  'Gemfile',
36
+ // Haskell and Nix projects: without these a Cabal, Stack or flake project never got a state directory at all.
37
+ 'cabal.project', 'stack.yaml', 'package.yaml', 'flake.nix', 'configuration.nix', 'default.nix', 'shell.nix',
36
38
  '.agentic-security',
37
39
  ];
40
+ const PROJECT_MARKER_SUFFIX = /\.cabal$/;
38
41
 
39
42
  function _findProjectRoot(startDir) {
40
43
  let dir = path.resolve(startDir);
@@ -81,8 +84,23 @@ export function statePath(scanRoot, ...parts) {
81
84
  // Safety check: refuse to create .agentic-security/ unless the parent
82
85
  // directory has at least one project marker. Prevents littering when
83
86
  // resolution falls through to a non-project directory.
87
+ // A state directory that is a SYMLINK is never safe: a hostile repository could point `.agentic-security` at a directory elsewhere
88
+ // and have every evidence file written (or overwritten) through it. The state root, and any state directory below it, must be a real
89
+ // directory (or not exist yet); a symlinked file inside it is refused by safeWriteState below.
90
+ function _viaSymlink(dir) {
91
+ const segments = path.resolve(dir).split(path.sep);
92
+ const idx = segments.lastIndexOf(STATE_DIR_NAME);
93
+ if (idx < 0) return false;
94
+ for (let i = idx; i < segments.length; i++) {
95
+ const prefix = segments.slice(0, i + 1).join(path.sep) || path.sep;
96
+ try { if (fs.lstatSync(prefix).isSymbolicLink()) return true; } catch { return false; /* does not exist yet: nothing to follow */ }
97
+ }
98
+ return false;
99
+ }
100
+
84
101
  export function isSafeStateDir(dir) {
85
102
  if (!dir) return false;
103
+ if (_viaSymlink(dir)) return false;
86
104
  const parent = path.dirname(dir);
87
105
  for (const m of PROJECT_MARKERS) {
88
106
  if (m === '.agentic-security') continue; // would be circular
@@ -90,6 +108,7 @@ export function isSafeStateDir(dir) {
90
108
  if (fs.existsSync(path.join(parent, m))) return true;
91
109
  } catch { /* ignore */ }
92
110
  }
111
+ try { if (fs.readdirSync(parent).some((n) => PROJECT_MARKER_SUFFIX.test(n))) return true; } catch { /* ignore */ }
93
112
  // A directory NESTED inside an already-valid state root is safe too.
94
113
  //
95
114
  // Without this the check only ever accepted `<project>/.agentic-security`
@@ -234,6 +253,8 @@ export function safeWriteState(filePath, content, { category } = {}) {
234
253
  }
235
254
  try {
236
255
  fs.mkdirSync(dir, { recursive: true });
256
+ // never write THROUGH a symlink: an attacker-placed link at the file's own path would redirect the write elsewhere
257
+ try { if (fs.lstatSync(filePath).isSymbolicLink()) return false; } catch { /* not there yet */ }
237
258
  fs.writeFileSync(filePath, content);
238
259
  return true;
239
260
  } catch {
@@ -23,7 +23,7 @@
23
23
  // .agentic-security/threat-model.md (human-readable).
24
24
 
25
25
  import * as fs from 'node:fs';
26
- import { stateDir, stateWritesEnabled } from './state-dir.js';
26
+ import { stateDir, stateWritesEnabled, safeWriteState } from './state-dir.js';
27
27
  import * as path from 'node:path';
28
28
 
29
29
  // STRIDE category descriptors
@@ -215,9 +215,9 @@ export function persistThreatModel(scanRoot, model) {
215
215
  // NON_MUTATING_SCAN_PRD S1 — a scan must not modify the tree it scans.
216
216
  if (!stateWritesEnabled()) return;
217
217
  const dir = stateDir(scanRoot);
218
- try { fs.mkdirSync(dir, { recursive: true }); } catch {}
219
- try { fs.writeFileSync(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2)); } catch {}
220
- try { fs.writeFileSync(path.join(dir, 'threat-model.md'), renderMarkdown(model)); } catch {}
218
+ // through the state seam: a symlinked state directory or file is refused, never written through
219
+ safeWriteState(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2));
220
+ safeWriteState(path.join(dir, 'threat-model.md'), renderMarkdown(model));
221
221
  }
222
222
 
223
223
  function renderMarkdown(model) {
@@ -29,13 +29,24 @@ function _save(scanRoot, data) {
29
29
 
30
30
  // Sync the triage store with the latest scan: new findings become 'open',
31
31
  // findings no longer surfaced become 'fixed' (with a transition entry).
32
- export function syncWithScan(scanRoot, findings) {
32
+ export function syncWithScan(scanRoot, findings, opts = {}) {
33
33
  const data = loadTriage(scanRoot);
34
34
  const now = new Date().toISOString();
35
35
  const seen = new Set();
36
+ // A finding that is no longer reported is only "fixed" when the scan that stopped reporting it was COMPLETE for the
37
+ // code it lives in. If its analysis was partial (a parser failed, a required analyzer was off), its absence is
38
+ // unverified, so it stays open and is marked `unverified` rather than silently closed.
39
+ const health = opts && opts.scanHealth;
40
+ const incomplete = !!(health && health.status && health.status !== 'complete');
36
41
  for (const f of findings) {
37
42
  const id = f.id || `${f.file}:${f.line}:${f.vuln}`;
38
43
  seen.add(id);
44
+ const existing = data.findings[id];
45
+ // A finding that was closed and is reported again is a regression: reopen it, never leave it 'fixed'.
46
+ if (existing && existing.state === 'fixed') {
47
+ existing.state = 'open'; existing.reopened_at = now; delete existing.fixed_at; delete existing.unverified;
48
+ data.transitions.push({ id, from: 'fixed', to: 'open', at: now, automatic: true, reason: 'reported again by a later scan' });
49
+ } else if (existing && existing.unverified) { delete existing.unverified; }
39
50
  if (!data.findings[id]) {
40
51
  data.findings[id] = {
41
52
  id,
@@ -56,6 +67,8 @@ export function syncWithScan(scanRoot, findings) {
56
67
  ecosystem: f.ecosystem || null,
57
68
  osvId: f.osvId || null,
58
69
  cveAliases: Array.isArray(f.cveAliases) ? f.cveAliases : [],
70
+ language: f.language || null,
71
+ rule: f.rule || null,
59
72
  };
60
73
  data.transitions.push({ id, from: null, to: 'open', at: now });
61
74
  }
@@ -65,6 +78,10 @@ export function syncWithScan(scanRoot, findings) {
65
78
  const cur = data.findings[id];
66
79
  if (seen.has(id)) continue;
67
80
  if (cur.state === 'fixed' || cur.state === 'wont-fix' || cur.state === 'false-positive') continue;
81
+ if (incomplete) {
82
+ if (!cur.unverified) { cur.unverified = { since: now, reason: 'not reported, but the scan was not complete' }; data.transitions.push({ id, from: cur.state, to: cur.state, at: now, automatic: true, unverified: true, reason: 'not reported by an incomplete scan' }); }
83
+ continue;
84
+ }
68
85
  cur.state = 'fixed';
69
86
  cur.fixed_at = now;
70
87
  data.transitions.push({ id, from: 'open', to: 'fixed', at: now, automatic: true });
@@ -24,6 +24,7 @@ import * as fsSync from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
 
26
26
  import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
27
+ import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, isExplicitExport, impactedFiles } from '../language/discovery.js';
27
28
  const STATUS_MD = 'watch-status.md';
28
29
  const STATUS_JSON = 'watch-status.json';
29
30
  const DEBOUNCE_MS = 350;
@@ -33,10 +34,35 @@ const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp
33
34
  const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
34
35
 
35
36
  function _isScanable(rel) {
36
- if (!rel || IGNORE_DIR_RE.test(rel)) return false;
37
+ if (!rel) return false;
38
+ // An explicit export is evidence even though it lives in a build directory.
39
+ if (isExplicitExport(rel)) return true;
40
+ if (IGNORE_DIR_RE.test(rel)) return false;
41
+ if (isLanguageSource(rel) || isLanguageManifest(rel)) return !isLanguageExcludedPath(rel);
37
42
  return SCAN_EXT_RE.test(rel);
38
43
  }
39
44
 
45
+ /**
46
+ * Widen a batch of changed absolute paths to every file whose results the
47
+ * change can stale: dependents through imports, and all Haskell/Nix sources for
48
+ * a manifest, lockfile or export change. Batches with no Haskell/Nix path are
49
+ * returned untouched, so other languages never pay for a tree read.
50
+ */
51
+ async function expandChangedBatch(scanRoot, batch) {
52
+ const rels = batch.map((p) => path.relative(scanRoot, p).split(path.sep).join('/'));
53
+ if (!rels.some((r) => isLanguageSource(r) || isLanguageManifest(r) || isExplicitExport(r))) return batch;
54
+ try {
55
+ const { readTree } = await import('../runScan.js');
56
+ const { fileContents, depFileContents } = await readTree(scanRoot);
57
+ const hit = impactedFiles(fileContents, depFileContents, rels);
58
+ const out = new Set(batch);
59
+ for (const r of hit) out.add(path.join(scanRoot, r));
60
+ return [...out];
61
+ } catch {
62
+ return batch;
63
+ }
64
+ }
65
+
40
66
  function _readJsonSafe(fp) {
41
67
  try { return JSON.parse(fsSync.readFileSync(fp, 'utf8')); } catch { return null; }
42
68
  }
@@ -144,7 +170,12 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
144
170
  if (pending.size > MAX_BURST) { pending.clear(); return; }
145
171
  const batch = Array.from(pending);
146
172
  pending.clear();
147
- try { onChange(batch); } catch {}
173
+ const needsExpansion = batch.some((p) => {
174
+ const r = path.relative(scanRoot, p).split(path.sep).join('/');
175
+ return isLanguageSource(r) || isLanguageManifest(r) || isExplicitExport(r);
176
+ });
177
+ if (!needsExpansion) { try { onChange(batch); } catch {} return; }
178
+ expandChangedBatch(scanRoot, batch).then((b) => { try { onChange(b); } catch {} });
148
179
  };
149
180
  let stopped = false;
150
181
  (async () => {
@@ -169,4 +200,4 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
169
200
  };
170
201
  }
171
202
 
172
- export const _internals = { _isScanable, SCAN_EXT_RE, IGNORE_DIR_RE };
203
+ export const _internals = { _isScanable, expandChangedBatch, SCAN_EXT_RE, IGNORE_DIR_RE };