@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.
Files changed (79) hide show
  1. package/LICENSE.md +35 -0
  2. package/README.md +417 -0
  3. package/bin/scd.js +140 -0
  4. package/lib/audit-report.js +93 -0
  5. package/lib/audit-sync.js +172 -0
  6. package/lib/audit.js +356 -0
  7. package/lib/cli-helpers.js +108 -0
  8. package/lib/commands/accept.js +28 -0
  9. package/lib/commands/audit.js +17 -0
  10. package/lib/commands/configure.js +200 -0
  11. package/lib/commands/doctor.js +14 -0
  12. package/lib/commands/exceptions.js +19 -0
  13. package/lib/commands/export-findings.js +46 -0
  14. package/lib/commands/findings.js +306 -0
  15. package/lib/commands/ignore.js +28 -0
  16. package/lib/commands/init.js +16 -0
  17. package/lib/commands/insights.js +24 -0
  18. package/lib/commands/install.js +15 -0
  19. package/lib/commands/list.js +109 -0
  20. package/lib/commands/remove.js +16 -0
  21. package/lib/commands/repo.js +862 -0
  22. package/lib/commands/report.js +234 -0
  23. package/lib/commands/resolve.js +25 -0
  24. package/lib/commands/rules.js +185 -0
  25. package/lib/commands/scan.js +519 -0
  26. package/lib/commands/scope.js +341 -0
  27. package/lib/commands/sync.js +40 -0
  28. package/lib/commands/uninstall.js +15 -0
  29. package/lib/commands/version.js +33 -0
  30. package/lib/comment-map.js +388 -0
  31. package/lib/config.js +325 -0
  32. package/lib/context-modifiers.js +211 -0
  33. package/lib/deep-analyzer.js +225 -0
  34. package/lib/doctor.js +236 -0
  35. package/lib/exception-manager.js +675 -0
  36. package/lib/export-findings.js +376 -0
  37. package/lib/file-context.js +380 -0
  38. package/lib/file-filter.js +204 -0
  39. package/lib/file-manifest.js +145 -0
  40. package/lib/git-utils.js +102 -0
  41. package/lib/global-config.js +239 -0
  42. package/lib/hooks-manager.js +130 -0
  43. package/lib/init-repo.js +147 -0
  44. package/lib/insights-analyzer.js +416 -0
  45. package/lib/insights-output.js +160 -0
  46. package/lib/installer.js +128 -0
  47. package/lib/output-constants.js +32 -0
  48. package/lib/output-terminal.js +407 -0
  49. package/lib/push-queue.js +322 -0
  50. package/lib/remove-repo.js +108 -0
  51. package/lib/repo-context.js +187 -0
  52. package/lib/report-html.js +1154 -0
  53. package/lib/report-index.js +157 -0
  54. package/lib/report-json.js +136 -0
  55. package/lib/report-markdown.js +250 -0
  56. package/lib/resolve-manager.js +148 -0
  57. package/lib/rule-registry.js +205 -0
  58. package/lib/scan-cache.js +171 -0
  59. package/lib/scan-context.js +312 -0
  60. package/lib/scan-schema.js +67 -0
  61. package/lib/scanner-full.js +681 -0
  62. package/lib/scanner-manual.js +348 -0
  63. package/lib/scanner-secrets.js +83 -0
  64. package/lib/scope.js +331 -0
  65. package/lib/store-verify.js +395 -0
  66. package/lib/store.js +310 -0
  67. package/lib/taint-register.js +196 -0
  68. package/lib/version-check.js +46 -0
  69. package/package.json +37 -0
  70. package/rules/rule-loader.js +324 -0
  71. package/rules/rules-aspx-cs.json +399 -0
  72. package/rules/rules-aspx.json +222 -0
  73. package/rules/rules-infra-leakage.json +434 -0
  74. package/rules/rules-js.json +664 -0
  75. package/rules/rules-php.json +521 -0
  76. package/rules/rules-python.json +466 -0
  77. package/rules/rules-secrets.json +99 -0
  78. package/rules/rules-sensitive-files.json +475 -0
  79. package/rules/rules-ts.json +76 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * report-index.js
