ship-safe 3.1.0 → 4.0.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 +200 -307
- package/cli/agents/api-fuzzer.js +224 -0
- package/cli/agents/auth-bypass-agent.js +326 -0
- package/cli/agents/base-agent.js +240 -0
- package/cli/agents/cicd-scanner.js +200 -0
- package/cli/agents/config-auditor.js +413 -0
- package/cli/agents/git-history-scanner.js +167 -0
- package/cli/agents/html-reporter.js +363 -0
- package/cli/agents/index.js +56 -0
- package/cli/agents/injection-tester.js +401 -0
- package/cli/agents/llm-redteam.js +251 -0
- package/cli/agents/mobile-scanner.js +225 -0
- package/cli/agents/orchestrator.js +152 -0
- package/cli/agents/policy-engine.js +149 -0
- package/cli/agents/recon-agent.js +196 -0
- package/cli/agents/sbom-generator.js +176 -0
- package/cli/agents/scoring-engine.js +207 -0
- package/cli/agents/ssrf-prober.js +130 -0
- package/cli/agents/supply-chain-agent.js +274 -0
- package/cli/bin/ship-safe.js +119 -2
- package/cli/commands/agent.js +606 -0
- package/cli/commands/audit.js +565 -0
- package/cli/commands/deps.js +447 -0
- package/cli/commands/fix.js +3 -3
- package/cli/commands/init.js +86 -3
- package/cli/commands/mcp.js +2 -2
- package/cli/commands/red-team.js +315 -0
- package/cli/commands/remediate.js +4 -4
- package/cli/commands/rotate.js +6 -6
- package/cli/commands/scan.js +64 -23
- package/cli/commands/score.js +446 -0
- package/cli/commands/watch.js +160 -0
- package/cli/index.js +40 -2
- package/cli/providers/llm-provider.js +288 -0
- package/cli/utils/entropy.js +6 -0
- package/cli/utils/output.js +42 -2
- package/cli/utils/patterns.js +393 -1
- package/package.json +19 -15
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Red Team Command
|
|
3
|
+
* =================
|
|
4
|
+
*
|
|
5
|
+
* Run all security agents against the codebase.
|
|
6
|
+
* This is the main entry point for the multi-agent security audit.
|
|
7
|
+
*
|
|
8
|
+
* USAGE:
|
|
9
|
+
* npx ship-safe red-team [path] Full multi-agent audit
|
|
10
|
+
* npx ship-safe red-team . --agents injection,auth Run specific agents
|
|
11
|
+
* npx ship-safe red-team . --json JSON output
|
|
12
|
+
* npx ship-safe red-team . --html report.html Generate HTML report
|
|
13
|
+
* npx ship-safe red-team . --sarif SARIF output
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import ora from 'ora';
|
|
20
|
+
import { buildOrchestrator } from '../agents/index.js';
|
|
21
|
+
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
22
|
+
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
23
|
+
import { HTMLReporter } from '../agents/html-reporter.js';
|
|
24
|
+
import { SBOMGenerator } from '../agents/sbom-generator.js';
|
|
25
|
+
import { autoDetectProvider } from '../providers/llm-provider.js';
|
|
26
|
+
import { runDepsAudit } from './deps.js';
|
|
27
|
+
import * as output from '../utils/output.js';
|
|
28
|
+
|
|
29
|
+
export async function redTeamCommand(targetPath = '.', options = {}) {
|
|
30
|
+
const absolutePath = path.resolve(targetPath);
|
|
31
|
+
|
|
32
|
+
if (!fs.existsSync(absolutePath)) {
|
|
33
|
+
output.error(`Path does not exist: ${absolutePath}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log();
|
|
38
|
+
output.header('Ship Safe v4.0 — Multi-Agent Security Audit');
|
|
39
|
+
console.log();
|
|
40
|
+
|
|
41
|
+
// ── 1. Run orchestrator ─────────────────────────────────────────────────────
|
|
42
|
+
const orchestrator = buildOrchestrator();
|
|
43
|
+
|
|
44
|
+
const agentFilter = options.agents
|
|
45
|
+
? options.agents.split(',').map(a => a.trim())
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
const results = await orchestrator.runAll(absolutePath, {
|
|
49
|
+
verbose: options.verbose,
|
|
50
|
+
agents: agentFilter,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const { recon, findings, agentResults } = results;
|
|
54
|
+
|
|
55
|
+
// ── 2. Dependency audit ─────────────────────────────────────────────────────
|
|
56
|
+
let depVulns = [];
|
|
57
|
+
if (!options.noDeps) {
|
|
58
|
+
const depSpinner = ora({ text: 'Auditing dependencies...', color: 'cyan' }).start();
|
|
59
|
+
try {
|
|
60
|
+
const depResult = await runDepsAudit(absolutePath);
|
|
61
|
+
depVulns = depResult.vulns || [];
|
|
62
|
+
depSpinner.succeed(
|
|
63
|
+
depVulns.length === 0
|
|
64
|
+
? chalk.green('Dependencies: clean')
|
|
65
|
+
: chalk.yellow(`Dependencies: ${depVulns.length} CVE(s)`)
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
depSpinner.succeed(chalk.gray('Dependencies: skipped'));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 3. Apply policy ─────────────────────────────────────────────────────────
|
|
73
|
+
const policy = PolicyEngine.load(absolutePath);
|
|
74
|
+
const filteredFindings = policy.applyPolicy(findings);
|
|
75
|
+
|
|
76
|
+
// ── 4. Score ────────────────────────────────────────────────────────────────
|
|
77
|
+
const scoringEngine = new ScoringEngine();
|
|
78
|
+
const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
|
|
79
|
+
scoringEngine.saveToHistory(absolutePath, scoreResult);
|
|
80
|
+
|
|
81
|
+
// ── 5. AI classification (if provider available) ────────────────────────────
|
|
82
|
+
if (options.ai !== false) {
|
|
83
|
+
const provider = autoDetectProvider(absolutePath);
|
|
84
|
+
if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
|
|
85
|
+
const aiSpinner = ora({ text: `Classifying ${filteredFindings.length} finding(s) with ${provider.name}...`, color: 'cyan' }).start();
|
|
86
|
+
try {
|
|
87
|
+
const classifications = await provider.classify(filteredFindings);
|
|
88
|
+
// Merge classifications back into findings
|
|
89
|
+
for (const cl of classifications) {
|
|
90
|
+
const finding = filteredFindings.find(f => `${f.file}:${f.line}` === cl.id);
|
|
91
|
+
if (finding) {
|
|
92
|
+
finding.aiClassification = cl.classification;
|
|
93
|
+
finding.aiReason = cl.reason;
|
|
94
|
+
finding.aiFix = cl.fix;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
aiSpinner.succeed(chalk.green(`AI classification complete (${provider.name})`));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
aiSpinner.fail(chalk.yellow(`AI classification failed: ${err.message}`));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── 6. Output ───────────────────────────────────────────────────────────────
|
|
105
|
+
if (options.json) {
|
|
106
|
+
outputJSON(scoreResult, filteredFindings, recon, agentResults);
|
|
107
|
+
} else if (options.sarif) {
|
|
108
|
+
outputSARIF(filteredFindings, absolutePath);
|
|
109
|
+
} else if (options.html) {
|
|
110
|
+
const reporter = new HTMLReporter();
|
|
111
|
+
const htmlPath = typeof options.html === 'string' ? options.html : 'ship-safe-report.html';
|
|
112
|
+
reporter.generateToFile(scoreResult, filteredFindings, recon, absolutePath, htmlPath);
|
|
113
|
+
output.success(`HTML report saved to ${htmlPath}`);
|
|
114
|
+
} else {
|
|
115
|
+
printResults(scoreResult, filteredFindings, recon, agentResults, depVulns, absolutePath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 7. SBOM (if requested) ──────────────────────────────────────────────────
|
|
119
|
+
if (options.sbom) {
|
|
120
|
+
const sbomGen = new SBOMGenerator();
|
|
121
|
+
const sbomPath = typeof options.sbom === 'string' ? options.sbom : 'sbom.json';
|
|
122
|
+
sbomGen.generateToFile(absolutePath, sbomPath);
|
|
123
|
+
output.success(`SBOM saved to ${sbomPath}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 8. Policy evaluation ────────────────────────────────────────────────────
|
|
127
|
+
const violations = policy.evaluate(scoreResult, filteredFindings);
|
|
128
|
+
if (violations.length > 0) {
|
|
129
|
+
console.log();
|
|
130
|
+
console.log(chalk.red.bold(' Policy Violations:'));
|
|
131
|
+
for (const v of violations.slice(0, 10)) {
|
|
132
|
+
console.log(chalk.red(` ✗ ${v.message}`));
|
|
133
|
+
}
|
|
134
|
+
if (violations.length > 10) {
|
|
135
|
+
console.log(chalk.gray(` ... and ${violations.length - 10} more`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 9. Trend ────────────────────────────────────────────────────────────────
|
|
140
|
+
const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
|
|
141
|
+
if (trend) {
|
|
142
|
+
const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
|
|
143
|
+
console.log();
|
|
144
|
+
console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (${trend.diff > 0 ? '+' : ''}${trend.diff})`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log();
|
|
148
|
+
|
|
149
|
+
// Exit code
|
|
150
|
+
process.exit(scoreResult.score >= 75 ? 0 : 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// OUTPUT FORMATTERS
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
function printResults(scoreResult, findings, recon, agentResults, depVulns, rootPath) {
|
|
158
|
+
const GRADE_COLOR = { A: chalk.green.bold, B: chalk.cyan.bold, C: chalk.yellow.bold, D: chalk.red, F: chalk.red.bold };
|
|
159
|
+
const SEV_COLOR = { critical: chalk.red.bold, high: chalk.yellow, medium: chalk.blue, low: chalk.gray };
|
|
160
|
+
|
|
161
|
+
// ── Score ───────────────────────────────────────────────────────────────────
|
|
162
|
+
console.log();
|
|
163
|
+
const gradeColor = GRADE_COLOR[scoreResult.grade.letter] || chalk.white;
|
|
164
|
+
const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
|
|
165
|
+
|
|
166
|
+
console.log(
|
|
167
|
+
chalk.white.bold(' Security Score: ') +
|
|
168
|
+
scoreColor(`${scoreResult.score}/100 `) +
|
|
169
|
+
gradeColor(scoreResult.grade.letter) +
|
|
170
|
+
chalk.gray(` — ${scoreResult.grade.label}`)
|
|
171
|
+
);
|
|
172
|
+
console.log(chalk.cyan(' ' + '─'.repeat(58)));
|
|
173
|
+
console.log();
|
|
174
|
+
|
|
175
|
+
// ── Category breakdown ──────────────────────────────────────────────────────
|
|
176
|
+
for (const [key, cat] of Object.entries(scoreResult.categories)) {
|
|
177
|
+
const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
|
|
178
|
+
const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
|
|
179
|
+
const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
|
|
180
|
+
const deduction = cat.deduction > 0 ? chalk.red(`-${cat.deduction} pts`) : chalk.gray('+0');
|
|
181
|
+
console.log(` ${icon} ${chalk.white.bold(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Dependencies row
|
|
185
|
+
const depCount = depVulns.length;
|
|
186
|
+
const depIcon = depCount === 0 ? chalk.green('✔') : chalk.red('✘');
|
|
187
|
+
const depDeduction = scoreResult.categories.deps?.deduction || 0;
|
|
188
|
+
console.log(` ${depIcon} ${chalk.white.bold('Dependencies'.padEnd(22))} ${depCount === 0 ? chalk.green('clean') : chalk.red(`${depCount} CVE(s)`)}`);
|
|
189
|
+
|
|
190
|
+
// ── Top findings ────────────────────────────────────────────────────────────
|
|
191
|
+
if (findings.length > 0) {
|
|
192
|
+
console.log();
|
|
193
|
+
console.log(chalk.yellow.bold(` Top Findings (${Math.min(findings.length, 20)} of ${findings.length})`));
|
|
194
|
+
console.log(chalk.yellow(' ' + '─'.repeat(58)));
|
|
195
|
+
|
|
196
|
+
for (const f of findings.slice(0, 20)) {
|
|
197
|
+
const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
|
|
198
|
+
const sevColor = SEV_COLOR[f.severity] || chalk.white;
|
|
199
|
+
const aiTag = f.aiClassification === 'FALSE_POSITIVE'
|
|
200
|
+
? chalk.gray(' [FP]')
|
|
201
|
+
: f.aiClassification === 'REAL' ? chalk.red(' [REAL]') : '';
|
|
202
|
+
|
|
203
|
+
console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`.padEnd(12))} ${chalk.white(`${relFile}:${f.line}`)}${aiTag}`);
|
|
204
|
+
console.log(` ${chalk.gray(' ')} ${f.title || f.rule}`);
|
|
205
|
+
if (f.aiFix || f.fix) {
|
|
206
|
+
console.log(` ${chalk.gray(' Fix:')} ${chalk.green((f.aiFix || f.fix).slice(0, 80))}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Attack surface summary ──────────────────────────────────────────────────
|
|
212
|
+
if (recon) {
|
|
213
|
+
console.log();
|
|
214
|
+
console.log(chalk.cyan(' Attack Surface:'));
|
|
215
|
+
if (recon.frameworks?.length) console.log(chalk.gray(` Frameworks: ${recon.frameworks.join(', ')}`));
|
|
216
|
+
if (recon.databases?.length) console.log(chalk.gray(` Databases: ${recon.databases.join(', ')}`));
|
|
217
|
+
if (recon.authPatterns?.length) console.log(chalk.gray(` Auth: ${recon.authPatterns.join(', ')}`));
|
|
218
|
+
if (recon.apiRoutes?.length) console.log(chalk.gray(` API Routes: ${recon.apiRoutes.length} discovered`));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Next steps ──────────────────────────────────────────────────────────────
|
|
222
|
+
if (findings.length > 0) {
|
|
223
|
+
console.log();
|
|
224
|
+
console.log(chalk.yellow.bold(' Next steps:'));
|
|
225
|
+
console.log(chalk.gray(' 1. Review and fix findings above'));
|
|
226
|
+
console.log(chalk.gray(' 2. Run again: ') + chalk.cyan('npx ship-safe red-team .'));
|
|
227
|
+
console.log(chalk.gray(' 3. Generate report: ') + chalk.cyan('npx ship-safe red-team . --html report.html'));
|
|
228
|
+
console.log(chalk.gray(' 4. Set policy: ') + chalk.cyan('npx ship-safe policy init'));
|
|
229
|
+
} else {
|
|
230
|
+
console.log();
|
|
231
|
+
output.success('All agents report clean — safe to ship!');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log(chalk.cyan(' ' + '═'.repeat(58)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function outputJSON(scoreResult, findings, recon, agentResults) {
|
|
238
|
+
console.log(JSON.stringify({
|
|
239
|
+
score: scoreResult.score,
|
|
240
|
+
grade: scoreResult.grade.letter,
|
|
241
|
+
gradeLabel: scoreResult.grade.label,
|
|
242
|
+
totalFindings: findings.length,
|
|
243
|
+
categories: Object.fromEntries(
|
|
244
|
+
Object.entries(scoreResult.categories).map(([k, v]) => [k, {
|
|
245
|
+
label: v.label,
|
|
246
|
+
findingCount: Object.values(v.counts).reduce((a, b) => a + b, 0),
|
|
247
|
+
deduction: v.deduction,
|
|
248
|
+
counts: v.counts,
|
|
249
|
+
}])
|
|
250
|
+
),
|
|
251
|
+
findings: findings.map(f => ({
|
|
252
|
+
file: f.file,
|
|
253
|
+
line: f.line,
|
|
254
|
+
severity: f.severity,
|
|
255
|
+
category: f.category,
|
|
256
|
+
rule: f.rule,
|
|
257
|
+
title: f.title,
|
|
258
|
+
description: f.description,
|
|
259
|
+
fix: f.fix,
|
|
260
|
+
aiClassification: f.aiClassification,
|
|
261
|
+
aiFix: f.aiFix,
|
|
262
|
+
})),
|
|
263
|
+
recon,
|
|
264
|
+
agents: agentResults,
|
|
265
|
+
}, null, 2));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function outputSARIF(findings, rootPath) {
|
|
269
|
+
const rules = {};
|
|
270
|
+
for (const f of findings) {
|
|
271
|
+
if (!rules[f.rule]) {
|
|
272
|
+
rules[f.rule] = {
|
|
273
|
+
id: f.rule,
|
|
274
|
+
name: f.title || f.rule,
|
|
275
|
+
shortDescription: { text: f.title || f.rule },
|
|
276
|
+
fullDescription: { text: f.description || '' },
|
|
277
|
+
defaultConfiguration: {
|
|
278
|
+
level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
|
|
279
|
+
},
|
|
280
|
+
helpUri: 'https://github.com/asamassekou10/ship-safe',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const sarif = {
|
|
286
|
+
version: '2.1.0',
|
|
287
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
288
|
+
runs: [{
|
|
289
|
+
tool: {
|
|
290
|
+
driver: {
|
|
291
|
+
name: 'ship-safe',
|
|
292
|
+
version: '4.0.0',
|
|
293
|
+
informationUri: 'https://github.com/asamassekou10/ship-safe',
|
|
294
|
+
rules: Object.values(rules),
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
results: findings.map(f => ({
|
|
298
|
+
ruleId: f.rule,
|
|
299
|
+
level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
|
|
300
|
+
message: { text: `${f.title}: ${f.description}` },
|
|
301
|
+
locations: [{
|
|
302
|
+
physicalLocation: {
|
|
303
|
+
artifactLocation: {
|
|
304
|
+
uri: path.relative(rootPath, f.file).replace(/\\/g, '/'),
|
|
305
|
+
uriBaseId: '%SRCROOT%',
|
|
306
|
+
},
|
|
307
|
+
region: { startLine: f.line, startColumn: f.column || 1 },
|
|
308
|
+
}
|
|
309
|
+
}],
|
|
310
|
+
})),
|
|
311
|
+
}],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
console.log(JSON.stringify(sarif, null, 2));
|
|
315
|
+
}
|
|
@@ -39,7 +39,7 @@ import chalk from 'chalk';
|
|
|
39
39
|
import ora from 'ora';
|
|
40
40
|
import pkg from 'write-file-atomic';
|
|
41
41
|
const { writeFile: writeFileAtomic } = pkg;
|
|
42
|
-
import
|
|
42
|
+
import fg from 'fast-glob';
|
|
43
43
|
import {
|
|
44
44
|
SECRET_PATTERNS,
|
|
45
45
|
SKIP_DIRS,
|
|
@@ -415,7 +415,7 @@ function stageFiles(files, rootPath) {
|
|
|
415
415
|
if (files.length === 0) return;
|
|
416
416
|
try {
|
|
417
417
|
const quoted = files.map(f => `"${f}"`).join(' ');
|
|
418
|
-
execSync(`git add ${quoted}`, { cwd: rootPath, stdio: 'inherit' });
|
|
418
|
+
execSync(`git add ${quoted}`, { cwd: rootPath, stdio: 'inherit' }); // ship-safe-ignore — paths come from our own file scan
|
|
419
419
|
output.success(`Staged ${files.length} file(s) with git add`);
|
|
420
420
|
} catch {
|
|
421
421
|
output.warning('Could not stage files — run git add manually.');
|
|
@@ -428,8 +428,8 @@ function stageFiles(files, rootPath) {
|
|
|
428
428
|
|
|
429
429
|
async function findFiles(rootPath) {
|
|
430
430
|
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
431
|
-
const files = await
|
|
432
|
-
cwd: rootPath, absolute: true,
|
|
431
|
+
const files = await fg('**/*', {
|
|
432
|
+
cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
|
|
433
433
|
});
|
|
434
434
|
|
|
435
435
|
const filtered = [];
|
package/cli/commands/rotate.js
CHANGED
|
@@ -24,7 +24,7 @@ import path from 'path';
|
|
|
24
24
|
import { execSync } from 'child_process';
|
|
25
25
|
import chalk from 'chalk';
|
|
26
26
|
import ora from 'ora';
|
|
27
|
-
import
|
|
27
|
+
import fg from 'fast-glob';
|
|
28
28
|
import {
|
|
29
29
|
SECRET_PATTERNS,
|
|
30
30
|
SKIP_DIRS,
|
|
@@ -390,9 +390,9 @@ async function revokeGitHubToken(token) {
|
|
|
390
390
|
function openBrowser(url) {
|
|
391
391
|
try {
|
|
392
392
|
const platform = process.platform;
|
|
393
|
-
if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
394
|
-
else if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
395
|
-
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
393
|
+
if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' }); // ship-safe-ignore — url is hardcoded provider dashboard URL
|
|
394
|
+
else if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' }); // ship-safe-ignore
|
|
395
|
+
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' }); // ship-safe-ignore
|
|
396
396
|
return true;
|
|
397
397
|
} catch {
|
|
398
398
|
return false;
|
|
@@ -405,8 +405,8 @@ function openBrowser(url) {
|
|
|
405
405
|
|
|
406
406
|
async function findFiles(rootPath) {
|
|
407
407
|
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
408
|
-
const files = await
|
|
409
|
-
cwd: rootPath, absolute: true,
|
|
408
|
+
const files = await fg('**/*', {
|
|
409
|
+
cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
|
|
410
410
|
});
|
|
411
411
|
const filtered = [];
|
|
412
412
|
for (const file of files) {
|
package/cli/commands/scan.js
CHANGED
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
|
|
22
22
|
import fs from 'fs';
|
|
23
23
|
import path from 'path';
|
|
24
|
-
import
|
|
24
|
+
import fg from 'fast-glob';
|
|
25
25
|
import ora from 'ora';
|
|
26
26
|
import chalk from 'chalk';
|
|
27
27
|
import {
|
|
28
28
|
SECRET_PATTERNS,
|
|
29
|
+
SECURITY_PATTERNS,
|
|
29
30
|
SKIP_DIRS,
|
|
30
31
|
SKIP_EXTENSIONS,
|
|
31
32
|
TEST_FILE_PATTERNS,
|
|
@@ -94,7 +95,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
|
|
|
94
95
|
|
|
95
96
|
// Load custom patterns from .ship-safe.json
|
|
96
97
|
const customPatterns = loadCustomPatterns(absolutePath);
|
|
97
|
-
const allPatterns = [...SECRET_PATTERNS, ...customPatterns];
|
|
98
|
+
const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
|
|
98
99
|
|
|
99
100
|
if (customPatterns.length > 0 && options.verbose) {
|
|
100
101
|
output.info(`Loaded ${customPatterns.length} custom pattern(s) from .ship-safe.json`);
|
|
@@ -102,7 +103,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
|
|
|
102
103
|
|
|
103
104
|
// Start spinner
|
|
104
105
|
const spinner = ora({
|
|
105
|
-
text: 'Scanning for secrets...',
|
|
106
|
+
text: 'Scanning for secrets and vulnerabilities...',
|
|
106
107
|
color: 'cyan'
|
|
107
108
|
}).start();
|
|
108
109
|
|
|
@@ -204,10 +205,10 @@ async function findFiles(rootPath, ignorePatterns, options = {}) {
|
|
|
204
205
|
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
205
206
|
|
|
206
207
|
// Find all files
|
|
207
|
-
const files = await
|
|
208
|
+
const files = await fg('**/*', {
|
|
208
209
|
cwd: rootPath,
|
|
209
210
|
absolute: true,
|
|
210
|
-
|
|
211
|
+
onlyFiles: true,
|
|
211
212
|
ignore: globIgnore,
|
|
212
213
|
dot: true
|
|
213
214
|
});
|
|
@@ -284,7 +285,8 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
|
|
|
284
285
|
patternName: pattern.name,
|
|
285
286
|
severity: pattern.severity,
|
|
286
287
|
confidence,
|
|
287
|
-
description: pattern.description
|
|
288
|
+
description: pattern.description,
|
|
289
|
+
category: pattern.category || 'secret'
|
|
288
290
|
});
|
|
289
291
|
}
|
|
290
292
|
}
|
|
@@ -293,7 +295,17 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
|
|
|
293
295
|
// Skip files that can't be read (binary, permissions, etc.)
|
|
294
296
|
}
|
|
295
297
|
|
|
296
|
-
|
|
298
|
+
// Deduplicate: multiple patterns can match the same secret on the same line
|
|
299
|
+
// (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
|
|
300
|
+
// unique (line, matched-text) pair — first match wins (patterns are ordered
|
|
301
|
+
// by severity: critical → high → medium).
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
return findings.filter(f => {
|
|
304
|
+
const key = `${f.line}:${f.matched}`;
|
|
305
|
+
if (seen.has(key)) return false;
|
|
306
|
+
seen.add(key);
|
|
307
|
+
return true;
|
|
308
|
+
});
|
|
297
309
|
}
|
|
298
310
|
|
|
299
311
|
// =============================================================================
|
|
@@ -301,11 +313,24 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
|
|
|
301
313
|
// =============================================================================
|
|
302
314
|
|
|
303
315
|
function outputPretty(results, filesScanned, rootPath) {
|
|
316
|
+
// Separate findings into secrets and code vulnerabilities
|
|
317
|
+
const secretResults = [];
|
|
318
|
+
const vulnResults = [];
|
|
319
|
+
|
|
320
|
+
for (const { file, findings } of results) {
|
|
321
|
+
const secrets = findings.filter(f => f.category !== 'vulnerability');
|
|
322
|
+
const vulns = findings.filter(f => f.category === 'vulnerability');
|
|
323
|
+
if (secrets.length > 0) secretResults.push({ file, findings: secrets });
|
|
324
|
+
if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
const stats = {
|
|
305
328
|
total: 0,
|
|
306
329
|
critical: 0,
|
|
307
330
|
high: 0,
|
|
308
331
|
medium: 0,
|
|
332
|
+
secretsTotal: 0,
|
|
333
|
+
vulnsTotal: 0,
|
|
309
334
|
filesScanned
|
|
310
335
|
};
|
|
311
336
|
|
|
@@ -313,31 +338,45 @@ function outputPretty(results, filesScanned, rootPath) {
|
|
|
313
338
|
for (const f of findings) {
|
|
314
339
|
stats.total++;
|
|
315
340
|
stats[f.severity] = (stats[f.severity] || 0) + 1;
|
|
341
|
+
if (f.category === 'vulnerability') stats.vulnsTotal++;
|
|
342
|
+
else stats.secretsTotal++;
|
|
316
343
|
}
|
|
317
344
|
}
|
|
318
345
|
|
|
319
346
|
output.header('Scan Results');
|
|
320
347
|
|
|
321
348
|
if (results.length === 0) {
|
|
322
|
-
output.success('No secrets detected in your codebase!');
|
|
349
|
+
output.success('No secrets or vulnerabilities detected in your codebase!');
|
|
323
350
|
console.log();
|
|
324
351
|
console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
|
|
325
352
|
console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
|
|
326
353
|
console.log(chalk.gray('Tip: Add a .ship-safeignore file to exclude paths.'));
|
|
327
354
|
} else {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
f.matched,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
355
|
+
// ── Secrets section ────────────────────────────────────────────────────
|
|
356
|
+
if (secretResults.length > 0) {
|
|
357
|
+
console.log();
|
|
358
|
+
console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
|
|
359
|
+
console.log(chalk.red(' ' + '─'.repeat(58)));
|
|
360
|
+
|
|
361
|
+
for (const { file, findings } of secretResults) {
|
|
362
|
+
const relPath = path.relative(rootPath, file);
|
|
363
|
+
for (const f of findings) {
|
|
364
|
+
output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Code Vulnerabilities section ───────────────────────────────────────
|
|
370
|
+
if (vulnResults.length > 0) {
|
|
371
|
+
console.log();
|
|
372
|
+
console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
|
|
373
|
+
console.log(chalk.yellow(' ' + '─'.repeat(58)));
|
|
374
|
+
|
|
375
|
+
for (const { file, findings } of vulnResults) {
|
|
376
|
+
const relPath = path.relative(rootPath, file);
|
|
377
|
+
for (const f of findings) {
|
|
378
|
+
output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
|
|
379
|
+
}
|
|
341
380
|
}
|
|
342
381
|
}
|
|
343
382
|
|
|
@@ -346,7 +385,8 @@ function outputPretty(results, filesScanned, rootPath) {
|
|
|
346
385
|
console.log(chalk.gray('Suppress a finding: add # ship-safe-ignore as a comment on that line'));
|
|
347
386
|
console.log(chalk.gray('Exclude a path: add it to .ship-safeignore'));
|
|
348
387
|
|
|
349
|
-
output.recommendations();
|
|
388
|
+
if (secretResults.length > 0) output.recommendations();
|
|
389
|
+
if (vulnResults.length > 0) output.vulnRecommendations();
|
|
350
390
|
}
|
|
351
391
|
|
|
352
392
|
output.summary(stats);
|
|
@@ -367,10 +407,11 @@ function outputJSON(results, filesScanned) {
|
|
|
367
407
|
file,
|
|
368
408
|
line: f.line,
|
|
369
409
|
column: f.column,
|
|
410
|
+
category: f.category || 'secret',
|
|
370
411
|
severity: f.severity,
|
|
371
412
|
confidence: f.confidence,
|
|
372
413
|
type: f.patternName,
|
|
373
|
-
matched: output.maskSecret(f.matched),
|
|
414
|
+
matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
|
|
374
415
|
description: f.description
|
|
375
416
|
});
|
|
376
417
|
}
|