ship-safe 9.1.0 → 9.1.2
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/cli/agents/deep-analyzer.js +26 -16
- package/cli/agents/orchestrator.js +6 -4
- package/cli/agents/stateful-watcher.js +238 -0
- package/cli/agents/swarm-orchestrator.js +200 -0
- package/cli/bin/ship-safe.js +18 -2
- package/cli/commands/audit.js +2 -5
- package/cli/commands/red-team.js +66 -19
- package/cli/commands/rotate.js +200 -3
- package/cli/commands/team-report.js +415 -0
- package/cli/commands/watch.js +134 -0
- package/cli/providers/llm-provider.js +91 -2
- package/cli/utils/output.js +21 -0
- package/package.json +1 -1
package/cli/commands/audit.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
loadGitignorePatterns
|
|
36
36
|
} from '../utils/patterns.js';
|
|
37
37
|
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
38
|
+
import { printBanner } from '../utils/output.js';
|
|
38
39
|
import { CacheManager } from '../utils/cache-manager.js';
|
|
39
40
|
import { filterBaseline } from './baseline.js';
|
|
40
41
|
import { SecurityMemory } from '../utils/security-memory.js';
|
|
@@ -89,11 +90,7 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
if (!machineOutput) {
|
|
92
|
-
|
|
93
|
-
console.log(chalk.cyan('═'.repeat(60)));
|
|
94
|
-
console.log(chalk.cyan.bold(' Ship Safe — Full Security Audit'));
|
|
95
|
-
console.log(chalk.cyan('═'.repeat(60)));
|
|
96
|
-
console.log();
|
|
93
|
+
printBanner();
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
// ── Cache Layer ──────────────────────────────────────────────────────────
|
package/cli/commands/red-team.js
CHANGED
|
@@ -18,6 +18,8 @@ import path from 'path';
|
|
|
18
18
|
import chalk from 'chalk';
|
|
19
19
|
import ora from 'ora';
|
|
20
20
|
import { buildOrchestratorAsync } from '../agents/index.js';
|
|
21
|
+
import { SwarmOrchestrator } from '../agents/swarm-orchestrator.js';
|
|
22
|
+
import { ReconAgent } from '../agents/recon-agent.js';
|
|
21
23
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
22
24
|
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
23
25
|
import { HTMLReporter } from '../agents/html-reporter.js';
|
|
@@ -25,6 +27,7 @@ import { SBOMGenerator } from '../agents/sbom-generator.js';
|
|
|
25
27
|
import { autoDetectProvider } from '../providers/llm-provider.js';
|
|
26
28
|
import { runDepsAudit } from './deps.js';
|
|
27
29
|
import * as output from '../utils/output.js';
|
|
30
|
+
import { printBanner } from '../utils/output.js';
|
|
28
31
|
|
|
29
32
|
export async function redTeamCommand(targetPath = '.', options = {}) {
|
|
30
33
|
const absolutePath = path.resolve(targetPath);
|
|
@@ -34,31 +37,75 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
|
|
|
34
37
|
process.exit(1);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
console.log();
|
|
38
|
-
output.header('Ship Safe v4.0 — Multi-Agent Security Audit');
|
|
39
40
|
console.log();
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
let findings = [];
|
|
43
|
+
let recon = {};
|
|
44
|
+
let agentResults = [];
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// ── 1a. Swarm mode (parallel execution via best available provider) ────────
|
|
47
|
+
if (options.swarm) {
|
|
48
|
+
printBanner();
|
|
49
|
+
output.header('AI Swarm Mode');
|
|
50
|
+
console.log();
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
const swarm = SwarmOrchestrator.create(absolutePath, {
|
|
53
|
+
provider: options.provider,
|
|
54
|
+
model: options.model,
|
|
55
|
+
verbose: options.verbose,
|
|
56
|
+
budgetCents: options.budget || 200,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!swarm) {
|
|
60
|
+
output.error('Swarm mode requires DEEPSEEK_API_KEY or MOONSHOT_API_KEY. Set one and retry.');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
58
63
|
|
|
59
|
-
|
|
64
|
+
const reconSpinner = ora({ text: 'Mapping attack surface...', color: 'cyan' }).start();
|
|
65
|
+
const reconAgent = new ReconAgent();
|
|
66
|
+
const reconResult = await reconAgent.analyze({ rootPath: absolutePath });
|
|
67
|
+
recon = Array.isArray(reconResult) ? {} : reconResult;
|
|
68
|
+
const files = await reconAgent.discoverFiles(absolutePath);
|
|
69
|
+
reconSpinner.succeed(chalk.green('Attack surface mapped'));
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
const providerLabel = swarm.provider?.name || 'AI';
|
|
72
|
+
const swarmSpinner = ora({ text: `Deploying ${chalk.cyan('23 swarm agents')} via ${providerLabel}...`, color: 'cyan' }).start();
|
|
73
|
+
try {
|
|
74
|
+
findings = await swarm.run(absolutePath, recon, files);
|
|
75
|
+
swarmSpinner.succeed(chalk.green(`Swarm complete — ${findings.length} finding(s)`));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
swarmSpinner.fail(chalk.red(`Swarm failed: ${err.message}`));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
agentResults = [{ agent: 'KimiSwarm', category: 'swarm', findingCount: findings.length, success: true }];
|
|
82
|
+
|
|
83
|
+
} else {
|
|
84
|
+
// ── 1b. Standard local orchestration ───────────────────────────────────
|
|
85
|
+
printBanner();
|
|
86
|
+
output.header('Multi-Agent Security Audit');
|
|
87
|
+
console.log();
|
|
88
|
+
|
|
89
|
+
const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
|
|
90
|
+
|
|
91
|
+
const agentFilter = options.agents
|
|
92
|
+
? options.agents.split(',').map(a => a.trim())
|
|
93
|
+
: null;
|
|
94
|
+
|
|
95
|
+
const orchestratorOpts = {
|
|
96
|
+
verbose: options.verbose,
|
|
97
|
+
agents: agentFilter,
|
|
98
|
+
};
|
|
99
|
+
if (options.deep) orchestratorOpts.deep = true;
|
|
100
|
+
if (options.local) orchestratorOpts.local = true;
|
|
101
|
+
if (options.model) orchestratorOpts.model = options.model;
|
|
102
|
+
if (options.provider) orchestratorOpts.provider = options.provider;
|
|
103
|
+
if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
|
|
104
|
+
if (options.budget) orchestratorOpts.budget = options.budget;
|
|
105
|
+
|
|
106
|
+
const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
|
|
107
|
+
({ recon, findings, agentResults } = results);
|
|
108
|
+
}
|
|
62
109
|
|
|
63
110
|
// ── 2. Dependency audit ─────────────────────────────────────────────────────
|
|
64
111
|
let depVulns = [];
|
package/cli/commands/rotate.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* (no auth required — designed for reporting exposed credentials).
|
|
11
11
|
*
|
|
12
12
|
* USAGE:
|
|
13
|
-
* ship-safe rotate .
|
|
14
|
-
* ship-safe rotate . --provider github
|
|
13
|
+
* ship-safe rotate . Scan local files and rotate found secrets
|
|
14
|
+
* ship-safe rotate . --provider github Only rotate GitHub tokens
|
|
15
|
+
* ship-safe rotate --plan rotation-plan.json Execute a plan from shipsafecli.com/rotate
|
|
15
16
|
*
|
|
16
17
|
* RECOMMENDED ORDER:
|
|
17
18
|
* 1. ship-safe rotate ← revoke the key so it can't be used
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
|
|
22
23
|
import fs from 'fs';
|
|
23
24
|
import path from 'path';
|
|
25
|
+
import readline from 'readline';
|
|
24
26
|
import { execSync } from 'child_process';
|
|
25
27
|
import chalk from 'chalk';
|
|
26
28
|
import ora from 'ora';
|
|
@@ -461,10 +463,205 @@ function maskToken(token) {
|
|
|
461
463
|
}
|
|
462
464
|
|
|
463
465
|
// =============================================================================
|
|
464
|
-
//
|
|
466
|
+
// PLAN-BASED ROTATION (--plan rotation-plan.json)
|
|
465
467
|
// =============================================================================
|
|
466
468
|
|
|
469
|
+
function promptHidden(question) {
|
|
470
|
+
return new Promise(resolve => {
|
|
471
|
+
process.stdout.write(question);
|
|
472
|
+
const stdin = process.stdin;
|
|
473
|
+
const wasRaw = stdin.isRaw;
|
|
474
|
+
try { stdin.setRawMode(true); } catch { /* not a TTY */ }
|
|
475
|
+
stdin.resume();
|
|
476
|
+
stdin.setEncoding('utf8');
|
|
477
|
+
let value = '';
|
|
478
|
+
function onData(ch) {
|
|
479
|
+
if (ch === '\n' || ch === '\r' || ch === '\u0003') {
|
|
480
|
+
stdin.removeListener('data', onData);
|
|
481
|
+
try { stdin.setRawMode(!!wasRaw); } catch { /* ignore */ }
|
|
482
|
+
stdin.pause();
|
|
483
|
+
process.stdout.write('\n');
|
|
484
|
+
if (ch === '\u0003') process.exit(0);
|
|
485
|
+
resolve(value);
|
|
486
|
+
} else if (ch === '\u007f' || ch === '\b') {
|
|
487
|
+
value = value.slice(0, -1);
|
|
488
|
+
} else {
|
|
489
|
+
value += ch;
|
|
490
|
+
process.stdout.write('*');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
stdin.on('data', onData);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function updateVercelEnvVar(token, projectId, envId, envType, newValue, teamId) {
|
|
498
|
+
const params = new URLSearchParams();
|
|
499
|
+
if (teamId) params.set('teamId', teamId);
|
|
500
|
+
const url = `https://api.vercel.com/v9/projects/${projectId}/env/${envId}?${params}`;
|
|
501
|
+
const r = await fetch(url, {
|
|
502
|
+
method: 'PATCH',
|
|
503
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
504
|
+
body: JSON.stringify({ value: newValue, type: envType || 'encrypted' }),
|
|
505
|
+
});
|
|
506
|
+
if (!r.ok) {
|
|
507
|
+
const body = await r.text();
|
|
508
|
+
throw new Error(`${r.status}: ${body}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function rotatePlanCommand(planFile) {
|
|
513
|
+
// ── 1. Read and validate plan ──────────────────────────────────────────────
|
|
514
|
+
const planPath = path.resolve(planFile);
|
|
515
|
+
if (!fs.existsSync(planPath)) {
|
|
516
|
+
output.error(`Plan file not found: ${planPath}`);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let plan;
|
|
521
|
+
try {
|
|
522
|
+
plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
|
|
523
|
+
} catch {
|
|
524
|
+
output.error('Failed to parse rotation plan JSON. Make sure it was downloaded from shipsafecli.com/rotate');
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const issuers = plan.issuers;
|
|
529
|
+
if (!issuers || typeof issuers !== 'object' || Object.keys(issuers).length === 0) {
|
|
530
|
+
output.success('No credentials in rotation plan — nothing to rotate.');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const totalEnvVars = Object.values(issuers).reduce((sum, g) => sum + (g.affected?.length ?? 0), 0);
|
|
535
|
+
const issuerList = Object.entries(issuers);
|
|
536
|
+
|
|
537
|
+
output.header('Credential Rotation Plan');
|
|
538
|
+
console.log(chalk.gray(`\n Plan generated: ${plan.generated ?? 'unknown'}`));
|
|
539
|
+
console.log(chalk.gray(` Projects scanned: ${plan.projectsScanned ?? 'unknown'}`));
|
|
540
|
+
console.log(chalk.gray(` Env vars to update: ${totalEnvVars}`));
|
|
541
|
+
console.log(chalk.gray(` Credential types: ${issuerList.length}\n`));
|
|
542
|
+
|
|
543
|
+
// ── 2. Prompt for Vercel token ────────────────────────────────────────────
|
|
544
|
+
console.log(chalk.cyan.bold(' This command will update your Vercel env vars via the API.'));
|
|
545
|
+
console.log(chalk.gray(' Your token is used only for API calls and is never stored.\n'));
|
|
546
|
+
|
|
547
|
+
const vercelToken = await promptHidden(chalk.white(' Enter your Vercel API token: '));
|
|
548
|
+
if (!vercelToken) {
|
|
549
|
+
output.error('No Vercel token provided. Aborting.');
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
554
|
+
const teamId = plan.teamId || '';
|
|
555
|
+
|
|
556
|
+
// ── 3. Process each issuer ────────────────────────────────────────────────
|
|
557
|
+
const auditLog = [];
|
|
558
|
+
let totalUpdated = 0;
|
|
559
|
+
let totalFailed = 0;
|
|
560
|
+
|
|
561
|
+
for (let i = 0; i < issuerList.length; i++) {
|
|
562
|
+
const [issuerKey, issuerData] = issuerList[i];
|
|
563
|
+
const affected = issuerData.affected ?? [];
|
|
564
|
+
if (affected.length === 0) continue;
|
|
565
|
+
|
|
566
|
+
const projectCount = new Set(affected.map(a => a.projectId)).size;
|
|
567
|
+
console.log(chalk.white.bold(`\n [${i + 1}/${issuerList.length}] ${issuerData.name ?? issuerKey}`));
|
|
568
|
+
console.log(chalk.gray(` ${affected.length} env var${affected.length !== 1 ? 's' : ''} across ${projectCount} project${projectCount !== 1 ? 's' : ''}`));
|
|
569
|
+
|
|
570
|
+
if (issuerData.rotateUrl) {
|
|
571
|
+
console.log(chalk.gray(` Rotate URL: ${chalk.cyan(issuerData.rotateUrl)}`));
|
|
572
|
+
const opened = openBrowser(issuerData.rotateUrl);
|
|
573
|
+
if (opened) {
|
|
574
|
+
console.log(chalk.gray(' ✓ Opened in browser'));
|
|
575
|
+
} else {
|
|
576
|
+
console.log(chalk.yellow(` → Open manually: ${issuerData.rotateUrl}`));
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
console.log(chalk.yellow(' No rotation URL — rotate manually in the provider dashboard.'));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Get one new value per unique env var name for this issuer
|
|
583
|
+
const uniqueKeys = [...new Set(affected.map(a => a.envVar))];
|
|
584
|
+
const newValues = {};
|
|
585
|
+
|
|
586
|
+
for (const envKey of uniqueKeys) {
|
|
587
|
+
const newVal = await promptHidden(chalk.white(`\n Paste new value for ${chalk.cyan(envKey)}: `));
|
|
588
|
+
if (!newVal) {
|
|
589
|
+
console.log(chalk.yellow(` Skipping ${envKey} (no value entered)`));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
newValues[envKey] = newVal;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (Object.keys(newValues).length === 0) {
|
|
596
|
+
console.log(chalk.gray(' Skipped all env vars for this issuer.\n'));
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Update each affected env var via Vercel API
|
|
601
|
+
const spinner = ora({ text: ` Updating ${affected.length} env var${affected.length !== 1 ? 's' : ''}...`, color: 'cyan' }).start();
|
|
602
|
+
let issuerUpdated = 0;
|
|
603
|
+
let issuerFailed = 0;
|
|
604
|
+
|
|
605
|
+
for (const item of affected) {
|
|
606
|
+
const newVal = newValues[item.envVar];
|
|
607
|
+
if (!newVal) continue;
|
|
608
|
+
try {
|
|
609
|
+
await updateVercelEnvVar(vercelToken, item.projectId, item.envId, item.envType, newVal, teamId);
|
|
610
|
+
issuerUpdated++;
|
|
611
|
+
auditLog.push({
|
|
612
|
+
ts: new Date().toISOString(),
|
|
613
|
+
issuer: issuerKey,
|
|
614
|
+
project: item.projectName,
|
|
615
|
+
projectId: item.projectId,
|
|
616
|
+
envVar: item.envVar,
|
|
617
|
+
status: 'updated',
|
|
618
|
+
});
|
|
619
|
+
} catch (e) {
|
|
620
|
+
issuerFailed++;
|
|
621
|
+
auditLog.push({
|
|
622
|
+
ts: new Date().toISOString(),
|
|
623
|
+
issuer: issuerKey,
|
|
624
|
+
project: item.projectName,
|
|
625
|
+
projectId: item.projectId,
|
|
626
|
+
envVar: item.envVar,
|
|
627
|
+
status: 'failed',
|
|
628
|
+
error: e instanceof Error ? e.message : String(e),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
totalUpdated += issuerUpdated;
|
|
634
|
+
totalFailed += issuerFailed;
|
|
635
|
+
|
|
636
|
+
if (issuerFailed === 0) {
|
|
637
|
+
spinner.succeed(chalk.green(` Updated ${issuerUpdated} env var${issuerUpdated !== 1 ? 's' : ''}`));
|
|
638
|
+
} else {
|
|
639
|
+
spinner.warn(chalk.yellow(` Updated ${issuerUpdated}, failed ${issuerFailed}`));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
rl.close();
|
|
644
|
+
|
|
645
|
+
// ── 4. Write audit log ────────────────────────────────────────────────────
|
|
646
|
+
const auditPath = path.resolve('rotation-audit.json');
|
|
647
|
+
fs.writeFileSync(auditPath, JSON.stringify({ rotatedAt: new Date().toISOString(), auditLog }, null, 2));
|
|
648
|
+
|
|
649
|
+
// ── 5. Summary ────────────────────────────────────────────────────────────
|
|
650
|
+
console.log();
|
|
651
|
+
console.log(chalk.cyan.bold(' Rotation complete'));
|
|
652
|
+
console.log(chalk.white(` ✓ ${totalUpdated} env var${totalUpdated !== 1 ? 's' : ''} updated`));
|
|
653
|
+
if (totalFailed > 0) {
|
|
654
|
+
console.log(chalk.red(` ✗ ${totalFailed} failed — see rotation-audit.json for details`));
|
|
655
|
+
}
|
|
656
|
+
console.log(chalk.gray(`\n Audit log written to: ${auditPath}`));
|
|
657
|
+
console.log(chalk.gray(' Run ship-safe scan . to confirm no hardcoded credentials remain.\n'));
|
|
658
|
+
}
|
|
659
|
+
|
|
467
660
|
export async function rotateCommand(targetPath = '.', options = {}) {
|
|
661
|
+
// If --plan flag provided, delegate to plan-based rotation
|
|
662
|
+
if (options.plan) {
|
|
663
|
+
return rotatePlanCommand(options.plan);
|
|
664
|
+
}
|
|
468
665
|
const absolutePath = path.resolve(targetPath);
|
|
469
666
|
|
|
470
667
|
if (!fs.existsSync(absolutePath)) {
|