fullcourtdefense-cli 1.3.0 → 1.3.2

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.
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.scanAgentFiles = scanAgentFiles;
37
+ const crypto = __importStar(require("crypto"));
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const MAX_FILES = 150;
42
+ const MAX_FILE_BYTES = 512 * 1024;
43
+ const EXCERPT_LEN = 180;
44
+ const RISK_HEURISTICS = [
45
+ { re: /\b(?:shell|bash|exec|subprocess|terminal|run_command)\b/i, tag: 'allows_shell' },
46
+ { re: /\b(?:ignore (?:all )?(?:previous )?instructions|bypass|jailbreak|DAN mode)\b/i, tag: 'jailbreak_hint' },
47
+ { re: /\b(?:always allow|never block|disable security|skip validation)\b/i, tag: 'weak_guardrails' },
48
+ { re: /\b(?:read any file|full filesystem|\/etc\/passwd|sudo)\b/i, tag: 'broad_filesystem' },
49
+ { re: /\b(?:send email|post to slack|transfer funds|delete all)\b/i, tag: 'destructive_actions' },
50
+ { re: /\b(?:api[_-]?key|secret|password|token)\s*[:=]/i, tag: 'embedded_secret_ref' },
51
+ ];
52
+ function stableId(parts) {
53
+ return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
54
+ }
55
+ function readAgentFile(filePath) {
56
+ try {
57
+ const stat = fs.statSync(filePath);
58
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
59
+ return null;
60
+ const content = fs.readFileSync(filePath, 'utf-8');
61
+ return { content, sizeBytes: stat.size, lineCount: content.split(/\r?\n/).length };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function analyzeContent(content) {
68
+ const tags = new Set();
69
+ for (const rule of RISK_HEURISTICS) {
70
+ if (rule.re.test(content))
71
+ tags.add(rule.tag);
72
+ }
73
+ return [...tags];
74
+ }
75
+ function pushFinding(out, seen, input) {
76
+ if (out.length >= MAX_FILES)
77
+ return;
78
+ const id = stableId([input.filePath, input.kind]);
79
+ if (seen.has(id))
80
+ return;
81
+ seen.add(id);
82
+ const riskTags = input.riskTags || (input.content ? analyzeContent(input.content) : []);
83
+ const excerpt = input.content
84
+ ? input.content.replace(/\s+/g, ' ').trim().slice(0, EXCERPT_LEN)
85
+ : undefined;
86
+ out.push({
87
+ id,
88
+ kind: input.kind,
89
+ client: input.client,
90
+ filePath: input.filePath,
91
+ title: input.title,
92
+ sizeBytes: input.sizeBytes,
93
+ lineCount: input.lineCount,
94
+ riskTags,
95
+ excerpt: excerpt || undefined,
96
+ });
97
+ }
98
+ function walkDir(dir, out, seen, matcher, mapper, maxDepth = 4, depth = 0) {
99
+ if (depth > maxDepth || out.length >= MAX_FILES)
100
+ return;
101
+ let entries;
102
+ try {
103
+ entries = fs.readdirSync(dir, { withFileTypes: true });
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ for (const entry of entries) {
109
+ if (out.length >= MAX_FILES)
110
+ return;
111
+ const full = path.join(dir, entry.name);
112
+ if (entry.isDirectory()) {
113
+ if (['node_modules', '.git', 'dist', 'build'].includes(entry.name))
114
+ continue;
115
+ walkDir(full, out, seen, matcher, mapper, maxDepth, depth + 1);
116
+ continue;
117
+ }
118
+ if (!entry.isFile() || !matcher(full, entry.name))
119
+ continue;
120
+ const parsed = readAgentFile(full);
121
+ if (!parsed)
122
+ continue;
123
+ pushFinding(out, seen, { ...mapper(full), ...parsed, content: parsed.content });
124
+ }
125
+ }
126
+ function scanNamedFiles(roots, names, kind, titleFor, out, seen) {
127
+ for (const { root, client } of roots) {
128
+ if (!fs.existsSync(root))
129
+ continue;
130
+ for (const name of names) {
131
+ const filePath = path.join(root, name);
132
+ if (!fs.existsSync(filePath))
133
+ continue;
134
+ const parsed = readAgentFile(filePath);
135
+ if (!parsed)
136
+ continue;
137
+ pushFinding(out, seen, {
138
+ kind,
139
+ client,
140
+ filePath,
141
+ title: titleFor(name),
142
+ sizeBytes: parsed.sizeBytes,
143
+ lineCount: parsed.lineCount,
144
+ content: parsed.content,
145
+ });
146
+ }
147
+ }
148
+ }
149
+ function scanAgentFiles(options = {}) {
150
+ const home = os.homedir();
151
+ const cwd = options.cwd || process.cwd();
152
+ const out = [];
153
+ const seen = new Set();
154
+ const projectRoots = [{ root: cwd, client: 'project' }, { root: home, client: 'user' }];
155
+ scanNamedFiles(projectRoots, [
156
+ 'AGENTS.md',
157
+ 'CLAUDE.md',
158
+ 'GEMINI.md',
159
+ '.cursorrules',
160
+ '.windsurfrules',
161
+ ], 'instructions', name => `Agent instructions (${name})`, out, seen);
162
+ scanNamedFiles([{ root: path.join(cwd, '.github'), client: 'github' }], [
163
+ 'copilot-instructions.md',
164
+ ], 'instructions', () => 'GitHub Copilot instructions', out, seen);
165
+ for (const base of [
166
+ { dir: path.join(home, '.cursor', 'rules'), client: 'Cursor', kind: 'rules' },
167
+ { dir: path.join(cwd, '.cursor', 'rules'), client: 'Cursor (project)', kind: 'rules' },
168
+ { dir: path.join(home, '.agents', 'skills'), client: 'Agents', kind: 'skills' },
169
+ { dir: path.join(cwd, '.agents', 'skills'), client: 'Agents (project)', kind: 'skills' },
170
+ ]) {
171
+ if (!fs.existsSync(base.dir))
172
+ continue;
173
+ walkDir(base.dir, out, seen, (filePath, name) => name.endsWith('.md') || name.endsWith('.mdc') || name === 'SKILL.md', filePath => ({
174
+ kind: base.kind,
175
+ client: base.client,
176
+ filePath,
177
+ title: path.basename(filePath),
178
+ sizeBytes: 0,
179
+ lineCount: 0,
180
+ }));
181
+ }
182
+ for (const hooksPath of [
183
+ path.join(home, '.cursor', 'hooks.json'),
184
+ path.join(cwd, '.cursor', 'hooks.json'),
185
+ ]) {
186
+ if (!fs.existsSync(hooksPath))
187
+ continue;
188
+ const parsed = readAgentFile(hooksPath);
189
+ if (!parsed)
190
+ continue;
191
+ pushFinding(out, seen, {
192
+ kind: 'hooks',
193
+ client: 'Cursor',
194
+ filePath: hooksPath,
195
+ title: 'Cursor hooks config',
196
+ sizeBytes: parsed.sizeBytes,
197
+ lineCount: parsed.lineCount,
198
+ content: parsed.content,
199
+ riskTags: parsed.content.includes('fullcourtdefense') ? [] : ['no_runtime_hook'],
200
+ });
201
+ }
202
+ out.sort((a, b) => b.riskTags.length - a.riskTags.length || b.sizeBytes - a.sizeBytes);
203
+ return out;
204
+ }
@@ -0,0 +1,30 @@
1
+ import type { ClientCoverageRow } from './discoverProxy';
2
+ import type { AgentFileFinding } from './discoverAgentFiles';
3
+ import type { SecretsScanResult } from './discoverSecrets';
4
+ export type BlastSeverity = 'critical' | 'high' | 'medium';
5
+ export interface BlastRadiusCombo {
6
+ id: string;
7
+ severity: BlastSeverity;
8
+ title: string;
9
+ detail: string;
10
+ }
11
+ export interface BlastRadiusReport {
12
+ score: number;
13
+ grade: 'A' | 'B' | 'C' | 'D' | 'F';
14
+ combos: BlastRadiusCombo[];
15
+ drivers: string[];
16
+ }
17
+ interface McpServerLike {
18
+ serverName: string;
19
+ riskLevel: 'critical' | 'high' | 'medium' | 'low';
20
+ riskTags: string[];
21
+ proxyStatus: string;
22
+ warnings: string[];
23
+ }
24
+ export declare function computeBlastRadius(input: {
25
+ mcpServers: McpServerLike[];
26
+ clientCoverage: ClientCoverageRow[];
27
+ secrets: SecretsScanResult;
28
+ agentFiles: AgentFileFinding[];
29
+ }): BlastRadiusReport;
30
+ export {};
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeBlastRadius = computeBlastRadius;
4
+ function gradeFromScore(score) {
5
+ if (score >= 90)
6
+ return 'A';
7
+ if (score >= 80)
8
+ return 'B';
9
+ if (score >= 70)
10
+ return 'C';
11
+ if (score >= 60)
12
+ return 'D';
13
+ return 'F';
14
+ }
15
+ function hasTag(servers, tag, directOnly = false) {
16
+ return servers.some(s => s.riskTags.includes(tag) && (!directOnly || s.proxyStatus === 'direct'));
17
+ }
18
+ function countDirect(servers, minLevel = 'medium') {
19
+ const order = { low: 0, medium: 1, high: 2, critical: 3 };
20
+ return servers.filter(s => s.proxyStatus === 'direct' && order[s.riskLevel] >= order[minLevel]).length;
21
+ }
22
+ function computeBlastRadius(input) {
23
+ const combos = [];
24
+ const drivers = [];
25
+ const { mcpServers, clientCoverage, secrets, agentFiles } = input;
26
+ const directServers = mcpServers.filter(s => s.proxyStatus === 'direct');
27
+ const criticalSecrets = secrets.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
28
+ const riskyAgentFiles = agentFiles.filter(f => f.riskTags.length > 0);
29
+ if (hasTag(directServers, 'shell/exec', true)) {
30
+ combos.push({
31
+ id: 'unproxied_shell',
32
+ severity: 'critical',
33
+ title: 'Direct shell/exec MCP',
34
+ detail: 'At least one shell or command-runner MCP is not routed through Full Court Defense gateway.',
35
+ });
36
+ }
37
+ if (hasTag(directServers, 'database', true)) {
38
+ combos.push({
39
+ id: 'unproxied_database',
40
+ severity: 'critical',
41
+ title: 'Direct database MCP',
42
+ detail: 'Database MCP tools are reachable without gateway policy enforcement.',
43
+ });
44
+ }
45
+ if (hasTag(directServers, 'shell/exec', true) && hasTag(directServers, 'filesystem', true)) {
46
+ combos.push({
47
+ id: 'shell_plus_filesystem',
48
+ severity: 'critical',
49
+ title: 'Shell + filesystem combo (direct)',
50
+ detail: 'Agent can execute commands and read/write files without gateway — high blast radius.',
51
+ });
52
+ }
53
+ if (hasTag(mcpServers, 'shell/exec', true) && criticalSecrets.length > 0) {
54
+ combos.push({
55
+ id: 'shell_plus_secrets',
56
+ severity: 'critical',
57
+ title: 'Shell MCP + secrets on disk',
58
+ detail: `${criticalSecrets.length} high/critical secret(s) on this machine while shell/exec MCP is enabled.`,
59
+ });
60
+ }
61
+ if (hasTag(directServers, 'web/network', true) && criticalSecrets.length > 0) {
62
+ combos.push({
63
+ id: 'browser_plus_secrets',
64
+ severity: 'high',
65
+ title: 'Browser/network MCP + local secrets',
66
+ detail: 'Web or browser automation MCP combined with API keys in .env or credential stores.',
67
+ });
68
+ }
69
+ const clientsWithMcp = clientCoverage.filter(c => c.mcpServerCount > 0);
70
+ const missingGateway = clientsWithMcp.filter(c => !c.mcpGatewayInstalled && !c.cursorHooksInstalled);
71
+ if (clientsWithMcp.length > 0 && missingGateway.length === clientsWithMcp.length) {
72
+ combos.push({
73
+ id: 'no_gateway_coverage',
74
+ severity: 'high',
75
+ title: 'No gateway on any AI client',
76
+ detail: `${clientsWithMcp.length} AI client(s) have MCP configured but no FCD gateway or Cursor hooks.`,
77
+ });
78
+ }
79
+ if (countDirect(mcpServers, 'critical') >= 2) {
80
+ combos.push({
81
+ id: 'many_direct_critical',
82
+ severity: 'high',
83
+ title: 'Multiple unproxied critical MCPs',
84
+ detail: `${countDirect(mcpServers, 'critical')} critical MCP server(s) run direct.`,
85
+ });
86
+ }
87
+ const unencryptedSsh = secrets.findings.find(f => f.category === 'ssh_key' && f.severity === 'high');
88
+ if (unencryptedSsh) {
89
+ combos.push({
90
+ id: 'ssh_key_exposed',
91
+ severity: 'high',
92
+ title: 'Unencrypted SSH private key',
93
+ detail: unencryptedSsh.filePath,
94
+ });
95
+ }
96
+ if (riskyAgentFiles.some(f => f.riskTags.includes('allows_shell') || f.riskTags.includes('broad_filesystem'))) {
97
+ combos.push({
98
+ id: 'permissive_agent_rules',
99
+ severity: 'medium',
100
+ title: 'Permissive agent rules/skills',
101
+ detail: 'Cursor rules or skills mention shell, filesystem, or weak guardrails — review what the agent is allowed to do.',
102
+ });
103
+ }
104
+ if (agentFiles.length >= 5 && !clientCoverage.some(c => c.cursorHooksInstalled)) {
105
+ combos.push({
106
+ id: 'rich_instructions_no_hooks',
107
+ severity: 'medium',
108
+ title: 'Many instruction files, no runtime hooks',
109
+ detail: `${agentFiles.length} agent instruction/skill files found but Cursor hooks are not installed.`,
110
+ });
111
+ }
112
+ let score = 100;
113
+ for (const combo of combos) {
114
+ if (combo.severity === 'critical')
115
+ score -= 22;
116
+ else if (combo.severity === 'high')
117
+ score -= 12;
118
+ else
119
+ score -= 6;
120
+ }
121
+ score -= Math.min(30, criticalSecrets.length * 8);
122
+ score -= Math.min(15, countDirect(mcpServers, 'high') * 4);
123
+ score -= Math.min(10, riskyAgentFiles.length * 2);
124
+ score = Math.max(0, Math.min(100, score));
125
+ if (criticalSecrets.length)
126
+ drivers.push(`${criticalSecrets.length} exposed secret(s)`);
127
+ if (directServers.length)
128
+ drivers.push(`${directServers.length} direct MCP server(s)`);
129
+ if (missingGateway.length)
130
+ drivers.push(`${missingGateway.length} client(s) without gateway`);
131
+ if (riskyAgentFiles.length)
132
+ drivers.push(`${riskyAgentFiles.length} risky agent file(s)`);
133
+ return {
134
+ score,
135
+ grade: gradeFromScore(score),
136
+ combos,
137
+ drivers,
138
+ };
139
+ }
@@ -0,0 +1,24 @@
1
+ export type SecretSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
2
+ export type SecretCategory = 'env_file' | 'credential_store' | 'config' | 'shell_history' | 'ssh_key' | 'other';
3
+ export interface SecretFinding {
4
+ id: string;
5
+ category: SecretCategory;
6
+ severity: SecretSeverity;
7
+ provider?: string;
8
+ filePath: string;
9
+ lineNumber?: number;
10
+ title: string;
11
+ detail: string;
12
+ redactedPreview?: string;
13
+ remediation: string;
14
+ }
15
+ export interface SecretsScanResult {
16
+ findings: SecretFinding[];
17
+ scannedFiles: number;
18
+ scannedStores: number;
19
+ healthScore: number;
20
+ }
21
+ export declare function scanSecrets(options?: {
22
+ cwd?: string;
23
+ maxEnvDepth?: number;
24
+ }): SecretsScanResult;
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.scanSecrets = scanSecrets;
37
+ const crypto = __importStar(require("crypto"));
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ /** Patterns inspired by GhostHunt + secrets-audit-mcp (local-only, no network). */
42
+ const SECRET_PATTERNS = [
43
+ { re: /\bsk-[a-zA-Z0-9_-]{20,}\b/, provider: 'openai', severity: 'critical', title: 'OpenAI API key' },
44
+ { re: /\bsk-proj-[a-zA-Z0-9_-]{20,}\b/, provider: 'openai', severity: 'critical', title: 'OpenAI project key' },
45
+ { re: /\bsk-ant-[a-zA-Z0-9_-]{20,}\b/, provider: 'anthropic', severity: 'critical', title: 'Anthropic API key' },
46
+ { re: /\bghp_[a-zA-Z0-9]{36,}\b/, provider: 'github', severity: 'critical', title: 'GitHub personal access token' },
47
+ { re: /\bgho_[a-zA-Z0-9]{36,}\b/, provider: 'github', severity: 'critical', title: 'GitHub OAuth token' },
48
+ { re: /\bgithub_pat_[a-zA-Z0-9_]{20,}\b/, provider: 'github', severity: 'critical', title: 'GitHub fine-grained PAT' },
49
+ { re: /\bAKIA[0-9A-Z]{16}\b/, provider: 'aws', severity: 'critical', title: 'AWS access key ID' },
50
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/, provider: 'google', severity: 'critical', title: 'Google API key' },
51
+ { re: /\bxox[baprs]-[a-zA-Z0-9-]{10,}\b/, provider: 'slack', severity: 'high', title: 'Slack token' },
52
+ { re: /\bsk_live_[0-9a-zA-Z]{24,}\b/, provider: 'stripe', severity: 'critical', title: 'Stripe live secret key' },
53
+ { re: /\brk_live_[0-9a-zA-Z]{24,}\b/, provider: 'stripe', severity: 'critical', title: 'Stripe restricted key' },
54
+ { re: /\bSG\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\b/, provider: 'sendgrid', severity: 'high', title: 'SendGrid API key' },
55
+ { re: /\bsk-[0-9a-fA-F]{32}\b/, provider: 'twilio', severity: 'high', title: 'Twilio API key' },
56
+ { re: /\bhf_[a-zA-Z0-9]{30,}\b/, provider: 'huggingface', severity: 'high', title: 'HuggingFace token' },
57
+ { re: /\bag_org_[a-zA-Z0-9_-]{20,}\b/, provider: 'fullcourtdefense', severity: 'high', title: 'FullCourtDefense org API key' },
58
+ { re: /\bshsk_[a-zA-Z0-9]{20,}\b/, provider: 'fullcourtdefense', severity: 'high', title: 'Shield API key' },
59
+ { re: /\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/, provider: 'jwt', severity: 'medium', title: 'JWT token' },
60
+ { re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, provider: 'private_key', severity: 'critical', title: 'Private key material' },
61
+ { re: /postgres(?:ql)?:\/\/[^\s'"]+:[^\s'"]+@/i, provider: 'database', severity: 'critical', title: 'Database connection string' },
62
+ { re: /mongodb(?:\+srv)?:\/\/[^\s'"]+:[^\s'"]+@/i, provider: 'database', severity: 'critical', title: 'MongoDB connection string' },
63
+ ];
64
+ const SKIP_DIR_NAMES = new Set([
65
+ 'node_modules', '.git', 'vendor', 'dist', 'build', '__pycache__', '.venv', 'venv',
66
+ 'Library', 'Caches', 'Cache', 'Temp', 'tmp', 'Windows', 'Program Files', 'Program Files (x86)',
67
+ 'AppData', 'Application Data', '.npm', '.cache', 'OneDrive', 'Google',
68
+ ]);
69
+ const MAX_FILE_BYTES = 256 * 1024;
70
+ const MAX_ENV_FILES = 400;
71
+ const MAX_FINDINGS = 200;
72
+ function redact(value) {
73
+ const trimmed = value.trim();
74
+ if (trimmed.length <= 8)
75
+ return '***';
76
+ return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
77
+ }
78
+ function findingId(parts) {
79
+ return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
80
+ }
81
+ function scoreFromFindings(findings) {
82
+ let score = 100;
83
+ for (const f of findings) {
84
+ if (f.severity === 'critical')
85
+ score -= 25;
86
+ else if (f.severity === 'high')
87
+ score -= 12;
88
+ else if (f.severity === 'medium')
89
+ score -= 5;
90
+ else if (f.severity === 'low')
91
+ score -= 2;
92
+ }
93
+ return Math.max(0, Math.min(100, score));
94
+ }
95
+ function scanTextLines(filePath, content, category, out, seen) {
96
+ const lines = content.split(/\r?\n/);
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i];
99
+ if (!line.trim() || line.trim().startsWith('#'))
100
+ continue;
101
+ for (const pattern of SECRET_PATTERNS) {
102
+ const match = line.match(pattern.re);
103
+ if (!match)
104
+ continue;
105
+ const raw = match[0];
106
+ const id = findingId([filePath, String(i + 1), pattern.provider, raw.slice(0, 12)]);
107
+ if (seen.has(id))
108
+ continue;
109
+ seen.add(id);
110
+ out.push({
111
+ id,
112
+ category,
113
+ severity: pattern.severity,
114
+ provider: pattern.provider,
115
+ filePath,
116
+ lineNumber: i + 1,
117
+ title: pattern.title,
118
+ detail: `Matched ${pattern.provider} pattern in ${path.basename(filePath)}`,
119
+ redactedPreview: redact(raw),
120
+ remediation: pattern.provider === 'private_key'
121
+ ? 'Move keys to a secure store; never commit private keys. Use ssh-agent or encrypted keys.'
122
+ : 'Rotate this credential immediately and store it in a secret manager or OS keychain — not in plain files.',
123
+ });
124
+ if (out.length >= MAX_FINDINGS)
125
+ return;
126
+ }
127
+ }
128
+ }
129
+ function readTextFile(filePath) {
130
+ try {
131
+ const stat = fs.statSync(filePath);
132
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
133
+ return null;
134
+ return fs.readFileSync(filePath, 'utf-8');
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ function credentialStores(home) {
141
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
142
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
143
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
144
+ return [
145
+ { path: path.join(home, '.aws', 'credentials'), category: 'credential_store', title: 'AWS credentials file' },
146
+ { path: path.join(home, '.docker', 'config.json'), category: 'credential_store', title: 'Docker registry auth' },
147
+ { path: path.join(home, '.npmrc'), category: 'credential_store', title: 'npm auth token' },
148
+ { path: path.join(home, '.pypirc'), category: 'credential_store', title: 'PyPI auth token' },
149
+ { path: path.join(xdg, 'gh', 'hosts.yml'), category: 'credential_store', title: 'GitHub CLI OAuth token' },
150
+ { path: path.join(home, '.kube', 'config'), category: 'credential_store', title: 'Kubernetes kubeconfig' },
151
+ { path: path.join(home, '.netrc'), category: 'credential_store', title: 'Netrc passwords' },
152
+ { path: path.join(xdg, 'gcloud', 'application_default_credentials.json'), category: 'credential_store', title: 'GCP ADC JSON' },
153
+ { path: path.join(appData, 'gcloud', 'application_default_credentials.json'), category: 'credential_store', title: 'GCP ADC JSON (win)' },
154
+ { path: path.join(home, '.config', 'gcloud', 'application_default_credentials.json'), category: 'credential_store', title: 'GCP ADC JSON' },
155
+ { path: path.join(localAppData, 'Microsoft', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'), category: 'shell_history', title: 'PowerShell history' },
156
+ { path: path.join(home, '.bash_history'), category: 'shell_history', title: 'Bash history' },
157
+ { path: path.join(home, '.zsh_history'), category: 'shell_history', title: 'Zsh history' },
158
+ { path: path.join(home, '.local', 'share', 'fish', 'fish_history'), category: 'shell_history', title: 'Fish history' },
159
+ ];
160
+ }
161
+ function scanSshKeys(home, out, seen) {
162
+ const sshDir = path.join(home, '.ssh');
163
+ let scanned = 0;
164
+ if (!fs.existsSync(sshDir))
165
+ return 0;
166
+ for (const name of fs.readdirSync(sshDir)) {
167
+ if (!/^id_(?:rsa|ed25519|ecdsa|dsa)$/.test(name))
168
+ continue;
169
+ const filePath = path.join(sshDir, name);
170
+ const content = readTextFile(filePath);
171
+ if (!content)
172
+ continue;
173
+ scanned += 1;
174
+ const encrypted = /ENCRYPTED/.test(content);
175
+ const id = findingId([filePath, 'ssh']);
176
+ if (seen.has(id))
177
+ continue;
178
+ seen.add(id);
179
+ out.push({
180
+ id,
181
+ category: 'ssh_key',
182
+ severity: encrypted ? 'info' : 'high',
183
+ provider: 'ssh',
184
+ filePath,
185
+ title: encrypted ? 'SSH private key (passphrase protected)' : 'Unencrypted SSH private key',
186
+ detail: encrypted
187
+ ? `${name} appears passphrase-protected — lower exposure if laptop is stolen while locked.`
188
+ : `${name} has no passphrase — any process/user on this machine can read it.`,
189
+ remediation: encrypted
190
+ ? 'Prefer hardware-backed or agent-stored keys for production access.'
191
+ : 'Run `ssh-keygen -p -f ~/.ssh/' + name + '` to add a passphrase, or use ssh-agent with limited lifetime.',
192
+ });
193
+ }
194
+ return scanned;
195
+ }
196
+ function collectEnvFiles(roots, maxDepth) {
197
+ const found = [];
198
+ const queue = roots.map(dir => ({ dir, depth: 0 }));
199
+ while (queue.length > 0 && found.length < MAX_ENV_FILES) {
200
+ const { dir, depth } = queue.shift();
201
+ let entries;
202
+ try {
203
+ entries = fs.readdirSync(dir, { withFileTypes: true });
204
+ }
205
+ catch {
206
+ continue;
207
+ }
208
+ for (const entry of entries) {
209
+ if (found.length >= MAX_ENV_FILES)
210
+ break;
211
+ const full = path.join(dir, entry.name);
212
+ if (entry.isDirectory()) {
213
+ if (depth >= maxDepth)
214
+ continue;
215
+ if (SKIP_DIR_NAMES.has(entry.name))
216
+ continue;
217
+ if (entry.name.startsWith('.') && !['.cursor', '.claude', '.codex', '.vscode'].includes(entry.name))
218
+ continue;
219
+ queue.push({ dir: full, depth: depth + 1 });
220
+ continue;
221
+ }
222
+ if (!entry.isFile())
223
+ continue;
224
+ if (/^\.env(\..+)?$/.test(entry.name) || entry.name === '.env.local' || entry.name === '.env.production') {
225
+ found.push(full);
226
+ }
227
+ }
228
+ }
229
+ return found;
230
+ }
231
+ function scanSecrets(options = {}) {
232
+ const home = os.homedir();
233
+ const cwd = options.cwd || process.cwd();
234
+ const maxDepth = options.maxEnvDepth ?? 4;
235
+ const findings = [];
236
+ const seen = new Set();
237
+ let scannedFiles = 0;
238
+ let scannedStores = 0;
239
+ for (const store of credentialStores(home)) {
240
+ if (!fs.existsSync(store.path))
241
+ continue;
242
+ scannedStores += 1;
243
+ const content = readTextFile(store.path);
244
+ if (!content)
245
+ continue;
246
+ scannedFiles += 1;
247
+ if (store.category === 'shell_history') {
248
+ const tail = content.split(/\r?\n/).slice(-500).join('\n');
249
+ scanTextLines(store.path, tail, store.category, findings, seen);
250
+ }
251
+ else {
252
+ scanTextLines(store.path, content, store.category, findings, seen);
253
+ }
254
+ if (findings.length >= MAX_FINDINGS)
255
+ break;
256
+ }
257
+ scannedStores += scanSshKeys(home, findings, seen);
258
+ const envRoots = Array.from(new Set([
259
+ home,
260
+ cwd,
261
+ path.join(home, 'dev'),
262
+ path.join(home, 'repos'),
263
+ path.join(home, 'projects'),
264
+ path.join(home, 'Documents'),
265
+ path.join(home, 'code'),
266
+ ].filter(root => fs.existsSync(root))));
267
+ for (const envFile of collectEnvFiles(envRoots, maxDepth)) {
268
+ if (findings.length >= MAX_FINDINGS)
269
+ break;
270
+ const content = readTextFile(envFile);
271
+ if (!content)
272
+ continue;
273
+ scannedFiles += 1;
274
+ scanTextLines(envFile, content, 'env_file', findings, seen);
275
+ }
276
+ findings.sort((a, b) => {
277
+ const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
278
+ return order[a.severity] - order[b.severity] || a.filePath.localeCompare(b.filePath);
279
+ });
280
+ return {
281
+ findings,
282
+ scannedFiles,
283
+ scannedStores,
284
+ healthScore: scoreFromFindings(findings),
285
+ };
286
+ }