ship-safe 6.1.1 → 6.2.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/README.md +735 -641
- package/cli/agents/api-fuzzer.js +345 -345
- package/cli/agents/auth-bypass-agent.js +348 -348
- package/cli/agents/base-agent.js +272 -272
- package/cli/agents/cicd-scanner.js +236 -201
- package/cli/agents/config-auditor.js +521 -521
- package/cli/agents/deep-analyzer.js +6 -2
- package/cli/agents/git-history-scanner.js +170 -170
- package/cli/agents/html-reporter.js +568 -568
- package/cli/agents/index.js +84 -84
- package/cli/agents/injection-tester.js +500 -500
- package/cli/agents/llm-redteam.js +251 -251
- package/cli/agents/mobile-scanner.js +231 -231
- package/cli/agents/orchestrator.js +322 -322
- package/cli/agents/pii-compliance-agent.js +301 -301
- package/cli/agents/scoring-engine.js +248 -248
- package/cli/agents/supabase-rls-agent.js +154 -154
- package/cli/agents/supply-chain-agent.js +650 -507
- package/cli/bin/ship-safe.js +452 -426
- package/cli/commands/agent.js +608 -608
- package/cli/commands/audit.js +986 -980
- package/cli/commands/baseline.js +193 -193
- package/cli/commands/ci.js +342 -342
- package/cli/commands/deps.js +516 -516
- package/cli/commands/doctor.js +159 -159
- package/cli/commands/fix.js +218 -218
- package/cli/commands/hooks.js +268 -0
- package/cli/commands/init.js +407 -407
- package/cli/commands/mcp.js +304 -304
- package/cli/commands/red-team.js +7 -1
- package/cli/commands/remediate.js +798 -798
- package/cli/commands/rotate.js +571 -571
- package/cli/commands/scan.js +569 -569
- package/cli/commands/score.js +449 -449
- package/cli/commands/watch.js +281 -281
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +73 -69
- package/cli/providers/llm-provider.js +397 -287
- package/cli/utils/autofix-rules.js +74 -74
- package/cli/utils/cache-manager.js +311 -311
- package/cli/utils/output.js +230 -230
- package/cli/utils/patterns.js +1121 -1121
- package/cli/utils/pdf-generator.js +94 -94
- package/package.json +69 -69
- package/configs/supabase/rls-templates.sql +0 -242
package/cli/commands/score.js
CHANGED
|
@@ -1,449 +1,449 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Score Command
|
|
3
|
-
* =============
|
|
4
|
-
*
|
|
5
|
-
* Compute a 0-100 security health score for your project.
|
|
6
|
-
* Combines secret detection, code vulnerability detection, and dependency auditing.
|
|
7
|
-
*
|
|
8
|
-
* USAGE:
|
|
9
|
-
* npx ship-safe score [path] Score the project in the current directory
|
|
10
|
-
* npx ship-safe score . --no-deps Skip dependency audit (faster)
|
|
11
|
-
*
|
|
12
|
-
* SCORING ALGORITHM (starts at 100):
|
|
13
|
-
* Secrets: critical −25, high −15, medium −5 (capped at −40)
|
|
14
|
-
* Code Vulns: critical −20, high −10, medium −3 (capped at −30)
|
|
15
|
-
* Dependencies: critical −20, high −10, moderate −5 (capped at −30)
|
|
16
|
-
*
|
|
17
|
-
* GRADES:
|
|
18
|
-
* A 90–100 Ship it!
|
|
19
|
-
* B 75–89 Minor issues to review
|
|
20
|
-
* C 60–74 Fix before shipping
|
|
21
|
-
* D 40–59 Significant security risks
|
|
22
|
-
* F 0–39 Not safe to ship
|
|
23
|
-
*
|
|
24
|
-
* EXIT CODES:
|
|
25
|
-
* 0 - Score is A or B (90+/75+)
|
|
26
|
-
* 1 - Score is C or below (< 75)
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import fs from 'fs';
|
|
30
|
-
import path from 'path';
|
|
31
|
-
import fg from 'fast-glob';
|
|
32
|
-
import chalk from 'chalk';
|
|
33
|
-
import ora from 'ora';
|
|
34
|
-
import {
|
|
35
|
-
SECRET_PATTERNS,
|
|
36
|
-
SECURITY_PATTERNS,
|
|
37
|
-
SKIP_DIRS,
|
|
38
|
-
SKIP_EXTENSIONS,
|
|
39
|
-
SKIP_FILENAMES,
|
|
40
|
-
TEST_FILE_PATTERNS,
|
|
41
|
-
MAX_FILE_SIZE
|
|
42
|
-
} from '../utils/patterns.js';
|
|
43
|
-
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
44
|
-
import { runDepsAudit } from './deps.js';
|
|
45
|
-
import * as output from '../utils/output.js';
|
|
46
|
-
|
|
47
|
-
// =============================================================================
|
|
48
|
-
// SCORING CONSTANTS
|
|
49
|
-
// =============================================================================
|
|
50
|
-
|
|
51
|
-
const SECRET_DEDUCTIONS = { critical: 25, high: 15, medium: 5 };
|
|
52
|
-
const SECRET_CAP = 40;
|
|
53
|
-
|
|
54
|
-
const VULN_DEDUCTIONS = { critical: 20, high: 10, medium: 3 };
|
|
55
|
-
const VULN_CAP = 30;
|
|
56
|
-
|
|
57
|
-
const DEP_DEDUCTIONS = { critical: 20, high: 10, moderate: 5, medium: 5 };
|
|
58
|
-
const DEP_CAP = 30;
|
|
59
|
-
|
|
60
|
-
const GRADES = [
|
|
61
|
-
{ min: 90, letter: 'A', label: 'Ship it!' },
|
|
62
|
-
{ min: 75, letter: 'B', label: 'Minor issues to review' },
|
|
63
|
-
{ min: 60, letter: 'C', label: 'Fix before shipping' },
|
|
64
|
-
{ min: 40, letter: 'D', label: 'Significant security risks' },
|
|
65
|
-
{ min: 0, letter: 'F', label: 'Not safe to ship' },
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
// =============================================================================
|
|
69
|
-
// MAIN COMMAND
|
|
70
|
-
// =============================================================================
|
|
71
|
-
|
|
72
|
-
export async function scoreCommand(targetPath = '.', options = {}) {
|
|
73
|
-
const absolutePath = path.resolve(targetPath);
|
|
74
|
-
|
|
75
|
-
if (!fs.existsSync(absolutePath)) {
|
|
76
|
-
output.error(`Path does not exist: ${absolutePath}`);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
console.log();
|
|
81
|
-
output.header('Security Health Score');
|
|
82
|
-
console.log();
|
|
83
|
-
|
|
84
|
-
const runDeps = options.deps !== false; // --no-deps sets options.deps = false
|
|
85
|
-
|
|
86
|
-
// ── 1. Scan for secrets and code vulns ──────────────────────────────────────
|
|
87
|
-
const spinner = ora({ text: 'Scanning for secrets and vulnerabilities...', color: 'cyan' }).start();
|
|
88
|
-
|
|
89
|
-
let findings = [];
|
|
90
|
-
let filesScanned = 0;
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const files = await findFiles(absolutePath);
|
|
94
|
-
filesScanned = files.length;
|
|
95
|
-
spinner.text = `Scanning ${files.length} files...`;
|
|
96
|
-
|
|
97
|
-
for (const file of files) {
|
|
98
|
-
const fileFindings = scanFile(file);
|
|
99
|
-
findings = findings.concat(fileFindings);
|
|
100
|
-
}
|
|
101
|
-
spinner.stop();
|
|
102
|
-
} catch (err) {
|
|
103
|
-
spinner.fail('Scan failed');
|
|
104
|
-
output.error(err.message);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const secretFindings = findings.filter(f => f.category !== 'vulnerability');
|
|
109
|
-
const vulnFindings = findings.filter(f => f.category === 'vulnerability');
|
|
110
|
-
|
|
111
|
-
// ── 2. Dependency audit ──────────────────────────────────────────────────────
|
|
112
|
-
let depVulns = [];
|
|
113
|
-
let pm = null;
|
|
114
|
-
|
|
115
|
-
if (runDeps) {
|
|
116
|
-
const depSpinner = ora({ text: 'Auditing dependencies...', color: 'cyan' }).start();
|
|
117
|
-
try {
|
|
118
|
-
const result = await runDepsAudit(absolutePath);
|
|
119
|
-
pm = result.pm;
|
|
120
|
-
depVulns = result.vulns;
|
|
121
|
-
depSpinner.stop();
|
|
122
|
-
} catch {
|
|
123
|
-
depSpinner.stop();
|
|
124
|
-
// Dep audit failure doesn't block scoring — just skip
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── 3. Compute score ─────────────────────────────────────────────────────────
|
|
129
|
-
const { score, secretDeduction, vulnDeduction, depDeduction, secretCounts, vulnCounts, depCounts } =
|
|
130
|
-
computeScore(secretFindings, vulnFindings, depVulns);
|
|
131
|
-
|
|
132
|
-
const grade = GRADES.find(g => score >= g.min);
|
|
133
|
-
|
|
134
|
-
// ── 4. Print results ─────────────────────────────────────────────────────────
|
|
135
|
-
printScore(score, grade, {
|
|
136
|
-
secretDeduction, vulnDeduction, depDeduction,
|
|
137
|
-
secretCounts, vulnCounts, depCounts,
|
|
138
|
-
filesScanned, pm, runDeps
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// Exit 0 for A/B, exit 1 for C/D/F
|
|
142
|
-
process.exit(score >= 75 ? 0 : 1);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// =============================================================================
|
|
146
|
-
// SCORE COMPUTATION
|
|
147
|
-
// =============================================================================
|
|
148
|
-
|
|
149
|
-
function computeScore(secretFindings, vulnFindings, depVulns) {
|
|
150
|
-
// ── Count by severity ────────────────────────────────────────────────────────
|
|
151
|
-
const secretCounts = countBySeverity(secretFindings);
|
|
152
|
-
const vulnCounts = countBySeverity(vulnFindings);
|
|
153
|
-
const depCounts = countBySeverity(depVulns);
|
|
154
|
-
|
|
155
|
-
// ── Compute deductions ───────────────────────────────────────────────────────
|
|
156
|
-
let secretDeduction = 0;
|
|
157
|
-
for (const [sev, pts] of Object.entries(SECRET_DEDUCTIONS)) {
|
|
158
|
-
secretDeduction += (secretCounts[sev] || 0) * pts;
|
|
159
|
-
}
|
|
160
|
-
secretDeduction = Math.min(secretDeduction, SECRET_CAP);
|
|
161
|
-
|
|
162
|
-
let vulnDeduction = 0;
|
|
163
|
-
for (const [sev, pts] of Object.entries(VULN_DEDUCTIONS)) {
|
|
164
|
-
vulnDeduction += (vulnCounts[sev] || 0) * pts;
|
|
165
|
-
}
|
|
166
|
-
vulnDeduction = Math.min(vulnDeduction, VULN_CAP);
|
|
167
|
-
|
|
168
|
-
let depDeduction = 0;
|
|
169
|
-
for (const [sev, pts] of Object.entries(DEP_DEDUCTIONS)) {
|
|
170
|
-
depDeduction += (depCounts[sev] || 0) * pts;
|
|
171
|
-
}
|
|
172
|
-
depDeduction = Math.min(depDeduction, DEP_CAP);
|
|
173
|
-
|
|
174
|
-
const score = Math.max(0, 100 - secretDeduction - vulnDeduction - depDeduction);
|
|
175
|
-
|
|
176
|
-
return { score, secretDeduction, vulnDeduction, depDeduction, secretCounts, vulnCounts, depCounts };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function countBySeverity(findings) {
|
|
180
|
-
const counts = {};
|
|
181
|
-
for (const f of findings) {
|
|
182
|
-
const sev = f.severity || 'unknown';
|
|
183
|
-
counts[sev] = (counts[sev] || 0) + 1;
|
|
184
|
-
}
|
|
185
|
-
return counts;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// =============================================================================
|
|
189
|
-
// OUTPUT
|
|
190
|
-
// =============================================================================
|
|
191
|
-
|
|
192
|
-
const GRADE_COLOR = {
|
|
193
|
-
A: chalk.green.bold,
|
|
194
|
-
B: chalk.cyan.bold,
|
|
195
|
-
C: chalk.yellow.bold,
|
|
196
|
-
D: chalk.red,
|
|
197
|
-
F: chalk.red.bold,
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
function printScore(score, grade, ctx) {
|
|
201
|
-
const gradeColor = GRADE_COLOR[grade.letter] || chalk.white;
|
|
202
|
-
const scoreColor = score >= 75 ? chalk.green.bold : score >= 60 ? chalk.yellow.bold : chalk.red.bold;
|
|
203
|
-
|
|
204
|
-
// ── Score headline ───────────────────────────────────────────────────────────
|
|
205
|
-
console.log(
|
|
206
|
-
chalk.white.bold(' Ship Safe Score: ') +
|
|
207
|
-
scoreColor(`${score}/100`) +
|
|
208
|
-
chalk.gray(' ') +
|
|
209
|
-
gradeColor(`${grade.letter}`) +
|
|
210
|
-
chalk.gray(` — ${grade.label}`)
|
|
211
|
-
);
|
|
212
|
-
console.log(chalk.cyan(' ' + '─'.repeat(58)));
|
|
213
|
-
console.log();
|
|
214
|
-
|
|
215
|
-
// ── Row: Secrets ─────────────────────────────────────────────────────────────
|
|
216
|
-
const secretCount = Object.values(ctx.secretCounts).reduce((a, b) => a + b, 0);
|
|
217
|
-
const secretIcon = secretCount === 0 ? chalk.green('✔') : chalk.red('✘');
|
|
218
|
-
const secretStatus = secretCount === 0
|
|
219
|
-
? chalk.green('0 found')
|
|
220
|
-
: chalk.red(`${secretCount} found`);
|
|
221
|
-
const secretDeductStr = ctx.secretDeduction === 0
|
|
222
|
-
? chalk.gray('+0 deductions')
|
|
223
|
-
: chalk.red(`−${ctx.secretDeduction} points`) + chalk.gray(` (${formatCounts(ctx.secretCounts)})`);
|
|
224
|
-
|
|
225
|
-
console.log(
|
|
226
|
-
` ${secretIcon} ${chalk.white.bold('Secrets ')} ${secretStatus.padEnd(18)} ${secretDeductStr}`
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
// ── Row: Code Vulns ───────────────────────────────────────────────────────────
|
|
230
|
-
const vulnCount = Object.values(ctx.vulnCounts).reduce((a, b) => a + b, 0);
|
|
231
|
-
const vulnIcon = vulnCount === 0 ? chalk.green('✔') : chalk.yellow('✘');
|
|
232
|
-
const vulnStatus = vulnCount === 0
|
|
233
|
-
? chalk.green('0 found')
|
|
234
|
-
: chalk.yellow(`${vulnCount} found`);
|
|
235
|
-
const vulnDeductStr = ctx.vulnDeduction === 0
|
|
236
|
-
? chalk.gray('+0 deductions')
|
|
237
|
-
: chalk.yellow(`−${ctx.vulnDeduction} points`) + chalk.gray(` (${formatCounts(ctx.vulnCounts)})`);
|
|
238
|
-
|
|
239
|
-
console.log(
|
|
240
|
-
` ${vulnIcon} ${chalk.white.bold('Code Vulns ')} ${vulnStatus.padEnd(18)} ${vulnDeductStr}`
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
// ── Row: Dependencies ─────────────────────────────────────────────────────────
|
|
244
|
-
if (ctx.runDeps) {
|
|
245
|
-
const depCount = Object.values(ctx.depCounts).reduce((a, b) => a + b, 0);
|
|
246
|
-
const depIcon = depCount === 0 ? chalk.green('✔') : chalk.red('✘');
|
|
247
|
-
const depLabel = ctx.pm ? `Dependencies ` : 'Dependencies ';
|
|
248
|
-
|
|
249
|
-
let depStatus, depDeductStr;
|
|
250
|
-
|
|
251
|
-
if (!ctx.pm) {
|
|
252
|
-
depStatus = chalk.gray('no manifest');
|
|
253
|
-
depDeductStr = chalk.gray('+0 deductions');
|
|
254
|
-
} else if (depCount === 0) {
|
|
255
|
-
depStatus = chalk.green('0 CVEs');
|
|
256
|
-
depDeductStr = chalk.gray('+0 deductions');
|
|
257
|
-
} else {
|
|
258
|
-
depStatus = chalk.red(`${depCount} CVEs`);
|
|
259
|
-
depDeductStr = chalk.red(`−${ctx.depDeduction} points`) + chalk.gray(` (${formatCounts(ctx.depCounts)})`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
console.log(
|
|
263
|
-
` ${depIcon} ${chalk.white.bold(depLabel)} ${depStatus.padEnd(18)} ${depDeductStr}`
|
|
264
|
-
);
|
|
265
|
-
} else {
|
|
266
|
-
console.log(
|
|
267
|
-
` ${chalk.gray('–')} ${chalk.gray('Dependencies skipped (--no-deps)')}`
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
console.log();
|
|
272
|
-
console.log(chalk.cyan(' ' + '─'.repeat(58)));
|
|
273
|
-
console.log(chalk.gray(` Files scanned: ${ctx.filesScanned}`));
|
|
274
|
-
|
|
275
|
-
// ── Next steps ────────────────────────────────────────────────────────────────
|
|
276
|
-
if (score < 100) {
|
|
277
|
-
console.log();
|
|
278
|
-
const actions = [];
|
|
279
|
-
if (Object.values(ctx.secretCounts).some(n => n > 0)) {
|
|
280
|
-
actions.push(chalk.white(' npx ship-safe agent .') + chalk.gray(' # AI audit: classify + auto-fix secrets'));
|
|
281
|
-
}
|
|
282
|
-
if (Object.values(ctx.vulnCounts).some(n => n > 0)) {
|
|
283
|
-
actions.push(chalk.white(' npx ship-safe agent .') + chalk.gray(' # AI audit: classify + fix suggestions'));
|
|
284
|
-
}
|
|
285
|
-
if (ctx.runDeps && Object.values(ctx.depCounts).some(n => n > 0) && ctx.pm) {
|
|
286
|
-
actions.push(chalk.white(` npx ship-safe deps .`) + chalk.gray(' # See full dependency CVE details'));
|
|
287
|
-
}
|
|
288
|
-
if (actions.length > 0) {
|
|
289
|
-
console.log(chalk.gray(' Fix issues:'));
|
|
290
|
-
// Deduplicate (agent appears for both secrets and vulns)
|
|
291
|
-
const seen = new Set();
|
|
292
|
-
for (const a of actions) {
|
|
293
|
-
if (!seen.has(a)) { console.log(a); seen.add(a); }
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
} else {
|
|
297
|
-
console.log();
|
|
298
|
-
console.log(chalk.green(' All clear — safe to ship!'));
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
console.log(chalk.cyan('='.repeat(60)));
|
|
302
|
-
console.log(chalk.gray(' Share your score & track trends: ') + chalk.cyan('https://shipsafecli.com'));
|
|
303
|
-
console.log();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function formatCounts(counts) {
|
|
307
|
-
const SEV_ORDER = ['critical', 'high', 'moderate', 'medium', 'low'];
|
|
308
|
-
return SEV_ORDER
|
|
309
|
-
.filter(s => counts[s] > 0)
|
|
310
|
-
.map(s => `${counts[s]} ${s}`)
|
|
311
|
-
.join(', ');
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// =============================================================================
|
|
315
|
-
// INTERNAL SCAN (no subprocess — import patterns directly)
|
|
316
|
-
// =============================================================================
|
|
317
|
-
|
|
318
|
-
const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Find all scannable files (same logic as scan.js, without test-exclusion
|
|
322
|
-
* and without .ship-safeignore loading — score is a quick overview).
|
|
323
|
-
*/
|
|
324
|
-
async function findFiles(rootPath) {
|
|
325
|
-
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
326
|
-
|
|
327
|
-
const files = await fg('**/*', {
|
|
328
|
-
cwd: rootPath,
|
|
329
|
-
absolute: true,
|
|
330
|
-
onlyFiles: true,
|
|
331
|
-
ignore: globIgnore,
|
|
332
|
-
dot: true
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const filtered = [];
|
|
336
|
-
|
|
337
|
-
for (const file of files) {
|
|
338
|
-
const ext = path.extname(file).toLowerCase();
|
|
339
|
-
if (SKIP_EXTENSIONS.has(ext)) continue;
|
|
340
|
-
if (SKIP_FILENAMES.has(path.basename(file))) continue;
|
|
341
|
-
|
|
342
|
-
const basename = path.basename(file);
|
|
343
|
-
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
|
|
344
|
-
|
|
345
|
-
// Load and respect .ship-safeignore
|
|
346
|
-
if (isIgnoredByFile(file, rootPath)) continue;
|
|
347
|
-
|
|
348
|
-
try {
|
|
349
|
-
const stats = fs.statSync(file);
|
|
350
|
-
if (stats.size > MAX_FILE_SIZE) continue;
|
|
351
|
-
} catch {
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
filtered.push(file);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return filtered;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Cache ignore patterns per root to avoid re-reading the file thousands of times
|
|
362
|
-
const _ignoreCache = new Map();
|
|
363
|
-
|
|
364
|
-
function loadIgnorePatterns(rootPath) {
|
|
365
|
-
if (_ignoreCache.has(rootPath)) return _ignoreCache.get(rootPath);
|
|
366
|
-
|
|
367
|
-
const ignorePath = path.join(rootPath, '.ship-safeignore');
|
|
368
|
-
let patterns = [];
|
|
369
|
-
|
|
370
|
-
if (fs.existsSync(ignorePath)) {
|
|
371
|
-
try {
|
|
372
|
-
patterns = fs.readFileSync(ignorePath, 'utf-8')
|
|
373
|
-
.split('\n')
|
|
374
|
-
.map(l => l.trim())
|
|
375
|
-
.filter(l => l && !l.startsWith('#'));
|
|
376
|
-
} catch {
|
|
377
|
-
// ignore read error
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
_ignoreCache.set(rootPath, patterns);
|
|
382
|
-
return patterns;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function isIgnoredByFile(filePath, rootPath) {
|
|
386
|
-
const patterns = loadIgnorePatterns(rootPath);
|
|
387
|
-
if (patterns.length === 0) return false;
|
|
388
|
-
|
|
389
|
-
const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
|
|
390
|
-
|
|
391
|
-
return patterns.some(pattern => {
|
|
392
|
-
if (pattern.endsWith('/')) {
|
|
393
|
-
return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
|
|
394
|
-
}
|
|
395
|
-
const escaped = pattern
|
|
396
|
-
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
397
|
-
.replace(/\*/g, '[^/]*')
|
|
398
|
-
.replace(/\?/g, '[^/]');
|
|
399
|
-
return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Scan a single file and return normalized findings.
|
|
405
|
-
* Same algorithm as scan.js — inline here to avoid circular dependency
|
|
406
|
-
* (scan.js has process.exit() side effects).
|
|
407
|
-
*/
|
|
408
|
-
function scanFile(filePath) {
|
|
409
|
-
const findings = [];
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
413
|
-
const lines = content.split('\n');
|
|
414
|
-
|
|
415
|
-
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
416
|
-
const line = lines[lineNum];
|
|
417
|
-
|
|
418
|
-
if (/ship-safe-ignore/i.test(line)) continue;
|
|
419
|
-
|
|
420
|
-
for (const pattern of ALL_PATTERNS) {
|
|
421
|
-
pattern.pattern.lastIndex = 0;
|
|
422
|
-
|
|
423
|
-
let match;
|
|
424
|
-
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
425
|
-
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
findings.push({
|
|
430
|
-
line: lineNum + 1,
|
|
431
|
-
severity: pattern.severity,
|
|
432
|
-
category: pattern.category || 'secret',
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
} catch {
|
|
438
|
-
// Skip unreadable files
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Deduplicate: same (line, severity, category)
|
|
442
|
-
const seen = new Set();
|
|
443
|
-
return findings.filter(f => {
|
|
444
|
-
const key = `${f.line}:${f.severity}:${f.category}`;
|
|
445
|
-
if (seen.has(key)) return false;
|
|
446
|
-
seen.add(key);
|
|
447
|
-
return true;
|
|
448
|
-
});
|
|
449
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Score Command
|
|
3
|
+
* =============
|
|
4
|
+
*
|
|
5
|
+
* Compute a 0-100 security health score for your project.
|
|
6
|
+
* Combines secret detection, code vulnerability detection, and dependency auditing.
|
|
7
|
+
*
|
|
8
|
+
* USAGE:
|
|
9
|
+
* npx ship-safe score [path] Score the project in the current directory
|
|
10
|
+
* npx ship-safe score . --no-deps Skip dependency audit (faster)
|
|
11
|
+
*
|
|
12
|
+
* SCORING ALGORITHM (starts at 100):
|
|
13
|
+
* Secrets: critical −25, high −15, medium −5 (capped at −40)
|
|
14
|
+
* Code Vulns: critical −20, high −10, medium −3 (capped at −30)
|
|
15
|
+
* Dependencies: critical −20, high −10, moderate −5 (capped at −30)
|
|
16
|
+
*
|
|
17
|
+
* GRADES:
|
|
18
|
+
* A 90–100 Ship it!
|
|
19
|
+
* B 75–89 Minor issues to review
|
|
20
|
+
* C 60–74 Fix before shipping
|
|
21
|
+
* D 40–59 Significant security risks
|
|
22
|
+
* F 0–39 Not safe to ship
|
|
23
|
+
*
|
|
24
|
+
* EXIT CODES:
|
|
25
|
+
* 0 - Score is A or B (90+/75+)
|
|
26
|
+
* 1 - Score is C or below (< 75)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import path from 'path';
|
|
31
|
+
import fg from 'fast-glob';
|
|
32
|
+
import chalk from 'chalk';
|
|
33
|
+
import ora from 'ora';
|
|
34
|
+
import {
|
|
35
|
+
SECRET_PATTERNS,
|
|
36
|
+
SECURITY_PATTERNS,
|
|
37
|
+
SKIP_DIRS,
|
|
38
|
+
SKIP_EXTENSIONS,
|
|
39
|
+
SKIP_FILENAMES,
|
|
40
|
+
TEST_FILE_PATTERNS,
|
|
41
|
+
MAX_FILE_SIZE
|
|
42
|
+
} from '../utils/patterns.js';
|
|
43
|
+
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
44
|
+
import { runDepsAudit } from './deps.js';
|
|
45
|
+
import * as output from '../utils/output.js';
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// SCORING CONSTANTS
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
const SECRET_DEDUCTIONS = { critical: 25, high: 15, medium: 5 };
|
|
52
|
+
const SECRET_CAP = 40;
|
|
53
|
+
|
|
54
|
+
const VULN_DEDUCTIONS = { critical: 20, high: 10, medium: 3 };
|
|
55
|
+
const VULN_CAP = 30;
|
|
56
|
+
|
|
57
|
+
const DEP_DEDUCTIONS = { critical: 20, high: 10, moderate: 5, medium: 5 };
|
|
58
|
+
const DEP_CAP = 30;
|
|
59
|
+
|
|
60
|
+
const GRADES = [
|
|
61
|
+
{ min: 90, letter: 'A', label: 'Ship it!' },
|
|
62
|
+
{ min: 75, letter: 'B', label: 'Minor issues to review' },
|
|
63
|
+
{ min: 60, letter: 'C', label: 'Fix before shipping' },
|
|
64
|
+
{ min: 40, letter: 'D', label: 'Significant security risks' },
|
|
65
|
+
{ min: 0, letter: 'F', label: 'Not safe to ship' },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// MAIN COMMAND
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
export async function scoreCommand(targetPath = '.', options = {}) {
|
|
73
|
+
const absolutePath = path.resolve(targetPath);
|
|
74
|
+
|
|
75
|
+
if (!fs.existsSync(absolutePath)) {
|
|
76
|
+
output.error(`Path does not exist: ${absolutePath}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log();
|
|
81
|
+
output.header('Security Health Score');
|
|
82
|
+
console.log();
|
|
83
|
+
|
|
84
|
+
const runDeps = options.deps !== false; // --no-deps sets options.deps = false
|
|
85
|
+
|
|
86
|
+
// ── 1. Scan for secrets and code vulns ──────────────────────────────────────
|
|
87
|
+
const spinner = ora({ text: 'Scanning for secrets and vulnerabilities...', color: 'cyan' }).start();
|
|
88
|
+
|
|
89
|
+
let findings = [];
|
|
90
|
+
let filesScanned = 0;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const files = await findFiles(absolutePath);
|
|
94
|
+
filesScanned = files.length;
|
|
95
|
+
spinner.text = `Scanning ${files.length} files...`;
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const fileFindings = scanFile(file);
|
|
99
|
+
findings = findings.concat(fileFindings);
|
|
100
|
+
}
|
|
101
|
+
spinner.stop();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
spinner.fail('Scan failed');
|
|
104
|
+
output.error(err.message);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const secretFindings = findings.filter(f => f.category !== 'vulnerability');
|
|
109
|
+
const vulnFindings = findings.filter(f => f.category === 'vulnerability');
|
|
110
|
+
|
|
111
|
+
// ── 2. Dependency audit ──────────────────────────────────────────────────────
|
|
112
|
+
let depVulns = [];
|
|
113
|
+
let pm = null;
|
|
114
|
+
|
|
115
|
+
if (runDeps) {
|
|
116
|
+
const depSpinner = ora({ text: 'Auditing dependencies...', color: 'cyan' }).start();
|
|
117
|
+
try {
|
|
118
|
+
const result = await runDepsAudit(absolutePath);
|
|
119
|
+
pm = result.pm;
|
|
120
|
+
depVulns = result.vulns;
|
|
121
|
+
depSpinner.stop();
|
|
122
|
+
} catch {
|
|
123
|
+
depSpinner.stop();
|
|
124
|
+
// Dep audit failure doesn't block scoring — just skip
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── 3. Compute score ─────────────────────────────────────────────────────────
|
|
129
|
+
const { score, secretDeduction, vulnDeduction, depDeduction, secretCounts, vulnCounts, depCounts } =
|
|
130
|
+
computeScore(secretFindings, vulnFindings, depVulns);
|
|
131
|
+
|
|
132
|
+
const grade = GRADES.find(g => score >= g.min);
|
|
133
|
+
|
|
134
|
+
// ── 4. Print results ─────────────────────────────────────────────────────────
|
|
135
|
+
printScore(score, grade, {
|
|
136
|
+
secretDeduction, vulnDeduction, depDeduction,
|
|
137
|
+
secretCounts, vulnCounts, depCounts,
|
|
138
|
+
filesScanned, pm, runDeps
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Exit 0 for A/B, exit 1 for C/D/F
|
|
142
|
+
process.exit(score >= 75 ? 0 : 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// SCORE COMPUTATION
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
function computeScore(secretFindings, vulnFindings, depVulns) {
|
|
150
|
+
// ── Count by severity ────────────────────────────────────────────────────────
|
|
151
|
+
const secretCounts = countBySeverity(secretFindings);
|
|
152
|
+
const vulnCounts = countBySeverity(vulnFindings);
|
|
153
|
+
const depCounts = countBySeverity(depVulns);
|
|
154
|
+
|
|
155
|
+
// ── Compute deductions ───────────────────────────────────────────────────────
|
|
156
|
+
let secretDeduction = 0;
|
|
157
|
+
for (const [sev, pts] of Object.entries(SECRET_DEDUCTIONS)) {
|
|
158
|
+
secretDeduction += (secretCounts[sev] || 0) * pts;
|
|
159
|
+
}
|
|
160
|
+
secretDeduction = Math.min(secretDeduction, SECRET_CAP);
|
|
161
|
+
|
|
162
|
+
let vulnDeduction = 0;
|
|
163
|
+
for (const [sev, pts] of Object.entries(VULN_DEDUCTIONS)) {
|
|
164
|
+
vulnDeduction += (vulnCounts[sev] || 0) * pts;
|
|
165
|
+
}
|
|
166
|
+
vulnDeduction = Math.min(vulnDeduction, VULN_CAP);
|
|
167
|
+
|
|
168
|
+
let depDeduction = 0;
|
|
169
|
+
for (const [sev, pts] of Object.entries(DEP_DEDUCTIONS)) {
|
|
170
|
+
depDeduction += (depCounts[sev] || 0) * pts;
|
|
171
|
+
}
|
|
172
|
+
depDeduction = Math.min(depDeduction, DEP_CAP);
|
|
173
|
+
|
|
174
|
+
const score = Math.max(0, 100 - secretDeduction - vulnDeduction - depDeduction);
|
|
175
|
+
|
|
176
|
+
return { score, secretDeduction, vulnDeduction, depDeduction, secretCounts, vulnCounts, depCounts };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function countBySeverity(findings) {
|
|
180
|
+
const counts = {};
|
|
181
|
+
for (const f of findings) {
|
|
182
|
+
const sev = f.severity || 'unknown';
|
|
183
|
+
counts[sev] = (counts[sev] || 0) + 1;
|
|
184
|
+
}
|
|
185
|
+
return counts;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// =============================================================================
|
|
189
|
+
// OUTPUT
|
|
190
|
+
// =============================================================================
|
|
191
|
+
|
|
192
|
+
const GRADE_COLOR = {
|
|
193
|
+
A: chalk.green.bold,
|
|
194
|
+
B: chalk.cyan.bold,
|
|
195
|
+
C: chalk.yellow.bold,
|
|
196
|
+
D: chalk.red,
|
|
197
|
+
F: chalk.red.bold,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function printScore(score, grade, ctx) {
|
|
201
|
+
const gradeColor = GRADE_COLOR[grade.letter] || chalk.white;
|
|
202
|
+
const scoreColor = score >= 75 ? chalk.green.bold : score >= 60 ? chalk.yellow.bold : chalk.red.bold;
|
|
203
|
+
|
|
204
|
+
// ── Score headline ───────────────────────────────────────────────────────────
|
|
205
|
+
console.log(
|
|
206
|
+
chalk.white.bold(' Ship Safe Score: ') +
|
|
207
|
+
scoreColor(`${score}/100`) +
|
|
208
|
+
chalk.gray(' ') +
|
|
209
|
+
gradeColor(`${grade.letter}`) +
|
|
210
|
+
chalk.gray(` — ${grade.label}`)
|
|
211
|
+
);
|
|
212
|
+
console.log(chalk.cyan(' ' + '─'.repeat(58)));
|
|
213
|
+
console.log();
|
|
214
|
+
|
|
215
|
+
// ── Row: Secrets ─────────────────────────────────────────────────────────────
|
|
216
|
+
const secretCount = Object.values(ctx.secretCounts).reduce((a, b) => a + b, 0);
|
|
217
|
+
const secretIcon = secretCount === 0 ? chalk.green('✔') : chalk.red('✘');
|
|
218
|
+
const secretStatus = secretCount === 0
|
|
219
|
+
? chalk.green('0 found')
|
|
220
|
+
: chalk.red(`${secretCount} found`);
|
|
221
|
+
const secretDeductStr = ctx.secretDeduction === 0
|
|
222
|
+
? chalk.gray('+0 deductions')
|
|
223
|
+
: chalk.red(`−${ctx.secretDeduction} points`) + chalk.gray(` (${formatCounts(ctx.secretCounts)})`);
|
|
224
|
+
|
|
225
|
+
console.log(
|
|
226
|
+
` ${secretIcon} ${chalk.white.bold('Secrets ')} ${secretStatus.padEnd(18)} ${secretDeductStr}`
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// ── Row: Code Vulns ───────────────────────────────────────────────────────────
|
|
230
|
+
const vulnCount = Object.values(ctx.vulnCounts).reduce((a, b) => a + b, 0);
|
|
231
|
+
const vulnIcon = vulnCount === 0 ? chalk.green('✔') : chalk.yellow('✘');
|
|
232
|
+
const vulnStatus = vulnCount === 0
|
|
233
|
+
? chalk.green('0 found')
|
|
234
|
+
: chalk.yellow(`${vulnCount} found`);
|
|
235
|
+
const vulnDeductStr = ctx.vulnDeduction === 0
|
|
236
|
+
? chalk.gray('+0 deductions')
|
|
237
|
+
: chalk.yellow(`−${ctx.vulnDeduction} points`) + chalk.gray(` (${formatCounts(ctx.vulnCounts)})`);
|
|
238
|
+
|
|
239
|
+
console.log(
|
|
240
|
+
` ${vulnIcon} ${chalk.white.bold('Code Vulns ')} ${vulnStatus.padEnd(18)} ${vulnDeductStr}`
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// ── Row: Dependencies ─────────────────────────────────────────────────────────
|
|
244
|
+
if (ctx.runDeps) {
|
|
245
|
+
const depCount = Object.values(ctx.depCounts).reduce((a, b) => a + b, 0);
|
|
246
|
+
const depIcon = depCount === 0 ? chalk.green('✔') : chalk.red('✘');
|
|
247
|
+
const depLabel = ctx.pm ? `Dependencies ` : 'Dependencies ';
|
|
248
|
+
|
|
249
|
+
let depStatus, depDeductStr;
|
|
250
|
+
|
|
251
|
+
if (!ctx.pm) {
|
|
252
|
+
depStatus = chalk.gray('no manifest');
|
|
253
|
+
depDeductStr = chalk.gray('+0 deductions');
|
|
254
|
+
} else if (depCount === 0) {
|
|
255
|
+
depStatus = chalk.green('0 CVEs');
|
|
256
|
+
depDeductStr = chalk.gray('+0 deductions');
|
|
257
|
+
} else {
|
|
258
|
+
depStatus = chalk.red(`${depCount} CVEs`);
|
|
259
|
+
depDeductStr = chalk.red(`−${ctx.depDeduction} points`) + chalk.gray(` (${formatCounts(ctx.depCounts)})`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(
|
|
263
|
+
` ${depIcon} ${chalk.white.bold(depLabel)} ${depStatus.padEnd(18)} ${depDeductStr}`
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
console.log(
|
|
267
|
+
` ${chalk.gray('–')} ${chalk.gray('Dependencies skipped (--no-deps)')}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log();
|
|
272
|
+
console.log(chalk.cyan(' ' + '─'.repeat(58)));
|
|
273
|
+
console.log(chalk.gray(` Files scanned: ${ctx.filesScanned}`));
|
|
274
|
+
|
|
275
|
+
// ── Next steps ────────────────────────────────────────────────────────────────
|
|
276
|
+
if (score < 100) {
|
|
277
|
+
console.log();
|
|
278
|
+
const actions = [];
|
|
279
|
+
if (Object.values(ctx.secretCounts).some(n => n > 0)) {
|
|
280
|
+
actions.push(chalk.white(' npx ship-safe agent .') + chalk.gray(' # AI audit: classify + auto-fix secrets'));
|
|
281
|
+
}
|
|
282
|
+
if (Object.values(ctx.vulnCounts).some(n => n > 0)) {
|
|
283
|
+
actions.push(chalk.white(' npx ship-safe agent .') + chalk.gray(' # AI audit: classify + fix suggestions'));
|
|
284
|
+
}
|
|
285
|
+
if (ctx.runDeps && Object.values(ctx.depCounts).some(n => n > 0) && ctx.pm) {
|
|
286
|
+
actions.push(chalk.white(` npx ship-safe deps .`) + chalk.gray(' # See full dependency CVE details'));
|
|
287
|
+
}
|
|
288
|
+
if (actions.length > 0) {
|
|
289
|
+
console.log(chalk.gray(' Fix issues:'));
|
|
290
|
+
// Deduplicate (agent appears for both secrets and vulns)
|
|
291
|
+
const seen = new Set();
|
|
292
|
+
for (const a of actions) {
|
|
293
|
+
if (!seen.has(a)) { console.log(a); seen.add(a); }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
console.log();
|
|
298
|
+
console.log(chalk.green(' All clear — safe to ship!'));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console.log(chalk.cyan('='.repeat(60)));
|
|
302
|
+
console.log(chalk.gray(' Share your score & track trends: ') + chalk.cyan('https://shipsafecli.com'));
|
|
303
|
+
console.log();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatCounts(counts) {
|
|
307
|
+
const SEV_ORDER = ['critical', 'high', 'moderate', 'medium', 'low'];
|
|
308
|
+
return SEV_ORDER
|
|
309
|
+
.filter(s => counts[s] > 0)
|
|
310
|
+
.map(s => `${counts[s]} ${s}`)
|
|
311
|
+
.join(', ');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// =============================================================================
|
|
315
|
+
// INTERNAL SCAN (no subprocess — import patterns directly)
|
|
316
|
+
// =============================================================================
|
|
317
|
+
|
|
318
|
+
const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Find all scannable files (same logic as scan.js, without test-exclusion
|
|
322
|
+
* and without .ship-safeignore loading — score is a quick overview).
|
|
323
|
+
*/
|
|
324
|
+
async function findFiles(rootPath) {
|
|
325
|
+
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
326
|
+
|
|
327
|
+
const files = await fg('**/*', {
|
|
328
|
+
cwd: rootPath,
|
|
329
|
+
absolute: true,
|
|
330
|
+
onlyFiles: true,
|
|
331
|
+
ignore: globIgnore,
|
|
332
|
+
dot: true
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const filtered = [];
|
|
336
|
+
|
|
337
|
+
for (const file of files) {
|
|
338
|
+
const ext = path.extname(file).toLowerCase();
|
|
339
|
+
if (SKIP_EXTENSIONS.has(ext)) continue;
|
|
340
|
+
if (SKIP_FILENAMES.has(path.basename(file))) continue;
|
|
341
|
+
|
|
342
|
+
const basename = path.basename(file);
|
|
343
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
|
|
344
|
+
|
|
345
|
+
// Load and respect .ship-safeignore
|
|
346
|
+
if (isIgnoredByFile(file, rootPath)) continue;
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const stats = fs.statSync(file);
|
|
350
|
+
if (stats.size > MAX_FILE_SIZE) continue;
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
filtered.push(file);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return filtered;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Cache ignore patterns per root to avoid re-reading the file thousands of times
|
|
362
|
+
const _ignoreCache = new Map();
|
|
363
|
+
|
|
364
|
+
function loadIgnorePatterns(rootPath) {
|
|
365
|
+
if (_ignoreCache.has(rootPath)) return _ignoreCache.get(rootPath);
|
|
366
|
+
|
|
367
|
+
const ignorePath = path.join(rootPath, '.ship-safeignore');
|
|
368
|
+
let patterns = [];
|
|
369
|
+
|
|
370
|
+
if (fs.existsSync(ignorePath)) {
|
|
371
|
+
try {
|
|
372
|
+
patterns = fs.readFileSync(ignorePath, 'utf-8')
|
|
373
|
+
.split('\n')
|
|
374
|
+
.map(l => l.trim())
|
|
375
|
+
.filter(l => l && !l.startsWith('#'));
|
|
376
|
+
} catch {
|
|
377
|
+
// ignore read error
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_ignoreCache.set(rootPath, patterns);
|
|
382
|
+
return patterns;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isIgnoredByFile(filePath, rootPath) {
|
|
386
|
+
const patterns = loadIgnorePatterns(rootPath);
|
|
387
|
+
if (patterns.length === 0) return false;
|
|
388
|
+
|
|
389
|
+
const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
|
|
390
|
+
|
|
391
|
+
return patterns.some(pattern => {
|
|
392
|
+
if (pattern.endsWith('/')) {
|
|
393
|
+
return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
|
|
394
|
+
}
|
|
395
|
+
const escaped = pattern
|
|
396
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
397
|
+
.replace(/\*/g, '[^/]*')
|
|
398
|
+
.replace(/\?/g, '[^/]');
|
|
399
|
+
return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Scan a single file and return normalized findings.
|
|
405
|
+
* Same algorithm as scan.js — inline here to avoid circular dependency
|
|
406
|
+
* (scan.js has process.exit() side effects).
|
|
407
|
+
*/
|
|
408
|
+
function scanFile(filePath) {
|
|
409
|
+
const findings = [];
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
413
|
+
const lines = content.split('\n');
|
|
414
|
+
|
|
415
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
416
|
+
const line = lines[lineNum];
|
|
417
|
+
|
|
418
|
+
if (/ship-safe-ignore/i.test(line)) continue;
|
|
419
|
+
|
|
420
|
+
for (const pattern of ALL_PATTERNS) {
|
|
421
|
+
pattern.pattern.lastIndex = 0;
|
|
422
|
+
|
|
423
|
+
let match;
|
|
424
|
+
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
425
|
+
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
findings.push({
|
|
430
|
+
line: lineNum + 1,
|
|
431
|
+
severity: pattern.severity,
|
|
432
|
+
category: pattern.category || 'secret',
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
// Skip unreadable files
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Deduplicate: same (line, severity, category)
|
|
442
|
+
const seen = new Set();
|
|
443
|
+
return findings.filter(f => {
|
|
444
|
+
const key = `${f.line}:${f.severity}:${f.category}`;
|
|
445
|
+
if (seen.has(key)) return false;
|
|
446
|
+
seen.add(key);
|
|
447
|
+
return true;
|
|
448
|
+
});
|
|
449
|
+
}
|