@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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push-queue.js
|
|
3
|
+
* Offline-first push queue for scd events → scd-server.
|
|
4
|
+
*
|
|
5
|
+
* Events are always written to audit.log first (existing behaviour).
|
|
6
|
+
* When a central URL is configured, events are also queued here and
|
|
7
|
+
* flushed to the server on every scd command (non-blocking).
|
|
8
|
+
*
|
|
9
|
+
* Queue file: ~/.scd/push-queue.jsonl (one JSON object per line)
|
|
10
|
+
*
|
|
11
|
+
* Each entry:
|
|
12
|
+
* { id, ts, attempts, lastAttempt, event: { ...audit event } }
|
|
13
|
+
*
|
|
14
|
+
* Flush behaviour:
|
|
15
|
+
* - Sends all pending events as a single POST /api/v1/events/batch
|
|
16
|
+
* - On success: removes sent entries from queue
|
|
17
|
+
* - On failure: increments attempts, updates lastAttempt, keeps entry
|
|
18
|
+
* - Silent on network errors – never blocks the CLI
|
|
19
|
+
*
|
|
20
|
+
* Stale threshold: entries with attempts >= 10 are considered stale.
|
|
21
|
+
* scd doctor reports stale count; scd repo --verify --clean can purge.
|
|
22
|
+
* TODO: scd store/repo --verify --clean is currently per-repo but the push queue
|
|
23
|
+
* lives globally at ~/.scd — consider a global scd verify --clean command.
|
|
24
|
+
*
|
|
25
|
+
* Grace period: if all events are older than 7 days the user is warned
|
|
26
|
+
* by scd doctor (not by the push worker itself).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
const { DIM } = require('./output-constants');
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const os = require('os');
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
|
|
37
|
+
const QUEUE_PATH = path.join(os.homedir(), '.scd', 'push-queue.jsonl');
|
|
38
|
+
// Stale threshold: entries older than STALE_DAYS with at least one permanent failure
|
|
39
|
+
// are considered stale. Transient failures (network down, server unreachable) do NOT
|
|
40
|
+
// increment attempts and never cause entries to go stale.
|
|
41
|
+
const STALE_DAYS = 30;
|
|
42
|
+
const STALE_ATTEMPTS = 10; // kept for backwards compat with existing queue entries
|
|
43
|
+
const GRACE_DAYS = 7;
|
|
44
|
+
const BATCH_ENDPOINT = '/api/v1/events/batch';
|
|
45
|
+
|
|
46
|
+
// ── Machine fingerprint ──────────────────────────────────────────────────
|
|
47
|
+
const { getMachineFingerprint } = require('./store');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the meta object sent alongside events.
|
|
51
|
+
* Includes installation identity and repo context.
|
|
52
|
+
* repoRoot is optional — omitted when flush is called outside a repo context.
|
|
53
|
+
*/
|
|
54
|
+
function buildMeta(repoRoot) {
|
|
55
|
+
const meta = {
|
|
56
|
+
installationId: getMachineFingerprint(),
|
|
57
|
+
hostname: os.hostname(),
|
|
58
|
+
platform: os.platform(),
|
|
59
|
+
scdVersion: (() => {
|
|
60
|
+
try { return require('../package.json').version; } catch { return null; }
|
|
61
|
+
})(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (repoRoot) {
|
|
65
|
+
try {
|
|
66
|
+
const store = require('./store');
|
|
67
|
+
meta.repoId = store.getRepoId(repoRoot);
|
|
68
|
+
meta.repoName = path.basename(path.resolve(repoRoot));
|
|
69
|
+
// Include remote URL if available
|
|
70
|
+
const identity = store.getRepoIdentity ? store.getRepoIdentity(repoRoot) : null;
|
|
71
|
+
if (identity && identity.type === 'remote') {
|
|
72
|
+
meta.repoRemote = identity.identifier;
|
|
73
|
+
}
|
|
74
|
+
} catch { /* non-fatal */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return meta;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Queue file helpers ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function readQueue() {
|
|
83
|
+
if (!fs.existsSync(QUEUE_PATH)) return [];
|
|
84
|
+
try {
|
|
85
|
+
return fs.readFileSync(QUEUE_PATH, 'utf8')
|
|
86
|
+
.split('\n')
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.map(line => JSON.parse(line));
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeQueue(entries) {
|
|
95
|
+
try {
|
|
96
|
+
const dir = path.dirname(QUEUE_PATH);
|
|
97
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
98
|
+
const content = entries.map(e => JSON.stringify(e)).join('\n');
|
|
99
|
+
fs.writeFileSync(QUEUE_PATH, content ? content + '\n' : '', { encoding: 'utf8', mode: 0o600 });
|
|
100
|
+
} catch {
|
|
101
|
+
// Queue write failure is non-fatal — audit.log is the source of truth
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Add an audit event to the push queue.
|
|
109
|
+
* Called from audit.js whenever a scan completes.
|
|
110
|
+
*/
|
|
111
|
+
function enqueue(auditEvent) {
|
|
112
|
+
try {
|
|
113
|
+
const entry = {
|
|
114
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
115
|
+
ts: new Date().toISOString(),
|
|
116
|
+
attempts: 0,
|
|
117
|
+
lastAttempt: null,
|
|
118
|
+
event: auditEvent,
|
|
119
|
+
};
|
|
120
|
+
const dir = path.dirname(QUEUE_PATH);
|
|
121
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
fs.appendFileSync(QUEUE_PATH, JSON.stringify(entry) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
123
|
+
} catch {
|
|
124
|
+
// Non-fatal
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Flush pending events to scd-server.
|
|
130
|
+
* Returns a status string for optional display: 'sent', 'unreachable', 'empty', 'error'.
|
|
131
|
+
* Always resolves — never rejects.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} centralUrl e.g. 'https://security.company.internal:3000'
|
|
134
|
+
* @param {object} opts
|
|
135
|
+
* @param {boolean} opts.verbose print result to terminal
|
|
136
|
+
*/
|
|
137
|
+
async function flush(centralUrl, opts = {}) {
|
|
138
|
+
const entries = readQueue();
|
|
139
|
+
const pending = entries.filter(e => !isStale(e));
|
|
140
|
+
|
|
141
|
+
if (pending.length === 0) return 'empty';
|
|
142
|
+
|
|
143
|
+
const url = centralUrl.replace(/\/$/, '') + BATCH_ENDPOINT;
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
const token = opts.token || (() => {
|
|
146
|
+
try { return require('./global-config').getCentralToken(); } catch { return null; }
|
|
147
|
+
})();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const http = url.startsWith('https') ? require('https') : require('http');
|
|
151
|
+
const meta = buildMeta(opts.repoRoot || null);
|
|
152
|
+
const body = JSON.stringify({ events: pending.map(e => e.event), meta });
|
|
153
|
+
|
|
154
|
+
const response = await httpPost(http, url, body, token);
|
|
155
|
+
|
|
156
|
+
if (response.status >= 200 && response.status < 300) {
|
|
157
|
+
// Remove successfully sent entries, keep stale ones
|
|
158
|
+
const sentIds = new Set(pending.map(e => e.id));
|
|
159
|
+
const remaining = entries.filter(e => !sentIds.has(e.id));
|
|
160
|
+
writeQueue(remaining);
|
|
161
|
+
|
|
162
|
+
// Cache server version info for CLI version warning
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(response.body);
|
|
165
|
+
if (parsed.server_version || parsed.min_cli_version) {
|
|
166
|
+
require('./global-config').setServerVersionInfo(
|
|
167
|
+
parsed.server_version || null,
|
|
168
|
+
parsed.min_cli_version || null
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
} catch { /* non-fatal */ }
|
|
172
|
+
|
|
173
|
+
if (opts.verbose) {
|
|
174
|
+
console.log(`${DIM} ↑ Synced ${pending.length} queued event(s) to central${RESET}`);
|
|
175
|
+
}
|
|
176
|
+
return 'sent';
|
|
177
|
+
} else if (response.status === 503) {
|
|
178
|
+
// Server license invalid — do NOT bump attempts, keep events intact for retry
|
|
179
|
+
// Data is safe in queue and will sync automatically when license is restored
|
|
180
|
+
let isLicenseInvalid = false;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(response.body);
|
|
183
|
+
isLicenseInvalid = parsed.error === 'License invalid';
|
|
184
|
+
} catch { /* ignore parse error */ }
|
|
185
|
+
|
|
186
|
+
if (isLicenseInvalid) {
|
|
187
|
+
if (opts.verbose) {
|
|
188
|
+
console.log(`${DIM} ↑ Server license invalid – ${pending.length} event(s) held in queue${RESET}`);
|
|
189
|
+
}
|
|
190
|
+
return 'license_invalid';
|
|
191
|
+
}
|
|
192
|
+
// Other 503 — server temporarily unavailable, do NOT bump attempts
|
|
193
|
+
if (opts.verbose) {
|
|
194
|
+
console.log(`${DIM} ↑ Central unavailable (503) – ${pending.length} event(s) held in queue${RESET}`);
|
|
195
|
+
}
|
|
196
|
+
return 'unreachable';
|
|
197
|
+
} else {
|
|
198
|
+
// Server reachable but returned error — increment attempts
|
|
199
|
+
bumpAttempts(entries, pending, now);
|
|
200
|
+
if (opts.verbose) {
|
|
201
|
+
console.log(`${DIM} ↑ Central returned ${response.status} – ${pending.length} event(s) queued for next sync${RESET}`);
|
|
202
|
+
}
|
|
203
|
+
return 'error';
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// Network error — server unreachable (connection refused, DNS failure, timeout)
|
|
207
|
+
// Do NOT bump attempts — this is a transient failure, not a permanent error.
|
|
208
|
+
// Events stay in queue and will sync when server comes back online.
|
|
209
|
+
if (opts.verbose) {
|
|
210
|
+
console.log(`${DIM} ↑ Central unreachable – ${pending.length} event(s) held in queue${RESET}`);
|
|
211
|
+
}
|
|
212
|
+
return 'unreachable';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function bumpAttempts(allEntries, pendingEntries, now) {
|
|
217
|
+
const pendingIds = new Set(pendingEntries.map(e => e.id));
|
|
218
|
+
const updated = allEntries.map(e => {
|
|
219
|
+
if (!pendingIds.has(e.id)) return e;
|
|
220
|
+
return { ...e, attempts: e.attempts + 1, lastAttempt: now };
|
|
221
|
+
});
|
|
222
|
+
writeQueue(updated);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Determine if a queue entry is stale.
|
|
227
|
+
* An entry is stale if:
|
|
228
|
+
* - It has >= STALE_ATTEMPTS permanent failures (backwards compat), OR
|
|
229
|
+
* - It is older than STALE_DAYS days
|
|
230
|
+
* Transient failures (unreachable) do not increment attempts and are never stale
|
|
231
|
+
* unless they are very old.
|
|
232
|
+
*/
|
|
233
|
+
function isStale(entry) {
|
|
234
|
+
if (entry.attempts >= STALE_ATTEMPTS) return true;
|
|
235
|
+
const ageMs = Date.now() - new Date(entry.ts).getTime();
|
|
236
|
+
return ageMs > STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Number of events currently in queue (excluding stale).
|
|
241
|
+
*/
|
|
242
|
+
function queueSize() {
|
|
243
|
+
return readQueue().filter(e => !isStale(e)).length;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Number of stale events (attempts >= STALE_ATTEMPTS).
|
|
248
|
+
*/
|
|
249
|
+
function staleCount() {
|
|
250
|
+
return readQueue().filter(e => isStale(e)).length;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Whether all pending events are older than the grace period.
|
|
255
|
+
* Returns false if queue is empty.
|
|
256
|
+
*/
|
|
257
|
+
function isPastGrace() {
|
|
258
|
+
const pending = readQueue().filter(e => e.attempts < STALE_ATTEMPTS);
|
|
259
|
+
if (pending.length === 0) return false;
|
|
260
|
+
const oldest = pending.reduce((min, e) => e.ts < min ? e.ts : min, pending[0].ts);
|
|
261
|
+
const ageMs = Date.now() - new Date(oldest).getTime();
|
|
262
|
+
return ageMs > GRACE_DAYS * 24 * 60 * 60 * 1000;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Remove all stale entries from queue.
|
|
267
|
+
* Returns number of entries removed.
|
|
268
|
+
*/
|
|
269
|
+
function purgeStale() {
|
|
270
|
+
const entries = readQueue();
|
|
271
|
+
const clean = entries.filter(e => !isStale(e));
|
|
272
|
+
writeQueue(clean);
|
|
273
|
+
return entries.length - clean.length;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── HTTP helper (no external deps) ───────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function httpPost(http, urlStr, body, token = null) {
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const u = new URL(urlStr);
|
|
281
|
+
const headers = {
|
|
282
|
+
'Content-Type': 'application/json',
|
|
283
|
+
'Content-Length': Buffer.byteLength(body),
|
|
284
|
+
'User-Agent': 'scd-cli/1',
|
|
285
|
+
};
|
|
286
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
287
|
+
const { getServerTimeout } = require('./global-config');
|
|
288
|
+
const options = {
|
|
289
|
+
hostname: u.hostname,
|
|
290
|
+
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
291
|
+
path: u.pathname + u.search,
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers,
|
|
294
|
+
timeout: getServerTimeout(),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const req = http.request(options, (res) => {
|
|
298
|
+
let data = '';
|
|
299
|
+
res.on('data', chunk => { data += chunk; });
|
|
300
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
304
|
+
req.on('error', reject);
|
|
305
|
+
req.write(body);
|
|
306
|
+
req.end();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
enqueue,
|
|
312
|
+
flush,
|
|
313
|
+
queueSize,
|
|
314
|
+
staleCount,
|
|
315
|
+
isPastGrace,
|
|
316
|
+
purgeStale,
|
|
317
|
+
getMachineFingerprint,
|
|
318
|
+
buildMeta,
|
|
319
|
+
QUEUE_PATH,
|
|
320
|
+
STALE_ATTEMPTS,
|
|
321
|
+
GRACE_DAYS,
|
|
322
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remove-repo.js
|
|
3
|
+
* Removes scd integration from the current repo.
|
|
4
|
+
*
|
|
5
|
+
* Hooks are global (shared across all repos via core.hooksPath) so they
|
|
6
|
+
* are NOT removed — removing them would break all other repos on this machine.
|
|
7
|
+
* Instead, the repo is marked as inactive in the store.
|
|
8
|
+
*
|
|
9
|
+
* Scan history in ~/.scd/repos/{repoId}/ is kept by default.
|
|
10
|
+
* The user must explicitly type "yes" to delete it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
const { RESET, BOLD, DIM, RED, GREEN, YELLOW, CYAN } = require('./output-constants');
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
async function removeRepo(repoRoot) {
|
|
20
|
+
const store = require('./store');
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const repoId = store.getRepoId(repoRoot);
|
|
24
|
+
const storePath = store.storeDir(repoRoot);
|
|
25
|
+
const meta = store.readMeta(repoRoot);
|
|
26
|
+
const repoName = meta?.name || path.basename(repoRoot);
|
|
27
|
+
|
|
28
|
+
console.log('\n' + BOLD + 'scd remove' + RESET);
|
|
29
|
+
console.log(DIM + ' Repository: ' + repoName + RESET);
|
|
30
|
+
console.log(DIM + ' Store ID: ' + repoId + RESET);
|
|
31
|
+
console.log(DIM + ' Path: ' + repoRoot + RESET);
|
|
32
|
+
console.log('');
|
|
33
|
+
|
|
34
|
+
// Disable hooks for this repo on removal — repo is no longer under scd management.
|
|
35
|
+
// Sets local core.hooksPath to /dev/null so git hooks stop triggering,
|
|
36
|
+
// regardless of global config or any previous local override.
|
|
37
|
+
const { disableHooks } = require('./hooks-manager');
|
|
38
|
+
try {
|
|
39
|
+
disableHooks(repoRoot);
|
|
40
|
+
console.log(GREEN + ' ✓ Git hooks disabled for this repo' + RESET);
|
|
41
|
+
console.log(DIM + ' Run scd init to re-enable scanning and hooks' + RESET);
|
|
42
|
+
} catch {
|
|
43
|
+
console.log(DIM + ' Note: Could not disable hooks — run manually:' + RESET);
|
|
44
|
+
const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
45
|
+
console.log(DIM + ' git config --local core.hooksPath ' + nullDev + RESET);
|
|
46
|
+
}
|
|
47
|
+
console.log('');
|
|
48
|
+
|
|
49
|
+
// Mark as inactive in store
|
|
50
|
+
try {
|
|
51
|
+
const metaPath = path.join(storePath, 'meta.json');
|
|
52
|
+
let existing = {};
|
|
53
|
+
try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch {}
|
|
54
|
+
existing.removed = true;
|
|
55
|
+
existing.removedAt = new Date().toISOString();
|
|
56
|
+
fs.writeFileSync(metaPath, JSON.stringify(existing, null, 2), 'utf8');
|
|
57
|
+
console.log(GREEN + ' ✓ Repository marked as inactive' + RESET);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.log(YELLOW + ' ⚠ Could not update store: ' + err.message + RESET);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Ask about scan history
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log(' Scan history is stored in:');
|
|
65
|
+
console.log(DIM + ' ' + storePath + RESET);
|
|
66
|
+
console.log('');
|
|
67
|
+
console.log(DIM + ' Keeping history lets you run ' + CYAN + 'scd init' + DIM + ' again and continue' + RESET);
|
|
68
|
+
console.log(DIM + ' from where you left off. You can also clean up later with:' + RESET);
|
|
69
|
+
console.log(DIM + ' scd repo --verify --clean' + RESET);
|
|
70
|
+
console.log('');
|
|
71
|
+
|
|
72
|
+
const answer = await promptLine(
|
|
73
|
+
YELLOW + ' Delete scan history? Type "yes" to confirm, or press Enter to keep: ' + RESET
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (answer.trim().toLowerCase() === 'yes') {
|
|
77
|
+
try {
|
|
78
|
+
fs.rmSync(storePath, { recursive: true, force: true });
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(RED + ' ✓ Scan history deleted' + RESET);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.log(RED + ' ✗ Could not delete history: ' + err.message + RESET);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(DIM + ' ✓ Scan history kept' + RESET);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(GREEN + BOLD + ' Done.' + RESET);
|
|
91
|
+
console.log(DIM + ' Run ' + CYAN + 'scd init' + DIM + ' to re-register this repo.' + RESET);
|
|
92
|
+
console.log(DIM + ' Run ' + CYAN + 'scd repo --verify --clean' + DIM + ' to manage inactive repos.' + RESET);
|
|
93
|
+
console.log('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function promptLine(question) {
|
|
97
|
+
return new Promise(resolve => {
|
|
98
|
+
process.stdout.write(question);
|
|
99
|
+
process.stdin.setEncoding('utf8');
|
|
100
|
+
process.stdin.resume();
|
|
101
|
+
process.stdin.once('data', data => {
|
|
102
|
+
process.stdin.pause();
|
|
103
|
+
resolve(data.toString().trim());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { removeRepo };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/repo-context.js
|
|
5
|
+
*
|
|
6
|
+
* Reads project manifest files to build a repo context snapshot.
|
|
7
|
+
* Context is used locally for smarter rule targeting and sent to
|
|
8
|
+
* scd-server for dependency tracking, compliance reports, and
|
|
9
|
+
* future intel (CVE matching) features.
|
|
10
|
+
*
|
|
11
|
+
* Supported manifests:
|
|
12
|
+
* JavaScript/TypeScript: package.json
|
|
13
|
+
* Python: requirements.txt, pyproject.toml
|
|
14
|
+
* PHP: composer.json
|
|
15
|
+
* .NET: *.csproj
|
|
16
|
+
*
|
|
17
|
+
* Storage: ~/.scd/repos/{repoId}/repo-context.json
|
|
18
|
+
* Updated: at each scan, only if content changed.
|
|
19
|
+
* Server: sent as repo_context event via push-queue after scan.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
function parsePackageJson(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
28
|
+
const deps = {};
|
|
29
|
+
for (const [n, v] of Object.entries(pkg.dependencies || {})) deps[n] = { version: v, dev: false };
|
|
30
|
+
for (const [n, v] of Object.entries(pkg.devDependencies || {})) deps[n] = { version: v, dev: true };
|
|
31
|
+
|
|
32
|
+
const keys = Object.keys(pkg.dependencies || {});
|
|
33
|
+
const frameworks = [];
|
|
34
|
+
if (keys.includes('express')) frameworks.push('express');
|
|
35
|
+
if (keys.includes('fastify')) frameworks.push('fastify');
|
|
36
|
+
if (keys.includes('koa')) frameworks.push('koa');
|
|
37
|
+
if (keys.includes('next')) frameworks.push('next');
|
|
38
|
+
if (keys.includes('react')) frameworks.push('react');
|
|
39
|
+
if (keys.includes('vue')) frameworks.push('vue');
|
|
40
|
+
if (keys.some(k => k.startsWith('@nestjs'))) frameworks.push('nestjs');
|
|
41
|
+
if (keys.includes('typeorm')) frameworks.push('typeorm');
|
|
42
|
+
if (keys.includes('sequelize')) frameworks.push('sequelize');
|
|
43
|
+
if (keys.includes('mongoose')) frameworks.push('mongoose');
|
|
44
|
+
if (keys.includes('jsonwebtoken')) frameworks.push('jsonwebtoken');
|
|
45
|
+
|
|
46
|
+
return { language: 'javascript', manifest: 'package.json',
|
|
47
|
+
name: pkg.name || null, version: pkg.version || null, frameworks, dependencies: deps };
|
|
48
|
+
} catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseRequirementsTxt(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
const deps = {};
|
|
54
|
+
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
55
|
+
const t = line.trim();
|
|
56
|
+
if (!t || t.startsWith('#') || t.startsWith('-')) continue;
|
|
57
|
+
const m = t.match(/^([A-Za-z0-9_.-]+)\s*([><=!~]+\s*[\d.*]+)?/);
|
|
58
|
+
if (m) deps[m[1].toLowerCase()] = { version: m[2]?.trim() || '*', dev: false };
|
|
59
|
+
}
|
|
60
|
+
const keys = Object.keys(deps);
|
|
61
|
+
const frameworks = [];
|
|
62
|
+
if (keys.includes('flask')) frameworks.push('flask');
|
|
63
|
+
if (keys.includes('django')) frameworks.push('django');
|
|
64
|
+
if (keys.includes('fastapi')) frameworks.push('fastapi');
|
|
65
|
+
if (keys.includes('sqlalchemy')) frameworks.push('sqlalchemy');
|
|
66
|
+
if (keys.includes('pyjwt')) frameworks.push('pyjwt');
|
|
67
|
+
return { language: 'python', manifest: 'requirements.txt', frameworks, dependencies: deps };
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parsePyprojectToml(filePath) {
|
|
72
|
+
try {
|
|
73
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
74
|
+
const deps = {};
|
|
75
|
+
const sec = content.match(/\[(?:project\.dependencies|tool\.poetry\.dependencies)\]([\s\S]*?)(?=\[|$)/);
|
|
76
|
+
if (sec) {
|
|
77
|
+
for (const line of sec[1].split('\n')) {
|
|
78
|
+
const m = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']?([^"'\n]+)["']?/);
|
|
79
|
+
if (m) deps[m[1].toLowerCase()] = { version: m[2].trim(), dev: false };
|
|
80
|
+
const am = line.match(/["']([A-Za-z0-9_.-]+)\s*([><=!~]+\s*[\d.*]+)?["']/);
|
|
81
|
+
if (am && !m) deps[am[1].toLowerCase()] = { version: am[2]?.trim() || '*', dev: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!Object.keys(deps).length) return null;
|
|
85
|
+
const keys = Object.keys(deps);
|
|
86
|
+
const frameworks = ['flask','django','fastapi','sqlalchemy','pyjwt'].filter(f => keys.includes(f));
|
|
87
|
+
return { language: 'python', manifest: 'pyproject.toml', frameworks, dependencies: deps };
|
|
88
|
+
} catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseComposerJson(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
94
|
+
const deps = {};
|
|
95
|
+
for (const [n, v] of Object.entries(pkg.require || {})) { if (n !== 'php') deps[n] = { version: v, dev: false }; }
|
|
96
|
+
for (const [n, v] of Object.entries(pkg['require-dev'] || {})) deps[n] = { version: v, dev: true };
|
|
97
|
+
const keys = Object.keys(deps);
|
|
98
|
+
const frameworks = [];
|
|
99
|
+
if (keys.some(k => k.startsWith('laravel/'))) frameworks.push('laravel');
|
|
100
|
+
if (keys.some(k => k.startsWith('symfony/'))) frameworks.push('symfony');
|
|
101
|
+
if (keys.includes('slim/slim')) frameworks.push('slim');
|
|
102
|
+
if (keys.includes('doctrine/orm')) frameworks.push('doctrine');
|
|
103
|
+
return { language: 'php', manifest: 'composer.json', name: pkg.name || null, frameworks, dependencies: deps };
|
|
104
|
+
} catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseCsproj(filePath) {
|
|
108
|
+
try {
|
|
109
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
110
|
+
const deps = {};
|
|
111
|
+
for (const [, n, v] of content.matchAll(/<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/gi))
|
|
112
|
+
deps[n.toLowerCase()] = { version: v, dev: false };
|
|
113
|
+
if (!Object.keys(deps).length) return null;
|
|
114
|
+
const keys = Object.keys(deps);
|
|
115
|
+
const frameworks = [];
|
|
116
|
+
if (keys.some(k => k.includes('aspnetcore'))) frameworks.push('aspnetcore');
|
|
117
|
+
if (keys.some(k => k.includes('entityframework'))) frameworks.push('entityframework');
|
|
118
|
+
const tfm = content.match(/<TargetFramework[s]?>([^<]+)<\/TargetFramework[s]?>/i);
|
|
119
|
+
return { language: 'csharp', manifest: path.basename(filePath),
|
|
120
|
+
targetFramework: tfm ? tfm[1].trim() : null, frameworks, dependencies: deps };
|
|
121
|
+
} catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseRepoContext(repoRoot) {
|
|
125
|
+
const results = [];
|
|
126
|
+
function add(r) { if (r) results.push(r); }
|
|
127
|
+
|
|
128
|
+
add(parsePackageJson(path.join(repoRoot, 'package.json')));
|
|
129
|
+
const hasReqTxt = fs.existsSync(path.join(repoRoot, 'requirements.txt'));
|
|
130
|
+
if (hasReqTxt) add(parseRequirementsTxt(path.join(repoRoot, 'requirements.txt')));
|
|
131
|
+
else add(parsePyprojectToml(path.join(repoRoot, 'pyproject.toml')));
|
|
132
|
+
add(parseComposerJson(path.join(repoRoot, 'composer.json')));
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const csproj = fs.readdirSync(repoRoot).find(e => e.endsWith('.csproj'));
|
|
136
|
+
if (csproj) add(parseCsproj(path.join(repoRoot, csproj)));
|
|
137
|
+
} catch { /* ignore */ }
|
|
138
|
+
|
|
139
|
+
if (!results.length) return null;
|
|
140
|
+
|
|
141
|
+
const languages = [...new Set(results.map(r => r.language))];
|
|
142
|
+
const frameworks = [...new Set(results.flatMap(r => r.frameworks || []))];
|
|
143
|
+
const manifests = results.map(r => r.manifest);
|
|
144
|
+
|
|
145
|
+
const dependencies = {};
|
|
146
|
+
for (const r of results)
|
|
147
|
+
for (const [n, info] of Object.entries(r.dependencies || {}))
|
|
148
|
+
dependencies[`${r.language}:${n}`] = { ...info, language: r.language };
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
scannedAt: new Date().toISOString(),
|
|
152
|
+
languages,
|
|
153
|
+
frameworks,
|
|
154
|
+
manifestFiles: manifests,
|
|
155
|
+
manifests: results.map(r => ({
|
|
156
|
+
language: r.language,
|
|
157
|
+
manifest: r.manifest,
|
|
158
|
+
name: r.name || null,
|
|
159
|
+
version: r.version || null,
|
|
160
|
+
frameworks: r.frameworks || [],
|
|
161
|
+
targetFramework: r.targetFramework || null,
|
|
162
|
+
dependencyCount: Object.keys(r.dependencies || {}).length,
|
|
163
|
+
})),
|
|
164
|
+
dependencies,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function loadRepoContext(repoRoot) {
|
|
169
|
+
try {
|
|
170
|
+
const { storeDir } = require('./store');
|
|
171
|
+
const p = path.join(storeDir(repoRoot), 'repo-context.json');
|
|
172
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
|
|
173
|
+
} catch { return null; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function saveRepoContext(repoRoot, context) {
|
|
177
|
+
try {
|
|
178
|
+
const { storeDir } = require('./store');
|
|
179
|
+
const p = path.join(storeDir(repoRoot), 'repo-context.json');
|
|
180
|
+
const existing = loadRepoContext(repoRoot);
|
|
181
|
+
const sig = c => JSON.stringify({ languages: c.languages, frameworks: c.frameworks, dependencies: c.dependencies });
|
|
182
|
+
fs.writeFileSync(p, JSON.stringify(context, null, 2), 'utf8');
|
|
183
|
+
return !existing || sig(existing) !== sig(context);
|
|
184
|
+
} catch { return false; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { parseRepoContext, loadRepoContext, saveRepoContext };
|