ship-safe 5.0.0 → 5.0.1

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.
@@ -29,6 +29,7 @@ import {
29
29
  SECURITY_PATTERNS,
30
30
  SKIP_DIRS,
31
31
  SKIP_EXTENSIONS,
32
+ SKIP_FILENAMES,
32
33
  MAX_FILE_SIZE,
33
34
  loadGitignorePatterns
34
35
  } from '../utils/patterns.js';
@@ -132,11 +133,39 @@ export async function auditCommand(targetPath = '.', options = {}) {
132
133
  description: f.description,
133
134
  matched: f.matched,
134
135
  confidence: f.confidence,
135
- fix: `Move to environment variable or secrets manager`,
136
+ fix: file.match(/\.env(\..*)?$/)
137
+ ? `Ensure .env is in .gitignore and use a secrets manager for production`
138
+ : `Move to environment variable or secrets manager`,
136
139
  });
137
140
  }
138
141
  }
139
142
 
143
+ // Downgrade .env findings if the file is gitignored (properly managed)
144
+ const gitignoreContent = (() => {
145
+ try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
146
+ })();
147
+ const envIsGitignored = gitignoreContent.split('\n')
148
+ .map(l => l.trim())
149
+ .some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
150
+
151
+ if (envIsGitignored) {
152
+ for (const f of secretFindings) {
153
+ if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
154
+ f.severity = 'low';
155
+ f.confidence = 'low';
156
+ f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
157
+ }
158
+ }
159
+ }
160
+
161
+ // Downgrade secrets in test files (intentional test fixtures)
162
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
163
+ for (const f of secretFindings) {
164
+ if (TEST_PATH.test(f.file)) {
165
+ f.confidence = 'low';
166
+ }
167
+ }
168
+
140
169
  // Merge with cached findings for unchanged files
141
170
  secretFindings = [...secretFindings, ...cachedSecretFindings];
142
171
 
@@ -154,13 +183,14 @@ export async function auditCommand(targetPath = '.', options = {}) {
154
183
  }
155
184
 
156
185
  // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
157
- const agentSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 2/4] Running 12 security agents...'), color: 'cyan' }).start();
186
+ const orchestrator = buildOrchestrator();
187
+ const registeredAgentCount = orchestrator.agents?.length || 15;
188
+ const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
158
189
  let agentFindings = [];
159
190
  let recon = null;
160
191
  let agentResults = [];
161
192
 
162
193
  try {
163
- const orchestrator = buildOrchestrator();
164
194
  // Suppress individual agent spinners by using quiet mode
165
195
  // Pass changedFiles for incremental scanning if cache is valid
166
196
  const orchestratorOpts = { quiet: true };
@@ -415,14 +445,47 @@ function buildRemediationPlan(findings, depVulns, rootPath) {
415
445
  const plan = [];
416
446
  let priority = 1;
417
447
 
448
+ // Exclude low-confidence findings (test files, docs, comments) from remediation plan
449
+ const actionable = findings.filter(f => f.confidence !== 'low');
450
+
418
451
  // Priority order: secrets first, then by severity
419
- const secretFindings = findings.filter(f => f.category === 'secrets' || f.category === 'secret');
420
- const otherFindings = findings.filter(f => f.category !== 'secrets' && f.category !== 'secret');
452
+ const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
453
+ const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
421
454
 
422
455
  // Group and sort
423
456
  for (const sev of SEV_ORDER) {
424
- // Secrets at this severity
425
- for (const f of secretFindings.filter(s => s.severity === sev)) {
457
+ // Secrets at this severity — group .env findings by file
458
+ const sevSecrets = secretFindings.filter(s => s.severity === sev);
459
+ const envGroups = new Map();
460
+ const nonEnvSecrets = [];
461
+
462
+ for (const f of sevSecrets) {
463
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
464
+ if (f.file.match(/\.env(\..*)?$/)) {
465
+ if (!envGroups.has(relFile)) envGroups.set(relFile, []);
466
+ envGroups.get(relFile).push(f);
467
+ } else {
468
+ nonEnvSecrets.push(f);
469
+ }
470
+ }
471
+
472
+ // One plan item per .env file
473
+ for (const [relFile, envFindings] of envGroups) {
474
+ const names = envFindings.map(f => f.title || f.rule).join(', ');
475
+ plan.push({
476
+ priority: priority++,
477
+ severity: sev,
478
+ category: 'secrets',
479
+ categoryLabel: 'SECRETS',
480
+ title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
481
+ file: relFile,
482
+ action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
483
+ effort: 'low',
484
+ });
485
+ }
486
+
487
+ // Individual items for non-.env secrets
488
+ for (const f of nonEnvSecrets) {
426
489
  plan.push({
427
490
  priority: priority++,
428
491
  severity: sev,
@@ -694,6 +757,7 @@ async function findFiles(rootPath) {
694
757
  return files.filter(file => {
695
758
  const ext = path.extname(file).toLowerCase();
696
759
  if (SKIP_EXTENSIONS.has(ext)) return false;
760
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
697
761
  if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
698
762
  try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
699
763
  return true;
@@ -17,7 +17,7 @@ import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
19
  import { buildOrchestrator } from '../agents/index.js';
20
- import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, MAX_FILE_SIZE } from '../utils/patterns.js';
20
+ import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
21
21
  import { isHighEntropyMatch } from '../utils/entropy.js';
22
22
  import fg from 'fast-glob';
23
23
 
@@ -45,6 +45,7 @@ async function quickScan(rootPath) {
45
45
  const filtered = files.filter(f => {
46
46
  const ext = path.extname(f).toLowerCase();
47
47
  if (SKIP_EXTENSIONS.has(ext)) return false;
48
+ if (SKIP_FILENAMES.has(path.basename(f))) return false;
48
49
  try { return fs.statSync(f).size <= MAX_FILE_SIZE; } catch { return false; }
49
50
  });
50
51