ship-safe 3.0.0 → 3.2.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.
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Rotate Command
3
+ * ==============
4
+ *
5
+ * Guides you through revoking and rotating exposed secrets.
6
+ * For each secret type found, opens the provider's key management page
7
+ * and shows step-by-step revocation instructions.
8
+ *
9
+ * For GitHub tokens, calls the GitHub credentials revocation API directly
10
+ * (no auth required — designed for reporting exposed credentials).
11
+ *
12
+ * USAGE:
13
+ * ship-safe rotate . Scan and rotate all found secrets
14
+ * ship-safe rotate . --provider github Only rotate GitHub tokens
15
+ *
16
+ * RECOMMENDED ORDER:
17
+ * 1. ship-safe rotate ← revoke the key so it can't be used
18
+ * 2. ship-safe remediate ← fix source code
19
+ * 3. Commit fixed files
20
+ */
21
+
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { execSync } from 'child_process';
25
+ import chalk from 'chalk';
26
+ import ora from 'ora';
27
+ import fg from 'fast-glob';
28
+ import {
29
+ SECRET_PATTERNS,
30
+ SKIP_DIRS,
31
+ SKIP_EXTENSIONS,
32
+ TEST_FILE_PATTERNS,
33
+ MAX_FILE_SIZE
34
+ } from '../utils/patterns.js';
35
+ import { isHighEntropyMatch } from '../utils/entropy.js';
36
+ import * as output from '../utils/output.js';
37
+
38
+ // =============================================================================
39
+ // PROVIDER ROTATION INFO
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Maps pattern names to provider revocation info.
44
+ * url: Where to revoke/rotate the key
45
+ * instructions: Exact steps to follow
46
+ * apiRevoke: If true, attempt programmatic revocation via API
47
+ * providerKey: Short identifier for --provider flag filtering
48
+ */
49
+ const PROVIDER_INFO = {
50
+ // AI Providers
51
+ 'OpenAI API Key': {
52
+ provider: 'openai',
53
+ name: 'OpenAI',
54
+ url: 'https://platform.openai.com/api-keys',
55
+ instructions: [
56
+ 'Go to platform.openai.com/api-keys',
57
+ 'Find the compromised key (starts with sk-...)',
58
+ 'Click the trash icon to revoke it',
59
+ 'Create a new key and update your .env'
60
+ ]
61
+ },
62
+ 'OpenAI Project Key': {
63
+ provider: 'openai',
64
+ name: 'OpenAI',
65
+ url: 'https://platform.openai.com/api-keys',
66
+ instructions: [
67
+ 'Go to platform.openai.com/api-keys',
68
+ 'Find the compromised project key',
69
+ 'Revoke it and create a new one'
70
+ ]
71
+ },
72
+ 'Anthropic API Key': {
73
+ provider: 'anthropic',
74
+ name: 'Anthropic',
75
+ url: 'https://console.anthropic.com/settings/keys',
76
+ instructions: [
77
+ 'Go to console.anthropic.com/settings/keys',
78
+ 'Delete the compromised key',
79
+ 'Create a new key and update your .env'
80
+ ]
81
+ },
82
+ 'Google AI (Gemini) API Key': {
83
+ provider: 'google',
84
+ name: 'Google AI',
85
+ url: 'https://aistudio.google.com/app/apikey',
86
+ instructions: [
87
+ 'Go to aistudio.google.com/app/apikey',
88
+ 'Delete the compromised key',
89
+ 'Create a new one and update your .env'
90
+ ]
91
+ },
92
+ 'Replicate API Token': {
93
+ provider: 'replicate',
94
+ name: 'Replicate',
95
+ url: 'https://replicate.com/account/api-tokens',
96
+ instructions: [
97
+ 'Go to replicate.com/account/api-tokens',
98
+ 'Delete the compromised token',
99
+ 'Create a new one and update your .env'
100
+ ]
101
+ },
102
+ 'Hugging Face Token': {
103
+ provider: 'huggingface',
104
+ name: 'Hugging Face',
105
+ url: 'https://huggingface.co/settings/tokens',
106
+ instructions: [
107
+ 'Go to huggingface.co/settings/tokens',
108
+ 'Revoke the compromised token',
109
+ 'Create a new one and update your .env'
110
+ ]
111
+ },
112
+ 'Groq API Key': {
113
+ provider: 'groq',
114
+ name: 'Groq',
115
+ url: 'https://console.groq.com/keys',
116
+ instructions: [
117
+ 'Go to console.groq.com/keys',
118
+ 'Delete the compromised key',
119
+ 'Create a new one and update your .env'
120
+ ]
121
+ },
122
+
123
+ // GitHub — API revocation supported
124
+ 'GitHub Personal Access Token': {
125
+ provider: 'github',
126
+ name: 'GitHub',
127
+ url: 'https://github.com/settings/tokens',
128
+ apiRevoke: true,
129
+ instructions: [
130
+ 'Attempting API revocation...',
131
+ 'Then go to github.com/settings/tokens to confirm it\'s revoked',
132
+ 'Create a new token with minimal required scopes'
133
+ ]
134
+ },
135
+ 'GitHub OAuth Token': {
136
+ provider: 'github',
137
+ name: 'GitHub',
138
+ url: 'https://github.com/settings/tokens',
139
+ apiRevoke: true,
140
+ instructions: [
141
+ 'Attempting API revocation...',
142
+ 'Verify at github.com/settings/applications'
143
+ ]
144
+ },
145
+ 'GitHub App Token': {
146
+ provider: 'github',
147
+ name: 'GitHub',
148
+ url: 'https://github.com/settings/tokens',
149
+ apiRevoke: true,
150
+ instructions: [
151
+ 'Attempting API revocation...',
152
+ 'Verify revocation at github.com/settings/tokens'
153
+ ]
154
+ },
155
+ 'GitHub Fine-Grained PAT': {
156
+ provider: 'github',
157
+ name: 'GitHub',
158
+ url: 'https://github.com/settings/personal-access-tokens',
159
+ apiRevoke: true,
160
+ instructions: [
161
+ 'Attempting API revocation...',
162
+ 'Verify at github.com/settings/personal-access-tokens'
163
+ ]
164
+ },
165
+
166
+ // Payments
167
+ 'Stripe Live Secret Key': {
168
+ provider: 'stripe',
169
+ name: 'Stripe',
170
+ url: 'https://dashboard.stripe.com/apikeys',
171
+ instructions: [
172
+ 'Go to dashboard.stripe.com/apikeys',
173
+ 'Click the "..." menu next to the compromised key',
174
+ 'Select "Roll key" — this revokes old and creates new simultaneously',
175
+ 'Update the new key in your .env immediately'
176
+ ]
177
+ },
178
+ 'Stripe Test Secret Key': {
179
+ provider: 'stripe',
180
+ name: 'Stripe',
181
+ url: 'https://dashboard.stripe.com/test/apikeys',
182
+ instructions: [
183
+ 'Go to dashboard.stripe.com/test/apikeys',
184
+ 'Roll the test key',
185
+ 'Update your .env'
186
+ ]
187
+ },
188
+ 'Stripe Webhook Secret': {
189
+ provider: 'stripe',
190
+ name: 'Stripe',
191
+ url: 'https://dashboard.stripe.com/webhooks',
192
+ instructions: [
193
+ 'Go to dashboard.stripe.com/webhooks',
194
+ 'Select the webhook endpoint',
195
+ 'Rotate the signing secret',
196
+ 'Update STRIPE_WEBHOOK_SECRET in your .env'
197
+ ]
198
+ },
199
+
200
+ // Cloud & Hosting
201
+ 'AWS Access Key ID': {
202
+ provider: 'aws',
203
+ name: 'AWS',
204
+ url: 'https://console.aws.amazon.com/iam/home#/security_credentials',
205
+ instructions: [
206
+ 'Run: aws iam delete-access-key --access-key-id <YOUR_KEY_ID>',
207
+ 'Then: aws iam create-access-key --user-name <YOUR_USER>',
208
+ 'Or go to console.aws.amazon.com → IAM → Security credentials',
209
+ 'Delete the compromised key and create a new one',
210
+ 'Update AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in your .env'
211
+ ]
212
+ },
213
+ 'AWS Secret Access Key': {
214
+ provider: 'aws',
215
+ name: 'AWS',
216
+ url: 'https://console.aws.amazon.com/iam/home#/security_credentials',
217
+ instructions: [
218
+ 'Deactivate key: aws iam update-access-key --status Inactive --access-key-id <ID>',
219
+ 'Delete key: aws iam delete-access-key --access-key-id <ID>',
220
+ 'Create new: aws iam create-access-key --user-name <USER>',
221
+ 'Update .env with new credentials'
222
+ ]
223
+ },
224
+ 'Vercel Token': {
225
+ provider: 'vercel',
226
+ name: 'Vercel',
227
+ url: 'https://vercel.com/account/tokens',
228
+ instructions: [
229
+ 'Go to vercel.com/account/tokens',
230
+ 'Delete the compromised token',
231
+ 'Create a new one and update your .env'
232
+ ]
233
+ },
234
+ 'NPM Token': {
235
+ provider: 'npm',
236
+ name: 'npm',
237
+ url: 'https://www.npmjs.com/settings/~/tokens',
238
+ instructions: [
239
+ 'Go to npmjs.com/settings/~/tokens',
240
+ 'Revoke the compromised token',
241
+ 'Create a new granular access token'
242
+ ]
243
+ },
244
+ 'Netlify Personal Access Token': {
245
+ provider: 'netlify',
246
+ name: 'Netlify',
247
+ url: 'https://app.netlify.com/user/applications#personal-access-tokens',
248
+ instructions: [
249
+ 'Go to app.netlify.com/user/applications',
250
+ 'Revoke the compromised token',
251
+ 'Create a new one and update your .env'
252
+ ]
253
+ },
254
+ 'DigitalOcean Token': {
255
+ provider: 'digitalocean',
256
+ name: 'DigitalOcean',
257
+ url: 'https://cloud.digitalocean.com/account/api/tokens',
258
+ instructions: [
259
+ 'Go to cloud.digitalocean.com/account/api/tokens',
260
+ 'Delete the compromised token',
261
+ 'Create a new one and update your .env'
262
+ ]
263
+ },
264
+
265
+ // Communication
266
+ 'Slack Token': {
267
+ provider: 'slack',
268
+ name: 'Slack',
269
+ url: 'https://api.slack.com/apps',
270
+ instructions: [
271
+ 'Go to api.slack.com/apps',
272
+ 'Select your app → OAuth & Permissions',
273
+ 'Revoke all tokens under "OAuth Tokens for Your Workspace"',
274
+ 'Reinstall the app to get fresh tokens'
275
+ ]
276
+ },
277
+ 'Slack Webhook': {
278
+ provider: 'slack',
279
+ name: 'Slack',
280
+ url: 'https://api.slack.com/apps',
281
+ instructions: [
282
+ 'Go to api.slack.com/apps → your app → Incoming Webhooks',
283
+ 'Revoke the compromised webhook URL',
284
+ 'Create a new webhook for the channel'
285
+ ]
286
+ },
287
+ 'Discord Webhook': {
288
+ provider: 'discord',
289
+ name: 'Discord',
290
+ url: 'https://discord.com/channels/@me',
291
+ instructions: [
292
+ 'Go to the Discord channel → Edit Channel → Integrations → Webhooks',
293
+ 'Delete the compromised webhook',
294
+ 'Create a new one and update your .env'
295
+ ]
296
+ },
297
+
298
+ // Email
299
+ 'SendGrid API Key': {
300
+ provider: 'sendgrid',
301
+ name: 'SendGrid',
302
+ url: 'https://app.sendgrid.com/settings/api_keys',
303
+ instructions: [
304
+ 'Go to app.sendgrid.com/settings/api_keys',
305
+ 'Revoke the compromised key',
306
+ 'Create a new key with minimum required permissions'
307
+ ]
308
+ },
309
+ 'Resend API Key': {
310
+ provider: 'resend',
311
+ name: 'Resend',
312
+ url: 'https://resend.com/api-keys',
313
+ instructions: [
314
+ 'Go to resend.com/api-keys',
315
+ 'Delete the compromised key',
316
+ 'Create a new one and update your .env'
317
+ ]
318
+ },
319
+
320
+ // Databases
321
+ 'Supabase Service Role Key': {
322
+ provider: 'supabase',
323
+ name: 'Supabase',
324
+ url: 'https://supabase.com/dashboard/project/_/settings/api',
325
+ instructions: [
326
+ 'Go to supabase.com/dashboard → your project → Settings → API',
327
+ 'Copy the current service role key for reference',
328
+ 'Click "Reset" to generate a new JWT secret — this rotates all keys',
329
+ 'Update SUPABASE_SERVICE_ROLE_KEY in your .env',
330
+ 'WARNING: This also rotates the anon key — update that too'
331
+ ]
332
+ },
333
+ 'PlanetScale Password': {
334
+ provider: 'planetscale',
335
+ name: 'PlanetScale',
336
+ url: 'https://app.planetscale.com',
337
+ instructions: [
338
+ 'Go to app.planetscale.com → your database → Passwords',
339
+ 'Delete the compromised password',
340
+ 'Create a new password and update your connection string'
341
+ ]
342
+ },
343
+ 'Neon Database Connection String': {
344
+ provider: 'neon',
345
+ name: 'Neon',
346
+ url: 'https://console.neon.tech',
347
+ instructions: [
348
+ 'Go to console.neon.tech → your project → Connection Details',
349
+ 'Reset the database password',
350
+ 'Update DATABASE_URL in your .env with the new connection string'
351
+ ]
352
+ },
353
+ };
354
+
355
+ // =============================================================================
356
+ // GITHUB API REVOCATION
357
+ // =============================================================================
358
+
359
+ /**
360
+ * Attempt to revoke a GitHub token via the public credentials revocation API.
361
+ * This endpoint does not require authentication — it's designed for reporting
362
+ * exposed credentials found in code.
363
+ *
364
+ * Docs: https://docs.github.com/en/rest/credentials/revoke
365
+ */
366
+ async function revokeGitHubToken(token) {
367
+ try {
368
+ const response = await fetch('https://api.github.com/credentials/revoke', {
369
+ method: 'DELETE',
370
+ headers: {
371
+ 'Accept': 'application/vnd.github+json',
372
+ 'X-GitHub-Api-Version': '2022-11-28',
373
+ 'Content-Type': 'application/json',
374
+ 'User-Agent': 'ship-safe-cli'
375
+ },
376
+ body: JSON.stringify({ access_token: token })
377
+ });
378
+
379
+ // 204 = revoked successfully, 422 = already revoked/invalid
380
+ return response.status === 204 || response.status === 422;
381
+ } catch {
382
+ return false; // Network error — fall back to manual
383
+ }
384
+ }
385
+
386
+ // =============================================================================
387
+ // BROWSER OPEN
388
+ // =============================================================================
389
+
390
+ function openBrowser(url) {
391
+ try {
392
+ const platform = process.platform;
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
+ return true;
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+
402
+ // =============================================================================
403
+ // SCAN
404
+ // =============================================================================
405
+
406
+ async function findFiles(rootPath) {
407
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
408
+ const files = await fg('**/*', {
409
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
410
+ });
411
+ const filtered = [];
412
+ for (const file of files) {
413
+ const ext = path.extname(file).toLowerCase();
414
+ if (SKIP_EXTENSIONS.has(ext)) continue;
415
+ const basename = path.basename(file);
416
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
417
+ if (TEST_FILE_PATTERNS.some(p => p.test(file))) continue;
418
+ if (basename === '.env' || basename === '.env.example') continue;
419
+ try {
420
+ const stats = fs.statSync(file);
421
+ if (stats.size > MAX_FILE_SIZE) continue;
422
+ } catch { continue; }
423
+ filtered.push(file);
424
+ }
425
+ return filtered;
426
+ }
427
+
428
+ async function scanFile(filePath) {
429
+ const findings = [];
430
+ try {
431
+ const content = fs.readFileSync(filePath, 'utf-8');
432
+ const lines = content.split('\n');
433
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
434
+ const line = lines[lineNum];
435
+ if (/ship-safe-ignore/i.test(line)) continue;
436
+ for (const pattern of SECRET_PATTERNS) {
437
+ pattern.pattern.lastIndex = 0;
438
+ let match;
439
+ while ((match = pattern.pattern.exec(line)) !== null) {
440
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
441
+ findings.push({
442
+ line: lineNum + 1,
443
+ matched: match[0],
444
+ patternName: pattern.name,
445
+ severity: pattern.severity,
446
+ });
447
+ }
448
+ }
449
+ }
450
+ } catch { /* skip */ }
451
+ return findings;
452
+ }
453
+
454
+ // =============================================================================
455
+ // MASK HELPER
456
+ // =============================================================================
457
+
458
+ function maskToken(token) {
459
+ if (token.length <= 8) return '****';
460
+ return token.substring(0, 6) + '*'.repeat(Math.min(token.length - 6, 16)) + token.slice(-4);
461
+ }
462
+
463
+ // =============================================================================
464
+ // MAIN COMMAND
465
+ // =============================================================================
466
+
467
+ export async function rotateCommand(targetPath = '.', options = {}) {
468
+ const absolutePath = path.resolve(targetPath);
469
+
470
+ if (!fs.existsSync(absolutePath)) {
471
+ output.error(`Path does not exist: ${absolutePath}`);
472
+ process.exit(1);
473
+ }
474
+
475
+ // ── 1. Scan ───────────────────────────────────────────────────────────────
476
+ const spinner = ora({ text: 'Scanning for secrets to rotate...', color: 'cyan' }).start();
477
+
478
+ const files = await findFiles(absolutePath);
479
+ const scanResults = [];
480
+ for (const file of files) {
481
+ const findings = await scanFile(file);
482
+ if (findings.length > 0) scanResults.push({ file, findings });
483
+ }
484
+
485
+ spinner.stop();
486
+
487
+ if (scanResults.length === 0) {
488
+ output.success('No secrets found — nothing to rotate!');
489
+ return;
490
+ }
491
+
492
+ // ── 2. Deduplicate findings by pattern name ───────────────────────────────
493
+ const uniqueFindings = new Map(); // patternName → {matched, file, line}
494
+ for (const { file, findings } of scanResults) {
495
+ for (const f of findings) {
496
+ if (!uniqueFindings.has(f.patternName)) {
497
+ uniqueFindings.set(f.patternName, { ...f, file });
498
+ }
499
+ }
500
+ }
501
+
502
+ // ── 3. Filter by --provider if specified ──────────────────────────────────
503
+ let findingsToRotate = [...uniqueFindings.values()];
504
+ if (options.provider) {
505
+ findingsToRotate = findingsToRotate.filter(f => {
506
+ const info = PROVIDER_INFO[f.patternName];
507
+ return info && info.provider === options.provider.toLowerCase();
508
+ });
509
+ if (findingsToRotate.length === 0) {
510
+ output.warning(`No secrets found for provider: ${options.provider}`);
511
+ return;
512
+ }
513
+ }
514
+
515
+ output.header('Secret Rotation Guide');
516
+ console.log(chalk.gray(`\n Found ${findingsToRotate.length} unique secret type(s) to rotate\n`));
517
+ console.log(chalk.yellow.bold(' Rotate secrets BEFORE fixing code or cleaning git history.\n'));
518
+
519
+ // ── 4. Process each finding ───────────────────────────────────────────────
520
+ for (const finding of findingsToRotate) {
521
+ const info = PROVIDER_INFO[finding.patternName];
522
+
523
+ console.log(chalk.white.bold(`\n ▸ ${finding.patternName}`));
524
+ console.log(chalk.gray(` Found: ${maskToken(finding.matched)}`));
525
+
526
+ if (!info) {
527
+ // Unknown provider — give generic instructions
528
+ console.log(chalk.yellow(' No specific rotation guide available for this secret type.'));
529
+ console.log(chalk.gray(' → Revoke it manually in the provider\'s dashboard'));
530
+ console.log(chalk.gray(' → Create a new key and update your .env'));
531
+ continue;
532
+ }
533
+
534
+ console.log(chalk.gray(` Provider: ${info.name}`));
535
+ console.log(chalk.gray(` Revocation URL: ${chalk.cyan(info.url)}`));
536
+ console.log();
537
+
538
+ // GitHub: attempt API revocation
539
+ if (info.apiRevoke && finding.patternName.includes('GitHub')) {
540
+ const apiSpinner = ora({ text: ' Attempting API revocation...', color: 'cyan' }).start();
541
+ const revoked = await revokeGitHubToken(finding.matched);
542
+ if (revoked) {
543
+ apiSpinner.succeed(chalk.green(' Token revoked via GitHub API'));
544
+ } else {
545
+ apiSpinner.warn(chalk.yellow(' API revocation failed — revoke manually (see URL above)'));
546
+ }
547
+ }
548
+
549
+ // Show step-by-step instructions
550
+ console.log(chalk.gray(' Steps:'));
551
+ info.instructions.forEach((step, i) => {
552
+ console.log(chalk.gray(` ${i + 1}. ${step}`));
553
+ });
554
+
555
+ // Open browser
556
+ const opened = openBrowser(info.url);
557
+ if (opened) {
558
+ console.log(chalk.gray(`\n ✓ Opened ${info.url} in your browser`));
559
+ } else {
560
+ console.log(chalk.gray(`\n → Open manually: ${info.url}`));
561
+ }
562
+ }
563
+
564
+ // ── 5. Final guidance ─────────────────────────────────────────────────────
565
+ console.log();
566
+ console.log(chalk.cyan.bold(' After rotating all keys:'));
567
+ console.log(chalk.white(' 1.') + chalk.gray(' Run ship-safe remediate . to fix your source code'));
568
+ console.log(chalk.white(' 2.') + chalk.gray(' Commit the cleaned files'));
569
+ console.log(chalk.white(' 3.') + chalk.gray(' Run ship-safe scan . to confirm nothing was missed'));
570
+ console.log();
571
+ }