blackbrake 0.1.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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +123 -0
- package/SECURITY.md +22 -0
- package/THIRD_PARTY_NOTICES +36 -0
- package/bin/blackbrake.mjs +163 -0
- package/package.json +47 -0
- package/src/cost/analyzer.mjs +114 -0
- package/src/cost/prices.mjs +26 -0
- package/src/load/inventory.mjs +184 -0
- package/src/run.mjs +13 -0
- package/src/secrets/audit.mjs +86 -0
- package/src/secrets/context.mjs +59 -0
- package/src/secrets/engine.mjs +121 -0
- package/src/transcripts.mjs +87 -0
- package/vendor/gitleaks.rules.json +4922 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Where the spend concentrates. Descriptive only: no savings estimates, no causal claims.
|
|
2
|
+
//
|
|
3
|
+
// Episode = one prompt written by the user plus all agent work until the next one. Subagent
|
|
4
|
+
// work is attributed to the episode that was open in the parent session when it started.
|
|
5
|
+
// Harness-injected messages (subagent hand-backs, skill bodies) do not open episodes.
|
|
6
|
+
import { isHarnessText } from '../transcripts.mjs';
|
|
7
|
+
import { priceFor, usageCost } from './prices.mjs';
|
|
8
|
+
|
|
9
|
+
const userPromptText = (msg) => {
|
|
10
|
+
if (msg?.role !== 'user') return null;
|
|
11
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [{ type: 'text', text: msg.content }];
|
|
12
|
+
if (blocks.some((b) => b?.type === 'tool_result')) return null;
|
|
13
|
+
const text = blocks.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').trim();
|
|
14
|
+
return text && !isHarnessText(text) ? text : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createCostAnalyzer() {
|
|
18
|
+
const sessions = new Map(); // session -> { project, episodes: [], turns, minInput, crSum, cost }
|
|
19
|
+
const subagentRuns = []; // { session, startTs, cost, turns, tools }
|
|
20
|
+
const unknownModels = new Set();
|
|
21
|
+
let file = null;
|
|
22
|
+
let current = null; // current file accumulator
|
|
23
|
+
let episode = null;
|
|
24
|
+
|
|
25
|
+
const session = (id, project) => {
|
|
26
|
+
if (!sessions.has(id)) sessions.set(id, { id, project, episodes: [], turns: 0, minInput: Infinity, crSum: 0, cost: 0 });
|
|
27
|
+
return sessions.get(id);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
onFile(info) {
|
|
32
|
+
file = info;
|
|
33
|
+
episode = null;
|
|
34
|
+
current = info.isSubagent ? { session: info.session, startTs: null, cost: 0, turns: 0, tools: 0 } : null;
|
|
35
|
+
if (current) subagentRuns.push(current);
|
|
36
|
+
else session(info.session, info.project);
|
|
37
|
+
},
|
|
38
|
+
onRecord(record) {
|
|
39
|
+
const msg = record.message;
|
|
40
|
+
if (!msg) return;
|
|
41
|
+
const ts = record.timestamp ? Date.parse(record.timestamp) : null;
|
|
42
|
+
if (current) {
|
|
43
|
+
if (current.startTs === null && ts) current.startTs = ts;
|
|
44
|
+
if (msg.role === 'assistant' && msg.usage) {
|
|
45
|
+
current.cost += usageCost(msg.usage, msg.model);
|
|
46
|
+
current.turns++;
|
|
47
|
+
current.tools += (Array.isArray(msg.content) ? msg.content : []).filter((b) => b?.type === 'tool_use').length;
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const s = session(file.session, file.project);
|
|
52
|
+
const prompt = userPromptText(msg);
|
|
53
|
+
if (prompt) {
|
|
54
|
+
episode = { session: s.id, project: s.project, start: ts, end: ts, cost: 0, turns: 0, tools: 0, subagents: 0 };
|
|
55
|
+
s.episodes.push(episode);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (msg.role !== 'assistant' || !msg.usage) return;
|
|
59
|
+
const u = msg.usage;
|
|
60
|
+
const p = priceFor(msg.model);
|
|
61
|
+
// "<synthetic>" marks messages generated locally by the harness; they carry no cost.
|
|
62
|
+
if (!p.known && msg.model && !msg.model.startsWith('<')) unknownModels.add(msg.model);
|
|
63
|
+
const c = usageCost(u, msg.model);
|
|
64
|
+
const input = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
65
|
+
s.turns++;
|
|
66
|
+
s.cost += c;
|
|
67
|
+
s.crSum += p.cr;
|
|
68
|
+
if (input > 0 && input < s.minInput) s.minInput = input;
|
|
69
|
+
if (!episode) { episode = { session: s.id, project: s.project, start: ts, end: ts, cost: 0, turns: 0, tools: 0, subagents: 0 }; s.episodes.push(episode); }
|
|
70
|
+
episode.cost += c;
|
|
71
|
+
episode.turns++;
|
|
72
|
+
episode.end = ts ?? episode.end;
|
|
73
|
+
episode.tools += (Array.isArray(msg.content) ? msg.content : []).filter((b) => b?.type === 'tool_use').length;
|
|
74
|
+
},
|
|
75
|
+
finish() {
|
|
76
|
+
// Attribute each subagent run to the parent episode open when it started.
|
|
77
|
+
let unattributed = 0;
|
|
78
|
+
for (const run of subagentRuns) {
|
|
79
|
+
const eps = sessions.get(run.session)?.episodes ?? [];
|
|
80
|
+
const target = [...eps].reverse().find((e) => e.start !== null && run.startTs !== null && e.start <= run.startTs);
|
|
81
|
+
if (target) { target.cost += run.cost; target.tools += run.tools; target.subagents++; } else unattributed += run.cost;
|
|
82
|
+
}
|
|
83
|
+
const episodes = [...sessions.values()].flatMap((s) => s.episodes).filter((e) => e.turns > 0 || e.cost > 0);
|
|
84
|
+
const costs = episodes.map((e) => e.cost).sort((a, b) => a - b);
|
|
85
|
+
const total = costs.reduce((a, b) => a + b, 0) + unattributed;
|
|
86
|
+
const q = (p) => (costs.length ? costs[Math.min(costs.length - 1, Math.floor(p * costs.length))] : 0);
|
|
87
|
+
const topN = Math.max(1, Math.round(episodes.length * 0.1));
|
|
88
|
+
const topShare = total ? costs.slice(-topN).reduce((a, b) => a + b, 0) / total : 0;
|
|
89
|
+
const floors = [...sessions.values()].filter((s) => s.turns >= 3 && Number.isFinite(s.minInput));
|
|
90
|
+
const floorCost = floors.reduce((a, s) => a + (s.minInput * s.crSum) / 1e6, 0);
|
|
91
|
+
const mainCost = floors.reduce((a, s) => a + s.cost, 0);
|
|
92
|
+
const sortedFloors = floors.map((s) => s.minInput).sort((a, b) => a - b);
|
|
93
|
+
return {
|
|
94
|
+
total,
|
|
95
|
+
episodes: episodes.length,
|
|
96
|
+
p50: q(0.5),
|
|
97
|
+
p90: q(0.9),
|
|
98
|
+
topShare,
|
|
99
|
+
topN,
|
|
100
|
+
worst: [...episodes].sort((a, b) => b.cost - a.cost).slice(0, 3).map((e) => ({
|
|
101
|
+
cost: e.cost, turns: e.turns, tools: e.tools, subagents: e.subagents, project: e.project,
|
|
102
|
+
date: e.start ? new Date(e.start).toISOString().slice(0, 10) : null,
|
|
103
|
+
hours: e.start && e.end ? (e.end - e.start) / 3.6e6 : null,
|
|
104
|
+
})),
|
|
105
|
+
floor: {
|
|
106
|
+
sessions: floors.length,
|
|
107
|
+
medianTokens: sortedFloors.length ? sortedFloors[Math.floor(sortedFloors.length / 2)] : 0,
|
|
108
|
+
share: mainCost ? floorCost / mainCost : 0,
|
|
109
|
+
},
|
|
110
|
+
unknownModels: [...unknownModels],
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// API list prices in USD per million tokens, from Anthropic's pricing page
|
|
2
|
+
// (https://docs.anthropic.com/en/docs/about-claude/pricing), checked on the date below.
|
|
3
|
+
// cw = 5-minute cache write. For subscription plans this is a common unit of effort, not a bill.
|
|
4
|
+
export const PRICES_DATE = '2026-09-23';
|
|
5
|
+
|
|
6
|
+
const TABLE = [
|
|
7
|
+
[/fable-5-1|mythos-5-1/i, { in: 10, cw: 12.5, cr: 0.25, out: 50 }],
|
|
8
|
+
[/fable-5|mythos-5/i, { in: 10, cw: 12.5, cr: 1, out: 50 }],
|
|
9
|
+
[/opus-4-1|opus-4-2025|opus-4-0|claude-opus-4$/i, { in: 15, cw: 18.75, cr: 1.5, out: 75 }],
|
|
10
|
+
[/opus/i, { in: 5, cw: 6.25, cr: 0.5, out: 25 }], // Opus 4.5 and later
|
|
11
|
+
[/sonnet-5/i, { in: 2, cw: 2.5, cr: 0.2, out: 10 }],
|
|
12
|
+
[/sonnet/i, { in: 3, cw: 3.75, cr: 0.3, out: 15 }],
|
|
13
|
+
[/haiku-3/i, { in: 0.8, cw: 1, cr: 0.08, out: 4 }],
|
|
14
|
+
[/haiku/i, { in: 1, cw: 1.25, cr: 0.1, out: 5 }],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function priceFor(model = '') {
|
|
18
|
+
const hit = TABLE.find(([re]) => re.test(model));
|
|
19
|
+
return hit ? { ...hit[1], known: true } : { in: 3, cw: 3.75, cr: 0.3, out: 15, known: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function usageCost(u, model) {
|
|
23
|
+
const p = priceFor(model);
|
|
24
|
+
return ((u.input_tokens || 0) * p.in + (u.output_tokens || 0) * p.out
|
|
25
|
+
+ (u.cache_creation_input_tokens || 0) * p.cw + (u.cache_read_input_tokens || 0) * p.cr) / 1e6;
|
|
26
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// What the agent loads: skills, agents, commands, plugins, hooks and MCP servers, how much of it
|
|
2
|
+
// is paid for on every turn, how much is ever used, and static patterns worth a look.
|
|
3
|
+
// Static only: nothing found here is ever executed (unlike scanners that start MCP servers).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
|
|
9
|
+
const readText = (f, max = 256 * 1024) => {
|
|
10
|
+
try { const st = fs.statSync(f); if (!st.isFile() || st.size > max) return null; return fs.readFileSync(f, 'utf8'); } catch { return null; }
|
|
11
|
+
};
|
|
12
|
+
const listDir = (d) => { try { return fs.readdirSync(d, { withFileTypes: true }); } catch { return []; } };
|
|
13
|
+
|
|
14
|
+
// Frontmatter "name" and "description" (single line, or folded/literal block).
|
|
15
|
+
export function frontmatter(text) {
|
|
16
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text ?? '');
|
|
17
|
+
if (!m) return {};
|
|
18
|
+
const out = {};
|
|
19
|
+
const lines = m[1].split(/\r?\n/);
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(lines[i]);
|
|
22
|
+
if (!kv) continue;
|
|
23
|
+
let value = kv[2].trim();
|
|
24
|
+
if (/^[|>][-+]?$/.test(value)) {
|
|
25
|
+
const block = [];
|
|
26
|
+
while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) block.push(lines[++i].trim());
|
|
27
|
+
value = block.join(' ');
|
|
28
|
+
}
|
|
29
|
+
out[kv[1]] = value.replace(/^["']|["']$/g, '');
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Rough token estimate (~4 characters per token for English text).
|
|
35
|
+
const tokens = (s) => Math.ceil((s ?? '').length / 4);
|
|
36
|
+
|
|
37
|
+
function collectItems(dir, kind, source) {
|
|
38
|
+
const items = [];
|
|
39
|
+
for (const e of listDir(dir)) {
|
|
40
|
+
const full = path.join(dir, e.name);
|
|
41
|
+
if (kind === 'skill' && e.isDirectory()) {
|
|
42
|
+
const md = path.join(full, 'SKILL.md');
|
|
43
|
+
const text = readText(md);
|
|
44
|
+
if (text === null) continue;
|
|
45
|
+
const fm = frontmatter(text);
|
|
46
|
+
const name = fm.name ?? e.name;
|
|
47
|
+
items.push({ kind, source, name, dir: full, file: md, perTurnTokens: tokens(`${name}: ${fm.description ?? ''}`) });
|
|
48
|
+
} else if ((kind === 'agent' || kind === 'command') && e.isFile() && e.name.endsWith('.md')) {
|
|
49
|
+
const text = readText(full);
|
|
50
|
+
if (text === null) continue;
|
|
51
|
+
const fm = frontmatter(text);
|
|
52
|
+
const name = fm.name ?? e.name.replace(/\.md$/, '');
|
|
53
|
+
items.push({ kind, source, name, dir: null, file: full, perTurnTokens: kind === 'agent' ? tokens(`${name}: ${fm.description ?? ''}`) : 0 });
|
|
54
|
+
} else if (kind === 'command' && e.isDirectory()) {
|
|
55
|
+
items.push(...collectItems(full, kind, source));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return items;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pluginInstalls(claudeDir, settings) {
|
|
62
|
+
const installed = readJson(path.join(claudeDir, 'plugins', 'installed_plugins.json'));
|
|
63
|
+
const enabled = settings?.enabledPlugins ?? {};
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const [id, entries] of Object.entries(installed?.plugins ?? {})) {
|
|
66
|
+
const entry = Array.isArray(entries) ? entries[0] : entries;
|
|
67
|
+
if (!entry?.installPath) continue;
|
|
68
|
+
out.push({ id, path: entry.installPath, enabled: enabled[id] === true, version: entry.version ?? null });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mcpServers(home, claudeJson, pluginList) {
|
|
74
|
+
const servers = [];
|
|
75
|
+
const add = (obj, source) => {
|
|
76
|
+
for (const [name, cfg] of Object.entries(obj ?? {})) servers.push({ name, source, command: [cfg?.command, ...(cfg?.args ?? [])].filter(Boolean).join(' ') || cfg?.url || '' });
|
|
77
|
+
};
|
|
78
|
+
add(claudeJson?.mcpServers, 'user');
|
|
79
|
+
for (const [proj, p] of Object.entries(claudeJson?.projects ?? {})) add(p?.mcpServers, `project:${path.basename(proj)}`);
|
|
80
|
+
for (const pl of pluginList.filter((p) => p.enabled)) add(readJson(path.join(pl.path, '.mcp.json'))?.mcpServers ?? readJson(path.join(pl.path, '.mcp.json')), `plugin:${pl.id}`);
|
|
81
|
+
return servers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------------------------------------
|
|
85
|
+
// Static patterns. A match is "worth a look", not a verdict: security skills legitimately describe
|
|
86
|
+
// these patterns in their documentation. Matches in executable files and hooks weigh more than
|
|
87
|
+
// matches in Markdown.
|
|
88
|
+
const PATTERNS = [
|
|
89
|
+
{ id: 'remote-exec', label: 'downloads and runs code', re: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z)?sh\b|\b(iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^\n|]{0,200}\|\s*(iex|Invoke-Expression)\b/i },
|
|
90
|
+
{ id: 'instruction-override', label: 'tells the agent to ignore rules or hide actions', re: /ignore (all )?(previous|prior|above) instructions|do not (tell|inform|mention (this )?to) the user|without (asking|telling|notifying) the user|hide (this|it) from the user/i },
|
|
91
|
+
{ id: 'permission-bypass', label: 'disables permission checks', re: /--dangerously-skip-permissions|bypassPermissions|skipDangerousModePermissionPrompt/ },
|
|
92
|
+
{ id: 'credential-access', label: 'reads credential files', re: /~\/\.ssh\/|id_rsa\b|\.aws\/credentials|\.claude\/\.credentials\.json|\.git-credentials|\.npmrc\b/ },
|
|
93
|
+
{ id: 'exfiltration', label: 'sends environment or secrets over the network', re: /\b(curl|wget|fetch|requests\.(post|put)|Invoke-WebRequest|Invoke-RestMethod)\b[^\n]{0,160}(\$\{?\w*(TOKEN|SECRET|KEY|PASSWORD)|process\.env|os\.environ|printenv|\benv\b\s*\|)/i },
|
|
94
|
+
{ id: 'obfuscation', label: 'decodes and runs hidden content', re: /base64\s+(-d|--decode)[^\n]{0,80}\|\s*(ba)?sh|eval\s*\(\s*(atob|Buffer\.from)\s*\(|[A-Za-z0-9+/]{400,}={0,2}/ },
|
|
95
|
+
];
|
|
96
|
+
const CODE_EXT = /\.(sh|bash|zsh|ps1|py|js|mjs|cjs|ts|rb|json)$/i;
|
|
97
|
+
const SCAN_EXT = /\.(md|sh|bash|zsh|ps1|py|js|mjs|cjs|ts|rb|json|toml|ya?ml)$/i;
|
|
98
|
+
|
|
99
|
+
function scanFiles(files, owner) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
const text = readText(f);
|
|
103
|
+
if (text === null) continue;
|
|
104
|
+
for (const p of PATTERNS) {
|
|
105
|
+
if (p.re.test(text)) findings.push({ owner, patternId: p.id, label: p.label, file: f, inCode: CODE_EXT.test(f) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function filesUnder(dir, depth = 3) {
|
|
112
|
+
if (!dir || depth < 0) return [];
|
|
113
|
+
return listDir(dir).flatMap((e) => {
|
|
114
|
+
const full = path.join(dir, e.name);
|
|
115
|
+
if (e.isDirectory()) return e.name === 'node_modules' || e.name.startsWith('.git') ? [] : filesUnder(full, depth - 1);
|
|
116
|
+
return SCAN_EXT.test(e.name) ? [full] : [];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function inventory({ home = os.homedir() } = {}) {
|
|
121
|
+
const claudeDir = path.join(home, '.claude');
|
|
122
|
+
const settings = readJson(path.join(claudeDir, 'settings.json')) ?? {};
|
|
123
|
+
const claudeJson = readJson(path.join(home, '.claude.json')) ?? {};
|
|
124
|
+
const plugins = pluginInstalls(claudeDir, settings);
|
|
125
|
+
|
|
126
|
+
const items = [
|
|
127
|
+
...collectItems(path.join(claudeDir, 'skills'), 'skill', 'user'),
|
|
128
|
+
...collectItems(path.join(claudeDir, 'agents'), 'agent', 'user'),
|
|
129
|
+
...collectItems(path.join(claudeDir, 'commands'), 'command', 'user'),
|
|
130
|
+
];
|
|
131
|
+
for (const pl of plugins.filter((p) => p.enabled)) {
|
|
132
|
+
items.push(
|
|
133
|
+
...collectItems(path.join(pl.path, 'skills'), 'skill', `plugin:${pl.id}`),
|
|
134
|
+
...collectItems(path.join(pl.path, 'agents'), 'agent', `plugin:${pl.id}`),
|
|
135
|
+
...collectItems(path.join(pl.path, 'commands'), 'command', `plugin:${pl.id}`),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The same skill or agent can be installed twice (e.g. copied to ~/.claude and bundled in a
|
|
140
|
+
// plugin). Count each name once; report how many duplicates were found.
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const unique = [];
|
|
143
|
+
let duplicates = 0;
|
|
144
|
+
for (const i of items) {
|
|
145
|
+
const key = `${i.kind}:${i.name.toLowerCase()}`;
|
|
146
|
+
if (seen.has(key)) { duplicates++; continue; }
|
|
147
|
+
seen.add(key);
|
|
148
|
+
unique.push(i);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const usage = claudeJson.skillUsage ?? null;
|
|
152
|
+
const usedNames = new Set(Object.entries(usage ?? {}).filter(([, v]) => (v?.usageCount ?? 0) > 0).flatMap(([k]) => [k, k.split(':').pop()]));
|
|
153
|
+
const skills = unique.filter((i) => i.kind === 'skill');
|
|
154
|
+
const usedSkills = usage ? skills.filter((s) => usedNames.has(s.name) || usedNames.has(path.basename(s.dir))).length : null;
|
|
155
|
+
|
|
156
|
+
const risks = [
|
|
157
|
+
...unique.flatMap((i) => scanFiles(i.dir ? filesUnder(i.dir) : [i.file], `${i.kind}:${i.name}`)),
|
|
158
|
+
...plugins.filter((p) => p.enabled).flatMap((p) => scanFiles(filesUnder(path.join(p.path, 'hooks')), `plugin-hooks:${p.id}`)),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const hookEvents = Object.keys(settings.hooks ?? {});
|
|
162
|
+
return {
|
|
163
|
+
counts: {
|
|
164
|
+
skills: skills.length,
|
|
165
|
+
agents: unique.filter((i) => i.kind === 'agent').length,
|
|
166
|
+
commands: unique.filter((i) => i.kind === 'command').length,
|
|
167
|
+
pluginsEnabled: plugins.filter((p) => p.enabled).length,
|
|
168
|
+
pluginsInstalled: plugins.length,
|
|
169
|
+
duplicates,
|
|
170
|
+
},
|
|
171
|
+
perTurnTokens: {
|
|
172
|
+
skills: skills.reduce((a, s) => a + s.perTurnTokens, 0),
|
|
173
|
+
agents: unique.filter((i) => i.kind === 'agent').reduce((a, s) => a + s.perTurnTokens, 0),
|
|
174
|
+
},
|
|
175
|
+
usedSkills,
|
|
176
|
+
mcp: mcpServers(home, claudeJson, plugins),
|
|
177
|
+
posture: {
|
|
178
|
+
dangerousModePromptSkipped: settings.skipDangerousModePermissionPrompt === true,
|
|
179
|
+
defaultModeBypass: settings.permissions?.defaultMode === 'bypassPermissions',
|
|
180
|
+
hookEvents,
|
|
181
|
+
},
|
|
182
|
+
risks,
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Reads every transcript once and feeds each record to all analyzers.
|
|
2
|
+
import { describeFile, readTranscript } from './transcripts.mjs';
|
|
3
|
+
|
|
4
|
+
export async function runAnalyzers({ root, files, analyzers }) {
|
|
5
|
+
for (const file of files) {
|
|
6
|
+
const info = { file, ...describeFile(root, file) };
|
|
7
|
+
for (const a of analyzers) a.onFile(info);
|
|
8
|
+
for await (const { record } of readTranscript(file)) {
|
|
9
|
+
for (const a of analyzers) a.onRecord(record);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return analyzers.map((a) => a.finish());
|
|
13
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Exposure audit: which secrets are in the transcripts, how many copies each one has, where the
|
|
2
|
+
// copies live and how the secret first got in. Values are never kept in the report: only a hash,
|
|
3
|
+
// the rule id and a masked shape.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fragmentsOf } from '../transcripts.mjs';
|
|
6
|
+
import { classifyOccurrence, classifySecret, isTestPath } from './context.mjs';
|
|
7
|
+
import { scanText } from './engine.mjs';
|
|
8
|
+
|
|
9
|
+
export const mask = (s) => `${s.slice(0, 4)}…(${s.length})`;
|
|
10
|
+
const hash = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
|
11
|
+
|
|
12
|
+
const ORIGIN = {
|
|
13
|
+
user: 'pasted by you',
|
|
14
|
+
'tool-output:Read': 'read from a file by the agent',
|
|
15
|
+
'tool-output:Bash': 'printed by a command',
|
|
16
|
+
'tool-output:PowerShell': 'printed by a command',
|
|
17
|
+
'tool-output': 'returned by a tool',
|
|
18
|
+
'tool-input': 'written by the agent',
|
|
19
|
+
assistant: 'written by the agent',
|
|
20
|
+
snapshot: 'file snapshot',
|
|
21
|
+
harness: 'injected by the harness',
|
|
22
|
+
other: 'transcript metadata',
|
|
23
|
+
};
|
|
24
|
+
const originOf = (kind, tool) => ORIGIN[`${kind}:${tool}`] ?? ORIGIN[kind] ?? kind;
|
|
25
|
+
|
|
26
|
+
// Streaming analyzer: onFile() for each transcript, onRecord() for each line, finish() at the end.
|
|
27
|
+
export function createSecretsAnalyzer(rules) {
|
|
28
|
+
const secrets = new Map();
|
|
29
|
+
let file = null;
|
|
30
|
+
let toolCalls = new Map();
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
onFile(info) { file = info; toolCalls = new Map(); },
|
|
34
|
+
onRecord(record) {
|
|
35
|
+
// Claude Code mirrors tool results inside the same record. Identical strings are scanned
|
|
36
|
+
// once per record; their copies are still counted.
|
|
37
|
+
const scanned = new Map();
|
|
38
|
+
for (const frag of fragmentsOf(record, toolCalls)) {
|
|
39
|
+
let found = scanned.get(frag.text);
|
|
40
|
+
if (!found) { found = scanText(rules, frag.text); scanned.set(frag.text, found); }
|
|
41
|
+
for (const f of found) {
|
|
42
|
+
const key = hash(f.secret);
|
|
43
|
+
let s = secrets.get(key);
|
|
44
|
+
if (!s) {
|
|
45
|
+
s = { key, ruleId: f.ruleId, shape: mask(f.secret), copies: 0, where: {}, sessions: new Set(), projects: new Set(), subagentCopies: 0, first: null, classes: [], seenInTestFile: false };
|
|
46
|
+
secrets.set(key, s);
|
|
47
|
+
}
|
|
48
|
+
s.copies++;
|
|
49
|
+
const place = frag.kind === 'tool-output' || frag.kind === 'tool-input' ? `${frag.kind}${frag.tool ? `:${frag.tool}` : ''}` : frag.kind;
|
|
50
|
+
s.where[place] = (s.where[place] ?? 0) + 1;
|
|
51
|
+
s.sessions.add(file.session);
|
|
52
|
+
s.projects.add(file.project);
|
|
53
|
+
if (file.isSubagent) s.subagentCopies++;
|
|
54
|
+
if (isTestPath(frag.filePath)) s.seenInTestFile = true;
|
|
55
|
+
// Mirror copies stored in record metadata carry no context of their own: they count as
|
|
56
|
+
// copies but do not vote on whether the value is real.
|
|
57
|
+
if (frag.kind !== 'other') s.classes.push(classifyOccurrence({ secret: f.secret, text: frag.text, index: f.index, filePath: frag.filePath }));
|
|
58
|
+
const ts = frag.ts ? Date.parse(frag.ts) : Number.POSITIVE_INFINITY;
|
|
59
|
+
if (!s.first || ts < s.first.ts) s.first = { ts, origin: originOf(frag.kind, frag.tool) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
finish() {
|
|
64
|
+
return [...secrets.values()].map((s) => ({
|
|
65
|
+
key: s.key,
|
|
66
|
+
ruleId: s.ruleId,
|
|
67
|
+
shape: s.shape,
|
|
68
|
+
classification: classifySecret(s.classes, { seenInTestFile: s.seenInTestFile }),
|
|
69
|
+
copies: s.copies,
|
|
70
|
+
subagentCopies: s.subagentCopies,
|
|
71
|
+
sessions: s.sessions.size,
|
|
72
|
+
projects: [...s.projects],
|
|
73
|
+
where: s.where,
|
|
74
|
+
firstSeen: Number.isFinite(s.first.ts) ? new Date(s.first.ts).toISOString() : null,
|
|
75
|
+
origin: s.first.origin,
|
|
76
|
+
})).sort((a, b) => b.copies - a.copies);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convenience wrapper: run the secrets analyzer alone over a list of transcript files.
|
|
82
|
+
export async function auditSecrets({ root, files, rules }) {
|
|
83
|
+
const { runAnalyzers } = await import('../run.mjs');
|
|
84
|
+
const [secrets] = await runAnalyzers({ root, files, analyzers: [createSecretsAnalyzer(rules)] });
|
|
85
|
+
return secrets;
|
|
86
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Decides whether a detected secret looks real, or looks like a test fixture, an example or a
|
|
2
|
+
// local-development value. Without this step, a scanner confidently tells people to rotate keys
|
|
3
|
+
// that never existed (it happened on the author's own transcripts: 12 "real" secrets, 0 real).
|
|
4
|
+
|
|
5
|
+
// Publicly documented example credentials that appear in docs and tests everywhere. Assembled
|
|
6
|
+
// from parts so that this file does not itself trip secret scanners.
|
|
7
|
+
const KNOWN_EXAMPLES = new Set([
|
|
8
|
+
'AKIA' + 'IOSFODNN7EXAMPLE',
|
|
9
|
+
'wJalrXUtnFEMI/K7MDENG/' + 'bPxRfiCYEXAMPLEKEY',
|
|
10
|
+
'AKIA' + 'ABCDEFGHIJKLMNOP',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const EXAMPLE_WORDS = /\b(fake|falso|falsa|dummy|example|ejemplo|sample|placeholder|mock(ed)?|fixture|lorem|not[- ]a[- ]real|for testing|de prueba|test(ing)?[-_ ]?(key|token|secret)|sk_test_|pk_test_)\b/i;
|
|
14
|
+
const EXAMPLE_PATHS = /(^|[\\/])(tests?|__tests__|spec|specs|fixtures?|examples?|samples?|docs?|testdata|mocks?)([\\/]|$)|\.(example|sample|template|dist)(\b|$)|\.(test|spec)\.[a-z]+\b/i;
|
|
15
|
+
const LOCAL_HOSTS = /\b(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])\b/i;
|
|
16
|
+
// Test code around the value: assertions and test declarations.
|
|
17
|
+
const TEST_CODE = /\b(assert\w*|expect|describe|it|test)\s*\(/;
|
|
18
|
+
// The "secret" is itself source code (an expression), not a value.
|
|
19
|
+
const CODE_EXPRESSION = /\?\.|\|\||&&|=>|\(\)|\bprocess\.env\b|\$\{/;
|
|
20
|
+
|
|
21
|
+
export const isTestPath = (p) => Boolean(p) && EXAMPLE_PATHS.test(p);
|
|
22
|
+
const WINDOW = 200;
|
|
23
|
+
|
|
24
|
+
export const CLASSES = {
|
|
25
|
+
real: 'likely-real',
|
|
26
|
+
example: 'example-or-test',
|
|
27
|
+
local: 'local-dev',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Classifies one occurrence using the text around it and, when known, the file the agent was
|
|
31
|
+
// reading or writing.
|
|
32
|
+
export function classifyOccurrence({ secret, text, index, filePath }) {
|
|
33
|
+
if (KNOWN_EXAMPLES.has(secret)) return CLASSES.example;
|
|
34
|
+
if (/^(sk|pk|rk)_test_/.test(secret)) return CLASSES.example;
|
|
35
|
+
if (CODE_EXPRESSION.test(secret)) return CLASSES.example;
|
|
36
|
+
// A short value made only of letters ("password", "changeme", "secret") is a word, not a key.
|
|
37
|
+
if (/^["'`]?[A-Za-z_-]{1,16}["'`]?$/.test(secret)) return CLASSES.example;
|
|
38
|
+
const around = text.slice(Math.max(0, index - WINDOW), Math.min(text.length, index + secret.length + WINDOW));
|
|
39
|
+
if (EXAMPLE_WORDS.test(around) || TEST_CODE.test(around)) return CLASSES.example;
|
|
40
|
+
if ((filePath && EXAMPLE_PATHS.test(filePath)) || EXAMPLE_PATHS.test(around)) return CLASSES.example;
|
|
41
|
+
if (LOCAL_HOSTS.test(around)) return CLASSES.local;
|
|
42
|
+
return CLASSES.real;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A secret is only reported as likely real if most of its occurrences look real. A single
|
|
46
|
+
// example-looking mention is not enough to dismiss a key the user pasted; a single real-looking
|
|
47
|
+
// copy of a fixture that is labelled "fake" everywhere else is not enough to raise an alarm.
|
|
48
|
+
// A value that was ever written into, or read from, a test/fixture/example file is treated as a
|
|
49
|
+
// fixture: real credentials do not normally live in test files, and when they do, the scanner the
|
|
50
|
+
// project already runs on its repository is the right place to catch them.
|
|
51
|
+
export function classifySecret(occurrenceClasses, { seenInTestFile = false } = {}) {
|
|
52
|
+
if (seenInTestFile) return CLASSES.example;
|
|
53
|
+
const n = occurrenceClasses.length;
|
|
54
|
+
if (n === 0) return CLASSES.real;
|
|
55
|
+
const count = (c) => occurrenceClasses.filter((x) => x === c).length;
|
|
56
|
+
if (count(CLASSES.example) / n >= 1 / 3) return CLASSES.example;
|
|
57
|
+
if (count(CLASSES.local) / n >= 1 / 2) return CLASSES.local;
|
|
58
|
+
return CLASSES.real;
|
|
59
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Secret detection using the gitleaks default rule set (vendored, MIT). Semantics follow
|
|
2
|
+
// gitleaks: keyword prefilter, first capture group (or secretGroup) as the secret, Shannon
|
|
3
|
+
// entropy threshold, per-rule and global allowlists and stopwords.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const RULES_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../vendor/gitleaks.rules.json');
|
|
9
|
+
|
|
10
|
+
export function loadRules(file = RULES_FILE) {
|
|
11
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
12
|
+
const compile = (r) => new RegExp(r.source, r.flags.includes('g') ? r.flags : r.flags + 'g');
|
|
13
|
+
const compileTest = (r) => new RegExp(r.source, r.flags.replace('g', ''));
|
|
14
|
+
// One combined, case-insensitive search for every rule keyword. A fragment is only tested
|
|
15
|
+
// against the rules whose keywords it contains (gitleaks' own prefilter, done in one pass).
|
|
16
|
+
const byKeyword = new Map();
|
|
17
|
+
const alwaysRun = [];
|
|
18
|
+
data.rules.forEach((r, i) => {
|
|
19
|
+
if (!r.keywords.length) alwaysRun.push(i);
|
|
20
|
+
for (const k of r.keywords) (byKeyword.get(k) ?? byKeyword.set(k, []).get(k)).push(i);
|
|
21
|
+
});
|
|
22
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
const keywordRe = new RegExp([...byKeyword.keys()].sort((a, b) => b.length - a.length).map(escape).join('|'), 'gi');
|
|
24
|
+
return {
|
|
25
|
+
keywordRe,
|
|
26
|
+
byKeyword,
|
|
27
|
+
alwaysRun,
|
|
28
|
+
meta: { upstreamCommit: data.upstreamCommit, generated: data.generated, count: data.rules.length },
|
|
29
|
+
global: {
|
|
30
|
+
regexes: data.globalAllowlist.regexes.map(compileTest),
|
|
31
|
+
stopwords: data.globalAllowlist.stopwords,
|
|
32
|
+
},
|
|
33
|
+
rules: data.rules.map((r) => ({
|
|
34
|
+
id: r.id,
|
|
35
|
+
re: compile(r.regex),
|
|
36
|
+
keywords: r.keywords,
|
|
37
|
+
entropy: r.entropy,
|
|
38
|
+
secretGroup: r.secretGroup,
|
|
39
|
+
allowlists: r.allowlists.map((a) => ({
|
|
40
|
+
condition: a.condition,
|
|
41
|
+
target: a.regexTarget,
|
|
42
|
+
regexes: a.regexes.map(compileTest),
|
|
43
|
+
stopwords: a.stopwords,
|
|
44
|
+
})),
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function shannon(s) {
|
|
50
|
+
if (!s) return 0;
|
|
51
|
+
const counts = new Map();
|
|
52
|
+
for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
53
|
+
let h = 0;
|
|
54
|
+
for (const n of counts.values()) { const p = n / s.length; h -= p * Math.log2(p); }
|
|
55
|
+
return h;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function allowed(al, secret, match, line) {
|
|
59
|
+
const target = al.target === 'match' ? match : al.target === 'line' ? line : secret;
|
|
60
|
+
const hitRe = al.regexes.length > 0 && al.regexes.some((re) => re.test(target));
|
|
61
|
+
const lower = secret.toLowerCase();
|
|
62
|
+
const hitStop = al.stopwords.length > 0 && al.stopwords.some((w) => lower.includes(w));
|
|
63
|
+
if (al.condition === 'AND') {
|
|
64
|
+
const checks = [];
|
|
65
|
+
if (al.regexes.length) checks.push(hitRe);
|
|
66
|
+
if (al.stopwords.length) checks.push(hitStop);
|
|
67
|
+
return checks.length > 0 && checks.every(Boolean);
|
|
68
|
+
}
|
|
69
|
+
return hitRe || hitStop;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const lineAround = (text, index, len) => {
|
|
73
|
+
const start = text.lastIndexOf('\n', index) + 1;
|
|
74
|
+
const endNl = text.indexOf('\n', index + len);
|
|
75
|
+
return text.slice(start, endNl === -1 ? text.length : endNl);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Returns [{ ruleId, secret, index, length }]
|
|
79
|
+
function candidateRules(rules, text) {
|
|
80
|
+
const idx = new Set(rules.alwaysRun);
|
|
81
|
+
rules.keywordRe.lastIndex = 0;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = rules.keywordRe.exec(text)) !== null) {
|
|
84
|
+
for (const i of rules.byKeyword.get(m[0].toLowerCase()) ?? []) idx.add(i);
|
|
85
|
+
}
|
|
86
|
+
return [...idx].sort((a, b) => a - b).map((i) => rules.rules[i]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function scanText(rules, text) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const rule of candidateRules(rules, text)) {
|
|
92
|
+
rule.re.lastIndex = 0;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = rule.re.exec(text)) !== null) {
|
|
95
|
+
if (m[0].length === 0) { rule.re.lastIndex++; continue; }
|
|
96
|
+
const group = rule.secretGroup ?? (m.length > 1 ? m.findIndex((g, i) => i > 0 && g !== undefined) : 0);
|
|
97
|
+
const secret = (group > 0 ? m[group] : m[0]) ?? m[0];
|
|
98
|
+
if (!secret) continue;
|
|
99
|
+
if (rule.entropy !== null && shannon(secret) < rule.entropy) continue;
|
|
100
|
+
if (rules.global.regexes.some((re) => re.test(secret))) continue;
|
|
101
|
+
const lowerSecret = secret.toLowerCase();
|
|
102
|
+
if (rules.global.stopwords.some((w) => lowerSecret.includes(w))) continue;
|
|
103
|
+
const line = lineAround(text, m.index, m[0].length);
|
|
104
|
+
if (rule.allowlists.some((al) => allowed(al, secret, m[0], line))) continue;
|
|
105
|
+
out.push({ ruleId: rule.id, secret, index: m.index + Math.max(0, m[0].indexOf(secret)), length: secret.length });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return dedupeOverlaps(out);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// When two rules match the same span (e.g. a specific rule and generic-api-key), keep one.
|
|
112
|
+
function dedupeOverlaps(findings) {
|
|
113
|
+
findings.sort((a, b) => a.index - b.index || (a.ruleId === 'generic-api-key') - (b.ruleId === 'generic-api-key'));
|
|
114
|
+
const kept = [];
|
|
115
|
+
for (const f of findings) {
|
|
116
|
+
const prev = kept.at(-1);
|
|
117
|
+
if (prev && f.index < prev.index + prev.length && f.secret.includes(prev.secret.slice(0, 8))) continue;
|
|
118
|
+
kept.push(f);
|
|
119
|
+
}
|
|
120
|
+
return kept;
|
|
121
|
+
}
|