ship-safe 4.0.0 → 4.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,149 @@
1
+ /**
2
+ * Doctor Command — Environment Diagnostics
3
+ * ==========================================
4
+ *
5
+ * Checks Node.js version, git, npm, API keys, ignore files,
6
+ * cache directory, and package version.
7
+ *
8
+ * USAGE:
9
+ * npx ship-safe doctor
10
+ */
11
+
12
+ import { execFileSync } from 'child_process';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import chalk from 'chalk';
16
+ import { readFileSync } from 'fs';
17
+ import { fileURLToPath } from 'url';
18
+ import { dirname, join } from 'path';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+ const PACKAGE_VERSION = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
23
+
24
+ export async function doctorCommand() {
25
+ console.log();
26
+ console.log(chalk.cyan.bold(' Ship Safe Doctor'));
27
+ console.log(chalk.gray(' ' + '─'.repeat(50)));
28
+ console.log();
29
+
30
+ let allGood = true;
31
+
32
+ // 1. Node.js version
33
+ const nodeVersion = process.version;
34
+ const nodeMajor = parseInt(nodeVersion.slice(1), 10);
35
+ if (nodeMajor >= 18) {
36
+ pass(`Node.js ${nodeVersion} (requires ≥18)`);
37
+ } else {
38
+ fail(`Node.js ${nodeVersion} — requires ≥18`);
39
+ allGood = false;
40
+ }
41
+
42
+ // 2. Git
43
+ try {
44
+ const gitVersion = execFileSync('git', ['--version'], { encoding: 'utf-8' }).trim();
45
+ pass(gitVersion.replace('git version ', 'git v'));
46
+ } catch {
47
+ fail('git not found (needed for guard, git-history-scanner)');
48
+ allGood = false;
49
+ }
50
+
51
+ // 3. Package manager
52
+ const managers = ['npm', 'yarn', 'pnpm'];
53
+ let foundPm = false;
54
+ for (const pm of managers) {
55
+ try {
56
+ const ver = execFileSync(pm, ['--version'], { encoding: 'utf-8', shell: true }).trim();
57
+ pass(`${pm} v${ver}`);
58
+ foundPm = true;
59
+ break;
60
+ } catch { /* try next */ }
61
+ }
62
+ if (!foundPm) {
63
+ fail('No package manager found (npm/yarn/pnpm)');
64
+ allGood = false;
65
+ }
66
+
67
+ // 4. API keys
68
+ const apiKeys = [
69
+ { name: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', required: false },
70
+ { name: 'OpenAI API key', env: 'OPENAI_API_KEY', required: false },
71
+ { name: 'Google AI API key', env: 'GOOGLE_API_KEY', required: false },
72
+ ];
73
+ for (const key of apiKeys) {
74
+ if (process.env[key.env]) {
75
+ pass(`${key.name} configured`);
76
+ } else {
77
+ info(`${key.name} not set (optional — for AI classification)`);
78
+ }
79
+ }
80
+
81
+ // 5. .ship-safeignore
82
+ const cwd = process.cwd();
83
+ const ignorePath = path.join(cwd, '.ship-safeignore');
84
+ if (fs.existsSync(ignorePath)) {
85
+ try {
86
+ const patterns = fs.readFileSync(ignorePath, 'utf-8')
87
+ .split('\n').filter(l => l.trim() && !l.startsWith('#')).length;
88
+ pass(`.ship-safeignore found (${patterns} patterns)`);
89
+ } catch {
90
+ pass('.ship-safeignore found');
91
+ }
92
+ } else {
93
+ info('.ship-safeignore not found (run: ship-safe init)');
94
+ }
95
+
96
+ // 6. Cache directory
97
+ const cacheDir = path.join(cwd, '.ship-safe');
98
+ if (fs.existsSync(cacheDir)) {
99
+ try {
100
+ const testFile = path.join(cacheDir, '.doctor-test');
101
+ fs.writeFileSync(testFile, 'test');
102
+ fs.unlinkSync(testFile);
103
+ pass('Cache directory writable');
104
+ } catch {
105
+ fail('Cache directory not writable');
106
+ allGood = false;
107
+ }
108
+ } else {
109
+ info('Cache directory does not exist yet (created on first scan)');
110
+ }
111
+
112
+ // 7. Version check
113
+ pass(`ship-safe v${PACKAGE_VERSION}`);
114
+ try {
115
+ const latest = execFileSync('npm', ['view', 'ship-safe', 'version'], {
116
+ encoding: 'utf-8', timeout: 5000, shell: true,
117
+ }).trim();
118
+ if (latest && latest !== PACKAGE_VERSION) {
119
+ const msg = ['v', latest, ' available (current: v', PACKAGE_VERSION, ')'].join('');
120
+ info(msg);
121
+ } else if (latest) {
122
+ pass('Up to date');
123
+ }
124
+ } catch {
125
+ // Skip version check if npm view fails
126
+ }
127
+
128
+ console.log();
129
+ if (allGood) {
130
+ console.log(chalk.green.bold(' All checks passed!'));
131
+ } else {
132
+ console.log(chalk.yellow.bold(' Some checks failed. See above for details.'));
133
+ }
134
+ console.log();
135
+ }
136
+
137
+ function pass(msg) {
138
+ console.log(chalk.green(' ✔ ') + chalk.white(msg));
139
+ }
140
+
141
+ function fail(msg) {
142
+ console.log(chalk.red(' ✗ ') + chalk.red(msg));
143
+ }
144
+
145
+ function info(msg) {
146
+ console.log(chalk.gray(' ○ ') + chalk.gray(msg));
147
+ }
148
+
149
+ export default doctorCommand;
@@ -34,7 +34,7 @@ import fs from 'fs';
34
34
  import path from 'path';
35
35
  import os from 'os';
36
36
  import { createInterface } from 'readline';
37
- import { execSync } from 'child_process';
37
+ import { execSync, execFileSync } from 'child_process';
38
38
  import chalk from 'chalk';
39
39
  import ora from 'ora';
40
40
  import pkg from 'write-file-atomic';
@@ -270,6 +270,11 @@ function createBackupDir(rootPath) {
270
270
  function backupFile(filePath, backupDir, rootPath) {
271
271
  const rel = path.relative(rootPath, filePath);
272
272
  const dest = path.join(backupDir, rel);
273
+ const resolvedDest = path.resolve(dest);
274
+ const resolvedBackupDir = path.resolve(backupDir);
275
+ if (!resolvedDest.startsWith(resolvedBackupDir + path.sep) && resolvedDest !== resolvedBackupDir) {
276
+ throw new Error(`Path traversal detected: ${rel} escapes backup directory`);
277
+ }
273
278
  fs.mkdirSync(path.dirname(dest), { recursive: true });
274
279
  fs.copyFileSync(filePath, dest);
275
280
  }
@@ -414,8 +419,7 @@ function checkPublicRepo(rootPath) {
414
419
  function stageFiles(files, rootPath) {
415
420
  if (files.length === 0) return;
416
421
  try {
417
- const quoted = files.map(f => `"${f}"`).join(' ');
418
- execSync(`git add ${quoted}`, { cwd: rootPath, stdio: 'inherit' }); // ship-safe-ignore — paths come from our own file scan
422
+ execFileSync('git', ['add', ...files], { cwd: rootPath, stdio: 'inherit' });
419
423
  output.success(`Staged ${files.length} file(s) with git add`);
420
424
  } catch {
421
425
  output.warning('Could not stage files — run git add manually.');
@@ -30,10 +30,12 @@ import {
30
30
  SKIP_DIRS,
31
31
  SKIP_EXTENSIONS,
32
32
  TEST_FILE_PATTERNS,
33
- MAX_FILE_SIZE
33
+ MAX_FILE_SIZE,
34
+ loadGitignorePatterns
34
35
  } from '../utils/patterns.js';
35
36
  import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
36
37
  import * as output from '../utils/output.js';
38
+ import { CacheManager } from '../utils/cache-manager.js';
37
39
 
38
40
  // =============================================================================
39
41
  // CUSTOM PATTERNS (.ship-safe.json)
@@ -110,13 +112,49 @@ export async function scanCommand(targetPath = '.', options = {}) {
110
112
  try {
111
113
  // Find all files
112
114
  const files = await findFiles(absolutePath, ignorePatterns, options);
113
- spinner.text = `Scanning ${files.length} files...`;
115
+
116
+ // Cache: determine which files changed
117
+ const useCache = options.cache !== false;
118
+ const cache = new CacheManager(absolutePath);
119
+ const cacheData = useCache ? cache.load() : null;
120
+ let filesToScan = files;
121
+ let cacheDiff = null;
122
+ const cachedResults = [];
123
+
124
+ if (cacheData) {
125
+ cacheDiff = cache.diff(files);
126
+ filesToScan = cacheDiff.changedFiles;
127
+
128
+ // Group cached findings by file
129
+ const cachedByFile = {};
130
+ for (const f of cacheDiff.cachedFindings) {
131
+ if (!cachedByFile[f.file]) cachedByFile[f.file] = [];
132
+ cachedByFile[f.file].push({
133
+ line: f.line,
134
+ column: f.column,
135
+ matched: f.matched,
136
+ patternName: f.rule || f.title,
137
+ severity: f.severity,
138
+ confidence: f.confidence,
139
+ description: f.description,
140
+ category: f.category,
141
+ });
142
+ }
143
+ for (const [file, findings] of Object.entries(cachedByFile)) {
144
+ cachedResults.push({ file, findings });
145
+ }
146
+ }
147
+
148
+ const cacheNote = cacheDiff && filesToScan.length < files.length
149
+ ? ` (${filesToScan.length} changed, ${cacheDiff.unchangedCount} cached)`
150
+ : '';
151
+ spinner.text = `Scanning ${filesToScan.length} files${cacheNote}...`;
114
152
 
115
153
  // Scan each file
116
154
  const results = [];
117
155
  let scannedCount = 0;
118
156
 
119
- for (const file of files) {
157
+ for (const file of filesToScan) {
120
158
  const findings = await scanFile(file, allPatterns);
121
159
  if (findings.length > 0) {
122
160
  results.push({ file, findings });
@@ -124,7 +162,36 @@ export async function scanCommand(targetPath = '.', options = {}) {
124
162
 
125
163
  scannedCount++;
126
164
  if (options.verbose) {
127
- spinner.text = `Scanned ${scannedCount}/${files.length}: ${path.relative(absolutePath, file)}`;
165
+ spinner.text = `Scanned ${scannedCount}/${filesToScan.length}: ${path.relative(absolutePath, file)}`;
166
+ }
167
+ }
168
+
169
+ // Merge with cached results
170
+ const allResults = [...results, ...cachedResults];
171
+
172
+ // Save cache
173
+ if (useCache) {
174
+ try {
175
+ const allFindings = [];
176
+ for (const { file, findings } of allResults) {
177
+ for (const f of findings) {
178
+ allFindings.push({
179
+ file,
180
+ line: f.line,
181
+ column: f.column,
182
+ severity: f.severity,
183
+ category: f.category || 'secrets',
184
+ rule: f.patternName,
185
+ title: f.patternName,
186
+ description: f.description,
187
+ matched: f.matched,
188
+ confidence: f.confidence,
189
+ });
190
+ }
191
+ }
192
+ cache.save(files, allFindings, null, null);
193
+ } catch {
194
+ // Silent
128
195
  }
129
196
  }
130
197
 
@@ -132,15 +199,15 @@ export async function scanCommand(targetPath = '.', options = {}) {
132
199
 
133
200
  // Output results
134
201
  if (options.sarif) {
135
- outputSARIF(results, absolutePath);
202
+ outputSARIF(allResults, absolutePath);
136
203
  } else if (options.json) {
137
- outputJSON(results, files.length);
204
+ outputJSON(allResults, files.length);
138
205
  } else {
139
- outputPretty(results, files.length, absolutePath);
206
+ outputPretty(allResults, files.length, absolutePath);
140
207
  }
141
208
 
142
209
  // Exit with appropriate code
143
- const hasFindings = results.length > 0;
210
+ const hasFindings = allResults.length > 0;
144
211
  process.exit(hasFindings ? 1 : 0);
145
212
 
146
213
  } catch (err) {
@@ -204,6 +271,10 @@ async function findFiles(rootPath, ignorePatterns, options = {}) {
204
271
  // Build ignore patterns from SKIP_DIRS
205
272
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
206
273
 
274
+ // Respect .gitignore patterns
275
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
276
+ globIgnore.push(...gitignoreGlobs);
277
+
207
278
  // Find all files
208
279
  const files = await fg('**/*', {
209
280
  cwd: rootPath,
package/cli/index.js CHANGED
@@ -1,50 +1,56 @@
1
- /**
2
- * Ship Safe CLI - Module Entry Point
3
- * ===================================
4
- *
5
- * This file exports the CLI commands and agents for programmatic use.
6
- * For normal CLI usage, run: npx ship-safe
7
- */
8
-
9
- // ── Core Commands ─────────────────────────────────────────────────────────────
10
- export { scanCommand } from './commands/scan.js';
11
- export { checklistCommand } from './commands/checklist.js';
12
- export { initCommand } from './commands/init.js';
13
- export { agentCommand } from './commands/agent.js';
14
- export { depsCommand, runDepsAudit } from './commands/deps.js';
15
- export { scoreCommand } from './commands/score.js';
16
-
17
- // ── v4.0 Commands ─────────────────────────────────────────────────────────────
18
- export { auditCommand } from './commands/audit.js';
19
- export { redTeamCommand } from './commands/red-team.js';
20
- export { watchCommand } from './commands/watch.js';
21
-
22
- // ── Patterns ──────────────────────────────────────────────────────────────────
23
- export { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS } from './utils/patterns.js';
24
-
25
- // ── Agent Framework ───────────────────────────────────────────────────────────
26
- export { BaseAgent, createFinding } from './agents/base-agent.js';
27
- export { Orchestrator } from './agents/orchestrator.js';
28
- export { buildOrchestrator } from './agents/index.js';
29
-
30
- // ── Individual Agents ─────────────────────────────────────────────────────────
31
- export { ReconAgent } from './agents/recon-agent.js';
32
- export { InjectionTester } from './agents/injection-tester.js';
33
- export { AuthBypassAgent } from './agents/auth-bypass-agent.js';
34
- export { SSRFProber } from './agents/ssrf-prober.js';
35
- export { SupplyChainAudit } from './agents/supply-chain-agent.js';
36
- export { ConfigAuditor } from './agents/config-auditor.js';
37
- export { LLMRedTeam } from './agents/llm-redteam.js';
38
- export { MobileScanner } from './agents/mobile-scanner.js';
39
- export { GitHistoryScanner } from './agents/git-history-scanner.js';
40
- export { CICDScanner } from './agents/cicd-scanner.js';
41
- export { APIFuzzer } from './agents/api-fuzzer.js';
42
-
43
- // ── Supporting Modules ────────────────────────────────────────────────────────
44
- export { ScoringEngine, GRADES, CATEGORIES } from './agents/scoring-engine.js';
45
- export { SBOMGenerator } from './agents/sbom-generator.js';
46
- export { PolicyEngine } from './agents/policy-engine.js';
47
- export { HTMLReporter } from './agents/html-reporter.js';
48
-
49
- // ── LLM Providers ─────────────────────────────────────────────────────────────
50
- export { createProvider, autoDetectProvider } from './providers/llm-provider.js';
1
+ /**
2
+ * Ship Safe CLI - Module Entry Point
3
+ * ===================================
4
+ *
5
+ * This file exports the CLI commands and agents for programmatic use.
6
+ * For normal CLI usage, run: npx ship-safe
7
+ */
8
+
9
+ // ── Core Commands ─────────────────────────────────────────────────────────────
10
+ export { scanCommand } from './commands/scan.js';
11
+ export { checklistCommand } from './commands/checklist.js';
12
+ export { initCommand } from './commands/init.js';
13
+ export { agentCommand } from './commands/agent.js';
14
+ export { depsCommand, runDepsAudit } from './commands/deps.js';
15
+ export { scoreCommand } from './commands/score.js';
16
+
17
+ // ── v4.0 Commands ─────────────────────────────────────────────────────────────
18
+ export { auditCommand } from './commands/audit.js';
19
+ export { redTeamCommand } from './commands/red-team.js';
20
+ export { watchCommand } from './commands/watch.js';
21
+
22
+ // ── v4.2 Commands ─────────────────────────────────────────────────────────────
23
+ export { doctorCommand } from './commands/doctor.js';
24
+
25
+ // ── Patterns ──────────────────────────────────────────────────────────────────
26
+ export { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS } from './utils/patterns.js';
27
+
28
+ // ── Agent Framework ───────────────────────────────────────────────────────────
29
+ export { BaseAgent, createFinding } from './agents/base-agent.js';
30
+ export { Orchestrator } from './agents/orchestrator.js';
31
+ export { buildOrchestrator } from './agents/index.js';
32
+
33
+ // ── Individual Agents ─────────────────────────────────────────────────────────
34
+ export { ReconAgent } from './agents/recon-agent.js';
35
+ export { InjectionTester } from './agents/injection-tester.js';
36
+ export { AuthBypassAgent } from './agents/auth-bypass-agent.js';
37
+ export { SSRFProber } from './agents/ssrf-prober.js';
38
+ export { SupplyChainAudit } from './agents/supply-chain-agent.js';
39
+ export { ConfigAuditor } from './agents/config-auditor.js';
40
+ export { LLMRedTeam } from './agents/llm-redteam.js';
41
+ export { MobileScanner } from './agents/mobile-scanner.js';
42
+ export { GitHistoryScanner } from './agents/git-history-scanner.js';
43
+ export { CICDScanner } from './agents/cicd-scanner.js';
44
+ export { APIFuzzer } from './agents/api-fuzzer.js';
45
+
46
+ // ── Supporting Modules ────────────────────────────────────────────────────────
47
+ export { ScoringEngine, GRADES, CATEGORIES } from './agents/scoring-engine.js';
48
+ export { SBOMGenerator } from './agents/sbom-generator.js';
49
+ export { PolicyEngine } from './agents/policy-engine.js';
50
+ export { HTMLReporter } from './agents/html-reporter.js';
51
+
52
+ // ── Caching ──────────────────────────────────────────────────────────────────
53
+ export { CacheManager } from './utils/cache-manager.js';
54
+
55
+ // ── LLM Providers ─────────────────────────────────────────────────────────────
56
+ export { createProvider, autoDetectProvider } from './providers/llm-provider.js';