@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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 (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -4,6 +4,20 @@ import { _isCustomSuppressed } from '../engine.js';
4
4
  import { alertFace, approveFace } from './mascot.js';
5
5
  import { SCANNER_VERSION } from '../posture/version.js';
6
6
  import { proofBlock } from '../posture/proof-artifact.js';
7
+ import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
8
+ // Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
9
+ // git-origin record from posture/provenance/. It is NOT `finding.provenance`
10
+ // (posture/ai-code-fingerprint.js's AI-authorship signal, normalized a few
11
+ // hundred lines below) and NOT `supplyChainEntry.provenance`
12
+ // (sca/sigstore-verify.js's SLSA/Sigstore build attestation). Three unrelated
13
+ // things, three distinct keys — do not collapse them.
14
+ import { redactFindingProvenance, sanitizeForTerminal } from '../posture/provenance/schema.js';
15
+ // Re-exported: FR-PROV-026. Lives in provenance/schema.js (shared with
16
+ // posture/auditor-walkthrough.js, a second CLI renderer of the same
17
+ // untrusted fields — see that module's header for why it lives there
18
+ // rather than here); re-exported so `explainProvenance`'s own module keeps
19
+ // being the discoverable home for provenance-text callers.
20
+ export { sanitizeForTerminal };
7
21
 
8
22
  const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
9
23
  const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
@@ -83,6 +97,62 @@ function explainParts(f, { verbose = false } = {}) {
83
97
  return { why, how, fix: fix.replace(/\s+/g, ' ').trim(), fixCode };
84
98
  }
85
99
 
100
+ // Human-readable rendering of a finding's git-origin provenance (FR-PROV
101
+ // output surface). Returns null when the finding carries none — a caller
102
+ // prints the block or omits it, and never has to know the schema.
103
+ //
104
+ // Every one of the six TERMINAL statuses gets a line of its own, and the
105
+ // non-`complete` ones say WHY in the status word itself rather than rendering
106
+ // an empty origin: that is the whole reason the status enum exists (see
107
+ // posture/provenance/coordinator.js's header). `Method` and `Confidence` are
108
+ // unconditional — a block that omitted them for a degraded status would read
109
+ // as "origin unknown" when the honest statement is "resolved by <method> to
110
+ // <confidence>". Nothing here throws on a partially-populated object; every
111
+ // field access is optional-chained or defaulted, because a provenance record
112
+ // that survived a failure path is exactly the input this has to render.
113
+ export function explainProvenance(f) {
114
+ const fp = f && f.findingProvenance;
115
+ if (!fp) return null;
116
+ const short = (v) => String(v || '').slice(0, 7);
117
+ const day = (v) => String(v || '').slice(0, 10);
118
+ const lines = [];
119
+ const o = fp.findingOrigin;
120
+ if (fp.status === 'complete' && o) {
121
+ lines.push(`Introduced: ${short(o.commit)} • ${day(o.authorDate)} • ${sanitizeForTerminal(o.authorName) || 'unknown'}`);
122
+ const bi = fp.branchIntroduction;
123
+ if (bi && bi.commit !== o.commit) {
124
+ // relationship is an internal enum ('merge'/'direct', see
125
+ // branch-entry.js) never sourced from untrusted git text — wrapped
126
+ // anyway for defense in depth and consistency with authorName above.
127
+ lines.push(`Branch entry: ${short(bi.commit)} • ${sanitizeForTerminal(bi.relationship) || 'unknown relationship'}`);
128
+ }
129
+ } else if (fp.status === 'partial') {
130
+ lines.push(`Origin: EARLIEST OBSERVABLE${o ? ' ' + short(o.commit) : ''}`);
131
+ } else if (fp.status === 'uncommitted') {
132
+ lines.push('Origin: UNCOMMITTED (working tree only)');
133
+ } else if (fp.status === 'not_available') {
134
+ lines.push('Origin: NOT AVAILABLE');
135
+ } else if (fp.status === 'error') {
136
+ lines.push('Origin: ERROR resolving provenance');
137
+ } else if (fp.status === 'budget_exhausted') {
138
+ lines.push('Origin: BUDGET EXHAUSTED before resolution completed');
139
+ } else {
140
+ // An unrecognised status is reported as itself, not silently dropped — a
141
+ // future status added to the enum must be visible here on the day it
142
+ // ships, even before this renderer learns a nicer wording for it.
143
+ lines.push(`Origin: ${String(fp.status || 'unknown').toUpperCase()}`);
144
+ }
145
+ if (fp.firstObserved) {
146
+ lines.push(`First observed: ${fp.firstObserved.scanId || 'unknown scan'} • ${day(fp.firstObserved.observedAt)}`);
147
+ }
148
+ lines.push(`Method: ${fp.method || 'none'}`);
149
+ lines.push(`Confidence: ${String(fp.confidence?.level || 'unknown').toUpperCase()}`);
150
+ if (Array.isArray(fp.limitations) && fp.limitations.length) {
151
+ lines.push(`Limitations: ${fp.limitations.join('; ')}`);
152
+ }
153
+ return lines.join('\n');
154
+ }
155
+
86
156
  function fingerprint(f){
87
157
  const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
88
158
  return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
@@ -107,6 +177,24 @@ export function _remediationOf(f) {
107
177
  return null;
108
178
  }
109
179
 
180
+ // Redacted passthrough for the git-origin provenance record (FR-PROV output
181
+ // surface). Author EMAIL is PII and is withheld by default EVERYWHERE — raw
182
+ // JSON included, since `toJSON` derives from this same function and an email
183
+ // in a committed report artifact is a leak nobody opted into. Set
184
+ // AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1 to include it (read per call, not
185
+ // cached at module load, so a test or a wrapper can set it per invocation).
186
+ // `authorName` is deliberately NOT redacted by default: it is already the
187
+ // value the existing `introducedBy` field has carried for releases. Set
188
+ // AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 (--pseudonymize-authors) to replace
189
+ // it with a stable Contributor-XXXXXXXX id instead — PRD Section 8.
190
+ function _normalizedProvenance(f) {
191
+ if (!f || !f.findingProvenance) return null;
192
+ return redactFindingProvenance(f.findingProvenance, {
193
+ includeEmail: process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL === '1',
194
+ pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
195
+ });
196
+ }
197
+
110
198
  export function normalizeFindings(scan){
111
199
  const out = [];
112
200
  // Feat-4: filter findings via custom suppressions, recording the suppression
@@ -249,6 +337,10 @@ export function normalizeFindings(scan){
249
337
  cloneClusterSize: typeof f.cloneClusterSize === 'number' ? f.cloneClusterSize : null,
250
338
  provenance: f.provenance || null,
251
339
  provenanceScore: typeof f.provenanceScore === 'number' ? f.provenanceScore : null,
340
+ // posture/provenance/coordinator.js#annotateGitProvenance — WHICH COMMIT
341
+ // introduced this finding. A different question, and a different field,
342
+ // from the two lines above it (see the import comment at the top).
343
+ findingProvenance: _normalizedProvenance(f),
252
344
  typeNarrowed: f.typeNarrowed || null,
253
345
  strideCategory: f.strideCategory || null,
254
346
  personaScores: f.personaScores || null,
@@ -348,6 +440,15 @@ export function normalizeFindings(scan){
348
440
  commit: s.commit || null,
349
441
  historical: s._historical === true,
350
442
  description: s.description || null,
443
+ // Second independent Finding Provenance PRD audit (Task 7, item 4):
444
+ // `stableId` was emitted for the SAST channel only (below), so a
445
+ // consumer of a secrets/logic/SCA finding could not recompute
446
+ // `findingProvenance.evidenceDigest` (it binds `stableId` as its first
447
+ // input — coordinator.js's `computeDigest`) — the exact case this
448
+ // channel needs it for, since `annotateStableIds(aSecrets)` (engine.js)
449
+ // already backfills a real one before provenance resolution runs.
450
+ stableId: s.stableId || null,
451
+ findingProvenance: _normalizedProvenance(s),
351
452
  });
352
453
  }
353
454
  for (const lv of (scan.logicVulns||[])) {
@@ -375,6 +476,15 @@ export function normalizeFindings(scan){
375
476
  ecosystem: lv.ecosystem || null,
376
477
  license: lv.license || null,
377
478
  description: lv.description || null,
479
+ // Second independent Finding Provenance PRD audit (Task 7, item 4) —
480
+ // same reasoning as the secrets channel above. Note: only the
481
+ // `blameableLogic` subset (engine.js) gets a real backfilled stableId
482
+ // via `annotateStableIds`; the three synthetic-line producers
483
+ // (license-policy:/deploy-platform:/stack-playbook:) never go through
484
+ // that backfill and stay `null` here — honest, since they also stay on
485
+ // the permanent not_available provenance path (posture/CLAUDE.md).
486
+ stableId: lv.stableId || null,
487
+ findingProvenance: _normalizedProvenance(lv),
378
488
  });
379
489
  }
380
490
  for (const sc of (scan.supplyChain||[])) {
@@ -424,8 +534,28 @@ export function normalizeFindings(scan){
424
534
  toxicity: sc.toxicityScore ?? null,
425
535
  toxicityFactors: sc.toxicityFactors || null,
426
536
  toxicityLabel: sc.toxicityLabel || null,
537
+ // Git-origin provenance for the dependency declaration (which commit
538
+ // moved the declared version into the advisory's vulnerable range).
539
+ // Distinct from `sc.provenance`, which sca/sigstore-verify.js uses for
540
+ // the package's Sigstore/SLSA build attestation — that one is NOT
541
+ // carried here today and must not be conflated with this field.
542
+ // Second independent Finding Provenance PRD audit (Task 7, item 4) —
543
+ // same reasoning as the secrets/logic channels above. Only entries
544
+ // routed through annotateGitProvenance (engine.js's `directDeps`/
545
+ // `transitiveDeps`, `isSca`/`isTransitiveSca` in coordinator.js) get a
546
+ // real backfilled `scaStableId`; an entry that was never routed
547
+ // through it (e.g. `unpinned_dep`/`no_lockfile` types) honestly stays
548
+ // `null` here rather than fabricating one.
549
+ stableId: sc.stableId || null,
550
+ findingProvenance: _normalizedProvenance(sc),
427
551
  });
428
552
  }
