@clear-capabilities/agentic-security-scanner 0.78.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 (126) hide show
  1. package/bin/.agentic-security/findings.json +16 -16
  2. package/bin/.agentic-security/last-scan.json +16 -16
  3. package/bin/.agentic-security/last-scan.json.sig +1 -1
  4. package/bin/.agentic-security/scan-history.json +51 -0
  5. package/bin/.agentic-security/streak.json +5 -5
  6. package/bin/agentic-security.js +22 -7
  7. package/dist/178.index.js +1 -1
  8. package/dist/333.index.js +283 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/476.index.js +5 -5
  11. package/dist/637.index.js +1 -1
  12. package/dist/700.index.js +138 -0
  13. package/dist/718.index.js +53 -0
  14. package/dist/838.index.js +1 -1
  15. package/dist/985.index.js +95 -1
  16. package/dist/agentic-security.mjs +83 -83
  17. package/dist/agentic-security.mjs.sha256 +1 -1
  18. package/package.json +6 -4
  19. package/src/.agentic-security/findings.json +29799 -7803
  20. package/src/.agentic-security/last-scan.json +29799 -7803
  21. package/src/.agentic-security/last-scan.json.sig +1 -1
  22. package/src/.agentic-security/scan-history.json +5119 -2611
  23. package/src/.agentic-security/streak.json +6 -6
  24. package/src/dataflow/.agentic-security/findings.json +2879 -308
  25. package/src/dataflow/.agentic-security/last-scan.json +2879 -308
  26. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -1
  27. package/src/dataflow/.agentic-security/scan-history.json +68 -520
  28. package/src/dataflow/.agentic-security/streak.json +6 -7
  29. package/src/dataflow/cross-service-taint.js +201 -0
  30. package/src/dataflow/engine.js +52 -8
  31. package/src/dataflow/formal-verify.js +204 -0
  32. package/src/dataflow/ifds-precise.js +222 -0
  33. package/src/dataflow/k2-summary-cache.js +153 -0
  34. package/src/dataflow/lib-taint-summaries.js +198 -0
  35. package/src/dataflow/privacy-taint.js +205 -0
  36. package/src/dataflow/smt-feasibility.js +189 -0
  37. package/src/engine.js +890 -132
  38. package/src/integrations/index.js +2 -1
  39. package/src/ir/.agentic-security/findings.json +240 -6
  40. package/src/ir/.agentic-security/last-scan.json +240 -6
  41. package/src/ir/.agentic-security/last-scan.json.sig +1 -1
  42. package/src/ir/.agentic-security/scan-history.json +16 -594
  43. package/src/ir/.agentic-security/streak.json +8 -9
  44. package/src/ir/callgraph.js +27 -7
  45. package/src/ir/cpp-preprocessor.js +142 -0
  46. package/src/ir/csharp-ir.js +604 -0
  47. package/src/ir/universal-ir.js +403 -0
  48. package/src/llm-validator/index.js +7 -5
  49. package/src/mcp/.agentic-security/findings.json +8632 -0
  50. package/src/mcp/.agentic-security/last-scan.json +8632 -0
  51. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  52. package/src/mcp/.agentic-security/scan-history.json +143 -0
  53. package/src/mcp/.agentic-security/streak.json +20 -0
  54. package/src/mcp/audit.js +5 -0
  55. package/src/mcp/tools.js +90 -1
  56. package/src/posture/.agentic-security/findings.json +16809 -4367
  57. package/src/posture/.agentic-security/last-scan.json +16809 -4367
  58. package/src/posture/.agentic-security/last-scan.json.sig +1 -1
  59. package/src/posture/.agentic-security/scan-history.json +6689 -177
  60. package/src/posture/.agentic-security/streak.json +8 -7
  61. package/src/posture/api-contract.js +193 -0
  62. package/src/posture/attack-taxonomy.js +227 -0
  63. package/src/posture/calibration-drift.js +2 -1
  64. package/src/posture/calibration.js +3 -2
  65. package/src/posture/compliance-policy.js +218 -0
  66. package/src/posture/composite-risk.js +122 -0
  67. package/src/posture/csharp-analysis.js +330 -0
  68. package/src/posture/exploit-bundle.js +210 -0
  69. package/src/posture/federated-learning.js +172 -0
  70. package/src/posture/fix-history.js +8 -2
  71. package/src/posture/license-attributions.js +94 -0
  72. package/src/posture/license-graph.js +238 -0
  73. package/src/posture/pqc-migration-plan.js +158 -0
  74. package/src/posture/profile.js +4 -5
  75. package/src/posture/reachability-filter.js +33 -2
  76. package/src/posture/realtime-cve-monitor.js +214 -0
  77. package/src/posture/rule-overrides.js +2 -3
  78. package/src/posture/rule-pack-signing.js +2 -3
  79. package/src/posture/rule-synthesis.js +5 -6
  80. package/src/posture/runtime-correlation.js +174 -0
  81. package/src/posture/sbom-diff.js +171 -0
  82. package/src/posture/sca-policy.js +235 -0
  83. package/src/posture/sca-upgrade.js +259 -0
  84. package/src/posture/security-trend.js +4 -7
  85. package/src/posture/state-dir.js +124 -0
  86. package/src/posture/streak.js +3 -0
  87. package/src/posture/suppressions.js +5 -8
  88. package/src/posture/threat-model-auto.js +268 -0
  89. package/src/posture/triage-learning.js +170 -0
  90. package/src/posture/triage.js +29 -6
  91. package/src/posture/validator-metrics.js +3 -6
  92. package/src/sast/.agentic-security/findings.json +996 -32
  93. package/src/sast/.agentic-security/last-scan.json +996 -32
  94. package/src/sast/.agentic-security/last-scan.json.sig +1 -1
  95. package/src/sast/.agentic-security/scan-history.json +565 -32
  96. package/src/sast/.agentic-security/streak.json +10 -8
  97. package/src/sast/_secret-entropy.js +145 -0
  98. package/src/sast/cloud-iam.js +312 -0
  99. package/src/sast/cpp.js +138 -4
  100. package/src/sast/crypto-protocol.js +388 -0
  101. package/src/sast/csharp-tokenizer.js +392 -0
  102. package/src/sast/csharp.js +924 -138
  103. package/src/sast/dapp-frontend.js +200 -0
  104. package/src/sast/db-taint.js +24 -0
  105. package/src/sast/k8s-admission.js +271 -0
  106. package/src/sast/llm-app.js +272 -0
  107. package/src/sast/ml-supply-chain.js +259 -0
  108. package/src/sast/mobile.js +224 -0
  109. package/src/sast/post-quantum-crypto.js +348 -0
  110. package/src/sast/rust.js +26 -0
  111. package/src/sast/web3-advanced.js +375 -0
  112. package/src/sca/.agentic-security/findings.json +6044 -171
  113. package/src/sca/.agentic-security/last-scan.json +6044 -171
  114. package/src/sca/.agentic-security/last-scan.json.sig +1 -1
  115. package/src/sca/.agentic-security/scan-history.json +83 -6
  116. package/src/sca/.agentic-security/streak.json +9 -9
  117. package/src/sca/CLAUDE.md +161 -0
  118. package/src/sca/binary-metadata.js +146 -0
  119. package/src/sca/py-package-functions.js +118 -0
  120. package/src/sca/sigstore-verify.js +215 -0
  121. package/src/sca/vendor-detect.js +53 -0
  122. package/src/report/.agentic-security/findings.json +0 -80
  123. package/src/report/.agentic-security/last-scan.json +0 -80
  124. package/src/report/.agentic-security/last-scan.json.sig +0 -1
  125. package/src/report/.agentic-security/scan-history.json +0 -35
  126. package/src/report/.agentic-security/streak.json +0 -22
