@clear-capabilities/agentic-security-scanner 0.79.0 → 0.84.1

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 (122) 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/839.index.js +170 -0
  7. package/dist/985.index.js +140 -1
  8. package/dist/agentic-security.mjs +10 -10
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +7 -5
  11. package/src/.agentic-security/findings.json +117732 -0
  12. package/src/.agentic-security/last-scan.json +117732 -0
  13. package/src/.agentic-security/last-scan.json.sig +1 -0
  14. package/src/.agentic-security/scan-history.json +12946 -0
  15. package/src/.agentic-security/streak.json +21 -0
  16. package/src/dataflow/.agentic-security/findings.json +6086 -0
  17. package/src/dataflow/.agentic-security/last-scan.json +6086 -0
  18. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  19. package/src/dataflow/.agentic-security/scan-history.json +250 -0
  20. package/src/dataflow/.agentic-security/streak.json +21 -0
  21. package/src/dataflow/cross-service-taint.js +201 -0
  22. package/src/dataflow/formal-verify.js +204 -0
  23. package/src/dataflow/ifds-precise.js +222 -0
  24. package/src/dataflow/k2-summary-cache.js +153 -0
  25. package/src/dataflow/lib-taint-summaries.js +198 -0
  26. package/src/dataflow/privacy-taint.js +205 -0
  27. package/src/dataflow/smt-feasibility.js +189 -0
  28. package/src/engine.js +825 -127
  29. package/src/ir/.agentic-security/findings.json +4011 -0
  30. package/src/ir/.agentic-security/last-scan.json +4011 -0
  31. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  32. package/src/ir/.agentic-security/scan-history.json +193 -0
  33. package/src/ir/.agentic-security/streak.json +20 -0
  34. package/src/ir/cpp-preprocessor.js +142 -0
  35. package/src/ir/csharp-ir.js +604 -0
  36. package/src/ir/universal-ir.js +403 -0
  37. package/src/mcp/.agentic-security/findings.json +8632 -0
  38. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  39. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  40. package/src/mcp/.agentic-security/scan-history.json +331 -0
  41. package/src/mcp/.agentic-security/streak.json +20 -0
  42. package/src/mcp/tools.js +140 -1
  43. package/src/posture/.agentic-security/findings.json +77181 -0
  44. package/src/posture/.agentic-security/last-scan.json +77181 -0
  45. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  46. package/src/posture/.agentic-security/scan-history.json +8904 -0
  47. package/src/posture/.agentic-security/streak.json +21 -0
  48. package/src/posture/api-contract.js +193 -0
  49. package/src/posture/attack-taxonomy.js +227 -0
  50. package/src/posture/auditor-walkthrough.js +252 -0
  51. package/src/posture/claude-authorship.js +197 -0
  52. package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
  53. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
  54. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
  55. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
  56. package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
  57. package/src/posture/compliance-frameworks/ccpa.json +32 -0
  58. package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
  59. package/src/posture/compliance-frameworks/gdpr.json +45 -0
  60. package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
  61. package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
  62. package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
  63. package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
  64. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
  65. package/src/posture/compliance-policy.js +218 -0
  66. package/src/posture/composite-risk.js +122 -0
  67. package/src/posture/cross-repo-memory.js +180 -0
  68. package/src/posture/csharp-analysis.js +330 -0
  69. package/src/posture/dep-add-guard.js +197 -0
  70. package/src/posture/exploit-bundle.js +210 -0
  71. package/src/posture/federated-learning.js +172 -0
  72. package/src/posture/findings-memory.js +152 -0
  73. package/src/posture/fix-style-mirror.js +118 -0
  74. package/src/posture/git-history.js +141 -0
  75. package/src/posture/intent-context.js +175 -0
  76. package/src/posture/license-attributions.js +94 -0
  77. package/src/posture/license-graph.js +238 -0
  78. package/src/posture/model-rescan.js +76 -0
  79. package/src/posture/pattern-propagation.js +39 -0
  80. package/src/posture/pqc-migration-plan.js +158 -0
  81. package/src/posture/pr-augment.js +234 -0
  82. package/src/posture/reachability-filter.js +33 -2
  83. package/src/posture/realtime-cve-monitor.js +214 -0
  84. package/src/posture/risk-dollars.js +158 -0
  85. package/src/posture/runtime-correlation.js +174 -0
  86. package/src/posture/sbom-diff.js +171 -0
  87. package/src/posture/sca-policy.js +235 -0
  88. package/src/posture/sca-upgrade.js +259 -0
  89. package/src/posture/threat-model-auto.js +268 -0
  90. package/src/posture/threat-model-grounding.js +169 -0
  91. package/src/posture/time-to-fix.js +129 -0
  92. package/src/posture/triage-learning.js +170 -0
  93. package/src/posture/triage-memory.js +151 -0
  94. package/src/posture/triage.js +40 -1
  95. package/src/posture/watch-mode.js +171 -0
  96. package/src/posture/workflow-installer.js +231 -0
  97. package/src/sast/.agentic-security/findings.json +6154 -0
  98. package/src/sast/.agentic-security/last-scan.json +6154 -0
  99. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  100. package/src/sast/.agentic-security/scan-history.json +941 -0
  101. package/src/sast/.agentic-security/streak.json +22 -0
  102. package/src/sast/_secret-entropy.js +145 -0
  103. package/src/sast/cloud-iam.js +312 -0
  104. package/src/sast/cpp.js +138 -4
  105. package/src/sast/crypto-protocol.js +388 -0
  106. package/src/sast/csharp-tokenizer.js +392 -0
  107. package/src/sast/csharp.js +924 -138
  108. package/src/sast/dapp-frontend.js +200 -0
  109. package/src/sast/k8s-admission.js +271 -0
  110. package/src/sast/llm-app.js +272 -0
  111. package/src/sast/ml-supply-chain.js +259 -0
  112. package/src/sast/mobile.js +224 -0
  113. package/src/sast/post-quantum-crypto.js +348 -0
  114. package/src/sast/web3-advanced.js +375 -0
  115. package/src/sca/.agentic-security/findings.json +7460 -0
  116. package/src/sca/.agentic-security/last-scan.json +7460 -0
  117. package/src/sca/.agentic-security/last-scan.json.sig +1 -0
  118. package/src/sca/.agentic-security/scan-history.json +113 -0
  119. package/src/sca/.agentic-security/streak.json +21 -0
  120. package/src/sca/CLAUDE.md +161 -0
  121. package/src/sca/binary-metadata.js +37 -15
  122. package/src/sca/sigstore-verify.js +215 -0