553
+ // FR-108: backfill deprecated field names from their current replacements
554
+ // before this shape reaches ANY consumer (JSON/SARIF/HTML/CSV/JUnit/MCP
555
+ // all derive from this one function) — one place, covers every output
556
+ // format, matches this function's own role as "the canonical shape".
557
+ for (const f of out) applyLegacyCompat(f);
558
+
429
559
  // Sort by severity tier, then within a tier by EPSS percentile (desc) so that
430
560
  // CVEs with active in-the-wild abuse float above theoretical CVEs.
431
561
  return out.sort((a, b) => {
@@ -516,6 +646,13 @@ export function toJSON(scan, meta={}, opts={}){
516
646
  // threw and were skipped. The findings still ship; downstream consumers
517
647
  // see the gap.
518
648
  annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
649
+ // FR-201 (assurance-hardening PRD, E2): per-file SAST/logic/secrets
650
+ // detector errors, captured by pipeline/detector-runner.js's runDetector()
651
+ // isolation wrapper. Empty array means clean; entries here mean one or
652
+ // more detectors threw on a specific file and were skipped -- other
653
+ // detectors and other files still ran (that isolation is the acceptance
654
+ // criterion itself, not just this field's presence).
655
+ detectorErrors: Array.isArray(scan.detectorErrors) ? scan.detectorErrors : [],
519
656
  // R4 — run attestation: a stable, order-independent digest over this
520
657
  // finding set bound to the engine/ruleset/bundle that produced it.
521
658
  // Attached by the CLI (posture/attestation.js); null when not computed.
@@ -529,6 +666,10 @@ export function toJSON(scan, meta={}, opts={}){
529
666
  // render an empty one. An AUTHORISED suppression is reported too — the
530
667
  // signature proves who asked for it, not that the results are absent.
531
668
  suppressedRules: scan.suppressedRules || null,
669
+ // FR-108: null in the (eventual, intended) steady state where no finding
670
+ // used a deprecated field name; present only when normalizeFindings()
671
+ // actually backfilled a legacy alias, naming exactly which and why.
672
+ legacyFieldNotice: legacyFieldDeprecationNotice(findings),
532
673
  _scanMeta: scan._scanMeta || null,
533
674
  // S7: engine.js computes these on every scan with components, and their
534
675
  // own findings already flow into `findings` above — but the structured
@@ -546,6 +687,21 @@ export function toJSON(scan, meta={}, opts={}){
546
687
  rootCauseSweep: scan.rootCauseSweep || null,
547
688
  attackTaxonomy: scan.attackTaxonomy || null,
548
689
  privacyFramework: scan.privacyFramework || null,
690
+ // FR-405: `false` is a real, meaningful value here (privacy analysis
691
+ // ran but was not IR-backed) — must not collapse to null via `||`,
692
+ // which would make it indistinguishable from "never ran at all".
693
+ privacyIrBacked: scan.privacyIrBacked ?? null,
694
+ // FR-402: which privacy data-classification taxonomy version actually
695
+ // classified this scan (built-in, or built-in+custom when an operator's
696
+ // .agentic-security/privacy-taxonomy.json extended it) — see
697
+ // dataflow/privacy-taxonomy.js. Null means the annotator never ran.
698
+ privacyTaxonomyVersion: scan.privacyTaxonomyVersion ?? null,
699
+ // Assurance-hardening PRD FR-206 (Milestone 0): additive scan-health
700
+ // summary (pipeline/scan-health.js) — separates "no findings" from
701
+ // "analysis complete". Null when the engine did not compute one (e.g. a
702
+ // hand-built `scan` object in a test), same convention as the S7 fields
703
+ // above; a real scan always computes it.
704
+ scanHealth: scan.scanHealth || null,
549
705
  };
550
706
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
551
707
  return out;
@@ -643,15 +799,22 @@ export function toCSV(scan){
643
799
  const s = String(v);
644
800
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
645
801
  };
646
- const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet'];
802
+ // FR-PROV-018: a nested findingProvenance object has no natural CSV
803
+ // representation, so this is a deliberate flattening (status/commit/
804
+ // authorDate/confidence), not full fidelity. Appended after the existing
805
+ // columns so a spreadsheet already keyed on column position is unaffected.
806
+ const header = ['id', 'severity', 'vuln', 'cwe', 'cvss', 'owasp', 'file', 'line', 'confidence', 'reachable', 'kind', 'snippet', 'provenanceStatus', 'provenanceCommit', 'provenanceAuthorDate', 'provenanceConfidence'];
647
807
  const rows = [header.join(',')];
648
808
  for (const f of findings) {
809
+ const fp = f.findingProvenance;
649
810
  rows.push([
650
811
  esc(f.id), esc(f.severity), esc(f.vuln), esc(f.cwe), esc(f.cvss || ''),
651
812
  esc(f.owasp || ''), esc(f.file), esc(f.line),
652
813
  esc(f.confidence == null ? '' : f.confidence.toFixed(3)),
653
814
  esc(f.reachable == null ? '' : f.reachable),
654
815
  esc(f.kind), esc((f.snippet || '').slice(0, 200)),
816
+ esc(fp?.status || ''), esc(fp?.findingOrigin?.commit || ''),
817
+ esc(fp?.findingOrigin?.authorDate || ''), esc(fp?.confidence?.level || ''),
655
818
  ].join(','));
656
819
  }
657
820
  return rows.join('\n');
@@ -699,6 +862,15 @@ export function toJUnit(scan, meta={}){
699
862
  return lines.join('\n');
700
863
  }
701
864
 
865
+ // FR-PROV-026: length of the Markdown code fence needed to safely wrap
866
+ // `text` without it being able to break out via an embedded backtick run
867
+ // (e.g. from an unsanitized-for-backticks authorName). Minimum 3, per
868
+ // CommonMark; longer only when `text` itself contains a run that long.
869
+ function _mdFenceLen(text) {
870
+ const runs = String(text == null ? '' : text).match(/`+/g) || [];
871
+ return Math.max(3, ...runs.map(r => r.length + 1));
872
+ }
873
+
702
874
  export function toMarkdown(scan, meta={}){
703
875
  const findings = normalizeFindings(scan);
704
876
  const lines = ['# Agentic Security — Scan Report', ''];
@@ -731,6 +903,32 @@ export function toMarkdown(scan, meta={}){
731
903
  lines.push(`| \`${f.file}:${f.line}\` | ${f.vuln} | ${f.cwe||'—'} | ${epss} | ${fix.replace(/\|/g,'\\|').slice(0,140)} |`);
732
904
  }
733
905
  }
906
+ // FR-PROV-018: one provenance block per finding that has one, reusing
907
+ // explainProvenance's content — never a second, divergent renderer.
908
+ const withProvenance = bySev[sev].filter(f => f.findingProvenance);
909
+ if (withProvenance.length) {
910
+ lines.push('');
911
+ lines.push('<details><summary>Provenance</summary>');
912
+ lines.push('');
913
+ for (const f of withProvenance) {
914
+ const block = explainProvenance(f);
915
+ if (!block) continue;
916
+ lines.push(`**\`${f.file}:${f.line}\`** — ${f.vuln}`);
917
+ // FR-PROV-026: sanitizeForTerminal (applied inside explainProvenance)
918
+ // strips control chars but NOT backticks, which are ordinary text —
919
+ // a malicious authorName containing ``` could otherwise break out of
920
+ // a fixed 3-backtick fence and inject raw Markdown/HTML into the
921
+ // report. Use a fence one backtick longer than any run already in
922
+ // the block (CommonMark's own escaping mechanism for fenced code),
923
+ // so normal content (never containing backticks) is unaffected.
924
+ const fence = '`'.repeat(_mdFenceLen(block));
925
+ lines.push(fence);
926
+ lines.push(block);
927
+ lines.push(fence);
928
+ lines.push('');
929
+ }
930
+ lines.push('</details>');
931
+ }
734
932
  lines.push('');
735
933
  }
736
934
  return lines.join('\n');
@@ -808,6 +1006,16 @@ export function toSARIF(scan, meta={}){
808
1006
  ...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
809
1007
  ...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
810
1008
  ...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
1009
+ // FR-PROV-018: run-level provenance summary — how many results
1010
+ // resolved which terminal status, so a SARIF consumer can judge
1011
+ // history coverage without walking every result's properties.
1012
+ ...(findings.some(f => f.findingProvenance) ? {
1013
+ provenanceCoverage: findings.reduce((acc, f) => {
1014
+ const s = f.findingProvenance?.status || 'none';
1015
+ acc[s] = (acc[s] || 0) + 1;
1016
+ return acc;
1017
+ }, {}),
1018
+ } : {}),
811
1019
  },
812
1020
  }],