@@ -8,9 +8,7 @@
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import * as yaml from 'js-yaml';
11
-
12
- const VIBECODER_PATH = '.agentic-security/accepted.json';
13
- const PRO_PATH = '.agentic-security/suppressions.yml';
11
+ import { statePath, safeWriteState } from './state-dir.js';
14
12
 
15
13
  const MS_PER_DAY = 86400000;
16
14
  const SOFT_TTL_DAYS = 30;
@@ -22,7 +20,7 @@ function _dateOnly(iso) {
22
20
  }
23
21
 
24
22
  export function loadSoftAccepted(scanRoot) {
25
- const fp = path.join(scanRoot || process.cwd(), VIBECODER_PATH);
23
+ const fp = statePath(scanRoot, 'accepted.json');
26
24
  if (!fs.existsSync(fp)) return [];
27
25
  try {
28
26
  const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
@@ -31,9 +29,8 @@ export function loadSoftAccepted(scanRoot) {
31
29
  }
32
30
 
33
31
  export function saveSoftAccepted(scanRoot, items) {
34
- const fp = path.join(scanRoot || process.cwd(), VIBECODER_PATH);
35
- fs.mkdirSync(path.dirname(fp), { recursive: true });
36
- fs.writeFileSync(fp, JSON.stringify({ accepted: items }, null, 2));
32
+ const fp = statePath(scanRoot, 'accepted.json');
33
+ safeWriteState(fp, JSON.stringify({ accepted: items }, null, 2));
37
34
  }
38
35
 
39
36
  export function addSoftAcceptance(scanRoot, finding, reason) {
@@ -53,7 +50,7 @@ export function addSoftAcceptance(scanRoot, finding, reason) {
53
50
  }
54
51
 
55
52
  export function loadProSuppressions(scanRoot) {
56
- const fp = path.join(scanRoot || process.cwd(), PRO_PATH);
53
+ const fp = statePath(scanRoot, 'suppressions.yml');
57
54
  if (!fs.existsSync(fp)) return [];
58
55
  try {
59
56
  const parsed = yaml.load(fs.readFileSync(fp, 'utf8'));
@@ -0,0 +1,268 @@
1
+ // Auto-generated threat model — Recommendation #2 of the world-class+2 plan.
2
+ //
3
+ // Builds a STRIDE threat model from the scan's findings + IR + privacy
4
+ // taint. Outputs Mermaid diagrams + per-asset attack trees grounded in
5
+ // actual scanner evidence. The model is "live" — regenerated every scan
6
+ // so it can't bit-rot.
7
+ //
8
+ // Pipeline:
9
+ // 1. Identify external entities — every route, message-queue consumer,
10
+ // file ingest, S3 listener, etc.
11
+ // 2. Identify trust boundaries — entity → handler boundary, internal-
12
+ // service → external-service boundary
13
+ // 3. Identify assets — every PII/PHI/PCI field, every credential, every
14
+ // authoritative DB/cache, every secret-bearing service
15
+ // 4. Apply STRIDE per (entity, asset) pair using template-driven rules
16
+ // 5. Generate attack trees rooted at each high-value asset with leaves
17
+ // grounded in actual scanner findings
18
+ //
19
+ // Output:
20
+ // { entities, boundaries, assets, threats, attackTrees, mermaid }
21
+ //
22
+ // Persisted to .agentic-security/threat-model.json (machine-readable) and
23
+ // .agentic-security/threat-model.md (human-readable).
24
+
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+
28
+ // STRIDE category descriptors
29
+ const STRIDE = {
30
+ S: { label: 'Spoofing', control: 'Authentication / Identity' },
31
+ T: { label: 'Tampering', control: 'Integrity / Authorization' },
32
+ R: { label: 'Repudiation', control: 'Audit logs / Non-repudiation' },
33
+ I: { label: 'Information Disclosure', control: 'Confidentiality / Encryption' },
34
+ D: { label: 'Denial of Service',control: 'Availability / Rate limiting' },
35
+ E: { label: 'Elevation of Privilege', control: 'Authorization / Least privilege' },
36
+ };
37
+
38
+ // Map CWE → STRIDE categories. Multi-mapping is allowed.
39
+ const CWE_TO_STRIDE = {
40
+ 'CWE-22': ['I'], // path-traversal
41
+ 'CWE-78': ['E', 'T'], // command-injection
42
+ 'CWE-79': ['T'], // xss
43
+ 'CWE-89': ['T', 'I'], // sql-injection
44
+ 'CWE-90': ['T'], // ldap-injection
45
+ 'CWE-94': ['E'], // code-injection
46
+ 'CWE-113': ['T'], // header injection
47
+ 'CWE-134': ['I'], // format-string
48
+ 'CWE-200': ['I'], // information-exposure
49
+ 'CWE-287': ['S'], // improper authentication
50
+ 'CWE-307': ['S'], // brute-force
51
+ 'CWE-327': ['I'], // weak-crypto
52
+ 'CWE-330': ['S'], // weak-rng
53
+ 'CWE-352': ['T'], // csrf
54
+ 'CWE-359': ['I'], // private-info exposure
55
+ 'CWE-415': ['D', 'E'], // double-free
56
+ 'CWE-416': ['D', 'E'], // use-after-free
57
+ 'CWE-434': ['E'], // file upload
58
+ 'CWE-502': ['E'], // insecure-deserialization
59
+ 'CWE-601': ['T'], // open-redirect
60
+ 'CWE-611': ['I'], // xxe
61
+ 'CWE-639': ['E'], // IDOR
62
+ 'CWE-643': ['T'], // xpath injection
63
+ 'CWE-798': ['I', 'S'], // hardcoded-secret
64
+ 'CWE-918': ['I', 'E'], // ssrf
65
+ 'CWE-1004':['I'], // missing cookie hardening
66
+ 'CWE-1321':['T', 'E'], // prototype-pollution
67
+ 'CWE-1333':['D'], // ReDoS
68
+ 'CWE-1427':['T'], // prompt injection
69
+ };
70
+
71
+ function _stridesForFinding(f) {
72
+ const c = f.cwe || '';
73
+ if (CWE_TO_STRIDE[c]) return CWE_TO_STRIDE[c];
74
+ // Family-based fallback
75
+ if (f.family === 'sql-injection') return ['T', 'I'];
76
+ if (f.family === 'command-injection') return ['E', 'T'];
77
+ if (f.family === 'xss') return ['T'];
78
+ if (f.family === 'hardcoded-secret') return ['I'];
79
+ return ['T'];
80
+ }
81
+
82
+ /**
83
+ * Build the threat-model graph. `scan` is the engine's scan result
84
+ * structure (findings + routes + supplyChain).
85
+ */
86
+ export function buildThreatModel(scan, opts = {}) {
87
+ const entities = []; // external entities: routes, queue consumers, file ingest
88
+ const boundaries = []; // trust boundary edges
89
+ const assets = []; // valuables: PII, credentials, DBs
90
+ const threats = []; // (entity, asset, stride) tuples
91
+
92
+ // Step 1: external entities — routes
93
+ for (const r of (scan.routes || [])) {
94
+ entities.push({
95
+ kind: 'http-route',
96
+ id: `route:${r.method || 'ANY'}:${r.path || r.file + ':' + r.line}`,
97
+ method: r.method || 'ANY',
98
+ path: r.path || null,
99
+ file: r.file, line: r.line,
100
+ requiresAuth: !!r.requiresAuth,
101
+ });
102
+ }
103
+ // External entities — message queues / consumers / file ingest (best-effort
104
+ // heuristic from findings).
105
+ for (const f of (scan.findings || [])) {
106
+ if (/kafka|sqs|sns|rabbit|pubsub|kinesis/i.test(f.snippet || '')) {
107
+ entities.push({ kind: 'queue-consumer', id: `queue:${f.file}:${f.line}`, file: f.file, line: f.line });
108
+ }
109
+ }
110
+
111
+ // Step 2: assets — PII / credentials / DBs
112
+ for (const f of (scan.findings || [])) {
113
+ if (f.family === 'hardcoded-secret') {
114
+ assets.push({ kind: 'credential', id: `cred:${f.file}:${f.line}`, file: f.file, line: f.line, name: (f.vuln||'').slice(0, 80) });
115
+ }
116
+ if (f.family === 'pii-exposure') {
117
+ assets.push({ kind: 'pii', id: `pii:${f.file}:${f.line}`, file: f.file, line: f.line, classes: f.piiClass || [] });
118
+ }
119
+ }
120
+ // DB-shaped assets: routes that touch SQL.
121
+ const dbAsset = { kind: 'datastore', id: 'datastore:default', name: 'Application Database' };
122
+ let hasDbFinding = false;
123
+ for (const f of (scan.findings || [])) {
124
+ if (f.family === 'sql-injection' || /SqlCommand|prepareStatement|EntityManager/i.test(f.snippet || '')) {
125
+ hasDbFinding = true; break;
126
+ }
127
+ }
128
+ if (hasDbFinding) assets.push(dbAsset);
129
+
130
+ // Step 3: trust boundaries
131
+ for (const e of entities) {
132
+ boundaries.push({ from: 'external', to: e.id, kind: 'trust-boundary', requiresAuth: e.requiresAuth });
133
+ }
134
+ for (const a of assets) {
135
+ boundaries.push({ from: 'application', to: a.id, kind: 'asset-boundary' });
136
+ }
137
+
138
+ // Step 4: STRIDE per finding (each finding implies one or more threats)
139
+ for (const f of (scan.findings || [])) {
140
+ const sts = _stridesForFinding(f);
141
+ for (const st of sts) {
142
+ threats.push({
143
+ stride: st,
144
+ strideLabel: STRIDE[st]?.label || st,
145
+ cwe: f.cwe || null,
146
+ family: f.family,
147
+ severity: f.severity,
148
+ file: f.file, line: f.line,
149
+ vuln: f.vuln,
150
+ finding_id: f.id,
151
+ affectsAsset: assets.find(a =>
152
+ (a.kind === 'credential' && f.family === 'hardcoded-secret') ||
153
+ (a.kind === 'pii' && f.family === 'pii-exposure') ||
154
+ (a.kind === 'datastore' && (f.family === 'sql-injection' || f.family === 'insecure-deserialization'))
155
+ )?.id || null,
156
+ atEntity: entities.find(e => e.file === f.file && Math.abs((e.line||0) - (f.line||0)) <= 50)?.id || null,
157
+ });
158
+ }
159
+ }
160
+
161
+ // Step 5: attack trees — per high-value asset
162
+ const attackTrees = assets.map(a => {
163
+ const leaves = threats
164
+ .filter(t => t.affectsAsset === a.id)
165
+ .map(t => ({
166
+ label: `${t.strideLabel} via ${t.family} (${t.cwe || '—'})`,
167
+ severity: t.severity,
168
+ file: t.file, line: t.line,
169
+ finding_id: t.finding_id,
170
+ }));
171
+ return {
172
+ root: `Compromise ${a.kind}: ${a.name || a.id}`,
173
+ asset_id: a.id,
174
+ leaves,
175
+ severity: leaves.some(l => l.severity === 'critical') ? 'critical'
176
+ : leaves.some(l => l.severity === 'high') ? 'high' : 'medium',
177
+ };
178
+ });
179
+
180
+ return { entities, boundaries, assets, threats, attackTrees };
181
+ }
182
+
183
+ /**
184
+ * Render the model as a Mermaid flowchart for visual review.
185
+ */
186
+ export function renderMermaid(model) {
187
+ const lines = ['flowchart TB'];
188
+ lines.push(' subgraph External');
189
+ for (const e of model.entities.slice(0, 30)) {
190
+ lines.push(` ${_mid(e.id)}["${e.kind}: ${e.method || ''} ${e.path || (e.file||'') + ':' + (e.line||'')}"]`);
191
+ }
192
+ lines.push(' end');
193
+ lines.push(' subgraph Application');
194
+ for (const a of model.assets.slice(0, 30)) {
195
+ lines.push(` ${_mid(a.id)}{{"${a.kind}: ${a.name || a.id}"}}`);
196
+ }
197
+ lines.push(' end');
198
+ for (const b of model.boundaries.slice(0, 100)) {
199
+ if (b.kind === 'trust-boundary') {
200
+ lines.push(` External --> ${_mid(b.to)}`);
201
+ } else if (b.kind === 'asset-boundary') {
202
+ lines.push(` ${_mid(b.from)} -.-> ${_mid(b.to)}`);
203
+ }
204
+ }
205
+ return lines.join('\n');
206
+ }
207
+
208
+ function _mid(id) { return String(id).replace(/[^A-Za-z0-9]/g, '_').slice(0, 60); }
209
+
210
+ /**
211
+ * Persist threat model to disk: JSON for tooling, Markdown for review.
212
+ */
213
+ export function persistThreatModel(scanRoot, model) {
214
+ const dir = path.join(scanRoot, '.agentic-security');
215
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
216
+ try { fs.writeFileSync(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2)); } catch {}
217
+ try { fs.writeFileSync(path.join(dir, 'threat-model.md'), renderMarkdown(model)); } catch {}
218
+ }
219
+
220
+ function renderMarkdown(model) {
221
+ const lines = [];
222
+ lines.push('# Threat Model (auto-generated)');
223
+ lines.push('');
224
+ lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0,10)}.`);
225
+ lines.push('');
226
+ lines.push('This threat model is derived from static analysis of the current codebase and is regenerated on every scan. It is intended as a working artifact, not a finished compliance document.');
227
+ lines.push('');
228
+ lines.push('## Entities + boundaries');
229
+ lines.push('');
230
+ lines.push('```mermaid');
231
+ lines.push(renderMermaid(model));
232
+ lines.push('```');
233
+ lines.push('');
234
+ lines.push('## Assets');
235
+ lines.push('');
236
+ for (const a of model.assets.slice(0, 100)) {
237
+ lines.push(`- **${a.kind}**: ${a.name || a.id} — at \`${a.file || '(global)'}${a.line ? ':'+a.line : ''}\``);
238
+ }
239
+ lines.push('');
240
+ lines.push('## STRIDE threats');
241
+ lines.push('');
242
+ const byStride = {};
243
+ for (const t of model.threats) (byStride[t.stride] ||= []).push(t);
244
+ for (const [st, threats] of Object.entries(byStride)) {
245
+ lines.push(`### ${STRIDE[st]?.label || st} (${threats.length})`);
246
+ lines.push('');
247
+ for (const t of threats.slice(0, 25)) {
248
+ lines.push(`- [${t.severity}] **${t.family}** (${t.cwe || '—'}) at \`${t.file}:${t.line}\` — ${t.vuln}`);
249
+ }
250
+ if (threats.length > 25) lines.push(`- … and ${threats.length - 25} more`);
251
+ lines.push('');
252
+ }
253
+ lines.push('## Attack trees');
254
+ lines.push('');
255
+ for (const tree of model.attackTrees) {
256
+ lines.push(`### ${tree.root}`);
257
+ lines.push(`Severity rollup: **${tree.severity}**`);
258
+ lines.push('');
259
+ for (const leaf of tree.leaves.slice(0, 20)) {
260
+ lines.push(`- [${leaf.severity}] ${leaf.label} — \`${leaf.file}:${leaf.line}\``);
261
+ }
262
+ if (tree.leaves.length > 20) lines.push(`- … and ${tree.leaves.length - 20} more leaves`);
263
+ lines.push('');
264
+ }
265
+ return lines.join('\n');
266
+ }
267
+
268
+ export const _internals = { STRIDE, CWE_TO_STRIDE };
@@ -0,0 +1,170 @@
1
+ // Continuous learning from triage decisions — Recommendation #8 of the
2
+ // world-class roadmap.
3
+ //
4
+ // Every triage transition (open → fixed | wont-fix | false-positive)
5
+ // auto-tunes a per-(project, family, file-glob, sink-method) calibration
6
+ // store. The store directly modifies finding confidence scores on
7
+ // subsequent scans, so the scanner's precision on each individual
8
+ // codebase improves monotonically the longer it runs.
9
+ //
10
+ // Calibration shape:
11
+ //
12
+ // { "global": { // per-(family, sink-method) prior across all projects
13
+ // "sql-injection|.executeQuery": { tp: 142, fp: 7, lastUpdated: "..." }
14
+ // },
15
+ // "perProject": { // per-(file-glob, family) project-specific delta
16
+ // "src/admin/**|hardcoded-secret": { tp: 0, fp: 23, lastUpdated: "..." }
17
+ // }
18
+ // }
19
+ //
20
+ // Update rule on triage:
21
+ // transition → 'fixed' / 'wont-fix-because-not-exploitable' counts as TP+1
22
+ // transition → 'false-positive' counts as FP+1
23
+ //
24
+ // Application rule at scan time:
25
+ // confidence *= bayesianFactor(prior, project)
26
+ // where bayesianFactor uses a beta-distribution update of the priors.
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { statePath, safeWriteState } from './state-dir.js';
31
+
32
+ const CALIBRATION_FILE = 'triage-calibration.json';
33
+
34
+ // Minimum sample size before per-project calibration takes effect. With
35
+ // fewer than this many triage decisions, we fall back to global priors.
36
+ const MIN_PROJECT_SAMPLES = 5;
37
+
38
+ // Bayesian prior — beta(α, β) over the precision rate. α=1, β=1 is a
39
+ // uniform prior (no opinion); α=2, β=1 mildly favors precision.
40
+ const PRIOR_ALPHA = 2;
41
+ const PRIOR_BETA = 1;
42
+
43
+ function _storePath(scanRoot) {
44
+ return statePath(scanRoot, CALIBRATION_FILE);
45
+ }
46
+
47
+ export function loadCalibration(scanRoot) {
48
+ const fp = _storePath(scanRoot);
49
+ if (!fs.existsSync(fp)) return { global: {}, perProject: {} };
50
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
51
+ catch { return { global: {}, perProject: {} }; }
52
+ }
53
+
54
+ function _save(scanRoot, data) {
55
+ const fp = _storePath(scanRoot);
56
+ safeWriteState(fp, JSON.stringify(data, null, 2));
57
+ }
58
+
59
+ function _bucketKey(family, sinkMethod) {
60
+ return `${family || 'unknown'}|${sinkMethod || ''}`;
61
+ }
62
+
63
+ function _projectKey(fileGlob, family) {
64
+ return `${fileGlob || '*'}|${family || 'unknown'}`;
65
+ }
66
+
67
+ /**
68
+ * Record a triage decision. Called by the triage transition path.
69
+ * verdict ∈ { 'true-positive', 'false-positive' }
70
+ */
71
+ export function recordTriageDecision(scanRoot, finding, verdict) {
72
+ if (!finding || !verdict) return null;
73
+ const data = loadCalibration(scanRoot);
74
+ const family = finding.family;
75
+ const sinkMethod = _extractSinkMethod(finding);
76
+ const fileGlob = _fileGlobFor(finding.file);
77
+ const now = new Date().toISOString();
78
+
79
+ const gk = _bucketKey(family, sinkMethod);
80
+ data.global[gk] ||= { tp: 0, fp: 0, lastUpdated: now };
81
+ if (verdict === 'true-positive') data.global[gk].tp++;
82
+ if (verdict === 'false-positive') data.global[gk].fp++;
83
+ data.global[gk].lastUpdated = now;
84
+
85
+ const pk = _projectKey(fileGlob, family);
86
+ data.perProject[pk] ||= { tp: 0, fp: 0, lastUpdated: now };
87
+ if (verdict === 'true-positive') data.perProject[pk].tp++;
88
+ if (verdict === 'false-positive') data.perProject[pk].fp++;
89
+ data.perProject[pk].lastUpdated = now;
90
+
91
+ _save(scanRoot, data);
92
+ return { gk, pk, data };
93
+ }
94
+
95
+ /**
96
+ * Apply learned calibration to a fresh batch of findings. Modifies
97
+ * confidence in place. Returns { adjusted: int, suppressed: int }
98
+ * — findings whose adjusted confidence drops below `suppressThreshold`
99
+ * are filtered out (added to the suppression log instead).
100
+ */
101
+ export function applyLearnedCalibration(scanRoot, findings, opts = {}) {
102
+ if (!Array.isArray(findings)) return { adjusted: 0, suppressed: 0 };
103
+ const data = loadCalibration(scanRoot);
104
+ const suppressThreshold = opts.suppressThreshold ?? 0.2;
105
+ let adjusted = 0;
106
+ const suppressedList = [];
107
+ for (const f of findings) {
108
+ const factor = _learnedFactor(data, f);
109
+ if (factor === 1.0) continue;
110
+ const before = typeof f.confidence === 'number' ? f.confidence : 0.85;
111
+ const after = Math.max(0.01, Math.min(0.99, before * factor));
112
+ f.confidence = after;
113
+ f._learnedCalibration = { factor, before, samples: _sampleCount(data, f) };
114
+ adjusted++;
115
+ if (after < suppressThreshold) {
116
+ f._suppressed_by = 'triage-learning';
117
+ suppressedList.push(f);
118
+ }
119
+ }
120
+ return { adjusted, suppressed: suppressedList.length, suppressedList, data };
121
+ }
122
+
123
+ function _learnedFactor(data, finding) {
124
+ const family = finding.family;
125
+ const sinkMethod = _extractSinkMethod(finding);
126
+ const fileGlob = _fileGlobFor(finding.file);
127
+
128
+ // Per-project: beta-distribution precision estimate when N is large enough.
129
+ const pk = _projectKey(fileGlob, family);
130
+ const proj = data.perProject?.[pk];
131
+ if (proj && proj.tp + proj.fp >= MIN_PROJECT_SAMPLES) {
132
+ const p = (PRIOR_ALPHA + proj.tp) / (PRIOR_ALPHA + PRIOR_BETA + proj.tp + proj.fp);
133
+ return p / 0.5; // 0.5 = neutral prior precision
134
+ }
135
+ // Global: same formula, less aggressive scaling.
136
+ const gk = _bucketKey(family, sinkMethod);
137
+ const glob = data.global?.[gk];
138
+ if (glob && glob.tp + glob.fp >= MIN_PROJECT_SAMPLES) {
139
+ const p = (PRIOR_ALPHA + glob.tp) / (PRIOR_ALPHA + PRIOR_BETA + glob.tp + glob.fp);
140
+ return (0.7 * (p / 0.5)) + 0.3; // weight: 70% global, 30% neutral
141
+ }
142
+ return 1.0;
143
+ }
144
+
145
+ function _sampleCount(data, finding) {
146
+ const family = finding.family;
147
+ const sinkMethod = _extractSinkMethod(finding);
148
+ const fileGlob = _fileGlobFor(finding.file);
149
+ const pk = _projectKey(fileGlob, family);
150
+ const gk = _bucketKey(family, sinkMethod);
151
+ const proj = data.perProject?.[pk] || { tp: 0, fp: 0 };
152
+ const glob = data.global?.[gk] || { tp: 0, fp: 0 };
153
+ return { perProject: proj.tp + proj.fp, global: glob.tp + glob.fp };
154
+ }
155
+
156
+ function _extractSinkMethod(finding) {
157
+ if (finding.sink?.method) return finding.sink.method;
158
+ // Try to parse from the vuln string ("SQL Injection — executeQuery").
159
+ const m = (finding.vuln || '').match(/\b([A-Za-z_]\w*)\s*\(/);
160
+ return m ? m[1] : '';
161
+ }
162
+
163
+ function _fileGlobFor(file) {
164
+ if (!file) return '*';
165
+ const parts = file.split('/');
166
+ if (parts.length <= 2) return file;
167
+ return parts.slice(0, 2).join('/') + '/**';
168
+ }
169
+
170
+ export const _internals = { _bucketKey, _projectKey, _extractSinkMethod, _fileGlobFor, MIN_PROJECT_SAMPLES };
@@ -4,13 +4,13 @@
4
4
 
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
-
8
- const STORE_PATH = '.agentic-security/triage.json';
7
+ import { statePath, safeWriteState } from './state-dir.js';
8
+ import { appendAcceptRiskFromTriage } from './sca-policy.js';
9
9
 
10
10
  export const STATES = ['open', 'in-progress', 'fixed', 'wont-fix', 'false-positive'];
11
11
 
12
12
  function _storePath(scanRoot) {
13
- return path.join(scanRoot || process.cwd(), STORE_PATH);
13
+ return statePath(scanRoot, 'triage.json');
14
14
  }
15
15
 
16
16
  export function loadTriage(scanRoot) {
@@ -22,8 +22,7 @@ export function loadTriage(scanRoot) {
22
22
 
23
23
  function _save(scanRoot, data) {
24
24
  const fp = _storePath(scanRoot);
25
- fs.mkdirSync(path.dirname(fp), { recursive: true });
26
- fs.writeFileSync(fp, JSON.stringify(data, null, 2));
25
+ safeWriteState(fp, JSON.stringify(data, null, 2));
27
26
  }
28
27
 
29
28
  // Sync the triage store with the latest scan: new findings become 'open',
@@ -46,6 +45,15 @@ export function syncWithScan(scanRoot, findings) {
46
45
  assignee: null,
47
46
  opened_at: now,
48
47
  comments: [],
48
+ // Phase 4 / Item 7: capture SCA-relevant fields so the
49
+ // triage → sca-policy bridge has enough data to materialize an
50
+ // accept-risk entry on wont-fix. No-ops for SAST findings.
51
+ type: f.type || null,
52
+ name: f.name || null,
53
+ version: f.version || null,
54
+ ecosystem: f.ecosystem || null,
55
+ osvId: f.osvId || null,
56
+ cveAliases: Array.isArray(f.cveAliases) ? f.cveAliases : [],
49
57
  };
50
58
  data.transitions.push({ id, from: null, to: 'open', at: now });
51
59
  }
@@ -82,7 +90,22 @@ export function transition(scanRoot, id, toState, comment) {
82
90
  if (toState === 'fixed') cur.fixed_at = new Date().toISOString();
83
91
  data.transitions.push({ id, from, to: toState, at: new Date().toISOString(), comment });
84
92
  _save(scanRoot, data);
85
- return { ok: true };
93
+
94
+ // Phase 4 / Item 7 of the SCA improvement plan — bridge wont-fix
95
+ // transitions on SCA findings into sca-policy.yml accept-risk entries
96
+ // so the suppression is durable across rescans. The finding object on
97
+ // the triage store has a `type` field when it was synced from a scan
98
+ // via syncWithScan; we only bridge type === 'vulnerable_dep'.
99
+ let policyBridge = null;
100
+ if (toState === 'wont-fix' && cur.type === 'vulnerable_dep') {
101
+ try {
102
+ const reason = comment || `Marked wont-fix in triage on ${new Date().toISOString().slice(0,10)}`;
103
+ policyBridge = appendAcceptRiskFromTriage(scanRoot, cur, reason);
104
+ } catch (e) {
105
+ policyBridge = { ok: false, reason: String(e && e.message || e) };
106
+ }
107
+ }
108
+ return { ok: true, policyBridge };
86
109
  }
87
110
 
88
111
  export function comment(scanRoot, id, author, body) {
@@ -24,11 +24,11 @@
24
24
 
25
25
  import * as fs from 'node:fs';
26
26
  import * as path from 'node:path';
27
+ import { statePath, safeWriteState } from './state-dir.js';
27
28
 
28
- const FILE = '.agentic-security/validator-metrics.json';
29
29
  const HISTORY_CAP = 100;
30
30
 
31
- function _filePath(scanRoot) { return path.join(scanRoot || process.cwd(), FILE); }
31
+ function _filePath(scanRoot) { return statePath(scanRoot, 'validator-metrics.json'); }
32
32
 
33
33
  function _read(scanRoot) {
34
34
  const fp = _filePath(scanRoot);
@@ -39,10 +39,7 @@ function _read(scanRoot) {
39
39
 
40
40
  function _write(scanRoot, data) {
41
41
  const fp = _filePath(scanRoot);
42
- try {
43
- fs.mkdirSync(path.dirname(fp), { recursive: true });
44
- fs.writeFileSync(fp, JSON.stringify(data, null, 2));
45
- } catch { /* swallow — telemetry is best-effort */ }
42
+ safeWriteState(fp, JSON.stringify(data, null, 2));
46
43
  }
47
44
 
48
45
  function _round(n) { return Math.round(n * 10000) / 10000; }