ship-safe 4.3.0 → 5.0.1
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 +83 -23
- package/cli/__tests__/agents.test.js +579 -0
- package/cli/agents/agentic-security-agent.js +261 -0
- package/cli/agents/base-agent.js +11 -1
- package/cli/agents/deep-analyzer.js +333 -0
- package/cli/agents/index.js +16 -1
- package/cli/agents/injection-tester.js +45 -0
- package/cli/agents/mcp-security-agent.js +358 -0
- package/cli/agents/mobile-scanner.js +6 -0
- package/cli/agents/orchestrator.js +67 -8
- package/cli/agents/pii-compliance-agent.js +301 -0
- package/cli/agents/rag-security-agent.js +204 -0
- package/cli/agents/sbom-generator.js +100 -11
- package/cli/agents/scoring-engine.js +4 -0
- package/cli/agents/supabase-rls-agent.js +6 -0
- package/cli/agents/supply-chain-agent.js +152 -1
- package/cli/agents/verifier-agent.js +292 -0
- package/cli/bin/ship-safe.js +32 -6
- package/cli/commands/agent.js +2 -0
- package/cli/commands/audit.js +103 -7
- package/cli/commands/baseline.js +2 -1
- package/cli/commands/ci.js +262 -0
- package/cli/commands/fix.js +218 -216
- package/cli/commands/mcp.js +304 -303
- package/cli/commands/red-team.js +8 -2
- package/cli/commands/remediate.js +2 -0
- package/cli/commands/scan.js +567 -565
- package/cli/commands/score.js +2 -0
- package/cli/commands/watch.js +161 -160
- package/cli/index.js +1 -1
- package/cli/utils/patterns.js +1118 -1104
- package/cli/utils/secrets-verifier.js +247 -0
- package/package.json +2 -2
package/cli/commands/audit.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
SECURITY_PATTERNS,
|
|
30
30
|
SKIP_DIRS,
|
|
31
31
|
SKIP_EXTENSIONS,
|
|
32
|
+
SKIP_FILENAMES,
|
|
32
33
|
MAX_FILE_SIZE,
|
|
33
34
|
loadGitignorePatterns
|
|
34
35
|
} from '../utils/patterns.js';
|
|
@@ -36,6 +37,7 @@ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
|
36
37
|
import { CacheManager } from '../utils/cache-manager.js';
|
|
37
38
|
import { filterBaseline } from './baseline.js';
|
|
38
39
|
import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
|
|
40
|
+
import { SecretsVerifier } from '../utils/secrets-verifier.js';
|
|
39
41
|
|
|
40
42
|
// =============================================================================
|
|
41
43
|
// CONSTANTS
|
|
@@ -131,11 +133,39 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
131
133
|
description: f.description,
|
|
132
134
|
matched: f.matched,
|
|
133
135
|
confidence: f.confidence,
|
|
134
|
-
fix:
|
|
136
|
+
fix: file.match(/\.env(\..*)?$/)
|
|
137
|
+
? `Ensure .env is in .gitignore and use a secrets manager for production`
|
|
138
|
+
: `Move to environment variable or secrets manager`,
|
|
135
139
|
});
|
|
136
140
|
}
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
// Downgrade .env findings if the file is gitignored (properly managed)
|
|
144
|
+
const gitignoreContent = (() => {
|
|
145
|
+
try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
|
|
146
|
+
})();
|
|
147
|
+
const envIsGitignored = gitignoreContent.split('\n')
|
|
148
|
+
.map(l => l.trim())
|
|
149
|
+
.some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
|
|
150
|
+
|
|
151
|
+
if (envIsGitignored) {
|
|
152
|
+
for (const f of secretFindings) {
|
|
153
|
+
if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
|
|
154
|
+
f.severity = 'low';
|
|
155
|
+
f.confidence = 'low';
|
|
156
|
+
f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Downgrade secrets in test files (intentional test fixtures)
|
|
162
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
|
|
163
|
+
for (const f of secretFindings) {
|
|
164
|
+
if (TEST_PATH.test(f.file)) {
|
|
165
|
+
f.confidence = 'low';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
139
169
|
// Merge with cached findings for unchanged files
|
|
140
170
|
secretFindings = [...secretFindings, ...cachedSecretFindings];
|
|
141
171
|
|
|
@@ -153,16 +183,22 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
153
183
|
}
|
|
154
184
|
|
|
155
185
|
// ── Phase 2: Agent Scan ───────────────────────────────────────────────────
|
|
156
|
-
const
|
|
186
|
+
const orchestrator = buildOrchestrator();
|
|
187
|
+
const registeredAgentCount = orchestrator.agents?.length || 15;
|
|
188
|
+
const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
|
|
157
189
|
let agentFindings = [];
|
|
158
190
|
let recon = null;
|
|
159
191
|
let agentResults = [];
|
|
160
192
|
|
|
161
193
|
try {
|
|
162
|
-
const orchestrator = buildOrchestrator();
|
|
163
194
|
// Suppress individual agent spinners by using quiet mode
|
|
164
195
|
// Pass changedFiles for incremental scanning if cache is valid
|
|
165
196
|
const orchestratorOpts = { quiet: true };
|
|
197
|
+
if (options.deep) orchestratorOpts.deep = true;
|
|
198
|
+
if (options.local) orchestratorOpts.local = true;
|
|
199
|
+
if (options.model) orchestratorOpts.model = options.model;
|
|
200
|
+
if (options.budget) orchestratorOpts.budget = options.budget;
|
|
201
|
+
if (options.verbose) orchestratorOpts.verbose = true;
|
|
166
202
|
if (cacheDiff && cacheDiff.changedFiles.length < allFiles.length) {
|
|
167
203
|
orchestratorOpts.changedFiles = cacheDiff.changedFiles;
|
|
168
204
|
}
|
|
@@ -287,6 +323,32 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
287
323
|
}
|
|
288
324
|
}
|
|
289
325
|
|
|
326
|
+
// ── Secrets Verification (optional, --verify flag) ─────────────────────
|
|
327
|
+
if (options.verify) {
|
|
328
|
+
const verifySpinner = machineOutput ? null : ora({ text: 'Verifying leaked secrets against provider APIs...', color: 'cyan' }).start();
|
|
329
|
+
try {
|
|
330
|
+
const verifier = new SecretsVerifier();
|
|
331
|
+
const verifyResults = await verifier.verify(filteredFindings);
|
|
332
|
+
const activeCount = verifyResults.filter(r => r.result.active === true).length;
|
|
333
|
+
const inactiveCount = verifyResults.filter(r => r.result.active === false).length;
|
|
334
|
+
if (verifySpinner) {
|
|
335
|
+
verifySpinner.succeed(chalk.green(
|
|
336
|
+
`Secrets verified: ${activeCount} active, ${inactiveCount} inactive, ${verifyResults.length - activeCount - inactiveCount} unknown`
|
|
337
|
+
));
|
|
338
|
+
}
|
|
339
|
+
// Show active secrets warning
|
|
340
|
+
if (activeCount > 0 && !machineOutput) {
|
|
341
|
+
console.log(chalk.red.bold(' ⚠ ACTIVE SECRETS DETECTED — rotate immediately:'));
|
|
342
|
+
for (const r of verifyResults.filter(r => r.result.active === true)) {
|
|
343
|
+
const rel = path.relative(absolutePath, r.finding.file).replace(/\\/g, '/');
|
|
344
|
+
console.log(chalk.red(` ${r.result.provider}: ${rel}:${r.finding.line} — ${r.result.info}`));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
if (verifySpinner) verifySpinner.fail(chalk.yellow(`Secrets verification failed: ${err.message}`));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
290
352
|
// ── Save Cache ──────────────────────────────────────────────────────────
|
|
291
353
|
if (useCache) {
|
|
292
354
|
try {
|
|
@@ -383,14 +445,47 @@ function buildRemediationPlan(findings, depVulns, rootPath) {
|
|
|
383
445
|
const plan = [];
|
|
384
446
|
let priority = 1;
|
|
385
447
|
|
|
448
|
+
// Exclude low-confidence findings (test files, docs, comments) from remediation plan
|
|
449
|
+
const actionable = findings.filter(f => f.confidence !== 'low');
|
|
450
|
+
|
|
386
451
|
// Priority order: secrets first, then by severity
|
|
387
|
-
const secretFindings =
|
|
388
|
-
const otherFindings =
|
|
452
|
+
const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
|
|
453
|
+
const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
|
|
389
454
|
|
|
390
455
|
// Group and sort
|
|
391
456
|
for (const sev of SEV_ORDER) {
|
|
392
|
-
// Secrets at this severity
|
|
393
|
-
|
|
457
|
+
// Secrets at this severity — group .env findings by file
|
|
458
|
+
const sevSecrets = secretFindings.filter(s => s.severity === sev);
|
|
459
|
+
const envGroups = new Map();
|
|
460
|
+
const nonEnvSecrets = [];
|
|
461
|
+
|
|
462
|
+
for (const f of sevSecrets) {
|
|
463
|
+
const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
|
|
464
|
+
if (f.file.match(/\.env(\..*)?$/)) {
|
|
465
|
+
if (!envGroups.has(relFile)) envGroups.set(relFile, []);
|
|
466
|
+
envGroups.get(relFile).push(f);
|
|
467
|
+
} else {
|
|
468
|
+
nonEnvSecrets.push(f);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// One plan item per .env file
|
|
473
|
+
for (const [relFile, envFindings] of envGroups) {
|
|
474
|
+
const names = envFindings.map(f => f.title || f.rule).join(', ');
|
|
475
|
+
plan.push({
|
|
476
|
+
priority: priority++,
|
|
477
|
+
severity: sev,
|
|
478
|
+
category: 'secrets',
|
|
479
|
+
categoryLabel: 'SECRETS',
|
|
480
|
+
title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
|
|
481
|
+
file: relFile,
|
|
482
|
+
action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
|
|
483
|
+
effort: 'low',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Individual items for non-.env secrets
|
|
488
|
+
for (const f of nonEnvSecrets) {
|
|
394
489
|
plan.push({
|
|
395
490
|
priority: priority++,
|
|
396
491
|
severity: sev,
|
|
@@ -662,6 +757,7 @@ async function findFiles(rootPath) {
|
|
|
662
757
|
return files.filter(file => {
|
|
663
758
|
const ext = path.extname(file).toLowerCase();
|
|
664
759
|
if (SKIP_EXTENSIONS.has(ext)) return false;
|
|
760
|
+
if (SKIP_FILENAMES.has(path.basename(file))) return false;
|
|
665
761
|
if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
|
|
666
762
|
try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
|
|
667
763
|
return true;
|
package/cli/commands/baseline.js
CHANGED
|
@@ -17,7 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import ora from 'ora';
|
|
19
19
|
import { buildOrchestrator } from '../agents/index.js';
|
|
20
|
-
import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, MAX_FILE_SIZE } from '../utils/patterns.js';
|
|
20
|
+
import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
|
|
21
21
|
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
22
22
|
import fg from 'fast-glob';
|
|
23
23
|
|
|
@@ -45,6 +45,7 @@ async function quickScan(rootPath) {
|
|
|
45
45
|
const filtered = files.filter(f => {
|
|
46
46
|
const ext = path.extname(f).toLowerCase();
|
|
47
47
|
if (SKIP_EXTENSIONS.has(ext)) return false;
|
|
48
|
+
if (SKIP_FILENAMES.has(path.basename(f))) return false;
|
|
48
49
|
try { return fs.statSync(f).size <= MAX_FILE_SIZE; } catch { return false; }
|
|
49
50
|
});
|
|
50
51
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI Command — Optimized for CI/CD Pipelines
|
|
3
|
+
* =============================================
|
|
4
|
+
*
|
|
5
|
+
* Single command for CI pipelines with:
|
|
6
|
+
* - Exit code 1 if score < threshold (default 75)
|
|
7
|
+
* - SARIF output for GitHub Code Scanning upload
|
|
8
|
+
* - JSON output for custom integrations
|
|
9
|
+
* - Compact summary for CI logs
|
|
10
|
+
* - --fail-on flag for severity-based gating
|
|
11
|
+
*
|
|
12
|
+
* USAGE:
|
|
13
|
+
* npx ship-safe ci . Default: fail if score < 75
|
|
14
|
+
* npx ship-safe ci . --threshold 60 Custom score threshold
|
|
15
|
+
* npx ship-safe ci . --fail-on critical Only fail on critical findings
|
|
16
|
+
* npx ship-safe ci . --sarif results.sarif SARIF for GitHub Code Scanning
|
|
17
|
+
* npx ship-safe ci . --baseline Only check new findings
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { buildOrchestrator } from '../agents/index.js';
|
|
23
|
+
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
24
|
+
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
25
|
+
import { runDepsAudit } from './deps.js';
|
|
26
|
+
import { filterBaseline } from './baseline.js';
|
|
27
|
+
import {
|
|
28
|
+
SECRET_PATTERNS,
|
|
29
|
+
SKIP_DIRS,
|
|
30
|
+
SKIP_EXTENSIONS,
|
|
31
|
+
SKIP_FILENAMES,
|
|
32
|
+
MAX_FILE_SIZE,
|
|
33
|
+
loadGitignorePatterns
|
|
34
|
+
} from '../utils/patterns.js';
|
|
35
|
+
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
36
|
+
import fg from 'fast-glob';
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// MAIN COMMAND
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
export async function ciCommand(targetPath = '.', options = {}) {
|
|
43
|
+
const absolutePath = path.resolve(targetPath);
|
|
44
|
+
const threshold = options.threshold || 75;
|
|
45
|
+
const failOn = options.failOn || null;
|
|
46
|
+
const sarifPath = options.sarif || null;
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(absolutePath)) {
|
|
49
|
+
console.error(`[ship-safe] Path does not exist: ${absolutePath}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
|
|
55
|
+
// ── Secret Scan ──────────────────────────────────────────────────────────
|
|
56
|
+
const allFiles = await findFiles(absolutePath);
|
|
57
|
+
const secretFindings = [];
|
|
58
|
+
|
|
59
|
+
for (const file of allFiles) {
|
|
60
|
+
try {
|
|
61
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
64
|
+
const line = lines[lineNum];
|
|
65
|
+
if (/ship-safe-ignore/i.test(line)) continue;
|
|
66
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
67
|
+
pattern.pattern.lastIndex = 0;
|
|
68
|
+
let match;
|
|
69
|
+
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
70
|
+
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
|
|
71
|
+
secretFindings.push({
|
|
72
|
+
file, line: lineNum + 1, column: match.index + 1,
|
|
73
|
+
matched: match[0], severity: pattern.severity,
|
|
74
|
+
category: pattern.category || 'secrets',
|
|
75
|
+
rule: pattern.name, title: pattern.name.replace(/_/g, ' '),
|
|
76
|
+
description: pattern.description,
|
|
77
|
+
confidence: getConfidence(pattern, match[0]),
|
|
78
|
+
fix: 'Move to environment variable or secrets manager',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch { /* skip */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Agent Scan ───────────────────────────────────────────────────────────
|
|
87
|
+
const orchestrator = buildOrchestrator();
|
|
88
|
+
const results = await orchestrator.runAll(absolutePath, { quiet: true });
|
|
89
|
+
const agentFindings = results.findings;
|
|
90
|
+
|
|
91
|
+
// ── Dependency Audit ─────────────────────────────────────────────────────
|
|
92
|
+
let depVulns = [];
|
|
93
|
+
if (options.deps !== false) {
|
|
94
|
+
try {
|
|
95
|
+
const depResult = await runDepsAudit(absolutePath);
|
|
96
|
+
depVulns = depResult.vulns || [];
|
|
97
|
+
} catch { /* skip */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Merge & Deduplicate ──────────────────────────────────────────────────
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
let allFindings = [...secretFindings, ...agentFindings].filter(f => {
|
|
103
|
+
const key = `${f.file}:${f.line}:${f.rule}`;
|
|
104
|
+
if (seen.has(key)) return false;
|
|
105
|
+
seen.add(key);
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Apply policy
|
|
110
|
+
const policy = PolicyEngine.load(absolutePath);
|
|
111
|
+
allFindings = policy.applyPolicy(allFindings);
|
|
112
|
+
|
|
113
|
+
// Apply baseline filter
|
|
114
|
+
if (options.baseline) {
|
|
115
|
+
allFindings = filterBaseline(allFindings, absolutePath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Score ────────────────────────────────────────────────────────────────
|
|
119
|
+
const scoringEngine = new ScoringEngine();
|
|
120
|
+
const scoreResult = scoringEngine.compute(allFindings, depVulns);
|
|
121
|
+
scoringEngine.saveToHistory(absolutePath, scoreResult);
|
|
122
|
+
|
|
123
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
124
|
+
|
|
125
|
+
// ── SARIF Output ─────────────────────────────────────────────────────────
|
|
126
|
+
if (sarifPath) {
|
|
127
|
+
const sarif = buildSARIF(allFindings, absolutePath);
|
|
128
|
+
fs.writeFileSync(sarifPath, JSON.stringify(sarif, null, 2));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── JSON Output ──────────────────────────────────────────────────────────
|
|
132
|
+
if (options.json) {
|
|
133
|
+
console.log(JSON.stringify({
|
|
134
|
+
score: scoreResult.score,
|
|
135
|
+
grade: scoreResult.grade.letter,
|
|
136
|
+
totalFindings: allFindings.length,
|
|
137
|
+
totalDepVulns: depVulns.length,
|
|
138
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
139
|
+
high: allFindings.filter(f => f.severity === 'high').length,
|
|
140
|
+
medium: allFindings.filter(f => f.severity === 'medium').length,
|
|
141
|
+
low: allFindings.filter(f => f.severity === 'low').length,
|
|
142
|
+
threshold,
|
|
143
|
+
pass: determinePass(scoreResult, allFindings, threshold, failOn),
|
|
144
|
+
duration: `${duration}s`,
|
|
145
|
+
}, null, 2));
|
|
146
|
+
} else {
|
|
147
|
+
// ── Compact CI Summary ───────────────────────────────────────────────
|
|
148
|
+
const critical = allFindings.filter(f => f.severity === 'critical').length;
|
|
149
|
+
const high = allFindings.filter(f => f.severity === 'high').length;
|
|
150
|
+
const medium = allFindings.filter(f => f.severity === 'medium').length;
|
|
151
|
+
|
|
152
|
+
console.log(`[ship-safe] Score: ${scoreResult.score}/100 (${scoreResult.grade.letter}) | Findings: ${allFindings.length} (${critical}C ${high}H ${medium}M) | CVEs: ${depVulns.length} | ${duration}s`);
|
|
153
|
+
|
|
154
|
+
if (critical > 0) {
|
|
155
|
+
console.log(`[ship-safe] Critical findings:`);
|
|
156
|
+
for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
|
|
157
|
+
const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
|
|
158
|
+
console.log(` - ${f.rule} at ${rel}:${f.line}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (sarifPath) {
|
|
163
|
+
console.log(`[ship-safe] SARIF: ${sarifPath}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Exit Code ────────────────────────────────────────────────────────────
|
|
168
|
+
const pass = determinePass(scoreResult, allFindings, threshold, failOn);
|
|
169
|
+
if (!pass) {
|
|
170
|
+
if (!options.json) {
|
|
171
|
+
if (failOn) {
|
|
172
|
+
console.log(`[ship-safe] FAIL: Found ${failOn}-severity findings`);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(`[ship-safe] FAIL: Score ${scoreResult.score} < threshold ${threshold}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
process.exit(1);
|
|
178
|
+
} else {
|
|
179
|
+
if (!options.json) {
|
|
180
|
+
console.log(`[ship-safe] PASS`);
|
|
181
|
+
}
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// =============================================================================
|
|
187
|
+
// HELPERS
|
|
188
|
+
// =============================================================================
|
|
189
|
+
|
|
190
|
+
function determinePass(scoreResult, findings, threshold, failOn) {
|
|
191
|
+
if (failOn) {
|
|
192
|
+
const sevOrder = ['critical', 'high', 'medium', 'low'];
|
|
193
|
+
const failIndex = sevOrder.indexOf(failOn);
|
|
194
|
+
if (failIndex === -1) return scoreResult.score >= threshold;
|
|
195
|
+
const blockingSevs = sevOrder.slice(0, failIndex + 1);
|
|
196
|
+
return !findings.some(f => blockingSevs.includes(f.severity));
|
|
197
|
+
}
|
|
198
|
+
return scoreResult.score >= threshold;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildSARIF(findings, rootPath) {
|
|
202
|
+
const rules = {};
|
|
203
|
+
for (const f of findings) {
|
|
204
|
+
if (!rules[f.rule]) {
|
|
205
|
+
rules[f.rule] = {
|
|
206
|
+
id: f.rule, name: f.title || f.rule,
|
|
207
|
+
shortDescription: { text: f.title || f.rule },
|
|
208
|
+
fullDescription: { text: f.description || '' },
|
|
209
|
+
defaultConfiguration: {
|
|
210
|
+
level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
version: '2.1.0',
|
|
218
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
219
|
+
runs: [{
|
|
220
|
+
tool: {
|
|
221
|
+
driver: {
|
|
222
|
+
name: 'ship-safe', version: '5.0.0',
|
|
223
|
+
informationUri: 'https://github.com/asamassekou10/ship-safe',
|
|
224
|
+
rules: Object.values(rules),
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
results: findings.map(f => ({
|
|
228
|
+
ruleId: f.rule,
|
|
229
|
+
level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
|
|
230
|
+
message: { text: `${f.title}: ${f.description}` },
|
|
231
|
+
locations: [{
|
|
232
|
+
physicalLocation: {
|
|
233
|
+
artifactLocation: {
|
|
234
|
+
uri: path.relative(rootPath, f.file).replace(/\\/g, '/'),
|
|
235
|
+
uriBaseId: '%SRCROOT%',
|
|
236
|
+
},
|
|
237
|
+
region: { startLine: f.line, startColumn: f.column || 1 },
|
|
238
|
+
},
|
|
239
|
+
}],
|
|
240
|
+
})),
|
|
241
|
+
}],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function findFiles(rootPath) {
|
|
246
|
+
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
247
|
+
const gitignoreGlobs = loadGitignorePatterns(rootPath);
|
|
248
|
+
globIgnore.push(...gitignoreGlobs);
|
|
249
|
+
|
|
250
|
+
const files = await fg('**/*', {
|
|
251
|
+
cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return files.filter(file => {
|
|
255
|
+
const ext = path.extname(file).toLowerCase();
|
|
256
|
+
if (SKIP_EXTENSIONS.has(ext)) return false;
|
|
257
|
+
if (SKIP_FILENAMES.has(path.basename(file))) return false;
|
|
258
|
+
if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
|
|
259
|
+
try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
|
|
260
|
+
return true;
|
|
261
|
+
});
|
|
262
|
+
}
|