ship-safe 3.1.0 → 4.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.
Files changed (38) hide show
  1. package/README.md +200 -307
  2. package/cli/agents/api-fuzzer.js +224 -0
  3. package/cli/agents/auth-bypass-agent.js +326 -0
  4. package/cli/agents/base-agent.js +240 -0
  5. package/cli/agents/cicd-scanner.js +200 -0
  6. package/cli/agents/config-auditor.js +413 -0
  7. package/cli/agents/git-history-scanner.js +167 -0
  8. package/cli/agents/html-reporter.js +363 -0
  9. package/cli/agents/index.js +56 -0
  10. package/cli/agents/injection-tester.js +401 -0
  11. package/cli/agents/llm-redteam.js +251 -0
  12. package/cli/agents/mobile-scanner.js +225 -0
  13. package/cli/agents/orchestrator.js +152 -0
  14. package/cli/agents/policy-engine.js +149 -0
  15. package/cli/agents/recon-agent.js +196 -0
  16. package/cli/agents/sbom-generator.js +176 -0
  17. package/cli/agents/scoring-engine.js +207 -0
  18. package/cli/agents/ssrf-prober.js +130 -0
  19. package/cli/agents/supply-chain-agent.js +274 -0
  20. package/cli/bin/ship-safe.js +119 -2
  21. package/cli/commands/agent.js +606 -0
  22. package/cli/commands/audit.js +565 -0
  23. package/cli/commands/deps.js +447 -0
  24. package/cli/commands/fix.js +3 -3
  25. package/cli/commands/init.js +86 -3
  26. package/cli/commands/mcp.js +2 -2
  27. package/cli/commands/red-team.js +315 -0
  28. package/cli/commands/remediate.js +4 -4
  29. package/cli/commands/rotate.js +6 -6
  30. package/cli/commands/scan.js +64 -23
  31. package/cli/commands/score.js +446 -0
  32. package/cli/commands/watch.js +160 -0
  33. package/cli/index.js +40 -2
  34. package/cli/providers/llm-provider.js +288 -0
  35. package/cli/utils/entropy.js +6 -0
  36. package/cli/utils/output.js +42 -2
  37. package/cli/utils/patterns.js +393 -1
  38. package/package.json +19 -15
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Deps Command
3
+ * ============
4
+ *
5
+ * Audit project dependencies for known CVEs using the package manager's
6
+ * built-in audit tool (npm, yarn, pnpm, pip-audit, bundler-audit).
7
+ *
8
+ * USAGE:
9
+ * npx ship-safe deps [path] Audit dependencies in the project
10
+ * npx ship-safe deps . --fix Also run the package manager fix command
11
+ *
12
+ * SUPPORTED PACKAGE MANAGERS:
13
+ * npm → npm audit --json
14
+ * yarn → yarn audit --json (NDJSON format)
15
+ * pnpm → pnpm audit --json
16
+ * pip → pip-audit --format json (requires: pip install pip-audit)
17
+ * bundler → bundle-audit check (requires: gem install bundler-audit)
18
+ *
19
+ * EXIT CODES:
20
+ * 0 - No vulnerabilities found (or tool not available)
21
+ * 1 - Vulnerabilities found
22
+ */
23
+
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import { execSync } from 'child_process';
27
+ import chalk from 'chalk';
28
+ import ora from 'ora';
29
+ import * as output from '../utils/output.js';
30
+
31
+ // =============================================================================
32
+ // MAIN COMMAND
33
+ // =============================================================================
34
+
35
+ export async function depsCommand(targetPath = '.', options = {}) {
36
+ const absolutePath = path.resolve(targetPath);
37
+
38
+ if (!fs.existsSync(absolutePath)) {
39
+ output.error(`Path does not exist: ${absolutePath}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ console.log();
44
+ output.header('Dependency Audit');
45
+ console.log();
46
+
47
+ // ── 1. Detect package manager ─────────────────────────────────────────────
48
+ const pm = detectPackageManager(absolutePath);
49
+
50
+ if (!pm) {
51
+ console.log(chalk.gray(' No supported package manifest found.'));
52
+ console.log(chalk.gray(' Supported: package.json (npm/yarn/pnpm), requirements.txt (pip), Gemfile (bundler)'));
53
+ console.log();
54
+ return;
55
+ }
56
+
57
+ console.log(chalk.gray(` Package manager: ${chalk.white(pm.name)} Manifest: ${chalk.white(pm.manifest)}`));
58
+ console.log();
59
+
60
+ // ── 2. Run audit ──────────────────────────────────────────────────────────
61
+ const spinner = ora({ text: `Running ${pm.name} audit...`, color: 'cyan' }).start();
62
+ let vulns = [];
63
+
64
+ try {
65
+ vulns = runAudit(pm, absolutePath);
66
+ spinner.stop();
67
+ } catch (err) {
68
+ spinner.stop();
69
+ if (err.code === 'ENOENT' || /not found|not recognized|command not found/i.test(err.message)) {
70
+ output.warning(`${pm.name} is not installed or not in PATH.`);
71
+ if (pm.installHint) {
72
+ console.log(chalk.gray(` Install it with: `) + chalk.cyan(pm.installHint));
73
+ }
74
+ console.log();
75
+ return;
76
+ }
77
+ output.error(`Audit failed: ${err.message}`);
78
+ console.log(chalk.gray(` Try running manually: `) + chalk.cyan(pm.auditCommand));
79
+ console.log();
80
+ process.exit(1);
81
+ }
82
+
83
+ // ── 3. No vulnerabilities ─────────────────────────────────────────────────
84
+ if (vulns.length === 0) {
85
+ output.success(`No known vulnerabilities in your ${pm.name} dependencies!`);
86
+ console.log();
87
+ process.exit(0);
88
+ }
89
+
90
+ // ── 4. Display findings ───────────────────────────────────────────────────
91
+ printDepFindings(vulns, pm);
92
+
93
+ // ── 5. Optionally fix ─────────────────────────────────────────────────────
94
+ if (options.fix) {
95
+ console.log();
96
+ console.log(chalk.cyan(` Running: ${pm.fixCommand}`));
97
+ try {
98
+ execSync(pm.fixCommand, { cwd: absolutePath, stdio: 'inherit' });
99
+ } catch {
100
+ output.warning('Fix command exited with errors — some vulnerabilities may require manual updates.');
101
+ }
102
+ }
103
+
104
+ process.exit(1);
105
+ }
106
+
107
+ // =============================================================================
108
+ // PACKAGE MANAGER DETECTION
109
+ // =============================================================================
110
+
111
+ function detectPackageManager(rootPath) {
112
+ // Node.js — detect specific lock file first for accuracy
113
+ if (fs.existsSync(path.join(rootPath, 'pnpm-lock.yaml'))) {
114
+ return {
115
+ name: 'pnpm',
116
+ manifest: 'package.json',
117
+ auditCommand: 'pnpm audit --json',
118
+ fixCommand: 'pnpm audit --fix',
119
+ type: 'npm-v2'
120
+ };
121
+ }
122
+
123
+ if (fs.existsSync(path.join(rootPath, 'yarn.lock'))) {
124
+ return {
125
+ name: 'yarn',
126
+ manifest: 'package.json',
127
+ auditCommand: 'yarn audit --json',
128
+ fixCommand: 'yarn upgrade',
129
+ type: 'yarn'
130
+ };
131
+ }
132
+
133
+ if (fs.existsSync(path.join(rootPath, 'package.json'))) {
134
+ return {
135
+ name: 'npm',
136
+ manifest: 'package.json',
137
+ auditCommand: 'npm audit --json',
138
+ fixCommand: 'npm audit fix',
139
+ type: 'npm-v2'
140
+ };
141
+ }
142
+
143
+ // Python
144
+ if (fs.existsSync(path.join(rootPath, 'requirements.txt'))) {
145
+ return {
146
+ name: 'pip-audit',
147
+ manifest: 'requirements.txt',
148
+ auditCommand: 'pip-audit --format json -r requirements.txt',
149
+ fixCommand: 'pip-audit --fix -r requirements.txt',
150
+ type: 'pip',
151
+ installHint: 'pip install pip-audit'
152
+ };
153
+ }
154
+
155
+ // Ruby
156
+ if (fs.existsSync(path.join(rootPath, 'Gemfile.lock'))) {
157
+ return {
158
+ name: 'bundler-audit',
159
+ manifest: 'Gemfile.lock',
160
+ auditCommand: 'bundle-audit check',
161
+ fixCommand: 'bundle update',
162
+ type: 'bundler',
163
+ installHint: 'gem install bundler-audit'
164
+ };
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+ // =============================================================================
171
+ // RUNNING THE AUDIT
172
+ // =============================================================================
173
+
174
+ /**
175
+ * Run the package manager audit and return normalized vulnerability list.
176
+ * Catches exit code 1 (vulnerabilities found) which is NOT a real error.
177
+ */
178
+ function runAudit(pm, cwd) {
179
+ let stdout;
180
+ try {
181
+ stdout = execSync(pm.auditCommand, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
182
+ } catch (err) {
183
+ // npm/yarn/pnpm exit with code 1 when vulns found — that's expected
184
+ if (err.stdout) {
185
+ stdout = err.stdout.toString();
186
+ } else {
187
+ throw err;
188
+ }
189
+ }
190
+
191
+ switch (pm.type) {
192
+ case 'npm-v2': return parseNpmAudit(stdout);
193
+ case 'yarn': return parseYarnAudit(stdout);
194
+ case 'pip': return parsePipAudit(stdout);
195
+ case 'bundler': return parseBundlerAudit(stdout);
196
+ default: return [];
197
+ }
198
+ }
199
+
200
+ // =============================================================================
201
+ // AUDIT PARSERS
202
+ // =============================================================================
203
+
204
+ /**
205
+ * Parse npm audit v7+ JSON (auditReportVersion: 2).
206
+ * Also works for pnpm which uses the same format.
207
+ */
208
+ function parseNpmAudit(jsonStr) {
209
+ let data;
210
+ try {
211
+ data = JSON.parse(jsonStr);
212
+ } catch {
213
+ return [];
214
+ }
215
+
216
+ const vulns = [];
217
+
218
+ if (data.auditReportVersion === 2) {
219
+ // npm v7+ format
220
+ for (const [name, vuln] of Object.entries(data.vulnerabilities || {})) {
221
+ // Find the advisory details (via array may contain objects or strings)
222
+ const advisory = Array.isArray(vuln.via)
223
+ ? vuln.via.find(v => typeof v === 'object')
224
+ : null;
225
+
226
+ vulns.push({
227
+ name,
228
+ range: vuln.range || 'unknown',
229
+ severity: vuln.severity || 'unknown',
230
+ title: advisory?.title || name,
231
+ cve: advisory?.cves?.[0] || null,
232
+ url: advisory?.url || null,
233
+ fix: vuln.fixAvailable
234
+ ? (typeof vuln.fixAvailable === 'object'
235
+ ? `npm install ${vuln.fixAvailable.name}@${vuln.fixAvailable.version}`
236
+ : 'npm audit fix')
237
+ : null,
238
+ isDirect: vuln.isDirect ?? false
239
+ });
240
+ }
241
+ } else {
242
+ // npm v6 format (legacy)
243
+ for (const [, adv] of Object.entries(data.advisories || {})) {
244
+ vulns.push({
245
+ name: adv.module_name,
246
+ range: adv.vulnerable_versions,
247
+ severity: adv.severity,
248
+ title: adv.title,
249
+ cve: adv.cves?.[0] || null,
250
+ url: adv.url || null,
251
+ fix: adv.patched_versions !== '<0.0.0'
252
+ ? `npm install ${adv.module_name}@"${adv.patched_versions}"`
253
+ : null,
254
+ isDirect: true
255
+ });
256
+ }
257
+ }
258
+
259
+ // Deduplicate by package name (transitive deps appear multiple times)
260
+ const seen = new Set();
261
+ return vulns.filter(v => {
262
+ const key = `${v.name}:${v.severity}`;
263
+ if (seen.has(key)) return false;
264
+ seen.add(key);
265
+ return true;
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Parse yarn audit output (NDJSON — one JSON object per line).
271
+ */
272
+ function parseYarnAudit(ndjsonStr) {
273
+ const vulns = [];
274
+ const seen = new Set();
275
+
276
+ for (const line of ndjsonStr.split('\n')) {
277
+ if (!line.trim()) continue;
278
+ let obj;
279
+ try { obj = JSON.parse(line); } catch { continue; }
280
+
281
+ if (obj.type !== 'auditAdvisory') continue;
282
+ const adv = obj.data?.advisory;
283
+ if (!adv) continue;
284
+
285
+ const key = `${adv.module_name}:${adv.severity}`;
286
+ if (seen.has(key)) continue;
287
+ seen.add(key);
288
+
289
+ vulns.push({
290
+ name: adv.module_name,
291
+ range: adv.vulnerable_versions,
292
+ severity: adv.severity,
293
+ title: adv.title,
294
+ cve: adv.cves?.[0] || null,
295
+ url: adv.url || null,
296
+ fix: adv.patched_versions !== '<0.0.0'
297
+ ? `yarn upgrade ${adv.module_name}`
298
+ : null,
299
+ isDirect: true
300
+ });
301
+ }
302
+
303
+ return vulns;
304
+ }
305
+
306
+ /**
307
+ * Parse pip-audit JSON output.
308
+ * Format: [{ name, version, vulns: [{ id, fix_versions, description }] }]
309
+ */
310
+ function parsePipAudit(jsonStr) {
311
+ let data;
312
+ try {
313
+ data = JSON.parse(jsonStr);
314
+ } catch {
315
+ return [];
316
+ }
317
+
318
+ const vulns = [];
319
+ for (const pkg of (Array.isArray(data) ? data : [])) {
320
+ for (const vuln of (pkg.vulns || [])) {
321
+ const fixVersion = vuln.fix_versions?.[0];
322
+ vulns.push({
323
+ name: pkg.name,
324
+ range: `==${pkg.version}`,
325
+ severity: 'high', // pip-audit doesn't reliably report CVSS severity
326
+ title: (vuln.description || vuln.id).slice(0, 100),
327
+ cve: vuln.id.startsWith('CVE-') ? vuln.id : null,
328
+ url: `https://osv.dev/vulnerability/${vuln.id}`,
329
+ fix: fixVersion ? `pip install "${pkg.name}>=${fixVersion}"` : null,
330
+ isDirect: true
331
+ });
332
+ }
333
+ }
334
+
335
+ return vulns;
336
+ }
337
+
338
+ /**
339
+ * Parse bundler-audit text output (plain text format).
340
+ */
341
+ function parseBundlerAudit(text) {
342
+ const vulns = [];
343
+ const blocks = text.split(/\n(?=Name:)/);
344
+
345
+ for (const block of blocks) {
346
+ const name = block.match(/^Name:\s*(.+)/m)?.[1]?.trim();
347
+ const version = block.match(/Version:\s*(.+)/m)?.[1]?.trim();
348
+ const cve = block.match(/CVE:\s*(.+)/m)?.[1]?.trim();
349
+ const title = block.match(/Title:\s*(.+)/m)?.[1]?.trim();
350
+ const solution = block.match(/Solution:\s*(.+)/m)?.[1]?.trim();
351
+
352
+ if (name) {
353
+ vulns.push({
354
+ name,
355
+ range: version ? `==${version}` : 'unknown',
356
+ severity: 'high',
357
+ title: title || cve || name,
358
+ cve: cve || null,
359
+ url: cve ? `https://www.cve.org/CVERecord?id=${cve}` : null,
360
+ fix: solution ? `bundle update ${name}` : null,
361
+ isDirect: true
362
+ });
363
+ }
364
+ }
365
+
366
+ return vulns;
367
+ }
368
+
369
+ // =============================================================================
370
+ // OUTPUT
371
+ // =============================================================================
372
+
373
+ const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, medium: 2, low: 3, unknown: 4 };
374
+ const SEVERITY_COLOR = {
375
+ critical: chalk.red.bold,
376
+ high: chalk.red,
377
+ moderate: chalk.yellow,
378
+ medium: chalk.yellow,
379
+ low: chalk.gray,
380
+ unknown: chalk.gray
381
+ };
382
+
383
+ function printDepFindings(vulns, pm) {
384
+ // Sort by severity
385
+ const sorted = [...vulns].sort((a, b) =>
386
+ (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4)
387
+ );
388
+
389
+ const counts = {};
390
+ for (const v of vulns) {
391
+ counts[v.severity] = (counts[v.severity] || 0) + 1;
392
+ }
393
+
394
+ console.log(chalk.red.bold(` Dependency Vulnerabilities (${vulns.length})`));
395
+ console.log(chalk.red(' ' + '─'.repeat(58)));
396
+ console.log();
397
+
398
+ for (const v of sorted) {
399
+ const sevColor = SEVERITY_COLOR[v.severity] || chalk.gray;
400
+ const sevLabel = `[${v.severity.toUpperCase()}]`;
401
+
402
+ console.log(
403
+ ` ${sevColor(sevLabel.padEnd(12))}` +
404
+ chalk.white(`${v.name}`) +
405
+ chalk.gray(`@${v.range}`)
406
+ );
407
+ console.log(chalk.gray(` ${v.title}`));
408
+ if (v.cve) {
409
+ console.log(chalk.gray(` ${v.cve}`) + (v.url ? chalk.gray(` ${v.url}`) : ''));
410
+ }
411
+ if (v.fix) {
412
+ console.log(chalk.gray(' Fix: ') + chalk.cyan(v.fix));
413
+ }
414
+ console.log();
415
+ }
416
+
417
+ console.log(chalk.cyan('='.repeat(60)));
418
+ console.log(chalk.red.bold(` ⚠ Found ${vulns.length} vulnerable package(s)`));
419
+ for (const [sev, count] of Object.entries(counts).sort(([a], [b]) => (SEVERITY_ORDER[a] ?? 4) - (SEVERITY_ORDER[b] ?? 4))) {
420
+ const color = SEVERITY_COLOR[sev] || chalk.gray;
421
+ console.log(color(` • ${sev}: ${count}`));
422
+ }
423
+ console.log(chalk.gray(` Run: `) + chalk.cyan(pm.fixCommand) + chalk.gray(' to apply automatic fixes'));
424
+ console.log(chalk.cyan('='.repeat(60)));
425
+ console.log();
426
+ }
427
+
428
+ // =============================================================================
429
+ // INTERNAL: run audit and return normalized vulns (used by score command)
430
+ // =============================================================================
431
+
432
+ /**
433
+ * Run the dependency audit for a given path and return normalized vulnerabilities.
434
+ * Returns { pm, vulns } or { pm: null, vulns: [] } if no manifest found.
435
+ * Does not print anything — used programmatically by other commands.
436
+ */
437
+ export async function runDepsAudit(rootPath) {
438
+ const pm = detectPackageManager(rootPath);
439
+ if (!pm) return { pm: null, vulns: [] };
440
+
441
+ try {
442
+ const vulns = runAudit(pm, rootPath);
443
+ return { pm, vulns };
444
+ } catch {
445
+ return { pm, vulns: [] };
446
+ }
447
+ }
@@ -22,7 +22,7 @@ import {
22
22
  MAX_FILE_SIZE
23
23
  } from '../utils/patterns.js';
