corpus-cli 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/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +163 -0
- package/dist/commands/init-graph.d.ts +7 -0
- package/dist/commands/init-graph.js +270 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +211 -0
- package/dist/commands/report.d.ts +1 -0
- package/dist/commands/report.js +93 -0
- package/dist/commands/scan.d.ts +1 -0
- package/dist/commands/scan.js +481 -0
- package/dist/commands/verify.d.ts +1 -0
- package/dist/commands/verify.js +334 -0
- package/dist/commands/watch.d.ts +1 -0
- package/dist/commands/watch.js +380 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +87 -0
- package/dist/utils/colors.d.ts +6 -0
- package/dist/utils/colors.js +6 -0
- package/dist/utils/config.d.ts +3 -0
- package/dist/utils/config.js +39 -0
- package/dist/utils/table.d.ts +2 -0
- package/dist/utils/table.js +24 -0
- package/package.json +28 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readPolicyFile, readEnvFile } from '../utils/config.js';
|
|
2
|
+
import { green, amber, red, dim, bold, cyan } from '../utils/colors.js';
|
|
3
|
+
import { progressBar } from '../utils/table.js';
|
|
4
|
+
export async function runReport() {
|
|
5
|
+
const args = process.argv.slice(3);
|
|
6
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
7
|
+
process.stdout.write(`
|
|
8
|
+
corpus report
|
|
9
|
+
|
|
10
|
+
Fetch and display trust-score statistics for your project from the
|
|
11
|
+
Corpus API (last 30 days). Requires a corpus.policy.yaml with an
|
|
12
|
+
agent name and an optional .env.corpus with CORPUS_API_ENDPOINT.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--help Show this help
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
corpus report
|
|
19
|
+
|
|
20
|
+
`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const policy = readPolicyFile();
|
|
24
|
+
if (!policy) {
|
|
25
|
+
process.stdout.write(red(' No corpus.policy.yaml found. Run corpus init first.\n'));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const env = readEnvFile();
|
|
29
|
+
const slug = String(policy.agent ?? 'unknown');
|
|
30
|
+
const endpoint = env.CORPUS_API_ENDPOINT ?? 'https://corpus.run';
|
|
31
|
+
process.stdout.write('\n');
|
|
32
|
+
process.stdout.write(bold(` CORPUS REPORT ${cyan(slug)} last 30 days\n`));
|
|
33
|
+
process.stdout.write(' ' + '\u2550'.repeat(51) + '\n\n');
|
|
34
|
+
// Fetch stats
|
|
35
|
+
try {
|
|
36
|
+
const url = `${endpoint}/api/project/${slug}`;
|
|
37
|
+
const res = await fetch(url);
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
if (res.status === 404) {
|
|
40
|
+
process.stdout.write(` Project '${slug}' not found on ${endpoint}\n`);
|
|
41
|
+
process.stdout.write(` Start sending events with the SDK to see data here.\n\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write(red(` API returned ${res.status}\n`));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const data = await res.json();
|
|
48
|
+
const s = data.stats;
|
|
49
|
+
// Pass rate bar
|
|
50
|
+
const barColor = s.passRate >= 95 ? green : s.passRate >= 80 ? amber : red;
|
|
51
|
+
process.stdout.write(` Pass rate ${barColor(s.passRate.toFixed(1) + '%')} ${barColor(progressBar(s.passRate))}\n`);
|
|
52
|
+
process.stdout.write(` Total actions ${s.totalRuns.toLocaleString()}\n`);
|
|
53
|
+
process.stdout.write(` Confirmed ${s.confirmCount} ${dim('user saw and approved')}\n`);
|
|
54
|
+
process.stdout.write(` Blocked ${s.blockCount} ${dim('stopped by hard policy')}\n`);
|
|
55
|
+
process.stdout.write(` Cancelled ${s.cancelledCount} ${dim('user saw and declined')}\n`);
|
|
56
|
+
// Policy breakdown
|
|
57
|
+
if (data.policyBreakdown.length > 0) {
|
|
58
|
+
process.stdout.write('\n');
|
|
59
|
+
process.stdout.write(bold(' POLICY BREAKDOWN\n'));
|
|
60
|
+
for (const p of data.policyBreakdown) {
|
|
61
|
+
const pColor = p.passRate >= 95 ? green : p.passRate >= 80 ? amber : red;
|
|
62
|
+
const name = p.policyTriggered.padEnd(22);
|
|
63
|
+
const extra = [];
|
|
64
|
+
if (p.blockCount > 0)
|
|
65
|
+
extra.push(`${p.blockCount} blocked`);
|
|
66
|
+
if (p.confirmCount > 0)
|
|
67
|
+
extra.push(`${p.confirmCount} confirmed`);
|
|
68
|
+
const detail = extra.length > 0 ? `[${extra.join(', ')}]` : '';
|
|
69
|
+
process.stdout.write(` ${name} ${pColor(p.passRate + '%')} (${p.totalRuns} runs) ${dim(detail)}\n`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Recent activity
|
|
73
|
+
if (data.recentActivity.length > 0) {
|
|
74
|
+
process.stdout.write('\n');
|
|
75
|
+
process.stdout.write(bold(' RECENT ACTIVITY (last 10)\n'));
|
|
76
|
+
for (const a of data.recentActivity.slice(0, 10)) {
|
|
77
|
+
const vColor = a.verdict === 'PASS' ? green : a.verdict === 'BLOCK' ? red : amber;
|
|
78
|
+
const verdict = vColor(a.verdict.padEnd(8));
|
|
79
|
+
const action = a.actionType.padEnd(18).slice(0, 18);
|
|
80
|
+
const policy = a.policyTriggered.padEnd(16).slice(0, 16);
|
|
81
|
+
const dur = `${a.durationMs}ms`.padStart(6);
|
|
82
|
+
process.stdout.write(` ${verdict} ${action} ${policy} ${dur}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
process.stdout.write('\n');
|
|
86
|
+
process.stdout.write(dim(` View full dashboard: ${endpoint}/${slug}\n`));
|
|
87
|
+
process.stdout.write('\n');
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
process.stdout.write(red(` Failed to fetch stats: ${e instanceof Error ? e.message : String(e)}\n`));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runScan(): Promise<void>;
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { green, amber, red, dim, bold } from '../utils/colors.js';
|
|
5
|
+
import { checkForCVEs } from '@corpus/core';
|
|
6
|
+
import { checkDependencies } from '@corpus/core';
|
|
7
|
+
// ── Secret detection patterns ────────────────────────────────────────────────
|
|
8
|
+
const SECRET_PATTERNS = [
|
|
9
|
+
{ name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, severity: 'CRITICAL' },
|
|
10
|
+
{ name: 'GitHub Token', regex: /gh[pousr]_[A-Za-z0-9_]{36,255}/g, severity: 'CRITICAL' },
|
|
11
|
+
{ name: 'OpenAI Key', regex: /sk-[A-Za-z0-9]{20,}/g, severity: 'CRITICAL' },
|
|
12
|
+
{ name: 'Anthropic Key', regex: /sk-ant-[A-Za-z0-9-]{20,}/g, severity: 'CRITICAL' },
|
|
13
|
+
{ name: 'Stripe Key', regex: /[sr]k_live_[A-Za-z0-9]{20,}/g, severity: 'CRITICAL' },
|
|
14
|
+
{ name: 'Database URL', regex: /(?:postgres|mysql|mongodb|redis):\/\/[^\s'"]+:[^\s'"]+@[^\s'"]+/g, severity: 'CRITICAL' },
|
|
15
|
+
{ name: 'Private Key', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g, severity: 'CRITICAL' },
|
|
16
|
+
{ name: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z-]{10,}/g, severity: 'CRITICAL' },
|
|
17
|
+
{ name: 'SendGrid Key', regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, severity: 'CRITICAL' },
|
|
18
|
+
{ name: 'Generic Secret', regex: /(?:api_key|apikey|api_secret|secret_key|auth_token|access_token)\s*[=:]\s*['"]([A-Za-z0-9_\-]{16,})['"]/gi, severity: 'WARNING' },
|
|
19
|
+
{ name: 'Password', regex: /(?:password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]/gi, severity: 'WARNING' },
|
|
20
|
+
];
|
|
21
|
+
const PLACEHOLDER_SKIP = [
|
|
22
|
+
/^sk-(?:test|fake|dummy|placeholder|example|xxx|your)/i,
|
|
23
|
+
/^(?:test|fake|dummy|placeholder|example|changeme|TODO|your_)/i,
|
|
24
|
+
/^(?:xxx|aaa|123|abc)/i,
|
|
25
|
+
/<[A-Z_]+>/,
|
|
26
|
+
/^pk_test_/,
|
|
27
|
+
];
|
|
28
|
+
// AI-specific patterns
|
|
29
|
+
const AI_PATTERNS = [
|
|
30
|
+
{ name: 'Inlined Env Var', regex: /(?:const|let|var)\s+\w+\s*=\s*['"](?:postgres|mysql|mongodb|redis|https?):\/\/[^'"]+['"]/g, severity: 'WARNING', message: 'URL hardcoded instead of read from environment variable' },
|
|
31
|
+
{ name: 'Disabled SSL', regex: /rejectUnauthorized\s*:\s*false/g, severity: 'INFO', message: 'SSL verification disabled' },
|
|
32
|
+
{ name: 'Wildcard CORS', regex: /(?:cors|origin)\s*[=:]\s*['"]\*['"]/gi, severity: 'WARNING', message: 'CORS set to wildcard (*)' },
|
|
33
|
+
{ name: 'chmod 777', regex: /chmod\s+777/g, severity: 'WARNING', message: 'Overly permissive file permissions' },
|
|
34
|
+
{ name: 'eval()', regex: /\beval\s*\(/g, severity: 'CRITICAL', message: 'eval() enables arbitrary code execution' },
|
|
35
|
+
{ name: 'Debug Endpoint', regex: /(?:app|router)\.\s*(?:get|post|use)\s*\(\s*['"]\/(?:debug|test|admin)/g, severity: 'WARNING', message: 'Debug/admin endpoint may be accessible in production' },
|
|
36
|
+
];
|
|
37
|
+
// PII patterns
|
|
38
|
+
const PII_PATTERNS = [
|
|
39
|
+
{ name: 'Email', regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g },
|
|
40
|
+
{ name: 'SSN', regex: /\b\d{3}-\d{2}-\d{4}\b/g },
|
|
41
|
+
{ name: 'Credit Card', regex: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g },
|
|
42
|
+
];
|
|
43
|
+
// Injection patterns
|
|
44
|
+
const INJECTION_PATTERNS = [
|
|
45
|
+
'ignore previous instructions', 'ignore all previous', 'you are now',
|
|
46
|
+
'system prompt', 'developer mode', 'jailbreak', 'bypass safety',
|
|
47
|
+
'override:', 'new instructions:', 'forget everything',
|
|
48
|
+
];
|
|
49
|
+
// ── File scanning ────────────────────────────────────────────────────────────
|
|
50
|
+
function isPlaceholder(val) {
|
|
51
|
+
return PLACEHOLDER_SKIP.some((p) => p.test(val));
|
|
52
|
+
}
|
|
53
|
+
function isTestFile(f) {
|
|
54
|
+
const l = f.toLowerCase();
|
|
55
|
+
return l.includes('test') || l.includes('spec') || l.includes('mock') || l.includes('fixture') || l.includes('__tests__');
|
|
56
|
+
}
|
|
57
|
+
function isScannable(f) {
|
|
58
|
+
const ext = path.extname(f).toLowerCase();
|
|
59
|
+
const name = path.basename(f).toLowerCase();
|
|
60
|
+
const codeExts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.c', '.cpp', '.h', '.cs', '.json', '.yaml', '.yml', '.toml', '.sh', '.bash', '.sql', '.tf', '.hcl', '.xml', '.vue', '.svelte', '.ini', '.cfg', '.conf', '.plist', '.md'];
|
|
61
|
+
return codeExts.includes(ext) || name.startsWith('.env') || name === 'dockerfile' || name === 'makefile';
|
|
62
|
+
}
|
|
63
|
+
function getFilesToScan(paths, staged) {
|
|
64
|
+
if (staged) {
|
|
65
|
+
try {
|
|
66
|
+
const output = execSync('git diff --cached --name-only --diff-filter=ACMR', { encoding: 'utf-8' });
|
|
67
|
+
return output.trim().split('\n').filter((f) => f && isScannable(f));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const files = [];
|
|
74
|
+
for (const p of paths) {
|
|
75
|
+
if (!existsSync(p))
|
|
76
|
+
continue;
|
|
77
|
+
const stat = statSync(p);
|
|
78
|
+
if (stat.isFile() && isScannable(p)) {
|
|
79
|
+
files.push(p);
|
|
80
|
+
}
|
|
81
|
+
else if (stat.isDirectory()) {
|
|
82
|
+
walkDir(p, files);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return files;
|
|
86
|
+
}
|
|
87
|
+
function walkDir(dir, files) {
|
|
88
|
+
const skip = ['node_modules', '.git', 'dist', '.next', '__pycache__', '.venv', 'venv'];
|
|
89
|
+
try {
|
|
90
|
+
for (const entry of readdirSync(dir)) {
|
|
91
|
+
if (skip.includes(entry))
|
|
92
|
+
continue;
|
|
93
|
+
const full = path.join(dir, entry);
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(full);
|
|
96
|
+
if (stat.isDirectory())
|
|
97
|
+
walkDir(full, files);
|
|
98
|
+
else if (stat.isFile() && isScannable(full))
|
|
99
|
+
files.push(full);
|
|
100
|
+
}
|
|
101
|
+
catch { /* permission errors */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch { /* permission errors */ }
|
|
105
|
+
}
|
|
106
|
+
function scanFile(filepath, opts) {
|
|
107
|
+
const findings = [];
|
|
108
|
+
let content;
|
|
109
|
+
try {
|
|
110
|
+
content = readFileSync(filepath, 'utf-8');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
115
|
+
if (isTestFile(filepath))
|
|
116
|
+
return findings;
|
|
117
|
+
// Secrets
|
|
118
|
+
if (opts.secrets) {
|
|
119
|
+
for (const pat of SECRET_PATTERNS) {
|
|
120
|
+
pat.regex.lastIndex = 0;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
123
|
+
const val = m[1] ?? m[0];
|
|
124
|
+
if (isPlaceholder(val))
|
|
125
|
+
continue;
|
|
126
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
127
|
+
findings.push({
|
|
128
|
+
severity: pat.severity,
|
|
129
|
+
type: pat.name,
|
|
130
|
+
file: filepath,
|
|
131
|
+
line,
|
|
132
|
+
message: `${pat.name} detected`,
|
|
133
|
+
value: val.length > 12 ? val.slice(0, 6) + '...' + val.slice(-4) : '***',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// AI patterns
|
|
138
|
+
for (const pat of AI_PATTERNS) {
|
|
139
|
+
pat.regex.lastIndex = 0;
|
|
140
|
+
let m;
|
|
141
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
142
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
143
|
+
findings.push({
|
|
144
|
+
severity: pat.severity,
|
|
145
|
+
type: pat.name,
|
|
146
|
+
file: filepath,
|
|
147
|
+
line,
|
|
148
|
+
message: pat.message,
|
|
149
|
+
isAiPattern: true,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// PII
|
|
155
|
+
if (opts.pii) {
|
|
156
|
+
for (const pat of PII_PATTERNS) {
|
|
157
|
+
pat.regex.lastIndex = 0;
|
|
158
|
+
let m;
|
|
159
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
160
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
161
|
+
findings.push({
|
|
162
|
+
severity: 'WARNING',
|
|
163
|
+
type: `PII: ${pat.name}`,
|
|
164
|
+
file: filepath,
|
|
165
|
+
line,
|
|
166
|
+
message: `${pat.name} found in source code`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Injection
|
|
172
|
+
if (opts.injection) {
|
|
173
|
+
const lower = content.toLowerCase();
|
|
174
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
175
|
+
if (lower.includes(pattern)) {
|
|
176
|
+
const idx = lower.indexOf(pattern);
|
|
177
|
+
const line = content.slice(0, idx).split('\n').length;
|
|
178
|
+
findings.push({
|
|
179
|
+
severity: 'CRITICAL',
|
|
180
|
+
type: 'Injection Pattern',
|
|
181
|
+
file: filepath,
|
|
182
|
+
line,
|
|
183
|
+
message: `Prompt injection pattern found: "${pattern}"`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// CVE patterns
|
|
189
|
+
if (opts.safety) {
|
|
190
|
+
try {
|
|
191
|
+
const cveFindings = checkForCVEs(content, filepath);
|
|
192
|
+
for (const cve of cveFindings) {
|
|
193
|
+
findings.push({
|
|
194
|
+
severity: cve.severity === 'HIGH' ? 'CRITICAL' : cve.severity === 'MEDIUM' ? 'WARNING' : cve.severity,
|
|
195
|
+
type: `CVE: ${cve.cveId}`,
|
|
196
|
+
file: filepath,
|
|
197
|
+
line: cve.line,
|
|
198
|
+
message: `${cve.name}: ${cve.description}`,
|
|
199
|
+
suggestion: cve.fixExample,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch { }
|
|
204
|
+
}
|
|
205
|
+
return findings;
|
|
206
|
+
}
|
|
207
|
+
// ── Output formatting ────────────────────────────────────────────────────────
|
|
208
|
+
function formatPretty(findings, fileCount, timeMs) {
|
|
209
|
+
const critical = findings.filter((f) => f.severity === 'CRITICAL');
|
|
210
|
+
const warning = findings.filter((f) => f.severity === 'WARNING');
|
|
211
|
+
const info = findings.filter((f) => f.severity === 'INFO');
|
|
212
|
+
process.stdout.write('\n');
|
|
213
|
+
process.stdout.write(bold(` CORPUS SCAN ${fileCount} files scanned in ${(timeMs / 1000).toFixed(1)}s\n`));
|
|
214
|
+
process.stdout.write(' ' + '\u2550'.repeat(46) + '\n\n');
|
|
215
|
+
if (findings.length === 0) {
|
|
216
|
+
process.stdout.write(green(' No issues found. Your code is clean.\n\n'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const f of findings) {
|
|
220
|
+
const sev = f.severity === 'CRITICAL' ? red('CRITICAL') :
|
|
221
|
+
f.severity === 'WARNING' ? amber('WARNING ') :
|
|
222
|
+
dim('INFO ');
|
|
223
|
+
const ai = f.isAiPattern ? amber(' [AI]') : '';
|
|
224
|
+
process.stdout.write(` ${sev} ${f.file}:${f.line}${ai}\n`);
|
|
225
|
+
process.stdout.write(` ${f.message}\n`);
|
|
226
|
+
if (f.value)
|
|
227
|
+
process.stdout.write(` Value: ${dim(f.value)}\n`);
|
|
228
|
+
if (f.suggestion)
|
|
229
|
+
process.stdout.write(` Fix: ${dim(f.suggestion)}\n`);
|
|
230
|
+
process.stdout.write('\n');
|
|
231
|
+
}
|
|
232
|
+
const summary = [];
|
|
233
|
+
if (critical.length > 0)
|
|
234
|
+
summary.push(red(`${critical.length} critical`));
|
|
235
|
+
if (warning.length > 0)
|
|
236
|
+
summary.push(amber(`${warning.length} warning`));
|
|
237
|
+
if (info.length > 0)
|
|
238
|
+
summary.push(dim(`${info.length} info`));
|
|
239
|
+
process.stdout.write(` ${summary.join(', ')}\n\n`);
|
|
240
|
+
}
|
|
241
|
+
function formatJson(findings, fileCount, timeMs) {
|
|
242
|
+
process.stdout.write(JSON.stringify({
|
|
243
|
+
scannedFiles: fileCount,
|
|
244
|
+
scanTimeMs: timeMs,
|
|
245
|
+
findings: findings.map((f) => ({
|
|
246
|
+
severity: f.severity,
|
|
247
|
+
type: f.type,
|
|
248
|
+
file: f.file,
|
|
249
|
+
line: f.line,
|
|
250
|
+
message: f.message,
|
|
251
|
+
isAiPattern: f.isAiPattern ?? false,
|
|
252
|
+
})),
|
|
253
|
+
summary: {
|
|
254
|
+
critical: findings.filter((f) => f.severity === 'CRITICAL').length,
|
|
255
|
+
warning: findings.filter((f) => f.severity === 'WARNING').length,
|
|
256
|
+
info: findings.filter((f) => f.severity === 'INFO').length,
|
|
257
|
+
},
|
|
258
|
+
}, null, 2) + '\n');
|
|
259
|
+
}
|
|
260
|
+
// ── Auto-fix ─────────────────────────────────────────────────────────────────
|
|
261
|
+
function autoFix(findings) {
|
|
262
|
+
// Types that have actual auto-fix logic implemented
|
|
263
|
+
const AUTO_FIXABLE_TYPES = new Set(['Disabled SSL', 'Wildcard CORS']);
|
|
264
|
+
// Types that look fixable but require manual intervention
|
|
265
|
+
const MANUAL_FIX_TYPES = new Set(['Generic Secret', 'Password', 'Inlined Env Var']);
|
|
266
|
+
const fixable = findings.filter((f) => AUTO_FIXABLE_TYPES.has(f.type));
|
|
267
|
+
const manualOnly = findings.filter((f) => MANUAL_FIX_TYPES.has(f.type));
|
|
268
|
+
if (fixable.length === 0 && manualOnly.length === 0) {
|
|
269
|
+
process.stdout.write(dim(' No auto-fixable issues found.\n'));
|
|
270
|
+
process.stdout.write(dim(' Auto-fix works on: disabled SSL, wildcard CORS.\n\n'));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (manualOnly.length > 0) {
|
|
274
|
+
process.stdout.write(dim(` ${manualOnly.length} issue(s) require manual fix (Generic Secret, Password, Inlined Env Var).\n`));
|
|
275
|
+
}
|
|
276
|
+
if (fixable.length === 0) {
|
|
277
|
+
process.stdout.write(dim(' No auto-fixable issues found.\n\n'));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
process.stdout.write(bold(` AUTO-FIX: ${fixable.length} fixable issue(s)\n\n`));
|
|
281
|
+
// Group by file
|
|
282
|
+
const byFile = new Map();
|
|
283
|
+
for (const f of fixable) {
|
|
284
|
+
if (!byFile.has(f.file))
|
|
285
|
+
byFile.set(f.file, []);
|
|
286
|
+
byFile.get(f.file).push(f);
|
|
287
|
+
}
|
|
288
|
+
let fixed = 0;
|
|
289
|
+
const envEntries = [];
|
|
290
|
+
for (const [filepath, fileFindings] of byFile) {
|
|
291
|
+
let content = readFileSync(filepath, 'utf-8');
|
|
292
|
+
const lines = content.split('\n');
|
|
293
|
+
for (const finding of fileFindings) {
|
|
294
|
+
if (finding.type === 'Disabled SSL') {
|
|
295
|
+
// Replace rejectUnauthorized: false with true
|
|
296
|
+
content = content.replace(/rejectUnauthorized\s*:\s*false/g, 'rejectUnauthorized: true');
|
|
297
|
+
process.stdout.write(` ${green('\u2714')} ${filepath}:${finding.line} - Enabled SSL verification\n`);
|
|
298
|
+
fixed++;
|
|
299
|
+
}
|
|
300
|
+
else if (finding.type === 'Wildcard CORS') {
|
|
301
|
+
// Replace wildcard with env var reference
|
|
302
|
+
content = content.replace(/((?:cors|origin)\s*[=:]\s*)['"]?\*['"]?/gi, '$1process.env.CORS_ORIGIN || "http://localhost:3000"');
|
|
303
|
+
envEntries.push('CORS_ORIGIN=http://localhost:3000');
|
|
304
|
+
process.stdout.write(` ${green('\u2714')} ${filepath}:${finding.line} - Replaced wildcard CORS with env var\n`);
|
|
305
|
+
fixed++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
writeFileSync(filepath, content);
|
|
309
|
+
}
|
|
310
|
+
// Write env entries if any
|
|
311
|
+
if (envEntries.length > 0) {
|
|
312
|
+
const envPath = '.env';
|
|
313
|
+
let envContent = '';
|
|
314
|
+
if (existsSync(envPath)) {
|
|
315
|
+
envContent = readFileSync(envPath, 'utf-8');
|
|
316
|
+
}
|
|
317
|
+
for (const entry of envEntries) {
|
|
318
|
+
const key = entry.split('=')[0];
|
|
319
|
+
if (!envContent.includes(key)) {
|
|
320
|
+
envContent += `\n# Added by corpus scan --fix\n${entry}\n`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
writeFileSync(envPath, envContent);
|
|
324
|
+
}
|
|
325
|
+
process.stdout.write(`\n ${green(`${fixed} issue(s) auto-fixed.`)}\n`);
|
|
326
|
+
if (fixed < fixable.length) {
|
|
327
|
+
process.stdout.write(dim(` ${fixable.length - fixed} issue(s) need manual review.\n`));
|
|
328
|
+
}
|
|
329
|
+
process.stdout.write('\n');
|
|
330
|
+
}
|
|
331
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
332
|
+
export async function runScan() {
|
|
333
|
+
const args = process.argv.slice(3);
|
|
334
|
+
const opts = {
|
|
335
|
+
paths: ['.'],
|
|
336
|
+
format: 'pretty',
|
|
337
|
+
staged: false,
|
|
338
|
+
secrets: false,
|
|
339
|
+
pii: false,
|
|
340
|
+
injection: false,
|
|
341
|
+
safety: false,
|
|
342
|
+
fix: false,
|
|
343
|
+
cve: false,
|
|
344
|
+
deps: false,
|
|
345
|
+
};
|
|
346
|
+
// Parse args — individual flags are collected first; if none are set we
|
|
347
|
+
// default to scanning everything.
|
|
348
|
+
let anyFilterFlag = false;
|
|
349
|
+
const paths = [];
|
|
350
|
+
for (let i = 0; i < args.length; i++) {
|
|
351
|
+
switch (args[i]) {
|
|
352
|
+
case '--format':
|
|
353
|
+
opts.format = args[++i] ?? 'pretty';
|
|
354
|
+
break;
|
|
355
|
+
case '--staged':
|
|
356
|
+
opts.staged = true;
|
|
357
|
+
break;
|
|
358
|
+
case '--json':
|
|
359
|
+
opts.format = 'json';
|
|
360
|
+
break;
|
|
361
|
+
case '--fix':
|
|
362
|
+
opts.fix = true;
|
|
363
|
+
break;
|
|
364
|
+
case '--secrets':
|
|
365
|
+
opts.secrets = true;
|
|
366
|
+
anyFilterFlag = true;
|
|
367
|
+
break;
|
|
368
|
+
case '--pii':
|
|
369
|
+
opts.pii = true;
|
|
370
|
+
anyFilterFlag = true;
|
|
371
|
+
break;
|
|
372
|
+
case '--injection':
|
|
373
|
+
opts.injection = true;
|
|
374
|
+
anyFilterFlag = true;
|
|
375
|
+
break;
|
|
376
|
+
case '--cve':
|
|
377
|
+
opts.cve = true;
|
|
378
|
+
anyFilterFlag = true;
|
|
379
|
+
break;
|
|
380
|
+
case '--deps':
|
|
381
|
+
opts.deps = true;
|
|
382
|
+
anyFilterFlag = true;
|
|
383
|
+
break;
|
|
384
|
+
case '--help':
|
|
385
|
+
case '-h':
|
|
386
|
+
process.stdout.write(`
|
|
387
|
+
corpus scan [paths...] [options]
|
|
388
|
+
|
|
389
|
+
Scan files for secrets, PII, injection patterns, and unsafe code.
|
|
390
|
+
|
|
391
|
+
Options:
|
|
392
|
+
--staged Scan only git staged files (for pre-commit hooks)
|
|
393
|
+
--fix Auto-fix: replace hardcoded secrets with env var references
|
|
394
|
+
--json Output as JSON
|
|
395
|
+
--secrets Scan for secrets only
|
|
396
|
+
--pii Scan for PII only
|
|
397
|
+
--injection Scan for injection patterns only
|
|
398
|
+
--cve Scan for CVE-linked vulnerability patterns
|
|
399
|
+
--deps Check imports for hallucinated/typosquatted packages
|
|
400
|
+
--format <fmt> Output format: pretty (default) or json
|
|
401
|
+
--help Show this help
|
|
402
|
+
|
|
403
|
+
Examples:
|
|
404
|
+
corpus scan Scan current directory
|
|
405
|
+
corpus scan src/ config/ Scan specific directories
|
|
406
|
+
corpus scan --staged Scan staged git changes (pre-commit)
|
|
407
|
+
corpus scan --fix Scan and auto-fix secrets
|
|
408
|
+
corpus scan --json Machine-readable output for CI
|
|
409
|
+
|
|
410
|
+
`);
|
|
411
|
+
return;
|
|
412
|
+
default:
|
|
413
|
+
if (!args[i].startsWith('-'))
|
|
414
|
+
paths.push(args[i]);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// If no filter flags were provided, enable all scan categories (default).
|
|
418
|
+
if (!anyFilterFlag) {
|
|
419
|
+
opts.secrets = true;
|
|
420
|
+
opts.pii = true;
|
|
421
|
+
opts.injection = true;
|
|
422
|
+
opts.safety = true;
|
|
423
|
+
opts.cve = true;
|
|
424
|
+
opts.deps = true;
|
|
425
|
+
}
|
|
426
|
+
if (paths.length > 0)
|
|
427
|
+
opts.paths = paths;
|
|
428
|
+
const start = Date.now();
|
|
429
|
+
const files = getFilesToScan(opts.paths, opts.staged);
|
|
430
|
+
if (files.length === 0) {
|
|
431
|
+
if (opts.format === 'json') {
|
|
432
|
+
process.stdout.write(JSON.stringify({ scannedFiles: 0, findings: [], summary: { critical: 0, warning: 0, info: 0 } }) + '\n');
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
process.stdout.write(dim('\n No scannable files found.\n\n'));
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const allFindings = [];
|
|
440
|
+
for (const filepath of files) {
|
|
441
|
+
allFindings.push(...scanFile(filepath, opts));
|
|
442
|
+
}
|
|
443
|
+
// Dependency checking (async - runs after file scan)
|
|
444
|
+
if (opts.deps) {
|
|
445
|
+
for (const filepath of files) {
|
|
446
|
+
try {
|
|
447
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
448
|
+
const depFindings = await checkDependencies(content, filepath, { projectRoot: opts.paths[0] || '.' });
|
|
449
|
+
for (const d of depFindings) {
|
|
450
|
+
allFindings.push({
|
|
451
|
+
severity: d.severity,
|
|
452
|
+
type: `Hallucinated Dep: ${d.package}`,
|
|
453
|
+
file: filepath,
|
|
454
|
+
line: d.line,
|
|
455
|
+
message: `Package '${d.package}' ${d.reason === 'nonexistent' ? 'does not exist on npm' : d.reason === 'typosquat' ? 'may be a typosquat' : 'is suspiciously unpopular'}`,
|
|
456
|
+
suggestion: d.suggestion,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch { }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const timeMs = Date.now() - start;
|
|
464
|
+
if (opts.format === 'json') {
|
|
465
|
+
formatJson(allFindings, files.length, timeMs);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
formatPretty(allFindings, files.length, timeMs);
|
|
469
|
+
}
|
|
470
|
+
// Auto-fix mode
|
|
471
|
+
if (opts.fix && allFindings.length > 0) {
|
|
472
|
+
autoFix(allFindings);
|
|
473
|
+
}
|
|
474
|
+
// Exit code: 2 for critical, 1 for warnings, 0 for clean
|
|
475
|
+
const hasCritical = allFindings.some((f) => f.severity === 'CRITICAL');
|
|
476
|
+
const hasWarning = allFindings.some((f) => f.severity === 'WARNING');
|
|
477
|
+
if (hasCritical)
|
|
478
|
+
process.exit(2);
|
|
479
|
+
if (hasWarning)
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runVerify(): Promise<void>;
|