@clear-capabilities/agentic-security-scanner 0.80.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/178.index.js +1 -1
- package/dist/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/dpia.md +26 -0
- 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/pqc-migration-plan.json +65 -0
- package/src/posture/.agentic-security/pqc-migration-plan.md +30 -0
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +6 -0
- package/src/posture/.agentic-security/scan-history.json +1942 -200
- package/src/posture/.agentic-security/streak.json +3 -3
- package/src/posture/.agentic-security/threat-model.json +2038 -0
- package/src/posture/.agentic-security/threat-model.md +73 -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/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/router.js +4 -4
- 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
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +6 -0
- package/src/report/.agentic-security/threat-model.json +7 -0
- package/src/report/.agentic-security/threat-model.md +22 -0
- package/src/report/index.js +1 -1
|
@@ -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 };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Dep-add interception — validate a package about to be installed before
|
|
2
|
+
// it lands in node_modules / site-packages / etc.
|
|
3
|
+
//
|
|
4
|
+
// Checks:
|
|
5
|
+
// 1. Is the package known-malicious? (OSV malicious-packages catalog)
|
|
6
|
+
// 2. Is the package yanked / unpublished / withdrawn?
|
|
7
|
+
// 3. Was it published in the last 7 days? (typosquat-attack indicator)
|
|
8
|
+
// 4. Does the name closely match a popular package? (Levenshtein ≤ 2
|
|
9
|
+
// against a curated top-1000 list — typosquat risk)
|
|
10
|
+
// 5. Is the package on the project's SCA-policy.yml deny list?
|
|
11
|
+
//
|
|
12
|
+
// Backed by ~/.claude/agentic-security/osv-cache/ (already populated by
|
|
13
|
+
// the engine's SCA pass) plus a bundled top-popular-packages list
|
|
14
|
+
// from sca/popular-packages.json.
|
|
15
|
+
//
|
|
16
|
+
// Intended caller: hooks/pre-bash-guard.js when it spots `npm install <pkg>`,
|
|
17
|
+
// `yarn add`, `pnpm add`, `pip install`, `cargo add`, `gem install` etc.
|
|
18
|
+
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const CACHE = path.join(process.env.HOME || '/tmp', '.claude', 'agentic-security', 'osv-cache');
|
|
23
|
+
const TYPOSQUAT_LEVENSHTEIN = 2;
|
|
24
|
+
const NEW_PACKAGE_WINDOW_DAYS = 7;
|
|
25
|
+
|
|
26
|
+
function _osvLookup(ecosystem, name) {
|
|
27
|
+
const fp = path.join(CACHE, ecosystem, `${name}.json`);
|
|
28
|
+
if (!fs.existsSync(fp)) return null;
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _levenshtein(a, b) {
|
|
33
|
+
if (a === b) return 0;
|
|
34
|
+
const al = a.length, bl = b.length;
|
|
35
|
+
if (!al || !bl) return Math.max(al, bl);
|
|
36
|
+
const v0 = new Array(bl + 1);
|
|
37
|
+
for (let i = 0; i <= bl; i++) v0[i] = i;
|
|
38
|
+
for (let i = 0; i < al; i++) {
|
|
39
|
+
let v1 = i + 1;
|
|
40
|
+
for (let j = 0; j < bl; j++) {
|
|
41
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
42
|
+
const ins = v1 + 1;
|
|
43
|
+
const del = v0[j + 1] + 1;
|
|
44
|
+
const sub = v0[j] + cost;
|
|
45
|
+
const next = Math.min(ins, del, sub);
|
|
46
|
+
v0[j] = v1;
|
|
47
|
+
v1 = next;
|
|
48
|
+
}
|
|
49
|
+
v0[bl] = v1;
|
|
50
|
+
}
|
|
51
|
+
return v0[bl];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _loadPopular(ecosystem) {
|
|
55
|
+
try {
|
|
56
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
57
|
+
const fp = path.resolve(here, '..', 'sca', 'popular-packages.json');
|
|
58
|
+
const all = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
59
|
+
return all[ecosystem] || [];
|
|
60
|
+
} catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _loadPolicy(scanRoot) {
|
|
64
|
+
const fp = path.join(scanRoot, '.agentic-security', 'sca-policy.yml');
|
|
65
|
+
if (!fs.existsSync(fp)) return { deny: [] };
|
|
66
|
+
try {
|
|
67
|
+
const body = fs.readFileSync(fp, 'utf8');
|
|
68
|
+
const names = [];
|
|
69
|
+
const lines = body.split('\n');
|
|
70
|
+
let inBlock = false;
|
|
71
|
+
let blockIndent = -1;
|
|
72
|
+
for (const ln of lines) {
|
|
73
|
+
if (/^deny\s*:/.test(ln)) { inBlock = true; blockIndent = -1; continue; }
|
|
74
|
+
if (!inBlock) continue;
|
|
75
|
+
if (!ln.trim()) continue;
|
|
76
|
+
const m = ln.match(/^(\s+)-\s+(.*)$/);
|
|
77
|
+
if (!m) {
|
|
78
|
+
if (!/^\s+/.test(ln)) inBlock = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const indent = m[1].length;
|
|
82
|
+
if (blockIndent < 0) blockIndent = indent;
|
|
83
|
+
if (indent < blockIndent) { inBlock = false; continue; }
|
|
84
|
+
const val = m[2].trim();
|
|
85
|
+
// Two shapes: - name: foo OR - foo
|
|
86
|
+
const nameMatch = val.match(/^name\s*:\s*['"]?([^'"#\s]+)/);
|
|
87
|
+
if (nameMatch) names.push(nameMatch[1]);
|
|
88
|
+
else if (!/:/.test(val)) names.push(val.replace(/^['"]|['"]$/g, ''));
|
|
89
|
+
}
|
|
90
|
+
return { deny: names };
|
|
91
|
+
} catch { return { deny: [] }; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Inspect a single package before install. Returns
|
|
96
|
+
* { decision: 'allow' | 'review' | 'deny', reasons: [...] }
|
|
97
|
+
*/
|
|
98
|
+
export function inspectPackage({ ecosystem, name, scanRoot }) {
|
|
99
|
+
const reasons = [];
|
|
100
|
+
let decision = 'allow';
|
|
101
|
+
|
|
102
|
+
// 1. Project deny list.
|
|
103
|
+
if (scanRoot) {
|
|
104
|
+
const policy = _loadPolicy(scanRoot);
|
|
105
|
+
if (policy.deny.includes(name)) {
|
|
106
|
+
reasons.push(`Project sca-policy.yml lists ${name} in deny`);
|
|
107
|
+
decision = 'deny';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. OSV malicious / yanked status from the disk cache.
|
|
112
|
+
const osv = _osvLookup(ecosystem, name);
|
|
113
|
+
if (osv) {
|
|
114
|
+
if (Array.isArray(osv.vulns)) {
|
|
115
|
+
const mal = osv.vulns.filter(v => /malicious/i.test(JSON.stringify(v.aliases || []).concat(JSON.stringify(v.id || ''))) ||
|
|
116
|
+
/MAL-/.test(v.id || ''));
|
|
117
|
+
if (mal.length) {
|
|
118
|
+
reasons.push(`OSV catalog marks ${name} as malicious (${mal.map(v => v.id).join(', ')})`);
|
|
119
|
+
decision = 'deny';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (osv.withdrawn || osv.yanked) {
|
|
123
|
+
reasons.push(`${name} is withdrawn / yanked from registry`);
|
|
124
|
+
if (decision === 'allow') decision = 'review';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 3. New package (potential typosquat).
|
|
129
|
+
if (osv && osv.published) {
|
|
130
|
+
const ageMs = Date.now() - new Date(osv.published).getTime();
|
|
131
|
+
const ageDays = ageMs / 86400000;
|
|
132
|
+
if (ageDays < NEW_PACKAGE_WINDOW_DAYS) {
|
|
133
|
+
reasons.push(`${name} published ${Math.round(ageDays)} day(s) ago — fresh-package risk`);
|
|
134
|
+
if (decision === 'allow') decision = 'review';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. Typosquat distance.
|
|
139
|
+
const popular = _loadPopular(ecosystem);
|
|
140
|
+
if (popular.length) {
|
|
141
|
+
const closest = popular
|
|
142
|
+
.map(p => ({ p, d: _levenshtein(name.toLowerCase(), p.toLowerCase()) }))
|
|
143
|
+
.filter(x => x.d > 0 && x.d <= TYPOSQUAT_LEVENSHTEIN)
|
|
144
|
+
.sort((a, b) => a.d - b.d)[0];
|
|
145
|
+
if (closest) {
|
|
146
|
+
reasons.push(`Name is ${closest.d} edit(s) from popular package "${closest.p}" — typosquat risk`);
|
|
147
|
+
if (decision === 'allow') decision = 'review';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { decision, reasons };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse a shell command line to extract install requests. Returns
|
|
156
|
+
* [{ ecosystem, name }, ...] for every package that would be installed.
|
|
157
|
+
*/
|
|
158
|
+
export function parseInstallCommand(cmdline) {
|
|
159
|
+
if (!cmdline) return [];
|
|
160
|
+
const reqs = [];
|
|
161
|
+
// npm / yarn / pnpm
|
|
162
|
+
const npm = cmdline.match(/\b(?:npm\s+install|yarn\s+add|pnpm\s+add)\s+([^\s|;&]+(?:\s+[^\s|;&]+)*)/);
|
|
163
|
+
if (npm) {
|
|
164
|
+
for (const tok of npm[1].split(/\s+/)) {
|
|
165
|
+
if (tok.startsWith('-')) continue; // flags
|
|
166
|
+
if (tok.startsWith('@types/')) continue; // type defs are low risk
|
|
167
|
+
const name = tok.replace(/@[\d.^~*<>=].*$/, '').replace(/@latest$/, '');
|
|
168
|
+
if (name) reqs.push({ ecosystem: 'npm', name });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// pip
|
|
172
|
+
const pip = cmdline.match(/\bpip\s+install\s+([^\s|;&]+(?:\s+[^\s|;&]+)*)/);
|
|
173
|
+
if (pip) {
|
|
174
|
+
for (const tok of pip[1].split(/\s+/)) {
|
|
175
|
+
if (tok.startsWith('-') || tok.startsWith('git+') || tok.startsWith('http')) continue;
|
|
176
|
+
const name = tok.replace(/[<>=!~].*$/, '');
|
|
177
|
+
if (name && name !== '.') reqs.push({ ecosystem: 'pypi', name });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// gem install
|
|
181
|
+
const gem = cmdline.match(/\bgem\s+install\s+([^\s|;&]+(?:\s+[^\s|;&]+)*)/);
|
|
182
|
+
if (gem) {
|
|
183
|
+
for (const tok of gem[1].split(/\s+/)) {
|
|
184
|
+
if (tok.startsWith('-')) continue;
|
|
185
|
+
reqs.push({ ecosystem: 'rubygems', name: tok });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// cargo add
|
|
189
|
+
const cargo = cmdline.match(/\bcargo\s+add\s+([^\s|;&]+)/);
|
|
190
|
+
if (cargo) reqs.push({ ecosystem: 'cargo', name: cargo[1].split('@')[0] });
|
|
191
|
+
// go get
|
|
192
|
+
const goget = cmdline.match(/\bgo\s+get\s+([^\s|;&]+)/);
|
|
193
|
+
if (goget) reqs.push({ ecosystem: 'golang', name: goget[1].split('@')[0] });
|
|
194
|
+
return reqs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const _internals = { _levenshtein, _osvLookup, _loadPopular, _loadPolicy };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Findings memory — natural-language Q&A over the institutional knowledge
|
|
2
|
+
// the scanner has accumulated. Backs the MCP query_findings_memory tool.
|
|
3
|
+
//
|
|
4
|
+
// Sources searched, in this order:
|
|
5
|
+
//
|
|
6
|
+
// 1. .agentic-security/last-scan.json current findings
|
|
7
|
+
// 2. .agentic-security/triage-memory.jsonl past wont-fix / FP decisions
|
|
8
|
+
// 3. .agentic-security/scan-history/*.json prior scans
|
|
9
|
+
// 4. .agentic-security/AGENTS.md continual-learning narrative
|
|
10
|
+
//
|
|
11
|
+
// Naive keyword matching for v1. Each match has a `score` (count of query
|
|
12
|
+
// terms matched) and a `source` ('finding' | 'triage' | 'history' |
|
|
13
|
+
// 'agents-md'). Returns top-10 by score.
|
|
14
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const STATE = '.agentic-security';
|
|
19
|
+
|
|
20
|
+
function _read(scanRoot, name) {
|
|
21
|
+
try { return fs.readFileSync(path.join(scanRoot, STATE, name), 'utf8'); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _readJson(scanRoot, name) {
|
|
25
|
+
const raw = _read(scanRoot, name);
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _terms(query) {
|
|
31
|
+
return String(query || '').toLowerCase().split(/\s+/).filter(t => t.length >= 2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _score(haystack, terms) {
|
|
35
|
+
const lower = String(haystack || '').toLowerCase();
|
|
36
|
+
let s = 0;
|
|
37
|
+
for (const t of terms) if (lower.includes(t)) s++;
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _findingHaystack(f) {
|
|
42
|
+
return [f.vuln, f.family, f.file, f.severity, f.description, f.cwe, f.id]
|
|
43
|
+
.filter(Boolean).join(' | ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _truncate(s, n = 160) {
|
|
47
|
+
return String(s || '').replace(/\s+/g, ' ').slice(0, n);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run a natural-language query over the scanner's accumulated memory.
|
|
52
|
+
*/
|
|
53
|
+
export function queryFindingsMemory(scanRoot, query) {
|
|
54
|
+
const terms = _terms(query);
|
|
55
|
+
if (!terms.length) return { results: [], count: 0 };
|
|
56
|
+
|
|
57
|
+
const results = [];
|
|
58
|
+
|
|
59
|
+
// 1. Current findings.
|
|
60
|
+
const scan = _readJson(scanRoot, 'last-scan.json');
|
|
61
|
+
if (scan && Array.isArray(scan.findings)) {
|
|
62
|
+
for (const f of scan.findings) {
|
|
63
|
+
const hay = _findingHaystack(f);
|
|
64
|
+
const score = _score(hay, terms);
|
|
65
|
+
if (!score) continue;
|
|
66
|
+
results.push({
|
|
67
|
+
source: 'finding',
|
|
68
|
+
score,
|
|
69
|
+
finding_id: f.id || null,
|
|
70
|
+
severity: f.severity,
|
|
71
|
+
family: f.family,
|
|
72
|
+
file: f.file,
|
|
73
|
+
line: f.line,
|
|
74
|
+
snippet: _truncate(f.vuln || f.description || f.family),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Triage memory (past decisions).
|
|
80
|
+
const triageRaw = _read(scanRoot, 'triage-memory.jsonl');
|
|
81
|
+
if (triageRaw) {
|
|
82
|
+
const lines = triageRaw.split('\n').filter(Boolean);
|
|
83
|
+
for (const ln of lines) {
|
|
84
|
+
let entry; try { entry = JSON.parse(ln); } catch { continue; }
|
|
85
|
+
const hay = [entry.decision, entry.reason, entry.family, entry.vuln, entry.file].join(' ');
|
|
86
|
+
const score = _score(hay, terms);
|
|
87
|
+
if (!score) continue;
|
|
88
|
+
results.push({
|
|
89
|
+
source: 'triage',
|
|
90
|
+
score,
|
|
91
|
+
decision: entry.decision,
|
|
92
|
+
at: entry.at,
|
|
93
|
+
family: entry.family,
|
|
94
|
+
snippet: _truncate(entry.reason || entry.vuln),
|
|
95
|
+
bucket: entry.bucket,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Scan history.
|
|
101
|
+
try {
|
|
102
|
+
const histDir = path.join(scanRoot, STATE, 'scan-history');
|
|
103
|
+
if (fs.existsSync(histDir)) {
|
|
104
|
+
const files = fs.readdirSync(histDir).filter(f => f.endsWith('.json')).slice(-10);
|
|
105
|
+
for (const f of files) {
|
|
106
|
+
try {
|
|
107
|
+
const hist = JSON.parse(fs.readFileSync(path.join(histDir, f), 'utf8'));
|
|
108
|
+
if (!Array.isArray(hist.findings)) continue;
|
|
109
|
+
for (const x of hist.findings.slice(0, 50)) {
|
|
110
|
+
const hay = _findingHaystack(x);
|
|
111
|
+
const score = _score(hay, terms);
|
|
112
|
+
if (!score) continue;
|
|
113
|
+
results.push({
|
|
114
|
+
source: 'history',
|
|
115
|
+
score,
|
|
116
|
+
from: f.replace(/\.json$/, ''),
|
|
117
|
+
severity: x.severity,
|
|
118
|
+
family: x.family,
|
|
119
|
+
file: x.file,
|
|
120
|
+
snippet: _truncate(x.vuln || x.description),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
|
|
128
|
+
// 4. AGENTS.md narrative.
|
|
129
|
+
const agents = _read(scanRoot, 'AGENTS.md');
|
|
130
|
+
if (agents) {
|
|
131
|
+
const sections = agents.split(/^##\s+/m);
|
|
132
|
+
for (const sec of sections) {
|
|
133
|
+
const score = _score(sec, terms);
|
|
134
|
+
if (!score) continue;
|
|
135
|
+
const title = sec.split('\n')[0] || '';
|
|
136
|
+
results.push({
|
|
137
|
+
source: 'agents-md',
|
|
138
|
+
score,
|
|
139
|
+
title: _truncate(title, 80),
|
|
140
|
+
snippet: _truncate(sec.replace(title, ''), 200),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Top-10 by score, ties broken by source priority (finding > triage >
|
|
146
|
+
// history > agents-md so live data wins).
|
|
147
|
+
const PRI = { finding: 4, triage: 3, history: 2, 'agents-md': 1 };
|
|
148
|
+
results.sort((a, b) => (b.score - a.score) || (PRI[b.source] - PRI[a.source]));
|
|
149
|
+
return { results: results.slice(0, 10), count: results.length };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const _internals = { _terms, _score, _findingHaystack };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Fix style mirror — find existing fix patterns in the repo for the
|
|
2
|
+
// security-fixer agent to mirror, so remediation matches house style
|
|
3
|
+
// rather than producing canned generic replacements.
|
|
4
|
+
//
|
|
5
|
+
// Strategy: for a given finding (family + file), look at sibling files
|
|
6
|
+
// in the same directory tree for instances of the canonical safe pattern
|
|
7
|
+
// for that family (e.g. parameterized queries for sqli). Return up to 5
|
|
8
|
+
// real examples the agent can reference.
|
|
9
|
+
//
|
|
10
|
+
// Cheap implementation — grep-style search via fs.readdirSync. No regex
|
|
11
|
+
// engine deps. v1 covers the canonical fix patterns for the 8 most-
|
|
12
|
+
// common families.
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const SAFE_PATTERNS = {
|
|
18
|
+
'sqli': ['\\.query\\([^,)]+,\\s*\\[', '\\.prepare\\(', '\\.execute\\([^,)]+,\\s*\\['],
|
|
19
|
+
'sql-injection': ['\\.query\\([^,)]+,\\s*\\[', '\\.prepare\\(', '\\.execute\\([^,)]+,\\s*\\['],
|
|
20
|
+
'xss': ['escapeHtml\\(', 'sanitize\\(', 'DOMPurify\\.', '\\bencodeHTML\\b'],
|
|
21
|
+
'command-injection': ['\\bexecFile\\(', '\\bspawn\\([^,)]+,\\s*\\['],
|
|
22
|
+
'path-traversal': ['path\\.resolve\\(', 'path\\.normalize\\(', 'startsWith\\(.*path\\.sep'],
|
|
23
|
+
'ssrf': ['allowlist\\.includes\\(', 'url\\.hostname'],
|
|
24
|
+
'crypto-weak-cipher':['createCipheriv\\(\\s*[\'"`]aes-256-gcm', 'createCipheriv\\(\\s*[\'"`]chacha20'],
|
|
25
|
+
'crypto-weak-hash': ['createHash\\(\\s*[\'"`]sha-?256', 'createHash\\(\\s*[\'"`]sha-?512', 'createHash\\(\\s*[\'"`]blake'],
|
|
26
|
+
'hardcoded-secret': ['process\\.env\\.[A-Z_]+', 'config\\.[a-z_]+'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.bench-cache', 'dist', 'build', 'coverage', '.next']);
|
|
30
|
+
const MAX_FILES = 200;
|
|
31
|
+
const MAX_EXAMPLES = 5;
|
|
32
|
+
const MAX_FILE_SIZE = 100_000;
|
|
33
|
+
|
|
34
|
+
function _siblings(scanRoot, file, maxDepth = 3) {
|
|
35
|
+
if (!file) return [];
|
|
36
|
+
const abs = path.isAbsolute(file) ? file : path.join(scanRoot, file);
|
|
37
|
+
const baseDir = path.dirname(abs);
|
|
38
|
+
// Walk upward maxDepth levels and collect files of the same extension.
|
|
39
|
+
const ext = path.extname(abs);
|
|
40
|
+
if (!ext) return [];
|
|
41
|
+
const candidates = [];
|
|
42
|
+
let cur = baseDir;
|
|
43
|
+
for (let d = 0; d < maxDepth; d++) {
|
|
44
|
+
if (!fs.existsSync(cur)) break;
|
|
45
|
+
_walkUp(cur, ext, candidates);
|
|
46
|
+
const parent = path.dirname(cur);
|
|
47
|
+
if (parent === cur) break;
|
|
48
|
+
cur = parent;
|
|
49
|
+
}
|
|
50
|
+
return candidates.slice(0, MAX_FILES).filter(p => p !== abs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _walkUp(dir, ext, out) {
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const p = path.join(dir, e.name);
|
|
58
|
+
if (e.isDirectory()) {
|
|
59
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
60
|
+
// Stop one level deep here; outer loop walks upward.
|
|
61
|
+
try {
|
|
62
|
+
const childEntries = fs.readdirSync(p, { withFileTypes: true });
|
|
63
|
+
for (const ce of childEntries) {
|
|
64
|
+
if (ce.isFile() && ce.name.endsWith(ext)) {
|
|
65
|
+
out.push(path.join(p, ce.name));
|
|
66
|
+
if (out.length >= MAX_FILES) return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (e.isFile() && e.name.endsWith(ext)) {
|
|
73
|
+
out.push(p);
|
|
74
|
+
if (out.length >= MAX_FILES) return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns up to 5 style-mirror examples for a finding's family. Each
|
|
81
|
+
* example is `{ file, line, snippet }`. The agent can quote these as
|
|
82
|
+
* "here's how this codebase already does it."
|
|
83
|
+
*/
|
|
84
|
+
export function findStyleExamples(scanRoot, finding) {
|
|
85
|
+
if (!finding || !finding.family) return [];
|
|
86
|
+
const patterns = SAFE_PATTERNS[finding.family] ||
|
|
87
|
+
SAFE_PATTERNS[String(finding.family).toLowerCase()] || null;
|
|
88
|
+
if (!patterns) return [];
|
|
89
|
+
const files = _siblings(scanRoot, finding.file || '');
|
|
90
|
+
const examples = [];
|
|
91
|
+
const patternRes = patterns.map(p => new RegExp(p));
|
|
92
|
+
|
|
93
|
+
for (const fp of files) {
|
|
94
|
+
if (examples.length >= MAX_EXAMPLES) break;
|
|
95
|
+
let content;
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(fp);
|
|
98
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
99
|
+
content = fs.readFileSync(fp, 'utf8');
|
|
100
|
+
} catch { continue; }
|
|
101
|
+
for (const re of patternRes) {
|
|
102
|
+
const m = re.exec(content);
|
|
103
|
+
if (!m) continue;
|
|
104
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
const snippet = lines.slice(Math.max(0, line - 2), Math.min(lines.length, line + 1)).join('\n').trim();
|
|
107
|
+
examples.push({
|
|
108
|
+
file: path.relative(scanRoot, fp),
|
|
109
|
+
line,
|
|
110
|
+
snippet: snippet.slice(0, 240),
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return examples;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const _internals = { SAFE_PATTERNS, _siblings };
|