@@ -0,0 +1,171 @@
1
+ // SBOM diff + dependency drift detection — Recommendation #10 of the
2
+ // world-class+2 plan.
3
+ //
4
+ // Tracks SBOMs across releases (keyed by git commit hash). On each scan,
5
+ // compares the current SBOM against the previous snapshot to surface
6
+ // drift before a CVE-publication catches it:
7
+ //
8
+ // - dependency-added new package since previous SBOM
9
+ // - dependency-removed package no longer present
10
+ // - dependency-version-bumped version changed
11
+ // - dependency-substitution SAME package name but different
12
+ // ecosystem / publisher / repo source
13
+ // (the SolarWinds / event-stream pattern)
14
+ // - dependency-deprecated transitioned to a deprecated state
15
+ //
16
+ // Suspicious additions (i.e., a new package that doesn't appear in any
17
+ // PR diff / commit message) get a higher severity tier than expected ones.
18
+ //
19
+ // Snapshots live at .agentic-security/sbom-history/<sha>.json. The
20
+ // engine writes the current snapshot at the end of every scan; this
21
+ // module produces the diff on the next scan.
22
+
23
+ import * as fs from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import * as crypto from 'node:crypto';
26
+ import { execSync } from 'node:child_process';
27
+
28
+ const HISTORY_DIR = 'sbom-history';
29
+
30
+ function _historyDir(scanRoot) {
31
+ return path.join(scanRoot, '.agentic-security', HISTORY_DIR);
32
+ }
33
+
34
+ function _gitHead(scanRoot) {
35
+ try {
36
+ return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
+ } catch { return null; }
38
+ }
39
+
40
+ function _snapshotKey(component) {
41
+ return `${component.ecosystem || 'unknown'}:${component.name || ''}`;
42
+ }
43
+
44
+ /**
45
+ * Persist the current SBOM as a snapshot keyed by the current git HEAD.
46
+ * If no git, falls back to a content hash of the components list.
47
+ */
48
+ export function persistSbom(scanRoot, components) {
49
+ const dir = _historyDir(scanRoot);
50
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
51
+ const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
52
+ const snap = {
53
+ sha, ts: new Date().toISOString(),
54
+ componentCount: components.length,
55
+ components: components.map(c => ({
56
+ ecosystem: c.ecosystem, name: c.name, version: c.version,
57
+ purl: c.purl, scope: c.scope, isUnpinned: !!c.isUnpinned,
58
+ sha256: c.sha256 || c.integrity || null,
59
+ })),
60
+ };
61
+ try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
62
+ return snap;
63
+ }
64
+
65
+ /**
66
+ * Load the previous SBOM snapshot for diffing.
67
+ */
68
+ export function loadPreviousSnapshot(scanRoot, currentSha) {
69
+ const dir = _historyDir(scanRoot);
70
+ if (!fs.existsSync(dir)) return null;
71
+ let snaps;
72
+ try { snaps = fs.readdirSync(dir); } catch { return null; }
73
+ snaps = snaps.filter(f => f.endsWith('.json') && f !== `${currentSha}.json`);
74
+ if (!snaps.length) return null;
75
+ // Sort by mtime descending; take the most recent.
76
+ snaps.sort((a, b) => fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs);
77
+ try {
78
+ return JSON.parse(fs.readFileSync(path.join(dir, snaps[0]), 'utf8'));
79
+ } catch { return null; }
80
+ }
81
+
82
+ /**
83
+ * Compute the structured diff between two SBOMs and emit drift findings.
84
+ */
85
+ export function diffSboms(previous, current) {
86
+ if (!previous || !current) return { findings: [], summary: { added: 0, removed: 0, bumped: 0, substituted: 0 } };
87
+ const findings = [];
88
+ const prevByKey = new Map();
89
+ for (const c of previous.components || []) prevByKey.set(_snapshotKey(c), c);
90
+ const curByKey = new Map();
91
+ for (const c of current.components || []) curByKey.set(_snapshotKey(c), c);
92
+ let added = 0, removed = 0, bumped = 0, substituted = 0;
93
+
94
+ // Added
95
+ for (const [key, cur] of curByKey) {
96
+ if (prevByKey.has(key)) continue;
97
+ added++;
98
+ findings.push({
99
+ family: 'dependency-drift', subfamily: 'dependency-added',
100
+ severity: 'medium', cwe: 'CWE-1357',
101
+ vuln: `Dependency added since previous release: ${cur.ecosystem}:${cur.name}@${cur.version}`,
102
+ file: cur.purl || 'package-lock.json', line: 0,
103
+ drift: { kind: 'added', component: cur, sinceSha: previous.sha },
104
+ remediation: 'Confirm this addition appears in a reviewed PR. Unexplained additions are the standard supply-chain-attack pattern.',
105
+ });
106
+ }
107
+ // Removed
108
+ for (const [key, prev] of prevByKey) {
109
+ if (curByKey.has(key)) continue;
110
+ removed++;
111
+ findings.push({
112
+ family: 'dependency-drift', subfamily: 'dependency-removed',
113
+ severity: 'low', cwe: 'CWE-1357',
114
+ vuln: `Dependency removed since previous release: ${prev.ecosystem}:${prev.name}@${prev.version}`,
115
+ file: prev.purl || 'package-lock.json', line: 0,
116
+ drift: { kind: 'removed', component: prev, sinceSha: previous.sha },
117
+ remediation: 'Confirm the removal was intentional. Silent removal of a vulnerable dep is fine; silent removal of a fix-receiving dep means CVEs may re-introduce.',
118
+ });
119
+ }
120
+ // Bumped
121
+ for (const [key, cur] of curByKey) {
122
+ const prev = prevByKey.get(key);
123
+ if (!prev) continue;
124
+ if (prev.version !== cur.version) {
125
+ bumped++;
126
+ const isMajor = _isMajorBump(prev.version, cur.version);
127
+ findings.push({
128
+ family: 'dependency-drift', subfamily: 'dependency-version-bumped',
129
+ severity: isMajor ? 'medium' : 'low',
130
+ cwe: 'CWE-1357',
131
+ vuln: `${cur.ecosystem}:${cur.name} bumped ${prev.version} → ${cur.version}${isMajor ? ' (MAJOR)' : ''}`,
132
+ file: cur.purl || 'package-lock.json', line: 0,
133
+ drift: { kind: 'bumped', from: prev.version, to: cur.version, isMajor, sinceSha: previous.sha },
134
+ remediation: 'Major-version bumps are breaking-change candidates AND attack pivot points. Verify the changelog signals match what your dependency intended.',
135
+ });
136
+ }
137
+ // Substitution check: integrity / hash changed but version stayed the same
138
+ if (prev.sha256 && cur.sha256 && prev.sha256 !== cur.sha256 && prev.version === cur.version) {
139
+ substituted++;
140
+ findings.push({
141
+ family: 'dependency-drift', subfamily: 'dependency-substitution',
142
+ severity: 'critical', cwe: 'CWE-1357',
143
+ vuln: `Suspicious substitution: ${cur.ecosystem}:${cur.name}@${cur.version} hash changed without version change`,
144
+ file: cur.purl || 'package-lock.json', line: 0,
145
+ drift: { kind: 'substituted', component: cur, oldHash: prev.sha256, newHash: cur.sha256, sinceSha: previous.sha },
146
+ remediation: 'Same package + same version + DIFFERENT content hash = the registry served a different artifact under the same identity. Investigate immediately; rotate the lockfile via fresh install.',
147
+ });
148
+ }
149
+ }
150
+ return { findings, summary: { added, removed, bumped, substituted } };
151
+ }
152
+
153
+ function _isMajorBump(prev, cur) {
154
+ const pa = (prev || '').match(/^(\d+)/);
155
+ const pc = (cur || '').match(/^(\d+)/);
156
+ if (!pa || !pc) return false;
157
+ return parseInt(pa[1], 10) !== parseInt(pc[1], 10);
158
+ }
159
+
160
+ /**
161
+ * Convenience entry — run the full pipeline: persist current, diff
162
+ * against previous, return findings.
163
+ */
164
+ export function runSbomDiff(scanRoot, components) {
165
+ const current = persistSbom(scanRoot, components);
166
+ const previous = loadPreviousSnapshot(scanRoot, current.sha);
167
+ if (!previous) return { findings: [], summary: { added: 0, removed: 0, bumped: 0, substituted: 0 }, first: true };
168
+ return diffSboms(previous, current);
169
+ }
170
+
171
+ export const _internals = { _historyDir, _gitHead, _isMajorBump, _snapshotKey };
@@ -0,0 +1,235 @@
1
+ // SCA policy — declarative per-project rules for vulnerable_dep findings.
2
+ // Phase 4 / Item 7 of the SCA improvement plan.
3
+ //
4
+ // Reads .agentic-security/sca-policy.yml. Three classes of rule:
5
+ //
6
+ // accept-risk — per-CVE or per-package suppression with reason +
7
+ // optional expiry. Treated as "wont-fix" — the
8
+ // finding still appears but is marked suppressed.
9
+ // sla — per-severity / per-tier deadlines for remediation.
10
+ // Findings older than the SLA are escalated.
11
+ // major-version-freeze — refuse automated major-version bumps per
12
+ // ecosystem. /fix --sca consults this before
13
+ // calling apply_sca_upgrade on a breaking change.
14
+ //
15
+ // Policy file shape:
16
+ //
17
+ // accept-risk:
18
+ // - cve: CVE-2024-12345
19
+ // reason: "patched upstream; bundled vendor copy doesn't include affected code"
20
+ // expires: 2026-12-31
21
+ // - package: log4j-core
22
+ // version: 2.17.1
23
+ // reason: "we run on Java 21; JNDI lookup is disabled at runtime"
24
+ // expires: 2026-06-30
25
+ //
26
+ // sla:
27
+ // critical-kev: 7d
28
+ // critical: 30d
29
+ // high: 90d
30
+ // medium: 180d
31
+ //
32
+ // major-version-freeze:
33
+ // npm: [react, vue] # never auto-upgrade these across majors
34
+ // pypi: [django]
35
+ //
36
+ // Default policy (no file) is permissive: nothing is suppressed and no
37
+ // SLA is enforced. Users opt in by creating the file.
38
+
39
+ import * as fs from 'node:fs';
40
+ import * as path from 'node:path';
41
+ import * as yaml from 'js-yaml';
42
+
43
+ const DEFAULT_POLICY = {
44
+ acceptRisk: [],
45
+ sla: {},
46
+ majorVersionFreeze: {},
47
+ };
48
+
49
+ export function loadScaPolicy(scanRoot) {
50
+ if (!scanRoot) return null;
51
+ for (const name of ['sca-policy.yml', 'sca-policy.yaml', 'sca-policy.json']) {
52
+ const p = path.join(scanRoot, '.agentic-security', name);
53
+ if (!fs.existsSync(p)) continue;
54
+ try {
55
+ const raw = fs.readFileSync(p, 'utf8');
56
+ const doc = name.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
57
+ return _normalize(doc);
58
+ } catch (e) {
59
+ return { _error: `Failed to parse ${p}: ${e.message}` };
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function _normalize(doc) {
66
+ return {
67
+ acceptRisk: Array.isArray(doc?.['accept-risk']) ? doc['accept-risk'].map(_normalizeAccept) : [],
68
+ sla: _normalizeSla(doc?.sla || {}),
69
+ majorVersionFreeze: _normalizeMajor(doc?.['major-version-freeze'] || {}),
70
+ };
71
+ }
72
+
73
+ function _normalizeAccept(entry) {
74
+ return {
75
+ cve: entry.cve ? String(entry.cve).toUpperCase() : null,
76
+ package: entry.package ? String(entry.package).toLowerCase() : null,
77
+ version: entry.version ? String(entry.version) : null,
78
+ ecosystem: entry.ecosystem ? String(entry.ecosystem).toLowerCase() : null,
79
+ reason: entry.reason || '',
80
+ expires: entry.expires || null,
81
+ };
82
+ }
83
+
84
+ function _normalizeSla(sla) {
85
+ const out = {};
86
+ for (const [k, v] of Object.entries(sla)) {
87
+ out[k.toLowerCase()] = _parseSlaDuration(v);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function _parseSlaDuration(v) {
93
+ if (typeof v === 'number') return v * 86400_000; // bare number = days
94
+ const m = String(v).match(/^(\d+)([dwmy])$/i);
95
+ if (!m) return null;
96
+ const n = parseInt(m[1], 10);
97
+ const unit = m[2].toLowerCase();
98
+ const factor = { d: 86400_000, w: 7 * 86400_000, m: 30 * 86400_000, y: 365 * 86400_000 }[unit];
99
+ return factor ? n * factor : null;
100
+ }
101
+
102
+ function _normalizeMajor(maj) {
103
+ const out = {};
104
+ for (const [eco, list] of Object.entries(maj)) {
105
+ if (Array.isArray(list)) out[eco.toLowerCase()] = list.map(s => String(s).toLowerCase());
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Check whether an accept-risk entry is currently active (not expired).
111
+ function _accepted(entry, today = new Date()) {
112
+ if (!entry.expires) return true;
113
+ const exp = new Date(entry.expires);
114
+ if (Number.isNaN(+exp)) return true; // unparseable: treat as still active
115
+ return exp >= today;
116
+ }
117
+
118
+ // Match a finding against the accept-risk list. Returns the matching entry
119
+ // or null.
120
+ export function matchAcceptRisk(finding, policy, today = new Date()) {
121
+ if (!policy || !Array.isArray(policy.acceptRisk)) return null;
122
+ const cves = Array.isArray(finding.cveAliases) ? finding.cveAliases.map(c => String(c).toUpperCase()) : [];
123
+ if (finding.osvId) cves.push(String(finding.osvId).toUpperCase());
124
+ const pkgName = String(finding.name || '').toLowerCase();
125
+ for (const entry of policy.acceptRisk) {
126
+ if (!_accepted(entry, today)) continue;
127
+ if (entry.cve && cves.includes(entry.cve)) return entry;
128
+ if (entry.package && pkgName === entry.package) {
129
+ if (entry.version && finding.version && entry.version !== finding.version) continue;
130
+ if (entry.ecosystem && finding.ecosystem && entry.ecosystem !== finding.ecosystem) continue;
131
+ return entry;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ // Apply policy to a list of SCA findings. Marks accept-risk hits as
138
+ // suppressed; computes SLA deadlines; flags major-version-freeze packages
139
+ // so /fix --sca knows not to auto-upgrade them.
140
+ //
141
+ // Returns { suppressed: number, slaTagged: number, frozen: number }.
142
+ export function applyScaPolicy(findings, policy, scanTime = new Date()) {
143
+ const stats = { suppressed: 0, slaTagged: 0, frozen: 0 };
144
+ if (!policy || !Array.isArray(findings)) return stats;
145
+ for (const f of findings) {
146
+ if (!f || f.type !== 'vulnerable_dep') continue;
147
+ // Accept-risk suppression
148
+ const acceptance = matchAcceptRisk(f, policy, scanTime);
149
+ if (acceptance) {
150
+ f.suppressed = true;
151
+ f.suppressionReason = acceptance.reason || 'accepted-risk';
152
+ f.suppressionSource = 'sca-policy.yml';
153
+ f.suppressionExpires = acceptance.expires || null;
154
+ stats.suppressed++;
155
+ continue;
156
+ }
157
+ // SLA tag: pick the narrowest applicable bucket.
158
+ const slaKey = (f.kev || f.kevListed) && f.severity === 'critical' ? 'critical-kev'
159
+ : f.severity;
160
+ if (policy.sla && policy.sla[slaKey]) {
161
+ const startMs = f.firstSeenAt ? Date.parse(f.firstSeenAt) : +scanTime;
162
+ const deadline = startMs + policy.sla[slaKey];
163
+ f.slaDeadline = new Date(deadline).toISOString();
164
+ f.slaOverdue = +scanTime > deadline;
165
+ stats.slaTagged++;
166
+ }
167
+ // Major-version freeze
168
+ if (policy.majorVersionFreeze
169
+ && Array.isArray(policy.majorVersionFreeze[f.ecosystem])
170
+ && policy.majorVersionFreeze[f.ecosystem].includes(String(f.name).toLowerCase())) {
171
+ f.majorVersionFrozen = true;
172
+ stats.frozen++;
173
+ }
174
+ }
175
+ return stats;
176
+ }
177
+
178
+ // Materialize a wont-fix triage decision into a new accept-risk entry.
179
+ // Called by the triage → suppression bridge (Phase 4 / Item 7 of the SCA
180
+ // plan): when a user marks a vulnerable_dep finding wont-fix, the policy
181
+ // file is updated so future scans automatically suppress it.
182
+ //
183
+ // If the policy file doesn't exist, one is created with safe defaults.
184
+ export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
185
+ if (!scanRoot || !finding) return { ok: false, reason: 'missing arguments' };
186
+ const dir = path.join(scanRoot, '.agentic-security');
187
+ const fp = path.join(dir, 'sca-policy.yml');
188
+ let policy = loadScaPolicy(scanRoot);
189
+ if (policy && policy._error) return { ok: false, reason: policy._error };
190
+ if (!policy) policy = { acceptRisk: [], sla: {}, majorVersionFreeze: {} };
191
+ // Defensive: even if a policy file existed, it may have been parsed into a
192
+ // value that shares references with DEFAULT_POLICY. Force a fresh array.
193
+ if (!Array.isArray(policy.acceptRisk)) policy.acceptRisk = [];
194
+
195
+ // De-dupe — don't add a second entry for the same CVE.
196
+ const cves = Array.isArray(finding.cveAliases) ? finding.cveAliases.map(c => String(c).toUpperCase()) : [];
197
+ if (finding.osvId) cves.push(String(finding.osvId).toUpperCase());
198
+ for (const entry of policy.acceptRisk) {
199
+ if (entry.cve && cves.includes(entry.cve)) return { ok: false, reason: 'CVE already in accept-risk list', cve: entry.cve };
200
+ }
201
+
202
+ const newEntry = {
203
+ cve: cves[0] || null,
204
+ package: finding.name ? String(finding.name).toLowerCase() : null,
205
+ version: finding.version || null,
206
+ ecosystem: finding.ecosystem || null,
207
+ reason: reason || `Marked wont-fix in triage on ${new Date().toISOString().slice(0, 10)}`,
208
+ expires: null,
209
+ };
210
+ policy.acceptRisk.push(newEntry);
211
+
212
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
213
+ const serialized = yaml.dump({
214
+ 'accept-risk': policy.acceptRisk.map(e => {
215
+ const o = {};
216
+ if (e.cve) o.cve = e.cve;
217
+ if (e.package) o.package = e.package;
218
+ if (e.version) o.version = e.version;
219
+ if (e.ecosystem) o.ecosystem = e.ecosystem;
220
+ o.reason = e.reason;
221
+ if (e.expires) o.expires = e.expires;
222
+ return o;
223
+ }),
224
+ sla: policy.sla && Object.keys(policy.sla).length ? Object.fromEntries(Object.entries(policy.sla).map(([k, v]) => [k, _formatSlaDuration(v)])) : undefined,
225
+ 'major-version-freeze': policy.majorVersionFreeze && Object.keys(policy.majorVersionFreeze).length ? policy.majorVersionFreeze : undefined,
226
+ });
227
+ fs.writeFileSync(fp, serialized);
228
+ return { ok: true, entry: newEntry, path: fp };
229
+ }
230
+
231
+ function _formatSlaDuration(ms) {
232
+ if (typeof ms !== 'number') return ms;
233
+ const days = Math.round(ms / 86400_000);
234
+ return `${days}d`;
235
+ }
@@ -0,0 +1,259 @@
1
+ // SCA upgrade engine: dry-run plan + worktree-isolated apply.
2
+ //
3
+ // Phase 3 / Item 5 of the SCA improvement plan. The MCP `apply_fix` path
4
+ // refuses to write manifest files (package.json, *-lock.*, poetry.lock,
5
+ // Cargo.lock, etc.) for safety. SCA findings need a separate path that:
6
+ // 1. Generates an upgrade *plan* via the ecosystem's native dry-run
7
+ // command (npm install --dry-run, pip install --dry-run, etc.).
8
+ // 2. Applies the upgrade via the package manager itself, with a backup
9
+ // + test-gate so a peer-dep break or test regression rolls back.
10
+ //
11
+ // Caller pattern: plan first (read-only), inspect the breaking-change
12
+ // flag / peer warnings, then apply with confirm:true.
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as fsp from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import * as crypto from 'node:crypto';
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import { statePath } from './state-dir.js';
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ // Per-ecosystem command/manifest map. Add ecosystems by extending this
25
+ // table — every other place in the module reads it.
26
+ const ECOSYSTEM = {
27
+ npm: {
28
+ manifests: ['package.json', 'package-lock.json'],
29
+ altManifests: [['yarn.lock'], ['pnpm-lock.yaml']],
30
+ dryRun: (pkg, ver) => ({ cmd: 'npm', args: ['install', `${pkg}@${ver}`, '--dry-run', '--json'] }),
31
+ apply: (pkg, ver) => ({ cmd: 'npm', args: ['install', `${pkg}@${ver}`, '--save'] }),
32
+ parseDryRun(stdout) {
33
+ try {
34
+ const j = JSON.parse(stdout);
35
+ const peerDeps = Array.isArray(j.warnings) ? j.warnings.filter(w => /peer dep/i.test(w)) : [];
36
+ const transitiveImpact = (j.added || []).length + (j.updated || []).length + (j.removed || []).length;
37
+ return { peerDeps, transitiveImpact, rawSummary: { added: (j.added || []).length, updated: (j.updated || []).length, removed: (j.removed || []).length } };
38
+ } catch { return { peerDeps: [], transitiveImpact: 0, rawSummary: null }; }
39
+ },
40
+ },
41
+ pypi: {
42
+ manifests: ['requirements.txt', 'pyproject.toml'],
43
+ altManifests: [['poetry.lock'], ['Pipfile.lock']],
44
+ dryRun: (pkg, ver) => ({ cmd: 'pip', args: ['install', '--dry-run', `${pkg}==${ver}`] }),
45
+ apply: (pkg, ver) => ({ cmd: 'pip', args: ['install', '--upgrade', `${pkg}==${ver}`] }),
46
+ parseDryRun() {
47
+ // pip --dry-run output is human-readable; we don't parse it for v1.
48
+ return { peerDeps: [], transitiveImpact: 0, rawSummary: null };
49
+ },
50
+ },
51
+ cargo: {
52
+ manifests: ['Cargo.toml', 'Cargo.lock'],
53
+ altManifests: [],
54
+ dryRun: (pkg, _ver) => ({ cmd: 'cargo', args: ['update', '--package', pkg, '--dry-run'] }),
55
+ apply: (pkg, _ver) => ({ cmd: 'cargo', args: ['update', '--package', pkg] }),
56
+ parseDryRun() { return { peerDeps: [], transitiveImpact: 0, rawSummary: null }; },
57
+ },
58
+ golang: {
59
+ manifests: ['go.mod', 'go.sum'],
60
+ altManifests: [],
61
+ dryRun: (_pkg, _ver) => null, // `go get` has no dry-run flag; we skip dry-run in v1.
62
+ apply: (pkg, ver) => ({ cmd: 'go', args: ['get', `${pkg}@v${ver}`] }),
63
+ parseDryRun() { return { peerDeps: [], transitiveImpact: 0, rawSummary: null }; },
64
+ },
65
+ // Other ecosystems (rubygems, packagist, pub, maven) return a structured
66
+ // "manual" plan in v1 — no native dry-run + the install side-effects
67
+ // (gem build artifacts, composer cache) are easier for the user to
68
+ // confirm interactively.
69
+ };
70
+
71
+ function _majorVersion(v) {
72
+ const m = String(v || '').match(/^(\d+)/);
73
+ return m ? parseInt(m[1], 10) : null;
74
+ }
75
+
76
+ function _detectTestCommand(scanRoot) {
77
+ try {
78
+ const pkg = path.join(scanRoot, 'package.json');
79
+ if (fs.existsSync(pkg)) {
80
+ const j = JSON.parse(fs.readFileSync(pkg, 'utf8'));
81
+ if (j.scripts?.test && !/no test specified/i.test(j.scripts.test)) {
82
+ return { cmd: 'npm', args: ['test'] };
83
+ }
84
+ }
85
+ } catch {}
86
+ if (fs.existsSync(path.join(scanRoot, 'Cargo.toml'))) return { cmd: 'cargo', args: ['test'] };
87
+ if (fs.existsSync(path.join(scanRoot, 'go.mod'))) return { cmd: 'go', args: ['test', './...'] };
88
+ if (fs.existsSync(path.join(scanRoot, 'pyproject.toml'))) return { cmd: 'pytest', args: [] };
89
+ return null;
90
+ }
91
+
92
+ // Produce a structured upgrade plan WITHOUT modifying anything on disk.
93
+ // Safe to call repeatedly; runs the ecosystem's --dry-run command.
94
+ export async function planScaUpgrade({ scanRoot, finding }) {
95
+ if (!finding || finding.type !== 'vulnerable_dep') {
96
+ return { ok: false, reason: 'not a vulnerable_dep finding' };
97
+ }
98
+ const eco = ECOSYSTEM[finding.ecosystem];
99
+ if (!eco) {
100
+ return {
101
+ ok: true,
102
+ mode: 'manual',
103
+ reason: `ecosystem '${finding.ecosystem}' has no automated upgrade in v1`,
104
+ package: finding.name, currentVersion: finding.version,
105
+ targetVersion: (finding.fixedVersions && finding.fixedVersions[0]) || null,
106
+ command: null,
107
+ };
108
+ }
109
+ const target = (finding.fixedVersions && finding.fixedVersions[0]) || null;
110
+ if (!target) {
111
+ return { ok: false, reason: 'no fixed version in OSV record' };
112
+ }
113
+ const isBreaking = (_majorVersion(target) ?? 0) > (_majorVersion(finding.version) ?? 0);
114
+ const apply = eco.apply(finding.name, target);
115
+ const dryRunSpec = eco.dryRun(finding.name, target);
116
+ let peerDeps = [], transitiveImpact = 0, rawSummary = null, dryRunOk = null;
117
+ if (dryRunSpec) {
118
+ try {
119
+ const r = await execFileAsync(dryRunSpec.cmd, dryRunSpec.args, {
120
+ cwd: scanRoot, timeout: 60_000, maxBuffer: 8 * 1024 * 1024,
121
+ });
122
+ const parsed = eco.parseDryRun(r.stdout);
123
+ peerDeps = parsed.peerDeps;
124
+ transitiveImpact = parsed.transitiveImpact;
125
+ rawSummary = parsed.rawSummary;
126
+ dryRunOk = true;
127
+ } catch (e) {
128
+ // Dry-run failed (e.g. peer-dep resolution conflict). Surface the
129
+ // error structurally so the caller can decide whether to proceed.
130
+ dryRunOk = false;
131
+ const stderr = (e && e.stderr) || (e && e.message) || '';
132
+ peerDeps = /peer dep/i.test(stderr) ? [String(stderr).slice(0, 500)] : [];
133
+ }
134
+ }
135
+ return {
136
+ ok: true,
137
+ mode: 'auto',
138
+ ecosystem: finding.ecosystem,
139
+ package: finding.name,
140
+ currentVersion: finding.version,
141
+ targetVersion: target,
142
+ isBreaking,
143
+ command: `${apply.cmd} ${apply.args.join(' ')}`,
144
+ manifestFiles: eco.manifests,
145
+ dryRun: { ok: dryRunOk, command: dryRunSpec ? `${dryRunSpec.cmd} ${dryRunSpec.args.join(' ')}` : null, peerDeps, transitiveImpact, rawSummary },
146
+ testCommand: (() => { const t = _detectTestCommand(scanRoot); return t ? `${t.cmd} ${t.args.join(' ')}` : null; })(),
147
+ };
148
+ }
149
+
150
+ // Apply the upgrade. Backs up affected manifests, runs the install, runs
151
+ // the project's test command if detected, and ROLLS BACK on failure.
152
+ // Audit-logged via the MCP audit layer at the call site.
153
+ export async function applyScaUpgrade({ scanRoot, finding, runTests = true }) {
154
+ const plan = await planScaUpgrade({ scanRoot, finding });
155
+ if (!plan.ok) return { applied: false, reason: plan.reason };
156
+ if (plan.mode === 'manual') {
157
+ return { applied: false, reason: plan.reason, plan };
158
+ }
159
+ const eco = ECOSYSTEM[finding.ecosystem];
160
+ const target = plan.targetVersion;
161
+
162
+ // Backup pass — record original contents of every relevant manifest so
163
+ // we can restore on test failure. node_modules / vendor dirs are NOT
164
+ // backed up (too big); they'll be rebuilt by re-running the install on
165
+ // restore.
166
+ const stateDir = statePath(scanRoot, 'sca-upgrade-history');
167
+ fs.mkdirSync(stateDir, { recursive: true });
168
+ const upgradeId = crypto.randomBytes(8).toString('hex');
169
+ const backups = {};
170
+ for (const mf of eco.manifests) {
171
+ const abs = path.join(scanRoot, mf);
172
+ if (!fs.existsSync(abs)) continue;
173
+ const content = await fsp.readFile(abs, 'utf8');
174
+ const backupPath = path.join(stateDir, `${upgradeId}-${mf.replace(/[\/\\]/g, '_')}.bak`);
175
+ await fsp.writeFile(backupPath, content);
176
+ backups[mf] = { abs, backupPath };
177
+ }
178
+ if (!Object.keys(backups).length) {
179
+ return { applied: false, reason: `no ${finding.ecosystem} manifest files found in scan root` };
180
+ }
181
+
182
+ // Run the install.
183
+ const apply = eco.apply(finding.name, target);
184
+ let installOutput = '';
185
+ try {
186
+ const r = await execFileAsync(apply.cmd, apply.args, {
187
+ cwd: scanRoot, timeout: 300_000, maxBuffer: 16 * 1024 * 1024,
188
+ });
189
+ installOutput = (r.stdout || '') + (r.stderr || '');
190
+ } catch (e) {
191
+ // Install failed; restore backups (manifests may have been touched).
192
+ for (const { abs, backupPath } of Object.values(backups)) {
193
+ try { await fsp.copyFile(backupPath, abs); } catch {}
194
+ }
195
+ return {
196
+ applied: false,
197
+ reason: `install failed: ${(e && e.message) || e}`.slice(0, 600),
198
+ installOutput: ((e && e.stdout) || '').slice(0, 1500),
199
+ restored: true,
200
+ upgradeId,
201
+ };
202
+ }
203
+
204
+ // Optionally run the project's test command. On failure, restore.
205
+ let testResult = null;
206
+ if (runTests) {
207
+ const test = _detectTestCommand(scanRoot);
208
+ if (test) {
209
+ try {
210
+ const r = await execFileAsync(test.cmd, test.args, {
211
+ cwd: scanRoot, timeout: 600_000, maxBuffer: 16 * 1024 * 1024,
212
+ });
213
+ testResult = { ok: true, command: `${test.cmd} ${test.args.join(' ')}`, output: ((r.stdout || '') + (r.stderr || '')).slice(0, 2000) };
214
+ } catch (e) {
215
+ // Tests failed — restore manifests so the working tree is clean.
216
+ for (const { abs, backupPath } of Object.values(backups)) {
217
+ try { await fsp.copyFile(backupPath, abs); } catch {}
218
+ }
219
+ return {
220
+ applied: false,
221
+ reason: `tests failed after upgrade: ${(e && e.message) || e}`.slice(0, 300),
222
+ testOutput: ((e && e.stdout) || '').slice(0, 2000),
223
+ restored: true,
224
+ upgradeId,
225
+ };
226
+ }
227
+ }
228
+ }
229
+
230
+ // Success path — write a log entry so the user can audit / undo.
231
+ const logEntry = {
232
+ id: upgradeId,
233
+ timestamp: new Date().toISOString(),
234
+ ecosystem: finding.ecosystem,
235
+ package: finding.name,
236
+ from: finding.version,
237
+ to: target,
238
+ backups: Object.fromEntries(Object.entries(backups).map(([k, v]) => [k, v.backupPath])),
239
+ testResult,
240
+ isBreaking: plan.isBreaking,
241
+ finding: { id: finding.id, osvId: finding.osvId, cveAliases: finding.cveAliases },
242
+ };
243
+ const logFp = path.join(stateDir, 'log.json');
244
+ let log = [];
245
+ try { log = JSON.parse(fs.readFileSync(logFp, 'utf8')); } catch {}
246
+ log.push(logEntry);
247
+ fs.writeFileSync(logFp, JSON.stringify(log, null, 2));
248
+
249
+ return {
250
+ applied: true,
251
+ upgradeId,
252
+ package: finding.name,
253
+ from: finding.version,
254
+ to: target,
255
+ isBreaking: plan.isBreaking,
256
+ testResult,
257
+ installOutput: installOutput.slice(0, 1500),
258
+ };
259
+ }