@clear-capabilities/agentic-security-scanner 0.79.0 → 0.80.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 (91) hide show
  1. package/dist/178.index.js +1 -1
  2. package/dist/333.index.js +283 -0
  3. package/dist/384.index.js +1 -1
  4. package/dist/637.index.js +1 -1
  5. package/dist/838.index.js +1 -1
  6. package/dist/985.index.js +90 -1
  7. package/dist/agentic-security.mjs +83 -83
  8. package/dist/agentic-security.mjs.sha256 +1 -1
  9. package/package.json +6 -4
  10. package/src/.agentic-security/findings.json +104638 -0
  11. package/src/.agentic-security/last-scan.json +104638 -0
  12. package/src/.agentic-security/last-scan.json.sig +1 -0
  13. package/src/.agentic-security/scan-history.json +12562 -0
  14. package/src/.agentic-security/streak.json +21 -0
  15. package/src/dataflow/.agentic-security/findings.json +6086 -0
  16. package/src/dataflow/.agentic-security/last-scan.json +6086 -0
  17. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  18. package/src/dataflow/.agentic-security/scan-history.json +250 -0
  19. package/src/dataflow/.agentic-security/streak.json +21 -0
  20. package/src/dataflow/cross-service-taint.js +201 -0
  21. package/src/dataflow/formal-verify.js +204 -0
  22. package/src/dataflow/ifds-precise.js +222 -0
  23. package/src/dataflow/k2-summary-cache.js +153 -0
  24. package/src/dataflow/lib-taint-summaries.js +198 -0
  25. package/src/dataflow/privacy-taint.js +205 -0
  26. package/src/dataflow/smt-feasibility.js +189 -0
  27. package/src/engine.js +784 -127
  28. package/src/ir/.agentic-security/findings.json +4011 -0
  29. package/src/ir/.agentic-security/last-scan.json +4011 -0
  30. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  31. package/src/ir/.agentic-security/scan-history.json +193 -0
  32. package/src/ir/.agentic-security/streak.json +20 -0
  33. package/src/ir/cpp-preprocessor.js +142 -0
  34. package/src/ir/csharp-ir.js +604 -0
  35. package/src/ir/universal-ir.js +403 -0
  36. package/src/mcp/.agentic-security/findings.json +8632 -0
  37. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  38. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  39. package/src/mcp/.agentic-security/scan-history.json +143 -0
  40. package/src/mcp/.agentic-security/streak.json +20 -0
  41. package/src/mcp/tools.js +90 -1
  42. package/src/posture/.agentic-security/findings.json +64004 -0
  43. package/src/posture/.agentic-security/last-scan.json +64004 -0
  44. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  45. package/src/posture/.agentic-security/scan-history.json +7162 -0
  46. package/src/posture/.agentic-security/streak.json +21 -0
  47. package/src/posture/api-contract.js +193 -0
  48. package/src/posture/attack-taxonomy.js +227 -0
  49. package/src/posture/compliance-policy.js +218 -0
  50. package/src/posture/composite-risk.js +122 -0
  51. package/src/posture/csharp-analysis.js +330 -0
  52. package/src/posture/exploit-bundle.js +210 -0
  53. package/src/posture/federated-learning.js +172 -0
  54. package/src/posture/license-attributions.js +94 -0
  55. package/src/posture/license-graph.js +238 -0
  56. package/src/posture/pqc-migration-plan.js +158 -0
  57. package/src/posture/reachability-filter.js +33 -2
  58. package/src/posture/realtime-cve-monitor.js +214 -0
  59. package/src/posture/runtime-correlation.js +174 -0
  60. package/src/posture/sbom-diff.js +171 -0
  61. package/src/posture/sca-policy.js +235 -0
  62. package/src/posture/sca-upgrade.js +259 -0
  63. package/src/posture/threat-model-auto.js +268 -0
  64. package/src/posture/triage-learning.js +170 -0
  65. package/src/posture/triage.js +26 -1
  66. package/src/sast/.agentic-security/findings.json +6154 -0
  67. package/src/sast/.agentic-security/last-scan.json +6154 -0
  68. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  69. package/src/sast/.agentic-security/scan-history.json +941 -0
  70. package/src/sast/.agentic-security/streak.json +22 -0
  71. package/src/sast/_secret-entropy.js +145 -0
  72. package/src/sast/cloud-iam.js +312 -0
  73. package/src/sast/cpp.js +138 -4
  74. package/src/sast/crypto-protocol.js +388 -0
  75. package/src/sast/csharp-tokenizer.js +392 -0
  76. package/src/sast/csharp.js +924 -138
  77. package/src/sast/dapp-frontend.js +200 -0
  78. package/src/sast/k8s-admission.js +271 -0
  79. package/src/sast/llm-app.js +272 -0
  80. package/src/sast/ml-supply-chain.js +259 -0
  81. package/src/sast/mobile.js +224 -0
  82. package/src/sast/post-quantum-crypto.js +348 -0
  83. package/src/sast/web3-advanced.js +375 -0
  84. package/src/sca/.agentic-security/findings.json +7460 -0
  85. package/src/sca/.agentic-security/last-scan.json +7460 -0
  86. package/src/sca/.agentic-security/last-scan.json.sig +1 -0
  87. package/src/sca/.agentic-security/scan-history.json +113 -0
  88. package/src/sca/.agentic-security/streak.json +21 -0
  89. package/src/sca/CLAUDE.md +161 -0
  90. package/src/sca/binary-metadata.js +37 -15
  91. package/src/sca/sigstore-verify.js +215 -0
