@activemind/scd 1.4.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/LICENSE.md +35 -0
- package/README.md +417 -0
- package/bin/scd.js +140 -0
- package/lib/audit-report.js +93 -0
- package/lib/audit-sync.js +172 -0
- package/lib/audit.js +356 -0
- package/lib/cli-helpers.js +108 -0
- package/lib/commands/accept.js +28 -0
- package/lib/commands/audit.js +17 -0
- package/lib/commands/configure.js +200 -0
- package/lib/commands/doctor.js +14 -0
- package/lib/commands/exceptions.js +19 -0
- package/lib/commands/export-findings.js +46 -0
- package/lib/commands/findings.js +306 -0
- package/lib/commands/ignore.js +28 -0
- package/lib/commands/init.js +16 -0
- package/lib/commands/insights.js +24 -0
- package/lib/commands/install.js +15 -0
- package/lib/commands/list.js +109 -0
- package/lib/commands/remove.js +16 -0
- package/lib/commands/repo.js +862 -0
- package/lib/commands/report.js +234 -0
- package/lib/commands/resolve.js +25 -0
- package/lib/commands/rules.js +185 -0
- package/lib/commands/scan.js +519 -0
- package/lib/commands/scope.js +341 -0
- package/lib/commands/sync.js +40 -0
- package/lib/commands/uninstall.js +15 -0
- package/lib/commands/version.js +33 -0
- package/lib/comment-map.js +388 -0
- package/lib/config.js +325 -0
- package/lib/context-modifiers.js +211 -0
- package/lib/deep-analyzer.js +225 -0
- package/lib/doctor.js +236 -0
- package/lib/exception-manager.js +675 -0
- package/lib/export-findings.js +376 -0
- package/lib/file-context.js +380 -0
- package/lib/file-filter.js +204 -0
- package/lib/file-manifest.js +145 -0
- package/lib/git-utils.js +102 -0
- package/lib/global-config.js +239 -0
- package/lib/hooks-manager.js +130 -0
- package/lib/init-repo.js +147 -0
- package/lib/insights-analyzer.js +416 -0
- package/lib/insights-output.js +160 -0
- package/lib/installer.js +128 -0
- package/lib/output-constants.js +32 -0
- package/lib/output-terminal.js +407 -0
- package/lib/push-queue.js +322 -0
- package/lib/remove-repo.js +108 -0
- package/lib/repo-context.js +187 -0
- package/lib/report-html.js +1154 -0
- package/lib/report-index.js +157 -0
- package/lib/report-json.js +136 -0
- package/lib/report-markdown.js +250 -0
- package/lib/resolve-manager.js +148 -0
- package/lib/rule-registry.js +205 -0
- package/lib/scan-cache.js +171 -0
- package/lib/scan-context.js +312 -0
- package/lib/scan-schema.js +67 -0
- package/lib/scanner-full.js +681 -0
- package/lib/scanner-manual.js +348 -0
- package/lib/scanner-secrets.js +83 -0
- package/lib/scope.js +331 -0
- package/lib/store-verify.js +395 -0
- package/lib/store.js +310 -0
- package/lib/taint-register.js +196 -0
- package/lib/version-check.js +46 -0
- package/package.json +37 -0
- package/rules/rule-loader.js +324 -0
- package/rules/rules-aspx-cs.json +399 -0
- package/rules/rules-aspx.json +222 -0
- package/rules/rules-infra-leakage.json +434 -0
- package/rules/rules-js.json +664 -0
- package/rules/rules-php.json +521 -0
- package/rules/rules-python.json +466 -0
- package/rules/rules-secrets.json +99 -0
- package/rules/rules-sensitive-files.json +475 -0
- package/rules/rules-ts.json +76 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store-verify.js
|
|
3
|
+
*
|
|
4
|
+
* Verifies that repos in the global store still exist on disk and are
|
|
5
|
+
* valid git repositories. Reports status for each, and optionally runs
|
|
6
|
+
* an interactive cleanup flow.
|
|
7
|
+
*
|
|
8
|
+
* Statuses:
|
|
9
|
+
* OK – localPath exists, is a git repo, remote matches (if remote-type)
|
|
10
|
+
* STALE – localPath exists but .git/ is gone (remote-type repos only)
|
|
11
|
+
* MISSING – localPath does not exist on disk
|
|
12
|
+
* ORPHAN – meta.json has no localPath recorded
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
const { RESET, BOLD, DIM, RED, GREEN, YELLOW, CYAN, OK } = require('./output-constants');
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const { execSync } = require('child_process');
|
|
22
|
+
const readline = require('readline');
|
|
23
|
+
|
|
24
|
+
const REPOS_DIR = path.join(
|
|
25
|
+
process.env.HOME || process.env.USERPROFILE || '~',
|
|
26
|
+
'.scd', 'repos'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// ── Status constants ───────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const STATUS = {
|
|
32
|
+
OK: 'OK',
|
|
33
|
+
STALE: 'STALE',
|
|
34
|
+
MISSING: 'MISSING',
|
|
35
|
+
ORPHAN: 'ORPHAN',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function daysSince(isoString) {
|
|
41
|
+
if (!isoString) return null;
|
|
42
|
+
const diff = Date.now() - new Date(isoString).getTime();
|
|
43
|
+
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function repoStorePath(repoId) {
|
|
47
|
+
return path.join(REPOS_DIR, repoId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readMeta(repoId) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(
|
|
53
|
+
fs.readFileSync(path.join(repoStorePath(repoId), 'meta.json'), 'utf8')
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
return { repoId, name: repoId, localPath: null, remote: null, type: null };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getStoreStats(repoId) {
|
|
61
|
+
const dir = repoStorePath(repoId);
|
|
62
|
+
let scanCount = 0;
|
|
63
|
+
let reportCount = 0;
|
|
64
|
+
let totalBytes = 0;
|
|
65
|
+
|
|
66
|
+
// Count scans in audit.log
|
|
67
|
+
const auditFile = path.join(dir, 'audit.log');
|
|
68
|
+
if (fs.existsSync(auditFile)) {
|
|
69
|
+
try {
|
|
70
|
+
const lines = fs.readFileSync(auditFile, 'utf8').split('\n').filter(Boolean);
|
|
71
|
+
scanCount = lines.filter(l => {
|
|
72
|
+
try { return JSON.parse(l).event === 'scan_complete'; } catch { return false; }
|
|
73
|
+
}).length;
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Count reports
|
|
78
|
+
const reportsDir = path.join(dir, 'reports');
|
|
79
|
+
if (fs.existsSync(reportsDir)) {
|
|
80
|
+
try {
|
|
81
|
+
reportCount = fs.readdirSync(reportsDir).filter(f => /\.(html|md|json)$/.test(f)).length;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Total store size
|
|
86
|
+
try {
|
|
87
|
+
const walk = (d) => {
|
|
88
|
+
for (const f of fs.readdirSync(d)) {
|
|
89
|
+
const full = path.join(d, f);
|
|
90
|
+
try {
|
|
91
|
+
const st = fs.statSync(full);
|
|
92
|
+
if (st.isDirectory()) walk(full);
|
|
93
|
+
else totalBytes += st.size;
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(dir);
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
return { scanCount, reportCount, totalBytes };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatBytes(bytes) {
|
|
104
|
+
if (bytes < 1024) return bytes + ' B';
|
|
105
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
106
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function currentRemote(localPath) {
|
|
110
|
+
try {
|
|
111
|
+
return execSync('git remote get-url origin', {
|
|
112
|
+
cwd: localPath, stdio: ['pipe', 'pipe', 'pipe']
|
|
113
|
+
}).toString().trim();
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Core verify logic ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function verifyRepo(repoId) {
|
|
122
|
+
const meta = readMeta(repoId);
|
|
123
|
+
const result = {
|
|
124
|
+
repoId,
|
|
125
|
+
name: meta.name || repoId,
|
|
126
|
+
localPath: meta.localPath || null,
|
|
127
|
+
remote: meta.remote || null,
|
|
128
|
+
type: meta.type || 'unknown',
|
|
129
|
+
lastSeen: meta.lastSeen || null,
|
|
130
|
+
lastScan: meta.lastScan || null,
|
|
131
|
+
daysSinceLastSeen: daysSince(meta.lastSeen),
|
|
132
|
+
status: null,
|
|
133
|
+
detail: null,
|
|
134
|
+
stats: getStoreStats(repoId),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (!meta.localPath) {
|
|
138
|
+
result.status = STATUS.ORPHAN;
|
|
139
|
+
result.detail = 'No localPath recorded in meta.json';
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!fs.existsSync(meta.localPath)) {
|
|
144
|
+
result.status = STATUS.MISSING;
|
|
145
|
+
result.detail = 'Directory no longer exists on disk';
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const gitDir = path.join(meta.localPath, '.git');
|
|
150
|
+
const hasGit = fs.existsSync(gitDir);
|
|
151
|
+
|
|
152
|
+
if (meta.type === 'path-based') {
|
|
153
|
+
// path-based repos are plain directory scans — .git/ is irrelevant
|
|
154
|
+
// Optionally note if .git has appeared (repo was initialised after first scan)
|
|
155
|
+
result.status = STATUS.OK;
|
|
156
|
+
if (hasGit) {
|
|
157
|
+
result.detail = 'Directory scan — .git/ present (repo was initialised after first scan)';
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// For remote-type repos: .git/ must exist
|
|
163
|
+
if (!hasGit) {
|
|
164
|
+
result.status = STATUS.STALE;
|
|
165
|
+
result.detail = '.git/ directory removed — no longer a git repository';
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check remote match
|
|
170
|
+
if (meta.remote) {
|
|
171
|
+
const actualRemote = currentRemote(meta.localPath);
|
|
172
|
+
if (actualRemote && actualRemote !== meta.remote) {
|
|
173
|
+
result.status = STATUS.STALE;
|
|
174
|
+
result.detail = `Remote mismatch — stored: ${meta.remote}, actual: ${actualRemote}`;
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
result.status = STATUS.OK;
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function verifyAll() {
|
|
184
|
+
if (!fs.existsSync(REPOS_DIR)) return [];
|
|
185
|
+
const ids = fs.readdirSync(REPOS_DIR).filter(id => {
|
|
186
|
+
try { return fs.statSync(path.join(REPOS_DIR, id)).isDirectory(); }
|
|
187
|
+
catch { return false; }
|
|
188
|
+
});
|
|
189
|
+
return ids.map(verifyRepo);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Archive a repo store entry ─────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function copyDirRecursive(src, dest) {
|
|
195
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
196
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
197
|
+
const srcPath = path.join(src, entry.name);
|
|
198
|
+
const destPath = path.join(dest, entry.name);
|
|
199
|
+
if (entry.isDirectory()) {
|
|
200
|
+
copyDirRecursive(srcPath, destPath);
|
|
201
|
+
} else {
|
|
202
|
+
fs.copyFileSync(srcPath, destPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function archiveRepo(repoId) {
|
|
208
|
+
const dir = repoStorePath(repoId);
|
|
209
|
+
const meta = readMeta(repoId);
|
|
210
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
211
|
+
const name = (meta.name || repoId).replace(/[^a-z0-9_-]/gi, '_');
|
|
212
|
+
|
|
213
|
+
if (process.platform !== 'win32') {
|
|
214
|
+
// Unix: use tar for a proper compressed archive
|
|
215
|
+
const { execSync } = require('child_process');
|
|
216
|
+
const archive = path.join(os.homedir(), '.scd', 'archive', `${name}_${ts}.tar.gz`);
|
|
217
|
+
fs.mkdirSync(path.dirname(archive), { recursive: true, mode: 0o700 });
|
|
218
|
+
execSync(`tar -czf "${archive}" -C "${path.dirname(dir)}" "${repoId}"`, {
|
|
219
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
220
|
+
});
|
|
221
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
222
|
+
return archive;
|
|
223
|
+
} else {
|
|
224
|
+
// Windows: copy to a named folder (no tar dependency)
|
|
225
|
+
const archiveDir = path.join(os.homedir(), '.scd', 'archive', `${name}_${ts}`);
|
|
226
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
227
|
+
copyDirRecursive(dir, archiveDir);
|
|
228
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
229
|
+
return archiveDir;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Delete a repo store entry ──────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function deleteRepo(repoId) {
|
|
236
|
+
const dir = repoStorePath(repoId);
|
|
237
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Interactive cleanup prompt ─────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async function promptClean(results) {
|
|
243
|
+
const issues = results.filter(r => r.status !== STATUS.OK);
|
|
244
|
+
if (issues.length === 0) return;
|
|
245
|
+
|
|
246
|
+
const rl = readline.createInterface({
|
|
247
|
+
input: process.stdin,
|
|
248
|
+
output: process.stdout,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
console.log('');
|
|
255
|
+
|
|
256
|
+
for (const repo of issues) {
|
|
257
|
+
const age = repo.daysSinceLastSeen !== null ? `last seen ${repo.daysSinceLastSeen} days ago` : 'never seen';
|
|
258
|
+
const statsStr = `${repo.stats.scanCount} scan${repo.stats.scanCount !== 1 ? 's' : ''}, `
|
|
259
|
+
+ `${repo.stats.reportCount} report${repo.stats.reportCount !== 1 ? 's' : ''}, `
|
|
260
|
+
+ `${formatBytes(repo.stats.totalBytes)} stored`;
|
|
261
|
+
|
|
262
|
+
console.log(BOLD + '──────────────────────────────────────────────────' + RESET);
|
|
263
|
+
console.log(BOLD + repo.name + RESET + ' ' + DIM + `(${repo.repoId.slice(0, 12)}…)` + RESET);
|
|
264
|
+
console.log(` Status: ${statusBadge(repo.status)} ${DIM}${repo.detail}${RESET}`);
|
|
265
|
+
console.log(` Path: ${DIM}${repo.localPath || 'unknown'}${RESET}`);
|
|
266
|
+
console.log(` History: ${DIM}${statsStr} · ${age}${RESET}`);
|
|
267
|
+
if (repo.remote) {
|
|
268
|
+
console.log(` Remote: ${DIM}${repo.remote}${RESET}`);
|
|
269
|
+
}
|
|
270
|
+
console.log('');
|
|
271
|
+
|
|
272
|
+
let choice = '';
|
|
273
|
+
while (!['k', 'a', 'd', 's'].includes(choice)) {
|
|
274
|
+
const raw = await ask(
|
|
275
|
+
` ${CYAN}[k]${RESET} Keep `
|
|
276
|
+
+ `${YELLOW}[a]${RESET} Archive `
|
|
277
|
+
+ `${RED}[d]${RESET} Delete `
|
|
278
|
+
+ `${DIM}[s]${RESET} Skip\n → `
|
|
279
|
+
);
|
|
280
|
+
choice = raw.trim().toLowerCase();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log('');
|
|
284
|
+
|
|
285
|
+
if (choice === 'k') {
|
|
286
|
+
console.log(` ${DIM}Kept — no changes made.${RESET}\n`);
|
|
287
|
+
|
|
288
|
+
} else if (choice === 'a') {
|
|
289
|
+
try {
|
|
290
|
+
const archivePath = archiveRepo(repo.repoId);
|
|
291
|
+
console.log(` ✓ Archived to: ${archivePath}\n`);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.log(` ✗ Archive failed: ${err.message}\n`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
} else if (choice === 'd') {
|
|
297
|
+
// Extra confirmation if there is scan history
|
|
298
|
+
let confirmed = true;
|
|
299
|
+
if (repo.stats.scanCount > 0) {
|
|
300
|
+
const confirm = await ask(
|
|
301
|
+
` ${RED}Warning:${RESET} This repo has ${repo.stats.scanCount} scan(s) in history.\n`
|
|
302
|
+
+ ` Type the repo name to confirm deletion: `
|
|
303
|
+
);
|
|
304
|
+
confirmed = confirm.trim() === repo.name;
|
|
305
|
+
if (!confirmed) {
|
|
306
|
+
console.log(` ${DIM}Name did not match — skipping deletion.${RESET}\n`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (confirmed) {
|
|
310
|
+
deleteRepo(repo.repoId);
|
|
311
|
+
console.log(` ✓ Deleted store entry for ${repo.name}\n`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
} else {
|
|
315
|
+
console.log(` ${DIM}Skipped.${RESET}\n`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
rl.close();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
const STATUS_ICON = {
|
|
325
|
+
OK: GREEN + '✓' + RESET,
|
|
326
|
+
MISSING: YELLOW + '⚠' + RESET,
|
|
327
|
+
STALE: RED + '✗' + RESET,
|
|
328
|
+
ORPHAN: RED + '✗' + RESET,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
function statusBadge(status) {
|
|
332
|
+
const colors = {
|
|
333
|
+
OK: GREEN,
|
|
334
|
+
MISSING: YELLOW,
|
|
335
|
+
STALE: RED,
|
|
336
|
+
ORPHAN: RED,
|
|
337
|
+
};
|
|
338
|
+
return (colors[status] || '') + status + RESET;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderResults(results, { verbose } = {}) {
|
|
342
|
+
|
|
343
|
+
const issues = results.filter(r => r.status !== STATUS.OK);
|
|
344
|
+
const ok = results.filter(r => r.status === STATUS.OK);
|
|
345
|
+
|
|
346
|
+
console.log('');
|
|
347
|
+
|
|
348
|
+
for (const repo of results) {
|
|
349
|
+
const icon = STATUS_ICON[repo.status] || '?';
|
|
350
|
+
const age = repo.daysSinceLastSeen !== null
|
|
351
|
+
? DIM + ` (last seen ${repo.daysSinceLastSeen}d ago)` + RESET
|
|
352
|
+
: '';
|
|
353
|
+
|
|
354
|
+
const pathStr = repo.localPath
|
|
355
|
+
? DIM + ' ' + repo.localPath + RESET
|
|
356
|
+
: DIM + ' (no path)' + RESET;
|
|
357
|
+
|
|
358
|
+
console.log(
|
|
359
|
+
` ${icon} ${BOLD}${repo.name.padEnd(24)}${RESET}`
|
|
360
|
+
+ statusBadge(repo.status).padEnd(18)
|
|
361
|
+
+ pathStr
|
|
362
|
+
+ age
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (verbose && repo.status !== STATUS.OK) {
|
|
366
|
+
console.log(` ${DIM}↳ ${repo.detail}${RESET}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log('');
|
|
371
|
+
console.log(
|
|
372
|
+
` ${DIM}${ok.length}/${results.length} repos OK`
|
|
373
|
+
+ (issues.length > 0 ? ` · ${issues.length} issue${issues.length !== 1 ? 's' : ''} found` : '')
|
|
374
|
+
+ RESET
|
|
375
|
+
);
|
|
376
|
+
console.log('');
|
|
377
|
+
|
|
378
|
+
if (issues.length > 0) {
|
|
379
|
+
console.log(
|
|
380
|
+
` ${DIM}Run ${RESET}sc store --verify --clean${DIM} to review cleanup options.${RESET}`
|
|
381
|
+
);
|
|
382
|
+
console.log('');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = {
|
|
387
|
+
verifyAll,
|
|
388
|
+
verifyRepo,
|
|
389
|
+
promptClean,
|
|
390
|
+
renderResults,
|
|
391
|
+
deleteRepo,
|
|
392
|
+
archiveRepo,
|
|
393
|
+
formatBytes,
|
|
394
|
+
STATUS,
|
|
395
|
+
};
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js
|
|
3
|
+
* Central path management for the Secure Code by Design global store.
|
|
4
|
+
*
|
|
5
|
+
* All per-repo data lives in ~/.scd/repos/{repoId}/
|
|
6
|
+
* Nothing is ever written inside the user's git repository.
|
|
7
|
+
*
|
|
8
|
+
* repoId = first 16 chars of SHA-256( git remote origin URL )
|
|
9
|
+
* Falls back to SHA-256( absolute repo root path ) if no remote.
|
|
10
|
+
* This makes the ID stable across re-clones of the same repo.
|
|
11
|
+
*
|
|
12
|
+
* Directory layout:
|
|
13
|
+
* ~/.scd/
|
|
14
|
+
* ├── config ← global settings (API key etc.)
|
|
15
|
+
* └── repos/
|
|
16
|
+
* └── {repoId}/
|
|
17
|
+
* ├── meta.json ← human-readable: remote URL, last seen path, name
|
|
18
|
+
* ├── config.yml ← per-repo security config (exceptions, rules)
|
|
19
|
+
* ├── audit.log ← full findings history (JSONL)
|
|
20
|
+
* ├── audit-summary.log ← anonymised statistics (JSONL)
|
|
21
|
+
* ├── last-scan.json ← symlink to latest scan (backwards compat)
|
|
22
|
+
* ├── scans/ ← one JSON per scan, never overwritten
|
|
23
|
+
* └── reports/ ← generated reports (html, md, json)
|
|
24
|
+
*
|
|
25
|
+
* If no git remote exists, repoId is based on absolute path.
|
|
26
|
+
* meta.json records type: "remote" | "path-based" so scd list can flag instability.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
const crypto = require('crypto');
|
|
35
|
+
|
|
36
|
+
const GLOBAL_DIR = path.join(os.homedir(), '.scd');
|
|
37
|
+
const REPOS_DIR = path.join(GLOBAL_DIR, 'repos');
|
|
38
|
+
|
|
39
|
+
// ── Repo identification ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive a stable repo ID from git remote origin URL.
|
|
43
|
+
* Falls back to absolute path if no remote is configured.
|
|
44
|
+
*/
|
|
45
|
+
function getRepoIdentity(repoRoot) {
|
|
46
|
+
try {
|
|
47
|
+
const { execSync } = require('child_process');
|
|
48
|
+
// Check it's a git repo first
|
|
49
|
+
execSync('git rev-parse --git-dir', {
|
|
50
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
const remote = execSync('git remote get-url origin', {
|
|
53
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
|
+
}).trim();
|
|
55
|
+
return { identifier: remote, type: 'remote' };
|
|
56
|
+
} catch {
|
|
57
|
+
// Not a git repo, or no remote – fall back to absolute path
|
|
58
|
+
return { identifier: path.resolve(repoRoot), type: 'path-based' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getRepoId(repoRoot) {
|
|
63
|
+
const { identifier } = getRepoIdentity(repoRoot);
|
|
64
|
+
return crypto.createHash('sha256').update(identifier).digest('hex').slice(0, 16);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Directory helpers ──────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function getRepoStoreDir(repoRoot) {
|
|
70
|
+
const id = getRepoId(repoRoot);
|
|
71
|
+
const dir = path.join(REPOS_DIR, id);
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getFilePath(repoRoot, filename) {
|
|
77
|
+
return path.join(getRepoStoreDir(repoRoot), filename);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns true if this repo has been registered in the store (has a meta.json).
|
|
82
|
+
* Unlike getRepoStoreDir(), this never creates any directories.
|
|
83
|
+
*/
|
|
84
|
+
function isRepoKnown(repoRoot) {
|
|
85
|
+
const { identifier } = getRepoIdentity(repoRoot);
|
|
86
|
+
const id = crypto.createHash('sha256').update(identifier).digest('hex').slice(0, 16);
|
|
87
|
+
const metaPath = path.join(REPOS_DIR, id, 'meta.json');
|
|
88
|
+
return fs.existsSync(metaPath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Meta (human-readable index of what repo each ID belongs to) ───────────
|
|
92
|
+
|
|
93
|
+
function updateMeta(repoRoot, scanData = null) {
|
|
94
|
+
const dir = getRepoStoreDir(repoRoot);
|
|
95
|
+
const metaPath = path.join(dir, 'meta.json');
|
|
96
|
+
const identity = getRepoIdentity(repoRoot);
|
|
97
|
+
|
|
98
|
+
// Preserve existing meta fields (e.g. lastScan) if file exists
|
|
99
|
+
let existing = {};
|
|
100
|
+
try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch {}
|
|
101
|
+
|
|
102
|
+
const meta = {
|
|
103
|
+
...existing,
|
|
104
|
+
repoId: path.basename(dir),
|
|
105
|
+
type: identity.type, // 'remote' | 'path-based'
|
|
106
|
+
remote: identity.type === 'remote' ? identity.identifier : null,
|
|
107
|
+
localPath: path.resolve(repoRoot),
|
|
108
|
+
name: path.basename(path.resolve(repoRoot)),
|
|
109
|
+
lastSeen: new Date().toISOString(),
|
|
110
|
+
// Preserve removed flag if set — cleared on re-init
|
|
111
|
+
removed: existing.removed || false,
|
|
112
|
+
removedAt: existing.removedAt || null,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Re-init clears the removed flag
|
|
116
|
+
if (!scanData) {
|
|
117
|
+
meta.removed = false;
|
|
118
|
+
meta.removedAt = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (scanData) {
|
|
122
|
+
meta.lastScan = new Date().toISOString();
|
|
123
|
+
meta.lastScanFindings = scanData.findingCount ?? null;
|
|
124
|
+
meta.lastScanCritical = scanData.criticalCount ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
|
|
128
|
+
return meta;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Public path accessors ──────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const FILES = {
|
|
134
|
+
CONFIG: 'config.yml',
|
|
135
|
+
SCOPE: 'scope.yml',
|
|
136
|
+
SCOPE_SERVER: 'scope-server.yml',
|
|
137
|
+
AUDIT: 'audit.log',
|
|
138
|
+
AUDIT_SUMMARY: 'audit-summary.log',
|
|
139
|
+
SCAN_CACHE: 'last-scan.json',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function configPath(repoRoot) { return getFilePath(repoRoot, FILES.CONFIG); }
|
|
143
|
+
function auditPath(repoRoot) { return getFilePath(repoRoot, FILES.AUDIT); }
|
|
144
|
+
function auditSummaryPath(repoRoot) { return getFilePath(repoRoot, FILES.AUDIT_SUMMARY); }
|
|
145
|
+
function scanCachePath(repoRoot) { return getFilePath(repoRoot, FILES.SCAN_CACHE); }
|
|
146
|
+
function scopePath(repoRoot) { return getFilePath(repoRoot, FILES.SCOPE); }
|
|
147
|
+
function serverScopePath(repoRoot) { return getFilePath(repoRoot, FILES.SCOPE_SERVER); }
|
|
148
|
+
function globalScopePath() { return path.join(GLOBAL_DIR, FILES.SCOPE); }
|
|
149
|
+
function storeDir(repoRoot) { return getRepoStoreDir(repoRoot); }
|
|
150
|
+
|
|
151
|
+
function reportsDir(repoRoot) {
|
|
152
|
+
const dir = path.join(getRepoStoreDir(repoRoot), 'reports');
|
|
153
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
154
|
+
return dir;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function reportPath(repoRoot, filename) {
|
|
158
|
+
return path.join(reportsDir(repoRoot), filename);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function scansDir(repoRoot) {
|
|
162
|
+
const dir = path.join(getRepoStoreDir(repoRoot), 'scans');
|
|
163
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
164
|
+
return dir;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scanPath(repoRoot, scanId) {
|
|
168
|
+
return path.join(scansDir(repoRoot), scanId + '.json');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function exportsDir(repoRoot) {
|
|
172
|
+
const dir = path.join(getRepoStoreDir(repoRoot), 'exports');
|
|
173
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
174
|
+
return dir;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function exportPath(repoRoot, filename) {
|
|
178
|
+
return path.join(exportsDir(repoRoot), filename);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* List all saved scans for a repo, newest first.
|
|
183
|
+
*/
|
|
184
|
+
function listScans(repoRoot) {
|
|
185
|
+
const dir = scansDir(repoRoot);
|
|
186
|
+
if (!fs.existsSync(dir)) return [];
|
|
187
|
+
return fs.readdirSync(dir)
|
|
188
|
+
.filter(f => f.endsWith('.json'))
|
|
189
|
+
.map(f => {
|
|
190
|
+
const full = path.join(dir, f);
|
|
191
|
+
const stat = fs.statSync(full);
|
|
192
|
+
const scanId = f.replace('.json', '');
|
|
193
|
+
let meta = { scanId, scanDate: null, totalFiles: 0, hasDeep: false, findingCount: 0 };
|
|
194
|
+
try {
|
|
195
|
+
const d = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
196
|
+
meta = {
|
|
197
|
+
scanId,
|
|
198
|
+
scanDate: d.scanDate || null,
|
|
199
|
+
totalFiles: d.totalFiles || 0,
|
|
200
|
+
findingCount: (d.findings || []).length,
|
|
201
|
+
hasDeep: !!(d.deepResults && d.deepResults.length > 0),
|
|
202
|
+
size: stat.size,
|
|
203
|
+
};
|
|
204
|
+
} catch {}
|
|
205
|
+
return meta;
|
|
206
|
+
})
|
|
207
|
+
.sort((a, b) => (b.scanDate || '').localeCompare(a.scanDate || ''));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function listReports(repoRoot) {
|
|
211
|
+
const dir = path.join(getRepoStoreDir(repoRoot), 'reports');
|
|
212
|
+
if (!fs.existsSync(dir)) return [];
|
|
213
|
+
return fs.readdirSync(dir)
|
|
214
|
+
.filter(f => /\.(html|md|json)$/.test(f))
|
|
215
|
+
.map(f => {
|
|
216
|
+
const full = path.join(dir, f);
|
|
217
|
+
const stat = fs.statSync(full);
|
|
218
|
+
return { filename: f, path: full, size: stat.size, mtime: stat.mtime.toISOString() };
|
|
219
|
+
})
|
|
220
|
+
.sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* List all known repos in the global store.
|
|
225
|
+
* Used by scd doctor and future scd export.
|
|
226
|
+
*/
|
|
227
|
+
function listRepos() {
|
|
228
|
+
if (!fs.existsSync(REPOS_DIR)) return [];
|
|
229
|
+
return fs.readdirSync(REPOS_DIR)
|
|
230
|
+
.filter(id => {
|
|
231
|
+
// Only process directories – ignore .DS_Store and other stray files
|
|
232
|
+
try { return fs.statSync(path.join(REPOS_DIR, id)).isDirectory(); }
|
|
233
|
+
catch { return false; }
|
|
234
|
+
})
|
|
235
|
+
.map(id => {
|
|
236
|
+
const metaFile = path.join(REPOS_DIR, id, 'meta.json');
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(fs.readFileSync(metaFile, 'utf8'));
|
|
239
|
+
} catch {
|
|
240
|
+
return { repoId: id, remote: null, localPath: null, name: id };
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function updateLastSynced(repoRoot, handledIds = []) {
|
|
246
|
+
const metaPath = path.join(getRepoStoreDir(repoRoot), 'meta.json');
|
|
247
|
+
let existing = {};
|
|
248
|
+
try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch {}
|
|
249
|
+
existing.lastSynced = new Date().toISOString();
|
|
250
|
+
// Accumulate handled IDs so getSyncNotice can exclude them
|
|
251
|
+
const prev = Array.isArray(existing.handledExceptionIds) ? existing.handledExceptionIds : [];
|
|
252
|
+
existing.handledExceptionIds = [...new Set([...prev, ...handledIds])];
|
|
253
|
+
fs.writeFileSync(metaPath, JSON.stringify(existing, null, 2), 'utf8');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readMeta(repoRoot) {
|
|
257
|
+
const metaPath = path.join(getRepoStoreDir(repoRoot), 'meta.json');
|
|
258
|
+
try { return JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return {}; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
// ── Machine fingerprint ────────────────────────────────────────────────────
|
|
263
|
+
// Stable installation identity derived from hardware characteristics.
|
|
264
|
+
// Mirrors the logic in scd-server/lib/auth.js getMachineFingerprint()
|
|
265
|
+
// so the server can correlate CLI events with its own fingerprint records.
|
|
266
|
+
// Also used as added_by identifier in scope.yml exclusion entries.
|
|
267
|
+
|
|
268
|
+
function getMachineFingerprint() {
|
|
269
|
+
try {
|
|
270
|
+
const hostname = os.hostname();
|
|
271
|
+
const platform = os.platform();
|
|
272
|
+
const cpus = os.cpus();
|
|
273
|
+
const cpuModel = cpus.length > 0 ? cpus[0].model : 'unknown';
|
|
274
|
+
const cpuCount = String(cpus.length);
|
|
275
|
+
const raw = `${hostname}|${platform}|${cpuModel}|${cpuCount}`;
|
|
276
|
+
return 'fp-' + crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
277
|
+
} catch {
|
|
278
|
+
return 'fp-unknown';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = {
|
|
283
|
+
getRepoId,
|
|
284
|
+
getRepoIdentity,
|
|
285
|
+
isRepoKnown,
|
|
286
|
+
getMachineFingerprint,
|
|
287
|
+
updateMeta,
|
|
288
|
+
updateLastSynced,
|
|
289
|
+
readMeta,
|
|
290
|
+
configPath,
|
|
291
|
+
auditPath,
|
|
292
|
+
auditSummaryPath,
|
|
293
|
+
scanCachePath,
|
|
294
|
+
scopePath,
|
|
295
|
+
serverScopePath,
|
|
296
|
+
globalScopePath,
|
|
297
|
+
storeDir,
|
|
298
|
+
scansDir,
|
|
299
|
+
scanPath,
|
|
300
|
+
listScans,
|
|
301
|
+
reportsDir,
|
|
302
|
+
reportPath,
|
|
303
|
+
listReports,
|
|
304
|
+
exportsDir,
|
|
305
|
+
exportPath,
|
|
306
|
+
listRepos,
|
|
307
|
+
GLOBAL_DIR,
|
|
308
|
+
REPOS_DIR,
|
|
309
|
+
FILES,
|
|
310
|
+
};
|