@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,147 @@
1
+ /**
2
+ * init-repo.js
3
+ * Initialises Secure Code by Design for a specific git repo.
4
+ *
5
+ * scd init – run once per repo, once per developer
6
+ *
7
+ * What it does:
8
+ * 1. Creates ~/.scd/repos/{repoId}/config.yml with defaults
9
+ * 2. Installs git hooks (pre-commit, pre-push) into .git/hooks/
10
+ *
11
+ * What it does NOT do:
12
+ * - Write any files into the repo itself
13
+ * - Modify .gitignore
14
+ * - Commit anything
15
+ *
16
+ * The repo remains completely untouched.
17
+ */
18
+
19
+ 'use strict';
20
+ const { RESET, BOLD, DIM, GREEN, YELLOW, CYAN } = require('./output-constants');
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const store = require('./store');
26
+ const { getRepoRoot } = require('./config');
27
+
28
+
29
+ const DEFAULT_CONFIG = `# ═══════════════════════════════════════════════════════════════
30
+ # Secure Code by Design – Per-repo configuration
31
+ # Stored in ~/.scd/repos/{repoId}/config.yml
32
+ # Never committed to the repository.
33
+ #
34
+ # Edit with: scd repo configure --<option> <value>
35
+ # View with: scd repo configure --show
36
+ # ═══════════════════════════════════════════════════════════════
37
+
38
+ # ── Trust level ──────────────────────────────────────────────────
39
+ # Controls what is sent to AI analysis (scd scan --deep)
40
+ # maximum_privacy – Everything runs locally, nothing sent externally
41
+ # balanced – Default. Anonymised patterns sent for deep analysis
42
+ # maximum_analysis – Full AI analysis via Claude API
43
+ trust_level: balanced
44
+
45
+ # ── Scan mode ────────────────────────────────────────────────────
46
+ # full (default) – All rules including taint analysis
47
+ # fast – Regex rules only, no taint analysis.
48
+ # Use for large codebases (800+ files) where scan time is a concern.
49
+ scan_mode: full
50
+
51
+ # ── Blocking behaviour ───────────────────────────────────────────
52
+ # CRITICAL always blocks commit/push (cannot be disabled)
53
+ # HIGH blocks push by default – set to false to warn only
54
+ block_on_critical: true
55
+ block_on_high: true
56
+
57
+ # ── Rule overrides ───────────────────────────────────────────────
58
+ # Change action for specific rules: block | warn | report
59
+ #
60
+ # Hardlocked rules (can NEVER be downgraded):
61
+ # SECRET-001 (AWS), SECRET-002 (OpenAI), SECRET-003 (GitHub),
62
+ # SECRET-006 (PEM), SECRET-007 (JWT secrets), JWT-001
63
+ #
64
+ # rule_overrides:
65
+ # SECRET-005:
66
+ # action: warn
67
+ # reason: "Test environment"
68
+
69
+ # ── Exceptions ───────────────────────────────────────────────────
70
+ # For findings that are conscious, documented decisions.
71
+ # Create interactively: scd approve --rule <id> --file <f> --line <n>
72
+ #
73
+ # exceptions:
74
+ # - id: "exc-001"
75
+ # rule: "FRONT-001"
76
+ # file: "src/maps/mapbox-config.js"
77
+ # line_range: [12, 12]
78
+ # line_hash: "sha256:a3f9b2c1d4e5f6a7"
79
+ # reason: "Mapbox public token – domain restriction enabled in Mapbox dashboard"
80
+ # approved_by: "cto@company.com"
81
+ # approved_date: "2026-03-01"
82
+ # expires: "2026-09-01"
83
+
84
+ # ── Resolutions (EXPOSURE findings) ─────────────────────────────
85
+ # For EXPOSURE findings handled at the service level.
86
+ # Create interactively: scd resolve --rule <id> --file <f> --line <n>
87
+ #
88
+ # resolutions:
89
+ # - id: "res-abc123"
90
+ # rule: "FRONT-002"
91
+ # file: "src/config/maps.js"
92
+ # line: 3
93
+ # line_hash: "sha256:b4c8d2e1f5a9"
94
+ # action_taken: "HTTP referrer restriction enabled in Google Cloud Console"
95
+ # resolved_by: "dev@company.com"
96
+ # resolved_date: "2026-03-01"
97
+ # review_date: "2026-09-01"
98
+ `;
99
+
100
+ async function initRepo(repoRoot) {
101
+ const storeDir = store.storeDir(repoRoot);
102
+ const configPath = store.configPath(repoRoot);
103
+
104
+ console.log(`\n${CYAN}${BOLD}Secure Code by Design – Initialising repo${RESET}`);
105
+ console.log(`${'─'.repeat(45)}`);
106
+ console.log(`${DIM}Repo: ${repoRoot}${RESET}`);
107
+ console.log(`${DIM}Store: ${storeDir}${RESET}\n`);
108
+
109
+ store.updateMeta(repoRoot);
110
+
111
+ // Config
112
+ if (fs.existsSync(configPath)) {
113
+ console.log(`${YELLOW}⚠️ Config already exists – not overwritten${RESET}`);
114
+ console.log(`${DIM} ${configPath}${RESET}`);
115
+ console.log(`${DIM} Delete it manually to re-initialise.${RESET}\n`);
116
+ } else {
117
+ fs.writeFileSync(configPath, DEFAULT_CONFIG, 'utf8');
118
+ console.log(`${GREEN}✓ Config created${RESET}`);
119
+ console.log(`${DIM} ${configPath}${RESET}`);
120
+ }
121
+
122
+ // Check if global hooks are installed — check files directly, not via getHookStatus()
123
+ // which requires a git repo context and returns 'not-a-git-repo' outside one.
124
+ const { execSync } = require('child_process');
125
+ const HOOKS_DIR = path.join(os.homedir(), '.scd', 'hooks');
126
+ const hooksFilesExist = fs.existsSync(path.join(HOOKS_DIR, 'pre-commit')) &&
127
+ fs.existsSync(path.join(HOOKS_DIR, 'pre-push'));
128
+ let globalHooksPath = null;
129
+ try {
130
+ globalHooksPath = execSync('git config --global core.hooksPath', { encoding: 'utf8' }).trim();
131
+ } catch { /* not set */ }
132
+ const hooksOk = hooksFilesExist && !!globalHooksPath;
133
+
134
+ console.log(`\n${BOLD}Next steps:${RESET}`);
135
+ let step = 1;
136
+ if (!hooksOk) {
137
+ console.log(` ${DIM}${step++}.${RESET} Run ${CYAN}scd install${RESET} to install global git hooks ${YELLOW}(not done yet)${RESET}`);
138
+ }
139
+ console.log(` ${DIM}${step++}.${RESET} Review and adjust the config if needed`);
140
+ console.log(` ${DIM}${step++}.${RESET} Run ${DIM}scd doctor${RESET} to verify the installation`);
141
+ console.log(` ${DIM}${step++}.${RESET} Run ${DIM}scd scan${RESET} to do your first scan\n`);
142
+
143
+ console.log(`${DIM}Note: nothing has been written to your repository.${RESET}`);
144
+ console.log(`${DIM} All data is stored in ${storeDir}${RESET}\n`);
145
+ }
146
+
147
+ module.exports = { initRepo };
@@ -0,0 +1,416 @@
1
+ /**
2
+ * insights-analyzer.js
3
+ * Analyses behavioural patterns in the audit log.
4
+ *
5
+ * Two modes:
6
+ * Local – statistics and pattern detection without external calls
7
+ * for deeper interpretation and concrete recommendations
8
+ *
9
+ * Detected patterns:
10
+ * 1. Recurring rules – same rule triggers repeatedly
11
+ * 2. Avoidance behaviour – high ratio of exceptions vs findings
12
+ * 3. Time patterns – more findings late in sprint
13
+ * 4. Kunskapsgap per kategori – dominanta OWASP-kategorier
14
+ * 5. File hotspots – files recurring in findings
15
+ * 6. Trend – improving or worsening?
16
+ * 7. Per-developer (if team) – individual patterns
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const { readAuditLog, EVENTS } = require('./audit');
22
+
23
+ // ── Helpers ────────────────────────────────────────────────────────────────
24
+
25
+ function topN(map, n = 5) {
26
+ return Object.entries(map)
27
+ .sort(([, a], [, b]) => b - a)
28
+ .slice(0, n)
29
+ .map(([k, v]) => ({ key: k, count: v }));
30
+ }
31
+
32
+ function weekOf(isoTs) {
33
+ const d = new Date(isoTs);
34
+ // ISO week: Monday-based
35
+ const day = d.getDay() || 7;
36
+ d.setDate(d.getDate() + 4 - day);
37
+ const yearStart = new Date(d.getFullYear(), 0, 1);
38
+ return `${d.getFullYear()}-W${String(Math.ceil(((d - yearStart) / 86400000 + 1) / 7)).padStart(2, '0')}`;
39
+ }
40
+
41
+ function hourOf(isoTs) {
42
+ return new Date(isoTs).getHours();
43
+ }
44
+
45
+ function dayOfWeek(isoTs) {
46
+ return new Date(isoTs).getDay(); // 0=Sun, 5=Fri, 6=Sat
47
+ }
48
+
49
+ function percent(part, total) {
50
+ if (!total) return 0;
51
+ return Math.round((part / total) * 100);
52
+ }
53
+
54
+ // ── Extrahera findings-events ─────────────────────────────────────────────
55
+
56
+ const FINDING_EVENTS = new Set([
57
+ EVENTS.FINDING_BLOCKED,
58
+ EVENTS.FINDING_WARNED,
59
+ EVENTS.FINDING_EXCEPTED,
60
+ EVENTS.FINDING_EXCEPTION_EXPIRED,
61
+ ]);
62
+
63
+ // ── Analysmoduler ──────────────────────────────────────────────────────────
64
+
65
+ function analyzeRecurringRules(findings) {
66
+ const ruleCount = {};
67
+ const ruleFirst = {};
68
+ const ruleLast = {};
69
+ for (const f of findings) {
70
+ ruleCount[f.rule_id] = (ruleCount[f.rule_id] || 0) + 1;
71
+ if (!ruleFirst[f.rule_id] || f.timestamp < ruleFirst[f.rule_id]) ruleFirst[f.rule_id] = f.timestamp;
72
+ if (!ruleLast[f.rule_id] || f.timestamp > ruleLast[f.rule_id]) ruleLast[f.rule_id] = f.timestamp;
73
+ }
74
+
75
+ const recurring = Object.entries(ruleCount)
76
+ .filter(([, count]) => count >= 3)
77
+ .sort(([, a], [, b]) => b - a)
78
+ .map(([ruleId, count]) => ({
79
+ ruleId,
80
+ count,
81
+ severity: findings.find(f => f.rule_id === ruleId)?.severity,
82
+ category: findings.find(f => f.rule_id === ruleId)?.category,
83
+ firstSeen: ruleFirst[ruleId]?.slice(0, 10),
84
+ lastSeen: ruleLast[ruleId]?.slice(0, 10),
85
+ spanDays: Math.round((new Date(ruleLast[ruleId]) - new Date(ruleFirst[ruleId])) / 86400000),
86
+ }));
87
+
88
+ return recurring;
89
+ }
90
+
91
+ function analyzeAvoidanceBehavior(events, findings) {
92
+ const totalFindings = findings.length;
93
+ const excepted = findings.filter(f => f.event === EVENTS.FINDING_EXCEPTED).length;
94
+ const expiredExc = findings.filter(f => f.event === EVENTS.FINDING_EXCEPTION_EXPIRED).length;
95
+ const exceptRate = percent(excepted, totalFindings);
96
+
97
+ // Who approves exceptions?
98
+ const approverCount = {};
99
+ for (const f of findings.filter(f => f.exception_by)) {
100
+ approverCount[f.exception_by] = (approverCount[f.exception_by] || 0) + 1;
101
+ }
102
+
103
+ // Files excepted repeatedly
104
+ const exceptedFileCount = {};
105
+ for (const f of findings.filter(f => f.event === EVENTS.FINDING_EXCEPTED)) {
106
+ if (f.file) exceptedFileCount[f.file] = (exceptedFileCount[f.file] || 0) + 1;
107
+ }
108
+
109
+ const scanBlocked = events.filter(e => e.event === EVENTS.SCAN_BLOCKED).length;
110
+ const scanTotal = events.filter(e => e.event === EVENTS.SCAN_STARTED).length;
111
+ const blockRate = percent(scanBlocked, scanTotal);
112
+
113
+ return {
114
+ totalFindings,
115
+ excepted,
116
+ expiredExceptions: expiredExc,
117
+ exceptRate,
118
+ blockRate,
119
+ topApprovers: topN(approverCount, 3),
120
+ topExceptedFiles: topN(exceptedFileCount, 3),
121
+ signal: exceptRate >= 25 ? 'HIGH'
122
+ : exceptRate >= 10 ? 'MEDIUM'
123
+ : 'LOW',
124
+ };
125
+ }
126
+
127
+ function analyzeTimePatterns(findings) {
128
+ const byHour = Array(24).fill(0);
129
+ const byWeekday = Array(7).fill(0); // 0=Sun
130
+
131
+ for (const f of findings) {
132
+ byHour[hourOf(f.timestamp)]++;
133
+ byWeekday[dayOfWeek(f.timestamp)]++;
134
+ }
135
+
136
+ const lateNightFindings = byHour.slice(20).reduce((a, b) => a + b, 0)
137
+ + byHour.slice(0, 5).reduce((a, b) => a + b, 0);
138
+ const businessHourFindings = byHour.slice(8, 18).reduce((a, b) => a + b, 0);
139
+ const lateRate = percent(lateNightFindings, findings.length);
140
+
141
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
142
+ const peakHour = byHour.indexOf(Math.max(...byHour));
143
+ const peakWeekday = byWeekday.indexOf(Math.max(...byWeekday));
144
+
145
+ // Fredag + helg
146
+ const weekendFindings = byWeekday[0] + byWeekday[6] + byWeekday[5];
147
+ const weekendRate = percent(weekendFindings, findings.length);
148
+
149
+ return {
150
+ byHour,
151
+ byWeekday,
152
+ peakHour,
153
+ peakWeekday: DAYS[peakWeekday],
154
+ lateNightFindings,
155
+ lateRate,
156
+ weekendRate,
157
+ businessHourFindings,
158
+ signal: lateRate >= 25 ? 'HIGH' : lateRate >= 10 ? 'MEDIUM' : 'LOW',
159
+ };
160
+ }
161
+
162
+ function analyzeKnowledgeGaps(findings) {
163
+ const catCount = {};
164
+ const catSev = {};
165
+
166
+ for (const f of findings) {
167
+ const cat = f.category || 'Unknown';
168
+ catCount[cat] = (catCount[cat] || 0) + 1;
169
+ if (!catSev[cat]) catSev[cat] = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, EXPOSURE: 0 };
170
+ if (catSev[cat][f.severity] !== undefined) catSev[cat][f.severity]++;
171
+ }
172
+
173
+ const total = findings.length;
174
+ const gaps = Object.entries(catCount)
175
+ .sort(([, a], [, b]) => b - a)
176
+ .map(([cat, count]) => ({
177
+ category: cat,
178
+ count,
179
+ percent: percent(count, total),
180
+ breakdown: catSev[cat] || {},
181
+ }));
182
+
183
+ // Dominant gap = top category if >30% of all findings
184
+ const dominantGap = gaps[0]?.percent >= 30 ? gaps[0] : null;
185
+
186
+ return { gaps: gaps.slice(0, 6), dominantGap };
187
+ }
188
+
189
+ function analyzeFileHotspots(findings) {
190
+ const fileCount = {};
191
+ const fileSev = {};
192
+
193
+ for (const f of findings) {
194
+ const fp = f.file || 'unknown';
195
+ fileCount[fp] = (fileCount[fp] || 0) + 1;
196
+ if (!fileSev[fp]) fileSev[fp] = { CRITICAL: 0, HIGH: 0 };
197
+ if (f.severity === 'CRITICAL') fileSev[fp].CRITICAL++;
198
+ if (f.severity === 'HIGH') fileSev[fp].HIGH++;
199
+ }
200
+
201
+ const hotspots = Object.entries(fileCount)
202
+ .sort(([, a], [, b]) => b - a)
203
+ .slice(0, 8)
204
+ .map(([file, count]) => ({
205
+ file,
206
+ count,
207
+ critical: fileSev[file]?.CRITICAL || 0,
208
+ high: fileSev[file]?.HIGH || 0,
209
+ }));
210
+
211
+ return { hotspots };
212
+ }
213
+
214
+ function analyzeTrend(findings) {
215
+ if (findings.length < 10) return { signal: 'INSUFFICIENT_DATA', weeks: [] };
216
+
217
+ const byWeek = {};
218
+ for (const f of findings) {
219
+ const w = weekOf(f.timestamp);
220
+ if (!byWeek[w]) byWeek[w] = { total: 0, critical: 0 };
221
+ byWeek[w].total++;
222
+ if (f.severity === 'CRITICAL') byWeek[w].critical++;
223
+ }
224
+
225
+ const weeks = Object.entries(byWeek)
226
+ .sort(([a], [b]) => a.localeCompare(b))
227
+ .map(([week, data]) => ({ week, ...data }));
228
+
229
+ if (weeks.length < 3) return { signal: 'INSUFFICIENT_DATA', weeks };
230
+
231
+ // Compare last 2 weeks with previous 2
232
+ const recent = weeks.slice(-2).reduce((s, w) => s + w.total, 0);
233
+ const earlier = weeks.slice(-4, -2).reduce((s, w) => s + w.total, 0);
234
+
235
+ let signal = 'STABLE';
236
+ let trendPct = 0;
237
+ if (earlier > 0) {
238
+ trendPct = Math.round(((recent - earlier) / earlier) * 100);
239
+ if (trendPct >= 20) signal = 'WORSENING';
240
+ if (trendPct <= -20) signal = 'IMPROVING';
241
+ }
242
+
243
+ return { signal, trendPct, weeks, recentAvg: Math.round(recent / 2), earlierAvg: Math.round(earlier / 2) };
244
+ }
245
+
246
+ function analyzeDevelopers(findings) {
247
+ if (findings.length === 0) return { developers: [], isTeam: false };
248
+
249
+ const devMap = {};
250
+ for (const f of findings) {
251
+ const dev = f.git_user || 'unknown';
252
+ if (!devMap[dev]) {
253
+ devMap[dev] = {
254
+ email: dev,
255
+ name: f.git_name || dev,
256
+ total: 0, critical: 0, high: 0, excepted: 0,
257
+ topRules: {}, topCategories: {},
258
+ };
259
+ }
260
+ const d = devMap[dev];
261
+ d.total++;
262
+ if (f.severity === 'CRITICAL') d.critical++;
263
+ if (f.severity === 'HIGH') d.high++;
264
+ if (f.event === EVENTS.FINDING_EXCEPTED) d.excepted++;
265
+ d.topRules[f.rule_id] = (d.topRules[f.rule_id] || 0) + 1;
266
+ if (f.category) d.topCategories[f.category] = (d.topCategories[f.category] || 0) + 1;
267
+ }
268
+
269
+ const developers = Object.values(devMap)
270
+ .sort((a, b) => b.total - a.total)
271
+ .map(d => ({
272
+ ...d,
273
+ criticalRate: percent(d.critical, d.total),
274
+ exceptRate: percent(d.excepted, d.total),
275
+ topRule: topN(d.topRules, 1)[0]?.key,
276
+ topCategory: topN(d.topCategories, 1)[0]?.key,
277
+ }));
278
+
279
+ return { developers, isTeam: developers.length > 1 };
280
+ }
281
+
282
+ // ── Lokal signaltolkning ───────────────────────────────────────────────────
283
+
284
+ function interpretSignals(analysis) {
285
+ const signals = [];
286
+
287
+ // Recurring rules
288
+ const top = analysis.recurringRules[0];
289
+ if (top && top.count >= 5) {
290
+ signals.push({
291
+ type: 'RECURRING_RULE',
292
+ level: top.severity === 'CRITICAL' ? '🔴' : '🟠',
293
+ title: `${top.ruleId} triggered ${top.count} times over ${top.spanDays} days`,
294
+ detail: top.category
295
+ ? `Category: ${top.category}. The issue recurs without addressing the root cause.`
296
+ : 'The issue recurs without addressing the root cause.',
297
+ action: 'Schedule a code review focused on this rule. Consider adding a concrete code example to onboarding.',
298
+ });
299
+ }
300
+
301
+ // Undvikandebeteende
302
+ if (analysis.avoidance.signal === 'HIGH') {
303
+ signals.push({
304
+ type: 'AVOIDANCE',
305
+ level: '🟠',
306
+ title: `${analysis.avoidance.exceptRate}% of findings are excepted instead of fixed`,
307
+ detail: 'A high exception rate suggests the tool is being silenced rather than code being improved.',
308
+ action: 'Review all exceptions – which are legitimate? Define a formal exception policy.',
309
+ });
310
+ }
311
+
312
+ // Time patterns
313
+ if (analysis.timePatterns.signal === 'HIGH') {
314
+ signals.push({
315
+ type: 'TIME_PRESSURE',
316
+ level: '🟡',
317
+ title: `${analysis.timePatterns.lateRate}% of findings occur late at night`,
318
+ detail: `Peak: ${analysis.timePatterns.peakHour}:00. Code written under time pressure or fatigue tends to have more security issues.`,
319
+ action: 'Investigate whether sprint pressure is driving late-night coding. Findings during these periods should be extra-reviewed.',
320
+ });
321
+ }
322
+
323
+ // Kunskapsgap
324
+ if (analysis.knowledgeGaps.dominantGap) {
325
+ const gap = analysis.knowledgeGaps.dominantGap;
326
+ signals.push({
327
+ type: 'KNOWLEDGE_GAP',
328
+ level: '🟠',
329
+ title: `${gap.percent}% of all findings belong to "${gap.category}"`,
330
+ detail: `${gap.count} findings in the same category indicates a systematic knowledge gap, not isolated mistakes.`,
331
+ action: `Plan targeted training on ${gap.category}. A half-day hands-on workshop yields the greatest impact.`,
332
+ });
333
+ }
334
+
335
+ // Trend
336
+ if (analysis.trend.signal === 'WORSENING') {
337
+ signals.push({
338
+ type: 'TREND',
339
+ level: '🔴',
340
+ title: `Findings increasing – +${analysis.trend.trendPct}% over the last 2 weeks`,
341
+ detail: `Average last 2 weeks: ${analysis.trend.recentAvg}/week vs ${analysis.trend.earlierAvg}/week previously.`,
342
+ action: 'Investigate whether new code was added, a new developer joined, or AI-assisted coding intensified without security review.',
343
+ });
344
+ } else if (analysis.trend.signal === 'IMPROVING') {
345
+ signals.push({
346
+ type: 'TREND',
347
+ level: '🟢',
348
+ title: `Findings decreasing – ${analysis.trend.trendPct}% over the last 2 weeks`,
349
+ detail: `Positive trend – security work is paying off.`,
350
+ action: 'Keep up the momentum. Share what is working with the team.',
351
+ });
352
+ }
353
+
354
+ // Hotspot-fil
355
+ const topFile = analysis.fileHotspots.hotspots[0];
356
+ if (topFile && topFile.count >= 5) {
357
+ signals.push({
358
+ type: 'HOTSPOT',
359
+ level: topFile.critical >= 3 ? '🔴' : '🟡',
360
+ title: `${topFile.file} is a hotspot (${topFile.count} findings, ${topFile.critical} CRITICAL)`,
361
+ detail: 'A file with many recurring findings needs structural refactoring, not just point fixes.',
362
+ action: 'Prioritize a security-focused code review of the entire file.',
363
+ });
364
+ }
365
+
366
+ return signals;
367
+ }
368
+
369
+ // ── Main analyser ─────────────────────────────────────────────────────────
370
+
371
+ async function analyzeInsights(repoRoot, opts = {}) {
372
+ const { days = 90 } = opts;
373
+
374
+ const allEvents = readAuditLog(repoRoot, 5000);
375
+
376
+ if (allEvents.length === 0) {
377
+ return { empty: true };
378
+ }
379
+
380
+ // Filter by period
381
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
382
+ const events = allEvents.filter(e => e.timestamp >= cutoff);
383
+ const findings = events.filter(e => FINDING_EVENTS.has(e.event));
384
+
385
+ if (findings.length === 0) {
386
+ return { empty: true, reason: `No findings in the last ${days} days.` };
387
+ }
388
+
389
+ const scans = events.filter(e => e.event === EVENTS.SCAN_STARTED);
390
+ const dates = findings.map(f => f.timestamp).sort();
391
+
392
+ const analysis = {
393
+ meta: {
394
+ periodDays: days,
395
+ totalScans: scans.length,
396
+ totalFindings: findings.length,
397
+ uniqueDevelopers: new Set(findings.map(f => f.git_user).filter(Boolean)).size,
398
+ firstFinding: dates[0]?.slice(0, 10),
399
+ lastFinding: dates[dates.length - 1]?.slice(0, 10),
400
+ },
401
+ recurringRules: analyzeRecurringRules(findings),
402
+ avoidance: analyzeAvoidanceBehavior(events, findings),
403
+ timePatterns: analyzeTimePatterns(findings),
404
+ knowledgeGaps: analyzeKnowledgeGaps(findings),
405
+ fileHotspots: analyzeFileHotspots(findings),
406
+ trend: analyzeTrend(findings),
407
+ developers: analyzeDevelopers(findings),
408
+ };
409
+
410
+ const signals = interpretSignals(analysis);
411
+ analysis.signals = signals;
412
+
413
+ return analysis;
414
+ }
415
+
416
+ module.exports = { analyzeInsights };