@clear-capabilities/agentic-security-scanner 0.80.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.
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/839.index.js +170 -0
- package/dist/985.index.js +51 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/.agentic-security/findings.json +21283 -8189
- package/src/.agentic-security/last-scan.json +21283 -8189
- package/src/.agentic-security/last-scan.json.sig +1 -1
- package/src/.agentic-security/scan-history.json +512 -128
- package/src/.agentic-security/streak.json +3 -3
- package/src/engine.js +41 -0
- package/src/mcp/.agentic-security/findings.json +4 -4
- package/src/mcp/.agentic-security/last-scan.json +4 -4
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -1
- package/src/mcp/.agentic-security/scan-history.json +188 -0
- package/src/mcp/.agentic-security/streak.json +5 -5
- package/src/mcp/tools.js +51 -1
- package/src/posture/.agentic-security/findings.json +17234 -4057
- package/src/posture/.agentic-security/last-scan.json +17234 -4057
- package/src/posture/.agentic-security/last-scan.json.sig +1 -1
- package/src/posture/.agentic-security/scan-history.json +1942 -200
- package/src/posture/.agentic-security/streak.json +3 -3
- package/src/posture/auditor-walkthrough.js +252 -0
- package/src/posture/claude-authorship.js +197 -0
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
- package/src/posture/compliance-frameworks/ccpa.json +32 -0
- package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
- package/src/posture/compliance-frameworks/gdpr.json +45 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/src/posture/cross-repo-memory.js +180 -0
- package/src/posture/dep-add-guard.js +197 -0
- package/src/posture/findings-memory.js +152 -0
- package/src/posture/fix-style-mirror.js +118 -0
- package/src/posture/git-history.js +141 -0
- package/src/posture/intent-context.js +175 -0
- package/src/posture/model-rescan.js +76 -0
- package/src/posture/pattern-propagation.js +39 -0
- package/src/posture/pr-augment.js +234 -0
- package/src/posture/risk-dollars.js +158 -0
- package/src/posture/threat-model-grounding.js +169 -0
- package/src/posture/time-to-fix.js +129 -0
- package/src/posture/triage-memory.js +151 -0
- package/src/posture/triage.js +15 -1
- package/src/posture/watch-mode.js +171 -0
- package/src/posture/workflow-installer.js +231 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Risk-in-dollars — expected value of exploitation per finding.
|
|
2
|
+
//
|
|
3
|
+
// Combines three signals into an EV estimate:
|
|
4
|
+
//
|
|
5
|
+
// P(exploited) from EPSS score on the finding's CVE if present,
|
|
6
|
+
// else from family-level base rate
|
|
7
|
+
// Impact($) from crown-jewel mapping (data class) and industry
|
|
8
|
+
// breach-cost averages
|
|
9
|
+
// Discount reachability tier (route-reachable > function-reachable
|
|
10
|
+
// > unknown > unreachable)
|
|
11
|
+
//
|
|
12
|
+
// EV per finding = P × Impact × Discount × ConfidenceFloor
|
|
13
|
+
//
|
|
14
|
+
// Industry breach-cost figures used here are sourced from publicly
|
|
15
|
+
// reported aggregates (Ponemon Cost of a Data Breach Report — IBM/Verizon
|
|
16
|
+
// methodology is widely cited but the figures are reported in the public
|
|
17
|
+
// summary; we use rounded estimates as defaults that users can override
|
|
18
|
+
// via .agentic-security/risk-config.yml).
|
|
19
|
+
//
|
|
20
|
+
// Disclaimer: this is an order-of-magnitude estimate for prioritization.
|
|
21
|
+
// It is NOT an actuarial or insurance assessment.
|
|
22
|
+
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
|
|
26
|
+
const STATE = '.agentic-security';
|
|
27
|
+
|
|
28
|
+
// Base rates per family (annual probability of at-least-one exploit given
|
|
29
|
+
// an exposed instance). Rough industry estimates; tune via config.
|
|
30
|
+
const FAMILY_BASE_PROB = {
|
|
31
|
+
'sqli': 0.18, 'sql-injection': 0.18,
|
|
32
|
+
'xss': 0.12, 'mutation-xss': 0.10,
|
|
33
|
+
'command-injection': 0.16,
|
|
34
|
+
'code-injection': 0.20,
|
|
35
|
+
'deserialization': 0.15,
|
|
36
|
+
'auth-missing': 0.25,
|
|
37
|
+
'authz': 0.18, 'idor': 0.15,
|
|
38
|
+
'csrf': 0.07,
|
|
39
|
+
'ssrf': 0.10, 'ssrf-cloud-metadata': 0.22,
|
|
40
|
+
'xxe': 0.08,
|
|
41
|
+
'open-redirect': 0.05,
|
|
42
|
+
'path-traversal': 0.10,
|
|
43
|
+
'crypto-weak-cipher': 0.04, 'crypto-weak-hash': 0.03,
|
|
44
|
+
'crypto-tls-no-verify': 0.10, 'crypto-tls-version': 0.05,
|
|
45
|
+
'crypto-jwt-none': 0.20, 'crypto-jwt-key-confusion': 0.18,
|
|
46
|
+
'hardcoded-secret': 0.30,
|
|
47
|
+
'vulnerable-dependency': 0.08,
|
|
48
|
+
'dependency-confusion': 0.06,
|
|
49
|
+
'iam-overpermissive': 0.10,
|
|
50
|
+
'k8s-rbac-cluster-admin': 0.12,
|
|
51
|
+
'k8s-pod-security-privileged': 0.10,
|
|
52
|
+
'prompt-injection': 0.20,
|
|
53
|
+
'agent-tool-exec': 0.25,
|
|
54
|
+
'reentrancy': 0.30,
|
|
55
|
+
'signature-replay': 0.15,
|
|
56
|
+
'eth-sign-used': 0.30,
|
|
57
|
+
'unlimited-approval': 0.18,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Default impact (USD) per crown-jewel / data-class tier.
|
|
61
|
+
const IMPACT_USD = {
|
|
62
|
+
'PII': 250_000,
|
|
63
|
+
'PHI': 400_000,
|
|
64
|
+
'PCI': 500_000,
|
|
65
|
+
'Confidential': 150_000,
|
|
66
|
+
'crown-jewel': 300_000,
|
|
67
|
+
'default': 50_000,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const REACH_DISCOUNT = {
|
|
71
|
+
'reachable-public': 1.0,
|
|
72
|
+
'public-unauthed': 1.0,
|
|
73
|
+
'route-reachable': 0.9,
|
|
74
|
+
'route-reachable-via-function': 0.7,
|
|
75
|
+
'function-reachable': 0.5,
|
|
76
|
+
'unknown': 0.3,
|
|
77
|
+
'unreachable': 0.05,
|
|
78
|
+
'function-reachable-but-not-route':0.4,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function _loadConfig(scanRoot) {
|
|
82
|
+
const fp = path.join(scanRoot, STATE, 'risk-config.yml');
|
|
83
|
+
if (!fs.existsSync(fp)) return null;
|
|
84
|
+
try {
|
|
85
|
+
const body = fs.readFileSync(fp, 'utf8');
|
|
86
|
+
// Tiny YAML — look for impactUSD / familyBaseProb overrides
|
|
87
|
+
const cfg = {};
|
|
88
|
+
const impactMatch = body.match(/^impactUSD\s*:\s*\n((?:\s+\w+\s*:\s*\d+\s*\n?)+)/m);
|
|
89
|
+
if (impactMatch) {
|
|
90
|
+
cfg.impactUSD = {};
|
|
91
|
+
for (const m of impactMatch[1].matchAll(/(\w+)\s*:\s*(\d+)/g)) cfg.impactUSD[m[1]] = parseInt(m[2], 10);
|
|
92
|
+
}
|
|
93
|
+
return cfg;
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _baseProb(family) {
|
|
98
|
+
if (!family) return 0.05;
|
|
99
|
+
return FAMILY_BASE_PROB[family] || FAMILY_BASE_PROB[String(family).toLowerCase()] || 0.05;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _impactFor(finding, cfg) {
|
|
103
|
+
const table = cfg && cfg.impactUSD ? { ...IMPACT_USD, ...cfg.impactUSD } : IMPACT_USD;
|
|
104
|
+
const dc = Array.isArray(finding.dataClasses) ? finding.dataClasses : [];
|
|
105
|
+
if (dc.includes('PHI')) return table.PHI;
|
|
106
|
+
if (dc.includes('PCI')) return table.PCI;
|
|
107
|
+
if (dc.includes('PII')) return table.PII;
|
|
108
|
+
if (dc.includes('Confidential')) return table.Confidential;
|
|
109
|
+
if (finding.threatModel?.crownJewel) return table['crown-jewel'];
|
|
110
|
+
return table.default;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _reachDiscount(finding) {
|
|
114
|
+
const tier = finding.reachabilityTier || finding.routeReachable && 'route-reachable' || 'unknown';
|
|
115
|
+
return REACH_DISCOUNT[tier] || 0.3;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _epssProb(finding) {
|
|
119
|
+
if (typeof finding.epssScore === 'number') return finding.epssScore;
|
|
120
|
+
if (typeof finding.epss === 'number') return finding.epss;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compute EV per finding. Mutates the finding in place: adds
|
|
126
|
+
* .riskDollars = { ev, prob, impact, discount }.
|
|
127
|
+
*/
|
|
128
|
+
export function annotateRiskDollars(scanRoot, findings) {
|
|
129
|
+
if (!Array.isArray(findings) || findings.length === 0) return { total: 0, sumEv: 0 };
|
|
130
|
+
const cfg = _loadConfig(scanRoot);
|
|
131
|
+
let sumEv = 0;
|
|
132
|
+
let critEv = 0, highEv = 0;
|
|
133
|
+
for (const f of findings) {
|
|
134
|
+
const epss = _epssProb(f);
|
|
135
|
+
const prob = epss != null ? epss : _baseProb(f.family);
|
|
136
|
+
const impact = _impactFor(f, cfg);
|
|
137
|
+
const discount = _reachDiscount(f);
|
|
138
|
+
const confidenceFloor = Math.max(0.4, f.confidence || 0.8);
|
|
139
|
+
const ev = Math.round(prob * impact * discount * confidenceFloor);
|
|
140
|
+
f.riskDollars = { ev, prob: Number(prob.toFixed(3)), impact, discount, confidenceFloor: Number(confidenceFloor.toFixed(2)) };
|
|
141
|
+
sumEv += ev;
|
|
142
|
+
if (f.severity === 'critical') critEv += ev;
|
|
143
|
+
else if (f.severity === 'high') highEv += ev;
|
|
144
|
+
}
|
|
145
|
+
return { total: findings.length, sumEv, critEv, highEv };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Format a USD figure for display.
|
|
150
|
+
*/
|
|
151
|
+
export function fmtUsd(n) {
|
|
152
|
+
if (typeof n !== 'number' || !isFinite(n)) return '$?';
|
|
153
|
+
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
|
|
154
|
+
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
|
|
155
|
+
return `$${n}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const _internals = { FAMILY_BASE_PROB, IMPACT_USD, REACH_DISCOUNT, _baseProb, _impactFor, _reachDiscount };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Threat-model-grounded prioritization.
|
|
2
|
+
//
|
|
3
|
+
// Reads project documentation (CLAUDE.md + docs/THREAT-MODEL.md + AGENTS.md)
|
|
4
|
+
// to extract the project's stated threat model and applies it to finding
|
|
5
|
+
// prioritization:
|
|
6
|
+
//
|
|
7
|
+
// - **Crown jewels** — file globs listed under "## Crown jewels" or
|
|
8
|
+
// "## Sensitive surfaces" boost severity by one tier when a finding
|
|
9
|
+
// lands there.
|
|
10
|
+
// - **Out-of-scope** — file globs under "## Out of scope" or "## Not in
|
|
11
|
+
// threat model" demote findings to low.
|
|
12
|
+
// - **Compliance regime** — declared under "## Compliance" (e.g.
|
|
13
|
+
// SOC2 / HIPAA / GDPR) adds compliance-tag fields to findings in
|
|
14
|
+
// matching families (PII → HIPAA/GDPR; auth → SOC2 CC6.1; etc.).
|
|
15
|
+
// - **Stated attacker** — "## Attacker model" / "## Threat actor"
|
|
16
|
+
// section sets f.attackerProfile = 'script-kiddie' | 'apt' | 'insider'
|
|
17
|
+
// for use in downstream prioritization.
|
|
18
|
+
//
|
|
19
|
+
// Opt-out: AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING=1
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const DOC_PATHS = [
|
|
25
|
+
'CLAUDE.md',
|
|
26
|
+
'docs/THREAT-MODEL.md',
|
|
27
|
+
'docs/threat-model.md',
|
|
28
|
+
'docs/THREATMODEL.md',
|
|
29
|
+
'THREAT-MODEL.md',
|
|
30
|
+
'.agentic-security/AGENTS.md',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function _readDoc(scanRoot, rel) {
|
|
34
|
+
try { return fs.readFileSync(path.join(scanRoot, rel), 'utf8'); } catch { return ''; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _allDocs(scanRoot) {
|
|
38
|
+
return DOC_PATHS.map(p => _readDoc(scanRoot, p)).join('\n\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _extractPathsFromSection(body, sectionRegex) {
|
|
42
|
+
const sec = body.match(sectionRegex);
|
|
43
|
+
if (!sec) return [];
|
|
44
|
+
const paths = [];
|
|
45
|
+
// Match `path/like/this/**`, "path/like", or list-item paths.
|
|
46
|
+
const re = /[`"]([\w./*?\-]+)[`"]|^\s*-\s+([\w./*?\-]+)/gm;
|
|
47
|
+
let m;
|
|
48
|
+
while ((m = re.exec(sec[0]))) {
|
|
49
|
+
const p = m[1] || m[2];
|
|
50
|
+
if (p && /[\/.]/.test(p)) paths.push(p);
|
|
51
|
+
}
|
|
52
|
+
return Array.from(new Set(paths));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _extractCompliance(body) {
|
|
56
|
+
const sec = body.match(/^#{1,3}\s+Compliance[\s\S]*?(?=\n#{1,3}\s|$(?![\s\S]))/im);
|
|
57
|
+
if (!sec) return [];
|
|
58
|
+
const found = new Set();
|
|
59
|
+
const re = /\b(SOC2|HIPAA|PCI[- ]DSS|GDPR|CCPA|FedRAMP|ISO[- ]?27001|NIST(?:[- ]?(?:CSF|800-53|AI 600-1))?|EU AI Act|OWASP (?:ASVS|LLM Top 10))\b/gi;
|
|
60
|
+
let m;
|
|
61
|
+
while ((m = re.exec(sec[0]))) found.add(m[1].toUpperCase().replace(/\s+/g, '-'));
|
|
62
|
+
return Array.from(found);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _extractAttacker(body) {
|
|
66
|
+
const sec = body.match(/^#{1,3}\s+(?:Attacker model|Threat actor|Adversary)[\s\S]*?(?=\n#{1,3}\s|$(?![\s\S]))/im);
|
|
67
|
+
if (!sec) return null;
|
|
68
|
+
const txt = sec[0].toLowerCase();
|
|
69
|
+
if (/\bapt\b|nation[- ]?state|sophisticated/.test(txt)) return 'apt';
|
|
70
|
+
if (/\binsider\b|employee|disgruntled/.test(txt)) return 'insider';
|
|
71
|
+
if (/script[- ]?kiddie|automated|opportunistic/.test(txt)) return 'script-kiddie';
|
|
72
|
+
return 'general';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _globMatch(pattern, p) {
|
|
76
|
+
const norm = String(p).replace(/\\/g, '/');
|
|
77
|
+
const re = new RegExp(
|
|
78
|
+
'^' + String(pattern).replace(/\\/g, '/')
|
|
79
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
80
|
+
.replace(/\*\*/g, '###DSTAR###')
|
|
81
|
+
.replace(/\*/g, '[^/]*')
|
|
82
|
+
.replace(/###DSTAR###/g, '.*')
|
|
83
|
+
+ '$',
|
|
84
|
+
);
|
|
85
|
+
return re.test(norm);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SEVERITY_RANK = ['info', 'low', 'medium', 'high', 'critical'];
|
|
89
|
+
|
|
90
|
+
function _bumpSeverity(sev) {
|
|
91
|
+
const i = SEVERITY_RANK.indexOf(sev);
|
|
92
|
+
if (i < 0 || i >= SEVERITY_RANK.length - 1) return sev;
|
|
93
|
+
return SEVERITY_RANK[i + 1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const FAMILY_TO_REGIME = {
|
|
97
|
+
'pii-exposure': ['HIPAA', 'GDPR'],
|
|
98
|
+
'training-data-pii': ['GDPR'],
|
|
99
|
+
'auth-missing': ['SOC2'],
|
|
100
|
+
'authz': ['SOC2'],
|
|
101
|
+
'idor': ['SOC2'],
|
|
102
|
+
'crypto-weak-cipher': ['PCI-DSS', 'FedRAMP'],
|
|
103
|
+
'crypto-tls-no-verify': ['PCI-DSS'],
|
|
104
|
+
'hardcoded-secret': ['SOC2', 'PCI-DSS'],
|
|
105
|
+
'k8s-rbac-cluster-admin': ['SOC2'],
|
|
106
|
+
'aws-public-s3': ['SOC2', 'GDPR'],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read project threat model from documentation. Cached per-scan-root via
|
|
111
|
+
* a module-level WeakMap-like... actually just pure read each time, since
|
|
112
|
+
* scan-time overhead is tiny.
|
|
113
|
+
*/
|
|
114
|
+
export function loadThreatModel(scanRoot) {
|
|
115
|
+
if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING === '1') {
|
|
116
|
+
return { crownJewels: [], outOfScope: [], compliance: [], attacker: null };
|
|
117
|
+
}
|
|
118
|
+
const body = _allDocs(scanRoot);
|
|
119
|
+
return {
|
|
120
|
+
crownJewels: _extractPathsFromSection(body, /^#{1,3}\s+(?:Crown jewels|Sensitive surfaces?)[\s\S]*?(?=\n#{1,3}\s|$(?![\s\S]))/im),
|
|
121
|
+
outOfScope: _extractPathsFromSection(body, /^#{1,3}\s+(?:Out of scope|Not in threat model)[\s\S]*?(?=\n#{1,3}\s|$(?![\s\S]))/im),
|
|
122
|
+
compliance: _extractCompliance(body),
|
|
123
|
+
attacker: _extractAttacker(body),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Annotator: applies the project's threat model to each finding.
|
|
129
|
+
*/
|
|
130
|
+
export function applyThreatModel(scanRoot, findings) {
|
|
131
|
+
if (process.env.AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING === '1') return { applied: 0 };
|
|
132
|
+
if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
|
|
133
|
+
const tm = loadThreatModel(scanRoot);
|
|
134
|
+
if (!tm.crownJewels.length && !tm.outOfScope.length && !tm.compliance.length && !tm.attacker) {
|
|
135
|
+
return { applied: 0, reason: 'no-threat-model-found' };
|
|
136
|
+
}
|
|
137
|
+
let applied = 0;
|
|
138
|
+
for (const f of findings) {
|
|
139
|
+
const rel = f.file ? (path.isAbsolute(f.file) ? path.relative(scanRoot, f.file) : f.file) : '';
|
|
140
|
+
|
|
141
|
+
// Out-of-scope demotion wins over crown-jewel promotion.
|
|
142
|
+
if (rel && tm.outOfScope.some(g => _globMatch(g, rel))) {
|
|
143
|
+
f.threatModel = { ...(f.threatModel || {}), outOfScope: true };
|
|
144
|
+
f.severity = 'low';
|
|
145
|
+
applied++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (rel && tm.crownJewels.some(g => _globMatch(g, rel))) {
|
|
149
|
+
f.threatModel = { ...(f.threatModel || {}), crownJewel: true };
|
|
150
|
+
f.severity = _bumpSeverity(f.severity || 'medium');
|
|
151
|
+
applied++;
|
|
152
|
+
}
|
|
153
|
+
// Compliance regime tagging based on family.
|
|
154
|
+
const regimes = FAMILY_TO_REGIME[f.family];
|
|
155
|
+
if (regimes && tm.compliance.length) {
|
|
156
|
+
const matched = regimes.filter(r => tm.compliance.includes(r));
|
|
157
|
+
if (matched.length) {
|
|
158
|
+
f.threatModel = { ...(f.threatModel || {}), compliance: matched };
|
|
159
|
+
applied++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (tm.attacker) {
|
|
163
|
+
f.threatModel = { ...(f.threatModel || {}), attacker: tm.attacker };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { applied, total: findings.length, threatModel: tm };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const _internals = { _extractPathsFromSection, _extractCompliance, _extractAttacker, _bumpSeverity, _globMatch };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Time-to-fix estimator.
|
|
2
|
+
//
|
|
3
|
+
// Estimates engineering hours to remediate each finding from:
|
|
4
|
+
//
|
|
5
|
+
// - family base difficulty (regex auth-missing ≠ deserialization)
|
|
6
|
+
// - patch shape (single-line vs cross-file refactor) from fix.code if present
|
|
7
|
+
// - prior fix-history for the same family in this project (learned base)
|
|
8
|
+
// - reachability tier (tests + verify cost adjusts)
|
|
9
|
+
//
|
|
10
|
+
// Output: f.estimatedFixHours, rolled-up totals + a per-family rollup so
|
|
11
|
+
// the PM/PR view can show "this PR ships ~6 hours of security debt."
|
|
12
|
+
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const STATE = '.agentic-security';
|
|
17
|
+
const HISTORY_FILE = 'fix-history/log.json';
|
|
18
|
+
|
|
19
|
+
// Family base estimates (hours). Tuned from typical patch shapes.
|
|
20
|
+
const FAMILY_BASE_HOURS = {
|
|
21
|
+
'sqli': 0.5, 'sql-injection': 0.5, // parameterize one query
|
|
22
|
+
'xss': 0.5, 'mutation-xss': 1.0,
|
|
23
|
+
'command-injection': 0.5,
|
|
24
|
+
'code-injection': 2.0, // usually needs refactor
|
|
25
|
+
'deserialization': 4.0, // protocol/serializer swap
|
|
26
|
+
'auth-missing': 0.5, // add middleware
|
|
27
|
+
'authz': 2.0, 'idor': 2.0, // ownership checks across handlers
|
|
28
|
+
'csrf': 1.0,
|
|
29
|
+
'ssrf': 1.5, 'ssrf-cloud-metadata': 1.0,
|
|
30
|
+
'xxe': 0.5,
|
|
31
|
+
'open-redirect': 0.5,
|
|
32
|
+
'path-traversal': 1.0,
|
|
33
|
+
'crypto-weak-cipher': 2.0, // algorithm swap + key plumbing
|
|
34
|
+
'crypto-weak-hash': 1.0,
|
|
35
|
+
'crypto-tls-no-verify': 0.5,
|
|
36
|
+
'crypto-tls-version': 1.0,
|
|
37
|
+
'crypto-jwt-none': 0.5,
|
|
38
|
+
'crypto-jwt-key-confusion': 0.5,
|
|
39
|
+
'hardcoded-secret': 1.0, // env-var plumbing + rotation
|
|
40
|
+
'vulnerable-dependency': 0.5, // npm install bump
|
|
41
|
+
'dependency-confusion': 1.0,
|
|
42
|
+
'iam-overpermissive': 1.5,
|
|
43
|
+
'k8s-rbac-cluster-admin': 1.0,
|
|
44
|
+
'k8s-pod-security-privileged': 1.0,
|
|
45
|
+
'prompt-injection': 3.0, // architectural — prompt isolation
|
|
46
|
+
'agent-tool-exec': 4.0, // narrow the tool surface
|
|
47
|
+
'reentrancy': 4.0, // Solidity refactor + tests
|
|
48
|
+
'pqc-migration': 8.0, // multi-quarter project
|
|
49
|
+
'license-graph': 2.0, // dep swap or policy negotiation
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function _loadFixHistory(scanRoot) {
|
|
53
|
+
const fp = path.join(scanRoot, STATE, HISTORY_FILE);
|
|
54
|
+
if (!fs.existsSync(fp)) return [];
|
|
55
|
+
try {
|
|
56
|
+
const arr = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
57
|
+
return Array.isArray(arr) ? arr : [];
|
|
58
|
+
} catch { return []; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _historicalAvg(history, family) {
|
|
62
|
+
const matches = history.filter(h => h.family === family && typeof h.elapsedHours === 'number');
|
|
63
|
+
if (!matches.length) return null;
|
|
64
|
+
const sum = matches.reduce((a, b) => a + b.elapsedHours, 0);
|
|
65
|
+
return sum / matches.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _patchShapeAdjust(finding) {
|
|
69
|
+
// If we have the synthesized fix code, estimate complexity from size.
|
|
70
|
+
const code = finding.fix?.code || finding.fix?.replacement || '';
|
|
71
|
+
if (!code) return 1.0;
|
|
72
|
+
const lines = code.split('\n').length;
|
|
73
|
+
if (lines <= 3) return 1.0;
|
|
74
|
+
if (lines <= 10) return 1.4;
|
|
75
|
+
if (lines <= 30) return 2.0;
|
|
76
|
+
return 3.0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _reachAdjust(finding) {
|
|
80
|
+
// Higher reachability → more careful testing → slightly higher cost.
|
|
81
|
+
const tier = finding.reachabilityTier;
|
|
82
|
+
if (tier === 'reachable-public' || tier === 'public-unauthed') return 1.3;
|
|
83
|
+
if (tier === 'route-reachable') return 1.15;
|
|
84
|
+
if (tier === 'unreachable') return 0.7;
|
|
85
|
+
return 1.0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Annotate findings with estimatedFixHours. Returns
|
|
90
|
+
* { perFinding: count, totalHours, perFamily: { fam: hours, ... } }
|
|
91
|
+
*/
|
|
92
|
+
export function annotateTimeToFix(scanRoot, findings) {
|
|
93
|
+
if (!Array.isArray(findings) || findings.length === 0) {
|
|
94
|
+
return { perFinding: 0, totalHours: 0, perFamily: {} };
|
|
95
|
+
}
|
|
96
|
+
const history = _loadFixHistory(scanRoot);
|
|
97
|
+
let total = 0;
|
|
98
|
+
const perFamily = {};
|
|
99
|
+
for (const f of findings) {
|
|
100
|
+
const base = _historicalAvg(history, f.family) ?? FAMILY_BASE_HOURS[f.family] ?? 1.5;
|
|
101
|
+
const patchAdj = _patchShapeAdjust(f);
|
|
102
|
+
const reachAdj = _reachAdjust(f);
|
|
103
|
+
const hours = Number((base * patchAdj * reachAdj).toFixed(2));
|
|
104
|
+
f.estimatedFixHours = hours;
|
|
105
|
+
f.estimatedFixHoursSource = _historicalAvg(history, f.family) != null ? 'history' : 'family-base';
|
|
106
|
+
total += hours;
|
|
107
|
+
perFamily[f.family || 'unknown'] = (perFamily[f.family || 'unknown'] || 0) + hours;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
perFinding: findings.length,
|
|
111
|
+
totalHours: Number(total.toFixed(1)),
|
|
112
|
+
perFamily: Object.fromEntries(
|
|
113
|
+
Object.entries(perFamily)
|
|
114
|
+
.sort((a, b) => b[1] - a[1])
|
|
115
|
+
.map(([k, v]) => [k, Number(v.toFixed(1))]),
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render a one-paragraph PM summary.
|
|
122
|
+
*/
|
|
123
|
+
export function renderTimeSummary(roll) {
|
|
124
|
+
if (!roll || roll.perFinding === 0) return 'No findings — 0 hours of security debt.';
|
|
125
|
+
const top = Object.entries(roll.perFamily).slice(0, 3).map(([k, v]) => `${k} (${v}h)`).join(', ');
|
|
126
|
+
return `${roll.perFinding} finding(s) — ~${roll.totalHours} engineering hours of security debt. Top families: ${top}.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const _internals = { FAMILY_BASE_HOURS, _patchShapeAdjust, _reachAdjust, _historicalAvg };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Triage memory — conversational triage memory layer.
|
|
2
|
+
//
|
|
3
|
+
// When a finding transitions to wont-fix or false-positive (via the
|
|
4
|
+
// triage CLI / MCP tool / agent action), this module:
|
|
5
|
+
//
|
|
6
|
+
// 1. writes a structured entry to AGENTS.md so the lesson is captured
|
|
7
|
+
// for next session (continual-learning surface),
|
|
8
|
+
// 2. records the decision shape (family + file-glob + reason) into
|
|
9
|
+
// .agentic-security/triage-memory.jsonl for fast pattern matching,
|
|
10
|
+
// 3. provides suppressByPastDecisions() — an annotator that pre-demotes
|
|
11
|
+
// findings whose (family, file-glob) was previously marked wont-fix
|
|
12
|
+
// or false-positive in the same project.
|
|
13
|
+
//
|
|
14
|
+
// This is the dialogue-aware analogue to posture/triage-learning.js,
|
|
15
|
+
// which adjusts confidence calibration from triage counts. triage-memory
|
|
16
|
+
// works on the discrete narrative ("we decided this is fine, here's
|
|
17
|
+
// why") rather than on calibration math.
|
|
18
|
+
//
|
|
19
|
+
// Opt-out: AGENTIC_SECURITY_NO_TRIAGE_MEMORY=1
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const STATE_DIR = '.agentic-security';
|
|
25
|
+
const MEMORY_FILE = 'triage-memory.jsonl';
|
|
26
|
+
const AGENTS_FILE = 'AGENTS.md';
|
|
27
|
+
|
|
28
|
+
function _stateDir(scanRoot) { return path.join(scanRoot, STATE_DIR); }
|
|
29
|
+
function _memPath(scanRoot) { return path.join(_stateDir(scanRoot), MEMORY_FILE); }
|
|
30
|
+
function _agentsPath(scanRoot) { return path.join(_stateDir(scanRoot), AGENTS_FILE); }
|
|
31
|
+
|
|
32
|
+
function _bucketKey(finding) {
|
|
33
|
+
const file = finding.file || finding.file_path || '';
|
|
34
|
+
const dir = file.split('/').slice(0, -1).join('/') || '.';
|
|
35
|
+
return `${finding.family || finding.parser || 'unknown'}::${dir}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Record a triage decision into both AGENTS.md (narrative) and
|
|
40
|
+
* triage-memory.jsonl (structured). Idempotent — repeated calls add a
|
|
41
|
+
* single new line; existing entries are not deduped (history is a feature).
|
|
42
|
+
*/
|
|
43
|
+
export function recordDecision(scanRoot, finding, decision, reason) {
|
|
44
|
+
if (!scanRoot || !finding || !decision) return null;
|
|
45
|
+
if (!['wont-fix', 'false-positive'].includes(decision)) return null;
|
|
46
|
+
try { fs.mkdirSync(_stateDir(scanRoot), { recursive: true }); } catch {}
|
|
47
|
+
|
|
48
|
+
const entry = {
|
|
49
|
+
at: new Date().toISOString(),
|
|
50
|
+
decision,
|
|
51
|
+
reason: String(reason || '').slice(0, 280),
|
|
52
|
+
bucket: _bucketKey(finding),
|
|
53
|
+
family: finding.family || null,
|
|
54
|
+
severity: finding.severity || null,
|
|
55
|
+
cwe: finding.cwe || null,
|
|
56
|
+
vuln: (finding.vuln || finding.title || '').slice(0, 160),
|
|
57
|
+
file: finding.file || finding.file_path || '',
|
|
58
|
+
line: finding.line || 0,
|
|
59
|
+
id: finding.id || finding.stableId || null,
|
|
60
|
+
};
|
|
61
|
+
try { fs.appendFileSync(_memPath(scanRoot), JSON.stringify(entry) + '\n'); } catch {}
|
|
62
|
+
|
|
63
|
+
// Narrative entry in AGENTS.md — short, human-readable, surfaced at SessionStart.
|
|
64
|
+
const narrative = [
|
|
65
|
+
`## Triage decision — ${entry.at.slice(0, 10)} (${decision})`,
|
|
66
|
+
`- Finding: ${entry.vuln || entry.family || 'unknown'} at ${entry.file}:${entry.line}`,
|
|
67
|
+
`- Bucket: \`${entry.bucket}\``,
|
|
68
|
+
reason ? `- Why: ${entry.reason}` : null,
|
|
69
|
+
`- Future scans should treat similar findings under this bucket as already-reviewed.`,
|
|
70
|
+
'',
|
|
71
|
+
].filter(Boolean).join('\n');
|
|
72
|
+
try { fs.appendFileSync(_agentsPath(scanRoot), '\n' + narrative); } catch {}
|
|
73
|
+
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load past decisions from triage-memory.jsonl.
|
|
79
|
+
*/
|
|
80
|
+
export function loadMemory(scanRoot) {
|
|
81
|
+
const fp = _memPath(scanRoot);
|
|
82
|
+
if (!fs.existsSync(fp)) return [];
|
|
83
|
+
try {
|
|
84
|
+
return fs.readFileSync(fp, 'utf8')
|
|
85
|
+
.split('\n').filter(Boolean)
|
|
86
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
} catch { return []; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Annotator: for each finding, check if its (family, file-glob) bucket
|
|
93
|
+
* was previously marked wont-fix or false-positive in this project. If
|
|
94
|
+
* so, attach `pastDecision` metadata and lower confidence.
|
|
95
|
+
*
|
|
96
|
+
* Does NOT remove findings — only annotates. UI / report layer decides
|
|
97
|
+
* how to display.
|
|
98
|
+
*/
|
|
99
|
+
export function suppressByPastDecisions(scanRoot, findings) {
|
|
100
|
+
if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY === '1') return { applied: 0 };
|
|
101
|
+
if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
|
|
102
|
+
const memory = loadMemory(scanRoot);
|
|
103
|
+
if (!memory.length) return { applied: 0 };
|
|
104
|
+
|
|
105
|
+
// Build bucket → most-recent decision map.
|
|
106
|
+
const bucketDecision = new Map();
|
|
107
|
+
for (const e of memory) {
|
|
108
|
+
if (!bucketDecision.has(e.bucket) || bucketDecision.get(e.bucket).at < e.at) {
|
|
109
|
+
bucketDecision.set(e.bucket, e);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let applied = 0;
|
|
114
|
+
for (const f of findings) {
|
|
115
|
+
const key = _bucketKey(f);
|
|
116
|
+
const past = bucketDecision.get(key);
|
|
117
|
+
if (!past) continue;
|
|
118
|
+
f.pastDecision = {
|
|
119
|
+
decision: past.decision,
|
|
120
|
+
at: past.at,
|
|
121
|
+
reason: past.reason,
|
|
122
|
+
sameBucket: true,
|
|
123
|
+
};
|
|
124
|
+
// Soft demote: drop confidence and add a tag explaining why.
|
|
125
|
+
if (typeof f.confidence === 'number') f.confidence = Math.max(0.2, f.confidence * 0.5);
|
|
126
|
+
f.tags = Array.isArray(f.tags) ? f.tags : [];
|
|
127
|
+
if (!f.tags.includes('past-decision')) f.tags.push('past-decision');
|
|
128
|
+
applied++;
|
|
129
|
+
}
|
|
130
|
+
return { applied, total: findings.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Search past triage decisions by natural-language query terms. Naive
|
|
135
|
+
* keyword match for v1 — sufficient when memory is < 1000 entries.
|
|
136
|
+
* Future: vector search against an embedding store.
|
|
137
|
+
*/
|
|
138
|
+
export function queryMemory(scanRoot, query) {
|
|
139
|
+
const memory = loadMemory(scanRoot);
|
|
140
|
+
if (!query || !memory.length) return memory.slice(-10);
|
|
141
|
+
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
142
|
+
if (!terms.length) return memory.slice(-10);
|
|
143
|
+
const scored = memory.map(e => {
|
|
144
|
+
const haystack = [e.reason, e.vuln, e.family, e.file, e.bucket].join(' ').toLowerCase();
|
|
145
|
+
const score = terms.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
|
|
146
|
+
return { ...e, score };
|
|
147
|
+
});
|
|
148
|
+
return scored.filter(e => e.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const _internals = { _bucketKey, _memPath, _agentsPath };
|
package/src/posture/triage.js
CHANGED
|
@@ -6,6 +6,8 @@ import * as fs from 'node:fs';
|
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
8
8
|
import { appendAcceptRiskFromTriage } from './sca-policy.js';
|
|
9
|
+
import { recordDecision as recordTriageMemory } from './triage-memory.js';
|
|
10
|
+
import { recordTriage as recordCrossRepoTriage } from './cross-repo-memory.js';
|
|
9
11
|
|
|
10
12
|
export const STATES = ['open', 'in-progress', 'fixed', 'wont-fix', 'false-positive'];
|
|
11
13
|
|
|
@@ -105,7 +107,19 @@ export function transition(scanRoot, id, toState, comment) {
|
|
|
105
107
|
policyBridge = { ok: false, reason: String(e && e.message || e) };
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
|
-
|
|
110
|
+
// Triage-memory bridge — write a narrative entry to AGENTS.md and a
|
|
111
|
+
// structured entry to triage-memory.jsonl whenever a finding is marked
|
|
112
|
+
// wont-fix or false-positive. The next scan's suppressByPastDecisions
|
|
113
|
+
// annotator demotes similar findings by bucket (family + dir).
|
|
114
|
+
let memoryBridge = null;
|
|
115
|
+
if (['wont-fix', 'false-positive'].includes(toState)) {
|
|
116
|
+
try { memoryBridge = recordTriageMemory(scanRoot, cur, toState, comment); }
|
|
117
|
+
catch (e) { memoryBridge = { ok: false, reason: String(e && e.message || e) }; }
|
|
118
|
+
// Also mirror into the cross-repo store so sibling repos benefit.
|
|
119
|
+
try { recordCrossRepoTriage({ scanRoot, finding: cur, decision: toState, reason: comment }); }
|
|
120
|
+
catch {}
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, policyBridge, memoryBridge };
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
export function comment(scanRoot, id, author, body) {
|