ship-safe 6.1.1 → 6.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.
Files changed (47) hide show
  1. package/README.md +735 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +84 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/llm-redteam.js +251 -251
  13. package/cli/agents/mobile-scanner.js +231 -231
  14. package/cli/agents/orchestrator.js +322 -322
  15. package/cli/agents/pii-compliance-agent.js +301 -301
  16. package/cli/agents/scoring-engine.js +248 -248
  17. package/cli/agents/supabase-rls-agent.js +154 -154
  18. package/cli/agents/supply-chain-agent.js +650 -507
  19. package/cli/bin/ship-safe.js +452 -426
  20. package/cli/commands/agent.js +608 -608
  21. package/cli/commands/audit.js +986 -980
  22. package/cli/commands/baseline.js +193 -193
  23. package/cli/commands/ci.js +342 -342
  24. package/cli/commands/deps.js +516 -516
  25. package/cli/commands/doctor.js +159 -159
  26. package/cli/commands/fix.js +218 -218
  27. package/cli/commands/hooks.js +268 -0
  28. package/cli/commands/init.js +407 -407
  29. package/cli/commands/mcp.js +304 -304
  30. package/cli/commands/red-team.js +7 -1
  31. package/cli/commands/remediate.js +798 -798
  32. package/cli/commands/rotate.js +571 -571
  33. package/cli/commands/scan.js +569 -569
  34. package/cli/commands/score.js +449 -449
  35. package/cli/commands/watch.js +281 -281
  36. package/cli/hooks/patterns.js +313 -0
  37. package/cli/hooks/post-tool-use.js +140 -0
  38. package/cli/hooks/pre-tool-use.js +186 -0
  39. package/cli/index.js +73 -69
  40. package/cli/providers/llm-provider.js +397 -287
  41. package/cli/utils/autofix-rules.js +74 -74
  42. package/cli/utils/cache-manager.js +311 -311
  43. package/cli/utils/output.js +230 -230
  44. package/cli/utils/patterns.js +1121 -1121
  45. package/cli/utils/pdf-generator.js +94 -94
  46. package/package.json +69 -69
  47. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,311 +1,311 @@
