ship-safe 4.3.0 → 5.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 +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 +9 -0
- 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/audit.js +32 -0
- package/cli/commands/ci.js +260 -0
- package/cli/commands/red-team.js +8 -2
- package/cli/utils/secrets-verifier.js +247 -0
- package/package.json +2 -2
|
@@ -283,7 +283,158 @@ export class SupplyChainAudit extends BaseAgent {
|
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
// ── 5.
|
|
286
|
+
// ── 5. Package behavioral signals (Socket-style) ─────────────────────────
|
|
287
|
+
if (fs.existsSync(pkgPath)) {
|
|
288
|
+
try {
|
|
289
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
290
|
+
const allDeps = {
|
|
291
|
+
...(pkg.dependencies || {}),
|
|
292
|
+
...(pkg.devDependencies || {}),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Scan node_modules for behavioral red flags
|
|
296
|
+
const nodeModulesPath = path.join(rootPath, 'node_modules');
|
|
297
|
+
if (fs.existsSync(nodeModulesPath)) {
|
|
298
|
+
for (const depName of Object.keys(allDeps).slice(0, 50)) {
|
|
299
|
+
const depDir = path.join(nodeModulesPath, depName);
|
|
300
|
+
const depPkgPath = path.join(depDir, 'package.json');
|
|
301
|
+
if (!fs.existsSync(depPkgPath)) continue;
|
|
302
|
+
try {
|
|
303
|
+
const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
|
|
304
|
+
|
|
305
|
+
// Check for postinstall scripts with network/eval calls
|
|
306
|
+
const scripts = depPkg.scripts || {};
|
|
307
|
+
for (const hook of ['preinstall', 'install', 'postinstall']) {
|
|
308
|
+
const cmd = scripts[hook];
|
|
309
|
+
if (!cmd) continue;
|
|
310
|
+
if (/node\s+-e|node\s+--eval/.test(cmd)) {
|
|
311
|
+
findings.push(createFinding({
|
|
312
|
+
file: depPkgPath,
|
|
313
|
+
line: 0,
|
|
314
|
+
severity: 'high',
|
|
315
|
+
category: 'supply-chain',
|
|
316
|
+
rule: 'BEHAVIORAL_INLINE_EVAL',
|
|
317
|
+
title: `Inline Code Execution in ${hook}: ${depName}`,
|
|
318
|
+
description: `Dependency "${depName}" runs inline Node.js code during ${hook}. This is a common pattern in malicious packages.`,
|
|
319
|
+
matched: cmd.slice(0, 200),
|
|
320
|
+
fix: 'Review the inline code. Consider using --ignore-scripts or removing the dependency.',
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch { /* skip */ }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Detect obfuscated code patterns in dependencies
|
|
329
|
+
const codeFiles = (context.files || []).filter(f =>
|
|
330
|
+
f.includes('node_modules') &&
|
|
331
|
+
!f.includes('node_modules/.cache') &&
|
|
332
|
+
path.extname(f).toLowerCase() === '.js' &&
|
|
333
|
+
!path.basename(f).endsWith('.min.js')
|
|
334
|
+
).slice(0, 30); // Sample up to 30 files
|
|
335
|
+
|
|
336
|
+
for (const file of codeFiles) {
|
|
337
|
+
const content = this.readFile(file);
|
|
338
|
+
if (!content || content.length < 100) continue;
|
|
339
|
+
|
|
340
|
+
// Excessive hex encoding
|
|
341
|
+
const hexMatches = (content.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
|
|
342
|
+
if (hexMatches > 20) {
|
|
343
|
+
findings.push(createFinding({
|
|
344
|
+
file,
|
|
345
|
+
line: 1,
|
|
346
|
+
severity: 'high',
|
|
347
|
+
category: 'supply-chain',
|
|
348
|
+
rule: 'BEHAVIORAL_HEX_OBFUSCATION',
|
|
349
|
+
title: 'Obfuscated Code: Excessive Hex Encoding',
|
|
350
|
+
description: `File contains ${hexMatches} hex-encoded sequences. Common in malicious packages trying to hide payload.`,
|
|
351
|
+
matched: `${hexMatches} hex sequences detected`,
|
|
352
|
+
fix: 'Inspect the deobfuscated code. Consider removing this dependency.',
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Excessive String.fromCharCode
|
|
357
|
+
const charCodeMatches = (content.match(/String\.fromCharCode/g) || []).length;
|
|
358
|
+
if (charCodeMatches > 5) {
|
|
359
|
+
findings.push(createFinding({
|
|
360
|
+
file,
|
|
361
|
+
line: 1,
|
|
362
|
+
severity: 'high',
|
|
363
|
+
category: 'supply-chain',
|
|
364
|
+
rule: 'BEHAVIORAL_CHARCODE_OBFUSCATION',
|
|
365
|
+
title: 'Obfuscated Code: Excessive String.fromCharCode',
|
|
366
|
+
description: `File contains ${charCodeMatches} String.fromCharCode calls. Common obfuscation technique in malicious packages.`,
|
|
367
|
+
matched: `${charCodeMatches} String.fromCharCode calls`,
|
|
368
|
+
fix: 'Inspect the deobfuscated code. Consider removing this dependency.',
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Base64 decode chains
|
|
373
|
+
const base64Matches = (content.match(/Buffer\.from\s*\([^,]+,\s*['"]base64['"]\)/g) || []).length;
|
|
374
|
+
if (base64Matches > 3) {
|
|
375
|
+
findings.push(createFinding({
|
|
376
|
+
file,
|
|
377
|
+
line: 1,
|
|
378
|
+
severity: 'medium',
|
|
379
|
+
category: 'supply-chain',
|
|
380
|
+
rule: 'BEHAVIORAL_BASE64_DECODE',
|
|
381
|
+
title: 'Suspicious: Multiple Base64 Decode Operations',
|
|
382
|
+
description: `File contains ${base64Matches} base64 decode operations. May indicate hidden payload.`,
|
|
383
|
+
matched: `${base64Matches} base64 decode operations`,
|
|
384
|
+
confidence: 'medium',
|
|
385
|
+
fix: 'Review what data is being decoded. Legitimate use is possible but warrants inspection.',
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Detect unused dependencies (in package.json but never imported)
|
|
391
|
+
const projectFiles = (context.files || []).filter(f =>
|
|
392
|
+
!f.includes('node_modules') &&
|
|
393
|
+
['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(path.extname(f).toLowerCase())
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
if (projectFiles.length > 0 && projectFiles.length < 500) {
|
|
397
|
+
const allImports = new Set();
|
|
398
|
+
for (const file of projectFiles) {
|
|
399
|
+
const content = this.readFile(file);
|
|
400
|
+
if (!content) continue;
|
|
401
|
+
// Capture import/require module names
|
|
402
|
+
const importMatches = content.matchAll(/(?:from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g);
|
|
403
|
+
for (const m of importMatches) {
|
|
404
|
+
const mod = (m[1] || m[2] || '').split('/')[0]; // Get package name (not subpath)
|
|
405
|
+
if (mod && !mod.startsWith('.')) allImports.add(mod);
|
|
406
|
+
// Handle scoped packages
|
|
407
|
+
const fullMod = m[1] || m[2] || '';
|
|
408
|
+
if (fullMod.startsWith('@')) {
|
|
409
|
+
const scopedPkg = fullMod.split('/').slice(0, 2).join('/');
|
|
410
|
+
allImports.add(scopedPkg);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const prodDeps = Object.keys(pkg.dependencies || {});
|
|
416
|
+
for (const dep of prodDeps) {
|
|
417
|
+
if (!allImports.has(dep) && !dep.startsWith('@types/')) {
|
|
418
|
+
findings.push(createFinding({
|
|
419
|
+
file: pkgPath,
|
|
420
|
+
line: 0,
|
|
421
|
+
severity: 'low',
|
|
422
|
+
category: 'supply-chain',
|
|
423
|
+
rule: 'UNUSED_DEPENDENCY',
|
|
424
|
+
title: `Unused Dependency: ${dep}`,
|
|
425
|
+
description: `"${dep}" is in dependencies but never imported in project code. Unused dependencies increase attack surface.`,
|
|
426
|
+
matched: dep,
|
|
427
|
+
confidence: 'low',
|
|
428
|
+
fix: `Remove if unused: npm uninstall ${dep}`,
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
} catch { /* skip */ }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── 6. Check Python requirements ──────────────────────────────────────────
|
|
287
438
|
const reqPath = path.join(rootPath, 'requirements.txt');
|
|
288
439
|
if (fs.existsSync(reqPath)) {
|
|
289
440
|
const content = this.readFile(reqPath) || '';
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VerifierAgent — Second-Pass Finding Confirmation
|
|
3
|
+
* ==================================================
|
|
4
|
+
*
|
|
5
|
+
* Runs after all agents complete. Takes high-confidence findings
|
|
6
|
+
* and attempts to confirm or downgrade them by analyzing surrounding
|
|
7
|
+
* code context.
|
|
8
|
+
*
|
|
9
|
+
* Checks:
|
|
10
|
+
* - Is the flagged value static/hardcoded or dynamic (from user input)?
|
|
11
|
+
* - Is there upstream sanitization or validation?
|
|
12
|
+
* - Is the code inside error handling that neutralizes it?
|
|
13
|
+
* - Is the finding in dead/unreachable code?
|
|
14
|
+
*
|
|
15
|
+
* Impact: Unverified findings get downgraded one confidence level.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// HEURISTIC PATTERNS
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/** Sources of user input — if a finding's matched code references these, it's more likely real */
|
|
26
|
+
const USER_INPUT_SOURCES = [
|
|
27
|
+
/req\.body/,
|
|
28
|
+
/req\.query/,
|
|
29
|
+
/req\.params/,
|
|
30
|
+
/req\.headers/,
|
|
31
|
+
/request\.body/,
|
|
32
|
+
/request\.query/,
|
|
33
|
+
/request\.params/,
|
|
34
|
+
/request\.form/,
|
|
35
|
+
/request\.args/,
|
|
36
|
+
/request\.json/,
|
|
37
|
+
/ctx\.request/,
|
|
38
|
+
/ctx\.query/,
|
|
39
|
+
/ctx\.params/,
|
|
40
|
+
/event\.body/,
|
|
41
|
+
/event\.queryStringParameters/,
|
|
42
|
+
/searchParams/,
|
|
43
|
+
/formData/,
|
|
44
|
+
/userinput/i,
|
|
45
|
+
/user_input/i,
|
|
46
|
+
/input\s*\(/,
|
|
47
|
+
/argv/,
|
|
48
|
+
/process\.env/,
|
|
49
|
+
/getenv/,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Sanitization/validation indicators — presence near a finding suggests it's protected */
|
|
53
|
+
const SANITIZATION_PATTERNS = [
|
|
54
|
+
/sanitize/i,
|
|
55
|
+
/validate/i,
|
|
56
|
+
/escape/i,
|
|
57
|
+
/purify/i,
|
|
58
|
+
/DOMPurify/,
|
|
59
|
+
/xss\s*\(/i,
|
|
60
|
+
/htmlencode/i,
|
|
61
|
+
/encodeURI/,
|
|
62
|
+
/encodeURIComponent/,
|
|
63
|
+
/parameterized/i,
|
|
64
|
+
/prepared\s*statement/i,
|
|
65
|
+
/placeholder/i,
|
|
66
|
+
/\?\s*,/,
|
|
67
|
+
/\$\d+/,
|
|
68
|
+
/bindParam/i,
|
|
69
|
+
/bindValue/i,
|
|
70
|
+
/zod/i,
|
|
71
|
+
/yup/i,
|
|
72
|
+
/joi\./i,
|
|
73
|
+
/ajv/i,
|
|
74
|
+
/schema\.parse/i,
|
|
75
|
+
/safeParse/i,
|
|
76
|
+
/validator\./i,
|
|
77
|
+
/parseInt\s*\(/,
|
|
78
|
+
/parseFloat\s*\(/,
|
|
79
|
+
/Number\s*\(/,
|
|
80
|
+
/\.trim\s*\(/,
|
|
81
|
+
/\.replace\s*\(/,
|
|
82
|
+
/allowlist/i,
|
|
83
|
+
/whitelist/i,
|
|
84
|
+
/blocklist/i,
|
|
85
|
+
/blacklist/i,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** Error handling wrappers — findings inside these are less exploitable */
|
|
89
|
+
const ERROR_HANDLING_PATTERNS = [
|
|
90
|
+
/}\s*catch\s*\(/,
|
|
91
|
+
/\.catch\s*\(/,
|
|
92
|
+
/try\s*\{/,
|
|
93
|
+
/if\s*\(\s*err/,
|
|
94
|
+
/on\s*\(\s*['"]error['"]/,
|
|
95
|
+
/\.on\s*\(\s*['"]error['"]/,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/** Static/hardcoded value indicators — finding uses a constant, not user input */
|
|
99
|
+
const STATIC_VALUE_PATTERNS = [
|
|
100
|
+
/['"][^'"]{0,200}['"]/,
|
|
101
|
+
/const\s+\w+\s*=\s*['"][^'"]*['"]/,
|
|
102
|
+
/^\s*\/\//,
|
|
103
|
+
/^\s*\*/,
|
|
104
|
+
/^\s*#/,
|
|
105
|
+
/TODO|FIXME|HACK|NOTE/,
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** Dead code indicators */
|
|
109
|
+
const DEAD_CODE_PATTERNS = [
|
|
110
|
+
/return\s+/,
|
|
111
|
+
/throw\s+/,
|
|
112
|
+
/process\.exit/,
|
|
113
|
+
/^\s*\/\//,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// VERIFIER AGENT
|
|
118
|
+
// =============================================================================
|
|
119
|
+
|
|
120
|
+
export class VerifierAgent {
|
|
121
|
+
constructor() {
|
|
122
|
+
this.name = 'VerifierAgent';
|
|
123
|
+
this.description = 'Second-pass verification of findings';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Verify an array of findings by analyzing surrounding code context.
|
|
128
|
+
* Returns findings with added `verified` and `verifierNote` fields.
|
|
129
|
+
*
|
|
130
|
+
* @param {object[]} findings — Findings from all agents (post-dedup)
|
|
131
|
+
* @param {object} options — { verbose }
|
|
132
|
+
* @returns {object[]} — Findings with verification metadata
|
|
133
|
+
*/
|
|
134
|
+
verify(findings, options = {}) {
|
|
135
|
+
const fileCache = new Map();
|
|
136
|
+
|
|
137
|
+
for (const finding of findings) {
|
|
138
|
+
// Only verify critical and high severity findings
|
|
139
|
+
if (finding.severity !== 'critical' && finding.severity !== 'high') {
|
|
140
|
+
finding.verified = null; // not checked
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = this._verifyFinding(finding, fileCache);
|
|
145
|
+
finding.verified = result.verified;
|
|
146
|
+
finding.verifierNote = result.note;
|
|
147
|
+
|
|
148
|
+
// Downgrade unverified findings one confidence level
|
|
149
|
+
if (!result.verified) {
|
|
150
|
+
if (finding.confidence === 'high') finding.confidence = 'medium';
|
|
151
|
+
else if (finding.confidence === 'medium') finding.confidence = 'low';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return findings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Verify a single finding by reading surrounding code.
|
|
160
|
+
*/
|
|
161
|
+
_verifyFinding(finding, fileCache) {
|
|
162
|
+
const { file, line, matched } = finding;
|
|
163
|
+
if (!file || !line) {
|
|
164
|
+
return { verified: null, note: 'Missing file or line info' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Read the file (cached)
|
|
168
|
+
let lines;
|
|
169
|
+
if (fileCache.has(file)) {
|
|
170
|
+
lines = fileCache.get(file);
|
|
171
|
+
} else {
|
|
172
|
+
try {
|
|
173
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
174
|
+
lines = content.split('\n');
|
|
175
|
+
fileCache.set(file, lines);
|
|
176
|
+
} catch {
|
|
177
|
+
return { verified: null, note: 'Could not read file' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Get a 30-line window around the finding (15 before, 15 after)
|
|
182
|
+
const windowStart = Math.max(0, line - 16);
|
|
183
|
+
const windowEnd = Math.min(lines.length, line + 15);
|
|
184
|
+
const window = lines.slice(windowStart, windowEnd);
|
|
185
|
+
const windowText = window.join('\n');
|
|
186
|
+
|
|
187
|
+
// Get lines BEFORE the finding (for upstream checks)
|
|
188
|
+
const beforeStart = Math.max(0, line - 16);
|
|
189
|
+
const beforeEnd = Math.max(0, line - 1);
|
|
190
|
+
const beforeText = lines.slice(beforeStart, beforeEnd).join('\n');
|
|
191
|
+
|
|
192
|
+
// Get the finding line itself
|
|
193
|
+
const findingLine = lines[line - 1] || '';
|
|
194
|
+
|
|
195
|
+
// ── Check 1: Is user input involved? ──────────────────────────
|
|
196
|
+
const hasUserInput = USER_INPUT_SOURCES.some(p => p.test(windowText));
|
|
197
|
+
|
|
198
|
+
// ── Check 2: Is there sanitization/validation upstream? ───────
|
|
199
|
+
const hasSanitization = SANITIZATION_PATTERNS.some(p => p.test(beforeText));
|
|
200
|
+
|
|
201
|
+
// ── Check 3: Is the value static/hardcoded? ───────────────────
|
|
202
|
+
const isStatic = this._isStaticValue(findingLine, matched);
|
|
203
|
+
|
|
204
|
+
// ── Check 4: Is it inside error handling? ─────────────────────
|
|
205
|
+
const inErrorHandler = ERROR_HANDLING_PATTERNS.some(p => p.test(beforeText));
|
|
206
|
+
|
|
207
|
+
// ── Check 5: Is it in dead/unreachable code? ──────────────────
|
|
208
|
+
const isDeadCode = this._isDeadCode(lines, line);
|
|
209
|
+
|
|
210
|
+
// ── Decision logic ────────────────────────────────────────────
|
|
211
|
+
if (isDeadCode) {
|
|
212
|
+
return {
|
|
213
|
+
verified: false,
|
|
214
|
+
note: 'Finding appears to be in unreachable code (after return/throw)',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (isStatic && !hasUserInput) {
|
|
219
|
+
return {
|
|
220
|
+
verified: false,
|
|
221
|
+
note: 'Value appears to be static/hardcoded, not user-controlled',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (hasSanitization) {
|
|
226
|
+
return {
|
|
227
|
+
verified: false,
|
|
228
|
+
note: 'Sanitization or validation detected upstream of finding',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (hasUserInput && !hasSanitization) {
|
|
233
|
+
return {
|
|
234
|
+
verified: true,
|
|
235
|
+
note: 'User input flows to this sink without visible sanitization',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (inErrorHandler) {
|
|
240
|
+
return {
|
|
241
|
+
verified: false,
|
|
242
|
+
note: 'Finding is inside error handling context, reducing exploitability',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Default: cannot determine, keep as-is
|
|
247
|
+
return {
|
|
248
|
+
verified: null,
|
|
249
|
+
note: 'Could not determine verification status from code context',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Check if the matched code is using a static/hardcoded value.
|
|
255
|
+
*/
|
|
256
|
+
_isStaticValue(line, matched) {
|
|
257
|
+
// If the finding line is a comment, it's static
|
|
258
|
+
if (/^\s*(?:\/\/|#|\*|\/\*)/.test(line)) return true;
|
|
259
|
+
|
|
260
|
+
// If the matched text is just a string literal with no interpolation
|
|
261
|
+
if (/^['"][^'"]*['"]$/.test(matched)) return true;
|
|
262
|
+
|
|
263
|
+
// If the line is a const assignment to a string literal
|
|
264
|
+
if (/const\s+\w+\s*=\s*['"][^'"]*['"]/.test(line)) return true;
|
|
265
|
+
|
|
266
|
+
// If it looks like a TODO/placeholder comment
|
|
267
|
+
if (/TODO|FIXME|EXAMPLE|PLACEHOLDER|SAMPLE/i.test(line)) return true;
|
|
268
|
+
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check if a line is after a return/throw (dead code).
|
|
274
|
+
*/
|
|
275
|
+
_isDeadCode(lines, lineNum) {
|
|
276
|
+
// Check the 5 lines before the finding for return/throw
|
|
277
|
+
for (let i = Math.max(0, lineNum - 6); i < lineNum - 1; i++) {
|
|
278
|
+
const l = lines[i]?.trim() || '';
|
|
279
|
+
// If a return/throw is found and there's no conditional/block opener after
|
|
280
|
+
if (/^(?:return\s|throw\s|process\.exit)/.test(l)) {
|
|
281
|
+
// Check if there's a } or else between the return and our line
|
|
282
|
+
const between = lines.slice(i + 1, lineNum - 1).join('\n');
|
|
283
|
+
if (!/[{}]|else|case/.test(between)) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export default VerifierAgent;
|
package/cli/bin/ship-safe.js
CHANGED
|
@@ -36,6 +36,7 @@ import { watchCommand } from '../commands/watch.js';
|
|
|
36
36
|
import { auditCommand } from '../commands/audit.js';
|
|
37
37
|
import { doctorCommand } from '../commands/doctor.js';
|
|
38
38
|
import { baselineCommand } from '../commands/baseline.js';
|
|
39
|
+
import { ciCommand } from '../commands/ci.js';
|
|
39
40
|
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
40
41
|
import { SBOMGenerator } from '../agents/sbom-generator.js';
|
|
41
42
|
|
|
@@ -188,7 +189,7 @@ program
|
|
|
188
189
|
// -----------------------------------------------------------------------------
|
|
189
190
|
program
|
|
190
191
|
.command('audit [path]')
|
|
191
|
-
.description('Full security audit: secrets +
|
|
192
|
+
.description('Full security audit: secrets + 16 agents + deps + score + deep analysis + remediation plan')
|
|
192
193
|
.option('--json', 'Output results as JSON')
|
|
193
194
|
.option('--sarif', 'Output results in SARIF format')
|
|
194
195
|
.option('--csv', 'Output results as CSV')
|
|
@@ -201,6 +202,11 @@ program
|
|
|
201
202
|
.option('--no-cache', 'Force full rescan (ignore cached results)')
|
|
202
203
|
.option('--baseline', 'Only show findings not in the baseline')
|
|
203
204
|
.option('--pdf [file]', 'Generate PDF report (requires Chrome/Chromium)')
|
|
205
|
+
.option('--deep', 'LLM-powered taint analysis for critical/high findings')
|
|
206
|
+
.option('--local', 'Use local Ollama model for deep analysis (default: llama3.2)')
|
|
207
|
+
.option('--model <model>', 'LLM model to use for deep/AI analysis')
|
|
208
|
+
.option('--budget <cents>', 'Max spend in cents for deep analysis (default: 50)', parseInt)
|
|
209
|
+
.option('--verify', 'Check if leaked secrets are still active (probes provider APIs)')
|
|
204
210
|
.option('-v, --verbose', 'Verbose output')
|
|
205
211
|
.action(auditCommand);
|
|
206
212
|
|
|
@@ -209,7 +215,7 @@ program
|
|
|
209
215
|
// -----------------------------------------------------------------------------
|
|
210
216
|
program
|
|
211
217
|
.command('red-team [path]')
|
|
212
|
-
.description('Multi-agent security audit:
|
|
218
|
+
.description('Multi-agent security audit: 16 agents scan for 80+ attack classes')
|
|
213
219
|
.option('--agents <list>', 'Comma-separated list of agents to run')
|
|
214
220
|
.option('--json', 'Output results as JSON')
|
|
215
221
|
.option('--sarif', 'Output results in SARIF format')
|
|
@@ -217,6 +223,10 @@ program
|
|
|
217
223
|
.option('--sbom [file]', 'Generate CycloneDX SBOM')
|
|
218
224
|
.option('--no-deps', 'Skip dependency audit')
|
|
219
225
|
.option('--no-ai', 'Skip AI classification')
|
|
226
|
+
.option('--deep', 'LLM-powered taint analysis for critical/high findings')
|
|
227
|
+
.option('--local', 'Use local Ollama model for deep analysis (default: llama3.2)')
|
|
228
|
+
.option('--model <model>', 'LLM model for deep analysis')
|
|
229
|
+
.option('--budget <cents>', 'Max spend in cents for deep analysis (default: 50)', parseInt)
|
|
220
230
|
.option('-v, --verbose', 'Verbose output')
|
|
221
231
|
.action(redTeamCommand);
|
|
222
232
|
|
|
@@ -269,6 +279,20 @@ program
|
|
|
269
279
|
.option('--clear', 'Remove the baseline')
|
|
270
280
|
.action(baselineCommand);
|
|
271
281
|
|
|
282
|
+
// -----------------------------------------------------------------------------
|
|
283
|
+
// CI COMMAND (v5.0 — CI/CD Pipeline Integration)
|
|
284
|
+
// -----------------------------------------------------------------------------
|
|
285
|
+
program
|
|
286
|
+
.command('ci [path]')
|
|
287
|
+
.description('CI/CD pipeline mode: scan, score, exit 1 on failure — optimized for automation')
|
|
288
|
+
.option('--threshold <score>', 'Minimum passing score (default: 75)', parseInt)
|
|
289
|
+
.option('--fail-on <severity>', 'Fail on findings at this severity or above (critical, high, medium)')
|
|
290
|
+
.option('--sarif <file>', 'Write SARIF output for GitHub Code Scanning')
|
|
291
|
+
.option('--json', 'JSON output')
|
|
292
|
+
.option('--no-deps', 'Skip dependency audit')
|
|
293
|
+
.option('--baseline', 'Only check new findings (not in baseline)')
|
|
294
|
+
.action(ciCommand);
|
|
295
|
+
|
|
272
296
|
// -----------------------------------------------------------------------------
|
|
273
297
|
// DOCTOR COMMAND
|
|
274
298
|
// -----------------------------------------------------------------------------
|
|
@@ -285,11 +309,13 @@ program
|
|
|
285
309
|
if (process.argv.length === 2) {
|
|
286
310
|
console.log(banner);
|
|
287
311
|
console.log(chalk.yellow('\nQuick start:\n'));
|
|
288
|
-
console.log(chalk.cyan.bold('
|
|
289
|
-
console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + agents + deps + remediation
|
|
290
|
-
console.log(chalk.white(' npx ship-safe
|
|
312
|
+
console.log(chalk.cyan.bold(' v5.0 — Full Security Audit'));
|
|
313
|
+
console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + 16 agents + deps + remediation'));
|
|
314
|
+
console.log(chalk.white(' npx ship-safe audit . --deep') + chalk.gray('# LLM-powered taint analysis (Anthropic/Ollama)'));
|
|
315
|
+
console.log(chalk.white(' npx ship-safe red-team . ') + chalk.gray('# 16-agent red team scan (80+ attack classes)'));
|
|
316
|
+
console.log(chalk.white(' npx ship-safe ci . ') + chalk.gray('# CI/CD mode: scan, score, exit code'));
|
|
291
317
|
console.log(chalk.white(' npx ship-safe watch . ') + chalk.gray('# Continuous monitoring mode'));
|
|
292
|
-
console.log(chalk.white(' npx ship-safe sbom . ') + chalk.gray('# Generate CycloneDX SBOM'));
|
|
318
|
+
console.log(chalk.white(' npx ship-safe sbom . ') + chalk.gray('# Generate CycloneDX SBOM (CRA-ready)'));
|
|
293
319
|
console.log(chalk.white(' npx ship-safe policy init ') + chalk.gray('# Create security policy template'));
|
|
294
320
|
console.log(chalk.white(' npx ship-safe doctor ') + chalk.gray('# Check environment and configuration'));
|
|
295
321
|
console.log();
|
package/cli/commands/audit.js
CHANGED
|
@@ -36,6 +36,7 @@ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
|
36
36
|
import { CacheManager } from '../utils/cache-manager.js';
|
|
37
37
|
import { filterBaseline } from './baseline.js';
|
|
38
38
|
import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
|
|
39
|
+
import { SecretsVerifier } from '../utils/secrets-verifier.js';
|
|
39
40
|
|
|
40
41
|
// =============================================================================
|
|
41
42
|
// CONSTANTS
|
|
@@ -163,6 +164,11 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
163
164
|
// Suppress individual agent spinners by using quiet mode
|
|
164
165
|
// Pass changedFiles for incremental scanning if cache is valid
|
|
165
166
|
const orchestratorOpts = { quiet: true };
|
|
167
|
+
if (options.deep) orchestratorOpts.deep = true;
|
|
168
|
+
if (options.local) orchestratorOpts.local = true;
|
|
169
|
+
if (options.model) orchestratorOpts.model = options.model;
|
|
170
|
+
if (options.budget) orchestratorOpts.budget = options.budget;
|
|
171
|
+
if (options.verbose) orchestratorOpts.verbose = true;
|
|
166
172
|
if (cacheDiff && cacheDiff.changedFiles.length < allFiles.length) {
|
|
167
173
|
orchestratorOpts.changedFiles = cacheDiff.changedFiles;
|
|
168
174
|
}
|
|
@@ -287,6 +293,32 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
// ── Secrets Verification (optional, --verify flag) ─────────────────────
|
|
297
|
+
if (options.verify) {
|
|
298
|
+
const verifySpinner = machineOutput ? null : ora({ text: 'Verifying leaked secrets against provider APIs...', color: 'cyan' }).start();
|
|
299
|
+
try {
|
|
300
|
+
const verifier = new SecretsVerifier();
|
|
301
|
+
const verifyResults = await verifier.verify(filteredFindings);
|
|
302
|
+
const activeCount = verifyResults.filter(r => r.result.active === true).length;
|
|
303
|
+
const inactiveCount = verifyResults.filter(r => r.result.active === false).length;
|
|
304
|
+
if (verifySpinner) {
|
|
305
|
+
verifySpinner.succeed(chalk.green(
|
|
306
|
+
`Secrets verified: ${activeCount} active, ${inactiveCount} inactive, ${verifyResults.length - activeCount - inactiveCount} unknown`
|
|
307
|
+
));
|
|
308
|
+
}
|
|
309
|
+
// Show active secrets warning
|
|
310
|
+
if (activeCount > 0 && !machineOutput) {
|
|
311
|
+
console.log(chalk.red.bold(' ⚠ ACTIVE SECRETS DETECTED — rotate immediately:'));
|
|
312
|
+
for (const r of verifyResults.filter(r => r.result.active === true)) {
|
|
313
|
+
const rel = path.relative(absolutePath, r.finding.file).replace(/\\/g, '/');
|
|
314
|
+
console.log(chalk.red(` ${r.result.provider}: ${rel}:${r.finding.line} — ${r.result.info}`));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (verifySpinner) verifySpinner.fail(chalk.yellow(`Secrets verification failed: ${err.message}`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
290
322
|
// ── Save Cache ──────────────────────────────────────────────────────────
|
|
291
323
|
if (useCache) {
|
|
292
324
|
try {
|