@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
@@ -205,6 +205,23 @@ export function buildScorecard(inputs) {
205
205
  corpusBaseline: committed.corpusBaseline
206
206
  ? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
207
207
  : null,
208
+ // Haskell and Nix support (HASKELL_NIXOS_FULL_CAPABILITY_PRD.md DOC-002): the committed support registry, passed through with
209
+ // its denominators, layers, corpus revision, date and tool availability. Read, never re-measured here.
210
+ languageSupport: committed.languageSupport
211
+ ? {
212
+ source: 'docs/language-support.json',
213
+ generatedAt: committed.languageSupport.generatedAt || null,
214
+ node: committed.languageSupport.node || null,
215
+ tools: committed.languageSupport.tools || null,
216
+ targets: committed.languageSupport.targets || null,
217
+ limits: committed.languageSupport.limits || [],
218
+ languages: Object.fromEntries(Object.entries(committed.languageSupport.languages || {}).map(([k, v]) => [k, {
219
+ frozen: v.frozen || null, measuredOn: v.measuredOn || null,
220
+ rows: Object.values(v.rows || {}).map((r) => ({ capability: r.capability, status: r.status, reasons: r.reasons || [], evidence: r.evidence ? { metric: r.evidence.metric || null, layer: r.evidence.layer || null, tp: r.evidence.tp ?? null, fp: r.evidence.fp ?? null, fn: r.evidence.fn ?? null, tn: r.evidence.tn ?? null, precision: r.evidence.precision ?? null, recall: r.evidence.recall ?? null, f1: r.evidence.f1 ?? null, cases: r.evidence.cases ?? null, familyCount: r.evidence.families ? Object.keys(r.evidence.families).length : null } : null })),
221
+ unknownOutcomes: v.unknownOutcomes || null, metamorphic: v.metamorphic || null,
222
+ }])),
223
+ }
224
+ : null,
208
225
  independent: committed.independent
209
226
  ? {
210
227
  source: 'bench/independent/RESULT.json',
@@ -621,6 +638,40 @@ export function renderScorecardMarkdown(m) {
621
638
  }
622
639
  }
623
640
 
641
+ const ls = m.committedInputs.languageSupport;
642
+ if (ls) {
643
+ L.push('## Haskell and Nix/NixOS support');
644
+ L.push('');
645
+ L.push(`*Committed artifact:* \`${ls.source}\`, measured ${ls.generatedAt || 'unknown date'} on node ${ls.node || '?'}. It is read here, not re-measured by this command.`);
646
+ L.push('');
647
+ L.push('Every row is **supported** only from passing evidence of its own metric kind. Layers are scored independently (the taint layer');
648
+ L.push('is the injection families, the SAST layer is every other family) and a finding of a different family inside a case is counted');
649
+ L.push('separately (family-scoped scoring); the strict precision that counts it is stored in the registry. Denominators are cases, with');
650
+ L.push('TP/FP/FN/TN shown; a row without a denominator carries a different kind of evidence (parse counts, fix gates, manifests).');
651
+ L.push('');
652
+ for (const [lang, v] of Object.entries(ls.languages)) {
653
+ L.push(`### ${lang === 'haskell' ? 'Haskell' : 'Nix and NixOS'}`);
654
+ L.push('');
655
+ L.push('| Capability | Status | Layer | Cases | TP | FP | FN | TN | Precision | Recall | F1 | Families |');
656
+ L.push('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
657
+ const pct = (x) => (typeof x === 'number' ? `${(x * 100).toFixed(1)}%` : ', ');
658
+ for (const r of v.rows) {
659
+ const e = r.evidence;
660
+ L.push(`| ${r.capability} | ${r.status} | ${e && e.layer ? e.layer : '—'} | ${e && e.cases != null ? e.cases : '—'} | ${e && e.tp != null ? e.tp : '—'} | ${e && e.fp != null ? e.fp : '—'} | ${e && e.fn != null ? e.fn : '—'} | ${e && e.tn != null ? e.tn : '—'} | ${e ? pct(e.precision) : '—'} | ${e ? pct(e.recall) : '—'} | ${e ? pct(e.f1) : '—'} | ${e && e.familyCount != null ? e.familyCount : '—'} |`);
661
+ }
662
+ L.push('');
663
+ const blocked = v.rows.filter((r) => r.status !== 'supported');
664
+ if (blocked.length) { L.push(`Not supported here: ${blocked.map((r) => `${r.capability} (${r.status}: ${(r.reasons[0] || '').slice(0, 140)})`).join('; ')}.`); L.push(''); }
665
+ if (v.unknownOutcomes) L.push(`Unknown and unmodelled cases: ${JSON.stringify(v.unknownOutcomes)}.`);
666
+ if (v.metamorphic) L.push(`Metamorphic pairs: ${JSON.stringify(v.metamorphic)}.`);
667
+ L.push('');
668
+ }
669
+ if (ls.tools) L.push(`Tools present where this was measured: ${Object.entries(ls.tools).map(([k, x]) => `${k}=${x === false ? 'absent' : x}`).join(', ')}.`);
670
+ L.push('');
671
+ for (const lim of ls.limits) L.push(`- ${lim}`);
672
+ L.push('');
673
+ }
674
+
624
675
  const ind = m.committedInputs.independent;
625
676
  if (ind && ind.overall) {
626
677
  L.push('## Independent evaluation population — the number that matters');
@@ -20,6 +20,7 @@
20
20
  // scan.components. Extraction precision is verified by a smoke test against
21
21
  // a labelled fixture set.
22
22
 
23
+ import { extractLanguageAI } from '../language/aibom.js';
23
24
  import * as crypto from 'node:crypto';
24
25
  import { statePath, safeWriteState } from './state-dir.js';
25
26
 
@@ -199,6 +200,16 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
199
200
  if (classes.includes('vector-store')) vectorStores.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
200
201
  if (classes.includes('embedding-provider')) embeddings.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
201
202
  }
203
+ // 4. Haskell and Nix (X-002): declared / installed / enabled / invoked are kept apart, evidence is a list,
204
+ // and anything source does not spell out is `unresolved` rather than guessed.
205
+ let lang = { models: [], endpoints: [], services: [], frameworks: [], vectorStores: [], embeddings: [], promptTemplates: [], unresolved: [], links: [], gaps: [] };
206
+ try { lang = extractLanguageAI({ ...(meta.manifests || {}), ...(fileContents || {}) }); } catch { /* best effort: the JS/Python inventory above stands */ }
207
+ const knownModel = new Set(models.map((m) => `${m.provider}:${m.modelId}`));
208
+ for (const m of lang.models) { const k = `${m.provider}:${m.modelId}`; if (!knownModel.has(k)) { knownModel.add(k); models.push({ ...m, file: (m.evidence && m.evidence[0] && m.evidence[0].file) || null, line: (m.evidence && m.evidence[0] && m.evidence[0].line) || null }); } }
209
+ promptTemplates.push(...lang.promptTemplates);
210
+ const knownFw = new Set(frameworks.map((f) => `${f.ecosystem}:${f.name}`));
211
+ for (const f of lang.frameworks) { const k = `${f.ecosystem}:${f.name}`; if (!knownFw.has(k)) { knownFw.add(k); frameworks.push(f); } }
212
+ vectorStores.push(...lang.vectorStores); embeddings.push(...lang.embeddings);
202
213
  return {
203
214
  aibomFormat: 'agentic-security AI-BOM',
204
215
  version: '1',
@@ -221,6 +232,15 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
221
232
  frameworks,
222
233
  vectorStores,
223
234
  embeddings,
235
+ endpoints: lang.endpoints,
236
+ services: lang.services,
237
+ unresolved: lang.unresolved,
238
+ links: lang.links,
239
+ limits: [
240
+ 'an AI component being declared, installed or enabled does not show that a model is called or that data is sent to it',
241
+ 'models, endpoints and providers that source does not spell out are listed under `unresolved`, not guessed',
242
+ ...(lang.gaps || []).map((g) => g.detail || g.kind),
243
+ ],
224
244
  summary: {
225
245
  totalModels: models.length,
226
246
  totalProviders: new Set(models.map(m => m.provider)).size,
@@ -229,6 +249,10 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
229
249
  promptTemplates: promptTemplates.length,
230
250
  frameworks: frameworks.length,
231
251
  vectorStores: vectorStores.length,
252
+ endpoints: lang.endpoints.length,
253
+ services: lang.services.length,
254
+ embeddings: embeddings.length,
255
+ unresolved: lang.unresolved.length,
232
256
  },
233
257
  };
234
258
  }
@@ -297,6 +321,30 @@ export function aibomToMarkdown(aibom) {
297
321
  out.push('');
298
322
  }
299
323
 
324
+ const row = (cells) => `| ${cells.map((c) => String(c ?? '—').replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ')} |`;
325
+ if ((aibom.endpoints || []).length) {
326
+ out.push('## Endpoints'); out.push(''); out.push('| Provider | Purpose | URL (redacted) | Evidence |'); out.push('|---|---|---|---|');
327
+ for (const e of aibom.endpoints) out.push(row([e.provider, e.purpose, e.url, (e.evidence || []).map((x) => `${x.file}:${x.line}`).join(', ')]));
328
+ out.push('');
329
+ }
330
+ if ((aibom.services || []).length) {
331
+ out.push('## Services'); out.push(''); out.push('| Service | Kind | Status | Evidence |'); out.push('|---|---|---|---|');
332
+ for (const x of aibom.services) out.push(row([x.name, x.kind, x.status, (x.evidence || []).map((e) => `${e.file}:${e.line}`).join(', ')]));
333
+ out.push('');
334
+ }
335
+ if ((aibom.embeddings || []).length) {
336
+ out.push('## Embeddings'); out.push(''); out.push('| Name | Provider | Status |'); out.push('|---|---|---|');
337
+ for (const e of aibom.embeddings) out.push(row([e.name, e.provider, e.status]));
338
+ out.push('');
339
+ }
340
+ if ((aibom.unresolved || []).length) {
341
+ out.push('## Unresolved'); out.push(''); out.push('| Kind | Location | Reason |'); out.push('|---|---|---|');
342
+ for (const u of aibom.unresolved) out.push(row([u.kind, `${u.file}:${u.line}`, u.reason]));
343
+ out.push('');
344
+ }
345
+ if ((aibom.limits || []).length) { out.push('## Limits'); out.push(''); for (const l of aibom.limits) out.push(`- ${l}`); out.push(''); }
346
+ out.push('> This document is the proprietary agentic-security AI-BOM, not a CycloneDX document. A CycloneDX 1.6 ML-BOM view is available separately.');
347
+ out.push('');
300
348
  return out.join('\n');
301
349
  }