@@ -0,0 +1,238 @@
1
+ // License-graph supply-chain analyzer — Item #5 of the world-class+3 plan.
2
+ //
3
+ // Extends posture/license-policy.js (per-component allow/deny/review) with:
4
+ //
5
+ // 1. TRANSITIVE COPYLEFT CONTAMINATION
6
+ // An MIT-licensed direct dep that pulls in a GPL/AGPL transitive
7
+ // dep. Today's per-component check passes because the direct dep is
8
+ // MIT — but the GPL is the actual exposure.
9
+ //
10
+ // 2. RELICENSING-RISK LICENSES
11
+ // BSL, SSPL, Elastic 2.0, Common Clause, Server Side Public License,
12
+ // Functional Source License, Sustainable Use License, etc.
13
+ // Permissive at first install — later versions or downstream usage
14
+ // may breach the additional terms.
15
+ //
16
+ // 3. DISTRIBUTION-MODE-AWARE POLICY
17
+ // "SaaS" vs "Binary" vs "Library" have radically different obligations.
18
+ // AGPL is fine for proprietary internal usage but kills SaaS;
19
+ // GPL is fine for a SaaS web app but kills a published library;
20
+ // LGPL static-link concerns only apply to native binary distribution.
21
+ //
22
+ // 4. DUAL-LICENSE TRAP DETECTION
23
+ // Packages declared as `(MIT OR Apache-2.0)` are usually fine, but
24
+ // `(GPL-2.0 OR Commercial)` are a contractual trap — the open option
25
+ // auto-converts your code to GPL unless you've signed a commercial
26
+ // agreement.
27
+ //
28
+ // 5. LICENSE-CHANGE DETECTION
29
+ // Packages known to have relicensed (Elastic, Redis, Sentry,
30
+ // HashiCorp Terraform, MongoDB) — flag the boundary version.
31
+ //
32
+ // Default mode: SaaS. Override via
33
+ // .agentic-security/license-policy.yml `distributionMode:`.
34
+ //
35
+ // Opt-out: AGENTIC_SECURITY_NO_LICENSE_GRAPH=1
36
+
37
+ import * as fs from 'node:fs';
38
+ import * as path from 'node:path';
39
+
40
+ // ── License taxonomy ───────────────────────────────────────────────────────
41
+
42
+ const LICENSE_FAMILIES = {
43
+ permissive: new Set(['MIT', 'APACHE-2.0', 'BSD-2-CLAUSE', 'BSD-3-CLAUSE', 'BSD-4-CLAUSE', 'ISC', '0BSD', 'CC0-1.0', 'UNLICENSE', 'WTFPL', 'ZLIB', 'PSF-2.0', 'PYTHON-2.0', 'POSTGRESQL', 'OPENSSL', 'X11']),
44
+ weak_copyleft: new Set(['LGPL-2.0', 'LGPL-2.1', 'LGPL-3.0', 'LGPL-2.1-OR-LATER', 'LGPL-3.0-OR-LATER', 'MPL-1.1', 'MPL-2.0', 'EPL-1.0', 'EPL-2.0', 'CDDL-1.0', 'CDDL-1.1']),
45
+ strong_copyleft: new Set(['GPL-2.0', 'GPL-2.0-ONLY', 'GPL-2.0-OR-LATER', 'GPL-3.0', 'GPL-3.0-ONLY', 'GPL-3.0-OR-LATER']),
46
+ network_copyleft: new Set(['AGPL-1.0', 'AGPL-3.0', 'AGPL-3.0-ONLY', 'AGPL-3.0-OR-LATER']),
47
+ source_available: new Set(['BSL-1.1', 'SSPL-1.0', 'ELASTIC-2.0', 'ELASTIC-1.0', 'COMMONS-CLAUSE', 'FSL-1.0', 'FSL-1.1', 'CONFLUENT-COMMUNITY', 'BUSL-1.1', 'PARITY-7.0.0', 'POLYFORM-NONCOMMERCIAL', 'POLYFORM-PERIMETER', 'POLYFORM-INTERNAL-USE']),
48
+ proprietary: new Set(['UNLICENSED', 'NOLICENSE', 'PROPRIETARY', 'COMMERCIAL']),
49
+ };
50
+
51
+ const KNOWN_RELICENSED = [
52
+ // pkg-name regex → { from, to, atVersion, ecosystem }
53
+ { pkg: /^elasticsearch$/i, from: 'Apache-2.0', to: 'Elastic-2.0 / SSPL', atVersion: '>=7.11.0', ecosystem: 'java/npm' },
54
+ { pkg: /^@elastic\/elasticsearch$/i, from: 'Apache-2.0', to: 'Elastic-2.0 / SSPL', atVersion: '>=8.0.0', ecosystem: 'npm' },
55
+ { pkg: /^redis$/i, from: 'BSD-3-Clause', to: 'RSALv2 / SSPL', atVersion: '>=7.4', ecosystem: 'multi' },
56
+ { pkg: /^@sentry\/.*$/i, from: 'BSD-3-Clause', to: 'FSL-1.1', atVersion: '>=8.0.0', ecosystem: 'npm' },
57
+ { pkg: /^terraform.*$/i, from: 'MPL-2.0', to: 'BSL-1.1', atVersion: '>=1.6.0', ecosystem: 'multi' },
58
+ { pkg: /^mongodb$/i, from: 'AGPL-3.0', to: 'SSPL-1.0', atVersion: '>=4.4', ecosystem: 'multi' },
59
+ { pkg: /^vault$/i, from: 'MPL-2.0', to: 'BSL-1.1', atVersion: '>=1.15.0', ecosystem: 'go' },
60
+ { pkg: /^consul$/i, from: 'MPL-2.0', to: 'BSL-1.1', atVersion: '>=1.17.0', ecosystem: 'go' },
61
+ { pkg: /^cockroachdb?$/i, from: 'Apache-2.0', to: 'CCL (modified)', atVersion: '>=19.2', ecosystem: 'go' },
62
+ ];
63
+
64
+ function _normLicense(s) { return String(s || '').toUpperCase().replace(/[()]/g, '').trim(); }
65
+
66
+ function _classify(license) {
67
+ if (!license) return 'unknown';
68
+ const l = _normLicense(license);
69
+ // Compound: pick worst family.
70
+ if (/\s(?:AND|OR|WITH)\s/i.test(l)) {
71
+ const parts = l.split(/\s+(?:AND|OR|WITH)\s+/i).map(p => p.trim()).filter(Boolean);
72
+ const families = parts.map(_classify);
73
+ // Worst → best: source_available > network_copyleft > strong > weak > permissive > unknown.
74
+ for (const f of ['source_available', 'network_copyleft', 'strong_copyleft', 'weak_copyleft', 'permissive']) {
75
+ if (families.includes(f)) return f;
76
+ }
77
+ return 'unknown';
78
+ }
79
+ for (const [fam, set] of Object.entries(LICENSE_FAMILIES)) {
80
+ if (set.has(l)) return fam;
81
+ }
82
+ return 'unknown';
83
+ }
84
+
85
+ // ── Distribution-mode policy ───────────────────────────────────────────────
86
+
87
+ const DEFAULT_DIST_MODE = 'saas';
88
+
89
+ const DIST_MODE_MATRIX = {
90
+ saas: {
91
+ permissive: { verdict: 'allow', why: 'Compatible with SaaS distribution.' },
92
+ weak_copyleft: { verdict: 'allow', why: 'Weak-copyleft (LGPL/MPL/CDDL/EPL) — SaaS distribution does not trigger reciprocity for unmodified usage.' },
93
+ strong_copyleft: { verdict: 'review', why: 'GPL/CGPL is compatible with SaaS (no distribution of binary) but propagates if you ever publish the source or a derived binary. Confirm internal-only.' },
94
+ network_copyleft: { verdict: 'deny', why: 'AGPL "network use as distribution" — SaaS deployment triggers source-disclosure obligations.' },
95
+ source_available: { verdict: 'deny', why: 'Source-available licenses (BSL/SSPL/Elastic/CommonsClause) restrict competitive SaaS offerings.' },
96
+ proprietary: { verdict: 'deny', why: 'Component has no license / declares proprietary — cannot redistribute.' },
97
+ unknown: { verdict: 'review', why: 'Unknown license — verify via upstream repo.' },
98
+ },
99
+ binary: {
100
+ permissive: { verdict: 'allow', why: 'Compatible with binary distribution.' },
101
+ weak_copyleft: { verdict: 'review', why: 'LGPL has static-linking obligations; MPL has file-level reciprocity. Confirm linkage model.' },
102
+ strong_copyleft: { verdict: 'deny', why: 'GPL copyleft propagates to the entire distributed binary.' },
103
+ network_copyleft: { verdict: 'deny', why: 'AGPL is even more restrictive than GPL for distribution.' },
104
+ source_available: { verdict: 'deny', why: 'Source-available licenses (BSL/SSPL/Elastic/CommonsClause) impose use restrictions that often conflict with binary distribution to customers.' },
105
+ proprietary: { verdict: 'deny', why: 'Component has no license / declares proprietary — cannot bundle.' },
106
+ unknown: { verdict: 'review', why: 'Unknown license — verify via upstream repo.' },
107
+ },
108
+ library: {
109
+ permissive: { verdict: 'allow', why: 'Compatible with library publishing.' },
110
+ weak_copyleft: { verdict: 'review', why: 'LGPL/MPL transitive deps complicate downstream users of YOUR library.' },
111
+ strong_copyleft: { verdict: 'deny', why: 'GPL locks all downstream users of your library into GPL.' },
112
+ network_copyleft: { verdict: 'deny', why: 'AGPL forces downstream users into AGPL.' },
113
+ source_available: { verdict: 'deny', why: 'Source-available licenses block downstream commercial use of your library.' },
114
+ proprietary: { verdict: 'deny', why: 'Component has no license — cannot redistribute via your library.' },
115
+ unknown: { verdict: 'review', why: 'Unknown license — verify via upstream repo.' },
116
+ },
117
+ };
118
+
119
+ // ── Transitive walker ──────────────────────────────────────────────────────
120
+
121
+ function _depPathLabel(c) {
122
+ return `${c.ecosystem || '?'}:${c.name}@${c.version || '?'}`;
123
+ }
124
+
125
+ /**
126
+ * Build the dep graph + collect transitive contamination paths.
127
+ *
128
+ * components — list of component objects (already produced by the engine).
129
+ * expects: { ecosystem, name, version, license, transitive (boolean),
130
+ * importedBy: string[] (optional) }.
131
+ */
132
+ export function analyzeLicenseGraph(components, options) {
133
+ const opts = options || {};
134
+ const mode = (opts.distributionMode || DEFAULT_DIST_MODE).toLowerCase();
135
+ const matrix = DIST_MODE_MATRIX[mode] || DIST_MODE_MATRIX[DEFAULT_DIST_MODE];
136
+ if (!Array.isArray(components) || components.length === 0) {
137
+ return { findings: [], summary: { total: 0, deny: 0, review: 0, allow: 0, unknown: 0 }, distributionMode: mode };
138
+ }
139
+ const byKey = new Map();
140
+ for (const c of components) byKey.set(_depPathLabel(c), c);
141
+ const findings = [];
142
+ const summary = { total: components.length, deny: 0, review: 0, allow: 0, unknown: 0 };
143
+
144
+ for (const c of components) {
145
+ const family = _classify(c.license);
146
+ const verdict = matrix[family] || matrix.unknown;
147
+ summary[verdict.verdict] = (summary[verdict.verdict] || 0) + 1;
148
+ if (verdict.verdict === 'allow') continue;
149
+
150
+ const isTransitive = !!c.transitive;
151
+ let path = [_depPathLabel(c)];
152
+ if (isTransitive && Array.isArray(c.importedBy) && c.importedBy.length) {
153
+ // Walk up the graph (one hop in v1 — sufficient for "direct dep that
154
+ // pulled in this offender").
155
+ path = [c.importedBy[0], _depPathLabel(c)];
156
+ }
157
+
158
+ findings.push({
159
+ id: `license-graph:${_depPathLabel(c)}:${family}:${verdict.verdict}`,
160
+ kind: 'license', family: 'license-graph',
161
+ severity: verdict.verdict === 'deny' ? 'high' : 'low',
162
+ file: c.filePath || 'package.json', line: 0,
163
+ vuln: `${verdict.verdict === 'deny' ? 'License-incompatible' : 'License-review-needed'}: ${c.name}@${c.version || '?'} (${c.license || 'no license'}) under ${mode} distribution mode`,
164
+ description: verdict.why + (isTransitive ? ` Transitive dep pulled in via ${path.slice(0, -1).join(' → ')}.` : ''),
165
+ remediation: verdict.verdict === 'deny'
166
+ ? `Replace ${c.name}@${c.version || '?'} with a permissively-licensed alternative, OR switch to a different distribution mode (set distributionMode: in .agentic-security/license-policy.yml), OR negotiate a commercial license with the upstream.`
167
+ : `Have legal review confirm ${c.license} compatibility with ${mode} distribution. Once approved, add ${c.name} to the policy allow-list.`,
168
+ package: c.name,
169
+ version: c.version,
170
+ ecosystem: c.ecosystem,
171
+ license: c.license || null,
172
+ licenseFamily: family,
173
+ distributionMode: mode,
174
+ isTransitive,
175
+ depPath: path,
176
+ });
177
+ }
178
+
179
+ // ── Dual-license trap detection ─────────────────────────────────────────
180
+ for (const c of components) {
181
+ if (!c.license) continue;
182
+ const lic = _normLicense(c.license);
183
+ if (!/\bOR\b/i.test(lic)) continue;
184
+ const atoms = lic.split(/\s+OR\s+/i).map(s => s.trim());
185
+ const hasCommercial = atoms.some(a => /COMMERCIAL|PROPRIETARY|ENTERPRISE/.test(a));
186
+ const hasStrongCopyleft = atoms.some(a => LICENSE_FAMILIES.strong_copyleft.has(a) || LICENSE_FAMILIES.network_copyleft.has(a));
187
+ if (hasCommercial && hasStrongCopyleft) {
188
+ findings.push({
189
+ id: `license-graph:dual-license-trap:${_depPathLabel(c)}`,
190
+ kind: 'license', family: 'license-dual-trap',
191
+ severity: 'high',
192
+ file: c.filePath || 'package.json', line: 0,
193
+ vuln: `Dual-license trap: ${c.name}@${c.version} offers ${c.license} — the open option is copyleft, the alternative requires a commercial agreement`,
194
+ description: 'Dual GPL-OR-Commercial licensing means: if you have not signed a commercial agreement with the upstream, your usage falls under GPL/AGPL and propagates to your codebase. Common pattern with Qt LGPL/Commercial, MongoDB AGPL/Commercial pre-SSPL, GraalVM.',
195
+ remediation: 'Verify with legal whether a commercial agreement is in place. If not, you are bound by the copyleft option — propagate that to your distribution mode policy.',
196
+ package: c.name, version: c.version, license: c.license,
197
+ });
198
+ summary.deny = (summary.deny || 0) + 1;
199
+ }
200
+ }
201
+
202
+ // ── Known relicensed packages ───────────────────────────────────────────
203
+ for (const c of components) {
204
+ for (const r of KNOWN_RELICENSED) {
205
+ if (!r.pkg.test(c.name)) continue;
206
+ findings.push({
207
+ id: `license-graph:relicensed:${_depPathLabel(c)}`,
208
+ kind: 'license', family: 'license-relicense',
209
+ severity: 'medium',
210
+ file: c.filePath || 'package.json', line: 0,
211
+ vuln: `${c.name}@${c.version}: upstream relicensed from ${r.from} → ${r.to} (boundary ${r.atVersion})`,
212
+ description: `Upstream relicensing event for ${c.name}. Older versions were ${r.from}; ${r.atVersion} and later are ${r.to}. Verify which side of the boundary your version is on, and update your policy.`,
213
+ remediation: `Pin to a pre-relicense version if the new terms are unacceptable, OR adopt a fork (e.g. OpenSearch for Elasticsearch, Valkey for Redis, OpenTofu for Terraform).`,
214
+ package: c.name, version: c.version, license: c.license, relicenseInfo: r,
215
+ });
216
+ }
217
+ }
218
+
219
+ return { findings, summary, distributionMode: mode };
220
+ }
221
+
222
+ // ── Policy file loader (extends posture/license-policy.js shape) ───────────
223
+
224
+ export function loadLicenseGraphPolicy(scanRoot) {
225
+ if (!scanRoot) return { distributionMode: DEFAULT_DIST_MODE };
226
+ const fp = path.join(scanRoot, '.agentic-security', 'license-policy.yml');
227
+ if (!fs.existsSync(fp)) return { distributionMode: DEFAULT_DIST_MODE };
228
+ try {
229
+ const raw = fs.readFileSync(fp, 'utf8');
230
+ const m = /\bdistributionMode\s*:\s*['"]?(saas|binary|library)['"]?/i.exec(raw);
231
+ return { distributionMode: m ? m[1].toLowerCase() : DEFAULT_DIST_MODE };
232
+ } catch { return { distributionMode: DEFAULT_DIST_MODE }; }
233
+ }
234
+
235
+ export const _internals = {
236
+ LICENSE_FAMILIES, DIST_MODE_MATRIX, KNOWN_RELICENSED,
237
+ _classify, _normLicense,
238
+ };
@@ -0,0 +1,158 @@
1
+ // PQC migration-plan artifact emitter.
2
+ //
3
+ // Aggregates pqc-migration findings (emitted by sast/post-quantum-crypto.js)
4
+ // into a structured plan suitable for an engineering organization to use as
5
+ // a project tracker:
6
+ //
7
+ // .agentic-security/pqc-migration-plan.json
8
+ // .agentic-security/pqc-migration-plan.md
9
+ //
10
+ // Buckets findings by:
11
+ // - HNDL criticality (high-priority — data captured today is harvest-now-decrypt-later
12
+ // exposure when a CRQC arrives)
13
+ // - Use case (signing / encryption / KEX) — drives the replacement primitive
14
+ // - Recommended replacement (ML-KEM-768, ML-DSA-65, etc.)
15
+ // - File / package locality so the plan can be carved into milestones.
16
+ //
17
+ // Cleartext markdown summarises the top recommendations and milestone
18
+ // suggestions; JSON-LD-shaped structured output is consumable by Vanta /
19
+ // Drata / SecureFrame or any custom rollup dashboard.
20
+
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+
24
+ function _byHndl(findings) {
25
+ return {
26
+ hndlCritical: findings.filter(f => f.hndlCritical),
27
+ standard: findings.filter(f => !f.hndlCritical),
28
+ };
29
+ }
30
+
31
+ function _byUseCase(findings) {
32
+ const map = new Map();
33
+ for (const f of findings) {
34
+ const k = f.pqcRecommendation?.primary || 'unspecified';
35
+ if (!map.has(k)) map.set(k, []);
36
+ map.get(k).push(f);
37
+ }
38
+ return map;
39
+ }
40
+
41
+ function _byFile(findings) {
42
+ const map = new Map();
43
+ for (const f of findings) {
44
+ const k = f.file || 'unknown';
45
+ if (!map.has(k)) map.set(k, []);
46
+ map.get(k).push(f);
47
+ }
48
+ return map;
49
+ }
50
+
51
+ export function buildMigrationPlan(allFindings) {
52
+ const pqc = (allFindings || []).filter(f => f.family === 'pqc-migration');
53
+ if (!pqc.length) return null;
54
+ const bySev = _byHndl(pqc);
55
+ const byPrimitive = _byUseCase(pqc);
56
+ const byFile = _byFile(pqc);
57
+ const summary = {
58
+ total: pqc.length,
59
+ hndlCritical: bySev.hndlCritical.length,
60
+ standard: bySev.standard.length,
61
+ filesAffected: byFile.size,
62
+ primitivesNeeded: Array.from(byPrimitive.keys()),
63
+ };
64
+ const milestones = [
65
+ {
66
+ id: 'M1',
67
+ title: 'Inventory & policy',
68
+ target: '90 days',
69
+ owner: 'security',
70
+ items: [
71
+ 'Confirm scanner findings against design docs',
72
+ 'Adopt PQC migration policy (CNSA 2.0 / NIST IR 8547 alignment)',
73
+ 'Establish KMS support for hybrid keys',
74
+ ],
75
+ },
76
+ {
77
+ id: 'M2',
78
+ title: 'HNDL-critical paths to PQ-hybrid',
79
+ target: '180 days',
80
+ owner: 'platform',
81
+ items: bySev.hndlCritical.slice(0, 25).map(f => ({
82
+ finding: f.id, file: f.file, line: f.line,
83
+ replacement: f.pqcRecommendation?.hybrid || f.pqcRecommendation?.primary,
84
+ })),
85
+ },
86
+ {
87
+ id: 'M3',
88
+ title: 'Standard signing/KEX migration',
89
+ target: '12 months',
90
+ owner: 'platform',
91
+ items: bySev.standard.slice(0, 50).map(f => ({
92
+ finding: f.id, file: f.file, line: f.line,
93
+ replacement: f.pqcRecommendation?.primary,
94
+ })),
95
+ },
96
+ {
97
+ id: 'M4',
98
+ title: 'Deprecate classical primitives',
99
+ target: '24 months',
100
+ owner: 'security',
101
+ items: ['Remove dual-stack libraries once peers are PQ-capable', 'Rotate root CA / long-lived signing keys to ML-DSA'],
102
+ },
103
+ ];
104
+ return {
105
+ generatedAt: new Date().toISOString(),
106
+ summary,
107
+ milestones,
108
+ perFile: Object.fromEntries(
109
+ Array.from(byFile.entries()).map(([file, fs]) => [file, {
110
+ count: fs.length,
111
+ subfamilies: Array.from(new Set(fs.map(f => f.subfamily))),
112
+ hndlCritical: fs.some(f => f.hndlCritical),
113
+ }]),
114
+ ),
115
+ };
116
+ }
117
+
118
+ export function persistMigrationPlan(scanRoot, plan) {
119
+ if (!plan) return null;
120
+ try { fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true }); } catch {}
121
+ try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2)); } catch {}
122
+ try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'pqc-migration-plan.md'), _markdown(plan)); } catch {}
123
+ return plan;
124
+ }
125
+
126
+ function _markdown(plan) {
127
+ const lines = [];
128
+ lines.push('# Post-quantum cryptography migration plan');
129
+ lines.push('');
130
+ lines.push(`Generated ${plan.generatedAt.slice(0, 10)}.`);
131
+ lines.push('');
132
+ lines.push(`**${plan.summary.total}** pre-quantum primitive sites across **${plan.summary.filesAffected}** files. `);
133
+ lines.push(`HNDL-critical: **${plan.summary.hndlCritical}** | Standard: **${plan.summary.standard}**`);
134
+ lines.push('');
135
+ lines.push('## Recommended PQ primitives');
136
+ for (const p of plan.summary.primitivesNeeded) lines.push(`- ${p}`);
137
+ lines.push('');
138
+ for (const m of plan.milestones) {
139
+ lines.push(`## ${m.id} — ${m.title} (target ${m.target}, owner ${m.owner})`);
140
+ if (Array.isArray(m.items) && m.items.length) {
141
+ for (const it of m.items.slice(0, 20)) {
142
+ if (typeof it === 'string') lines.push(`- ${it}`);
143
+ else lines.push(`- \`${it.file}:${it.line}\` → ${it.replacement || '(see finding)'}`);
144
+ }
145
+ if (m.items.length > 20) lines.push(`- … ${m.items.length - 20} more`);
146
+ }
147
+ lines.push('');
148
+ }
149
+ lines.push('## References');
150
+ lines.push('- NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), FIPS 205 (SLH-DSA)');
151
+ lines.push('- NIST IR 8547 — Transition to Post-Quantum Cryptographic Standards');
152
+ lines.push('- CNSA 2.0 — Commercial National Security Algorithm Suite, Sept 2022');
153
+ lines.push('- RFC 9794 — X25519MLKEM768 hybrid key exchange for TLS 1.3');
154
+ lines.push('- Open Quantum Safe project (liboqs, oqs-provider for OpenSSL 3)');
155
+ return lines.join('\n');
156
+ }
157
+
158
+ export const _internals = { _byHndl, _byUseCase, _byFile, _markdown };
@@ -5,6 +5,11 @@
5
5
  // module turns that signal into a precision lever: findings marked reachable=
