@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.
Files changed (68) hide show
  1. package/dist/178.index.js +1 -1
  2. package/dist/384.index.js +1 -1
  3. package/dist/637.index.js +1 -1
  4. package/dist/838.index.js +1 -1
  5. package/dist/839.index.js +170 -0
  6. package/dist/985.index.js +51 -1
  7. package/dist/agentic-security.mjs +83 -83
  8. package/dist/agentic-security.mjs.sha256 +1 -1
  9. package/package.json +3 -3
  10. package/src/.agentic-security/findings.json +21283 -8189
  11. package/src/.agentic-security/last-scan.json +21283 -8189
  12. package/src/.agentic-security/last-scan.json.sig +1 -1
  13. package/src/.agentic-security/scan-history.json +512 -128
  14. package/src/.agentic-security/streak.json +3 -3
  15. package/src/engine.js +41 -0
  16. package/src/mcp/.agentic-security/findings.json +4 -4
  17. package/src/mcp/.agentic-security/last-scan.json +4 -4
  18. package/src/mcp/.agentic-security/last-scan.json.sig +1 -1
  19. package/src/mcp/.agentic-security/scan-history.json +188 -0
  20. package/src/mcp/.agentic-security/streak.json +5 -5
  21. package/src/mcp/tools.js +51 -1
  22. package/src/posture/.agentic-security/dpia.md +26 -0
  23. package/src/posture/.agentic-security/findings.json +17234 -4057
  24. package/src/posture/.agentic-security/last-scan.json +17234 -4057
  25. package/src/posture/.agentic-security/last-scan.json.sig +1 -1
  26. package/src/posture/.agentic-security/pqc-migration-plan.json +65 -0
  27. package/src/posture/.agentic-security/pqc-migration-plan.md +30 -0
  28. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +6 -0
  29. package/src/posture/.agentic-security/scan-history.json +1942 -200
  30. package/src/posture/.agentic-security/streak.json +3 -3
  31. package/src/posture/.agentic-security/threat-model.json +2038 -0
  32. package/src/posture/.agentic-security/threat-model.md +73 -0
  33. package/src/posture/auditor-walkthrough.js +252 -0
  34. package/src/posture/claude-authorship.js +197 -0
  35. package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
  36. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
  37. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
  38. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
  39. package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
  40. package/src/posture/compliance-frameworks/ccpa.json +32 -0
  41. package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
  42. package/src/posture/compliance-frameworks/gdpr.json +45 -0
  43. package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
  44. package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
  45. package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
  46. package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
  47. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
  48. package/src/posture/cross-repo-memory.js +180 -0
  49. package/src/posture/dep-add-guard.js +197 -0
  50. package/src/posture/findings-memory.js +152 -0
  51. package/src/posture/fix-style-mirror.js +118 -0
  52. package/src/posture/git-history.js +141 -0
  53. package/src/posture/intent-context.js +175 -0
  54. package/src/posture/model-rescan.js +76 -0
  55. package/src/posture/pattern-propagation.js +39 -0
  56. package/src/posture/pr-augment.js +234 -0
  57. package/src/posture/risk-dollars.js +158 -0
  58. package/src/posture/router.js +4 -4
  59. package/src/posture/threat-model-grounding.js +169 -0
  60. package/src/posture/time-to-fix.js +129 -0
  61. package/src/posture/triage-memory.js +151 -0
  62. package/src/posture/triage.js +15 -1
  63. package/src/posture/watch-mode.js +171 -0
  64. package/src/posture/workflow-installer.js +231 -0
  65. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +6 -0
  66. package/src/report/.agentic-security/threat-model.json +7 -0
  67. package/src/report/.agentic-security/threat-model.md +22 -0
  68. 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 };