@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,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { RESET, DIM, GREEN, CYAN, RED } = require('../output-constants');
|
|
3
|
+
// lib/commands/report.js
|
|
4
|
+
|
|
5
|
+
module.exports = { register };
|
|
6
|
+
|
|
7
|
+
// ── shared: load scan cache ───────────────────────────────────────────────────
|
|
8
|
+
function loadReportCache(repoRoot, scanId) {
|
|
9
|
+
const { loadCache, loadScan } = require('../scan-cache');
|
|
10
|
+
if (scanId) {
|
|
11
|
+
const cache = loadScan(repoRoot, scanId);
|
|
12
|
+
if (!cache) {
|
|
13
|
+
console.error('\n' + RED + '✗ Scan not found: ' + scanId + RESET);
|
|
14
|
+
console.error(' Run ' + CYAN + 'scd repo scans' + RESET + ' to list available scans.\n');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
return cache;
|
|
18
|
+
}
|
|
19
|
+
const cache = loadCache(repoRoot);
|
|
20
|
+
if (!cache) {
|
|
21
|
+
console.error('\n' + RED + '✗ No saved scan found.' + RESET);
|
|
22
|
+
console.error(" Run 'scd scan' first to generate findings to report from.\n");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
return cache;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── shared: generate report file ─────────────────────────────────────────────
|
|
29
|
+
async function generateReportFile(repoRoot, opts) {
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const { cacheAge } = require('../scan-cache');
|
|
33
|
+
const store = require('../store');
|
|
34
|
+
|
|
35
|
+
const cache = loadReportCache(repoRoot, opts.scan);
|
|
36
|
+
const { findings, target, totalFiles, skipped, scanDate, deepResults } = cache;
|
|
37
|
+
const age = cacheAge(scanDate);
|
|
38
|
+
const deepMap = deepResults ? new Map(deepResults) : null;
|
|
39
|
+
|
|
40
|
+
console.log('\n' + DIM + '↺ Using cached scan from ' + age + ' (' + new Date(scanDate).toLocaleString('en-SE') + ')' + RESET);
|
|
41
|
+
console.log(' Target: ' + target + ' · ' + findings.length + ' findings · ' + totalFiles + ' files' +
|
|
42
|
+
(deepMap && deepMap.size > 0 ? ' · ' + CYAN + 'deep analysis included' + RESET : '') + '\n');
|
|
43
|
+
|
|
44
|
+
const fmt = (opts.format || 'html').toLowerCase();
|
|
45
|
+
const scanIdStr = cache.scanId || new Date(scanDate).toISOString().slice(0,19).replace(/:/g,'-');
|
|
46
|
+
const ext = fmt === 'markdown' ? 'md' : fmt;
|
|
47
|
+
const defaultName = 'security-report-' + scanIdStr + '.' + ext;
|
|
48
|
+
const outPath = opts.output
|
|
49
|
+
? path.resolve(process.cwd(), opts.output)
|
|
50
|
+
: store.reportPath(repoRoot, defaultName);
|
|
51
|
+
|
|
52
|
+
const metaPath = path.join(store.storeDir(repoRoot), 'meta.json');
|
|
53
|
+
let repoName = null;
|
|
54
|
+
try { repoName = JSON.parse(fs.readFileSync(metaPath, 'utf8')).name || null; } catch {}
|
|
55
|
+
if (!repoName) repoName = path.basename(path.resolve(repoRoot));
|
|
56
|
+
|
|
57
|
+
const reportOpts = {
|
|
58
|
+
target, repoName,
|
|
59
|
+
scanDate: new Date(scanDate),
|
|
60
|
+
totalFiles,
|
|
61
|
+
skipped: skipped || [],
|
|
62
|
+
repoRoot,
|
|
63
|
+
deepResults: deepMap,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (fmt === 'json') {
|
|
67
|
+
const { generateJson, writeJson } = require('../report-json');
|
|
68
|
+
writeJson(generateJson(findings, reportOpts), outPath);
|
|
69
|
+
console.log(GREEN + '✓ JSON report saved:' + RESET + ' ' + CYAN + outPath + RESET + '\n');
|
|
70
|
+
return { outPath, fmt };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (fmt === 'md' || fmt === 'markdown') {
|
|
74
|
+
const { generateMarkdown, writeMarkdown } = require('../report-markdown');
|
|
75
|
+
writeMarkdown(generateMarkdown(findings, reportOpts), outPath);
|
|
76
|
+
console.log(GREEN + '✓ Markdown report saved:' + RESET + ' ' + CYAN + outPath + RESET + '\n');
|
|
77
|
+
return { outPath, fmt };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// HTML (default)
|
|
81
|
+
const { generateReport, writeReport } = require('../report-html');
|
|
82
|
+
writeReport(generateReport(findings, reportOpts), outPath);
|
|
83
|
+
|
|
84
|
+
const term = process.env.TERM_PROGRAM || '';
|
|
85
|
+
const supportsOsc8 = ['iTerm.app', 'vscode', 'WarpTerminal', 'ghostty', 'JetBrains'].some(t => term.includes(t));
|
|
86
|
+
const fileUri = 'file://' + outPath;
|
|
87
|
+
if (supportsOsc8) {
|
|
88
|
+
const osc8Link = '\x1b]8;;' + fileUri + '\x07' + outPath + '\x1b]8;;\x07';
|
|
89
|
+
console.log(GREEN + '✓ HTML report:' + RESET + ' ' + CYAN + osc8Link + RESET);
|
|
90
|
+
} else {
|
|
91
|
+
console.log(GREEN + '✓ HTML report:' + RESET + ' ' + CYAN + outPath + RESET);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { outPath, fmt };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── shared: HTTP server logic ─────────────────────────────────────────────────
|
|
98
|
+
async function serveReport(repoRoot, outPath, opts) {
|
|
99
|
+
const http = require('http');
|
|
100
|
+
const fs = require('fs');
|
|
101
|
+
const path = require('path');
|
|
102
|
+
const { buildIndexPage } = require('../report-index');
|
|
103
|
+
const { openInBrowser } = require('../cli-helpers');
|
|
104
|
+
const store = require('../store');
|
|
105
|
+
|
|
106
|
+
const reportDir = store.reportsDir(repoRoot);
|
|
107
|
+
const reportFile = path.basename(outPath);
|
|
108
|
+
const allReports = store.listReports(repoRoot).filter(r => r.filename.endsWith('.html'));
|
|
109
|
+
const showIndex = opts.index || allReports.length > 1;
|
|
110
|
+
|
|
111
|
+
const getPort = () => new Promise((resolve, reject) => {
|
|
112
|
+
const srv = require('net').createServer();
|
|
113
|
+
srv.listen(opts.port ? parseInt(opts.port) : 0, () => {
|
|
114
|
+
const p = srv.address().port;
|
|
115
|
+
srv.close(() => resolve(p));
|
|
116
|
+
});
|
|
117
|
+
srv.on('error', reject);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const port = await getPort();
|
|
121
|
+
const baseUrl = 'http://localhost:' + port;
|
|
122
|
+
|
|
123
|
+
const server = http.createServer((req, res) => {
|
|
124
|
+
const url = decodeURIComponent(req.url.split('?')[0]);
|
|
125
|
+
|
|
126
|
+
if (url === '/download-all') {
|
|
127
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
128
|
+
res.end('<p style="font-family:monospace;padding:2rem;background:#0a0f1a;color:#e2e8f0">' +
|
|
129
|
+
'Reports folder: <code>' + reportDir + '</code></p>');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (url.startsWith('/download/')) {
|
|
133
|
+
const fname = path.basename(url.slice('/download/'.length));
|
|
134
|
+
const fpath = path.join(reportDir, fname);
|
|
135
|
+
if (!fpath.startsWith(reportDir) || !fs.existsSync(fpath)) { res.writeHead(404); res.end('Not found'); return; }
|
|
136
|
+
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="' + fname + '"' });
|
|
137
|
+
fs.createReadStream(fpath).pipe(res);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (url === '/' || url === '/index.html') {
|
|
141
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
142
|
+
res.end(buildIndexPage(allReports, reportDir, reportFile));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const fname = path.basename(url);
|
|
146
|
+
const fpath = path.join(reportDir, fname);
|
|
147
|
+
if (!fpath.startsWith(reportDir) || !fs.existsSync(fpath)) { res.writeHead(404); res.end('Not found'); return; }
|
|
148
|
+
const ext = path.extname(fpath).toLowerCase();
|
|
149
|
+
const mime = { '.html': 'text/html', '.json': 'application/json', '.md': 'text/plain' }[ext] || 'text/plain';
|
|
150
|
+
res.writeHead(200, { 'Content-Type': mime + '; charset=utf-8' });
|
|
151
|
+
fs.createReadStream(fpath).pipe(res);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
server.listen(port, '127.0.0.1', () => {
|
|
155
|
+
const openUrl = showIndex ? baseUrl + '/' : baseUrl + '/' + encodeURIComponent(reportFile);
|
|
156
|
+
if (showIndex) {
|
|
157
|
+
console.log(CYAN + '⇢ Report index: ' + baseUrl + '/' + RESET);
|
|
158
|
+
console.log(DIM + ' ' + allReports.length + ' report' + (allReports.length !== 1 ? 's' : '') + ' available' + RESET);
|
|
159
|
+
} else {
|
|
160
|
+
console.log(CYAN + '⇢ Serving report: ' + openUrl + RESET);
|
|
161
|
+
}
|
|
162
|
+
openInBrowser(openUrl);
|
|
163
|
+
console.log(DIM + ' Press any key to stop the server…' + RESET);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await new Promise((resolve) => {
|
|
167
|
+
const cleanup = () => {
|
|
168
|
+
server.close();
|
|
169
|
+
try { process.stdin.pause(); if (process.stdin.setRawMode) process.stdin.setRawMode(false); } catch {}
|
|
170
|
+
console.log(DIM + ' Server stopped.' + RESET + '\n');
|
|
171
|
+
resolve();
|
|
172
|
+
};
|
|
173
|
+
const hasRawMode = process.stdin.isTTY && typeof process.stdin.setRawMode === 'function';
|
|
174
|
+
if (hasRawMode) {
|
|
175
|
+
try { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.once('data', cleanup); }
|
|
176
|
+
catch { console.log(DIM + ' Press Ctrl-C to stop the server.' + RESET); }
|
|
177
|
+
} else {
|
|
178
|
+
console.log(DIM + ' Press Enter or Ctrl-C to stop the server.' + RESET);
|
|
179
|
+
process.stdin.resume();
|
|
180
|
+
process.stdin.once('data', cleanup);
|
|
181
|
+
}
|
|
182
|
+
process.once('SIGINT', cleanup);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function register(program) {
|
|
187
|
+
const { Command } = require('commander');
|
|
188
|
+
|
|
189
|
+
const reportCmd = new Command('report')
|
|
190
|
+
.description('Generate HTML, Markdown or JSON report from the last scan (without re-scanning)')
|
|
191
|
+
.option('--format <fmt>', 'Output format: html (default), md, json', 'html')
|
|
192
|
+
.option('--output <file>', 'Save to specified file (default: ~/.scd/repos/{id}/reports/security-report-{id}.html)')
|
|
193
|
+
.option('--scan <id>', 'Generate report from a specific scan ID (scd repo scans to list)')
|
|
194
|
+
.action(async (opts) => {
|
|
195
|
+
const { getRepoRoot } = require('../config');
|
|
196
|
+
const repoRoot = getRepoRoot();
|
|
197
|
+
await generateReportFile(repoRoot, opts);
|
|
198
|
+
console.log();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── scd report open ───────────────────────────────────────────────────────
|
|
202
|
+
const reportOpenCmd = new Command('open')
|
|
203
|
+
.description('Generate report and open in browser')
|
|
204
|
+
.option('--format <fmt>', 'Output format: html (default), md, json', 'html')
|
|
205
|
+
.option('--output <file>', 'Save to specified file')
|
|
206
|
+
.option('--scan <id>', 'Use a specific scan ID')
|
|
207
|
+
.action(async (opts) => {
|
|
208
|
+
const { getRepoRoot } = require('../config');
|
|
209
|
+
const { openInBrowser } = require('../cli-helpers');
|
|
210
|
+
const repoRoot = getRepoRoot();
|
|
211
|
+
const { outPath } = await generateReportFile(repoRoot, opts);
|
|
212
|
+
openInBrowser(outPath);
|
|
213
|
+
console.log();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── scd report serve ──────────────────────────────────────────────────────
|
|
217
|
+
const reportServeCmd = new Command('serve')
|
|
218
|
+
.description('Generate report and serve via local HTTP server (works on all platforms)')
|
|
219
|
+
.option('--format <fmt>', 'Output format: html (default), md, json', 'html')
|
|
220
|
+
.option('--output <file>', 'Save to specified file')
|
|
221
|
+
.option('--scan <id>', 'Use a specific scan ID')
|
|
222
|
+
.option('--port <port>', 'Port for HTTP server (default: random available port)')
|
|
223
|
+
.option('--index', 'Always show report index page')
|
|
224
|
+
.action(async (opts) => {
|
|
225
|
+
const { getRepoRoot } = require('../config');
|
|
226
|
+
const repoRoot = getRepoRoot();
|
|
227
|
+
const { outPath } = await generateReportFile(repoRoot, opts);
|
|
228
|
+
await serveReport(repoRoot, outPath, opts);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
reportCmd.addCommand(reportOpenCmd);
|
|
232
|
+
reportCmd.addCommand(reportServeCmd);
|
|
233
|
+
program.addCommand(reportCmd);
|
|
234
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/commands/resolve.js
|
|
3
|
+
|
|
4
|
+
module.exports = { register };
|
|
5
|
+
|
|
6
|
+
function register(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('resolve')
|
|
9
|
+
.description('Mark an EXPOSURE finding as handled, or remove a rejected exception by ID')
|
|
10
|
+
.option('--rule <id>', 'Rule ID (for EXPOSURE findings)')
|
|
11
|
+
.option('--file <path>', 'File path (for EXPOSURE findings)')
|
|
12
|
+
.option('--line <n>', 'Line number (for EXPOSURE findings)')
|
|
13
|
+
.option('--rejected <id>', 'Remove a rejected exception from local config by exception ID')
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
const { getRepoRoot } = require('../config');
|
|
16
|
+
const repoRoot = getRepoRoot();
|
|
17
|
+
if (opts.rejected) {
|
|
18
|
+
const { removeRejected } = require('../exception-manager');
|
|
19
|
+
removeRejected(repoRoot, opts.rejected);
|
|
20
|
+
} else {
|
|
21
|
+
const { resolveExposure } = require('../resolve-manager');
|
|
22
|
+
await resolveExposure(repoRoot, opts);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { RESET, BOLD, DIM, RED, YELLOW, BLUE, CYAN } = require('../output-constants');
|
|
3
|
+
// lib/commands/rules.js
|
|
4
|
+
|
|
5
|
+
module.exports = { register };
|
|
6
|
+
|
|
7
|
+
function register(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('rules')
|
|
10
|
+
.description('List, search and inspect security rules')
|
|
11
|
+
.option('--lang <langs>', 'Filter by language (js, ts, py, php, cs, aspx, all) — comma-separated')
|
|
12
|
+
.option('--severity <level>', 'Filter by severity (critical, high, medium, exposure)')
|
|
13
|
+
.option('--id <id>', 'Show full detail for a specific rule ID (e.g. INFRA-001)')
|
|
14
|
+
.option('--search <query>', 'Free-text search in ID, name, category, why, fix')
|
|
15
|
+
.option('--stats', 'Show rule counts by severity, language and category')
|
|
16
|
+
.option('--format <fmt>', 'Output format: table (default) | json')
|
|
17
|
+
.action((opts) => {
|
|
18
|
+
const { queryRules, getStats, getRegistry, SEV_ORDER } = require('../rule-registry');
|
|
19
|
+
|
|
20
|
+
const SEV_COLOR = {
|
|
21
|
+
CRITICAL: RED, // red
|
|
22
|
+
HIGH: YELLOW, // yellow
|
|
23
|
+
MEDIUM: BLUE, // blue
|
|
24
|
+
EXPOSURE: CYAN, // cyan
|
|
25
|
+
LOW: DIM, // dim
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const colorSev = (s) => (SEV_COLOR[s] || '') + s.padEnd(8) + RESET;
|
|
29
|
+
|
|
30
|
+
// ── JSON output ──────────────────────────────────────────────────────
|
|
31
|
+
if (opts.format === 'json') {
|
|
32
|
+
const rules = queryRules({ lang: opts.lang, severity: opts.severity,
|
|
33
|
+
id: opts.id, search: opts.search });
|
|
34
|
+
if (opts.stats) {
|
|
35
|
+
console.log(JSON.stringify(getStats(rules), null, 2));
|
|
36
|
+
} else {
|
|
37
|
+
console.log(JSON.stringify(rules, null, 2));
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Stats view ───────────────────────────────────────────────────────
|
|
43
|
+
if (opts.stats) {
|
|
44
|
+
const rules = queryRules({ lang: opts.lang, severity: opts.severity, search: opts.search });
|
|
45
|
+
const s = getStats(rules);
|
|
46
|
+
console.log('\n' + BOLD + 'Secure Code by Design – Rule stats' + RESET);
|
|
47
|
+
console.log(DIM + '─'.repeat(50) + RESET + '\n');
|
|
48
|
+
console.log(' Total rules: ' + BOLD + s.total + RESET + '\n');
|
|
49
|
+
|
|
50
|
+
console.log(' By severity:');
|
|
51
|
+
for (const [sev, n] of Object.entries(s.bySeverity).sort((a,b) => (SEV_ORDER[a[0]]??9)-(SEV_ORDER[b[0]]??9))) {
|
|
52
|
+
console.log(' ' + colorSev(sev) + ' ' + n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('\n By language:');
|
|
56
|
+
const langEntries = Object.entries(s.byLanguage).sort((a,b) => b[1]-a[1]);
|
|
57
|
+
for (const [lang, n] of langEntries) {
|
|
58
|
+
console.log(' ' + lang.padEnd(12) + DIM + n + RESET);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('\n By category:');
|
|
62
|
+
const catEntries = Object.entries(s.byCategory).sort((a,b) => b[1]-a[1]);
|
|
63
|
+
for (const [cat, n] of catEntries) {
|
|
64
|
+
const short = cat.replace(/\s*\(OWASP.*?\)/,'').trim();
|
|
65
|
+
console.log(' ' + short.padEnd(40) + DIM + n + RESET);
|
|
66
|
+
}
|
|
67
|
+
console.log();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Detail view (--id) ───────────────────────────────────────────────
|
|
72
|
+
if (opts.id) {
|
|
73
|
+
const rules = queryRules({ id: opts.id });
|
|
74
|
+
if (rules.length === 0) {
|
|
75
|
+
console.log('\nYELLOW Rule not found: ' + opts.id + RESET);
|
|
76
|
+
console.log(DIM + ' Use scd rules --search <term> to find rules.\n' + RESET);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const r = rules[0];
|
|
80
|
+
const sev = SEV_COLOR[r.severity] || '';
|
|
81
|
+
console.log('\n' + BOLD + r.id + RESET + ' ' + sev + r.severity + RESET);
|
|
82
|
+
console.log(DIM + '─'.repeat(60) + RESET);
|
|
83
|
+
console.log(BOLD + r.name + RESET + '\n');
|
|
84
|
+
console.log(' Category: ' + r.category);
|
|
85
|
+
console.log(' Languages: ' + r.languages.join(', '));
|
|
86
|
+
console.log(' Match: ' + r.matchMode);
|
|
87
|
+
|
|
88
|
+
if (r.why) {
|
|
89
|
+
console.log('\n' + BOLD + 'Why this matters' + RESET);
|
|
90
|
+
console.log(wordWrap(r.why, 70, ' '));
|
|
91
|
+
}
|
|
92
|
+
if (r.scenario) {
|
|
93
|
+
console.log('\n' + BOLD + 'Attack scenario' + RESET);
|
|
94
|
+
console.log(wordWrap(r.scenario, 70, ' '));
|
|
95
|
+
}
|
|
96
|
+
if (r.fix) {
|
|
97
|
+
console.log('\n' + BOLD + 'How to fix' + RESET);
|
|
98
|
+
console.log(wordWrap(r.fix, 70, ' '));
|
|
99
|
+
}
|
|
100
|
+
if (r.checklist && r.checklist.length) {
|
|
101
|
+
console.log('\n' + BOLD + 'Verification checklist' + RESET);
|
|
102
|
+
for (const item of r.checklist) {
|
|
103
|
+
console.log(' ☐ ' + item);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.log();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── List view (default) ──────────────────────────────────────────────
|
|
111
|
+
const rules = queryRules({ lang: opts.lang, severity: opts.severity, search: opts.search });
|
|
112
|
+
|
|
113
|
+
if (rules.length === 0) {
|
|
114
|
+
console.log('\n' + DIM + ' No rules match the given filters.\n' + RESET);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const title = buildTitle(opts);
|
|
119
|
+
console.log('\n' + BOLD + 'Secure Code by Design – Rules' + (title ? ' ' + DIM + title + RESET : '') + RESET);
|
|
120
|
+
console.log(DIM + '─'.repeat(90) + RESET);
|
|
121
|
+
|
|
122
|
+
// Column widths
|
|
123
|
+
const ID_W = 16, SEV_W = 10, LANG_W = 18, CAT_W = 32;
|
|
124
|
+
console.log(
|
|
125
|
+
DIM +
|
|
126
|
+
'ID'.padEnd(ID_W) +
|
|
127
|
+
'Severity'.padEnd(SEV_W) +
|
|
128
|
+
'Languages'.padEnd(LANG_W) +
|
|
129
|
+
'Category'.padEnd(CAT_W) +
|
|
130
|
+
'Name' +
|
|
131
|
+
RESET
|
|
132
|
+
);
|
|
133
|
+
console.log(DIM + '─'.repeat(90) + RESET);
|
|
134
|
+
|
|
135
|
+
// Group by category for readability
|
|
136
|
+
const byCategory = {};
|
|
137
|
+
for (const r of rules) {
|
|
138
|
+
const cat = r.category.replace(/\s*\(OWASP.*?\)/,'').trim();
|
|
139
|
+
if (!byCategory[cat]) byCategory[cat] = [];
|
|
140
|
+
byCategory[cat].push(r);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const [cat, catRules] of Object.entries(byCategory)) {
|
|
144
|
+
for (const r of catRules) {
|
|
145
|
+
const id = r.id.padEnd(ID_W);
|
|
146
|
+
const sev = (SEV_COLOR[r.severity]||'') + r.severity.padEnd(SEV_W - 1) + RESET + ' ';
|
|
147
|
+
const langs = r.languages.join(',').slice(0, LANG_W - 1).padEnd(LANG_W);
|
|
148
|
+
const category = cat.slice(0, CAT_W - 1).padEnd(CAT_W);
|
|
149
|
+
const name = r.name.slice(0, 46) + (r.name.length > 46 ? '…' : '');
|
|
150
|
+
console.log(id + sev + DIM + langs + RESET + DIM + category + RESET + name);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(DIM + '─'.repeat(90) + RESET);
|
|
155
|
+
console.log(' ' + rules.length + ' rule' + (rules.length !== 1 ? 's' : '') +
|
|
156
|
+
(rules.length < getRegistry().length ? ' (filtered from ' + getRegistry().length + ' total)' : ' total') + '\n');
|
|
157
|
+
console.log(DIM +
|
|
158
|
+
' scd rules --id <ID> full detail for a rule\n' +
|
|
159
|
+
' scd rules --lang php filter by language\n' +
|
|
160
|
+
' scd rules --severity critical filter by severity\n' +
|
|
161
|
+
' scd rules --search <term> free-text search\n' +
|
|
162
|
+
' scd rules --stats counts by severity / language / category\n' +
|
|
163
|
+
RESET);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function wordWrap(text, width, indent) {
|
|
168
|
+
const words = text.split(' ');
|
|
169
|
+
const lines = [];
|
|
170
|
+
let line = '';
|
|
171
|
+
for (const word of words) {
|
|
172
|
+
if ((line + word).length > width) { lines.push(indent + line.trim()); line = ''; }
|
|
173
|
+
line += word + ' ';
|
|
174
|
+
}
|
|
175
|
+
if (line.trim()) lines.push(indent + line.trim());
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildTitle(opts) {
|
|
180
|
+
const parts = [];
|
|
181
|
+
if (opts.lang) parts.push('lang=' + opts.lang);
|
|
182
|
+
if (opts.severity) parts.push('severity=' + opts.severity);
|
|
183
|
+
if (opts.search) parts.push('search="' + opts.search + '"');
|
|
184
|
+
return parts.join(' ');
|
|
185
|
+
}
|