1
- /**
2
- * Cache Manager
3
- * =============
4
- *
5
- * Provides incremental scanning by caching file hashes and findings.
6
- * On subsequent runs, only changed files are re-scanned.
7
- *
8
- * Cache location: .ship-safe/context.json
9
- *
10
- * USAGE:
11
- * import { CacheManager } from './cache-manager.js';
12
- * const cache = new CacheManager(rootPath);
13
- * const { changedFiles, cachedFindings } = await cache.getChangedFiles(currentFiles);
14
- * // ... scan only changedFiles ...
15
- * cache.save(allFiles, allFindings, recon, scoreResult);
16
- */
17
-
18
- import fs from 'fs';
19
- import path from 'path';
20
- import crypto from 'crypto';
21
- import { readFileSync } from 'fs';
22
- import { fileURLToPath } from 'url';
23
- import { dirname, join } from 'path';
24
-
25
- // Read version from package.json
26
- const __filename = fileURLToPath(import.meta.url); // ship-safe-ignore — module's own path via import.meta.url, not user input
27
- const __dirname = dirname(__filename);
28
- const PACKAGE_VERSION = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
29
-
30
- // Cache TTL: 24 hours
31
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
32
-
33
- export class CacheManager {
34
- /**
35
- * @param {string} rootPath — Absolute path to project root
36
- */
37
- constructor(rootPath) {
38
- this.rootPath = rootPath;
39
- this.cacheDir = path.join(rootPath, '.ship-safe');
40
- this.cachePath = path.join(this.cacheDir, 'context.json');
41
- this.cache = null;
42
- }
43
-
44
- /**
45
- * Load the cache from disk. Returns null if cache is missing, expired, or invalid.
46
- */
47
- load() {
48
- try {
49
- if (!fs.existsSync(this.cachePath)) return null;
50
-
51
- const raw = fs.readFileSync(this.cachePath, 'utf-8');
52
- const cache = JSON.parse(raw);
53
-
54
- // Version mismatch — patterns may have changed
55
- if (cache.version !== PACKAGE_VERSION) return null;
56
-
57
- // TTL expired
58
- const age = Date.now() - new Date(cache.generatedAt).getTime();
59
- if (age > CACHE_TTL_MS) return null;
60
-
61
- this.cache = cache;
62
- return cache;
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- /**
69
- * Compute SHA-256 hash of a file's contents.
70
- */
71
- hashFile(filePath) {
72
- try {
73
- const content = fs.readFileSync(filePath);
74
- return crypto.createHash('sha256').update(content).digest('hex');
75
- } catch {
76
- return null;
77
- }
78
- }
79
-
80
- /**
81
- * Compare current files against cached file index to find what changed.
82
- *
83
- * @param {string[]} currentFiles — Array of absolute file paths
84
- * @returns {{ changedFiles: string[], cachedFindings: object[], unchangedCount: number, newCount: number, modifiedCount: number, deletedCount: number }}
85
- */
86
- diff(currentFiles) {
87
- if (!this.cache || !this.cache.fileIndex) {
88
- return {
89
- changedFiles: currentFiles,
90
- cachedFindings: [],
91
- unchangedCount: 0,
92
- newCount: currentFiles.length,
93
- modifiedCount: 0,
94
- deletedCount: 0,
95
- };
96
- }
97
-
98
- const cachedIndex = this.cache.fileIndex;
99
- const cachedFindings = this.cache.lastFindings || {};
100
- const changedFiles = [];
101
- const reusedFindings = [];
102
- let unchangedCount = 0;
103
- let newCount = 0;
104
- let modifiedCount = 0;
105
-
106
- const currentSet = new Set(currentFiles);
107
-
108
- for (const file of currentFiles) {
109
- const relPath = path.relative(this.rootPath, file).replace(/\\/g, '/');
110
- const cached = cachedIndex[relPath];
111
-
112
- if (!cached) {
113
- // New file — needs scanning
114
- changedFiles.push(file);
115
- newCount++;
116
- continue;
117
- }
118
-
119
- // Quick size check before expensive hash
120
- try {
121
- const stats = fs.statSync(file);
122
- if (stats.size !== cached.size) {
123
- changedFiles.push(file);
124
- modifiedCount++;
125
- continue;
126
- }
127
- } catch {
128
- changedFiles.push(file);
129
- modifiedCount++;
130
- continue;
131
- }
132
-
133
- // Hash check
134
- const currentHash = this.hashFile(file);
135
- if (currentHash !== cached.hash) {
136
- changedFiles.push(file);
137
- modifiedCount++;
138
- continue;
139
- }
140
-
141
- // File unchanged — reuse cached findings
142
- unchangedCount++;
143
- if (cachedFindings[relPath]) {
144
- // Restore absolute paths for cached findings
145
- for (const finding of cachedFindings[relPath]) {
146
- reusedFindings.push({ ...finding, file });
147
- }
148
- }
149
- }
150
-
151
- // Count deleted files (in cache but not in current)
152
- const currentRelPaths = new Set(
153
- currentFiles.map(f => path.relative(this.rootPath, f).replace(/\\/g, '/'))
154
- );
155
- const deletedCount = Object.keys(cachedIndex).filter(p => !currentRelPaths.has(p)).length;
156
-
157
- return {
158
- changedFiles,
159
- cachedFindings: reusedFindings,
160
- unchangedCount,
161
- newCount,
162
- modifiedCount,
163
- deletedCount,
164
- };
165
- }
166
-
167
- /**
168
- * Save the cache to disk.
169
- *
170
- * @param {string[]} allFiles — All scanned file paths
171
- * @param {object[]} allFindings — All findings from the scan
172
- * @param {object} recon — ReconAgent output
173
- * @param {object} [scoreResult] — Optional score result
174
- */
175
- save(allFiles, allFindings, recon, scoreResult) {
176
- try {
177
- // Ensure .ship-safe directory exists
178
- if (!fs.existsSync(this.cacheDir)) {
179
- fs.mkdirSync(this.cacheDir, { recursive: true });
180
- }
181
-
182
- // Build file index with hashes
183
- const fileIndex = {};
184
- for (const file of allFiles) {
185
- const relPath = path.relative(this.rootPath, file).replace(/\\/g, '/');
186
- const hash = this.hashFile(file);
187
- if (hash) {
188
- try {
189
- const stats = fs.statSync(file);
190
- fileIndex[relPath] = {
191
- hash,
192
- size: stats.size,
193
- lastScanned: new Date().toISOString(),
194
- };
195
- } catch {
196
- // Skip files we can't stat
197
- }
198
- }
199
- }
200
-
201
- // Group findings by file (relative paths)
202
- const lastFindings = {};
203
- for (const f of allFindings) {
204
- const relPath = path.relative(this.rootPath, f.file).replace(/\\/g, '/');
205
- if (!lastFindings[relPath]) lastFindings[relPath] = [];
206
- // Store a lightweight copy (no absolute paths)
207
- lastFindings[relPath].push({
208
- line: f.line,
209
- column: f.column,
210
- severity: f.severity,
211
- category: f.category,
212
- rule: f.rule,
213
- title: f.title,
214
- description: f.description,
215
- matched: f.matched,
216
- confidence: f.confidence,
217
- cwe: f.cwe,
218
- owasp: f.owasp,
219
- fix: f.fix,
220
- });
221
- }
222
-
223
- const cache = {
224
- version: PACKAGE_VERSION,
225
- generatedAt: new Date().toISOString(),
226
- rootPath: this.rootPath,
227
- recon: recon || null,
228
- fileIndex,
229
- lastFindings,
230
- stats: {
231
- totalFiles: allFiles.length,
232
- totalFindings: allFindings.length,
233
- lastScore: scoreResult?.score ?? null,
234
- lastGrade: scoreResult?.grade?.letter ?? null,
235
- },
236
- };
237
-
238
- fs.writeFileSync(this.cachePath, JSON.stringify(cache, null, 2));
239
- } catch {
240
- // Silent failure — caching should never break a scan
241
- }
242
- }
243
-
244
- /**
245
- * Delete the cache file.
246
- */
247
- invalidate() {
248
- try {
249
- if (fs.existsSync(this.cachePath)) {
250
- fs.unlinkSync(this.cachePath);
251
- }
252
- } catch {
253
- // Silent
254
- }
255
- }
256
-
257
- // ===========================================================================
258
- // LLM CLASSIFICATION CACHE
259
- // ===========================================================================
260
-
261
- get llmCachePath() {
262
- return path.join(this.cacheDir, 'llm-cache.json');
263
- }
264
-
265
- /**
266
- * Generate a cache key for an LLM classification.
267
- */
268
- getLLMCacheKey(finding) {
269
- const data = `${finding.file}:${finding.line}:${finding.rule}:${finding.matched || ''}`;
270
- return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
271
- }
272
-
273
- /**
274
- * Load cached LLM classifications. Returns {} if none or expired.
275
- */
276
- loadLLMClassifications() {
277
- const LLM_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
278
- try {
279
- if (!fs.existsSync(this.llmCachePath)) return {};
280
- const raw = JSON.parse(fs.readFileSync(this.llmCachePath, 'utf-8'));
281
- const now = Date.now();
282
- const valid = {};
283
- for (const [key, entry] of Object.entries(raw)) {
284
- if (now - new Date(entry.cachedAt).getTime() < LLM_CACHE_TTL) {
285
- valid[key] = entry;
286
- }
287
- }
288
- return valid;
289
- } catch {
290
- return {};
291
- }
292
- }
293
-
294
- /**
295
- * Save LLM classifications to cache.
296
- */
297
- saveLLMClassifications(classifications) {
298
- try {
299
- if (!fs.existsSync(this.cacheDir)) {
300
- fs.mkdirSync(this.cacheDir, { recursive: true });
301
- }
302
- const existing = this.loadLLMClassifications();
303
- const merged = { ...existing, ...classifications };
304
- fs.writeFileSync(this.llmCachePath, JSON.stringify(merged, null, 2));
305
- } catch {
306
- // Silent
307
- }
308
- }
309
- }
310
-
311
- export default CacheManager;
1
+ /**
2
+ * Cache Manager
3
+ * =============
4
+ *
5
+ * Provides incremental scanning by caching file hashes and findings.
6
+ * On subsequent runs, only changed files are re-scanned.
7
+ *
8
+ * Cache location: .ship-safe/context.json
9
+ *
10
+ * USAGE:
11
+ * import { CacheManager } from './cache-manager.js';
12
+ * const cache = new CacheManager(rootPath);
13
+ * const { changedFiles, cachedFindings } = await cache.getChangedFiles(currentFiles);
14
+ * // ... scan only changedFiles ...
15
+ * cache.save(allFiles, allFindings, recon, scoreResult);
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import crypto from 'crypto';
21
+ import { readFileSync } from 'fs';
22
+ import { fileURLToPath } from 'url';
23
+ import { dirname, join } from 'path';
24
+
25
+ // Read version from package.json
26
+ const __filename = fileURLToPath(import.meta.url); // ship-safe-ignore — module's own path via import.meta.url, not user input
27
+ const __dirname = dirname(__filename);
28
+ const PACKAGE_VERSION = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
29
+
30
+ // Cache TTL: 24 hours
31
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
32
+
33
+ export class CacheManager {
34
+ /**
35
+ * @param {string} rootPath — Absolute path to project root
36
+ */
37
+ constructor(rootPath) {
38
+ this.rootPath = rootPath;
39
+ this.cacheDir = path.join(rootPath, '.ship-safe');
40
+ this.cachePath = path.join(this.cacheDir, 'context.json');
41
+ this.cache = null;
42
+ }
43
+
44
+ /**
45
+ * Load the cache from disk. Returns null if cache is missing, expired, or invalid.
46
+ */
47
+ load() {
48
+ try {
49
+ if (!fs.existsSync(this.cachePath)) return null;
50
+
51
+ const raw = fs.readFileSync(this.cachePath, 'utf-8');
52
+ const cache = JSON.parse(raw);
53
+
54
+ // Version mismatch — patterns may have changed
55
+ if (cache.version !== PACKAGE_VERSION) return null;
56
+
57
+ // TTL expired
58
+ const age = Date.now() - new Date(cache.generatedAt).getTime();
59
+ if (age > CACHE_TTL_MS) return null;
60
+
61
+ this.cache = cache;
62
+ return cache;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Compute SHA-256 hash of a file's contents.
70
+ */
71
+ hashFile(filePath) {
72
+ try {
73
+ const content = fs.readFileSync(filePath);
74
+ return crypto.createHash('sha256').update(content).digest('hex');
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Compare current files against cached file index to find what changed.
82
+ *
83
+ * @param {string[]} currentFiles — Array of absolute file paths
84
+ * @returns {{ changedFiles: string[], cachedFindings: object[], unchangedCount: number, newCount: number, modifiedCount: number, deletedCount: number }}
85
+ */
86
+ diff(currentFiles) {
87
+ if (!this.cache || !this.cache.fileIndex) {
88
+ return {
89
+ changedFiles: currentFiles,
90
+ cachedFindings: [],
91
+ unchangedCount: 0,
92
+ newCount: currentFiles.length,
93
+ modifiedCount: 0,
94
+ deletedCount: 0,
95
+ };
96
+ }
97
+
98
+ const cachedIndex = this.cache.fileIndex;
99
+ const cachedFindings = this.cache.lastFindings || {};
100
+ const changedFiles = [];
101
+ const reusedFindings = [];
102
+ let unchangedCount = 0;
103
+ let newCount = 0;
104
+ let modifiedCount = 0;
105
+
106
+ const currentSet = new Set(currentFiles);
107
+
108
+ for (const file of currentFiles) {
109
+ const relPath = path.relative(this.rootPath, file).replace(/\\/g, '/');
110
+ const cached = cachedIndex[relPath];
111
+
112
+ if (!cached) {
113
+ // New file — needs scanning
114
+ changedFiles.push(file);
115
+ newCount++;
116
+ continue;
117
+ }
118
+
119
+ // Quick size check before expensive hash
120
+ try {
121
+ const stats = fs.statSync(file);
122
+ if (stats.size !== cached.size) {
123
+ changedFiles.push(file);
124
+ modifiedCount++;
125
+ continue;
126
+ }
127
+ } catch {
128
+ changedFiles.push(file);
129
+ modifiedCount++;
130
+ continue;
131
+ }
132
+
133
+ // Hash check
134
+ const currentHash = this.hashFile(file);
135
+ if (currentHash !== cached.hash) {
136
+ changedFiles.push(file);
137
+ modifiedCount++;
138
+ continue;
139
+ }
140
+
141
+ // File unchanged — reuse cached findings
142
+ unchangedCount++;
143
+ if (cachedFindings[relPath]) {
144
+ // Restore absolute paths for cached findings
145
+ for (const finding of cachedFindings[relPath]) {
146
+ reusedFindings.push({ ...finding, file });
147
+ }
148
+ }
149
+ }
150
+
151
+ // Count deleted files (in cache but not in current)
152
+ const currentRelPaths = new Set(
153
+ currentFiles.map(f => path.relative(this.rootPath, f).replace(/\\/g, '/'))
154
+ );
155
+ const deletedCount = Object.keys(cachedIndex).filter(p => !currentRelPaths.has(p)).length;
156
+
157
+ return {
158
+ changedFiles,
159
+ cachedFindings: reusedFindings,
160
+ unchangedCount,
161
+ newCount,
162
+ modifiedCount,
163
+ deletedCount,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Save the cache to disk.
169
+ *
170
+ * @param {string[]} allFiles — All scanned file paths
171
+ * @param {object[]} allFindings — All findings from the scan
172
+ * @param {object} recon — ReconAgent output
173
+ * @param {object} [scoreResult] — Optional score result
174
+ */
175
+ save(allFiles, allFindings, recon, scoreResult) {
176
+ try {
177
+ // Ensure .ship-safe directory exists
178
+ if (!fs.existsSync(this.cacheDir)) {
179
+ fs.mkdirSync(this.cacheDir, { recursive: true });
180
+ }
181
+
182
+ // Build file index with hashes
183
+ const fileIndex = {};
184
+ for (const file of allFiles) {
185
+ const relPath = path.relative(this.rootPath, file).replace(/\\/g, '/');
186
+ const hash = this.hashFile(file);
187
+ if (hash) {
188
+ try {
189
+ const stats = fs.statSync(file);
190
+ fileIndex[relPath] = {
191
+ hash,
192
+ size: stats.size,
193
+ lastScanned: new Date().toISOString(),
194
+ };
195
+ } catch {
196
+ // Skip files we can't stat
197
+ }
198
+ }
199
+ }
200
+
201
+ // Group findings by file (relative paths)
202
+ const lastFindings = {};
203
+ for (const f of allFindings) {
204
+ const relPath = path.relative(this.rootPath, f.file).replace(/\\/g, '/');
205
+ if (!lastFindings[relPath]) lastFindings[relPath] = [];
206
+ // Store a lightweight copy (no absolute paths)
207
+ lastFindings[relPath].push({
208
+ line: f.line,
209
+ column: f.column,
210
+ severity: f.severity,
211
+ category: f.category,
212
+ rule: f.rule,
213
+ title: f.title,
214
+ description: f.description,
215
+ matched: f.matched,
216
+ confidence: f.confidence,
217
+ cwe: f.cwe,
218
+ owasp: f.owasp,
219
+ fix: f.fix,
220
+ });
221
+ }
222
+
223
+ const cache = {
224
+ version: PACKAGE_VERSION,
225
+ generatedAt: new Date().toISOString(),
226
+ rootPath: this.rootPath,
227
+ recon: recon || null,
228
+ fileIndex,
229
+ lastFindings,
230
+ stats: {
231
+ totalFiles: allFiles.length,
232
+ totalFindings: allFindings.length,
233
+ lastScore: scoreResult?.score ?? null,
234
+ lastGrade: scoreResult?.grade?.letter ?? null,
235
+ },
236
+ };
237
+
238
+ fs.writeFileSync(this.cachePath, JSON.stringify(cache, null, 2));
239
+ } catch {
240
+ // Silent failure — caching should never break a scan
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Delete the cache file.
246
+ */
247
+ invalidate() {
248
+ try {
249
+ if (fs.existsSync(this.cachePath)) {
250
+ fs.unlinkSync(this.cachePath);
251
+ }
252
+ } catch {
253
+ // Silent
254
+ }
255
+ }
256
+
257
+ // ===========================================================================
258
+ // LLM CLASSIFICATION CACHE
259
+ // ===========================================================================
260
+
261
+ get llmCachePath() {
262
+ return path.join(this.cacheDir, 'llm-cache.json');
263
+ }
264
+
265
+ /**
266
+ * Generate a cache key for an LLM classification.
267
+ */
268
+ getLLMCacheKey(finding) {
269
+ const data = `${finding.file}:${finding.line}:${finding.rule}:${finding.matched || ''}`;
270
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
271
+ }
272
+
273
+ /**
274
+ * Load cached LLM classifications. Returns {} if none or expired.
275
+ */
276
+ loadLLMClassifications() {
277
+ const LLM_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
278
+ try {
279
+ if (!fs.existsSync(this.llmCachePath)) return {};
280
+ const raw = JSON.parse(fs.readFileSync(this.llmCachePath, 'utf-8'));
281
+ const now = Date.now();
282
+ const valid = {};
283
+ for (const [key, entry] of Object.entries(raw)) {
284
+ if (now - new Date(entry.cachedAt).getTime() < LLM_CACHE_TTL) {
285
+ valid[key] = entry;
286
+ }
287
+ }
288
+ return valid;
289
+ } catch {
290
+ return {};
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Save LLM classifications to cache.
296
+ */
297
+ saveLLMClassifications(classifications) {
298
+ try {
299
+ if (!fs.existsSync(this.cacheDir)) {
300
+ fs.mkdirSync(this.cacheDir, { recursive: true });
301
+ }
302
+ const existing = this.loadLLMClassifications();
303
+ const merged = { ...existing, ...classifications };
304
+ fs.writeFileSync(this.llmCachePath, JSON.stringify(merged, null, 2));
305
+ } catch {
306
+ // Silent
307
+ }
308
+ }
309
+ }
310
+
311
+ export default CacheManager;