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.
- package/README.md +626 -459
- package/cli/bin/ship-safe.js +200 -139
- package/cli/commands/agent.js +606 -0
- package/cli/commands/deps.js +447 -0
- package/cli/commands/fix.js +3 -3
- package/cli/commands/init.js +86 -3
- package/cli/commands/mcp.js +2 -2
- package/cli/commands/remediate.js +646 -0
- package/cli/commands/rotate.js +571 -0
- package/cli/commands/scan.js +64 -23
- package/cli/commands/score.js +446 -0
- package/cli/index.js +4 -1
- package/cli/utils/entropy.js +6 -0
- package/cli/utils/output.js +42 -2
- package/cli/utils/patterns.js +393 -1
- package/package.json +64 -63
|
@@ -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
|
+
}
|