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.
@@ -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
- console.log();
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 ──────────────────────────────────────────────────────────
@@ -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
- // ── 1. Run orchestrator ─────────────────────────────────────────────────────
42
- const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
42
+ let findings = [];
43
+ let recon = {};
44
+ let agentResults = [];
43
45
 
44
- const agentFilter = options.agents
45
- ? options.agents.split(',').map(a => a.trim())
46
- : null;
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
- const orchestratorOpts = {
49
- verbose: options.verbose,
50
- agents: agentFilter,
51
- };
52
- if (options.deep) orchestratorOpts.deep = true;
53
- if (options.local) orchestratorOpts.local = true;
54
- if (options.model) orchestratorOpts.model = options.model;
55
- if (options.provider) orchestratorOpts.provider = options.provider;
56
- if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
57
- if (options.budget) orchestratorOpts.budget = options.budget;
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
- const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
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
- const { recon, findings, agentResults } = results;
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 = [];
@@ -10,8 +10,9 @@
10
10
  * (no auth required — designed for reporting exposed credentials).
11
11
  *
12
12
  * USAGE:
13
- * ship-safe rotate . Scan and rotate all found secrets
14
- * ship-safe rotate . --provider github Only rotate GitHub tokens
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
- // MAIN COMMAND
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)) {