813
1021
  results: findings.map(f => {
@@ -872,6 +1080,12 @@ export function toSARIF(scan, meta={}){
872
1080
  signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
873
1081
  ...(f._unsigned ? { unsigned: true } : {}),
874
1082
  ...(f._passThroughSigning ? { passThroughSigning: true } : {}),
1083
+ // FR-PROV-018: `f` is already normalized (findingProvenance already
1084
+ // passed through _normalizedProvenance/redactFindingProvenance by
1085
+ // normalizeFindings), so this is a redacted passthrough, never a
1086
+ // second redaction pass and never a read of a raw pre-normalization
1087
+ // finding.
1088
+ ...(f.findingProvenance ? { findingProvenance: f.findingProvenance } : {}),
875
1089
  },
876
1090
  };}),
877
1091
  }],
@@ -933,7 +1147,7 @@ export function toHTML(scan, meta = {}) {
933
1147
  // server-side) so the browser render shows them without a second command.
934
1148
  const findings = normalizeFindings(scan).map(f => {
935
1149
  const ex = explainParts(f, { verbose: true });
936
- return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how };
1150
+ return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how, _explainProvenance: explainProvenance(f) };
937
1151
  });
938
1152
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
939
1153
  for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
@@ -1011,6 +1225,8 @@ export function toHTML(scan, meta = {}) {
1011
1225
  .f-how{margin-top:6px;color:#94a3b8;font-size:13px}
1012
1226
  .f-how code{font-family:ui-monospace,monospace;color:#e2e8f4}
1013
1227
  .f-fix{background:#0d1f3d;border-left:3px solid #38bdf8;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
1228
+ .f-provenance{background:#0f1f14;border-left:3px solid #34d058;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
1229
+ .f-provenance pre{background:transparent;padding:0;margin:4px 0 0 0}
1014
1230
  .hidden{display:none!important}
1015
1231
  </style></head>
1016
1232
  <body>
@@ -1066,6 +1282,7 @@ function makeCard(f) {
1066
1282
  (f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
1067
1283
  (f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
1068
1284
  (f.fix && f.fix.description ? '<div class="f-fix"><b>Fix:</b> ' + esc(f.fix.description) + (f.fix.code ? '<pre>' + esc(f.fix.code) + '</pre>' : '') + '</div>' : '') +
1285
+ (f._explainProvenance ? '<div class="f-provenance"><b>Provenance:</b><pre>' + esc(f._explainProvenance) + '</pre></div>' : '') +
1069
1286
  '</div>';
1070
1287
  div.addEventListener('click', () => div.classList.toggle('expanded'));
1071
1288
  return div;
@@ -1145,7 +1362,10 @@ const RESET = '\x1b[0m';
1145
1362
  const DIM = '\x1b[2m';
1146
1363
  const BOLD = '\x1b[1m';
1147
1364
 
1148
- export function toCLI(scan, { verbose=false, color=true }={}){
1365
+ // `provenance` defaults to FALSE on purpose: the block is five-plus extra
1366
+ // lines per finding, and the default CLI listing is already dense. It prints
1367
+ // only when the operator asks for it.
1368
+ export function toCLI(scan, { verbose=false, color=true, provenance=false }={}){
1149
1369
  const findings = normalizeFindings(scan);
1150
1370
  const lines = [];
1151
1371
  const c = (s, code) => color ? `${code}${s}${RESET}` : s;
@@ -1171,6 +1391,13 @@ export function toCLI(scan, { verbose=false, color=true }={}){
1171
1391
  if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
1172
1392
  if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
1173
1393
  if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
1394
+ if (provenance) {
1395
+ // `f` here is already normalized, so its findingProvenance has been
1396
+ // through redactFindingProvenance — the email is gone before it can
1397
+ // reach a terminal.
1398
+ const prov = explainProvenance(f);
1399
+ if (prov) for (const ln of prov.split('\n')) lines.push(` ${c(ln, DIM)}`);
1400
+ }
1174
1401
  }
1175
1402
  lines.push('');
1176
1403
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
@@ -1284,17 +1511,33 @@ export function toShipVerdict(scan, options = {}) {
1284
1511
  const confirmedCount = findings.filter(f => f.validated === true || f.confirmed === true).length;
1285
1512
  const cats = categoryScores(findings);
1286
1513
 
1514
+ // FR-206: "a partial zero-finding scan never says clean." `scanHealth`
1515
+ // (computeScanHealth, scan-health.js) was already computed correctly —
1516
+ // an annotator error, a file timeout, or a skipped analyzer already
1517
+ // demoted `status` to 'partial' — but nothing in this, the actual
1518
+ // human-facing one-screen verdict, ever consulted it. Zero actionable
1519
+ // findings on a scan that didn't finish cleanly is "we don't know," not
1520
+ // "safe."
1521
+ const scanIncomplete = scan.scanHealth && scan.scanHealth.status && scan.scanHealth.status !== 'complete';
1522
+
1287
1523
  const lines = [];
1288
1524
  const bar = '─────────────────────────────────────────';
1289
1525
  // Patch the mascot reacts to the result — APPROVE if clean, ALERT if findings.
1290
- lines.push(actionable.length === 0 ? approveFace({ color }) : alertFace({ color }));
1526
+ const clean = actionable.length === 0 && !scanIncomplete;
1527
+ lines.push(clean ? approveFace({ color }) : alertFace({ color }));
1291
1528
  lines.push(bar);
1292
- if (actionable.length === 0) {
1529
+ if (clean) {
1293
1530
  lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
1531
+ } else if (actionable.length === 0) {
1532
+ lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
1294
1533
  } else {
1295
1534
  lines.push(c(' ❌ Not safe to deploy', SEV_COLOR.critical + BOLD));
1296
1535
  }
1297
1536
  lines.push(bar);
1537
+ if (scanIncomplete) {
1538
+ const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
1539
+ lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
1540
+ }
1298
1541
  lines.push(` • ${sev.critical} critical · ${sev.high} high · ${advisoryCount} advisory`);
1299
1542
  // Per-category 0..100 score bars — Secrets / Permissions / Hooks / MCP / Agents.
1300
1543
  // Only render when at least one category has been contributed to (skip the
@@ -50,6 +50,7 @@
50
50
  import crypto from 'node:crypto';
51
51
  import { isDeterministic, SCANNER_VERSION } from '../posture/deterministic.js';
52
52
  import { normalizeFindings, TOOL_CAVEATS } from './index.js';
53
+ import { EVIDENCE_GRADE_DISCLAIMER } from '../posture/evidence-grade-wording.js';
53
54
 
54
55
  // The OSCAL release these documents declare conformance to. Bump deliberately:
55
56
  // `oscal-version` is a claim a validator checks the rest of the document
@@ -620,9 +621,13 @@ export function toOSCALCompliance(framework, rows, meta = {}) {
620
621
  observations,
621
622
  ...(findings.length ? { findings } : {}),
622
623
  ...(risks.length ? { risks } : {}),
624
+ // FR-507: names all three assurance tiers explicitly, not just "not
625
+ // certified" — see evidence-grade-wording.js for why that distinction
626
+ // matters (a reader who has never heard "attestation" used correctly
627
+ // has no way to know a real one is a different, valid artifact they
628
+ // might separately need).
623
629
  remarks:
624
- 'This document organizes automated scanner evidence against a control set. It is not an attestation '
625
- + 'of compliance and no licensed assessor reviewed it. '
630
+ `${EVIDENCE_GRADE_DISCLAIMER} No licensed assessor reviewed this document. `
626
631
  + TOOL_CAVEATS.map(c => `${c.id}: ${c.shortDescription}`).join(' | '),
627
632
  }],
628
633
  'back-matter': { resources: [frameworkResource, ...caveats] },
package/src/runScan.js CHANGED
@@ -4,6 +4,7 @@ import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
6
  import { listFiles } from './util/glob.js';
7
+ import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
7
8
  import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
8
9
  import { appendScanSnapshot } from './posture/security-trend.js';
9
10
  import { recover as recoverFixHistory } from './posture/fix-history.js';
@@ -111,17 +112,28 @@ export async function readTree(root, { ignore = [] } = {}) {
111
112
 
112
113
  // Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
113
114
  // repo-relative paths modified since the ref, or null if git is unavailable.
115
+ //
116
+ // `root` is the scan target's repository, not this project's own trusted
117
+ // checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
118
+ // audit (same exposure class as provenance/git-evidence.js: a hostile
119
+ // .git/config's `core.fsmonitor` fires on the `git status --porcelain`
120
+ // call below just from reading repo state). `--no-ext-diff` is added to the
121
+ // `diff --name-only` call too: VERIFIED that shape does not itself invoke an
122
+ // external diff driver in current git (no content is rendered), but it costs
123
+ // nothing and keeps every `git diff` call site in this codebase uniformly
124
+ // hardened against the surface `material-change.js`'s `classifyGitDiff` was
125
+ // actually caught on (see that file's comment for the live exploit).
114
126
  export function changedSince(root, gitRef) {
115
127
  if (!gitRef) return null;
116
128
  try {
117
- const out = cp.execFileSync('git', ['diff', '--name-only', `${gitRef}...HEAD`], {
118
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
129
+ const out = cp.execFileSync('git', hardenGitArgs(['diff', '--name-only', '--no-ext-diff', `${gitRef}...HEAD`]), {
130
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
119
131
  });
120
132
  const set = new Set(out.split('\n').filter(Boolean));
121
133
  // Also include uncommitted changes
122
134
  try {
123
- const dirty = cp.execFileSync('git', ['status', '--porcelain'], {
124
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
135
+ const dirty = cp.execFileSync('git', hardenGitArgs(['status', '--porcelain']), {
136
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
125
137
  });
126
138
  for (const line of dirty.split('\n')) {
127
139
  const f = line.slice(3).trim();
@@ -151,9 +163,22 @@ export async function runScan(rootDir, opts = {}) {
151
163
  // Caller may pre-build fileContents (used by the MCP server's scan_diff to
152
164
  // scope a scan to a specific file list without walking the whole tree).
153
165
  let fileContents, depFileContents;
166
+ // `completeScan` answers ONE question for the whole pipeline: does the file
167
+ // set below cover all of `root`, or only a subset of it? Anything downstream
168
+ // that reasons about the ABSENCE of a finding — most importantly the
169
+ // provenance lifecycle ledger, whose remediation pass closes every open
170
+ // stableId missing from this scan — is only sound on a complete scan. A
171
+ // subset scan that claims completeness marks the entire rest of the project
172
+ // remediated. It starts true and is only ever narrowed, so a new subsetting
173
+ // path added later must opt OUT explicitly rather than silently inherit a
174
+ // false claim of coverage.
175
+ let completeScan = true;
154
176
  if (opts.fileContents) {
177
+ // Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
178
+ // construction a subset of the tree, not a scan of it.
155
179
  fileContents = opts.fileContents;
156
180
  depFileContents = opts.depFileContents || {};
181
+ completeScan = false;
157
182
  } else {
158
183
  ({ fileContents, depFileContents } = await readTree(root, opts));
159
184
  }
@@ -167,6 +192,10 @@ export async function runScan(rootDir, opts = {}) {
167
192
  if (changed.has(f)) filtered[f] = fileContents[f];
168
193
  }
169
194
  fileContents = filtered;
195
+ // Only when the filter actually applied. A `changedSince` that resolved
196
+ // to null (not a git repo / bad ref) is warned about below and scans the
197
+ // whole tree, which IS complete.
198
+ completeScan = false;
170
199
  } else if (opts.onProgress) {
171
200
  opts.onProgress({ phase: 'warning', file: 'changedSince ignored: not a git repo or invalid ref', current: 0, total: 0 });
172
201
  }
@@ -174,7 +203,7 @@ export async function runScan(rootDir, opts = {}) {
174
203
 
175
204
  // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
176
205
  // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
177
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
206
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
178
207
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
179
208
  // notify if the operator pinned a different version than what's installed.
180
209
  try { stampScan(root, scan); } catch {}
package/src/sast/cpp.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // - hardcoded hardcoded user/password in fopen / connect calls
17
17
 
18
18
  import { blankComments } from './_comment-strip.js';
19
+ import { classifySecretCandidate as _entropyClassifySecret } from './_secret-entropy.js';
19
20
 
20
21
  // ── context detectors ───────────────────────────────────────────────────────
21
22
 
@@ -279,14 +280,8 @@ const FINDINGS = [
279
280
  const val = m && m[2];
280
281
  if (!val) return false;
281
282
  try {
282
- // Lazy import — avoids a circular dep on the entropy module being
283
- // present in older snapshots.
284
- // eslint-disable-next-line no-unused-vars
285
- const { classifySecretCandidate } = _entropyMod || {};
286
- if (classifySecretCandidate) {
287
- const r = classifySecretCandidate(val);
288
- if (r.skip) return false;
289
- }
283
+ const r = _entropyClassifySecret(val);
284
+ if (r.skip) return false;
290
285
  } catch { /* fail open */ }
291
286
  return true;
292
287
  },
@@ -319,12 +314,6 @@ const FINDINGS = [
319
314
  },
320
315
  ];
321
316
 
322
- // Late-bound entropy module — imported via a dynamic require shim so the
323
- // rule table can lazy-call it from inside gate functions without creating
324
- // a circular import at module load time.
325
- let _entropyMod = null;
326
- import('./_secret-entropy.js').then(m => { _entropyMod = m; }).catch(() => { _entropyMod = null; });
327
-
328
317
  function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
329
318
 
330
319
  export function scanCpp(fp, raw) {
@@ -31,7 +31,13 @@ const AI_PATH_RE = /\/(?:ai|chat|generate|complete|completion|embed|embedding|gp
31
31
  const PAYMENT_PATH_RE = /\/(?:pay(?:ment)?|checkout|stripe|order|subscribe|billing|invoice|charge|purchase)\b/i;
32
32
  const CONTACT_PATH_RE = /\/(?:contact|submit|feedback|form|newsletter|subscribe|waitlist|signup|onboard)\b/i;
33
33
 
34
- function _hasRateLimit(content) {
34
+ // Exported (FR-PROV-017) so posture/provenance/coordinator.js can reuse the
35
+ // EXACT SAME presence test as a `resolveMissingControl` predicate: "did a
36
+ // commit's historical blob have rate limiting" must be answered by the same
37
+ // logic that decides "does HEAD have rate limiting", or a drift between the
38
+ // two could fabricate a regression (or miss a real one) that only existed in
39
+ // the predicate's own disagreement with the detector, not in the code.
40
+ function hasRateLimit(content) {
35
41
  return RL_IMPORT_RE.test(content) || RL_USAGE_RE.test(content) || REDIS_RL_RE.test(content);
36
42
  }
37
43
 
@@ -77,7 +83,7 @@ const CATEGORY_META = {
77
83
  function scanRateLimit(file, content) {
78
84
  if (!_SCAN_EXT_RE.test(file)) return [];
79
85
  if (_NONPROD_RE.test(file)) return [];
80
- if (_hasRateLimit(content)) return [];
86
+ if (hasRateLimit(content)) return [];
81
87
  const findings = [];
82
88
  const lines = content.split('\n');
83
89
 
@@ -99,6 +105,18 @@ function scanRateLimit(file, content) {
99
105
  description: meta.description,
100
106
  remediation: meta.remediation,
101
107
  cwe: meta.cwe,
108
+ // FR-PROV-017: routes posture/provenance/coordinator.js to
109
+ // resolveMissingControl instead of the plain SAST origin-resolver.
110
+ // "A route lacks rate limiting" is exactly the "was this control
111
+ // ever present, and if so when did it disappear" question that
112
+ // resolver answers — a plain SAST resolver would instead ask "when
113
+ // was this LINE introduced," which is the wrong question for a
114
+ // finding about something ABSENT. An explicit boolean marker set
115
+ // here (rather than coordinator.js string-matching finding.id/vuln)
116
+ // keeps the two modules' string formats decoupled, matching how
117
+ // isDirect/isTransitiveSca-style markers already route elsewhere in
118
+ // this pipeline.
119
+ missingControlCandidate: true,
102
120
  });
103
121
  }
104
122
  }
@@ -120,6 +138,18 @@ function scanRateLimit(file, content) {
120
138
  description: meta.description,
121
139
  remediation: meta.remediation,
122
140
  cwe: meta.cwe,
141
+ // FR-PROV-017: routes posture/provenance/coordinator.js to
142
+ // resolveMissingControl instead of the plain SAST origin-resolver.
143
+ // "A route lacks rate limiting" is exactly the "was this control
144
+ // ever present, and if so when did it disappear" question that
145
+ // resolver answers — a plain SAST resolver would instead ask "when
146
+ // was this LINE introduced," which is the wrong question for a
147
+ // finding about something ABSENT. An explicit boolean marker set
148
+ // here (rather than coordinator.js string-matching finding.id/vuln)
149
+ // keeps the two modules' string formats decoupled, matching how
150
+ // isDirect/isTransitiveSca-style markers already route elsewhere in
151
+ // this pipeline.
152
+ missingControlCandidate: true,
123
153
  });
124
154
  }
125
155
  }
@@ -127,4 +157,4 @@ function scanRateLimit(file, content) {
127
157
  return findings;
128
158
  }
129
159
 
130
- export { scanRateLimit };
160
+ export { scanRateLimit, hasRateLimit };
@@ -9,6 +9,7 @@
9
9
  import * as crypto from 'node:crypto';
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
+ import { evaluateEgress } from '../egress/policy.js';
12
13
 
13
14
  const CACHE_DIR = process.env.XDG_CONFIG_HOME
14
15
  ? path.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
@@ -63,6 +64,11 @@ export async function extractVulnFunctionsViaLLM(supplyChain, opts = {}) {
63
64
  if (!isLlmScaEnabled()) return [];
64
65
  const config = _endpointConfig();
65
66
  if (!config) return [];
67
+ // FR-601: one decision for the whole batch (same endpoint for every CVE in
68
+ // this run) — evaluated before any of the per-candidate prompts below are
69
+ // built.
70
+ const egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'sca-llm-function-extract', endpoint: config.endpoint });
71
+ if (!egressDecision.allowed) return [];
66
72
 
67
73
  const enriched = [];
68
74
  const candidates = (supplyChain || []).filter(sc =>