302
350
 
@@ -324,9 +372,9 @@ export function toCycloneDXMLBOM(aibom, meta = {}) {
324
372
  const models = (aibom && aibom.models) || [];
325
373
  const components = models.map((m) => ({
326
374
  type: 'machine-learning-model',
327
- 'bom-ref': `model:${m.provider || 'unknown'}/${m.name || 'unknown'}${m.version ? `@${m.version}` : ''}`,
328
- name: m.name || 'unknown',
329
- ...(m.version ? { version: m.version } : {}),
375
+ 'bom-ref': `model:${m.provider || 'unknown'}/${m.name || m.modelId || 'unknown'}${m.version || m.revision ? `@${m.version || m.revision}` : ''}`,
376
+ name: m.name || m.modelId || 'unknown',
377
+ ...(m.version || m.revision ? { version: m.version || m.revision } : {}),
330
378
  modelCard: {
331
379
  modelParameters: {
332
380
  ...(m.provider ? { approach: { type: 'supervised' } } : {}),
@@ -359,6 +407,10 @@ export function toCycloneDXMLBOM(aibom, meta = {}) {
359
407
  component: { type: 'application', name: 'scan-target', version: '1.0.0' },
360
408
  },
361
409
  components,
410
+ ...(((aibom && aibom.services) || []).length || ((aibom && aibom.endpoints) || []).length ? { services: [
411
+ ...(aibom.services || []).map((x) => ({ 'bom-ref': `service:${x.name}`, name: x.name, provider: { name: x.provider || 'unknown' }, description: `${x.kind} (${x.status})`, properties: [{ name: 'agentic-security:status', value: String(x.status) }, { name: 'agentic-security:egress', value: 'not-established' }] })),
412
+ ...(aibom.endpoints || []).map((e) => ({ 'bom-ref': `endpoint:${e.url}`, name: `${e.provider} ${e.purpose}`, provider: { name: e.provider }, endpoints: [e.url], properties: [{ name: 'agentic-security:status', value: String(e.status) }] })),
413
+ ] } : {}),
362
414
  };
363
415
  }
364
416
 
@@ -394,6 +446,12 @@ export function validateMLBOM(doc) {
394
446
  `components[${i}] is a machine-learning-model but carries no modelCard — that is the whole ML-BOM extension`);
395
447
  }
396
448
  }
449
+ for (const [i, sv] of (Array.isArray(doc.services) ? doc.services : []).entries()) {
450
+ req(typeof sv.name === 'string' && sv.name, `services[${i}].name is required`);
451
+ if (sv.endpoints !== undefined) req(Array.isArray(sv.endpoints) && sv.endpoints.every((u) => typeof u === 'string' && !/\/\/[^/@]*:[^/@]*@/.test(u)), `services[${i}].endpoints must be URL strings without credentials`);
452
+ }
453
+ const refs = (Array.isArray(doc.components) ? doc.components : []).concat(Array.isArray(doc.services) ? doc.services : []).map((c) => c && c['bom-ref']).filter(Boolean);
454
+ req(new Set(refs).size === refs.length, 'bom-ref values must be unique');
397
455
  return { ok: errors.length === 0, errors, checked: 'structural (required fields + ML-BOM component shape), NOT full JSON-Schema validation' };
398
456
  }
399
457
 
@@ -95,6 +95,16 @@ export const ARTIFACT_REGISTRY = [
95
95
  // clears by default, same as last-scan.json itself.
96
96
  { name: 'lineage-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
97
97
  { name: 'lineage-graph.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
98
+ // X-016: Haskell/Nix analysis metadata (capability status, parser/resolver ledger, limitations, input digests) and the
99
+ // language BOM, written by src/language/state-artifacts.js beside last-scan.json. Paths and digests only, never source
100
+ // text. Scan-class retention for the metadata; the BOM is evidence.
101
+ { name: 'language-analysis.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
102
+ { name: 'language-analysis.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
103
+ { name: 'language-bom.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
104
+ { name: 'language-bom.json.sig', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
105
+ // X-016: pre-image of every file a Haskell/Nix fix rewrote (original text + meta), the undo source. It holds customer
106
+ // source, so it is a backup-class artifact and marked confidential for the encryption policy.
107
+ { name: 'fix-backups', kind: 'dir', classification: 'generated', retentionClass: 'backup', confidential: true, source: 'src/language/fix-lifecycle.js (writeWithBackup)' },
98
108
  // M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): the "Data-Flow
99
109
  // Time Machine" foundation — one commit-keyed GraphSnapshot per scan,
100
110
  // written by src/lineage/graph-snapshot.js's persistGraphSnapshot(),
@@ -237,6 +247,8 @@ export const ARTIFACT_REGISTRY = [
237
247
  { name: 'provenance-providers.yml', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M3 §3.4 GitHub/GitLab provider enrichment opt-in ({token} or provider-scoped tokens) — read by posture/provenance/providers/config.js, never written by the scanner; env vars (AGENTIC_SECURITY_GITHUB_TOKEN/AGENTIC_SECURITY_GITLAB_TOKEN) take precedence when set' },
238
248
  { name: 'repo-lineage.json', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M4 §4.2 cross-repository lineage declaration ({linkedFrom: {path, atCommit}}) — read by posture/provenance/repo-lineage.js, never written by the scanner; the linked path is verified as a real local git repo before use, no remote fetch' },
239
249
  { name: 'recipient-profiles.json', kind: 'file', classification: 'operator-config', note: 'FR-506 recipient/subprocessor governance profiles — hand- or agent-authored via `governance propose-edit`, never scanner-regenerable, so a routine reset must never delete it' },
250
+ { name: 'hackage-advisories.json', kind: 'file', classification: 'operator-config', note: 'X-016: operator-supplied Hackage advisory snapshot read by language/haskell-supply.js; never written by the scanner, so `reset` preserves it' },
251
+ { name: 'nix-advisories.json', kind: 'file', classification: 'operator-config', note: 'QA-005: operator-supplied Nix advisory snapshot read by language/resolved-pass.js; never written by the scanner, so `reset` preserves it' },
240
252
  { name: 'cross-repo-links.json', kind: 'file', classification: 'operator-config', note: 'M5 deliverable #8 (FR-304 "declared" half) — declared local<->remote node links between two independently-scanned repos, written via `federate declare --yes`, never scanner-regenerable, so a routine reset must never delete it' },
241
253
  { name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
242
254
  { name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
@@ -43,11 +43,13 @@
43
43
  // attestation" describes independent certification, not attestation).
44
44
 
45
45
  import * as fs from 'node:fs';
46
+ import * as crypto from 'node:crypto';
46
47
  import * as path from 'node:path';
47
48
 
48
49
  import { statePath, stateWritesEnabled } from './state-dir.js';
49
50
  import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
50
51
  import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
52
+ import { LANGUAGE_COMPLIANCE_FAMILIES, languageAnalysisGaps } from '../language/compliance-map.js';
51
53
  import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
52
54
  // FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
53
55
  // metadata. renderWalkthrough()'s output is console.log'd verbatim by
@@ -497,6 +499,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
497
499
  || logicVulns.length > 0
498
500
  || supplyChain.length > 0;
499
501
 
502
+ const langIncomplete = languageAnalysisGaps(scan);
500
503
  const results = [];
501
504
  for (const c of fw.controls || []) {
502
505
  const obs = [];
@@ -599,6 +602,12 @@ export function evaluateFramework(scanRoot, fw, scan) {
599
602
  allCleared = false;
600
603
  contributingFindings.push(...open);
601
604
  obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
605
+ } else if (langIncomplete.incomplete && LANGUAGE_COMPLIANCE_FAMILIES.has(fam)) {
606
+ // Empty bucket, but Haskell/Nix analysis in this scan did not complete (a parser failed, a required analyzer
607
+ // was off, an effective configuration is partial): an empty bucket is then not evidence, and an unknown
608
+ // option or an unresolved taint result must never read as satisfied.
609
+ obs.push(`⚠ ${fam}: Haskell/Nix analysis was incomplete (${langIncomplete.reasons[0]}${langIncomplete.reasons.length > 1 ? `; +${langIncomplete.reasons.length - 1} more` : ''}), so an empty finding bucket is not evidence (not assessed).`);
610
+ hasUnverifiableMapping = true;
602
611
  } else if (!assessable) {
603
612
  // Empty bucket, but the scan examined nothing — see the guard above.
604
613
  // Not evidence, and deliberately not a failure either.
@@ -855,4 +864,36 @@ export function persistWalkthrough(scanRoot, fw, body) {
855
864
  return fp;
856
865
  }
857
866
 
867
+ /**
868
+ * A signable evidence document for one framework evaluation (the same shape `signComplianceEvidence` covers), so a
869
+ * walkthrough can be handed to an auditor with an integrity signature. Statuses, observations and finding references
870
+ * are copied from the evaluation; nothing is added. The language-analysis gaps that capped any control travel in
871
+ * `provenance`, so a reader of the signed document sees why a control is "partial".
872
+ */
873
+ export function walkthroughToEvidenceJsonLd(fw, evaluation, { scan = null, engineVersion = null, generatedAt = null } = {}) {
874
+ const controls = (evaluation || []).map((e) => ({
875
+ '@type': 'Control', id: e.control && e.control.id, title: e.control && e.control.summary, status: e.status,
876
+ observations: e.observations || [], controlRefs: e.controlRefs || [],
877
+ codeTestable: (e.control && e.control.codeTestable) || null,
878
+ }));
879
+ const count = (st) => controls.filter((c) => c.status === st).length;
880
+ const gaps = languageAnalysisGaps(scan);
881
+ const digest = crypto.createHash('sha256').update(JSON.stringify(controls)).digest('hex');
882
+ return {
883
+ '@context': { '@vocab': 'https://agentic-security.io/compliance/v1/', schema: 'https://schema.org/' },
884
+ '@type': 'ComplianceEvidence',
885
+ schemaVersion: 1,
886
+ statusSemantics: 'present = no open findings on every mapped family and nothing capped it; partial = evidence with a gap or an unverifiable mapping; absent = nothing checked out; manual = no automated mapping',
887
+ policySource: `bundled:${fw.id}`,
888
+ framework: fw.id,
889
+ version: fw.controlsDigest || null,
890
+ generatedAt: generatedAt || new Date().toISOString(),
891
+ disclaimer: EVIDENCE_GRADE_DISCLAIMER_SHORT,
892
+ provenance: { engineVersion, languageAnalysis: { present: gaps.present, incomplete: gaps.incomplete, reasons: gaps.reasons } },
893
+ evidenceDigest: digest,
894
+ summary: { total: controls.length, present: count('present'), partial: count('partial'), absent: count('absent'), manual: count('manual') },
895
+ controls,
896
+ };
897
+ }
898
+
858
899
  export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };
@@ -38,8 +38,8 @@ function sinkKey(f) {
38
38
  // finding reaching this point has a distinct line — folding the line into
39
39
  // the key here can only split buckets more finely, never wrongly merge one.
40
40
  const sinkExpr = (parser === 'IR-TAINT'
41
- ? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}`
42
- : (f.sink?.label || f.sink?.snippet || f.snippet || ''))
41
+ ? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}${f.siteKey ? `:${f.siteKey}` : ''}` // Haskell: distinct sinks on one line stay distinct
42
+ : `${f.sink?.label || f.sink?.snippet || f.snippet || ''}${f.siteKey ? `|${f.siteKey}` : ''}`)
43
43
  .replace(/['"`][^'"`]*['"`]/g, '_S_')
44
44
  .replace(/\s+/g, ' ')
45
45
  .trim()
@@ -197,6 +197,12 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
197
197
  cwe: finding.cwe ?? null,
198
198
  family: finding.family ?? null,
199
199
  parser: finding.parser ?? null,
200
+ // Haskell/Nix findings carry where the code is and what kind of evidence decided them. Added only when present, so
201
+ // the canonical bytes (and signatures) of every existing bundle are unchanged.
202
+ ...(finding.language ? { language: finding.language } : {}),
203
+ ...(finding.evidenceKind ? { evidenceKind: finding.evidenceKind } : {}),
204
+ ...(finding.originalLocation ? { originalLocation: finding.originalLocation } : {}),
205
+ ...(finding.generatedLocation ? { generatedLocation: finding.generatedLocation } : {}),
200
206
  },
201
207
  evidence: {
202
208
  // The honest chain, in the vocabulary the engine already uses.
@@ -206,7 +212,7 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
206
212
  exploitability: finding.exploitability ?? null,
207
213
  unreachable: finding.unreachable ?? null,
208
214
  // Present only for the layers that produce them; absent means absent.
209
- taintPath: finding.pathSteps ?? finding.exampleFlows ?? null,
215
+ taintPath: finding.pathSteps ?? finding.exampleFlows ?? (Array.isArray(finding.chain) && finding.language ? finding.chain : null),
210
216
  discovery: finding.discovery ?? null,
211
217
  },
212
218
  engine: {
@@ -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"