@clear-capabilities/agentic-security-scanner 0.79.0 → 0.84.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/839.index.js +170 -0
- package/dist/985.index.js +140 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/.agentic-security/findings.json +117732 -0
- package/src/.agentic-security/last-scan.json +117732 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12946 -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 +825 -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 +331 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +140 -1
- package/src/posture/.agentic-security/findings.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +8904 -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/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/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/cross-repo-memory.js +180 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/dep-add-guard.js +197 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -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/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/model-rescan.js +76 -0
- package/src/posture/pattern-propagation.js +39 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/pr-augment.js +234 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/risk-dollars.js +158 -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/threat-model-grounding.js +169 -0
- package/src/posture/time-to-fix.js +129 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage-memory.js +151 -0
- package/src/posture/triage.js +40 -1
- package/src/posture/watch-mode.js +171 -0
- package/src/posture/workflow-installer.js +231 -0
- 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,218 @@
|
|
|
1
|
+
// Compliance-as-code DSL — Recommendation #9 of the world-class+2 plan.
|
|
2
|
+
//
|
|
3
|
+
// Customers declare their compliance policy in
|
|
4
|
+
// .agentic-security/compliance.policy.yml. The scanner reads the policy,
|
|
5
|
+
// runs verification (each rule is a deterministic check against scanner
|
|
6
|
+
// findings + config files + state) and emits a structured JSON-LD
|
|
7
|
+
// evidence file consumable by Vanta / Drata / SecureFrame / auditors.
|
|
8
|
+
//
|
|
9
|
+
// DSL shape:
|
|
10
|
+
//
|
|
11
|
+
// framework: "SOC2 Type II"
|
|
12
|
+
// controls:
|
|
13
|
+
// CC6.1:
|
|
14
|
+
// title: "Logical access controls"
|
|
15
|
+
// requires:
|
|
16
|
+
// - finding-family: "auth-missing"
|
|
17
|
+
// must-be: zero
|
|
18
|
+
// - file-exists: ".github/dependabot.yml"
|
|
19
|
+
// - documented: ".agentic-security/auth-policy.md"
|
|
20
|
+
// evidence:
|
|
21
|
+
// - "Scanner finds 0 auth-missing findings on the current release"
|
|
22
|
+
// - "Dependency-update automation present"
|
|
23
|
+
// CC7.2:
|
|
24
|
+
// title: "Security incident response"
|
|
25
|
+
// requires:
|
|
26
|
+
// - file-exists: "INCIDENT-PLAN.md"
|
|
27
|
+
//
|
|
28
|
+
// Verifier primitives in v1:
|
|
29
|
+
// finding-family: <name> must-be: zero | min: <n> | max: <n>
|
|
30
|
+
// file-exists: <relative-path>
|
|
31
|
+
// documented: <relative-path> (alias for file-exists)
|
|
32
|
+
// env-var-set: <name>
|
|
33
|
+
// sca-policy-has-entry: <type> (e.g. accept-risk, sla)
|
|
34
|
+
//
|
|
35
|
+
// Output:
|
|
36
|
+
// .agentic-security/compliance-evidence.json — JSON-LD compliant
|
|
37
|
+
// structured artifact
|
|
38
|
+
// .agentic-security/compliance-evidence.md — human-readable summary
|
|
39
|
+
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import * as yaml from 'js-yaml';
|
|
43
|
+
|
|
44
|
+
const POLICY_FILE = 'compliance.policy.yml';
|
|
45
|
+
|
|
46
|
+
export function loadPolicy(scanRoot) {
|
|
47
|
+
const fp = path.join(scanRoot, '.agentic-security', POLICY_FILE);
|
|
48
|
+
if (!fs.existsSync(fp)) return null;
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
51
|
+
const doc = yaml.load(raw);
|
|
52
|
+
return _normalize(doc);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { _error: `Failed to parse ${fp}: ${e.message}` };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _normalize(doc) {
|
|
59
|
+
if (!doc) return null;
|
|
60
|
+
return {
|
|
61
|
+
framework: doc.framework || 'Custom',
|
|
62
|
+
version: doc.version || '1.0',
|
|
63
|
+
controls: Object.entries(doc.controls || {}).map(([id, c]) => ({
|
|
64
|
+
id,
|
|
65
|
+
title: c.title || id,
|
|
66
|
+
requires: Array.isArray(c.requires) ? c.requires : [],
|
|
67
|
+
evidence: Array.isArray(c.evidence) ? c.evidence : [],
|
|
68
|
+
not_applicable: !!c['not-applicable'],
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run a single primitive check against the scanner state.
|
|
75
|
+
* { passed, reason }
|
|
76
|
+
*/
|
|
77
|
+
function _runCheck(check, ctx) {
|
|
78
|
+
if (check['finding-family']) {
|
|
79
|
+
const family = check['finding-family'];
|
|
80
|
+
const matching = (ctx.findings || []).filter(f => f.family === family);
|
|
81
|
+
if (check['must-be'] === 'zero') {
|
|
82
|
+
if (matching.length === 0) return { passed: true, reason: '0 findings' };
|
|
83
|
+
return { passed: false, reason: `${matching.length} findings in family '${family}'` };
|
|
84
|
+
}
|
|
85
|
+
if (typeof check.min === 'number') {
|
|
86
|
+
if (matching.length >= check.min) return { passed: true, reason: `${matching.length} ≥ ${check.min}` };
|
|
87
|
+
return { passed: false, reason: `${matching.length} < ${check.min}` };
|
|
88
|
+
}
|
|
89
|
+
if (typeof check.max === 'number') {
|
|
90
|
+
if (matching.length <= check.max) return { passed: true, reason: `${matching.length} ≤ ${check.max}` };
|
|
91
|
+
return { passed: false, reason: `${matching.length} > ${check.max}` };
|
|
92
|
+
}
|
|
93
|
+
return { passed: false, reason: 'finding-family check has no must-be/min/max' };
|
|
94
|
+
}
|
|
95
|
+
if (check['file-exists'] || check['documented']) {
|
|
96
|
+
const rel = check['file-exists'] || check['documented'];
|
|
97
|
+
const fp = path.join(ctx.scanRoot, rel);
|
|
98
|
+
if (fs.existsSync(fp)) return { passed: true, reason: `${rel} exists` };
|
|
99
|
+
return { passed: false, reason: `${rel} not found` };
|
|
100
|
+
}
|
|
101
|
+
if (check['env-var-set']) {
|
|
102
|
+
const name = check['env-var-set'];
|
|
103
|
+
if (process.env[name]) return { passed: true, reason: `$${name} set` };
|
|
104
|
+
return { passed: false, reason: `$${name} not set` };
|
|
105
|
+
}
|
|
106
|
+
if (check['sca-policy-has-entry']) {
|
|
107
|
+
const type = check['sca-policy-has-entry'];
|
|
108
|
+
const policyPath = path.join(ctx.scanRoot, '.agentic-security', 'sca-policy.yml');
|
|
109
|
+
if (!fs.existsSync(policyPath)) return { passed: false, reason: 'sca-policy.yml not found' };
|
|
110
|
+
try {
|
|
111
|
+
const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
|
|
112
|
+
if (type === 'accept-risk' && Array.isArray(policy['accept-risk']) && policy['accept-risk'].length) {
|
|
113
|
+
return { passed: true, reason: `${policy['accept-risk'].length} accept-risk entries` };
|
|
114
|
+
}
|
|
115
|
+
if (type === 'sla' && policy.sla && Object.keys(policy.sla).length) {
|
|
116
|
+
return { passed: true, reason: `${Object.keys(policy.sla).length} SLA buckets defined` };
|
|
117
|
+
}
|
|
118
|
+
return { passed: false, reason: `no ${type} entries in sca-policy.yml` };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return { passed: false, reason: 'sca-policy.yml parse error: ' + e.message };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { passed: false, reason: 'unknown check primitive' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run all controls in the policy and emit a verification report.
|
|
128
|
+
*/
|
|
129
|
+
export function verifyPolicy(policy, ctx) {
|
|
130
|
+
if (!policy || !policy.controls) return { controls: [], status: 'no-policy' };
|
|
131
|
+
const results = [];
|
|
132
|
+
for (const control of policy.controls) {
|
|
133
|
+
if (control.not_applicable) {
|
|
134
|
+
results.push({ ...control, status: 'not-applicable', checks: [] });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const checkResults = control.requires.map(c => ({ check: c, result: _runCheck(c, ctx) }));
|
|
138
|
+
const allPassed = checkResults.every(r => r.result.passed);
|
|
139
|
+
results.push({
|
|
140
|
+
...control,
|
|
141
|
+
status: allPassed ? 'compliant' : 'non-compliant',
|
|
142
|
+
checks: checkResults,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const summary = {
|
|
146
|
+
total: results.length,
|
|
147
|
+
compliant: results.filter(r => r.status === 'compliant').length,
|
|
148
|
+
nonCompliant: results.filter(r => r.status === 'non-compliant').length,
|
|
149
|
+
notApplicable: results.filter(r => r.status === 'not-applicable').length,
|
|
150
|
+
};
|
|
151
|
+
return { framework: policy.framework, version: policy.version, controls: results, summary };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Emit JSON-LD compliance evidence (the Vanta/Drata-shape artifact).
|
|
156
|
+
*/
|
|
157
|
+
export function emitEvidenceJsonLd(report, scanRoot) {
|
|
158
|
+
if (!report) return null;
|
|
159
|
+
const jsonld = {
|
|
160
|
+
'@context': {
|
|
161
|
+
'@vocab': 'https://agentic-security.io/compliance/v1/',
|
|
162
|
+
'schema': 'https://schema.org/',
|
|
163
|
+
},
|
|
164
|
+
'@type': 'ComplianceEvidence',
|
|
165
|
+
framework: report.framework,
|
|
166
|
+
version: report.version,
|
|
167
|
+
generatedAt: new Date().toISOString(),
|
|
168
|
+
summary: report.summary,
|
|
169
|
+
controls: report.controls.map(c => ({
|
|
170
|
+
'@type': 'Control',
|
|
171
|
+
id: c.id, title: c.title, status: c.status,
|
|
172
|
+
checks: c.checks.map(ck => ({
|
|
173
|
+
'@type': 'Check',
|
|
174
|
+
rule: ck.check,
|
|
175
|
+
passed: ck.result.passed,
|
|
176
|
+
reason: ck.result.reason,
|
|
177
|
+
})),
|
|
178
|
+
narrative_evidence: c.evidence || [],
|
|
179
|
+
})),
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true });
|
|
183
|
+
fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'compliance-evidence.json'), JSON.stringify(jsonld, null, 2));
|
|
184
|
+
} catch {}
|
|
185
|
+
return jsonld;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Emit a human-readable markdown summary.
|
|
190
|
+
*/
|
|
191
|
+
export function emitEvidenceMarkdown(report, scanRoot) {
|
|
192
|
+
const lines = [];
|
|
193
|
+
lines.push(`# Compliance evidence — ${report.framework}`);
|
|
194
|
+
lines.push('');
|
|
195
|
+
lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0,10)}.`);
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
|
|
198
|
+
lines.push('');
|
|
199
|
+
for (const c of report.controls) {
|
|
200
|
+
lines.push(`## ${c.id} — ${c.title} (${c.status})`);
|
|
201
|
+
for (const ck of c.checks) {
|
|
202
|
+
const mark = ck.result.passed ? '✓' : '✗';
|
|
203
|
+
lines.push(`- ${mark} \`${JSON.stringify(ck.check)}\` — ${ck.result.reason}`);
|
|
204
|
+
}
|
|
205
|
+
if (c.evidence && c.evidence.length) {
|
|
206
|
+
lines.push('');
|
|
207
|
+
lines.push('**Narrative evidence:**');
|
|
208
|
+
for (const e of c.evidence) lines.push(`- ${e}`);
|
|
209
|
+
}
|
|
210
|
+
lines.push('');
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'compliance-evidence.md'), lines.join('\n'));
|
|
214
|
+
} catch {}
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const _internals = { _normalize, _runCheck };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Composite risk score — derived 0–100 ordinal for agent + UI ordering.
|
|
2
|
+
//
|
|
3
|
+
// Today three independent ordinals coexist on every finding:
|
|
4
|
+
// 1. f.exploitability ∈ [0,1] (posture/exploitability.js)
|
|
5
|
+
// 2. f.toxicityScore: integer (engine.js scoreToxicity — unbounded)
|
|
6
|
+
// 3. f.mitigationVerdict (posture/mitigation-composite.js — 3-state enum)
|
|
7
|
+
//
|
|
8
|
+
// An agent sorting "which finding first" has no canonical key. This
|
|
9
|
+
// annotator composes the three into one normalized 0–100 ordinal:
|
|
10
|
+
//
|
|
11
|
+
// compositeRisk — 0..100 number, sortable
|
|
12
|
+
// compositeRiskTier — 'critical' | 'high' | 'medium' | 'low' | 'minimal'
|
|
13
|
+
// compositeRiskFactors — provenance strings; same pattern as
|
|
14
|
+
// f.exploitabilityFactors. The reader can audit
|
|
15
|
+
// how the score was assembled.
|
|
16
|
+
//
|
|
17
|
+
// IMPORTANT — this is NOT a probability.
|
|
18
|
+
//
|
|
19
|
+
// The plan calls it a derived field on purpose: the three upstream signals
|
|
20
|
+
// are themselves not calibrated probabilities. compositeRisk inherits that
|
|
21
|
+
// limitation. Treat it as a triage key for "show me top 10," not as a
|
|
22
|
+
// number to render as "65% likely to be exploited."
|
|
23
|
+
//
|
|
24
|
+
// The annotator NEVER modifies the inputs (exploitability, toxicityScore,
|
|
25
|
+
// mitigationVerdict). They retain their independent shapes for callers
|
|
26
|
+
// that depend on them.
|
|
27
|
+
|
|
28
|
+
// Tier thresholds. Calibrated against the SEVERITY_BASE constants from
|
|
29
|
+
// exploitability.js so that:
|
|
30
|
+
// - a critical sev + reachable + KEV produces a 'critical' tier
|
|
31
|
+
// - a medium sev with no extra signals produces 'low' or 'medium'
|
|
32
|
+
// These can move once we have a held-out labeled corpus; for now they are
|
|
33
|
+
// hand-picked.
|
|
34
|
+
const TIER_THRESHOLDS = [
|
|
35
|
+
{ min: 85, name: 'critical' },
|
|
36
|
+
{ min: 65, name: 'high' },
|
|
37
|
+
{ min: 35, name: 'medium' },
|
|
38
|
+
{ min: 15, name: 'low' },
|
|
39
|
+
{ min: 0, name: 'minimal' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function tierFor(score) {
|
|
43
|
+
for (const t of TIER_THRESHOLDS) if (score >= t.min) return t.name;
|
|
44
|
+
return 'minimal';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scoreOne(f) {
|
|
48
|
+
const factors = [];
|
|
49
|
+
// Base: exploitability is the most informative single signal. Scale 0–1
|
|
50
|
+
// to 0–100. Findings with no exploitability fall back to severity-only
|
|
51
|
+
// ordinals via toxicityScore below.
|
|
52
|
+
let base = 0;
|
|
53
|
+
if (typeof f.exploitability === 'number' && Number.isFinite(f.exploitability)) {
|
|
54
|
+
base = f.exploitability * 100;
|
|
55
|
+
factors.push(`exploit:${f.exploitability}`);
|
|
56
|
+
} else if (f.severity) {
|
|
57
|
+
// Conservative fallback: rough severity-only mapping. Stops the score
|
|
58
|
+
// from being 0 on findings that bypassed annotateExploitability.
|
|
59
|
+
const sevBase = { critical: 70, high: 55, medium: 35, low: 20, info: 10 }[f.severity];
|
|
60
|
+
if (typeof sevBase === 'number') {
|
|
61
|
+
base = sevBase;
|
|
62
|
+
factors.push(`sev-only:${f.severity}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Mitigation verdict adjusts the base. 'mitigated-in-prod' and
|
|
67
|
+
// 'unreachable-in-prod' are demoting; 'exposed-in-prod' is neutral.
|
|
68
|
+
// The multipliers err conservative — even an unreachable critical KEV
|
|
69
|
+
// keeps a floor (mitigations might be wrong; the finding still merits
|
|
70
|
+
// a human glance).
|
|
71
|
+
if (f.mitigationVerdict === 'mitigated-in-prod') {
|
|
72
|
+
base *= 0.4;
|
|
73
|
+
factors.push('mitigated-in-prod');
|
|
74
|
+
} else if (f.mitigationVerdict === 'unreachable-in-prod') {
|
|
75
|
+
base *= 0.2;
|
|
76
|
+
factors.push('unreachable-in-prod');
|
|
77
|
+
} else if (f.mitigationVerdict === 'exposed-in-prod') {
|
|
78
|
+
factors.push('exposed-in-prod');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Toxicity nudge: toxicityScore is unbounded but typically caps around
|
|
82
|
+
// 150 on the noisiest findings. Scale by /10 and cap at +15 so it can
|
|
83
|
+
// tie-break peers but never dominate.
|
|
84
|
+
if (typeof f.toxicityScore === 'number' && Number.isFinite(f.toxicityScore) && f.toxicityScore > 0) {
|
|
85
|
+
const nudge = Math.min(15, f.toxicityScore / 10);
|
|
86
|
+
base += nudge;
|
|
87
|
+
factors.push(`toxicity+${nudge.toFixed(1)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// KEV / EPSS-now overrides — even when other signals are weak, an
|
|
91
|
+
// actively-weaponized CVE deserves attention. Floor at high-tier.
|
|
92
|
+
if (f.kev === true || f.kevListed === true || f.weaponized === true) {
|
|
93
|
+
base = Math.max(base, 80);
|
|
94
|
+
factors.push('kev-floor:80');
|
|
95
|
+
}
|
|
96
|
+
if (f.exploitedNow === true) {
|
|
97
|
+
base = Math.max(base, 75);
|
|
98
|
+
factors.push('exploited-now-floor:75');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const score = Math.round(Math.max(0, Math.min(100, base)));
|
|
102
|
+
return { score, factors };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function annotateCompositeRisk(findings) {
|
|
106
|
+
if (!Array.isArray(findings)) return findings;
|
|
107
|
+
for (const f of findings) {
|
|
108
|
+
if (!f || typeof f !== 'object') continue;
|
|
109
|
+
try {
|
|
110
|
+
const { score, factors } = scoreOne(f);
|
|
111
|
+
f.compositeRisk = score;
|
|
112
|
+
f.compositeRiskTier = tierFor(score);
|
|
113
|
+
f.compositeRiskFactors = factors;
|
|
114
|
+
} catch (_) {
|
|
115
|
+
// No-throw contract for posture annotators (see posture/CLAUDE.md).
|
|
116
|
+
f.compositeRisk = null;
|
|
117
|
+
f.compositeRiskTier = null;
|
|
118
|
+
f.compositeRiskFactors = [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Cross-repo intelligence — a per-developer store of fix patterns and
|
|
2
|
+
// triage decisions that span every repo this developer has used the
|
|
3
|
+
// plugin against.
|
|
4
|
+
//
|
|
5
|
+
// Location: ~/.claude/agentic-security/cross-repo/
|
|
6
|
+
// patterns.jsonl — append-only log of "developer fixed family X
|
|
7
|
+
// in repo Y at commit Z using pattern P"
|
|
8
|
+
// triage.jsonl — append-only log of "developer marked family X
|
|
9
|
+
// in repo Y wont-fix with reason R"
|
|
10
|
+
//
|
|
11
|
+
// When a finding lands in the current repo, surface matching patterns
|
|
12
|
+
// and triage decisions from sibling repos — "you fixed this exact shape
|
|
13
|
+
// in repo-A last week; same fix here?"
|
|
14
|
+
//
|
|
15
|
+
// Privacy:
|
|
16
|
+
// - All data stored locally under the developer's $HOME
|
|
17
|
+
// - Nothing transmitted; no network calls
|
|
18
|
+
// - Repo identifiers are git-remote-derived SHA fingerprints, not
|
|
19
|
+
// bare names — so the store doesn't accidentally reveal repo names
|
|
20
|
+
// to anyone reading the local file
|
|
21
|
+
// - Opt-out: AGENTIC_SECURITY_NO_CROSS_REPO=1
|
|
22
|
+
|
|
23
|
+
import * as cp from 'node:child_process';
|
|
24
|
+
import * as fs from 'node:fs';
|
|
25
|
+
import * as crypto from 'node:crypto';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
|
|
28
|
+
// Lazy — process.env.HOME may be mutated mid-process (e.g. tests isolating).
|
|
29
|
+
function _storeDir() {
|
|
30
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
31
|
+
return path.join(HOME, '.claude', 'agentic-security', 'cross-repo');
|
|
32
|
+
}
|
|
33
|
+
function _patternsFile() { return path.join(_storeDir(), 'patterns.jsonl'); }
|
|
34
|
+
function _triageFile() { return path.join(_storeDir(), 'triage.jsonl'); }
|
|
35
|
+
const MAX_LINES = 5000;
|
|
36
|
+
|
|
37
|
+
function _ensureDir() { try { fs.mkdirSync(_storeDir(), { recursive: true }); } catch {} }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stable, privacy-preserving repo fingerprint: SHA-256 of the git remote
|
|
41
|
+
* URL (or scan-root absolute path if no remote). Truncated to 12 chars.
|
|
42
|
+
*/
|
|
43
|
+
export function repoFingerprint(scanRoot) {
|
|
44
|
+
let source = String(scanRoot || '');
|
|
45
|
+
try {
|
|
46
|
+
const remote = cp.execFileSync('git', ['remote', 'get-url', 'origin'],
|
|
47
|
+
{ cwd: scanRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
48
|
+
if (remote) source = remote;
|
|
49
|
+
} catch {}
|
|
50
|
+
return crypto.createHash('sha256').update(source).digest('hex').slice(0, 12);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _appendLine(fp, obj) {
|
|
54
|
+
if (process.env.AGENTIC_SECURITY_NO_CROSS_REPO === '1') return;
|
|
55
|
+
_ensureDir();
|
|
56
|
+
try { fs.appendFileSync(fp, JSON.stringify(obj) + '\n'); } catch {}
|
|
57
|
+
_rotateIfNeeded(fp);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _rotateIfNeeded(fp) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.statSync(fp);
|
|
63
|
+
if (stat.size < 1_000_000) return;
|
|
64
|
+
const lines = fs.readFileSync(fp, 'utf8').split('\n').filter(Boolean);
|
|
65
|
+
if (lines.length <= MAX_LINES) return;
|
|
66
|
+
fs.writeFileSync(fp, lines.slice(-MAX_LINES).join('\n') + '\n');
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _readAll(fp) {
|
|
71
|
+
if (process.env.AGENTIC_SECURITY_NO_CROSS_REPO === '1') return [];
|
|
72
|
+
try {
|
|
73
|
+
return fs.readFileSync(fp, 'utf8')
|
|
74
|
+
.split('\n').filter(Boolean)
|
|
75
|
+
.map(ln => { try { return JSON.parse(ln); } catch { return null; } })
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
} catch { return []; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record that a finding was fixed. Caller passes the finding object +
|
|
82
|
+
* a short description of the fix pattern (often extracted from the
|
|
83
|
+
* synthesize_fix replacement text).
|
|
84
|
+
*/
|
|
85
|
+
export function recordFix({ scanRoot, finding, fixPattern, commitSha }) {
|
|
86
|
+
if (!finding || !finding.family) return null;
|
|
87
|
+
const entry = {
|
|
88
|
+
at: new Date().toISOString(),
|
|
89
|
+
kind: 'fix',
|
|
90
|
+
repo: repoFingerprint(scanRoot),
|
|
91
|
+
family: finding.family,
|
|
92
|
+
severity: finding.severity || null,
|
|
93
|
+
cwe: finding.cwe || null,
|
|
94
|
+
vuln: String(finding.vuln || '').slice(0, 160),
|
|
95
|
+
fixPattern: String(fixPattern || '').slice(0, 280),
|
|
96
|
+
commitSha: commitSha || null,
|
|
97
|
+
};
|
|
98
|
+
_appendLine(_patternsFile(), entry);
|
|
99
|
+
return entry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Record a triage decision into the cross-repo store as well. (The
|
|
104
|
+
* existing posture/triage-memory.js handles the per-repo case; this
|
|
105
|
+
* mirrors it cross-repo so sibling repos benefit too.)
|
|
106
|
+
*/
|
|
107
|
+
export function recordTriage({ scanRoot, finding, decision, reason }) {
|
|
108
|
+
if (!finding || !decision) return null;
|
|
109
|
+
if (!['wont-fix', 'false-positive'].includes(decision)) return null;
|
|
110
|
+
const entry = {
|
|
111
|
+
at: new Date().toISOString(),
|
|
112
|
+
kind: 'triage',
|
|
113
|
+
repo: repoFingerprint(scanRoot),
|
|
114
|
+
family: finding.family || null,
|
|
115
|
+
cwe: finding.cwe || null,
|
|
116
|
+
vuln: String(finding.vuln || '').slice(0, 160),
|
|
117
|
+
decision,
|
|
118
|
+
reason: String(reason || '').slice(0, 280),
|
|
119
|
+
};
|
|
120
|
+
_appendLine(_triageFile(), entry);
|
|
121
|
+
return entry;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Look up cross-repo signals matching a new finding. Returns:
|
|
126
|
+
* { siblingFixes: [], siblingTriage: [] }
|
|
127
|
+
*
|
|
128
|
+
* Matching is family + (cwe optional). Same-repo entries are excluded
|
|
129
|
+
* so the result is genuinely cross-repo learning.
|
|
130
|
+
*/
|
|
131
|
+
export function findSiblingSignals(scanRoot, finding) {
|
|
132
|
+
if (!finding || !finding.family) return { siblingFixes: [], siblingTriage: [] };
|
|
133
|
+
const here = repoFingerprint(scanRoot);
|
|
134
|
+
const fam = finding.family;
|
|
135
|
+
const fixes = _readAll(_patternsFile()).filter(e => e.repo !== here && e.family === fam);
|
|
136
|
+
const triage = _readAll(_triageFile()) .filter(e => e.repo !== here && e.family === fam);
|
|
137
|
+
return {
|
|
138
|
+
siblingFixes: fixes.slice(-5).reverse(), // most recent 5
|
|
139
|
+
siblingTriage: triage.slice(-5).reverse(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render a short Markdown note suitable for surfacing on a finding card.
|
|
145
|
+
*/
|
|
146
|
+
export function renderSiblingNote(signals) {
|
|
147
|
+
if (!signals || (!signals.siblingFixes.length && !signals.siblingTriage.length)) return '';
|
|
148
|
+
const lines = [];
|
|
149
|
+
lines.push('### Cross-repo signal');
|
|
150
|
+
lines.push('');
|
|
151
|
+
if (signals.siblingFixes.length) {
|
|
152
|
+
lines.push(`Past fixes for this family in other repos:`);
|
|
153
|
+
for (const f of signals.siblingFixes) {
|
|
154
|
+
const ago = _ago(f.at);
|
|
155
|
+
lines.push(`- \`${f.repo}\` ${ago} — ${f.fixPattern || '(no pattern recorded)'}`);
|
|
156
|
+
}
|
|
157
|
+
lines.push('');
|
|
158
|
+
}
|
|
159
|
+
if (signals.siblingTriage.length) {
|
|
160
|
+
lines.push(`Past triage for this family in other repos:`);
|
|
161
|
+
for (const t of signals.siblingTriage) {
|
|
162
|
+
const ago = _ago(t.at);
|
|
163
|
+
lines.push(`- \`${t.repo}\` ${ago} — ${t.decision} (${t.reason || 'no reason'})`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _ago(iso) {
|
|
170
|
+
if (!iso) return '';
|
|
171
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
172
|
+
const d = Math.floor(ms / 86_400_000);
|
|
173
|
+
if (d <= 1) return 'today';
|
|
174
|
+
if (d < 7) return `${d}d ago`;
|
|
175
|
+
if (d < 30) return `${Math.floor(d / 7)}w ago`;
|
|
176
|
+
if (d < 365) return `${Math.floor(d / 30)}mo ago`;
|
|
177
|
+
return `${Math.floor(d / 365)}y ago`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const _internals = { _storeDir, _ensureDir, _ago };
|