@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/init-repo.js
ADDED
|
@@ -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 };
|