3
+ * Generates the HTML index page for scd report --serve.
4
+ * Matches the visual theme of report-html.js.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ function buildIndexPage(reports, reportDir, currentFile) {
13
+ const meta = (() => {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(path.join(reportDir, '..', 'meta.json'), 'utf8'));
16
+ } catch { return {}; }
17
+ })();
18
+
19
+ const rows = reports.map(r => {
20
+ const date = (r.filename.match(/(\d{4}-\d{2}-\d{2})/) || [])[1] || '—';
21
+ const sizeStr = r.size > 1024 * 1024
22
+ ? (r.size / 1024 / 1024).toFixed(1) + ' MB'
23
+ : Math.round(r.size / 1024) + ' KB';
24
+ const age = Math.floor((Date.now() - new Date(r.mtime)) / 86400000);
25
+ const ageStr = age === 0 ? 'today' : age === 1 ? 'yesterday' : age + ' days ago';
26
+ const isCurrent = r.filename === currentFile;
27
+ const badge = isCurrent ? '<span class="badge-latest">latest</span>' : '';
28
+
29
+ return [
30
+ '<tr class="' + (isCurrent ? 'current' : '') + '">',
31
+ ' <td class="td-date"><span class="mono">' + date + '</span></td>',
32
+ ' <td class="td-name"><span class="mono">' + esc(r.filename) + '</span>' + badge + '</td>',
33
+ ' <td class="td-size muted">' + sizeStr + '</td>',
34
+ ' <td class="td-age muted">' + ageStr + '</td>',
35
+ ' <td class="td-actions">',
36
+ ' <a class="btn btn-open" href="/' + encodeURIComponent(r.filename) + '" target="_blank">Open</a>',
37
+ ' <a class="btn btn-dl" href="/download/' + encodeURIComponent(r.filename) + '" download="' + esc(r.filename) + '">↓</a>',
38
+ ' </td>',
39
+ '</tr>',
40
+ ].join('\n');
41
+ }).join('\n');
42
+
43
+ const dlAll = reports.length > 1
44
+ ? '<a class="btn btn-dl-all" href="/download-all">↓ Download all</a>'
45
+ : '';
46
+
47
+ const tableOrEmpty = reports.length === 0
48
+ ? '<div class="empty">No reports found. Run <code>scd report</code> to generate one.</div>'
49
+ : [
50
+ '<table>',
51
+ ' <thead><tr>',
52
+ ' <th>Date</th><th>File</th><th>Size</th><th>Age</th><th></th>',
53
+ ' </tr></thead>',
54
+ ' <tbody>' + rows + '</tbody>',
55
+ '</table>',
56
+ ].join('\n');
57
+
58
+ return [
59
+ '<!DOCTYPE html>',
60
+ '<html lang="en">',
61
+ '<head>',
62
+ ' <meta charset="UTF-8">',
63
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
64
+ ' <title>Secure Code by Design \u2013 Reports</title>',
65
+ ' <link rel="preconnect" href="https://fonts.googleapis.com">',
66
+ ' <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">',
67
+ ' <style>' + buildCSS() + ' </style>',
68
+ '</head>',
69
+ '<body>',
70
+ ' <div class="page">',
71
+ ' <div class="report-header">',
72
+ ' <div>',
73
+ ' <div class="brand">',
74
+ ' <div class="brand-icon">\uD83D\uDEE1</div>',
75
+ ' <div class="brand-name">Secure Code by Design</div>',
76
+ ' </div>',
77
+ ' <div class="repo-name">' + esc(meta.name || 'Reports') + '</div>',
78
+ meta.localPath ? ' <div class="repo-path">' + esc(meta.localPath) + '</div>' : '',
79
+ ' </div>',
80
+ ' </div>',
81
+ ' <div class="section-title">',
82
+ ' <span>' + reports.length + ' saved report' + (reports.length !== 1 ? 's' : '') + '</span>',
83
+ ' ' + dlAll,
84
+ ' </div>',
85
+ ' ' + tableOrEmpty,
86
+ ' </div>',
87
+ '</body>',
88
+ '</html>',
89
+ ].join('\n');
90
+ }
91
+
92
+ function buildCSS() {
93
+ return [
94
+ ' *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }',
95
+ ' :root {',
96
+ ' --bg: #0a0f1a;',
97
+ ' --surface: #0f172a;',
98
+ ' --surface2: #1e293b;',
99
+ ' --border: #1e293b;',
100
+ ' --text: #e2e8f0;',
101
+ ' --muted: #64748b;',
102
+ ' --accent: #38bdf8;',
103
+ ' }',
104
+ ' html { font-size: 15px; }',
105
+ ' body { background: var(--bg); color: var(--text); font-family: \'Syne\', sans-serif;',
106
+ ' line-height: 1.6; min-height: 100vh; }',
107
+ ' .page { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }',
108
+ ' .report-header { border-bottom: 1px solid var(--border); padding: 3rem 0 2rem;',
109
+ ' margin-bottom: 2.5rem; display: flex; align-items: center; gap: 2rem; }',
110
+ ' .brand { display: flex; align-items: center; gap: 1rem; }',
111
+ ' .brand-icon { width: 38px; height: 38px; background: var(--accent); border-radius: 8px;',
112
+ ' display: flex; align-items: center; justify-content: center; font-size: 1.2rem; }',
113
+ ' .brand-name { font-size: 0.85rem; font-weight: 700; letter-spacing: 0.1em;',
114
+ ' text-transform: uppercase; color: var(--muted); }',
115
+ ' .repo-name { font-size: 1.5rem; font-weight: 800; color: var(--text); margin-top: 0.4rem; }',
116
+ ' .repo-path { font-family: \'JetBrains Mono\', monospace; color: var(--muted);',
117
+ ' font-size: 0.78rem; margin-top: 0.3rem; }',
118
+ ' .section-title { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;',
119
+ ' text-transform: uppercase; color: var(--muted); margin-bottom: 1rem;',
120
+ ' display: flex; align-items: center; justify-content: space-between; }',
121
+ ' table { width: 100%; border-collapse: collapse; }',
122
+ ' th { font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;',
123
+ ' color: var(--muted); padding: 0 1rem 0.75rem; text-align: left;',
124
+ ' border-bottom: 1px solid var(--border); }',
125
+ ' td { padding: 0.85rem 1rem; border-bottom: 1px solid rgba(30,41,59,0.6); vertical-align: middle; }',
126
+ ' tr:hover td { background: var(--surface); }',
127
+ ' tr.current td { background: rgba(56,189,248,0.04); }',
128
+ ' tr:last-child td { border-bottom: none; }',
129
+ ' .mono { font-family: \'JetBrains Mono\', monospace; font-size: 0.82rem; }',
130
+ ' .muted { color: var(--muted); font-size: 0.85rem; }',
131
+ ' .td-actions { text-align: right; white-space: nowrap; }',
132
+ ' .badge-latest { font-size: 0.63rem; font-weight: 700; letter-spacing: 0.06em;',
133
+ ' text-transform: uppercase; background: rgba(56,189,248,0.15);',
134
+ ' color: var(--accent); border: 1px solid rgba(56,189,248,0.3);',
135
+ ' border-radius: 4px; padding: 0.1rem 0.4rem; margin-left: 0.5rem;',
136
+ ' vertical-align: middle; }',
137
+ ' .btn { display: inline-block; font-size: 0.78rem; font-weight: 600; border-radius: 6px;',
138
+ ' padding: 0.3rem 0.8rem; text-decoration: none; cursor: pointer; transition: opacity 0.15s; }',
139
+ ' .btn:hover { opacity: 0.8; }',
140
+ ' .btn-open { background: var(--accent); color: #0a0f1a; margin-right: 0.4rem; }',
141
+ ' .btn-dl { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }',
142
+ ' .btn-dl-all { background: var(--surface2); color: var(--text); border: 1px solid var(--border);',
143
+ ' font-size: 0.78rem; font-weight: 600; border-radius: 6px;',
144
+ ' padding: 0.3rem 0.9rem; text-decoration: none; }',
145
+ ' .empty { color: var(--muted); padding: 3rem 0; text-align: center; font-size: 0.9rem; }',
146
+ ].join('\n');
147
+ }
148
+
149
+ function esc(str) {
150
+ return String(str || '')
151
+ .replace(/&/g, '&amp;')
152
+ .replace(/</g, '&lt;')
153
+ .replace(/>/g, '&gt;')
154
+ .replace(/"/g, '&quot;');
155
+ }
156
+
157
+ module.exports = { buildIndexPage };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * report-json.js
3
+ * Generates a structured JSON security report from scan findings.
4
+ *
5
+ * Designed for:
6
+ * - CI/CD pipeline integration (fail build on CRITICAL count threshold)
7
+ * - Import into dashboards, ticketing systems, or SIEMs
8
+ * - Diffing findings between scans
9
+ *
10
+ * Structure:
11
+ * {
12
+ * meta: { scanDate, target, totalFiles, generatedBy, version }
13
+ * summary: { riskScore, riskLabel, totalFindings, bySeverity, byCategory }
14
+ * findings: [ { ruleId, severity, name, category, filePath, line, match, why, fix } ]
15
+ * checklist: [ { ruleId, severity, name, occurrences } ]
16
+ * }
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ // ── Risk scoring ───────────────────────────────────────────────────────────
25
+
26
+ const SEV_WEIGHT = { CRITICAL: 10, HIGH: 5, MEDIUM: 2, EXPOSURE: 1, INFO: 0 };
27
+ const SEV_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'EXPOSURE', 'INFO'];
28
+
29
+ function computeRiskScore(findings) {
30
+ if (!findings.length) return 0;
31
+ const raw = findings.reduce((sum, f) => sum + (SEV_WEIGHT[f.severity] || 0), 0);
32
+ return Math.min(100, Math.round(raw / Math.max(findings.length, 1) * 5));
33
+ }
34
+
35
+ function riskLabel(score) {
36
+ if (score >= 80) return 'CRITICAL';
37
+ if (score >= 55) return 'HIGH';
38
+ if (score >= 30) return 'MEDIUM';
39
+ if (score >= 10) return 'LOW';
40
+ return 'MINIMAL';
41
+ }
42
+
43
+ function countBySeverity(findings) {
44
+ const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, EXPOSURE: 0, INFO: 0 };
45
+ findings.forEach(f => { if (counts[f.severity] !== undefined) counts[f.severity]++; });
46
+ return counts;
47
+ }
48
+
49
+ function countByCategory(findings) {
50
+ const counts = {};
51
+ findings.forEach(f => {
52
+ const cat = f.category || 'Unknown';
53
+ counts[cat] = (counts[cat] || 0) + 1;
54
+ });
55
+ // Sort by count descending
56
+ return Object.fromEntries(
57
+ Object.entries(counts).sort(([, a], [, b]) => b - a)
58
+ );
59
+ }
60
+
61
+ function buildChecklist(findings) {
62
+ const seen = new Set();
63
+ const items = [];
64
+ for (const sev of SEV_ORDER) {
65
+ for (const f of findings.filter(x => x.severity === sev)) {
66
+ if (seen.has(f.ruleId)) continue;
67
+ seen.add(f.ruleId);
68
+ items.push({
69
+ ruleId: f.ruleId,
70
+ severity: f.severity,
71
+ name: f.name,
72
+ category: f.category || null,
73
+ occurrences: findings.filter(x => x.ruleId === f.ruleId).length,
74
+ resolved: false, // placeholder for tooling integration
75
+ });
76
+ }
77
+ }
78
+ return items;
79
+ }
80
+
81
+ // ── Main generator ─────────────────────────────────────────────────────────
82
+
83
+ function generateJson(findings, opts = {}) {
84
+ const {
85
+ target = '.',
86
+ scanDate = new Date(),
87
+ totalFiles = 0,
88
+ skipped = [],
89
+ repoRoot = process.cwd(),
90
+ } = opts;
91
+
92
+ const score = computeRiskScore(findings);
93
+
94
+ const report = {
95
+ meta: {
96
+ generatedBy: 'Secure Code by Design',
97
+ version: require('../package.json').version,
98
+ scanDate: new Date(scanDate).toISOString(),
99
+ target,
100
+ totalFiles,
101
+ skippedFiles: skipped.length,
102
+ },
103
+ summary: {
104
+ riskScore: score,
105
+ riskLabel: riskLabel(score),
106
+ totalFindings: findings.length,
107
+ bySeverity: countBySeverity(findings),
108
+ byCategory: countByCategory(findings),
109
+ },
110
+ findings: findings.map(f => ({
111
+ findingId: f.findingId || null,
112
+ ruleId: f.ruleId,
113
+ severity: f.severity,
114
+ name: f.name || null,
115
+ category: f.category || null,
116
+ filePath: f.filePath,
117
+ line: f.line,
118
+ match: f.match || null,
119
+ why: f.why || null,
120
+ scenario: f.scenario || null,
121
+ fix: f.fix || null,
122
+ excepted: f.excepted || false,
123
+ codeHash: f.codeHash || null,
124
+ })),
125
+ checklist: buildChecklist(findings),
126
+ };
127
+
128
+ return JSON.stringify(report, null, 2);
129
+ }
130
+
131
+ function writeJson(json, outPath) {
132
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
133
+ fs.writeFileSync(outPath, json, { encoding: 'utf8', mode: 0o644 });
134
+ }
135
+
136
+ module.exports = { generateJson, writeJson };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * report-markdown.js
3
+ * Generates a Markdown security report from scan findings.
4
+ *
5
+ * Designed for two use cases:
6
+ * 1. Pasting into GitHub Issues, Confluence, Notion, Jira, etc.
7
+ * 2. Committing as SECURITY.md or including in PR descriptions.
8
+ *
9
+ * Sections:
10
+ * - Executive summary (risk score, severity counts)
11
+ * - Findings by severity (CRITICAL → EXPOSURE)
12
+ * - Per-finding detail: file, line, why, fix
13
+ * - Remediation checklist
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // ── Risk scoring (same weights as report-html) ─────────────────────────────
22
+
23
+ const SEV_WEIGHT = { CRITICAL: 10, HIGH: 5, MEDIUM: 2, EXPOSURE: 1, INFO: 0 };
24
+ const SEV_EMOJI = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', EXPOSURE: '🔵', INFO: '⚪' };
25
+ const SEV_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'EXPOSURE', 'INFO'];
26
+
27
+ function computeRiskScore(findings) {
28
+ if (!findings.length) return 0;
29
+ const raw = findings.reduce((sum, f) => sum + (SEV_WEIGHT[f.severity] || 0), 0);
30
+ return Math.min(100, Math.round(raw / Math.max(findings.length, 1) * 5));
31
+ }
32
+
33
+ function riskLabel(score) {
34
+ if (score >= 80) return '🔴 CRITICAL RISK';
35
+ if (score >= 55) return '🟠 HIGH RISK';
36
+ if (score >= 30) return '🟡 MEDIUM RISK';
37
+ if (score >= 10) return '🟢 LOW RISK';
38
+ return '⚪ MINIMAL RISK';
39
+ }
40
+
41
+ function countBySeverity(findings) {
42
+ const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, EXPOSURE: 0, INFO: 0 };
43
+ findings.forEach(f => { if (counts[f.severity] !== undefined) counts[f.severity]++; });
44
+ return counts;
45
+ }
46
+
47
+ // ── Helpers ────────────────────────────────────────────────────────────────
48
+
49
+ function escMd(str) {
50
+ // Escape markdown special chars in inline text
51
+ return String(str || '').replace(/([`*_\[\]<>|\\])/g, '\\$1');
52
+ }
53
+
54
+ function shortPath(filePath, repoRoot) {
55
+ if (repoRoot && filePath.startsWith(repoRoot)) {
56
+ return filePath.slice(repoRoot.length).replace(/^[\\/]/, '');
57
+ }
58
+ return filePath;
59
+ }
60
+
61
+ function severityBar(counts) {
62
+ const parts = SEV_ORDER
63
+ .filter(s => counts[s] > 0)
64
+ .map(s => `${SEV_EMOJI[s]} **${counts[s]} ${s}**`);
65
+ return parts.join(' · ');
66
+ }
67
+
68
+ function groupBy(arr, key) {
69
+ return arr.reduce((acc, item) => {
70
+ const k = item[key] || 'Unknown';
71
+ (acc[k] = acc[k] || []).push(item);
72
+ return acc;
73
+ }, {});
74
+ }
75
+
76
+ // ── Remediation checklist ──────────────────────────────────────────────────
77
+
78
+ function buildChecklist(findings) {
79
+ // Deduplicate by ruleId – one checklist item per unique rule
80
+ const seen = new Set();
81
+ const items = [];
82
+ for (const sev of SEV_ORDER) {
83
+ const bySev = findings.filter(f => f.severity === sev);
84
+ for (const f of bySev) {
85
+ if (seen.has(f.ruleId)) continue;
86
+ seen.add(f.ruleId);
87
+ const count = findings.filter(x => x.ruleId === f.ruleId).length;
88
+ items.push({ sev, ruleId: f.ruleId, name: f.name, count });
89
+ }
90
+ }
91
+ return items;
92
+ }
93
+
94
+ // ── Main generator ─────────────────────────────────────────────────────────
95
+
96
+ function generateMarkdown(findings, opts = {}) {
97
+ const {
98
+ target = '.',
99
+ scanDate = new Date(),
100
+ totalFiles = 0,
101
+ skipped = [],
102
+ repoRoot = process.cwd(),
103
+ } = opts;
104
+
105
+ const counts = countBySeverity(findings);
106
+ const score = computeRiskScore(findings);
107
+ const risk = riskLabel(score);
108
+ const dateStr = new Date(scanDate).toLocaleDateString('sv-SE', {
109
+ year: 'numeric', month: 'long', day: 'numeric',
110
+ hour: '2-digit', minute: '2-digit',
111
+ });
112
+
113
+ const lines = [];
114
+
115
+ // ── Header ────────────────────────────────────────────────────────────────
116
+ lines.push(`# 🛡️ Secure Code by Design – Security Report`);
117
+ lines.push('');
118
+ lines.push(`> Genererad: **${dateStr}** · Target: \`${escMd(target)}\` · Filer: **${totalFiles}**`);
119
+ lines.push('');
120
+
121
+ // ── Executive summary ─────────────────────────────────────────────────────
122
+ lines.push('## Summary');
123
+ lines.push('');
124
+ lines.push(`| Property | Value |`);
125
+ lines.push(`|---|---|`);
126
+ lines.push(`| Risk assessment | ${risk} (${score}/100) |`);
127
+ lines.push(`| Total findings | **${findings.length}** |`);
128
+ lines.push(`| Files scanned | ${totalFiles} |`);
129
+ if (skipped.length > 0) {
130
+ lines.push(`| Skipped files | ${skipped.length} (too large) |`);
131
+ }
132
+ lines.push('');
133
+
134
+ // Severity breakdown
135
+ lines.push('### Findings by severity');
136
+ lines.push('');
137
+ lines.push('| Severity | Count | Description |');
138
+ lines.push('|---|---|---|');
139
+ const sevDesc = {
140
+ CRITICAL: 'Directly exploitable – fix immediately',
141
+ HIGH: 'High risk – fix within sprint',
142
+ MEDIUM: 'Medium risk – schedule remediation',
143
+ EXPOSURE: 'Configuration risk – may leak information',
144
+ INFO: 'Informational',
145
+ };
146
+ for (const sev of SEV_ORDER) {
147
+ if (counts[sev] === 0) continue;
148
+ lines.push(`| ${SEV_EMOJI[sev]} ${sev} | **${counts[sev]}** | ${sevDesc[sev]} |`);
149
+ }
150
+ lines.push('');
151
+
152
+ // ── Findings per severity ─────────────────────────────────────────────────
153
+ lines.push('---');
154
+ lines.push('');
155
+ lines.push('## Findings');
156
+ lines.push('');
157
+
158
+ for (const sev of SEV_ORDER) {
159
+ const sevFindings = findings.filter(f => f.severity === sev);
160
+ if (sevFindings.length === 0) continue;
161
+
162
+ lines.push(`### ${SEV_EMOJI[sev]} ${sev} (${sevFindings.length})`);
163
+ lines.push('');
164
+
165
+ // Group by rule for compact output
166
+ const byRule = groupBy(sevFindings, 'ruleId');
167
+
168
+ for (const [ruleId, ruleFindings] of Object.entries(byRule)) {
169
+ const first = ruleFindings[0];
170
+ lines.push(`#### ${escMd(ruleId)} – ${escMd(first.name)}`);
171
+ lines.push('');
172
+
173
+ if (first.category) {
174
+ lines.push(`**Kategori:** ${escMd(first.category)}`);
175
+ lines.push('');
176
+ }
177
+
178
+ // Affected locations
179
+ lines.push('**Occurrences:**');
180
+ lines.push('');
181
+ for (const f of ruleFindings) {
182
+ const fp = shortPath(f.filePath, repoRoot);
183
+ const matchSnippet = f.match ? ` \`${escMd(f.match.slice(0, 80).trim())}\`` : '';
184
+ lines.push(`- \`${escMd(fp)}\` rad **${f.line}**${matchSnippet}`);
185
+ }
186
+ lines.push('');
187
+
188
+ // Why
189
+ if (first.why) {
190
+ lines.push('<details>');
191
+ lines.push('<summary>Why is this a problem?</summary>');
192
+ lines.push('');
193
+ lines.push(first.why);
194
+ lines.push('');
195
+ lines.push('</details>');
196
+ lines.push('');
197
+ }
198
+
199
+ // Scenario
200
+ if (first.scenario) {
201
+ lines.push('<details>');
202
+ lines.push('<summary>Attackscenario</summary>');
203
+ lines.push('');
204
+ lines.push(`> ${first.scenario}`);
205
+ lines.push('');
206
+ lines.push('</details>');
207
+ lines.push('');
208
+ }
209
+
210
+ // Fix
211
+ if (first.fix) {
212
+ lines.push('**Fix:**');
213
+ lines.push('');
214
+ lines.push(`> ${first.fix}`);
215
+ lines.push('');
216
+ }
217
+
218
+ lines.push('---');
219
+ lines.push('');
220
+ }
221
+ }
222
+
223
+ // ── Remediation checklist ─────────────────────────────────────────────────
224
+ lines.push('## Remediation checklist');
225
+ lines.push('');
226
+ lines.push('Check off each finding when resolved:');
227
+ lines.push('');
228
+
229
+ const checklist = buildChecklist(findings);
230
+ for (const item of checklist) {
231
+ const plural = item.count > 1 ? ` _(${item.count} occurrences)_` : '';
232
+ lines.push(`- [ ] ${SEV_EMOJI[item.sev]} **${item.ruleId}** – ${escMd(item.name)}${plural}`);
233
+ }
234
+ lines.push('');
235
+
236
+ // ── Footer ────────────────────────────────────────────────────────────────
237
+ lines.push('---');
238
+ lines.push('');
239
+ lines.push(`_Report generated by **Secure Code by Design** · ${dateStr}_`);
240
+ lines.push('');
241
+
242
+ return lines.join('\n');
243
+ }
244
+
245
+ function writeMarkdown(md, outPath) {
246
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
247
+ fs.writeFileSync(outPath, md, { encoding: 'utf8', mode: 0o644 });
248
+ }
249
+
250
+ module.exports = { generateMarkdown, writeMarkdown };
@@ -0,0 +1,148 @@
1
+ const { RESET, BOLD, DIM, GREEN, YELLOW, CYAN } = require('./output-constants');
2
+ /**
3
+ * resolve-manager.js
4
+ * Interactive CLI for resolving EXPOSURE-class findings.
5
+ *
6
+ * Unlike exceptions (approve), a resolve means:
7
+ * "We have taken action outside the code to make this safe."
8
+ *
9
+ * Usage: scd resolve --rule FRONT-001 --file src/maps/config.js --line 3
10
+ *
11
+ * Creates a resolution record in ~/.scd/repos/{repoId}/config.yml under `resolutions:`
12
+ * and logs the event to the audit trail.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const readline = require('readline');
18
+ const { CONFIG_FILENAME, hashLine, EXPOSURE_RULES } = require('./config');
19
+ const { logEvent } = require('./audit');
20
+
21
+ // Import EXPOSURE_RULES for checklist display
22
+ let EXPOSURE_RULE_MAP = {};
23
+ try {
24
+ const { EXPOSURE_RULES } = require('./scanner-full');
25
+ for (const r of EXPOSURE_RULES) EXPOSURE_RULE_MAP[r.id] = r;
26
+ } catch { /* scanner-full may not be loaded yet */ }
27
+
28
+
29
+ async function resolveExposure(repoRoot, opts) {
30
+ const { rule, file, line } = opts;
31
+
32
+ if (!rule || !file || !line) {
33
+ console.log(`\nREDUsage: scd resolve --rule <id> --file <path> --line <n>${RESET}`);
34
+ console.log(`${DIM}Example: scd resolve --rule FRONT-001 --file src/maps/config.js --line 3${RESET}\n`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const lineNum = parseInt(line);
39
+ const filePath = path.resolve(repoRoot, file);
40
+
41
+ // Show which rule this is
42
+ const ruleInfo = EXPOSURE_RULE_MAP[rule];
43
+
44
+ console.log(`\n${CYAN}${BOLD}Secure Code by Design – Resolve EXPOSURE finding${RESET}`);
45
+ console.log(`${'─'.repeat(50)}`);
46
+ console.log(`Regel: ${rule}${ruleInfo ? ' – ' + ruleInfo.name : ''}`);
47
+ console.log(`Fil: ${file}:${lineNum}`);
48
+
49
+ // Show the actual line
50
+ if (fs.existsSync(filePath)) {
51
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
52
+ const lineContent = lines[lineNum - 1];
53
+ if (lineContent) {
54
+ console.log(`Kod: ${DIM}${lineContent.trim()}${RESET}`);
55
+ }
56
+ }
57
+
58
+ // Show checklist if available
59
+ if (ruleInfo?.checklist) {
60
+ console.log(`\n${BOLD}Confirm the following is in place:${RESET}`);
61
+ ruleInfo.checklist.forEach((item, i) => {
62
+ console.log(` ${YELLOW}☐${RESET} ${item}`);
63
+ });
64
+ }
65
+
66
+ console.log('');
67
+
68
+ // Prompt
69
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
70
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
71
+
72
+ const action_taken = await ask('Action taken (describe what was done): ');
73
+ const resolved_by = await ask('Hanterat av (e-post): ');
74
+ const reviewDays = await ask('Review in (days, Enter = 180): ');
75
+ rl.close();
76
+
77
+ const days = parseInt(reviewDays) || 180;
78
+ const review_date = new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
79
+ const resId = `res-${Date.now().toString(36)}`;
80
+
81
+ // Read line hash
82
+ let lineHash = null;
83
+ if (fs.existsSync(filePath)) {
84
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
85
+ const lineContent = lines[lineNum - 1];
86
+ if (lineContent) lineHash = hashLine(lineContent);
87
+ }
88
+
89
+ const resolution = {
90
+ id: resId,
91
+ rule,
92
+ file: file.replace(/\\/g, '/'),
93
+ line: lineNum,
94
+ line_hash: lineHash,
95
+ action_taken: action_taken.trim(),
96
+ resolved_by: resolved_by.trim(),
97
+ resolved_date: new Date().toISOString().slice(0, 10),
98
+ review_date,
99
+ };
100
+
101
+ writeResolution(repoRoot, resolution);
102
+
103
+ logEvent(repoRoot, 'exposure_resolved', {
104
+ resolution_id: resId,
105
+ rule,
106
+ file,
107
+ line: lineNum,
108
+ action_taken: action_taken.trim(),
109
+ resolved_by: resolved_by.trim(),
110
+ review_date,
111
+ });
112
+
113
+ console.log(`\n${GREEN}${BOLD}✅ Resolution recorded (${resId})${RESET}`);
114
+ console.log(`${DIM} Review due: ${review_date} (in ${days} days)${RESET}`);
115
+ console.log(`${DIM} Finding will show as "Resolved" in reports until review is due.${RESET}\n`);
116
+ }
117
+
118
+ function writeResolution(repoRoot, resolution) {
119
+ const configPath = require('./store').configPath(repoRoot);
120
+
121
+ let content = '';
122
+ if (fs.existsSync(configPath)) {
123
+ content = fs.readFileSync(configPath, 'utf8');
124
+ } else {
125
+ content = '# Secure Code by Design configuration\ntrust_level: balanced\n\n';
126
+ }
127
+
128
+ const block = `
129
+ - id: "${resolution.id}"
130
+ rule: "${resolution.rule}"
131
+ file: "${resolution.file}"
132
+ line: ${resolution.line}
133
+ line_hash: "${resolution.line_hash || ''}"
134
+ action_taken: "${resolution.action_taken}"
135
+ resolved_by: "${resolution.resolved_by}"
136
+ resolved_date: "${resolution.resolved_date}"
137
+ review_date: "${resolution.review_date}"`;
138
+
139
+ if (content.includes('\nresolutions:')) {
140
+ content = content.replace(/\nresolutions:\n/, `\nresolutions:\n${block}\n`);
141
+ } else {
142
+ content += `\nresolutions:${block}\n`;
143
+ }
144
+
145
+ fs.writeFileSync(configPath, content);
146
+ }
147
+
148
+ module.exports = { resolveExposure };