24
24
  import { isHighEntropyMatch } from '../utils/entropy.js';
25
- import { glob } from 'glob';
25
+ import fg from 'fast-glob';
26
26
  import * as output from '../utils/output.js';
27
27
 
28
28
  // =============================================================================
@@ -74,8 +74,8 @@ export async function fixCommand(options = {}) {
74
74
 
75
75
  async function findFiles(rootPath) {
76
76
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
77
- const files = await glob('**/*', {
78
- cwd: rootPath, absolute: true, nodir: true, ignore: globIgnore, dot: true
77
+ const files = await fg('**/*', {
78
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
79
79
  });
80
80
 
81
81
  const filtered = [];
@@ -47,9 +47,13 @@ export async function initCommand(options = {}) {
47
47
  errors: []
48
48
  };
49
49
 
50
- // Determine which files to copy
51
- const copyGitignore = !options.headers || options.gitignore;
52
- const copyHeaders = !options.gitignore || options.headers;
50
+ // Determine which files to copy.
51
+ // If a specific flag is set, only run that category.
52
+ // With no flags, run everything.
53
+ const hasSpecificFlag = options.gitignore || options.headers || options.agents;
54
+ const copyGitignore = hasSpecificFlag ? !!options.gitignore : true;
55
+ const copyHeaders = hasSpecificFlag ? !!options.headers : true;
56
+ const copyAgents = hasSpecificFlag ? !!options.agents : true;
53
57
 
54
58
  // Copy .gitignore
55
59
  if (copyGitignore) {
@@ -61,6 +65,11 @@ export async function initCommand(options = {}) {
61
65
  await handleSecurityHeaders(targetDir, options.force, results);
62
66
  }
63
67
 
68
+ // Append security rules to AI agent instruction files
69
+ if (copyAgents) {
70
+ await handleAgentFiles(targetDir, options.force, results);
71
+ }
72
+
64
73
  // Print summary
65
74
  printSummary(results);
66
75
  }
@@ -213,6 +222,80 @@ async function handleSecurityHeaders(targetDir, force, results) {
213
222
  }
214
223
  }
215
224
 
225
+ // =============================================================================
226
+ // AGENT FILE HANDLING
227
+ // =============================================================================
228
+
229
+ // Marker used for idempotency — if this string is present we already added our section.
230
+ const AGENT_MARKER = '<!-- ship-safe -->';
231
+
232
+ // The security rules block appended to existing agent instruction files.
233
+ const AGENT_SECTION = `
234
+ ## Security (ship-safe)
235
+ ${AGENT_MARKER}
236
+ - Always run \`npx ship-safe scan .\` before committing code
237
+ - Never hardcode API keys, tokens, or credentials in source files — use environment variables
238
+ - If hardcoded secrets are found or generated, run \`npx ship-safe remediate\` to auto-fix them
239
+ `;
240
+
241
+ // Standalone content for a new Cursor rules file.
242
+ const CURSOR_RULE_CONTENT = `---
243
+ description: Prevent API key leaks and credential exposure in generated code
244
+ alwaysApply: true
245
+ ---
246
+
247
+ - Always run \`npx ship-safe scan .\` before committing code
248
+ - Never hardcode API keys, tokens, or credentials in source files — use environment variables
249
+ - If hardcoded secrets are found or generated, run \`npx ship-safe remediate\` to auto-fix them
250
+ `;
251
+
252
+ async function handleAgentFiles(targetDir, force, results) {
253
+ // Files where we append a section if they already exist, or create if they don't.
254
+ const appendTargets = [
255
+ { file: 'CLAUDE.md', label: 'CLAUDE.md' },
256
+ { file: '.windsurfrules', label: '.windsurfrules' },
257
+ { file: path.join('.github', 'copilot-instructions.md'), label: '.github/copilot-instructions.md' },
258
+ ];
259
+
260
+ for (const { file, label } of appendTargets) {
261
+ const targetPath = path.join(targetDir, file);
262
+
263
+ if (fs.existsSync(targetPath)) {
264
+ const existing = fs.readFileSync(targetPath, 'utf-8');
265
+ if (existing.includes(AGENT_MARKER)) {
266
+ results.skipped.push(`${label} (already contains ship-safe rules)`);
267
+ continue;
268
+ }
269
+ if (force || true) { // always append unless already present
270
+ fs.writeFileSync(targetPath, existing.trimEnd() + '\n' + AGENT_SECTION);
271
+ results.merged.push(label);
272
+ }
273
+ } else {
274
+ // Ensure parent directory exists (e.g. .github/)
275
+ const parentDir = path.dirname(targetPath);
276
+ if (!fs.existsSync(parentDir)) {
277
+ fs.mkdirSync(parentDir, { recursive: true });
278
+ }
279
+ fs.writeFileSync(targetPath, AGENT_SECTION.trim() + '\n');
280
+ results.copied.push(label);
281
+ }
282
+ }
283
+
284
+ // Cursor rules — dedicated file, no merging needed.
285
+ const cursorRulesDir = path.join(targetDir, '.cursor', 'rules');
286
+ const cursorRulesFile = path.join(cursorRulesDir, 'ship-safe.mdc');
287
+
288
+ if (fs.existsSync(cursorRulesFile) && !force) {
289
+ results.skipped.push('.cursor/rules/ship-safe.mdc (already exists, use -f to overwrite)');
290
+ } else {
291
+ if (!fs.existsSync(cursorRulesDir)) {
292
+ fs.mkdirSync(cursorRulesDir, { recursive: true });
293
+ }
294
+ fs.writeFileSync(cursorRulesFile, CURSOR_RULE_CONTENT.trim() + '\n');
295
+ results.copied.push('.cursor/rules/ship-safe.mdc');
296
+ }
297
+ }
298
+
216
299
  // =============================================================================
217
300
  // SUMMARY
218
301
  // =============================================================================
@@ -31,7 +31,7 @@
31
31
 
32
32
  import fs from 'fs';
33
33
  import path from 'path';
34
- import { glob } from 'glob';
34
+ import fg from 'fast-glob';
35
35
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
36
36
  import { isHighEntropyMatch } from '../utils/entropy.js';
37
37
 
@@ -169,7 +169,7 @@ async function analyzeFile({ path: filePath }) {
169
169
 
170
170
  async function findFiles(rootPath, includeTests) {
171
171
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
172
- const files = await glob('**/*', { cwd: rootPath, absolute: true, nodir: true, ignore: globIgnore, dot: true });
172
+ const files = await fg('**/*', { cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true });
173
173
 
174
174
  return files.filter(file => {
175
175
  const ext = path.extname(file).toLowerCase();