@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
package/lib/scope.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/scope.js
|
|
3
|
+
* Loads and applies scan scope exclusions from scope.yml files.
|
|
4
|
+
*
|
|
5
|
+
* Scope decisions are explicit, documented choices about what scd scans.
|
|
6
|
+
* They are distinct from operational config (config.yml) and are treated
|
|
7
|
+
* as security decisions — every entry requires reason, added_by, added_at.
|
|
8
|
+
*
|
|
9
|
+
* Three sources, merged in priority order (last wins on conflict):
|
|
10
|
+
* 1. ~/.scd/scope.yml — global, user-owned
|
|
11
|
+
* 2. ~/.scd/repos/{id}/scope.yml — repo, user-owned
|
|
12
|
+
* 3. ~/.scd/repos/{id}/scope-server.yml — server-owned, read-only
|
|
13
|
+
*
|
|
14
|
+
* Source 3 only applied when a server URL is configured (getCentralUrl()).
|
|
15
|
+
* Standalone-first: missing scope files are not errors.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
const { RESET, DIM } = require('./output-constants');
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const store = require('./store');
|
|
24
|
+
|
|
25
|
+
// ── YAML parser (hand-rolled, consistent with config.js) ─────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minimal YAML parser for scope.yml structure.
|
|
29
|
+
* Handles: top-level keys, list items (- key: value), nested keys under lists.
|
|
30
|
+
* Does not support anchors, aliases, or multi-document streams.
|
|
31
|
+
*/
|
|
32
|
+
function parseScope(yamlText) {
|
|
33
|
+
const lines = yamlText.split('\n');
|
|
34
|
+
const result = { file_excludes: [], rule_excludes: [] };
|
|
35
|
+
|
|
36
|
+
let section = null; // 'file_excludes' | 'rule_excludes'
|
|
37
|
+
let current = null; // current list entry being built
|
|
38
|
+
|
|
39
|
+
function finalise() {
|
|
40
|
+
if (!current) return;
|
|
41
|
+
if (section === 'file_excludes' && current.pattern) {
|
|
42
|
+
result.file_excludes.push(current);
|
|
43
|
+
} else if (section === 'rule_excludes' && current.rule) {
|
|
44
|
+
result.rule_excludes.push(current);
|
|
45
|
+
}
|
|
46
|
+
current = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const rawLine of lines) {
|
|
50
|
+
const line = rawLine.trimEnd();
|
|
51
|
+
if (!line || line.trimStart().startsWith('#')) continue;
|
|
52
|
+
|
|
53
|
+
// Top-level section headers
|
|
54
|
+
if (/^file_excludes\s*:/.test(line)) { finalise(); section = 'file_excludes'; continue; }
|
|
55
|
+
if (/^rule_excludes\s*:/.test(line)) { finalise(); section = 'rule_excludes'; continue; }
|
|
56
|
+
|
|
57
|
+
// List item start: " - key: value"
|
|
58
|
+
const listItemMatch = line.match(/^(\s+)-\s+(\w+)\s*:\s*(.*)/);
|
|
59
|
+
if (listItemMatch) {
|
|
60
|
+
finalise();
|
|
61
|
+
current = {};
|
|
62
|
+
const [, , key, val] = listItemMatch;
|
|
63
|
+
current[key] = unquote(val.trim());
|
|
64
|
+
if (section === 'rule_excludes' && key === 'rule' && !current.files) {
|
|
65
|
+
current.files = null;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Nested key under current list item: " key: value"
|
|
71
|
+
if (current) {
|
|
72
|
+
const nestedKeyMatch = line.match(/^(\s+)(\w+)\s*:\s*(.*)/);
|
|
73
|
+
if (nestedKeyMatch) {
|
|
74
|
+
const [, , key, val] = nestedKeyMatch;
|
|
75
|
+
const trimmedVal = val.trim();
|
|
76
|
+
|
|
77
|
+
// files: is a list — initialise it if we see the key with no value
|
|
78
|
+
if (key === 'files') {
|
|
79
|
+
current.files = trimmedVal ? [unquote(trimmedVal)] : [];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
current[key] = unquote(trimmedVal);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// List item under files: " - pattern"
|
|
88
|
+
const filesItemMatch = line.match(/^(\s+)-\s+(.*)/);
|
|
89
|
+
if (filesItemMatch && Array.isArray(current.files)) {
|
|
90
|
+
current.files.push(unquote(filesItemMatch[2].trim()));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
finalise();
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unquote(str) {
|
|
101
|
+
if (!str) return str;
|
|
102
|
+
if ((str.startsWith('"') && str.endsWith('"')) ||
|
|
103
|
+
(str.startsWith("'") && str.endsWith("'"))) {
|
|
104
|
+
return str.slice(1, -1);
|
|
105
|
+
}
|
|
106
|
+
return str;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Load a single scope file ──────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function loadScopeFile(filePath, source) {
|
|
112
|
+
if (!fs.existsSync(filePath)) return null;
|
|
113
|
+
try {
|
|
114
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
115
|
+
const parsed = parseScope(text);
|
|
116
|
+
// Tag each entry with its source
|
|
117
|
+
for (const e of parsed.file_excludes) e._source = source;
|
|
118
|
+
for (const e of parsed.rule_excludes) e._source = source;
|
|
119
|
+
return parsed;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(`${DIM}[scd] scope.yml warning (${source}): ${err.message}${RESET}`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Merge two scope objects (later wins on conflict) ──────────────────────
|
|
127
|
+
|
|
128
|
+
function mergeScope(base, override) {
|
|
129
|
+
if (!override) return base;
|
|
130
|
+
return {
|
|
131
|
+
file_excludes: [...base.file_excludes, ...override.file_excludes],
|
|
132
|
+
rule_excludes: [...base.rule_excludes, ...override.rule_excludes],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Load merged scope for a repo ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Load and merge scope from all three sources for a given repo root.
|
|
140
|
+
* Returns a merged scope object: { file_excludes, rule_excludes }
|
|
141
|
+
*
|
|
142
|
+
* Source priority (last wins): global → repo → server
|
|
143
|
+
*
|
|
144
|
+
* @param {string} repoRoot Absolute path to repo root
|
|
145
|
+
* @returns {{ file_excludes: Array, rule_excludes: Array }}
|
|
146
|
+
*/
|
|
147
|
+
function loadScope(repoRoot) {
|
|
148
|
+
let scope = { file_excludes: [], rule_excludes: [] };
|
|
149
|
+
|
|
150
|
+
// 1. Global scope (~/.scd/scope.yml)
|
|
151
|
+
const global = loadScopeFile(store.globalScopePath(), 'global');
|
|
152
|
+
if (global) scope = mergeScope(scope, global);
|
|
153
|
+
|
|
154
|
+
// 2. Repo scope (~/.scd/repos/{id}/scope.yml)
|
|
155
|
+
const repo = loadScopeFile(store.scopePath(repoRoot), 'repo');
|
|
156
|
+
if (repo) scope = mergeScope(scope, repo);
|
|
157
|
+
|
|
158
|
+
// 3. Server scope (~/.scd/repos/{id}/scope-server.yml)
|
|
159
|
+
// Only applied when a central URL is configured
|
|
160
|
+
try {
|
|
161
|
+
const { getCentralUrl } = require('./global-config');
|
|
162
|
+
if (getCentralUrl()) {
|
|
163
|
+
const server = loadScopeFile(store.serverScopePath(repoRoot), 'server');
|
|
164
|
+
if (server) scope = mergeScope(scope, server);
|
|
165
|
+
}
|
|
166
|
+
} catch { /* global-config unavailable — standalone mode */ }
|
|
167
|
+
|
|
168
|
+
return scope;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Pattern matching ───────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Convert a scope pattern to a match function.
|
|
175
|
+
* Supports:
|
|
176
|
+
* - Directory: "tests/fixtures/" or "tests/fixtures" → prefix match
|
|
177
|
+
* - Glob: "**\/*.test.js" → glob → regex
|
|
178
|
+
* - Exact: "bin/scd.js" → equality
|
|
179
|
+
*/
|
|
180
|
+
function makePatternMatcher(pattern) {
|
|
181
|
+
const norm = pattern.replace(/\\/g, '/').replace(/\/$/, '');
|
|
182
|
+
|
|
183
|
+
// Directory pattern (trailing slash in original, or contains no extension and no glob chars)
|
|
184
|
+
const isDir = pattern.endsWith('/');
|
|
185
|
+
if (isDir) {
|
|
186
|
+
return (rel) => rel === norm || rel.startsWith(norm + '/');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Glob pattern
|
|
190
|
+
if (pattern.includes('*') || pattern.includes('?')) {
|
|
191
|
+
const regexStr = norm
|
|
192
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
193
|
+
.replace(/\\\*/g, '__STAR__')
|
|
194
|
+
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
195
|
+
.replace(/__STAR__/g, '[^/]*')
|
|
196
|
+
.replace(/__DOUBLESTAR__/g, '.*')
|
|
197
|
+
.replace(/\?/g, '[^/]');
|
|
198
|
+
const re = new RegExp('^' + regexStr + '$');
|
|
199
|
+
return (rel) => re.test(rel);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Exact match (also matches as directory prefix)
|
|
203
|
+
return (rel) => rel === norm || rel.startsWith(norm + '/');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Public filter API ─────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if a file should be excluded by scope.file_excludes.
|
|
210
|
+
*
|
|
211
|
+
* @param {{ file_excludes: Array }} scope Merged scope object
|
|
212
|
+
* @param {string} absPath Absolute file path
|
|
213
|
+
* @param {string} repoRoot Repo root for relative path calculation
|
|
214
|
+
* @returns {{ excluded: boolean, entry: object|null }}
|
|
215
|
+
*/
|
|
216
|
+
function isFileExcluded(scope, absPath, repoRoot) {
|
|
217
|
+
if (!scope.file_excludes.length) return { excluded: false, entry: null };
|
|
218
|
+
|
|
219
|
+
// Normalise to forward-slash relative path for matching
|
|
220
|
+
const rel = path.relative(repoRoot, absPath).replace(/\\/g, '/');
|
|
221
|
+
|
|
222
|
+
for (const entry of scope.file_excludes) {
|
|
223
|
+
if (!entry.pattern) continue;
|
|
224
|
+
const matches = makePatternMatcher(entry.pattern);
|
|
225
|
+
if (matches(rel)) return { excluded: true, entry };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { excluded: false, entry: null };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if a finding should be excluded by scope.rule_excludes.
|
|
233
|
+
*
|
|
234
|
+
* @param {{ rule_excludes: Array }} scope Merged scope object
|
|
235
|
+
* @param {string} ruleId Finding rule ID
|
|
236
|
+
* @param {string} filePath Finding file path (relative)
|
|
237
|
+
* @returns {{ excluded: boolean, entry: object|null }}
|
|
238
|
+
*/
|
|
239
|
+
function isRuleExcluded(scope, ruleId, filePath) {
|
|
240
|
+
if (!scope.rule_excludes.length) return { excluded: false, entry: null };
|
|
241
|
+
|
|
242
|
+
for (const entry of scope.rule_excludes) {
|
|
243
|
+
if (!entry.rule || entry.rule !== ruleId) continue;
|
|
244
|
+
|
|
245
|
+
// No files list → exclude for all files
|
|
246
|
+
if (!entry.files || !entry.files.length) {
|
|
247
|
+
return { excluded: true, entry };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Files list → check if filePath matches any pattern
|
|
251
|
+
const normFile = (filePath || '').replace(/\\/g, '/');
|
|
252
|
+
for (const pattern of entry.files) {
|
|
253
|
+
const matches = makePatternMatcher(pattern);
|
|
254
|
+
if (matches(normFile)) return { excluded: true, entry };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { excluded: false, entry: null };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Validate a parsed scope object. Returns an array of warning objects
|
|
265
|
+
* for entries missing required fields (reason, added_by, added_at).
|
|
266
|
+
* Never throws — validation is advisory only.
|
|
267
|
+
*
|
|
268
|
+
* @param {{ file_excludes: Array, rule_excludes: Array }} scope
|
|
269
|
+
* @returns {Array<{ type: string, identifier: string, missing: string[] }>}
|
|
270
|
+
*/
|
|
271
|
+
function validateScope(scope) {
|
|
272
|
+
const warnings = [];
|
|
273
|
+
const REQUIRED = ['reason', 'added_by', 'added_at'];
|
|
274
|
+
|
|
275
|
+
for (const entry of scope.file_excludes) {
|
|
276
|
+
const missing = REQUIRED.filter(f => !entry[f]);
|
|
277
|
+
if (missing.length) {
|
|
278
|
+
warnings.push({ type: 'file_exclude', identifier: entry.pattern || '(unknown)', missing });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const entry of scope.rule_excludes) {
|
|
283
|
+
const missing = REQUIRED.filter(f => !entry[f]);
|
|
284
|
+
if (missing.length) {
|
|
285
|
+
warnings.push({ type: 'rule_exclude', identifier: entry.rule || '(unknown)', missing });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return warnings;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Scope summary for output ──────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build a human-readable summary of active scope exclusions.
|
|
296
|
+
* Used in terminal output and audit log.
|
|
297
|
+
*
|
|
298
|
+
* @param {{ file_excludes: Array, rule_excludes: Array }} scope
|
|
299
|
+
* @returns {{ hasExclusions: boolean, fileLines: string[], ruleLines: string[] }}
|
|
300
|
+
*/
|
|
301
|
+
function summariseScope(scope) {
|
|
302
|
+
const fileLines = scope.file_excludes.map(e => {
|
|
303
|
+
const reason = e.reason ? ` — "${e.reason}"` : ' — (no reason given)';
|
|
304
|
+
const meta = [e.added_by, e.added_at].filter(Boolean).join(', ');
|
|
305
|
+
const src = e._source ? ` [${e._source}]` : '';
|
|
306
|
+
return ` ${e.pattern}${reason} (${meta})${src}`;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const ruleLines = scope.rule_excludes.map(e => {
|
|
310
|
+
const scope_ = e.files && e.files.length ? e.files.join(', ') : 'all files';
|
|
311
|
+
const reason = e.reason ? ` — "${e.reason}"` : ' — (no reason given)';
|
|
312
|
+
const meta = [e.added_by, e.added_at].filter(Boolean).join(', ');
|
|
313
|
+
const src = e._source ? ` [${e._source}]` : '';
|
|
314
|
+
return ` ${e.rule} (${scope_})${reason} (${meta})${src}`;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
hasExclusions: fileLines.length > 0 || ruleLines.length > 0,
|
|
319
|
+
fileLines,
|
|
320
|
+
ruleLines,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
loadScope,
|
|
326
|
+
isFileExcluded,
|
|
327
|
+
isRuleExcluded,
|
|
328
|
+
validateScope,
|
|
329
|
+
summariseScope,
|
|
330
|
+
parseScope, // exported for testing
|
|
331
|
+
};
|