@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,141 @@
|
|
|
1
|
+
// Git-history-aware finding annotation.
|
|
2
|
+
//
|
|
3
|
+
// For each finding, looks up the introducing commit via `git blame -L
|
|
4
|
+
// <line>,<line>`. Adds:
|
|
5
|
+
//
|
|
6
|
+
// introducedBy — author name (or 'AI' if commit message marks it)
|
|
7
|
+
// introducedIn — commit SHA (12 chars)
|
|
8
|
+
// introducedAt — commit ISO date
|
|
9
|
+
// introducedInMessage — commit subject line (cap 120 chars)
|
|
10
|
+
// originatingPrompt — extracted from commit body when present
|
|
11
|
+
//
|
|
12
|
+
// Then can render a Slack-ready / PR-comment-ready author-ping draft.
|
|
13
|
+
//
|
|
14
|
+
// Conservative: any subprocess error / non-git repo / file-not-tracked
|
|
15
|
+
// leaves the finding unannotated. Caps blame to 1 line per finding (so
|
|
16
|
+
// 200 findings = 200 blame calls). Set AGENTIC_SECURITY_NO_GIT_HISTORY=1
|
|
17
|
+
// to skip entirely.
|
|
18
|
+
|
|
19
|
+
import * as cp from 'node:child_process';
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const MAX_BLAME_PER_SCAN = 500;
|
|
24
|
+
const SUBPROC_TIMEOUT_MS = 1500;
|
|
25
|
+
const PROMPT_MARKER_RE = /(?:^|\n)(?:Prompt|User asked|Original request|Co-Authored-By:\s*Claude)/i;
|
|
26
|
+
|
|
27
|
+
function _isGitRepo(scanRoot) {
|
|
28
|
+
try {
|
|
29
|
+
cp.execFileSync('git', ['rev-parse', '--git-dir'], { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS });
|
|
30
|
+
return true;
|
|
31
|
+
} catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _blame(scanRoot, file, line) {
|
|
35
|
+
if (!file || !line || line < 1) return null;
|
|
36
|
+
const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
|
|
37
|
+
if (rel.startsWith('..')) return null;
|
|
38
|
+
try {
|
|
39
|
+
const stdout = cp.execFileSync(
|
|
40
|
+
'git',
|
|
41
|
+
['blame', '-L', `${line},${line}`, '--porcelain', '--', rel],
|
|
42
|
+
{ cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
43
|
+
);
|
|
44
|
+
return _parsePorcelain(stdout);
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _parsePorcelain(out) {
|
|
49
|
+
if (!out) return null;
|
|
50
|
+
const lines = out.split('\n');
|
|
51
|
+
// First line: <sha> <orig-line> <final-line> <num-lines>
|
|
52
|
+
const head = lines[0].split(' ');
|
|
53
|
+
const sha = head[0];
|
|
54
|
+
if (!sha || sha === '0000000000000000000000000000000000000000') return null;
|
|
55
|
+
const meta = { sha: sha.slice(0, 12) };
|
|
56
|
+
for (const ln of lines) {
|
|
57
|
+
if (ln.startsWith('author ')) meta.author = ln.slice(7);
|
|
58
|
+
else if (ln.startsWith('author-mail ')) meta.email = ln.slice(12).replace(/[<>]/g, '');
|
|
59
|
+
else if (ln.startsWith('author-time ')) meta.ts = parseInt(ln.slice(12), 10);
|
|
60
|
+
else if (ln.startsWith('summary ')) meta.summary = ln.slice(8);
|
|
61
|
+
}
|
|
62
|
+
if (meta.ts) meta.at = new Date(meta.ts * 1000).toISOString();
|
|
63
|
+
return meta;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _fullMessage(scanRoot, sha) {
|
|
67
|
+
try {
|
|
68
|
+
return cp.execFileSync(
|
|
69
|
+
'git', ['show', '-s', '--format=%B', sha],
|
|
70
|
+
{ cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
71
|
+
);
|
|
72
|
+
} catch { return ''; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _extractPrompt(body) {
|
|
76
|
+
if (!body || !PROMPT_MARKER_RE.test(body)) return null;
|
|
77
|
+
// Take the line(s) that follow a "Prompt:" or "User asked:" marker, up to
|
|
78
|
+
// the next blank line. Truncate to 280 chars.
|
|
79
|
+
const m = body.match(/(?:Prompt|User asked|Original request)\s*[:\-]\s*([\s\S]*?)(?=\n\s*\n|$)/i);
|
|
80
|
+
if (!m) return null;
|
|
81
|
+
return m[1].trim().slice(0, 280);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Annotate findings with git blame + commit context. Returns
|
|
86
|
+
* { annotated, cached, skipped } counts.
|
|
87
|
+
*/
|
|
88
|
+
export function annotateGitHistory(scanRoot, findings) {
|
|
89
|
+
if (process.env.AGENTIC_SECURITY_NO_GIT_HISTORY === '1') return { annotated: 0 };
|
|
90
|
+
if (!Array.isArray(findings) || findings.length === 0) return { annotated: 0 };
|
|
91
|
+
if (!_isGitRepo(scanRoot)) return { annotated: 0, reason: 'not-a-git-repo' };
|
|
92
|
+
|
|
93
|
+
const messageCache = new Map(); // sha → body
|
|
94
|
+
let annotated = 0, skipped = 0;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < findings.length && i < MAX_BLAME_PER_SCAN; i++) {
|
|
97
|
+
const f = findings[i];
|
|
98
|
+
if (!f || !f.file || !f.line) { skipped++; continue; }
|
|
99
|
+
const blame = _blame(scanRoot, f.file, f.line);
|
|
100
|
+
if (!blame) { skipped++; continue; }
|
|
101
|
+
|
|
102
|
+
f.introducedBy = blame.author || null;
|
|
103
|
+
f.introducedIn = blame.sha;
|
|
104
|
+
f.introducedAt = blame.at || null;
|
|
105
|
+
|
|
106
|
+
let body = messageCache.get(blame.sha);
|
|
107
|
+
if (body === undefined) {
|
|
108
|
+
body = _fullMessage(scanRoot, blame.sha);
|
|
109
|
+
messageCache.set(blame.sha, body);
|
|
110
|
+
}
|
|
111
|
+
if (body) {
|
|
112
|
+
const subj = body.split('\n')[0].slice(0, 120);
|
|
113
|
+
f.introducedInMessage = subj;
|
|
114
|
+
const prompt = _extractPrompt(body);
|
|
115
|
+
if (prompt) f.originatingPrompt = prompt;
|
|
116
|
+
// Mark AI-authored when commit message carries the Claude co-author trailer.
|
|
117
|
+
if (/Co-Authored-By:\s*Claude/i.test(body)) f.aiAuthored = true;
|
|
118
|
+
}
|
|
119
|
+
annotated++;
|
|
120
|
+
}
|
|
121
|
+
return { annotated, skipped, capped: findings.length > MAX_BLAME_PER_SCAN };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate a Slack-ready / PR-comment author-ping for a finding. Returns
|
|
126
|
+
* a Markdown string with a per-finding callout.
|
|
127
|
+
*/
|
|
128
|
+
export function generateAuthorPing(finding) {
|
|
129
|
+
if (!finding || !finding.introducedBy) return null;
|
|
130
|
+
const where = `${finding.file || '?'}:${finding.line || 0}`;
|
|
131
|
+
const lines = [];
|
|
132
|
+
lines.push(`Hey @${finding.introducedBy.replace(/\s+/g, '.')} — heads-up on \`${where}\`:`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(`- **${(finding.severity || '?').toUpperCase()}** ${finding.vuln || finding.family || 'finding'}`);
|
|
135
|
+
if (finding.introducedIn) lines.push(`- Introduced in \`${finding.introducedIn}\`${finding.introducedInMessage ? ` — _"${finding.introducedInMessage}"_` : ''}`);
|
|
136
|
+
if (finding.originatingPrompt) lines.push(`- Originating prompt: _"${finding.originatingPrompt}"_`);
|
|
137
|
+
lines.push(`- Could you take a look?`);
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const _internals = { _parsePorcelain, _extractPrompt, _isGitRepo };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Intent-aware false-positive suppression.
|
|
2
|
+
//
|
|
3
|
+
// Reads project / file / Claude-session context to detect when code is
|
|
4
|
+
// deliberately vulnerable (CTF challenge, sandbox, tutorial, example,
|
|
5
|
+
// fixture) so the scanner can demote findings rather than presenting them
|
|
6
|
+
// as production-grade issues.
|
|
7
|
+
//
|
|
8
|
+
// Signal sources (in order of strength):
|
|
9
|
+
//
|
|
10
|
+
// 1. .agentic-security/current-intent.md
|
|
11
|
+
// A file Claude (or the user) writes to declare the current
|
|
12
|
+
// session's intent. The PreToolUse / SessionStart hook can populate
|
|
13
|
+
// this from the recent transcript. Format:
|
|
14
|
+
// # Intent
|
|
15
|
+
// - tutorial: building an intentional SQLi demo for our security training
|
|
16
|
+
// - excluded-paths: ["examples/sqli-demo/**"]
|
|
17
|
+
//
|
|
18
|
+
// 2. File header comments (first ~1500 chars):
|
|
19
|
+
// @sandbox / @example / @intentionally-vulnerable / @ctf-challenge /
|
|
20
|
+
// @demo / @tutorial / // INTENTIONALLY VULNERABLE
|
|
21
|
+
//
|
|
22
|
+
// 3. Path patterns: examples/, demo/, demos/, tutorial/, sandbox/,
|
|
23
|
+
// playground/, challenges/, ctf/
|
|
24
|
+
//
|
|
25
|
+
// 4. CLAUDE.md section headed "Intentionally vulnerable" / "Out of scope"
|
|
26
|
+
//
|
|
27
|
+
// Opt-out: AGENTIC_SECURITY_NO_INTENT_CTX=1
|
|
28
|
+
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import * as path from 'node:path';
|
|
31
|
+
|
|
32
|
+
const INTENT_PATH_RE = /(?:^|\/)(?:examples?|demos?|tutorials?|sandbox|playground|challenges?|ctf)(?:\/|$)/i;
|
|
33
|
+
|
|
34
|
+
const FILE_HEADER_MARKERS = [
|
|
35
|
+
/@sandbox\b/i,
|
|
36
|
+
/@example\b/i,
|
|
37
|
+
/@intentionally[-_]?vulnerable\b/i,
|
|
38
|
+
/@ctf[-_]?challenge\b/i,
|
|
39
|
+
/@demo\b/i,
|
|
40
|
+
/@tutorial\b/i,
|
|
41
|
+
/(?:^|[^A-Za-z])INTENTIONALLY[- ]?VULNERABLE(?:[^A-Za-z]|$)/,
|
|
42
|
+
/(?:^|[^A-Za-z])DELIBERATELY[- ]?UNSAFE(?:[^A-Za-z]|$)/,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const HEADER_BUDGET = 1500;
|
|
46
|
+
|
|
47
|
+
function _readSafely(fp) {
|
|
48
|
+
try { return fs.readFileSync(fp, 'utf8'); } catch { return ''; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _readIntentDeclaration(scanRoot) {
|
|
52
|
+
const fp = path.join(scanRoot, '.agentic-security', 'current-intent.md');
|
|
53
|
+
if (!fs.existsSync(fp)) return null;
|
|
54
|
+
const body = _readSafely(fp);
|
|
55
|
+
if (!body) return null;
|
|
56
|
+
const exMatch = body.match(/excluded-paths\s*:\s*\[([\s\S]*?)\]/);
|
|
57
|
+
let excludedPaths = [];
|
|
58
|
+
if (exMatch) {
|
|
59
|
+
excludedPaths = (exMatch[1].match(/"([^"]+)"|'([^']+)'/g) || [])
|
|
60
|
+
.map(s => s.replace(/['"]/g, ''));
|
|
61
|
+
}
|
|
62
|
+
return { body, excludedPaths };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _claudeMdHasOutOfScope(scanRoot) {
|
|
66
|
+
const fp = path.join(scanRoot, 'CLAUDE.md');
|
|
67
|
+
if (!fs.existsSync(fp)) return [];
|
|
68
|
+
const body = _readSafely(fp);
|
|
69
|
+
const out = [];
|
|
70
|
+
const re = /^#{1,3}\s+(?:Out[- ]of[- ]scope|Intentionally vulnerable|Sandbox|Examples?)[\s\S]*?(?=\n#{1,3}\s|$(?![\s\S]))/gim;
|
|
71
|
+
let m;
|
|
72
|
+
while ((m = re.exec(body))) {
|
|
73
|
+
const sec = m[0];
|
|
74
|
+
// Pull file globs / paths from the section.
|
|
75
|
+
const paths = (sec.match(/`([^`]+)`/g) || []).map(s => s.replace(/`/g, ''));
|
|
76
|
+
out.push(...paths.filter(p => /\/|\*/.test(p)));
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _fileHeaderHasIntent(file) {
|
|
82
|
+
if (!file) return false;
|
|
83
|
+
try {
|
|
84
|
+
const fd = fs.openSync(file, 'r');
|
|
85
|
+
const buf = Buffer.alloc(HEADER_BUDGET);
|
|
86
|
+
fs.readSync(fd, buf, 0, HEADER_BUDGET, 0);
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
const head = buf.toString('utf8');
|
|
89
|
+
return FILE_HEADER_MARKERS.some(re => re.test(head));
|
|
90
|
+
} catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _globMatch(pattern, p) {
|
|
94
|
+
// Minimal glob: `**` → `.*`, `*` → `[^/]*`. Path separators normalized.
|
|
95
|
+
const norm = String(p).replace(/\\/g, '/');
|
|
96
|
+
const re = new RegExp(
|
|
97
|
+
'^' + String(pattern).replace(/\\/g, '/')
|
|
98
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
99
|
+
.replace(/\*\*/g, '###DSTAR###')
|
|
100
|
+
.replace(/\*/g, '[^/]*')
|
|
101
|
+
.replace(/###DSTAR###/g, '.*')
|
|
102
|
+
+ '$',
|
|
103
|
+
);
|
|
104
|
+
return re.test(norm);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract intent signals once per scan.
|
|
109
|
+
*/
|
|
110
|
+
export function extractIntentSignals(scanRoot) {
|
|
111
|
+
if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX === '1') {
|
|
112
|
+
return { declaredExcludedPaths: [], claudeMdExcludedPaths: [], intent: null };
|
|
113
|
+
}
|
|
114
|
+
const decl = _readIntentDeclaration(scanRoot);
|
|
115
|
+
const claudeMdPaths = _claudeMdHasOutOfScope(scanRoot);
|
|
116
|
+
return {
|
|
117
|
+
declaredExcludedPaths: decl ? decl.excludedPaths : [],
|
|
118
|
+
claudeMdExcludedPaths: claudeMdPaths,
|
|
119
|
+
intent: decl ? decl.body : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Per-finding suppression. Returns the count of findings demoted.
|
|
125
|
+
* Findings are mutated in place — sets `intentSuppressed=true`, drops
|
|
126
|
+
* confidence by 50%, adds 'intent-suppressed' tag.
|
|
127
|
+
*/
|
|
128
|
+
export function suppressByIntent(scanRoot, findings) {
|
|
129
|
+
if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX === '1') return { applied: 0 };
|
|
130
|
+
if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
|
|
131
|
+
const signals = extractIntentSignals(scanRoot);
|
|
132
|
+
const allExcluded = [...signals.declaredExcludedPaths, ...signals.claudeMdExcludedPaths];
|
|
133
|
+
|
|
134
|
+
let applied = 0;
|
|
135
|
+
const fileHeaderCache = new Map();
|
|
136
|
+
|
|
137
|
+
for (const f of findings) {
|
|
138
|
+
const file = f.file || '';
|
|
139
|
+
const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
|
|
140
|
+
let suppress = false;
|
|
141
|
+
let reason = null;
|
|
142
|
+
|
|
143
|
+
if (INTENT_PATH_RE.test(rel)) { suppress = true; reason = 'intent-path-pattern'; }
|
|
144
|
+
|
|
145
|
+
if (!suppress && allExcluded.length) {
|
|
146
|
+
for (const pat of allExcluded) {
|
|
147
|
+
if (_globMatch(pat, rel)) { suppress = true; reason = 'intent-declared-exclusion'; break; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!suppress && file) {
|
|
152
|
+
let hdr = fileHeaderCache.get(file);
|
|
153
|
+
if (hdr === undefined) {
|
|
154
|
+
hdr = _fileHeaderHasIntent(file);
|
|
155
|
+
fileHeaderCache.set(file, hdr);
|
|
156
|
+
}
|
|
157
|
+
if (hdr) { suppress = true; reason = 'intent-file-header'; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (suppress) {
|
|
161
|
+
f.intentSuppressed = true;
|
|
162
|
+
f.intentReason = reason;
|
|
163
|
+
if (typeof f.confidence === 'number') f.confidence = Math.max(0.15, f.confidence * 0.5);
|
|
164
|
+
f.tags = Array.isArray(f.tags) ? f.tags : [];
|
|
165
|
+
if (!f.tags.includes('intent-suppressed')) f.tags.push('intent-suppressed');
|
|
166
|
+
applied++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { applied, total: findings.length };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const _internals = {
|
|
173
|
+
INTENT_PATH_RE, FILE_HEADER_MARKERS,
|
|
174
|
+
_readIntentDeclaration, _claudeMdHasOutOfScope, _fileHeaderHasIntent, _globMatch,
|
|
175
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Model-of-the-month re-scan delta.
|
|
2
|
+
//
|
|
3
|
+
// Re-runs the LLM validator (already opt-in via AGENTIC_SECURITY_LLM_VALIDATE)
|
|
4
|
+
// with a different model and produces a delta report: which findings the
|
|
5
|
+
// newer model marked TP that the prior model marked FP (or vice versa),
|
|
6
|
+
// what newer reasoning catches that older reasoning missed.
|
|
7
|
+
//
|
|
8
|
+
// Use case: every time Anthropic ships a new Claude model (or you want to
|
|
9
|
+
// A/B against gpt-5 / a custom finetune), re-validate the last scan and see
|
|
10
|
+
// which findings change verdict.
|
|
11
|
+
//
|
|
12
|
+
// Output: .agentic-security/model-rescan/<from>-vs-<to>.json with:
|
|
13
|
+
// { from, to, changed: [{ finding_id, before, after, why }], ts }
|
|
14
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const STATE = '.agentic-security';
|
|
19
|
+
|
|
20
|
+
function _readJson(scanRoot, name) {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(path.join(scanRoot, STATE, name), 'utf8')); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compare two validator runs by finding_id. Each run is a JSON like:
|
|
26
|
+
* { model: 'claude-sonnet-4', results: { findingId: { verdict, reason }, ... } }
|
|
27
|
+
*/
|
|
28
|
+
export function diffValidatorRuns(runA, runB) {
|
|
29
|
+
const a = runA && runA.results ? runA.results : {};
|
|
30
|
+
const b = runB && runB.results ? runB.results : {};
|
|
31
|
+
const ids = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
32
|
+
const changed = [];
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
const av = (a[id] && a[id].verdict) || null;
|
|
35
|
+
const bv = (b[id] && b[id].verdict) || null;
|
|
36
|
+
if (av !== bv) {
|
|
37
|
+
changed.push({
|
|
38
|
+
finding_id: id,
|
|
39
|
+
before: av,
|
|
40
|
+
after: bv,
|
|
41
|
+
before_reason: a[id]?.reason || null,
|
|
42
|
+
after_reason: b[id]?.reason || null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return changed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Persist a model-rescan report. Returns the file path.
|
|
51
|
+
*/
|
|
52
|
+
export function persistRescanReport(scanRoot, from, to, changed) {
|
|
53
|
+
const dir = path.join(scanRoot, STATE, 'model-rescan');
|
|
54
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
55
|
+
const safe = (s) => String(s || 'unknown').replace(/[^\w.-]/g, '-');
|
|
56
|
+
const fp = path.join(dir, `${safe(from)}-vs-${safe(to)}.json`);
|
|
57
|
+
const report = { from, to, ts: new Date().toISOString(), changed };
|
|
58
|
+
try { fs.writeFileSync(fp, JSON.stringify(report, null, 2)); } catch {}
|
|
59
|
+
return fp;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a quick natural-language summary of the delta.
|
|
64
|
+
*/
|
|
65
|
+
export function summarizeDelta(changed) {
|
|
66
|
+
if (!Array.isArray(changed) || !changed.length) return 'No changes — validators agree on every finding.';
|
|
67
|
+
const flipsToTP = changed.filter(c => c.before === 'fp' && c.after === 'tp');
|
|
68
|
+
const flipsToFP = changed.filter(c => c.before === 'tp' && c.after === 'fp');
|
|
69
|
+
const lines = [];
|
|
70
|
+
lines.push(`${changed.length} verdict change(s) between models:`);
|
|
71
|
+
if (flipsToTP.length) lines.push(` ${flipsToTP.length} finding(s) now confirmed TP (newer model caught what older missed)`);
|
|
72
|
+
if (flipsToFP.length) lines.push(` ${flipsToFP.length} finding(s) now FP (newer model recognized as safe)`);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const _internals = {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Pattern propagation — annotator that surfaces cross-repo signals on
|
|
2
|
+
// findings. For each finding, queries the cross-repo store for past
|
|
3
|
+
// fixes and triage decisions on the same family from sibling repos and
|
|
4
|
+
// stamps the finding with a crossRepoSignal field.
|
|
5
|
+
//
|
|
6
|
+
// The /show-findings command / PR-augment / explain_finding MCP tool
|
|
7
|
+
// can render the signal alongside the finding so the developer sees
|
|
8
|
+
// "you already fixed this exact shape in repo X."
|
|
9
|
+
//
|
|
10
|
+
// Pure annotator — no LLM calls. Opt-out via the cross-repo-memory
|
|
11
|
+
// AGENTIC_SECURITY_NO_CROSS_REPO=1 flag.
|
|
12
|
+
|
|
13
|
+
import { findSiblingSignals, renderSiblingNote } from './cross-repo-memory.js';
|
|
14
|
+
|
|
15
|
+
export function annotateCrossRepoSignals(scanRoot, findings) {
|
|
16
|
+
if (process.env.AGENTIC_SECURITY_NO_CROSS_REPO === '1') return { annotated: 0 };
|
|
17
|
+
if (!Array.isArray(findings) || findings.length === 0) return { annotated: 0 };
|
|
18
|
+
let annotated = 0;
|
|
19
|
+
// De-duplicate signal lookups per family — many findings share a family.
|
|
20
|
+
const sigCache = new Map();
|
|
21
|
+
for (const f of findings) {
|
|
22
|
+
const fam = f.family;
|
|
23
|
+
if (!fam) continue;
|
|
24
|
+
let signals = sigCache.get(fam);
|
|
25
|
+
if (signals === undefined) {
|
|
26
|
+
signals = findSiblingSignals(scanRoot, f);
|
|
27
|
+
sigCache.set(fam, signals);
|
|
28
|
+
}
|
|
29
|
+
if (signals.siblingFixes.length || signals.siblingTriage.length) {
|
|
30
|
+
f.crossRepoSignal = {
|
|
31
|
+
fixes: signals.siblingFixes.length,
|
|
32
|
+
triage: signals.siblingTriage.length,
|
|
33
|
+
note: renderSiblingNote(signals),
|
|
34
|
+
};
|
|
35
|
+
annotated++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { annotated, total: findings.length };
|
|
39
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// PR-description auto-augmentation.
|
|
2
|
+
//
|
|
3
|
+
// Reads the current last-scan vs a baseline (git base branch or
|
|
4
|
+
// a stored snapshot), and produces a Markdown block suitable for
|
|
5
|
+
// injecting into a PR body via `gh pr edit --body` or chaining into
|
|
6
|
+
// `gh pr create`.
|
|
7
|
+
//
|
|
8
|
+
// Output sections:
|
|
9
|
+
// 1. Security delta summary (added / removed / changed by severity)
|
|
10
|
+
// 2. ATT&CK tactics covered by new findings
|
|
11
|
+
// 3. Suggested reviewers by class (auth changes → security; PII → privacy)
|
|
12
|
+
// 4. Links to relevant artifacts (threat-model, compliance-evidence,
|
|
13
|
+
// PQC plan, exploit bundles) when present
|
|
14
|
+
//
|
|
15
|
+
// Pure render — does not call git or gh; caller orchestrates that.
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { diffScans, summarizeDiff } from './baseline-compare.js';
|
|
20
|
+
|
|
21
|
+
const REVIEWER_TRIGGERS = [
|
|
22
|
+
{ family: /^auth/, team: 'security', why: 'Auth-related findings' },
|
|
23
|
+
{ family: /^crypto/, team: 'security', why: 'Cryptography findings' },
|
|
24
|
+
{ family: /^pii|gdpr|hipaa/i, team: 'privacy', why: 'PII / data-handling findings' },
|
|
25
|
+
{ family: /^iam|cloud|k8s/, team: 'platform', why: 'Cloud / Kubernetes posture findings' },
|
|
26
|
+
{ family: /^supply|license|vuln/, team: 'platform', why: 'Supply-chain findings' },
|
|
27
|
+
{ family: /^llm|prompt|ml/, team: 'ml', why: 'LLM / ML supply-chain findings' },
|
|
28
|
+
{ family: /^web3|defi/, team: 'security', why: 'Smart-contract findings' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function _stateFile(scanRoot, name) {
|
|
32
|
+
return path.join(scanRoot, '.agentic-security', name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _readJson(fp) {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _baselinePath(scanRoot, ref) {
|
|
40
|
+
const safe = String(ref || 'main').replace(/[^\w.-]/g, '-');
|
|
41
|
+
return path.join(scanRoot, '.agentic-security', 'scan-baselines', `${safe}.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist a scan snapshot under .agentic-security/scan-baselines/<ref>.json
|
|
46
|
+
* so subsequent PRs can diff against it.
|
|
47
|
+
*/
|
|
48
|
+
export function persistBaseline(scanRoot, ref, scan) {
|
|
49
|
+
const fp = _baselinePath(scanRoot, ref);
|
|
50
|
+
try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch {}
|
|
51
|
+
try { fs.writeFileSync(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2)); } catch {}
|
|
52
|
+
return fp;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadBaseline(scanRoot, ref) {
|
|
56
|
+
return _readJson(_baselinePath(scanRoot, ref));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Recommended reviewers derived from the diff's added-findings.
|
|
61
|
+
*/
|
|
62
|
+
function _suggestReviewers(addedFindings) {
|
|
63
|
+
const hits = new Map(); // team → {why: Set, count: number}
|
|
64
|
+
for (const f of addedFindings) {
|
|
65
|
+
for (const t of REVIEWER_TRIGGERS) {
|
|
66
|
+
if (t.family.test(f.family || '')) {
|
|
67
|
+
if (!hits.has(t.team)) hits.set(t.team, { why: new Set(), count: 0 });
|
|
68
|
+
const ent = hits.get(t.team);
|
|
69
|
+
ent.why.add(t.why);
|
|
70
|
+
ent.count++;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return Array.from(hits.entries()).map(([team, ent]) => ({
|
|
76
|
+
team, count: ent.count, why: Array.from(ent.why),
|
|
77
|
+
})).sort((a, b) => b.count - a.count);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ATT&CK techniques surfaced by added findings (attack-taxonomy annotator
|
|
82
|
+
* must have run for this to be populated).
|
|
83
|
+
*/
|
|
84
|
+
function _addedAttckSummary(addedFindings) {
|
|
85
|
+
const map = new Map();
|
|
86
|
+
for (const f of addedFindings) {
|
|
87
|
+
for (const t of (f.attck || [])) {
|
|
88
|
+
if (!map.has(t)) map.set(t, { count: 0, name: f.attckName || t });
|
|
89
|
+
map.get(t).count++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return Array.from(map.entries())
|
|
93
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
94
|
+
.slice(0, 8)
|
|
95
|
+
.map(([id, v]) => ({ id, name: v.name, count: v.count }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Artifact links — pull file paths that exist under .agentic-security/.
|
|
100
|
+
*/
|
|
101
|
+
function _artifactLinks(scanRoot) {
|
|
102
|
+
const candidates = [
|
|
103
|
+
{ name: 'Threat model', file: 'threat-model.md' },
|
|
104
|
+
{ name: 'Compliance evidence', file: 'compliance-evidence.md' },
|
|
105
|
+
{ name: 'PQC migration plan', file: 'pqc-migration-plan.md' },
|
|
106
|
+
{ name: 'DPIA', file: 'dpia.md' },
|
|
107
|
+
{ name: 'ATTRIBUTIONS', file: 'ATTRIBUTIONS.md' },
|
|
108
|
+
{ name: 'NOTICE', file: 'NOTICE' },
|
|
109
|
+
];
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const c of candidates) {
|
|
112
|
+
const fp = path.join(scanRoot, '.agentic-security', c.file);
|
|
113
|
+
if (fs.existsSync(fp)) out.push({ name: c.name, path: `.agentic-security/${c.file}` });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a Markdown PR-body augmentation block.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} scanRoot
|
|
122
|
+
* @param {object} opts
|
|
123
|
+
* - baselineRef: string (default 'main')
|
|
124
|
+
* - title: string section title (default 'Security review')
|
|
125
|
+
* - blocking: bool if true, prepend a 🛑 block-merge banner when new criticals added
|
|
126
|
+
*/
|
|
127
|
+
export function augmentPrBody(scanRoot, opts = {}) {
|
|
128
|
+
const baselineRef = opts.baselineRef || 'main';
|
|
129
|
+
const title = opts.title || 'Security review (automated)';
|
|
130
|
+
const blocking = opts.blocking !== false;
|
|
131
|
+
|
|
132
|
+
const current = _readJson(_stateFile(scanRoot, 'last-scan.json'));
|
|
133
|
+
if (!current) return { ok: false, error: 'No .agentic-security/last-scan.json — run a scan first.' };
|
|
134
|
+
|
|
135
|
+
const baseline = loadBaseline(scanRoot, baselineRef);
|
|
136
|
+
const diff = baseline ? diffScans(baseline, current) : { added: current.findings || [], removed: [], changed: [], unchanged: 0 };
|
|
137
|
+
const summary = summarizeDiff(diff);
|
|
138
|
+
|
|
139
|
+
const lines = [];
|
|
140
|
+
lines.push(`## ${title}`);
|
|
141
|
+
lines.push('');
|
|
142
|
+
|
|
143
|
+
if (!baseline) {
|
|
144
|
+
lines.push(`> Baseline against \`${baselineRef}\` not found — showing the full current scan as added. Run \`/pr-augment --persist-baseline ${baselineRef}\` from \`${baselineRef}\` to enable diff mode.`);
|
|
145
|
+
lines.push('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const newCriticals = summary.bySeverity.critical?.added || 0;
|
|
149
|
+
const newHighs = summary.bySeverity.high?.added || 0;
|
|
150
|
+
|
|
151
|
+
if (blocking && newCriticals > 0) {
|
|
152
|
+
lines.push(`> 🛑 **${newCriticals} new critical finding(s)** — recommend blocking merge until resolved.`);
|
|
153
|
+
lines.push('');
|
|
154
|
+
} else if (newHighs > 0) {
|
|
155
|
+
lines.push(`> ⚠️ **${newHighs} new high-severity finding(s)** — review before merging.`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
} else if (summary.addedCount === 0) {
|
|
158
|
+
lines.push('> ✅ No new findings vs baseline.');
|
|
159
|
+
lines.push('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Delta table
|
|
163
|
+
lines.push('### Findings delta vs `' + baselineRef + '`');
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push('| Severity | Added | Removed |');
|
|
166
|
+
lines.push('|---|---:|---:|');
|
|
167
|
+
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
|
168
|
+
const s = summary.bySeverity[sev] || { added: 0, removed: 0 };
|
|
169
|
+
lines.push(`| ${sev} | ${s.added} | ${s.removed} |`);
|
|
170
|
+
}
|
|
171
|
+
lines.push('');
|
|
172
|
+
|
|
173
|
+
// ATT&CK tactics
|
|
174
|
+
const attck = _addedAttckSummary(diff.added);
|
|
175
|
+
if (attck.length) {
|
|
176
|
+
lines.push('### MITRE ATT&CK techniques (new findings)');
|
|
177
|
+
lines.push('');
|
|
178
|
+
for (const t of attck) lines.push(`- \`${t.id}\` ${t.name} (${t.count})`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Suggested reviewers
|
|
183
|
+
const reviewers = _suggestReviewers(diff.added);
|
|
184
|
+
if (reviewers.length) {
|
|
185
|
+
lines.push('### Suggested reviewers');
|
|
186
|
+
lines.push('');
|
|
187
|
+
for (const r of reviewers) lines.push(`- **${r.team}** — ${r.why.join('; ')} (${r.count})`);
|
|
188
|
+
lines.push('');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Top 5 added findings
|
|
192
|
+
if (diff.added.length) {
|
|
193
|
+
const top = diff.added
|
|
194
|
+
.slice()
|
|
195
|
+
.sort((a, b) => (sevRank(b.severity) - sevRank(a.severity)) || ((b.confidence || 0) - (a.confidence || 0)))
|
|
196
|
+
.slice(0, 5);
|
|
197
|
+
lines.push('### Top added findings');
|
|
198
|
+
lines.push('');
|
|
199
|
+
for (const f of top) {
|
|
200
|
+
const where = `${f.file || '?'}:${f.line || 0}`;
|
|
201
|
+
lines.push(`- **[${(f.severity || '?').toUpperCase()}]** ${f.vuln || f.family || 'finding'} — \`${where}\``);
|
|
202
|
+
}
|
|
203
|
+
lines.push('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Artifact links
|
|
207
|
+
const arts = _artifactLinks(scanRoot);
|
|
208
|
+
if (arts.length) {
|
|
209
|
+
lines.push('### Posture artifacts');
|
|
210
|
+
lines.push('');
|
|
211
|
+
for (const a of arts) lines.push(`- [${a.name}](${a.path})`);
|
|
212
|
+
lines.push('');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
lines.push('_Generated by [agentic-security](https://github.com/Clear-Capabilities/agentic-security)._');
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
body: lines.join('\n'),
|
|
220
|
+
summary: {
|
|
221
|
+
newCriticals,
|
|
222
|
+
newHighs,
|
|
223
|
+
added: summary.addedCount,
|
|
224
|
+
removed: summary.removedCount,
|
|
225
|
+
reviewers,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sevRank(s) {
|
|
231
|
+
return { critical: 4, high: 3, medium: 2, low: 1, info: 0 }[s] || 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const _internals = { _suggestReviewers, _addedAttckSummary, _artifactLinks, _baselinePath };
|