ship-safe 4.1.0 → 4.3.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 +65 -16
- package/cli/__tests__/agents.test.js +722 -0
- package/cli/agents/api-fuzzer.js +345 -224
- package/cli/agents/auth-bypass-agent.js +348 -326
- package/cli/agents/base-agent.js +262 -253
- package/cli/agents/cicd-scanner.js +201 -200
- package/cli/agents/config-auditor.js +529 -413
- package/cli/agents/git-history-scanner.js +170 -167
- package/cli/agents/html-reporter.js +370 -363
- package/cli/agents/index.js +59 -56
- package/cli/agents/injection-tester.js +455 -401
- package/cli/agents/llm-redteam.js +251 -251
- package/cli/agents/mobile-scanner.js +225 -225
- package/cli/agents/orchestrator.js +263 -157
- package/cli/agents/scoring-engine.js +225 -207
- package/cli/agents/supabase-rls-agent.js +148 -0
- package/cli/agents/supply-chain-agent.js +356 -274
- package/cli/bin/ship-safe.js +29 -1
- package/cli/commands/audit.js +875 -620
- package/cli/commands/baseline.js +192 -0
- package/cli/commands/doctor.js +149 -0
- package/cli/commands/remediate.js +7 -3
- package/cli/index.js +60 -53
- package/cli/providers/llm-provider.js +287 -288
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/cache-manager.js +311 -258
- package/cli/utils/pdf-generator.js +94 -0
- package/package.json +2 -2
|
@@ -1,258 +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);
|
|
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
|
-
|
|
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);
|
|
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;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF Generator
|
|
3
|
+
* ==============
|
|
4
|
+
*
|
|
5
|
+
* Zero-dependency PDF generation via Chrome/Chromium headless mode.
|
|
6
|
+
* Falls back to generating a print-optimized HTML file if Chrome is not found.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Well-known Chrome/Chromium paths by platform.
|
|
15
|
+
*/
|
|
16
|
+
function findChrome() {
|
|
17
|
+
const candidates = process.platform === 'win32'
|
|
18
|
+
? [
|
|
19
|
+
process.env.CHROME_PATH,
|
|
20
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
21
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
22
|
+
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
|
|
23
|
+
]
|
|
24
|
+
: process.platform === 'darwin'
|
|
25
|
+
? [
|
|
26
|
+
process.env.CHROME_PATH,
|
|
27
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
28
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
29
|
+
]
|
|
30
|
+
: [
|
|
31
|
+
process.env.CHROME_PATH,
|
|
32
|
+
'/usr/bin/google-chrome',
|
|
33
|
+
'/usr/bin/google-chrome-stable',
|
|
34
|
+
'/usr/bin/chromium',
|
|
35
|
+
'/usr/bin/chromium-browser',
|
|
36
|
+
'/snap/bin/chromium',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
if (c && fs.existsSync(c)) return c;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if Chrome is available.
|
|
47
|
+
*/
|
|
48
|
+
export function isChromeAvailable() {
|
|
49
|
+
return findChrome() !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate PDF from an HTML file using Chrome headless.
|
|
54
|
+
* Returns the output path, or null if Chrome is not available.
|
|
55
|
+
*/
|
|
56
|
+
export function generatePDF(htmlPath, outputPath) {
|
|
57
|
+
const chrome = findChrome();
|
|
58
|
+
if (!chrome) return null;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const args = [
|
|
62
|
+
'--headless',
|
|
63
|
+
'--disable-gpu',
|
|
64
|
+
'--no-sandbox',
|
|
65
|
+
`--print-to-pdf=${outputPath}`,
|
|
66
|
+
'--print-to-pdf-no-header',
|
|
67
|
+
htmlPath,
|
|
68
|
+
];
|
|
69
|
+
execFileSync(chrome, args, { timeout: 30000, stdio: 'pipe' });
|
|
70
|
+
return outputPath;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a print-optimized HTML file as PDF fallback.
|
|
78
|
+
*/
|
|
79
|
+
export function generatePrintHTML(htmlPath, outputPath) {
|
|
80
|
+
let html = fs.readFileSync(htmlPath, 'utf-8');
|
|
81
|
+
// Add print-optimized styles
|
|
82
|
+
const printCSS = `
|
|
83
|
+
<style media="print">
|
|
84
|
+
body { background: #fff !important; color: #1e293b !important; }
|
|
85
|
+
.score-card, .stat, .summary-card, .toc { background: #f8fafc !important; border: 1px solid #e2e8f0 !important; }
|
|
86
|
+
table, th, td { border: 1px solid #e2e8f0 !important; }
|
|
87
|
+
code { background: #f1f5f9 !important; color: #0f172a !important; }
|
|
88
|
+
pre { background: #f1f5f9 !important; }
|
|
89
|
+
a { color: #0369a1 !important; }
|
|
90
|
+
</style>`;
|
|
91
|
+
html = html.replace('</head>', printCSS + '\n</head>');
|
|
92
|
+
fs.writeFileSync(outputPath, html);
|
|
93
|
+
return outputPath;
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ship-safe",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "AI-powered multi-agent security platform. 12 agents scan 50+ attack classes. Red team your code before attackers do.",
|
|
5
5
|
"main": "cli/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node --test",
|
|
11
|
+
"test": "node --test cli/__tests__/*.test.js",
|
|
12
12
|
"lint": "eslint cli/",
|
|
13
13
|
"ship-safe": "node cli/bin/ship-safe.js"
|
|
14
14
|
},
|