@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,170 @@
|
|
|
1
|
+
// Continuous learning from triage decisions โ Recommendation #8 of the
|
|
2
|
+
// world-class roadmap.
|
|
3
|
+
//
|
|
4
|
+
// Every triage transition (open โ fixed | wont-fix | false-positive)
|
|
5
|
+
// auto-tunes a per-(project, family, file-glob, sink-method) calibration
|
|
6
|
+
// store. The store directly modifies finding confidence scores on
|
|
7
|
+
// subsequent scans, so the scanner's precision on each individual
|
|
8
|
+
// codebase improves monotonically the longer it runs.
|
|
9
|
+
//
|
|
10
|
+
// Calibration shape:
|
|
11
|
+
//
|
|
12
|
+
// { "global": { // per-(family, sink-method) prior across all projects
|
|
13
|
+
// "sql-injection|.executeQuery": { tp: 142, fp: 7, lastUpdated: "..." }
|
|
14
|
+
// },
|
|
15
|
+
// "perProject": { // per-(file-glob, family) project-specific delta
|
|
16
|
+
// "src/admin/**|hardcoded-secret": { tp: 0, fp: 23, lastUpdated: "..." }
|
|
17
|
+
// }
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// Update rule on triage:
|
|
21
|
+
// transition โ 'fixed' / 'wont-fix-because-not-exploitable' counts as TP+1
|
|
22
|
+
// transition โ 'false-positive' counts as FP+1
|
|
23
|
+
//
|
|
24
|
+
// Application rule at scan time:
|
|
25
|
+
// confidence *= bayesianFactor(prior, project)
|
|
26
|
+
// where bayesianFactor uses a beta-distribution update of the priors.
|
|
27
|
+
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
31
|
+
|
|
32
|
+
const CALIBRATION_FILE = 'triage-calibration.json';
|
|
33
|
+
|
|
34
|
+
// Minimum sample size before per-project calibration takes effect. With
|
|
35
|
+
// fewer than this many triage decisions, we fall back to global priors.
|
|
36
|
+
const MIN_PROJECT_SAMPLES = 5;
|
|
37
|
+
|
|
38
|
+
// Bayesian prior โ beta(ฮฑ, ฮฒ) over the precision rate. ฮฑ=1, ฮฒ=1 is a
|
|
39
|
+
// uniform prior (no opinion); ฮฑ=2, ฮฒ=1 mildly favors precision.
|
|
40
|
+
const PRIOR_ALPHA = 2;
|
|
41
|
+
const PRIOR_BETA = 1;
|
|
42
|
+
|
|
43
|
+
function _storePath(scanRoot) {
|
|
44
|
+
return statePath(scanRoot, CALIBRATION_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadCalibration(scanRoot) {
|
|
48
|
+
const fp = _storePath(scanRoot);
|
|
49
|
+
if (!fs.existsSync(fp)) return { global: {}, perProject: {} };
|
|
50
|
+
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); }
|
|
51
|
+
catch { return { global: {}, perProject: {} }; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _save(scanRoot, data) {
|
|
55
|
+
const fp = _storePath(scanRoot);
|
|
56
|
+
safeWriteState(fp, JSON.stringify(data, null, 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _bucketKey(family, sinkMethod) {
|
|
60
|
+
return `${family || 'unknown'}|${sinkMethod || ''}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _projectKey(fileGlob, family) {
|
|
64
|
+
return `${fileGlob || '*'}|${family || 'unknown'}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record a triage decision. Called by the triage transition path.
|
|
69
|
+
* verdict โ { 'true-positive', 'false-positive' }
|
|
70
|
+
*/
|
|
71
|
+
export function recordTriageDecision(scanRoot, finding, verdict) {
|
|
72
|
+
if (!finding || !verdict) return null;
|
|
73
|
+
const data = loadCalibration(scanRoot);
|
|
74
|
+
const family = finding.family;
|
|
75
|
+
const sinkMethod = _extractSinkMethod(finding);
|
|
76
|
+
const fileGlob = _fileGlobFor(finding.file);
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
|
|
79
|
+
const gk = _bucketKey(family, sinkMethod);
|
|
80
|
+
data.global[gk] ||= { tp: 0, fp: 0, lastUpdated: now };
|
|
81
|
+
if (verdict === 'true-positive') data.global[gk].tp++;
|
|
82
|
+
if (verdict === 'false-positive') data.global[gk].fp++;
|
|
83
|
+
data.global[gk].lastUpdated = now;
|
|
84
|
+
|
|
85
|
+
const pk = _projectKey(fileGlob, family);
|
|
86
|
+
data.perProject[pk] ||= { tp: 0, fp: 0, lastUpdated: now };
|
|
87
|
+
if (verdict === 'true-positive') data.perProject[pk].tp++;
|
|
88
|
+
if (verdict === 'false-positive') data.perProject[pk].fp++;
|
|
89
|
+
data.perProject[pk].lastUpdated = now;
|
|
90
|
+
|
|
91
|
+
_save(scanRoot, data);
|
|
92
|
+
return { gk, pk, data };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Apply learned calibration to a fresh batch of findings. Modifies
|
|
97
|
+
* confidence in place. Returns { adjusted: int, suppressed: int }
|
|
98
|
+
* โ findings whose adjusted confidence drops below `suppressThreshold`
|
|
99
|
+
* are filtered out (added to the suppression log instead).
|
|
100
|
+
*/
|
|
101
|
+
export function applyLearnedCalibration(scanRoot, findings, opts = {}) {
|
|
102
|
+
if (!Array.isArray(findings)) return { adjusted: 0, suppressed: 0 };
|
|
103
|
+
const data = loadCalibration(scanRoot);
|
|
104
|
+
const suppressThreshold = opts.suppressThreshold ?? 0.2;
|
|
105
|
+
let adjusted = 0;
|
|
106
|
+
const suppressedList = [];
|
|
107
|
+
for (const f of findings) {
|
|
108
|
+
const factor = _learnedFactor(data, f);
|
|
109
|
+
if (factor === 1.0) continue;
|
|
110
|
+
const before = typeof f.confidence === 'number' ? f.confidence : 0.85;
|
|
111
|
+
const after = Math.max(0.01, Math.min(0.99, before * factor));
|
|
112
|
+
f.confidence = after;
|
|
113
|
+
f._learnedCalibration = { factor, before, samples: _sampleCount(data, f) };
|
|
114
|
+
adjusted++;
|
|
115
|
+
if (after < suppressThreshold) {
|
|
116
|
+
f._suppressed_by = 'triage-learning';
|
|
117
|
+
suppressedList.push(f);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { adjusted, suppressed: suppressedList.length, suppressedList, data };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _learnedFactor(data, finding) {
|
|
124
|
+
const family = finding.family;
|
|
125
|
+
const sinkMethod = _extractSinkMethod(finding);
|
|
126
|
+
const fileGlob = _fileGlobFor(finding.file);
|
|
127
|
+
|
|
128
|
+
// Per-project: beta-distribution precision estimate when N is large enough.
|
|
129
|
+
const pk = _projectKey(fileGlob, family);
|
|
130
|
+
const proj = data.perProject?.[pk];
|
|
131
|
+
if (proj && proj.tp + proj.fp >= MIN_PROJECT_SAMPLES) {
|
|
132
|
+
const p = (PRIOR_ALPHA + proj.tp) / (PRIOR_ALPHA + PRIOR_BETA + proj.tp + proj.fp);
|
|
133
|
+
return p / 0.5; // 0.5 = neutral prior precision
|
|
134
|
+
}
|
|
135
|
+
// Global: same formula, less aggressive scaling.
|
|
136
|
+
const gk = _bucketKey(family, sinkMethod);
|
|
137
|
+
const glob = data.global?.[gk];
|
|
138
|
+
if (glob && glob.tp + glob.fp >= MIN_PROJECT_SAMPLES) {
|
|
139
|
+
const p = (PRIOR_ALPHA + glob.tp) / (PRIOR_ALPHA + PRIOR_BETA + glob.tp + glob.fp);
|
|
140
|
+
return (0.7 * (p / 0.5)) + 0.3; // weight: 70% global, 30% neutral
|
|
141
|
+
}
|
|
142
|
+
return 1.0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _sampleCount(data, finding) {
|
|
146
|
+
const family = finding.family;
|
|
147
|
+
const sinkMethod = _extractSinkMethod(finding);
|
|
148
|
+
const fileGlob = _fileGlobFor(finding.file);
|
|
149
|
+
const pk = _projectKey(fileGlob, family);
|
|
150
|
+
const gk = _bucketKey(family, sinkMethod);
|
|
151
|
+
const proj = data.perProject?.[pk] || { tp: 0, fp: 0 };
|
|
152
|
+
const glob = data.global?.[gk] || { tp: 0, fp: 0 };
|
|
153
|
+
return { perProject: proj.tp + proj.fp, global: glob.tp + glob.fp };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _extractSinkMethod(finding) {
|
|
157
|
+
if (finding.sink?.method) return finding.sink.method;
|
|
158
|
+
// Try to parse from the vuln string ("SQL Injection โ executeQuery").
|
|
159
|
+
const m = (finding.vuln || '').match(/\b([A-Za-z_]\w*)\s*\(/);
|
|
160
|
+
return m ? m[1] : '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _fileGlobFor(file) {
|
|
164
|
+
if (!file) return '*';
|
|
165
|
+
const parts = file.split('/');
|
|
166
|
+
if (parts.length <= 2) return file;
|
|
167
|
+
return parts.slice(0, 2).join('/') + '/**';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const _internals = { _bucketKey, _projectKey, _extractSinkMethod, _fileGlobFor, MIN_PROJECT_SAMPLES };
|
|
@@ -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
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
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';
|
|
8
11
|
|
|
9
12
|
export const STATES = ['open', 'in-progress', 'fixed', 'wont-fix', 'false-positive'];
|
|
10
13
|
|
|
@@ -44,6 +47,15 @@ export function syncWithScan(scanRoot, findings) {
|
|
|
44
47
|
assignee: null,
|
|
45
48
|
opened_at: now,
|
|
46
49
|
comments: [],
|
|
50
|
+
// Phase 4 / Item 7: capture SCA-relevant fields so the
|
|
51
|
+
// triage โ sca-policy bridge has enough data to materialize an
|
|
52
|
+
// accept-risk entry on wont-fix. No-ops for SAST findings.
|
|
53
|
+
type: f.type || null,
|
|
54
|
+
name: f.name || null,
|
|
55
|
+
version: f.version || null,
|
|
56
|
+
ecosystem: f.ecosystem || null,
|
|
57
|
+
osvId: f.osvId || null,
|
|
58
|
+
cveAliases: Array.isArray(f.cveAliases) ? f.cveAliases : [],
|
|
47
59
|
};
|
|
48
60
|
data.transitions.push({ id, from: null, to: 'open', at: now });
|
|
49
61
|
}
|
|
@@ -80,7 +92,34 @@ export function transition(scanRoot, id, toState, comment) {
|
|
|
80
92
|
if (toState === 'fixed') cur.fixed_at = new Date().toISOString();
|
|
81
93
|
data.transitions.push({ id, from, to: toState, at: new Date().toISOString(), comment });
|
|
82
94
|
_save(scanRoot, data);
|
|
83
|
-
|
|
95
|
+
|
|
96
|
+
// Phase 4 / Item 7 of the SCA improvement plan โ bridge wont-fix
|
|
97
|
+
// transitions on SCA findings into sca-policy.yml accept-risk entries
|
|
98
|
+
// so the suppression is durable across rescans. The finding object on
|
|
99
|
+
// the triage store has a `type` field when it was synced from a scan
|
|
100
|
+
// via syncWithScan; we only bridge type === 'vulnerable_dep'.
|
|
101
|
+
let policyBridge = null;
|
|
102
|
+
if (toState === 'wont-fix' && cur.type === 'vulnerable_dep') {
|
|
103
|
+
try {
|
|
104
|
+
const reason = comment || `Marked wont-fix in triage on ${new Date().toISOString().slice(0,10)}`;
|
|
105
|
+
policyBridge = appendAcceptRiskFromTriage(scanRoot, cur, reason);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
policyBridge = { ok: false, reason: String(e && e.message || e) };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
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 };
|
|
84
123
|
}
|
|
85
124
|
|
|
86
125
|
export function comment(scanRoot, id, author, body) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Watch mode โ continuous incremental scan as the developer edits.
|
|
2
|
+
//
|
|
3
|
+
// Spawns a long-running scan watcher that:
|
|
4
|
+
// 1. Subscribes to file-system events under the project root
|
|
5
|
+
// 2. When a file matching the scan glob changes, re-scans incrementally
|
|
6
|
+
// using dataflow/incremental-cache.js (per-file cache hits avoid the
|
|
7
|
+
// full IR build)
|
|
8
|
+
// 3. Diffs against the prior scan to compute a "risk delta" string
|
|
9
|
+
// (added/removed/changed criticals + highs)
|
|
10
|
+
// 4. Writes the delta to .agentic-security/watch-status.{md,json}
|
|
11
|
+
//
|
|
12
|
+
// The Claude Code statusline / a chat command can poll watch-status.md
|
|
13
|
+
// (cheap file read) to surface the delta inline without re-running anything.
|
|
14
|
+
//
|
|
15
|
+
// Implementation is deliberately node-only โ no chokidar dep, uses
|
|
16
|
+
// fs.promises.watch (Node โฅ 20). Debounces to 350ms.
|
|
17
|
+
//
|
|
18
|
+
// Lifecycle: start() returns an AbortController-like handle. The caller
|
|
19
|
+
// (a /watch slash command or a long-running daemon spawn) is responsible
|
|
20
|
+
// for keeping the process alive; this module is the pure logic.
|
|
21
|
+
|
|
22
|
+
import * as fs from 'node:fs/promises';
|
|
23
|
+
import * as fsSync from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
|
|
26
|
+
const STATE = '.agentic-security';
|
|
27
|
+
const STATUS_MD = 'watch-status.md';
|
|
28
|
+
const STATUS_JSON = 'watch-status.json';
|
|
29
|
+
const DEBOUNCE_MS = 350;
|
|
30
|
+
const MAX_BURST = 50; // ignore beyond this # of rapid events
|
|
31
|
+
|
|
32
|
+
const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
|
|
33
|
+
const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
|
|
34
|
+
|
|
35
|
+
function _isScanable(rel) {
|
|
36
|
+
if (!rel || IGNORE_DIR_RE.test(rel)) return false;
|
|
37
|
+
return SCAN_EXT_RE.test(rel);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _readJsonSafe(fp) {
|
|
41
|
+
try { return JSON.parse(fsSync.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _sevRank(s) { return ['info', 'low', 'medium', 'high', 'critical'].indexOf(s) + 1; }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pure delta computation between two finding arrays. Same logic
|
|
48
|
+
* baseline-compare uses, surfaced as a single ASCII status line.
|
|
49
|
+
*/
|
|
50
|
+
export function computeDelta(prevFindings, currFindings) {
|
|
51
|
+
const key = (f) => `${f.file || ''}::${f.line || 0}::${f.family || f.parser || ''}`;
|
|
52
|
+
const prev = new Map();
|
|
53
|
+
for (const f of prevFindings || []) prev.set(key(f), f);
|
|
54
|
+
const cur = new Map();
|
|
55
|
+
for (const f of currFindings || []) cur.set(key(f), f);
|
|
56
|
+
const added = [], removed = [];
|
|
57
|
+
for (const [k, f] of cur) if (!prev.has(k)) added.push(f);
|
|
58
|
+
for (const [k, f] of prev) if (!cur.has(k)) removed.push(f);
|
|
59
|
+
const newCrit = added.filter(f => f.severity === 'critical').length;
|
|
60
|
+
const newHigh = added.filter(f => f.severity === 'high').length;
|
|
61
|
+
const fixedCrit = removed.filter(f => f.severity === 'critical').length;
|
|
62
|
+
const fixedHigh = removed.filter(f => f.severity === 'high').length;
|
|
63
|
+
return {
|
|
64
|
+
addedCount: added.length, removedCount: removed.length,
|
|
65
|
+
newCritical: newCrit, newHigh, fixedCritical: fixedCrit, fixedHigh,
|
|
66
|
+
added, removed,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Render a one-line status string for the Claude Code statusline.
|
|
72
|
+
*/
|
|
73
|
+
export function renderStatusLine(delta) {
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (delta.newCritical) parts.push(`๐ +${delta.newCritical} crit`);
|
|
76
|
+
if (delta.newHigh) parts.push(`โ ๏ธ +${delta.newHigh} high`);
|
|
77
|
+
if (delta.fixedCritical) parts.push(`โ
-${delta.fixedCritical} crit`);
|
|
78
|
+
if (delta.fixedHigh) parts.push(`โ
-${delta.fixedHigh} high`);
|
|
79
|
+
if (!parts.length && (delta.addedCount + delta.removedCount) === 0) return 'agentic-security: clean';
|
|
80
|
+
if (!parts.length) return `agentic-security: +${delta.addedCount} / -${delta.removedCount}`;
|
|
81
|
+
return 'agentic-security: ' + parts.join(' ยท ');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Persist watch-status.{md,json}. Cheap atomic write (write tmp, rename).
|
|
86
|
+
*/
|
|
87
|
+
export function persistStatus(scanRoot, delta) {
|
|
88
|
+
const dir = path.join(scanRoot, STATE);
|
|
89
|
+
try { fsSync.mkdirSync(dir, { recursive: true }); } catch {}
|
|
90
|
+
const status = {
|
|
91
|
+
ts: new Date().toISOString(),
|
|
92
|
+
line: renderStatusLine(delta),
|
|
93
|
+
delta: {
|
|
94
|
+
addedCount: delta.addedCount, removedCount: delta.removedCount,
|
|
95
|
+
newCritical: delta.newCritical, newHigh: delta.newHigh,
|
|
96
|
+
fixedCritical: delta.fixedCritical, fixedHigh: delta.fixedHigh,
|
|
97
|
+
},
|
|
98
|
+
addedTop5: (delta.added || []).slice(0, 5).map(f => ({
|
|
99
|
+
file: f.file, line: f.line, family: f.family, severity: f.severity, vuln: f.vuln,
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
const jsonPath = path.join(dir, STATUS_JSON);
|
|
103
|
+
const mdPath = path.join(dir, STATUS_MD);
|
|
104
|
+
try { fsSync.writeFileSync(jsonPath, JSON.stringify(status, null, 2)); } catch {}
|
|
105
|
+
const md = [
|
|
106
|
+
`# Watch status โ ${status.ts.slice(11, 19)} UTC`,
|
|
107
|
+
'',
|
|
108
|
+
status.line,
|
|
109
|
+
'',
|
|
110
|
+
];
|
|
111
|
+
if (status.addedTop5.length) {
|
|
112
|
+
md.push('## New findings');
|
|
113
|
+
for (const f of status.addedTop5) {
|
|
114
|
+
md.push(`- **[${(f.severity || '?').toUpperCase()}]** ${f.vuln || f.family || 'finding'} โ \`${f.file}:${f.line}\``);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try { fsSync.writeFileSync(mdPath, md.join('\n')); } catch {}
|
|
118
|
+
return status;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read the latest watch-status (returns null if none).
|
|
123
|
+
*/
|
|
124
|
+
export function readStatus(scanRoot) {
|
|
125
|
+
return _readJsonSafe(path.join(scanRoot, STATE, STATUS_JSON));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Subscribe to FS events and call onChange(absPath, eventType) for each
|
|
130
|
+
* matching event. Debounces bursts. Returns a controller with stop().
|
|
131
|
+
*
|
|
132
|
+
* The actual incremental scan call lives in the caller โ this module
|
|
133
|
+
* stays pure for testability.
|
|
134
|
+
*/
|
|
135
|
+
export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
136
|
+
if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
|
|
137
|
+
const recursive = opts.recursive !== false;
|
|
138
|
+
const ac = new AbortController();
|
|
139
|
+
let timer = null;
|
|
140
|
+
const pending = new Set();
|
|
141
|
+
const flush = () => {
|
|
142
|
+
timer = null;
|
|
143
|
+
if (pending.size > MAX_BURST) { pending.clear(); return; }
|
|
144
|
+
const batch = Array.from(pending);
|
|
145
|
+
pending.clear();
|
|
146
|
+
try { onChange(batch); } catch {}
|
|
147
|
+
};
|
|
148
|
+
let stopped = false;
|
|
149
|
+
(async () => {
|
|
150
|
+
try {
|
|
151
|
+
for await (const evt of fs.watch(scanRoot, { recursive, signal: ac.signal })) {
|
|
152
|
+
if (stopped) break;
|
|
153
|
+
const rel = String(evt.filename || '').replace(/\\/g, '/');
|
|
154
|
+
if (!_isScanable(rel)) continue;
|
|
155
|
+
pending.add(path.join(scanRoot, rel));
|
|
156
|
+
if (timer) clearTimeout(timer);
|
|
157
|
+
timer = setTimeout(flush, DEBOUNCE_MS);
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (e && e.name !== 'AbortError') {
|
|
161
|
+
// Surface but don't crash โ caller decides how to handle.
|
|
162
|
+
try { onChange([], e); } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
return {
|
|
167
|
+
stop: async () => { stopped = true; ac.abort(); if (timer) clearTimeout(timer); },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const _internals = { _isScanable, SCAN_EXT_RE, IGNORE_DIR_RE };
|