@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.
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +90 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +104638 -0
- package/src/.agentic-security/last-scan.json +104638 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12562 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +784 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +7162 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +26 -1
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -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
|
+
}
|
|
@@ -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 };
|