6
6
  // false are demoted to severity 'info' with f.unreachable = true.
7
7
  //
8
+ // Phase 2 / Item 4 (SCA improvement plan): also demote SCA findings whose
9
+ // reachabilityTier indicates the vulnerable code is not reached by any route
10
+ // handler. Critical+manifest-only on a 500-dep transitive graph would
11
+ // otherwise drown out real reachable bugs.
12
+ //
8
13
  // Disabled when scanRoot/--include-unreachable signals are present, or when
9
14
  // AGENTIC_SECURITY_INCLUDE_UNREACHABLE=1 is set.
10
15
 
@@ -15,6 +20,19 @@ const SEVERITY_DEMOTE = {
15
20
  low: 'info',
16
21
  };
17
22
 
23
+ // SCA reachability tiers, ordered from highest urgency to lowest. A tier
24
+ // in DEMOTE_SCA_TIERS triggers severity demotion; a tier NOT in the set
25
+ // keeps full severity. route-reachable-via-function and function-reachable
26
+ // both keep full severity because the vulnerable function is provably
27
+ // called; import-reachable also keeps full severity (imported = uncertain
28
+ // but plausible). The lower three tiers get demoted.
29
+ const DEMOTE_SCA_TIERS = new Set([
30
+ 'unreachable', // function never called from project
31
+ 'build-only', // dev/build-time dependency only
32
+ 'manifest-only', // declared, but no use observed
33
+ 'transitive-only', // transitive dep, scope unclear
34
+ ]);
35
+
18
36
  export function demoteUnreachable(findings, opts = {}) {
19
37
  if (!Array.isArray(findings)) return;
20
38
  if (opts.includeUnreachable || process.env.AGENTIC_SECURITY_INCLUDE_UNREACHABLE === '1') return;
@@ -26,9 +44,22 @@ export function demoteUnreachable(findings, opts = {}) {
26
44
  if (!haveRoutes) return;
27
45
  for (const f of findings) {
28
46
  if (!f || typeof f !== 'object') continue;
29
- if (f.reachable !== false) continue;
30
- if (f.type === 'vulnerable_dep') continue;
31
47
  if (f.unreachable) continue;
48
+ // SCA findings: demote based on reachabilityTier instead of f.reachable
49
+ // (the latter isn't meaningful for an SCA finding — components don't
50
+ // have call sites in the SAST sense).
51
+ if (f.type === 'vulnerable_dep') {
52
+ if (!DEMOTE_SCA_TIERS.has(f.reachabilityTier)) continue;
53
+ const beforeSca = f.severity;
54
+ const afterSca = SEVERITY_DEMOTE[beforeSca];
55
+ if (!afterSca || beforeSca === afterSca) continue;
56
+ f.severity = afterSca;
57
+ f.unreachable = true;
58
+ f._reachabilityDemoted = beforeSca;
59
+ f._reachabilityDemoteReason = `tier:${f.reachabilityTier}`;
60
+ continue;
61
+ }
62
+ if (f.reachable !== false) continue;
32
63
  // Source has an explicit HTTP/DOM/Form/URL category → engine is confident
33
64
  // it's a user-input source even though no route was linked. Don't demote.
34
65
  if (f.source && f.source.category && /HTTP|DOM|Form|URL|Query/i.test(f.source.category)) continue;
@@ -0,0 +1,214 @@
1
+ // Real-time emergent vulnerability monitor — Recommendation #8 of the
2
+ // world-class+2 plan.
3
+ //
4
+ // Polls / streams the OSV.dev feed (and optionally GHSA + vendor
5
+ // advisories) and within minutes of a new CVE matching the customer's
6
+ // SBOM, delivers a push notification via Slack / Discord / generic
7
+ // webhook.
8
+ //
9
+ // Design:
10
+ // 1. Pre-index the project's SBOM by (ecosystem, package) so a new
11
+ // CVE checks against the index in O(1)
12
+ // 2. Poll OSV's published-vulns endpoint every POLL_INTERVAL_MS
13
+ // (default 60s)
14
+ // 3. On match: emit a structured alert + write to a state file so
15
+ // duplicate alerts aren't sent
16
+ // 4. Push delivery via existing webhook utilities; configurable
17
+ // destinations: AGENTIC_SECURITY_ALERT_WEBHOOK, _SLACK, _DISCORD
18
+ //
19
+ // Out of scope: persistent daemon. This module exposes a `cycle()`
20
+ // function the caller invokes from a cron-shape loop (the existing
21
+ // commands/cve-alerts.md skill is the user-facing wrapper).
22
+
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import { statePath, safeWriteState } from './state-dir.js';
26
+
27
+ const STATE_FILE = 'cve-monitor-state.json';
28
+ const DEFAULT_POLL_MS = 60_000;
29
+ const DEFAULT_WINDOW_HOURS = 24;
30
+ const SLA_MINUTES = 5;
31
+
32
+ const OSV_FEED_URL = 'https://api.osv.dev/v1/vulns/recent';
33
+
34
+ function _statePath(scanRoot) { return statePath(scanRoot, STATE_FILE); }
35
+
36
+ function _loadState(scanRoot) {
37
+ const fp = _statePath(scanRoot);
38
+ if (!fs.existsSync(fp)) return { alertedIds: [], lastPolledAt: null };
39
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
40
+ catch { return { alertedIds: [], lastPolledAt: null }; }
41
+ }
42
+ function _saveState(scanRoot, state) {
43
+ safeWriteState(_statePath(scanRoot), JSON.stringify(state, null, 2));
44
+ }
45
+
46
+ /**
47
+ * Build a name→component index from the project's most recent SBOM.
48
+ * Accepts the SBOM as parsed from the engine output's `components`
49
+ * array (each entry: { ecosystem, name, version, … }).
50
+ */
51
+ export function indexSbom(components) {
52
+ const index = new Map(); // key: `${ecosystem}:${name}` → component
53
+ for (const c of components || []) {
54
+ if (!c || !c.ecosystem || !c.name) continue;
55
+ const key = `${String(c.ecosystem).toLowerCase()}:${String(c.name).toLowerCase()}`;
56
+ if (!index.has(key)) index.set(key, []);
57
+ index.get(key).push(c);
58
+ }
59
+ return index;
60
+ }
61
+
62
+ /**
63
+ * Pull the recent-published-vulnerabilities feed from OSV. Returns an
64
+ * array of OSV vulnerability records.
65
+ */
66
+ export async function pollOsv(opts = {}) {
67
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') return [];
68
+ const url = opts.feedUrl || OSV_FEED_URL;
69
+ const since = opts.sinceIso || new Date(Date.now() - DEFAULT_WINDOW_HOURS * 3600_000).toISOString();
70
+ try {
71
+ const u = new URL(url);
72
+ u.searchParams.set('time', since);
73
+ const res = await fetch(u.toString(), {
74
+ headers: { 'User-Agent': 'agentic-security/0.1' },
75
+ });
76
+ if (!res.ok) return [];
77
+ const body = await res.json();
78
+ return Array.isArray(body.vulns) ? body.vulns : (Array.isArray(body) ? body : []);
79
+ } catch { return []; }
80
+ }
81
+
82
+ /**
83
+ * Match a single OSV record against the SBOM index. Returns an array of
84
+ * (component, vuln) tuples that match.
85
+ */
86
+ export function matchOsvToSbom(vuln, sbomIndex) {
87
+ const out = [];
88
+ for (const aff of (vuln.affected || [])) {
89
+ const eco = (aff.package?.ecosystem || '').toLowerCase();
90
+ const name = (aff.package?.name || '').toLowerCase();
91
+ if (!eco || !name) continue;
92
+ const key = `${_normalizeEco(eco)}:${name}`;
93
+ const matches = sbomIndex.get(key);
94
+ if (!matches) continue;
95
+ for (const comp of matches) {
96
+ // Best-effort version-range check — many advisories don't carry
97
+ // semver ranges in OSV affected objects; we conservatively
98
+ // include all matches and let the customer triage.
99
+ out.push({ component: comp, vuln });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function _normalizeEco(eco) {
106
+ const e = eco.toLowerCase();
107
+ if (e === 'npm' || e === 'node') return 'npm';
108
+ if (e === 'pypi' || e === 'pip' || e === 'python') return 'pypi';
109
+ if (e === 'maven') return 'maven';
110
+ if (e === 'gem' || e === 'rubygems') return 'rubygems';
111
+ if (e === 'cargo' || e === 'crates.io') return 'cargo';
112
+ if (e === 'go' || e === 'golang') return 'golang';
113
+ if (e === 'packagist' || e === 'composer') return 'packagist';
114
+ if (e === 'pub' || e === 'dart') return 'pub';
115
+ return e;
116
+ }
117
+
118
+ /**
119
+ * Compose the alert payload for delivery.
120
+ */
121
+ function _composeAlert(matchTuple, ts = new Date().toISOString()) {
122
+ const { component, vuln } = matchTuple;
123
+ return {
124
+ schema: 'agentic-security/cve-alert/v1',
125
+ ts,
126
+ cve: (vuln.aliases || []).find(a => /^CVE-/.test(a)) || null,
127
+ osvId: vuln.id,
128
+ summary: vuln.summary || vuln.details?.slice(0, 200) || '(no summary)',
129
+ component: {
130
+ ecosystem: component.ecosystem,
131
+ name: component.name,
132
+ version: component.version,
133
+ },
134
+ references: (vuln.references || []).slice(0, 4).map(r => r.url),
135
+ severity: _inferSeverity(vuln),
136
+ sla: { promised: `${SLA_MINUTES}m`, ts },
137
+ };
138
+ }
139
+
140
+ function _inferSeverity(vuln) {
141
+ if (Array.isArray(vuln.severity) && vuln.severity.length) {
142
+ const cvss = vuln.severity.find(s => /^CVSS_V/.test(s.type || ''));
143
+ if (cvss && cvss.score) return cvss.score;
144
+ }
145
+ if (vuln.database_specific?.severity) return String(vuln.database_specific.severity);
146
+ return 'unknown';
147
+ }
148
+
149
+ /**
150
+ * Deliver an alert via every configured channel. Webhook URLs come from
151
+ * env: AGENTIC_SECURITY_ALERT_WEBHOOK / _SLACK_WEBHOOK / _DISCORD_WEBHOOK.
152
+ */
153
+ export async function deliverAlert(alert) {
154
+ const sinks = {
155
+ generic: process.env.AGENTIC_SECURITY_ALERT_WEBHOOK,
156
+ slack: process.env.AGENTIC_SECURITY_SLACK_WEBHOOK,
157
+ discord: process.env.AGENTIC_SECURITY_DISCORD_WEBHOOK,
158
+ };
159
+ const results = {};
160
+ for (const [name, url] of Object.entries(sinks)) {
161
+ if (!url) continue;
162
+ try {
163
+ const body = name === 'slack'
164
+ ? { text: `*New CVE affecting your stack*\n• ${alert.cve || alert.osvId} — ${alert.severity}\n• Package: \`${alert.component.ecosystem}:${alert.component.name}@${alert.component.version}\`\n• ${alert.summary}` }
165
+ : name === 'discord'
166
+ ? { content: `**New CVE affecting your stack**\n• ${alert.cve || alert.osvId} — ${alert.severity}\n• \`${alert.component.ecosystem}:${alert.component.name}@${alert.component.version}\`\n• ${alert.summary}` }
167
+ : alert;
168
+ const res = await fetch(url, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'agentic-security/0.1' },
171
+ body: JSON.stringify(body),
172
+ });
173
+ results[name] = { ok: res.ok, status: res.status };
174
+ } catch (e) {
175
+ results[name] = { ok: false, error: String(e && e.message || e) };
176
+ }
177
+ }
178
+ return results;
179
+ }
180
+
181
+ /**
182
+ * One polling cycle. Caller invokes from a daemon / cron / loop.
183
+ * - Fetches recent OSV records
184
+ * - Matches against the SBOM index
185
+ * - Deduplicates against prior alerts (by OSV id)
186
+ * - Delivers alerts
187
+ * - Persists state
188
+ */
189
+ export async function cycle(scanRoot, sbomComponents, opts = {}) {
190
+ const state = _loadState(scanRoot);
191
+ const seen = new Set(state.alertedIds || []);
192
+ const index = indexSbom(sbomComponents);
193
+ const recent = await pollOsv({ sinceIso: state.lastPolledAt, ...opts });
194
+ const matches = [];
195
+ for (const v of recent) {
196
+ if (seen.has(v.id)) continue;
197
+ const m = matchOsvToSbom(v, index);
198
+ if (m.length === 0) continue;
199
+ matches.push(...m);
200
+ }
201
+ const alerts = matches.map(m => _composeAlert(m));
202
+ const deliveries = [];
203
+ for (const alert of alerts) {
204
+ const d = await deliverAlert(alert);
205
+ deliveries.push({ alert, delivery: d });
206
+ seen.add(alert.osvId);
207
+ }
208
+ state.alertedIds = [...seen];
209
+ state.lastPolledAt = new Date().toISOString();
210
+ _saveState(scanRoot, state);
211
+ return { recentCount: recent.length, matches: matches.length, alerts: alerts.length, deliveries };
212
+ }
213
+
214
+ export const _internals = { _normalizeEco, _composeAlert, _inferSeverity, OSV_FEED_URL, DEFAULT_POLL_MS, SLA_MINUTES };