diff --git a/plugins/llm-security/scripts/lib/report-renderers.mjs b/plugins/llm-security/scripts/lib/report-renderers.mjs new file mode 100644 index 0000000..3935bcc --- /dev/null +++ b/plugins/llm-security/scripts/lib/report-renderers.mjs @@ -0,0 +1,3042 @@ +/* + * report-renderers.mjs — llm-security playground rapport-renderere + parsers. + * + * CANONICAL SOURCE. The same code lives INLINE in + * playground/llm-security-playground.html as a duplicated copy. The two + * MUST stay in sync. file:// + ESM `import` is blocked in Chrome and + * Firefox without flags, so the playground retains an inline copy to + * remain a single-file file:// distribution. This module is the canonical + * source for the upcoming session 4 CLI (scripts/render-report.mjs). + * + * 45 exported functions + PARSERS routing-map + RENDERERS routing-map + + * KEY_STATS_CONFIG. See the export-block at the bottom of this file. + * + * Renderer API: render*(data, slot) — mutates slot.innerHTML and returns + * undefined. For Node CLI use, pass slot = { innerHTML: '' } and read + * slot.innerHTML after the call. This matches the playground exactly + * (bit-identical output by construction; identical code lives in both). + */ + + +// ============================================================ +// ESCAPE HELPERS +// ============================================================ +function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} +function escapeAttr(str) { return escapeHtml(str); } + +// ============================================================ +// PAGE-SHELL HELPERS (DS Tier 3) +// ============================================================ +function renderVerdictPill(verdict, sub) { + const v = String(verdict || 'n-a').toLowerCase(); + const labels = { + 'go': 'GO', + 'go-with-conditions': 'BETINGET', + 'block': 'BLOKKERT', + 'approved': 'GODKJENT', + 'failed': 'UNDERKJENT', + 'allow': 'TILLATT', + 'warning': 'ADVARSEL', + 'n-a': 'IKKE VURDERT' + }; + const dsVerdict = ( + v === 'failed' ? 'block' : + v === 'go-with-conditions' ? 'warning' : + v === 'go' || v === 'approved' ? 'allow' : + v + ); + const subHtml = sub + ? '' + escapeHtml(String(sub)) + '' + : ''; + return ( + '
' + + '' + escapeHtml(labels[v] || v.toUpperCase()) + '' + + subHtml + + '
' + ); +} + +function renderKeyStatsGrid(stats) { + if (!stats || !stats.length) return ''; + const items = stats.map(function (s) { + const cls = 'key-stat' + (s.modifier ? ' key-stat--' + escapeAttr(s.modifier) : ''); + const hint = s.hint ? '' + escapeHtml(s.hint) + '' : ''; + return '
' + + '' + escapeHtml(s.label || '') + '' + + '' + escapeHtml(String(s.value)) + '' + + hint + + '
'; + }).join(''); + return '
' + items + '
'; +} + +/** + * Render page-shell — DS Tier 3 page__header-klyngen brukt på alle 4 overflater: + * - onboarding: page__eyebrow="ONBOARDING · n av 5 grupper komplette" + * - home: page__eyebrow="HJEM" (m/ hero-modifier for editorial type-hierarki) + * - catalog: page__eyebrow="KATALOG" + * - project: page__eyebrow="PROSJEKT · " + * Pluss alle 18 rapport-renderere (eyebrow per archetype). + * Verdict-rendering via renderVerdictPill — produserer DS verdict-pill-lg. + * opts: { eyebrow, title, lede, meta:[], verdict, verdictSub, hero, keyStats } + */ +function renderPageShell(opts, bodyHtml) { + opts = opts || {}; + const eyebrow = opts.eyebrow ? '' + escapeHtml(opts.eyebrow) + '' : ''; + const title = '

' + escapeHtml(opts.title || '') + '

'; + const lede = opts.lede ? '

' + escapeHtml(opts.lede) + '

' : ''; + const meta = (opts.meta && opts.meta.length) + ? '
' + opts.meta.map(function (m) { return '' + escapeHtml(m) + ''; }).join('') + '
' + : ''; + const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict, opts.verdictSub) : ''; + const aside = verdict ? '
' + verdict + '
' : ''; + const stats = renderKeyStatsGrid(opts.keyStats); + const heroClass = opts.hero ? ' page__header--hero' : ''; + return ( + '
' + + '
' + eyebrow + title + lede + meta + '
' + + aside + + '
' + + stats + + (bodyHtml || '') + ); +} + +// ============================================================ +// INFER VERDICT + KEY-STATS PER ARCHETYPE +// ============================================================ +function normalizeVerdict(v) { + const s = String(v || '').toLowerCase().trim(); + const map = { + 'block': 'block', 'blokk': 'block', 'blokkert': 'block', 'failed': 'failed', 'underkjent': 'failed', + 'warning': 'warning', 'advarsel': 'warning', + 'go-with-conditions': 'go-with-conditions', 'betinget': 'go-with-conditions', 'conditional': 'go-with-conditions', + 'go': 'go', 'tillatt': 'allow', 'allow': 'allow', 'approved': 'approved', 'godkjent': 'approved', + 'n-a': 'n-a', 'na': 'n-a', 'ikke-vurdert': 'n-a' + }; + return map[s] || s || 'n-a'; +} + +function inferVerdict(data, archetype) { + if (!data) return 'n-a'; + if (data.verdict) return normalizeVerdict(data.verdict); + switch (archetype) { + case 'findings': { + const fs = data.findings || []; + if (!fs.length) return 'allow'; + const crit = fs.some(function (f) { return /crit|kritisk/i.test(f.severity || ''); }); + return crit ? 'block' : 'warning'; + } + case 'findings-grade': { + const g = String(data.grade || '').toUpperCase(); + if (g === 'A' || g === 'B') return 'allow'; + if (g === 'C' || g === 'D') return 'warning'; + if (g === 'F') return 'block'; + return 'n-a'; + } + case 'posture-cards': { + const g = String(data.grade || '').toUpperCase(); + if (g === 'A' || g === 'B') return 'allow'; + if (g === 'C' || g === 'D') return 'warning'; + if (g === 'F') return 'block'; + return 'n-a'; + } + case 'risk-score-meter': { + const score = Number(data.risk_score); + if (isNaN(score)) return 'n-a'; + if (score >= 65) return 'block'; + if (score >= 15) return 'warning'; + return 'allow'; + } + case 'dashboard-fleet': { + const g = String(data.machine_grade || '').toUpperCase(); + if (g === 'A' || g === 'B') return 'allow'; + if (g === 'C' || g === 'D') return 'warning'; + if (g === 'F') return 'block'; + return 'n-a'; + } + case 'red-team-results': { + const fail = Number(data.fail_count) || 0; + if (fail > 5) return 'block'; + if (fail > 0) return 'warning'; + return 'allow'; + } + case 'diff-report': { + const newCount = (data['new'] || []).length; + if (newCount > 0) return 'warning'; + return 'allow'; + } + case 'kanban-buckets': { + const remove = (data.remove || []).length; + if (remove > 0) return 'warning'; + return 'allow'; + } + case 'matrix-risk': { + const threats = data.threats || data.findings || []; + const hasCritical = threats.some(function (t) { return /crit|kritisk/i.test(t.severity || ''); }); + if (hasCritical) return 'block'; + if (threats.length) return 'warning'; + return 'n-a'; + } + default: + return 'n-a'; + } +} + +const KEY_STATS_CONFIG = { + 'findings': function (d) { + const fs = d.findings || []; + const crit = fs.filter(function (f) { return /crit|kritisk/i.test(f.severity || ''); }).length; + const high = fs.filter(function (f) { return /^high|^høy/i.test(f.severity || ''); }).length; + return [ + { label: 'TOTALT', value: fs.length }, + { label: 'KRITISK', value: crit, modifier: crit > 0 ? 'critical' : null }, + { label: 'HØY', value: high, modifier: high > 0 ? 'high' : null } + ]; + }, + 'findings-grade': function (d) { + const out = []; + if (d.grade) out.push({ label: 'GRADE', value: String(d.grade).toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') }); + if (d.score != null) out.push({ label: 'SCORE', value: d.score }); + if (d.findings) out.push({ label: 'FUNN', value: d.findings.length }); + return out; + }, + 'risk-score-meter': function (d) { + const out = []; + if (d.risk_score != null) { + const mod = d.risk_score >= 65 ? 'critical' : (d.risk_score >= 15 ? 'medium' : 'low'); + out.push({ label: 'RISK SCORE', value: d.risk_score, modifier: mod }); + } + if (d.riskBand) out.push({ label: 'BAND', value: d.riskBand }); + return out; + }, + 'red-team-results': function (d) { + return [ + { label: 'TOTALT', value: d.total || 0 }, + { label: 'PASS', value: d.pass_count || 0, modifier: 'low' }, + { label: 'FAIL', value: d.fail_count || 0, modifier: (d.fail_count > 0 ? 'critical' : null) } + ]; + }, + 'dashboard-fleet': function (d) { + return [ + { label: 'PROSJEKTER', value: (d.projects || []).length }, + { label: 'MASKINKLASSE', value: String(d.machine_grade || 'n/a').toUpperCase() }, + { label: 'SVAKEST', value: d.weakest_link || '–' } + ]; + }, + 'posture-cards': function (d) { + const cats = d.categories || []; + const pass = cats.filter(function (c) { return c.status === 'PASS'; }).length; + const fail = cats.filter(function (c) { return c.status === 'FAIL'; }).length; + return [ + { label: 'GRADE', value: String(d.grade || '?').toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') }, + { label: 'PASS', value: pass, modifier: 'low' }, + { label: 'FAIL', value: fail, modifier: fail > 0 ? 'critical' : 'low' } + ]; + }, + 'diff-report': function (d) { + const newCount = (d['new'] || []).length; + const unchangedCount = (d.unchanged || []).length; + return [ + { label: 'NÅ-GRADE', value: String(d.current_grade || '?').toUpperCase() }, + { label: 'AKSJONER', value: newCount, modifier: newCount > 0 ? 'medium' : 'low' }, + { label: 'SKIPPED', value: unchangedCount } + ]; + }, + 'kanban-buckets': function (d) { + const auto = (d.buckets && d.buckets.auto) || d.auto || []; + const semi = (d.buckets && (d.buckets['semi-auto'] || d.buckets.semi_auto)) || d['semi-auto'] || d.semi_auto || []; + const manual = (d.buckets && d.buckets.manual) || d.manual || []; + return [ + { label: 'AUTO', value: auto.length, modifier: 'low' }, + { label: 'SEMI-AUTO', value: semi.length, modifier: semi.length ? 'medium' : 'low' }, + { label: 'MANUAL', value: manual.length, modifier: manual.length ? 'high' : 'low' } + ]; + }, + 'matrix-risk': function (d) { + const threats = d.threats || d.findings || []; + const cells = d.matrix_cells || []; + const maxScore = cells.length ? Math.max.apply(null, cells.map(function (c) { return Number(c.score) || 0; })) : 0; + const sev = maxScore >= 16 ? 'critical' : maxScore >= 9 ? 'high' : maxScore >= 4 ? 'medium' : 'low'; + return [ + { label: 'TRUSLER', value: threats.length }, + { label: 'MAKS SCORE', value: maxScore || '–', modifier: sev }, + { label: 'CELLER', value: cells.length } + ]; + } +}; + +function inferKeyStats(data, archetype) { + if (!data) return []; + if (Array.isArray(data.keyStats)) return data.keyStats; + const fn = KEY_STATS_CONFIG[archetype]; + if (typeof fn !== 'function') return []; + try { + const out = fn(data); + return Array.isArray(out) ? out : []; + } catch (e) { return []; } +} + +// ============================================================ +// PARSER HELPERS (markdown → struktur) +// ============================================================ +function parseTableRow(line) { + const inner = line.replace(/^\|/, '').replace(/\|$/, ''); + return inner.split('|').map(function (c) { return c.trim(); }); +} + +function parseTable(md, anchorRegex) { + if (typeof md !== 'string') return null; + let body = md; + if (anchorRegex) { + const m = anchorRegex.exec(md); + if (!m) return null; + body = md.slice(m.index + m[0].length); + } + const lines = body.split(/\r?\n/); + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + const next = (lines[i + 1] || '').trim(); + if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) { + const headers = parseTableRow(line); + const rows = []; + for (let j = i + 2; j < lines.length; j++) { + const rowLine = lines[j].trim(); + if (rowLine.indexOf('|') !== 0) break; + const cells = parseTableRow(rowLine); + if (cells.length === 0) break; + const row = {}; + for (let k = 0; k < headers.length; k++) { + row[headers[k]] = (cells[k] || '').trim(); + } + rows.push(row); + } + return { headers: headers, rows: rows }; + } + } + return null; +} + +function parseAllTables(md, anchorRegex) { + // Returnerer alle tabeller etter (valgfri) anchor til neste H2 + // Brukt av parsers som har flere severity-tabeller (### Critical, ### High osv). + if (typeof md !== 'string') return []; + let body = md; + if (anchorRegex) { + const m = anchorRegex.exec(md); + if (!m) return []; + body = md.slice(m.index + m[0].length); + } + const out = []; + const lines = body.split(/\r?\n/); + let i = 0; + while (i < lines.length - 1) { + const line = lines[i].trim(); + const next = (lines[i + 1] || '').trim(); + if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) { + const headers = parseTableRow(line); + const rows = []; + let j = i + 2; + for (; j < lines.length; j++) { + const rowLine = lines[j].trim(); + if (rowLine.indexOf('|') !== 0) break; + const cells = parseTableRow(rowLine); + if (cells.length === 0) break; + const row = {}; + for (let k = 0; k < headers.length; k++) { + row[headers[k]] = (cells[k] || '').trim(); + } + rows.push(row); + } + out.push({ headers: headers, rows: rows }); + i = j; + } else { + i++; + } + } + return out; +} + +function parseSections(md) { + if (typeof md !== 'string') return []; + const sections = []; + const lines = md.split(/\r?\n/); + let current = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = /^##\s+(.+)$/.exec(line); + if (m && line.charAt(2) === ' ') { + if (current) sections.push(current); + current = { heading: m[1].trim(), body: '' }; + } else if (current) { + current.body += (current.body ? '\n' : '') + line; + } + } + if (current) sections.push(current); + return sections.map(function (s) { + return { heading: s.heading, body: s.body.trim() }; + }); +} + +function extractField(md, label) { + if (typeof md !== 'string') return null; + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Markdown-tabellrader: | **Label** | value | OR | Label | value | + const tblRe = new RegExp('^\\s*\\|\\s*\\**\\s*' + escaped + '\\s*\\**\\s*\\|\\s*([^|]+?)\\s*\\|', 'mi'); + const tbl = tblRe.exec(md); + if (tbl) return tbl[1].trim(); + // **Label:** value OR Label: value + const re = new RegExp('^\\s*\\**\\s*' + escaped + '\\**\\s*:\\s*(.+)$', 'mi'); + const m = re.exec(md); + return m ? m[1].trim() : null; +} + +function intOrZero(s) { + if (s == null) return 0; + if (typeof s !== 'string') s = String(s); + const v = parseInt(s.replace(/[^\d-]/g, ''), 10); + return isNaN(v) ? 0 : v; +} + +function emptyInput(md) { + return !md || typeof md !== 'string' || !md.trim(); +} + +function normalizeSeverity(s) { + const v = String(s || '').toLowerCase().trim(); + if (/crit|kritisk/.test(v)) return 'critical'; + if (/^high|^høy/.test(v)) return 'high'; + if (/medium|moderat/.test(v)) return 'medium'; + if (/^low|^lav/.test(v)) return 'low'; + if (/^info|^observ/.test(v)) return 'info'; + return v || 'info'; +} + +function normalizeVerdictText(s) { + const v = String(s || '').toUpperCase().trim(); + if (/BLOCK|BLOKK|UNDERKJENT|FAIL/.test(v)) return 'block'; + if (/GO[-\s]WITH[-\s]CONDITIONS|CONDITIONAL|BETINGET/.test(v)) return 'go-with-conditions'; + if (/WARNING|ADVARSEL/.test(v)) return 'warning'; + if (/ALLOW|TILLATT|GO|PASS|GODKJENT/.test(v)) return 'allow'; + if (/N\/?A|IKKE/.test(v)) return 'n-a'; + return ''; +} + +function gradeFromText(s) { + const m = /\b([A-F])\b/.exec(String(s || '').toUpperCase()); + return m ? m[1] : null; +} + +// Hjelper: parse Risk Dashboard-tabellen (fellesmønster) +function parseRiskDashboard(md) { + const out = {}; + const score = extractField(md, 'Risk Score'); + if (score) { + const m = /(\d+)\s*\/\s*100/.exec(score); + if (m) out.risk_score = parseInt(m[1], 10); + else out.risk_score = intOrZero(score); + } + const band = extractField(md, 'Risk Band'); + if (band) out.riskBand = band; + const grade = extractField(md, 'Grade'); + if (grade) out.grade = gradeFromText(grade); + const verdict = extractField(md, 'Verdict'); + if (verdict) { + const norm = normalizeVerdictText(verdict); + if (norm) out.verdict = norm; + } + const rationale = extractField(md, 'Verdict rationale'); + if (rationale) out.verdict_rationale = rationale; + // Severity counts-tabell (Severity | Count) — etter Risk Dashboard-headeren + const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*Count/i); + if (sevTbl && sevTbl.rows.length) { + const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0, total: 0 }; + sevTbl.rows.forEach(function (row) { + const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, ''); + const n = intOrZero(row[sevTbl.headers[1]] || '0'); + if (/^critical|^kritisk/.test(label)) counts.critical = n; + else if (/^high|^høy/.test(label)) counts.high = n; + else if (/^medium/.test(label)) counts.medium = n; + else if (/^low|^lav/.test(label)) counts.low = n; + else if (/^info/.test(label)) counts.info = n; + else if (/^total/.test(label)) counts.total = n; + }); + if (!counts.total) { + counts.total = counts.critical + counts.high + counts.medium + counts.low + counts.info; + } + out.severity_counts = counts; + } + return out; +} + +// Hjelper: parse alle findings-tabeller (### Critical / High / Medium / Low / Info) +function parseFindingsTables(md) { + const findings = []; + // Match alle ### -headere innenfor ## Findings + const findingsSection = parseSections(md).find(function (s) { + return /^findings$/i.test(s.heading) || /^funn$/i.test(s.heading); + }); + if (!findingsSection) return findings; + const body = findingsSection.body; + // Splitt på ### -headere + const subRe = /^###\s+(.+)$/gm; + const matches = []; + let m; + while ((m = subRe.exec(body)) !== null) { + matches.push({ severity: m[1].trim(), index: m.index }); + } + for (let i = 0; i < matches.length; i++) { + const start = matches[i].index; + const end = i + 1 < matches.length ? matches[i + 1].index : body.length; + const chunk = body.slice(start, end); + const tbl = parseTable(chunk); + if (!tbl || !tbl.rows.length) continue; + const sev = matches[i].severity.split(/[\s/,]/)[0]; // "Low / Info" → "Low" + tbl.rows.forEach(function (row) { + const idKey = tbl.headers[0]; + const catKey = tbl.headers.find(function (h) { return /category|kategori/i.test(h); }); + const fileKey = tbl.headers.find(function (h) { return /file|fil/i.test(h); }); + const lineKey = tbl.headers.find(function (h) { return /^line$|linje/i.test(h); }); + const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); }); + const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); }); + findings.push({ + id: row[idKey] || '', + severity: normalizeSeverity(sev), + category: catKey ? row[catKey] : '', + file: fileKey ? row[fileKey] : '', + line: lineKey ? row[lineKey] : '', + description: descKey ? row[descKey] : '', + owasp: owaspKey ? row[owaspKey] : '' + }); + }); + } + return findings; +} + +function parseRecommendations(md) { + const sec = parseSections(md).find(function (s) { return /^recommendations$|^anbefalinger$/i.test(s.heading); }); + if (!sec) return []; + const out = []; + const lines = sec.body.split(/\r?\n/); + lines.forEach(function (line) { + const m = /^\s*(?:\d+\.|[-*])\s+(.+)$/.exec(line); + if (m) out.push(m[1].replace(/^\*\*[^*]+\*\*[:]?\s*/, '').trim()); + }); + return out; +} + +function safeOk(parser) { + return function (md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + try { return parser(md); } + catch (e) { return { ok: false, errors: [{ section: 'parser', reason: String(e && e.message || e) }] }; } + }; +} + +// ============================================================ +// parseNarrativeAudit — v7.1.1 Narrative Audit-blokk +// ============================================================ +/** + * Parse v7.1.1 Narrative Audit-blokk: "**Suppressed signals:** N (reason1: count examples, ...)" + * Returnerer { count, by_category: {reason: count, ...}, examples: {reason: text, ...} } eller null. + */ +function parseNarrativeAudit(md) { + const m = String(md || '').match(/Suppressed signals:\s*\*?\*?\s*(\d+)\s*(?:\(([^)]+)\))?/i); + if (!m) return null; + const count = Number(m[1]) || 0; + const by_category = {}; + const examples = {}; + if (m[2]) { + m[2].split(',').forEach(function (part) { + const seg = part.trim(); + const colonIdx = seg.indexOf(':'); + if (colonIdx < 0) { + by_category[seg] = (by_category[seg] || 0) + 1; + return; + } + const reason = seg.slice(0, colonIdx).trim(); + const rest = seg.slice(colonIdx + 1).trim(); + const cm = rest.match(/^(\d+)\s+(.*)$/); + if (cm) { + by_category[reason] = (by_category[reason] || 0) + (Number(cm[1]) || 1); + examples[reason] = cm[2].trim(); + } else { + by_category[reason] = (by_category[reason] || 0) + 1; + examples[reason] = rest; + } + }); + } + return { count: count, by_category: by_category, examples: examples }; +} + +// ============================================================ +// 10 PARSERS — én per høy-prio kommando. +// Returner { ok: true, data: { ...domain-specific } } eller +// { ok: false, errors: [{ section, reason }] } +// ============================================================ +const parseScan = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const findings = parseFindingsTables(md); + const owaspTbl = parseTable(md, /##\s+OWASP\s+Categorization/i); + const owasp = owaspTbl ? owaspTbl.rows.map(function (row) { + return { + category: row[owaspTbl.headers[0]] || '', + findings: intOrZero(row[owaspTbl.headers[1]] || '0'), + max_severity: normalizeSeverity(row[owaspTbl.headers[2]] || ''), + scanners: row[owaspTbl.headers[3]] || '' + }; + }) : []; + const supplyTbl = parseTable(md, /##\s+Supply\s+Chain\s+Assessment/i); + const supply_chain = supplyTbl ? supplyTbl.rows.map(function (row) { + return { + component: row[supplyTbl.headers[0]] || '', + type: row[supplyTbl.headers[1]] || '', + source: row[supplyTbl.headers[2]] || '', + trust: row[supplyTbl.headers[3]] || '', + notes: row[supplyTbl.headers[4]] || '' + }; + }) : []; + const exec = parseSections(md).find(function (s) { return /^executive\s+summary/i.test(s.heading); }); + const suppressed = parseNarrativeAudit(md); + return { ok: true, data: Object.assign({}, dash, { + findings: findings, + owasp: owasp, + supply_chain: supply_chain, + executive_summary: exec ? exec.body.split(/\n##/)[0].trim() : '', + narrative_audit: suppressed ? { suppressed_findings: suppressed } : undefined, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseDeepScan = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Per-scanner-blokker: ### N. Name (TAG) — Status / Files / Findings / Time + const scannerBlocks = []; + const scannerRe = /^###\s+\d+\.\s+(.+?)\s+\(([A-Z]{2,4})\)\s*$([\s\S]*?)(?=^###\s+\d+\.|^##\s+|\Z)/gm; + let m; + while ((m = scannerRe.exec(md)) !== null) { + const name = m[1].trim(); + const tag = m[2].trim(); + const body = m[3] || ''; + const statusMatch = /\*\*Status:\*\*\s*([^|]+?)\s*\|/i.exec(body); + const filesMatch = /\*\*Files:\*\*\s*([^|]+?)\s*\|/i.exec(body); + const findingsMatch = /\*\*Findings:\*\*\s*(\d+)/i.exec(body); + const timeMatch = /\*\*Time:\*\*\s*(\d+)/i.exec(body); + const detailLines = body.split(/\r?\n/).filter(function (l) { + return l.trim() && !/^\*\*Status:\*\*/i.test(l.trim()); + }); + scannerBlocks.push({ + tag: tag, + name: name, + status: statusMatch ? statusMatch[1].trim() : '', + files: filesMatch ? filesMatch[1].trim() : '', + findings: findingsMatch ? parseInt(findingsMatch[1], 10) : 0, + duration_ms: timeMatch ? parseInt(timeMatch[1], 10) : 0, + details: detailLines.join(' ').trim() + }); + } + // Scanner Risk Matrix + const matrixTbl = parseTable(md, /##\s+Scanner\s+Risk\s+Matrix/i); + const scanner_matrix = matrixTbl ? matrixTbl.rows + .filter(function (row) { return !/^\s*\*\*total/i.test(row[matrixTbl.headers[0]] || ''); }) + .map(function (row) { + return { + scanner: row[matrixTbl.headers[0]] || '', + critical: intOrZero(row[matrixTbl.headers[1]] || '0'), + high: intOrZero(row[matrixTbl.headers[2]] || '0'), + medium: intOrZero(row[matrixTbl.headers[3]] || '0'), + low: intOrZero(row[matrixTbl.headers[4]] || '0'), + info: intOrZero(row[matrixTbl.headers[5]] || '0') + }; + }) : []; + const exec = parseSections(md).find(function (s) { return /^executive\s+summary/i.test(s.heading); }); + const suppressed = parseNarrativeAudit(md); + return { ok: true, data: Object.assign({}, dash, { + scanners: scannerBlocks, + scanner_matrix: scanner_matrix, + score: dash.risk_score, + findings: parseFindingsTables(md), + executive_summary: exec ? exec.body.split(/\n##/)[0].trim() : '', + narrative_audit: suppressed ? { suppressed_findings: suppressed } : undefined, + recommendations: parseRecommendations(md) + }) }; +}); + +const parsePluginAudit = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Plugin Metadata-tabell + const metaTbl = parseTable(md, /##\s+Plugin\s+Metadata/i); + const plugin_metadata = {}; + if (metaTbl) { + metaTbl.rows.forEach(function (row) { + const k = String(row[metaTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_'); + plugin_metadata[k] = row[metaTbl.headers[1]] || ''; + }); + } + // Component Inventory + const compTbl = parseTable(md, /##\s+Component\s+Inventory/i); + const components = compTbl ? compTbl.rows.map(function (row) { + return { + component: row[compTbl.headers[0]] || '', + count: intOrZero(row[compTbl.headers[1]] || '0'), + notes: row[compTbl.headers[2]] || '' + }; + }) : []; + // Permission Matrix + const permTbl = parseTable(md, /##\s+Permission\s+Matrix/i); + const permissions = permTbl ? permTbl.rows.map(function (row) { + return { + tool: row[permTbl.headers[0]] || '', + required_by: row[permTbl.headers[1]] || '', + justified: row[permTbl.headers[2]] || '' + }; + }) : []; + // Trust Verdict-seksjon + const sections = parseSections(md); + const trustSec = sections.find(function (s) { return /trust\s+verdict/i.test(s.heading); }); + let trust_verdict_text = ''; + let trust_verdict_value = ''; + if (trustSec) { + trust_verdict_text = trustSec.body; + const vmatch = /\*\*Verdict:\*\*\s*([A-Z\-]+)/i.exec(trustSec.body); + if (vmatch) trust_verdict_value = normalizeVerdictText(vmatch[1]); + } + return { ok: true, data: Object.assign({}, dash, { + plugin_metadata: plugin_metadata, + components: components, + permissions: permissions, + trust_verdict_text: trust_verdict_text, + trust_verdict: trust_verdict_value || dash.verdict || '', + findings: parseFindingsTables(md), + recommendations: parseRecommendations(md) + }) }; +}); + +const parseMcpAudit = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // MCP Landscape-tabell + const landTbl = parseTable(md, /##\s+MCP\s+Landscape/i); + const mcp_servers = landTbl ? landTbl.rows.map(function (row) { + return { + server: row[landTbl.headers[0]] || '', + type: row[landTbl.headers[1]] || '', + trust: row[landTbl.headers[2]] || '', + tools: intOrZero(row[landTbl.headers[3]] || '0'), + active: /^yes|^aktiv|^ja/i.test(String(row[landTbl.headers[4]] || '')) + }; + }) : []; + // Per-Server-Analysis er fritekst-seksjoner med ### server-name + const sections = parseSections(md); + const perServerSec = sections.find(function (s) { return /per-server\s+analysis/i.test(s.heading); }); + const per_server = []; + if (perServerSec) { + const subRe = /^###\s+(.+)$/gm; + const body = perServerSec.body; + const heads = []; + let m2; + while ((m2 = subRe.exec(body)) !== null) heads.push({ name: m2[1].trim(), index: m2.index }); + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + per_server.push({ + name: heads[i].name.replace(/\s*\([^)]+\)\s*$/, ''), + note: heads[i].name.match(/\(([^)]+)\)/) ? heads[i].name.match(/\(([^)]+)\)/)[1] : '', + body: body.slice(start, end).replace(/^###[^\n]+\n+/, '').trim() + }); + } + } + // Keep / Review / Remove buckets + const krrTbl = parseTable(md, /##\s+Keep\s*\/\s*Review\s*\/\s*Remove/i); + const buckets = { keep: [], review: [], remove: [] }; + if (krrTbl) { + krrTbl.rows.forEach(function (row) { + const decision = String(row[krrTbl.headers[0]] || '').toLowerCase().trim(); + const item = { + server: row[krrTbl.headers[1]] || '', + reason: row[krrTbl.headers[2]] || '' + }; + if (/^keep/.test(decision)) buckets.keep.push(item); + else if (/^review/.test(decision)) buckets.review.push(item); + else if (/^remove/.test(decision)) buckets.remove.push(item); + }); + } + // Findings: tabeller under ## Findings + const findings = []; + const findingsSec = sections.find(function (s) { return /^findings$/i.test(s.heading); }); + if (findingsSec) { + const subRe = /^###\s+(.+)$/gm; + const body = findingsSec.body; + const heads = []; + let m3; + while ((m3 = subRe.exec(body)) !== null) heads.push({ severity: m3[1].trim(), index: m3.index }); + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + const tbl = parseTable(chunk); + if (!tbl || !tbl.rows.length) continue; + const sev = heads[i].severity.split(/[\s/,]/)[0]; + tbl.rows.forEach(function (row) { + const idKey = tbl.headers[0]; + const serverKey = tbl.headers.find(function (h) { return /server/i.test(h); }); + const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); }); + const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); }); + findings.push({ + id: row[idKey] || '', + severity: normalizeSeverity(sev), + server: serverKey ? row[serverKey] : '', + description: descKey ? row[descKey] : '', + owasp: owaspKey ? row[owaspKey] : '' + }); + }); + } + } + return { ok: true, data: Object.assign({}, dash, { + mcp_servers: mcp_servers, + per_server: per_server, + buckets: buckets, + findings: findings, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseIdeScan = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Scan Coverage-tabell + const covTbl = parseTable(md, /##\s+Scan\s+Coverage/i); + const coverage = covTbl ? covTbl.rows + .filter(function (row) { return !/^\s*\*\*total/i.test(row[covTbl.headers[0]] || ''); }) + .map(function (row) { + return { + ide: row[covTbl.headers[0]] || '', + extensions: intOrZero(row[covTbl.headers[1]] || '0'), + findings: intOrZero(row[covTbl.headers[2]] || '0') + }; + }) : []; + // Findings: under ### Critical/High/Medium/Low/Info — extension+IDE-spesifikk + const findings = []; + const sections = parseSections(md); + const findingsSec = sections.find(function (s) { return /^findings$/i.test(s.heading); }); + if (findingsSec) { + const body = findingsSec.body; + const subRe = /^###\s+(.+)$/gm; + const heads = []; + let m; + while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index }); + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + const tbl = parseTable(chunk); + if (!tbl || !tbl.rows.length) continue; + const sev = heads[i].severity.split(/[\s/,]/)[0]; + tbl.rows.forEach(function (row) { + const idKey = tbl.headers[0]; + const extKey = tbl.headers.find(function (h) { return /extension/i.test(h); }); + const ideKey = tbl.headers.find(function (h) { return /^ide$/i.test(h); }); + const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); }); + const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); }); + findings.push({ + id: row[idKey] || '', + severity: normalizeSeverity(sev), + extension: extKey ? row[extKey] : '', + ide: ideKey ? row[ideKey] : '', + description: descKey ? row[descKey] : '', + owasp: owaspKey ? row[owaspKey] : '' + }); + }); + } + } + return { ok: true, data: Object.assign({}, dash, { + coverage: coverage, + findings: findings, + recommendations: parseRecommendations(md) + }) }; +}); + +const parsePosture = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Overall Score-seksjon: "**N / M categories covered (Grade X)**" + const overallSec = parseSections(md).find(function (s) { return /^overall\s+score/i.test(s.heading); }); + let posture_score = null; + let posture_applicable = null; + if (overallSec) { + const m = /\*\*\s*(\d+)\s*\/\s*(\d+)\s+categories/i.exec(overallSec.body); + if (m) { + posture_score = parseInt(m[1], 10); + posture_applicable = parseInt(m[2], 10); + } + } + // Category Scorecard-tabell + const catTbl = parseTable(md, /##\s+Category\s+Scorecard/i); + const categories = catTbl ? catTbl.rows.map(function (row) { + const status = String(row[catTbl.headers.find(function (h) { return /status/i.test(h); }) || catTbl.headers[2]] || '').toUpperCase().trim(); + return { + num: intOrZero(row[catTbl.headers[0]] || '0'), + name: row[catTbl.headers[1]] || '', + status: status, + findings: intOrZero(row[catTbl.headers[3]] || '0') + }; + }) : []; + // Top findings under ## Top Findings (med ### severity-grupper) + const findings = []; + const sections = parseSections(md); + const topSec = sections.find(function (s) { return /^top\s+findings/i.test(s.heading); }); + if (topSec) { + const body = topSec.body; + const subRe = /^###\s+(.+)$/gm; + const heads = []; + let m; + while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index }); + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + const tbl = parseTable(chunk); + if (!tbl || !tbl.rows.length) continue; + tbl.rows.forEach(function (row) { + findings.push({ + id: row[tbl.headers[0]] || '', + severity: normalizeSeverity(heads[i].severity), + category: row[tbl.headers[1]] || '', + file: row[tbl.headers[2]] || '', + description: row[tbl.headers[3]] || '' + }); + }); + } + } + // Quick Wins + const quickSec = sections.find(function (s) { return /^quick\s+wins/i.test(s.heading); }); + const quick_wins = quickSec ? quickSec.body.split(/\r?\n/).map(function (l) { + const m = /^\s*\d+\.\s+(.+)$/.exec(l); + return m ? m[1].replace(/^\*\*[^*]+\*\*\s*[—-]?\s*/, '').trim() : null; + }).filter(Boolean) : []; + return { ok: true, data: Object.assign({}, dash, { + score: posture_score != null ? posture_score : dash.risk_score, + posture_score: posture_score, + posture_applicable: posture_applicable, + categories: categories, + findings: findings, + quick_wins: quick_wins, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseAudit = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Radar Axes-tabell + const radarTbl = parseTable(md, /##\s+Radar\s+Axes/i); + const radar_axes = radarTbl ? radarTbl.rows.map(function (row) { + return { + name: row[radarTbl.headers[0]] || '', + score: intOrZero(row[radarTbl.headers[1]] || '0') + }; + }) : []; + // Category Assessment: ### Category N — Name + status-tabell + const sections = parseSections(md); + const catAssessSec = sections.find(function (s) { return /^category\s+assessment/i.test(s.heading); }); + const categories = []; + if (catAssessSec) { + const body = catAssessSec.body; + const subRe = /^###\s+Category\s+(\d+)\s+[—-]\s+(.+)$/gm; + const heads = []; + let m; + while ((m = subRe.exec(body)) !== null) { + heads.push({ num: parseInt(m[1], 10), name: m[2].trim(), index: m.index }); + } + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + const statusMatch = /\|\s*Status\s*\|\s*([A-Z\-]+)\s*\|/i.exec(chunk); + categories.push({ + num: heads[i].num, + name: heads[i].name, + status: statusMatch ? statusMatch[1].trim().toUpperCase() : '' + }); + } + } + // Risk Matrix (L×I) + const riskTbl = parseTable(md, /##\s+Risk\s+Matrix/i); + const risk_matrix = riskTbl ? riskTbl.rows.map(function (row) { + return { + category: row[riskTbl.headers[0]] || '', + likelihood: intOrZero(row[riskTbl.headers[1]] || '0'), + impact: intOrZero(row[riskTbl.headers[2]] || '0'), + score: intOrZero(row[riskTbl.headers[3]] || '0') + }; + }) : []; + // Action Plan: ### IMMEDIATE / HIGH / MEDIUM + const actionSec = sections.find(function (s) { return /^action\s+plan/i.test(s.heading); }); + const action_plan = { immediate: [], high: [], medium: [] }; + if (actionSec) { + const body = actionSec.body; + const subRe = /^###\s+(IMMEDIATE|HIGH|MEDIUM)/gmi; + const heads = []; + let m; + while ((m = subRe.exec(body)) !== null) heads.push({ tier: m[1].toLowerCase(), index: m.index }); + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + chunk.split(/\r?\n/).forEach(function (line) { + const mm = /^\s*\d+\.\s+(.+)$/.exec(line); + if (mm) action_plan[heads[i].tier].push(mm[1].trim()); + }); + } + } + const exec = sections.find(function (s) { return /^executive\s+summary/i.test(s.heading); }); + return { ok: true, data: Object.assign({}, dash, { + score: dash.risk_score, + radar_axes: radar_axes, + categories: categories, + risk_matrix: risk_matrix, + action_plan: action_plan, + findings: parseFindingsTables(md), + executive_summary: exec ? exec.body.trim() : '' + }) }; +}); + +const parseDashboard = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Header-Risk Dashboard-tabell har egne felter + const machine_grade = gradeFromText(extractField(md, 'Machine Grade') || ''); + const projects_scanned = intOrZero(extractField(md, 'Projects Scanned') || '0'); + const total_findings = intOrZero(extractField(md, 'Total Findings') || '0'); + const cache = extractField(md, 'Cache') || ''; + // Project Overview-tabell + const projTbl = parseTable(md, /##\s+Project\s+Overview/i); + const projects = projTbl ? projTbl.rows.map(function (row) { + return { + name: row[projTbl.headers[0]] || '', + grade: gradeFromText(row[projTbl.headers[1]] || ''), + risk: intOrZero(row[projTbl.headers[2]] || '0'), + worst_category: row[projTbl.headers[3]] || '', + findings: intOrZero(row[projTbl.headers[4]] || '0') + }; + }) : []; + // Trend-tabell + const trendTbl = parseTable(md, /##\s+Trend/i); + const trends = trendTbl ? trendTbl.rows.map(function (row) { + return { + name: row[trendTbl.headers[0]] || '', + trend: String(row[trendTbl.headers[1]] || '').toLowerCase().trim(), + d_risk: row[trendTbl.headers[2]] || '', + d_findings: row[trendTbl.headers[3]] || '' + }; + }) : []; + // Errors-seksjon + const errSec = parseSections(md).find(function (s) { return /^errors/i.test(s.heading); }); + let errors = []; + if (errSec) { + const errTbl = parseTable(errSec.body); + if (errTbl) { + errors = errTbl.rows.map(function (row) { + return { + project: row[errTbl.headers[0]] || '', + error: row[errTbl.headers[errTbl.headers.length - 1]] || '' + }; + }); + } + } + // Weakest link = første prosjekt sortert worst-first (allerede sortert i fixture) + const weakest = projects.length ? projects[0].name : ''; + return { ok: true, data: Object.assign({}, dash, { + machine_grade: machine_grade, + projects_scanned: projects_scanned, + total_findings: total_findings, + cache: cache, + projects: projects, + trends: trends, + errors: errors, + weakest_link: weakest, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseHarden = safeOk(function (md) { + const current_grade = gradeFromText(extractField(md, 'Current Grade') || ''); + const project_type = extractField(md, 'Project Type') || ''; + const recRaw = extractField(md, 'Recommendations') || ''; + let actionable = 0, total = 0; + const recMatch = /(\d+)\s*\/\s*(\d+)/.exec(recRaw); + if (recMatch) { actionable = parseInt(recMatch[1], 10); total = parseInt(recMatch[2], 10); } + const mode = extractField(md, 'Mode') || 'dry-run'; + // Recommendations: ### N. Category — File med Action / Content preview + const sections = parseSections(md); + const recSec = sections.find(function (s) { return /^recommendations$/i.test(s.heading); }); + const recommendations = []; + if (recSec) { + const body = recSec.body; + const subRe = /^###\s+(\d+)\.\s+(.+?)\s+[—-]\s+(.+)$/gm; + const heads = []; + let m; + while ((m = subRe.exec(body)) !== null) { + heads.push({ num: parseInt(m[1], 10), category: m[2].trim(), file: m[3].trim(), index: m.index }); + } + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + const actionMatch = /-\s+\*\*Action:\*\*\s*(.+)$/im.exec(chunk); + const contentMatch = /-\s+\*\*Content preview:\*\*\s*([\s\S]*?)(?=\n-\s+\*\*|\n###|\n##|$)/i.exec(chunk); + recommendations.push({ + num: heads[i].num, + category: heads[i].category, + file: heads[i].file, + action: actionMatch ? actionMatch[1].trim() : '', + content_preview: contentMatch ? contentMatch[1].trim() : '' + }); + } + } + // Diff Summary-tabell + const diffTbl = parseTable(md, /##\s+Diff\s+Summary/i); + const diff_summary = diffTbl ? diffTbl.rows + .filter(function (row) { return !/^\s*\*\*total/i.test(row[diffTbl.headers[0]] || ''); }) + .map(function (row) { + return { + file: row[diffTbl.headers[0]] || '', + action: row[diffTbl.headers[1]] || '', + lines: row[diffTbl.headers[2]] || '' + }; + }) : []; + // Map til diff-archetype: new = create, resolved = (none), unchanged = skipped + const newItems = recommendations.filter(function (r) { return /create|append|merge/i.test(r.action); }); + const skippedItems = recommendations.filter(function (r) { return /none|skip/i.test(r.action); }); + return { ok: true, data: { + current_grade: current_grade, + project_type: project_type, + actionable: actionable, + total: total, + mode: mode, + recommendations: recommendations, + diff_summary: diff_summary, + 'new': newItems, + unchanged: skippedItems, + resolved: [], + moved: [] + } }; +}); + +const parseRedTeam = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const defenseRaw = extractField(md, 'Defense Score') || ''; + const defense_score = intOrZero(defenseRaw); + const total = intOrZero(extractField(md, 'Total Scenarios') || '0'); + const pass_count = intOrZero(extractField(md, 'Pass') || '0'); + const fail_count = intOrZero(extractField(md, 'Fail') || '0'); + const adaptive = /^on/i.test(String(extractField(md, 'Adaptive Mode') || '')); + // Per-Category Breakdown-tabell + const catTbl = parseTable(md, /##\s+Per-Category\s+Breakdown/i); + const categories = catTbl ? catTbl.rows.map(function (row) { + return { + category: row[catTbl.headers[0]] || '', + pass: intOrZero(row[catTbl.headers[1]] || '0'), + fail: intOrZero(row[catTbl.headers[2]] || '0'), + coverage: row[catTbl.headers[3]] || '' + }; + }) : []; + // Failed Scenarios med severity-grupper + const sections = parseSections(md); + const failSec = sections.find(function (s) { return /failed\s+scenarios/i.test(s.heading); }); + const scenarios = []; + if (failSec) { + const body = failSec.body; + const subRe = /^###\s+(.+)$/gm; + const heads = []; + let m; + while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index }); + for (let i = 0; i < heads.length; i++) { + const start = heads[i].index; + const end = i + 1 < heads.length ? heads[i + 1].index : body.length; + const chunk = body.slice(start, end); + const tbl = parseTable(chunk); + if (!tbl || !tbl.rows.length) continue; + tbl.rows.forEach(function (row) { + scenarios.push({ + id: row[tbl.headers[0]] || '', + severity: normalizeSeverity(heads[i].severity), + category: row[tbl.headers[1]] || '', + payload_class: row[tbl.headers[2]] || '', + reason: row[tbl.headers[3]] || '' + }); + }); + } + } + // Test History + const histTbl = parseTable(md, /##\s+Test\s+History/i); + const history = histTbl ? histTbl.rows.map(function (row) { + return { + run: row[histTbl.headers[0]] || '', + date: row[histTbl.headers[1]] || '', + defense_score: intOrZero(row[histTbl.headers[2]] || '0'), + delta: row[histTbl.headers[3]] || '' + }; + }) : []; + return { ok: true, data: Object.assign({}, dash, { + defense_score: defense_score, + total: total, + pass_count: pass_count, + fail_count: fail_count, + adaptive: adaptive, + categories: categories, + scenarios: scenarios, + history: history, + recommendations: parseRecommendations(md) + }) }; +}); + +// ============================================================ +// FASE 3: 8 PARSERS — én per gjenstående produces_report-kommando. +// Mønstre gjenbrukes fra Fase 2 (parseRiskDashboard + parseFindingsTables +// + safeOk). Matrix-risk-parsing er kopiert fra ms-ai-architect. +// ============================================================ +const parseMcpInspect = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const invTbl = parseTable(md, /##\s+Server\s+Inventory/i); + const server_inventory = invTbl ? invTbl.rows.map(function (row) { + return { + server: row[invTbl.headers[0]] || '', + transport: row[invTbl.headers[1]] || '', + tools: intOrZero(row[invTbl.headers[2]] || '0'), + status: row[invTbl.headers[3]] || '', + connected: /^yes|^ja/i.test(String(row[invTbl.headers[4]] || '')) + }; + }) : []; + const cpTbl = parseTable(md, /##\s+Codepoint\s+Reveal/i); + const codepoints = cpTbl ? cpTbl.rows.map(function (row) { + return { + server: row[cpTbl.headers[0]] || '', + tool: row[cpTbl.headers[1]] || '', + codepoints: row[cpTbl.headers[2]] || '', + risk: row[cpTbl.headers[3]] || '' + }; + }) : []; + // Findings: merge default finding-shape med server-spesifikk meta + const findingsRaw = parseFindingsTables(md); + const findings = findingsRaw.map(function (f) { + // Severity-tabellene bruker «Server» som kolonne → category=Server, file=tom + return Object.assign({}, f, { + server: f.category || f.file || '', + file: f.file || '' + }); + }); + return { ok: true, data: Object.assign({}, dash, { + server_inventory: server_inventory, + codepoints: codepoints, + findings: findings, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseSupplyCheck = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const ecoTbl = parseTable(md, /##\s+Ecosystem\s+Coverage/i); + const ecosystems = ecoTbl ? ecoTbl.rows + .filter(function (row) { return !/^\s*\*\*total/i.test(row[ecoTbl.headers[0]] || ''); }) + .map(function (row) { + return { + ecosystem: row[ecoTbl.headers[0]] || '', + lockfile: row[ecoTbl.headers[1]] || '', + packages: intOrZero(row[ecoTbl.headers[2]] || '0'), + osv_hits: intOrZero(row[ecoTbl.headers[3]] || '0'), + typosquats: intOrZero(row[ecoTbl.headers[4]] || '0') + }; + }) : []; + return { ok: true, data: Object.assign({}, dash, { + ecosystems: ecosystems, + findings: parseFindingsTables(md), + recommendations: parseRecommendations(md) + }) }; +}); + +const parsePreDeploy = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const lightTbl = parseTable(md, /##\s+Traffic\s+Light\s+Categories/i); + const traffic_lights = lightTbl ? lightTbl.rows.map(function (row) { + const status = String(row[lightTbl.headers[1]] || '').toUpperCase().trim(); + return { + category: row[lightTbl.headers[0]] || '', + status: status, + notes: row[lightTbl.headers[2]] || '' + }; + }) : []; + const condSec = parseSections(md).find(function (s) { return /^conditions/i.test(s.heading); }); + const conditions = condSec ? condSec.body.split(/\r?\n/).map(function (l) { + const m = /^\s*\d+\.\s+(.+)$/.exec(l); + return m ? m[1].replace(/^\*\*[^*]+\*\*\s*[—:-]?\s*/, '').trim() : null; + }).filter(Boolean) : []; + const apprTbl = parseTable(md, /##\s+Approvals/i); + const approvals = apprTbl ? apprTbl.rows.map(function (row) { + return { + role: row[apprTbl.headers[0]] || '', + approver: row[apprTbl.headers[1]] || '', + date: row[apprTbl.headers[2]] || '', + notes: row[apprTbl.headers[3]] || '' + }; + }) : []; + return { ok: true, data: Object.assign({}, dash, { + traffic_lights: traffic_lights, + conditions: conditions, + approvals: approvals, + findings: parseFindingsTables(md), + recommendations: parseRecommendations(md) + }) }; +}); + +const parseDiff = safeOk(function (md) { + // NB: diff har egen severity-tabell (New/Resolved/Unchanged) — bruker + // ikke parseRiskDashboard sin Count-kolonne. + const dash = parseRiskDashboard(md); + const current_grade = gradeFromText(extractField(md, 'Current Grade') || dash.grade || ''); + const baseline_grade = gradeFromText(extractField(md, 'Baseline Grade') || ''); + const baseline_date = extractField(md, 'Baseline') || ''; + // Per-severity matrix (Severity | New | Resolved | Unchanged) + const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*New\s*\|\s*Resolved/i); + const severity_matrix = { critical: {}, high: {}, medium: {}, low: {}, info: {} }; + if (sevTbl) { + sevTbl.rows.forEach(function (row) { + const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, ''); + const key = /^crit/.test(label) ? 'critical' : + /^high/.test(label) ? 'high' : + /^medium/.test(label) ? 'medium' : + /^low/.test(label) ? 'low' : + /^info/.test(label) ? 'info' : null; + if (!key) return; + severity_matrix[key] = { + 'new': intOrZero(row[sevTbl.headers[1]] || '0'), + resolved: intOrZero(row[sevTbl.headers[2]] || '0'), + unchanged: intOrZero(row[sevTbl.headers[3]] || '0') + }; + }); + } + // Per-bucket finding-tabeller + const newTbl = parseTable(md, /##\s+New\s*\(?\d*\)?/i); + const newItems = newTbl ? newTbl.rows.map(function (row) { + const idKey = newTbl.headers[0]; + const sevKey = newTbl.headers.find(function (h) { return /severity/i.test(h); }); + const catKey = newTbl.headers.find(function (h) { return /category|kategori/i.test(h); }); + const fileKey = newTbl.headers.find(function (h) { return /file|fil/i.test(h); }); + const descKey = newTbl.headers.find(function (h) { return /description|beskriv/i.test(h); }); + const owaspKey = newTbl.headers.find(function (h) { return /owasp/i.test(h); }); + return { + id: row[idKey] || '', + severity: normalizeSeverity(sevKey ? row[sevKey] : ''), + category: catKey ? row[catKey] : '', + file: fileKey ? row[fileKey] : '', + description: descKey ? row[descKey] : '', + owasp: owaspKey ? row[owaspKey] : '' + }; + }) : []; + const resolvedTbl = parseTable(md, /##\s+Resolved\s*\(?\d*\)?/i); + const resolvedItems = resolvedTbl ? resolvedTbl.rows.map(function (row) { + const idKey = resolvedTbl.headers[0]; + const sevKey = resolvedTbl.headers.find(function (h) { return /severity/i.test(h); }); + const catKey = resolvedTbl.headers.find(function (h) { return /category|kategori/i.test(h); }); + const fileKey = resolvedTbl.headers.find(function (h) { return /file|fil/i.test(h); }); + const resKey = resolvedTbl.headers.find(function (h) { return /resolution|løsning/i.test(h); }); + return { + id: row[idKey] || '', + severity: normalizeSeverity(sevKey ? row[sevKey] : ''), + category: catKey ? row[catKey] : '', + file: fileKey ? row[fileKey] : '', + resolution: resKey ? row[resKey] : '' + }; + }) : []; + const unchangedTbl = parseTable(md, /##\s+Unchanged\s*\(?\d*\)?/i); + const unchangedItems = unchangedTbl ? unchangedTbl.rows.map(function (row) { + const idKey = unchangedTbl.headers[0]; + const sevKey = unchangedTbl.headers.find(function (h) { return /severity/i.test(h); }); + const catKey = unchangedTbl.headers.find(function (h) { return /category|kategori/i.test(h); }); + const fileKey = unchangedTbl.headers.find(function (h) { return /file|fil/i.test(h); }); + const noteKey = unchangedTbl.headers.find(function (h) { return /notes|note|merknad/i.test(h); }); + return { + id: row[idKey] || '', + severity: normalizeSeverity(sevKey ? row[sevKey] : ''), + category: catKey ? row[catKey] : '', + file: fileKey ? row[fileKey] : '', + notes: noteKey ? row[noteKey] : '' + }; + }) : []; + const movedTbl = parseTable(md, /##\s+Moved\s*\(?\d*\)?/i); + const movedItems = movedTbl ? movedTbl.rows.map(function (row) { + return { + id: row[movedTbl.headers[0]] || '', + from: row[movedTbl.headers[1]] || '', + to: row[movedTbl.headers[2]] || '' + }; + }) : []; + return { ok: true, data: Object.assign({}, dash, { + current_grade: current_grade, + baseline_grade: baseline_grade, + baseline_date: baseline_date, + severity_matrix: severity_matrix, + 'new': newItems, + resolved: resolvedItems, + unchanged: unchangedItems, + moved: movedItems, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseWatch = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const meterTbl = parseTable(md, /##\s+Live\s+Meter/i); + const live_meter = {}; + if (meterTbl) { + meterTbl.rows.forEach(function (row) { + const k = String(row[meterTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_'); + live_meter[k] = row[meterTbl.headers[1]] || ''; + }); + } + const histTbl = parseTable(md, /##\s+Recent\s+History/i); + const history = histTbl ? histTbl.rows.map(function (row) { + return { + run: row[histTbl.headers[0]] || '', + time: row[histTbl.headers[1]] || '', + grade: gradeFromText(row[histTbl.headers[2]] || ''), + risk_score: intOrZero(row[histTbl.headers[3]] || '0'), + delta: row[histTbl.headers[4]] || '' + }; + }) : []; + const notTbl = parseTable(md, /##\s+Notify\s+Events/i); + const notify_events = notTbl ? notTbl.rows.map(function (row) { + return { + time: row[notTbl.headers[0]] || '', + event: row[notTbl.headers[1]] || '', + channel: row[notTbl.headers[2]] || '', + status: row[notTbl.headers[3]] || '' + }; + }) : []; + return { ok: true, data: Object.assign({}, dash, { + live_meter: live_meter, + history: history, + notify_events: notify_events, + findings: parseFindingsTables(md), + recommendations: parseRecommendations(md), + interval: extractField(md, 'Interval') || '', + last_run: extractField(md, 'Last Run') || '' + }) }; +}); + +const parseRegistry = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const statsTbl = parseTable(md, /##\s+Registry\s+Stats/i); + const stats = {}; + if (statsTbl) { + statsTbl.rows.forEach(function (row) { + const k = String(row[statsTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_'); + stats[k] = row[statsTbl.headers[1]] || ''; + }); + } + const sigTbl = parseTable(md, /##\s+Signature\s+Table/i); + const signatures = sigTbl ? sigTbl.rows.map(function (row) { + return { + skill: row[sigTbl.headers[0]] || '', + source: row[sigTbl.headers[1]] || '', + fingerprint: row[sigTbl.headers[2]] || '', + status: String(row[sigTbl.headers[3]] || '').toUpperCase().trim(), + first_seen: row[sigTbl.headers[4]] || '' + }; + }) : []; + // Findings — bruk renderFindingsBlock men med skill+file som meta + const findingsRaw = parseFindingsTables(md); + const findings = findingsRaw.map(function (f) { + // Tabell-header: «Skill» som 3. kolonne maps til category i parseFindingsTables + return Object.assign({}, f, { + skill: f.category || '', + file: f.file || '' + }); + }); + return { ok: true, data: Object.assign({}, dash, { + stats: stats, + signatures: signatures, + findings: findings, + recommendations: parseRecommendations(md) + }) }; +}); + +const parseClean = safeOk(function (md) { + const dash = parseRiskDashboard(md); + const sumTbl = parseTable(md, /##\s+Remediation\s+Summary/i); + const summary = {}; + if (sumTbl) { + sumTbl.rows + .filter(function (row) { return !/^\s*\*\*total/i.test(row[sumTbl.headers[0]] || ''); }) + .forEach(function (row) { + const k = String(row[sumTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/[\s-]/g, '_'); + summary[k] = { + count: intOrZero(row[sumTbl.headers[1]] || '0'), + action: row[sumTbl.headers[2]] || '' + }; + }); + } + // Per-bucket-tabeller (Auto / Semi-auto / Manual / Suppressed) + const bucketParse = function (heading) { + const tbl = parseTable(md, new RegExp('##\\s+' + heading + '\\s*$', 'mi')); + if (!tbl || !tbl.rows.length) return []; + return tbl.rows.map(function (row) { + const idKey = tbl.headers[0]; + const actKey = tbl.headers[1]; + const descKey = tbl.headers[2]; + return { + id: row[idKey] || '', + action: row[actKey] || '', + description: row[descKey] || '' + }; + }); + }; + const buckets = { + auto: bucketParse('Auto'), + 'semi-auto': bucketParse('Semi-auto'), + manual: bucketParse('Manual'), + suppressed: bucketParse('Suppressed') + }; + return { ok: true, data: Object.assign({}, dash, { + summary: summary, + buckets: buckets, + findings: parseFindingsTables(md), + recommendations: parseRecommendations(md), + mode: extractField(md, 'Mode') || '' + }) }; +}); + +const parseThreatModel = safeOk(function (md) { + const dash = parseRiskDashboard(md); + // Risikomatrise: Trussel | Sannsynlighet | Konsekvens | Score + const matrixTbl = parseTable(md, /##\s+Risikomatrise/i); + const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) { + const labelKey = matrixTbl.headers[0]; + const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); }) || matrixTbl.headers[1]; + const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); }) || matrixTbl.headers[2]; + const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); }) || matrixTbl.headers[3]; + return { + label: row[labelKey] || '', + prob: intOrZero(row[sannKey] || '0'), + cons: intOrZero(row[konsKey] || '0'), + score: intOrZero(row[scoreKey] || '0') + }; + }) : []; + // Trusler: ID | Beskrivelse | Severity | Mitigation + const threatsTbl = parseTable(md, /##\s+Trusler/i); + const threats = threatsTbl ? threatsTbl.rows.map(function (row) { + const idKey = threatsTbl.headers[0]; + const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1]; + const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); }); + const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }); + return { + id: row[idKey] || '', + description: row[descKey] || '', + severity: normalizeSeverity(sevKey ? row[sevKey] : ''), + mitigation: mitKey ? row[mitKey] : '' + }; + }) : []; + // STRIDE / MAESTRO Coverage + const strideTbl = parseTable(md, /##\s+STRIDE\s+Coverage/i); + const stride = strideTbl ? strideTbl.rows.map(function (row) { + return { + category: row[strideTbl.headers[0]] || '', + count: intOrZero(row[strideTbl.headers[1]] || '0'), + notes: row[strideTbl.headers[2]] || '' + }; + }) : []; + const maestroTbl = parseTable(md, /##\s+MAESTRO\s+Coverage/i); + const maestro = maestroTbl ? maestroTbl.rows.map(function (row) { + return { + layer: row[maestroTbl.headers[0]] || '', + count: intOrZero(row[maestroTbl.headers[1]] || '0'), + notes: row[maestroTbl.headers[2]] || '' + }; + }) : []; + // Mitigation Roadmap + const roadTbl = parseTable(md, /##\s+Mitigation\s+Roadmap/i); + const roadmap = roadTbl ? roadTbl.rows.map(function (row) { + return { + priority: row[roadTbl.headers[0]] || '', + threat_id: row[roadTbl.headers[1]] || '', + mitigation: row[roadTbl.headers[2]] || '', + owner: row[roadTbl.headers[3]] || '', + eta: row[roadTbl.headers[4]] || '' + }; + }) : []; + return { ok: true, data: Object.assign({}, dash, { + matrix_cells: matrix_cells, + threats: threats, + stride: stride, + maestro: maestro, + roadmap: roadmap, + recommendations: parseRecommendations(md), + framework: extractField(md, 'Framework') || '' + }) }; +}); + +// ============================================================ +// PARSERS routing-map (commandId → parser). 18 produces_report=true. +// ============================================================ +const PARSERS = { + 'scan': parseScan, + 'deep-scan': parseDeepScan, + 'plugin-audit': parsePluginAudit, + 'mcp-audit': parseMcpAudit, + 'mcp-inspect': parseMcpInspect, + 'ide-scan': parseIdeScan, + 'supply-check': parseSupplyCheck, + 'posture': parsePosture, + 'audit': parseAudit, + 'dashboard': parseDashboard, + 'pre-deploy': parsePreDeploy, + 'diff': parseDiff, + 'watch': parseWatch, + 'registry': parseRegistry, + 'clean': parseClean, + 'harden': parseHarden, + 'threat-model': parseThreatModel, + 'red-team': parseRedTeam +}; + +// ============================================================ +// RENDERERS routing-map — populated inline after each renderer-fn. +// ============================================================ +const RENDERERS = {}; + +// ============================================================ +// RENDERER HELPERS +// ============================================================ +function renderEmptyState(message) { + return '
' + + '' + + '
' + + '

' + escapeHtml(message || 'Ingen data å vise.') + '

' + + '
' + + '
'; +} + +function renderFindingsBlock(findings, label) { + if (!findings || !findings.length) return ''; + const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; + const sorted = findings.slice().sort(function (a, b) { + return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9); + }); + const items = sorted.map(function (f) { + const sev = String(f.severity || 'info').toLowerCase(); + // DS Tier 3 (v7.6.0 fase 5h): card--severity-{level} modifier på outer + // .findings__item gir severity-tinted left-border. Beholdes ved siden av + // den eksisterende .findings__item-severity-dot for ARIA + visuell + // redundans (border-farge + dot-fyll signaliserer samme severity). + const sevClass = 'card--severity-' + (sev === 'info' ? 'info' : sev); + const meta = [ + f.file ? f.file + (f.line ? ':' + f.line : '') : '', + f.category || '', + f.owasp || '' + ].filter(Boolean).join(' · '); + return ( + '
' + + '
' + + '
' + + '
' + escapeHtml(f.id || '—') + '
' + + '
' + escapeHtml(f.description || f.title || '') + '
' + + (meta ? '
' + escapeHtml(meta) + '
' : '') + + '
' + + '
' + ); + }).join(''); + // DS .findings outer-class er et 2-kolonners grid (360px list + 1fr detail-panel) — + // playgroundet bruker bare list-delen, så vi wrapper i .findings__list (uten outer + // .findings) for å unngå at headeren ender i venstre 360px-kolonne. v7.6.1 fix. + return ( + '
' + + '

' + escapeHtml(label || 'Funn') + '

' + + '
' + + '
' + + '
' + escapeHtml(label || 'Funn') + ' (' + findings.length + ')
' + + '
' + items + '
' + + '
' + + '
' + + '
' + ); +} + +/** + * Render recommendation-card med ordnet liste av anbefalinger. + * Tredje argument (severity) styrer DS-tier3 `data-severity`-attributtet: + * 'critical' / 'high' / 'medium' / 'low' / 'positive'. Default 'low' + * (info-tonet). Mapping: severity → border-left-farge + label-bakgrunn. + */ +function renderRecommendationsList(recs, label, severity) { + if (!recs || !recs.length) return ''; + const sev = severity || 'low'; + const items = recs.map(function (r) { return '
  • ' + escapeHtml(r) + '
  • '; }).join(''); + return ( + '
    ' + + '' + escapeHtml(label || 'Anbefalinger') + '' + + '
      ' + items + '
    ' + + '
    ' + ); +} + +/** + * Map severity-string til DS-tier3 recommendation-card data-severity. + * Aksepterer både severity-konvensjoner (critical/high/medium/low/info) + * og action-types (CREATE/APPEND/MERGE/SKIP/NONE). + */ +function mapSeverityToCardLevel(input) { + const s = String(input || '').toLowerCase().trim(); + if (!s) return 'low'; + if (s === 'critical' || s === 'crit') return 'critical'; + if (s === 'high') return 'high'; + if (s === 'medium' || s === 'med') return 'medium'; + if (s === 'low') return 'low'; + if (s === 'info') return 'low'; + if (s === 'positive' || s === 'success' || s === 'ok' || s === 'pass') return 'positive'; + // Action-types fra renderHarden + if (s === 'create') return 'positive'; + if (s === 'append') return 'medium'; + if (s === 'merge') return 'low'; + if (s === 'skip' || s === 'none') return 'low'; + return 'low'; +} + +function renderRiskMeter(score, band) { + const s = Math.max(0, Math.min(100, Number(score) || 0)); + const bands = [ + { label: 'Low', from: 0, to: 14 }, + { label: 'Medium', from: 15, to: 39 }, + { label: 'High', from: 40, to: 64 }, + { label: 'Critical', from: 65, to: 84 }, + { label: 'Extreme', from: 85, to: 100 } + ]; + const labels = bands.map(function (b) { + const w = (b.to - b.from + 1); + return '' + escapeHtml(b.label) + ''; + }).join(''); + return ( + '
    ' + + '
    ' + s + ' / 100 · ' + escapeHtml(band || '') + '
    ' + + '
    ' + + '
    ' + labels + '
    ' + + '
    050100
    ' + + '
    ' + ); +} + +function renderSmallMultiples(items) { + // items: [{ name, score, max, grade?, status? }] + if (!items || !items.length) return ''; + const cards = items.map(function (it) { + const score = Number(it.score) || 0; + const max = Number(it.max) || 5; + const pct = Math.max(0, Math.min(100, (score / max) * 100)); + const grade = it.grade || ''; + const gradeAttr = grade ? ' data-grade="' + escapeAttr(grade) + '"' : ''; + return ( + '
    ' + + '
    ' + + '' + escapeHtml(it.name || '') + '' + + (grade ? '' + escapeHtml(grade) + '' : '') + + '
    ' + + '
    ' + + '' + escapeHtml(it.status || (score + ' / ' + max)) + '' + + '
    ' + ); + }).join(''); + return '
    ' + cards + '
    '; +} + +function renderRadarSvg(axes) { + // axes: [{ name, score (0-5) }] + if (!axes || axes.length < 3) return ''; + // v7.6.1 fix: øk SVG-bredden fra 280 til 380 og r fra 105 til 125 for å gi + // labels mer plass. Bruk text-anchor basert på horisontal-posisjon for å + // unngå at bottom-labels overlapper hverandre ved 6+ akser. + const size = 380, cx = size / 2, cy = size / 2, r = 125; + const n = axes.length; + const axisRows = axes.map(function (a) { + return '
    ' + escapeHtml(a.name) + '' + escapeHtml(String(a.score || 0)) + '/5
    '; + }).join(''); + const angle = function (i) { return -Math.PI / 2 + (i * 2 * Math.PI / n); }; + const labelHtml = axes.map(function (a, i) { + const ang = angle(i); + const lx = cx + Math.cos(ang) * (r + 28); + const ly = cy + Math.sin(ang) * (r + 28); + // Velg text-anchor basert på posisjon: ankerene til venstre/høyre snur. + const dx = Math.cos(ang); + const anchor = Math.abs(dx) < 0.2 ? 'middle' : (dx > 0 ? 'start' : 'end'); + return '' + escapeHtml(a.name) + ''; + }).join(''); + const grids = [1, 2, 3, 4, 5].map(function (k) { + const rk = (r * k) / 5; + const pts = axes.map(function (a, i) { + const ang = angle(i); + return (cx + Math.cos(ang) * rk).toFixed(1) + ',' + (cy + Math.sin(ang) * rk).toFixed(1); + }).join(' '); + return ''; + }).join(''); + const pts = axes.map(function (a, i) { + const ang = angle(i); + const sc = Math.max(0, Math.min(5, Number(a.score) || 0)); + const rs = (r * sc) / 5; + return (cx + Math.cos(ang) * rs).toFixed(1) + ',' + (cy + Math.sin(ang) * rs).toFixed(1); + }).join(' '); + return ( + '
    ' + + '
    ' + + '' + + grids + labelHtml + + '' + + '' + + '
    ' + + '
    ' + axisRows + '
    ' + + '
    ' + ); +} + +// ============================================================ +// TIER 3 SPESIALKOMPONENTER — DS-helpers (v7.6.0 fase 5a-d). +// ============================================================ + +/** + * Render tfa-flow + tfa-leg + tfa-arrow for et lethal trifecta-funn. + * Brukes på scan + deep-scan-rapporter når findings inneholder + * en trifecta-pattern (f.eks. SCN-002 "Lethal trifecta: [Bash, Read, WebFetch]"). + * Synthesiserer 3-leddet kjede: untrusted-input → sensitive-access → exfil-sink. + */ +function renderToxicFlow(findings) { + if (!findings || !findings.length) return ''; + const trifectaFinding = findings.find(function (f) { + const desc = String(f.description || ''); + const cat = String(f.category || ''); + const owasp = String(f.owasp || ''); + return /trifecta/i.test(desc) || /trifecta/i.test(cat) || + /excessive\s*agency/i.test(cat) || + /ASI01/i.test(owasp); + }); + if (!trifectaFinding) return ''; + const sev = String(trifectaFinding.severity || 'critical').toLowerCase(); + const verdictMap = { critical: 'BLOCK', high: 'BLOCK', medium: 'WARN', low: 'ALLOW' }; + const verdict = verdictMap[sev] || 'BLOCK'; + const fileLine = trifectaFinding.file + ? trifectaFinding.file + (trifectaFinding.line ? ':' + trifectaFinding.line : '') + : 'agent definition'; + // Default trifecta-bensin: WebFetch + Read + Bash. Override hvis description nevner andre. + const desc = String(trifectaFinding.description || ''); + const m = desc.match(/\[([^\]]+)\]/); + let tools = ['WebFetch', 'Read', 'Bash']; + if (m) { + const parsed = m[1].split(',').map(function (s) { return s.trim(); }).filter(Boolean); + if (parsed.length === 3) tools = parsed; + } + const legs = [ + { label: 'Untrusted input', name: tools[0], source: fileLine, mit: 'unmitigated', mitText: 'Ingen pre-prompt-inject-scan eller post-mcp-verify guard' }, + { label: 'Sensitive access', name: tools[1], source: '.env / credentials / git-history', mit: 'unmitigated', mitText: 'Ingen pre-write-pathguard på sti' }, + { label: 'Exfil sink', name: tools[2], source: 'curl / fetch til ekstern host', mit: 'unmitigated', mitText: 'Ingen post-session-guard trifecta-deteksjon' } + ]; + const legHtml = function (leg) { + return ( + '' + ); + }; + const arrowHtml = ''; + return ( + '
    ' + + '

    Toxic flow — Lethal trifecta-kjede

    ' + + '

    Den fulle 3-leddete kjeden som overskrider Rule of Two. Hver leg er umitigert — ingen hook bryter kjeden.

    ' + + '
    ' + + '
    ' + escapeHtml(verdict) + '
    ' + + legHtml(legs[0]) + arrowHtml + legHtml(legs[1]) + arrowHtml + legHtml(legs[2]) + + '
    ' + + '
    ' + ); +} + +/** + * Render mat-ladder + mat-step for posture-modenhet. + * Mapper antall PASS-kategorier til 5 modenhetstrinn (Initial → Optimized). + */ +function renderMatLadder(categories, postureScore, postureApplicable) { + if (!categories || !categories.length) return ''; + const passCount = postureScore != null + ? Number(postureScore) + : categories.filter(function (c) { return c.status === 'PASS'; }).length; + const total = postureApplicable != null + ? Number(postureApplicable) + : categories.filter(function (c) { return c.status !== 'N-A' && c.status !== 'N/A'; }).length; + const pct = total > 0 ? Math.round((passCount / total) * 100) : 0; + // 5 modenhetstrinn — terskler basert på % PASS + const steps = [ + { num: 1, name: 'Initial', threshold: 0, desc: 'Bare bones — ingen hooks eller minimal posture.' }, + { num: 2, name: 'Aware', threshold: 25, desc: 'Posture-skanning aktiv, kjenner risikoene.' }, + { num: 3, name: 'Defensive', threshold: 50, desc: 'Hooks engasjert på kritiske flater (PreToolUse, UserPromptSubmit).' }, + { num: 4, name: 'Mature', threshold: 75, desc: 'De fleste 16 kategoriene dekket; trifecta-deteksjon på.' }, + { num: 5, name: 'Optimized', threshold: 95, desc: 'Full coverage; A-grade på posture; aktiv overvåking.' } + ]; + const currentIdx = steps.reduce(function (acc, s, i) { + return pct >= s.threshold ? i : acc; + }, 0); + const stepHtml = steps.map(function (s, i) { + const state = i < currentIdx ? 'completed' : i === currentIdx ? 'current' : 'future'; + const icon = state === 'completed' ? '✓' : String(s.num); + const pillCls = state === 'current' ? ' mat-step__pill mat-step__pill--current' : + state === 'completed' ? ' mat-step__pill mat-step__pill--complete' : ''; + const pillText = state === 'current' ? 'Du er her' : state === 'completed' ? 'Oppnådd' : ''; + const pill = pillText ? '' + escapeHtml(pillText) + '' : ''; + const progress = state === 'current' ? ( + '
    ' + + '
    ' + + '' + passCount + ' / ' + total + ' kategorier' + + '
    ' + ) : ''; + return ( + '
    ' + + '' + + '
    ' + + '
    ' + escapeHtml(s.name) + pill + '
    ' + + '
    ' + escapeHtml(s.desc) + '
    ' + + progress + + '
    ' + + '
    ' + ); + }).join(''); + return ( + '
    ' + + '

    Modenhetsstige — posture-progresjon

    ' + + '

    Posture-score på ' + passCount + ' av ' + total + ' kategorier (' + pct + '%) plasserer dette prosjektet på trinn ' + (currentIdx + 1) + ' av 5.

    ' + + '
    ' + stepHtml + '
    ' + + '
    ' + ); +} + +/** + * Render suppressed-group fra v7.1.1 narrative-audit. + * Parser executive_summary-tekst for "Suppressed signals: N (reason1: count examples, ...)" + * eller bruker data.narrative_audit.suppressed_findings hvis strukturert. + */ +function renderSuppressedGroup(data) { + if (!data) return ''; + const audit = data.narrative_audit || {}; + const sf = audit.suppressed_findings || {}; + let groups = []; + let totalCount = 0; + if (sf.by_category && typeof sf.by_category === 'object') { + totalCount = Number(sf.count || 0); + groups = Object.keys(sf.by_category).map(function (k) { + return { reason: k, count: Number(sf.by_category[k]) || 0, example: '' }; + }); + } else { + // Fall back: parse fra executive_summary + const summary = String(data.executive_summary || ''); + const m = summary.match(/Suppressed signals:\s*\*?\*?\s*(\d+)\s*\(([^)]+)\)/i); + if (!m) return ''; + totalCount = Number(m[1]) || 0; + groups = m[2].split(',').map(function (part) { + const seg = part.trim(); + const colonIdx = seg.indexOf(':'); + if (colonIdx < 0) return { reason: seg, count: 1, example: '' }; + const reason = seg.slice(0, colonIdx).trim(); + const rest = seg.slice(colonIdx + 1).trim(); + const cm = rest.match(/^(\d+)\s+(.*)$/); + if (cm) { + return { reason: reason, count: Number(cm[1]) || 1, example: cm[2].trim() }; + } + return { reason: reason, count: 1, example: rest }; + }); + } + if (!groups.length) return ''; + const groupsHtml = groups.map(function (g) { + const example = g.example ? ( + '
    ' + + '' + escapeHtml(g.example) + '' + + '
    ' + ) : ''; + return ( + '
    ' + + '
    ' + + '' + escapeHtml(g.reason) + '' + + '' + g.count + ' ' + (g.count === 1 ? 'forekomst' : 'forekomster') + '' + + '
    ' + + example + + '
    ' + ); + }).join(''); + return ( + '
    ' + + '

    Narrative audit — supprimerte signaler

    ' + + '

    ' + totalCount + ' signaler ble supprimert pre-rapport (v7.1.1 narrative_audit). Disse er ikke false-positives walked-back i prosa, men auto-suppress før klassifisering.

    ' + + groupsHtml + + '
    ' + ); +} + +/** + * Render codepoint-reveal + cp-tag for Unicode-steganografi (UNI-funn). + * Brukes på mcp-inspect-rapporter — bytter plain table mot side-by-side + * "synlig vs. decoded codepoint"-visning per tool. + */ +function renderCodepointReveal(codepoints) { + if (!codepoints || !codepoints.length) return ''; + const tagFor = function (code) { + // U+200B/200C/200D/FEFF = zero-width + if (/U\+(200[B-D]|FEFF|2060|180E)/i.test(code)) return 'cp-zw'; + // U+202E/202D/2066-2069 = bidi/RTL + if (/U\+(202[ADE]|206[6-9])/i.test(code)) return 'cp-bidi'; + // Other = generic cp-tag (warning class) + return 'cp-tag'; + }; + const blocks = codepoints.map(function (c) { + const risk = String(c.risk || '').trim(); + const sev = /high/i.test(risk) ? 'critical' : /medium/i.test(risk) ? 'medium' : 'low'; + const isClean = /clean|—|^-$/i.test(c.codepoints || '') || risk === '—' || risk === '-'; + const cps = String(c.codepoints || ''); + // Highlight U+XXXX-mønstre + const highlighted = cps.replace(/U\+[0-9A-Fa-f]{4,6}/g, function (m) { + return '' + m + ''; + }); + const headRisk = isClean + ? 'Ren — ingen non-ASCII' + : '' + escapeHtml(risk) + ' risk'; + const visibleCol = isClean + ? '
    ' + escapeHtml(c.tool || '—') + '
    ' + : '
    ' + escapeHtml(c.tool || '—') + ' (rendert visuelt)
    '; + const decodedCol = isClean + ? '
    (ingen suspekte codepoints)
    ' + : '
    ' + highlighted + '
    '; + return ( + '
    ' + + '
    ' + + '' + escapeHtml(c.server || '—') + ' · ' + escapeHtml(c.tool || '—') + '' + + headRisk + + '
    ' + + '
    ' + + '
    ' + + 'Synlig (rendret tekst)' + + visibleCol + + '
    ' + + '
    ' + + 'Decoded (codepoints)' + + decodedCol + + '
    ' + + '
    ' + + '
    ' + ); + }).join(''); + return ( + '
    ' + + '

    Codepoint-reveal — Unicode-steganografi

    ' + + '

    Tools med non-ASCII codepoints i deskripsjoner — zero-width / homoglyph / bidi-override. Side-ved-side: synlig form vs. dekoded codepoints.

    ' + + '
    ' + blocks + '
    ' + + '
    ' + ); +} + +/** + * Render top-risks + top-risk for rangert top-funn-listing. + * Tar de N (default 5) høyeste alvorlighetsnivåene fra findings og + * viser dem som ordnet liste. Bruker `.top-risks` / `.top-risk` med + * `data-severity` for severity-tinted left-border per DS Tier 3-supplement. + * Returnerer tom streng hvis ingen findings (eller kun info-funn). + */ +function renderTopRisks(findings, n) { + if (!findings || !findings.length) return ''; + const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; + const max = typeof n === 'number' && n > 0 ? n : 5; + // Filtrer ut info-only — top-risks viser reelle risker, ikke observability-noise + const filtered = findings.filter(function (f) { + return (f.severity || 'info').toLowerCase() !== 'info'; + }); + if (!filtered.length) return ''; + const sorted = filtered.slice().sort(function (a, b) { + return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9); + }); + const top = sorted.slice(0, max); + const items = top.map(function (f, idx) { + const sev = String(f.severity || 'info').toLowerCase(); + const sevLabel = sev.toUpperCase(); + const meta = [ + f.file ? f.file + (f.line ? ':' + f.line : '') : '', + f.id || '', + f.owasp || '' + ].filter(Boolean).join(' · '); + const title = f.description || f.title || '—'; + return ( + '
  • ' + + '
    ' + (idx + 1) + '
    ' + + '
    ' + + '
    ' + escapeHtml(title) + '
    ' + + (meta ? '
    ' + escapeHtml(meta) + '
    ' : '') + + '
    ' + + '' + escapeHtml(sevLabel) + '' + + '
  • ' + ); + }).join(''); + return ( + '
    ' + + '

    Top ' + top.length + ' risks

    ' + + '
      ' + items + '
    ' + + '
    ' + ); +} + +// ============================================================ +// 10 RENDERERS — én per høy-prio kommando. +// ============================================================ +function renderScan(data, slot) { + const meterHtml = renderRiskMeter(data.risk_score, data.riskBand); + const suppressedHtml = renderSuppressedGroup(data); + const toxicHtml = renderToxicFlow(data.findings || []); + const owaspHtml = (data.owasp && data.owasp.length) ? ( + '

    OWASP-kategorier

    ' + + '' + + data.owasp.map(function (o) { + return ''; + }).join('') + + '
    KategoriFunnMaks severitySkannere
    ' + escapeHtml(o.category) + '' + o.findings + '' + escapeHtml(o.max_severity) + '' + escapeHtml(o.scanners) + '
    ' + + '
    ' + ) : ''; + const supplyHtml = (data.supply_chain && data.supply_chain.length) ? ( + '

    Supply chain

    ' + + '' + + data.supply_chain.map(function (s) { + return ''; + }).join('') + + '
    KomponentTypeKildeTrustNotater
    ' + escapeHtml(s.component) + '' + escapeHtml(s.type) + '' + escapeHtml(s.source) + '' + escapeHtml(s.trust) + '' + escapeHtml(s.notes) + '
    ' + + '
    ' + ) : ''; + const topRisksHtml = renderTopRisks(data.findings || [], 5); + const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = meterHtml + suppressedHtml + toxicHtml + topRisksHtml + owaspHtml + supplyHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'SKANNING', + title: data.title || 'Security Scan', + lede: data.lede || (data.executive_summary ? data.executive_summary.split('\n')[0].slice(0, 220) : 'Skann av skills, MCP-konfig, kataloger eller GitHub-URL.'), + verdict: data.verdict || inferVerdict(data, 'risk-score-meter'), + keyStats: data.keyStats || inferKeyStats(data, 'risk-score-meter') + }, body); +} +RENDERERS.renderScan = renderScan; + +function renderDeepScan(data, slot) { + // Per-scanner small-multiples + const sm = (data.scanners || []).map(function (s) { + const okStatus = /ok/i.test(s.status || '') ? 'ok' : (s.status || 'unknown'); + const grade = (s.findings === 0) ? 'A' : (s.findings <= 3) ? 'B' : (s.findings <= 8) ? 'C' : (s.findings <= 15) ? 'D' : 'F'; + return { + name: s.tag + ' · ' + s.name, + score: Math.max(0, 5 - Math.min(5, Math.floor((s.findings || 0) / 3))), + max: 5, + grade: grade, + status: s.findings + ' funn · ' + (s.duration_ms || 0) + 'ms · ' + okStatus + }; + }); + const smHtml = renderSmallMultiples(sm); + // Scanner Risk Matrix-tabell + const matrixRows = (data.scanner_matrix || []).map(function (r) { + return '' + escapeHtml(r.scanner) + '' + + '' + r.critical + '' + + '' + r.high + '' + + '' + r.medium + '' + + '' + r.low + '' + + '' + r.info + ''; + }).join(''); + const matrixHtml = matrixRows ? ( + '

    Scanner Risk Matrix

    ' + + '' + + matrixRows + '
    ScannerCRITHIGHMEDLOWINFO
    ' + + '
    ' + ) : ''; + const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : ''; + const topRisksHtml = renderTopRisks(data.findings || [], 5); + const findingsHtml = renderFindingsBlock(data.findings || [], 'Findings (utvalg)'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const suppressedHtml = renderSuppressedGroup(data); + const toxicHtml = renderToxicFlow(data.findings || []); + const body = meterHtml + suppressedHtml + toxicHtml + smHtml + matrixHtml + topRisksHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'DEEP-SCAN', + title: data.title || 'Deterministisk deep-scan', + lede: data.lede || '10 deterministiske Node.js-scannere, ingen LLM-invokasjon.', + verdict: data.verdict || inferVerdict(data, 'findings-grade'), + keyStats: data.keyStats || inferKeyStats(data, 'findings-grade') + }, body); +} +RENDERERS.renderDeepScan = renderDeepScan; + +function renderPluginAudit(data, slot) { + const meta = data.plugin_metadata || {}; + const metaRows = Object.keys(meta).map(function (k) { + return '' + escapeHtml(k.replace(/_/g, ' ')) + '' + escapeHtml(meta[k]) + ''; + }).join(''); + const metaHtml = metaRows ? '

    Plugin-metadata

    ' + metaRows + '
    ' : ''; + const compHtml = (data.components && data.components.length) ? ( + '

    Komponenter

    ' + + '' + + data.components.map(function (c) { + return ''; + }).join('') + + '
    KomponentAntallNotater
    ' + escapeHtml(c.component) + '' + c.count + '' + escapeHtml(c.notes) + '
    ' + + '
    ' + ) : ''; + const permHtml = (data.permissions && data.permissions.length) ? ( + '

    Permission-matrise

    ' + + '' + + data.permissions.map(function (p) { + const isYes = /^yes|^ja/i.test(p.justified); + const isNo = /^no$|^nei/i.test(p.justified); + const cls = isYes ? 'low' : (isNo ? 'critical' : 'medium'); + return ''; + }).join('') + + '
    VerktøyKrevet avBegrunnet
    ' + escapeHtml(p.tool) + '' + escapeHtml(p.required_by) + '' + escapeHtml(p.justified) + '
    ' + + '
    ' + ) : ''; + const trustSev = (function () { + const t = String(data.trust_verdict_text || '').toLowerCase(); + if (/block|fail|critical|do\s*not\s*install/i.test(t)) return 'critical'; + if (/warn|caution|review|conditional/i.test(t)) return 'high'; + if (/allow|trust|verified|pass/i.test(t)) return 'positive'; + return 'medium'; + })(); + const trustHtml = data.trust_verdict_text ? ( + '
    ' + + 'Trust-verdict' + + '

    ' + escapeHtml(data.trust_verdict_text).replace(/\n/g, '
    ') + '

    ' + + '
    ' + ) : ''; + const topRisksHtml = renderTopRisks(data.findings || [], 5); + const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = renderRiskMeter(data.risk_score, data.riskBand) + metaHtml + compHtml + permHtml + trustHtml + topRisksHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'PLUGIN-AUDIT', + title: data.title || 'Plugin trust-vurdering', + lede: data.lede || 'Trust-verdikt basert på maintainer, lisens, permissions og MCP-deskripsjoner.', + verdict: data.verdict || inferVerdict(data, 'risk-score-meter'), + keyStats: data.keyStats || inferKeyStats(data, 'risk-score-meter') + }, body); +} +RENDERERS.renderPluginAudit = renderPluginAudit; + +function renderMcpAudit(data, slot) { + const landRows = (data.mcp_servers || []).map(function (s) { + return '' + + '' + escapeHtml(s.server) + '' + + '' + escapeHtml(s.type) + '' + + '' + escapeHtml(s.trust) + '' + + '' + s.tools + '' + + '' + (s.active ? 'aktiv' : 'dormant') + '' + + ''; + }).join(''); + const landHtml = landRows ? ( + '

    MCP-landskap

    ' + + '' + landRows + '
    ServerTypeTrustToolsStatus
    ' + + '
    ' + ) : ''; + // Per-server som critique-cards + const psHtml = (data.per_server && data.per_server.length) ? ( + '
    ' + data.per_server.map(function (p) { + const sev = /(verdict:.*BLOCK|verdict:.*FAIL|critical)/i.test(p.body) ? 'critical' : + /(verdict:.*WARNING|warn|medium|drift)/i.test(p.body) ? 'medium' : + 'low'; + const lines = p.body.split(/\r?\n/).slice(0, 6).join(' '); + return '
    ' + + '
    ' + + '
    ' + escapeHtml(p.name) + '
    ' + + (p.note ? '
    ' + escapeHtml(p.note) + '
    ' : '') + + '
    ' + + '
    ' + escapeHtml(lines.slice(0, 360)) + (lines.length > 360 ? '…' : '') + '
    ' + + '
    '; + }).join('') + '
    ' + ) : ''; + // Keep / Review / Remove kanban + const buckets = data.buckets || { keep: [], review: [], remove: [] }; + const cardFor = function (bucket, label) { + const items = buckets[bucket] || []; + const cards = items.length ? items.map(function (it) { + return '
    ' + + '
    ' + escapeHtml(it.server) + '
    ' + + (it.reason ? '
    ' + escapeHtml(it.reason) + '
    ' : '') + + '
    '; + }).join('') : '
    Ingen
    '; + return '
    ' + + '
    ' + + '' + escapeHtml(label) + '' + + '' + items.length + '' + + '
    ' + cards + '
    '; + }; + const kanbanHtml = '
    ' + + cardFor('keep', 'Keep') + + cardFor('review', 'Review') + + cardFor('remove', 'Remove') + + '
    '; + const findingsHtml = renderFindingsBlock(data.findings || [], 'MCP-funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = landHtml + psHtml + kanbanHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'MCP-AUDIT', + title: data.title || 'MCP-konfig audit', + lede: data.lede || 'Permissions, trust og deskripsjon-drift på tvers av installerte MCP-servere.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderMcpAudit = renderMcpAudit; + +function renderIdeScan(data, slot) { + const covRows = (data.coverage || []).map(function (c) { + return '' + escapeHtml(c.ide) + '' + c.extensions + '' + c.findings + ''; + }).join(''); + const covHtml = covRows ? ( + '

    Scan-dekning

    ' + + '' + covRows + '
    IDEExtensionsFunn
    ' + + '
    ' + ) : ''; + // Findings — bruk renderFindingsBlock men med extension+ide som meta + const fs = (data.findings || []).map(function (f) { + return Object.assign({}, f, { + file: f.extension || f.file || '', + category: f.ide || '' + }); + }); + const findingsHtml = renderFindingsBlock(fs, 'IDE-extension funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = covHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'IDE-SCAN', + title: data.title || 'IDE-extension scan', + lede: data.lede || 'VS Code + JetBrains supply-chain-sjekk, blocklist + typosquat + obfuskering.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderIdeScan = renderIdeScan; + +function renderPosture(data, slot) { + // Small-multiples per kategori + const items = (data.categories || []).filter(function (c) { + return c.status !== 'N-A' && c.status !== 'N/A'; + }).map(function (c) { + const score = c.status === 'PASS' ? 5 : c.status === 'PARTIAL' ? 3 : c.status === 'FAIL' ? 1 : 0; + const grade = c.status === 'PASS' ? 'A' : c.status === 'PARTIAL' ? 'C' : c.status === 'FAIL' ? 'F' : ''; + return { + name: c.num + '. ' + c.name, + score: score, + max: 5, + grade: grade, + status: c.status + (c.findings ? ' · ' + c.findings + ' funn' : '') + }; + }); + const smHtml = renderSmallMultiples(items); + const ladderHtml = renderMatLadder(data.categories || [], data.posture_score, data.posture_applicable); + // Quick wins + const quickHtml = (data.quick_wins && data.quick_wins.length) ? ( + '
    ' + + 'Quick wins' + + '
      ' + + data.quick_wins.map(function (w) { return '
    1. ' + escapeHtml(w) + '
    2. '; }).join('') + + '
    ' + + '
    ' + ) : ''; + const topRisksHtml = renderTopRisks(data.findings || [], 5); + const findingsHtml = renderFindingsBlock(data.findings || [], 'Top findings'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const overall = data.posture_score != null ? ( + '

    Overall score

    ' + data.posture_score + ' / ' + (data.posture_applicable || '?') + ' kategorier dekket — Grade ' + escapeHtml(data.grade || '?') + '.

    ' + ) : ''; + const body = overall + ladderHtml + smHtml + quickHtml + topRisksHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'POSTURE', + title: data.title || 'Security posture', + lede: data.lede || 'Rask scorecard, deterministisk scanner, <2s.', + verdict: data.verdict || inferVerdict(data, 'posture-cards'), + keyStats: data.keyStats || inferKeyStats(data, 'posture-cards') + }, body); +} +RENDERERS.renderPosture = renderPosture; + +function renderAudit(data, slot) { + const radarHtml = renderRadarSvg(data.radar_axes || []); + // Category Assessment som expansion-kort + const catHtml = (data.categories && data.categories.length) ? ( + '

    Kategori-vurdering

    ' + + '
    ' + data.categories.map(function (c) { + const sev = c.status === 'FAIL' ? 'critical' : c.status === 'PARTIAL' ? 'medium' : c.status === 'PASS' ? 'low' : 'info'; + const sevClass = 'card--severity-' + sev; + return '
    ' + + '
    ' + + '
    ' + + '
    Kat. ' + c.num + '
    ' + + '
    ' + escapeHtml(c.name) + '
    ' + + '
    Status: ' + escapeHtml(c.status || '—') + '
    ' + + '
    ' + + '
    '; + }).join('') + '
    ' + + '
    ' + ) : ''; + // Action Plan tre-tier + const tierHtml = function (tier, label, sev) { + const items = (data.action_plan && data.action_plan[tier]) || []; + if (!items.length) return ''; + return '
    ' + + '' + escapeHtml(label) + '' + + '
      ' + items.map(function (a) { return '
    1. ' + escapeHtml(a) + '
    2. '; }).join('') + '
    ' + + '
    '; + }; + const actionHtml = tierHtml('immediate', 'Umiddelbar', 'critical') + tierHtml('high', 'Høy prioritet', 'high') + tierHtml('medium', 'Medium prioritet', 'medium'); + const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : ''; + const topRisksHtml = renderTopRisks(data.findings || [], 5); + const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn'); + const body = meterHtml + radarHtml + catHtml + actionHtml + topRisksHtml + findingsHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'AUDIT', + title: data.title || 'Full security audit', + lede: data.lede || 'OWASP LLM Top 10-vurdering, A-F grading, action plan.', + verdict: data.verdict || inferVerdict(data, 'findings-grade'), + keyStats: data.keyStats || inferKeyStats(data, 'findings-grade') + }, body); +} +RENDERERS.renderAudit = renderAudit; + +function renderDashboard(data, slot) { + // Fleet-grid med fleet-tile per prosjekt + const projects = data.projects || []; + const sevForGrade = function (g) { + const u = String(g || '').toUpperCase(); + if (u === 'A') return 'low'; + if (u === 'B') return 'low'; + if (u === 'C') return 'medium'; + if (u === 'D') return 'high'; + if (u === 'F') return 'critical'; + return 'info'; + }; + const tiles = projects.length ? projects.map(function (p) { + const trend = (data.trends || []).find(function (t) { return t.name === p.name; }); + const trendCls = trend ? ('fleet-tile__trend--' + trend.trend) : 'fleet-tile__trend--stable'; + const fillPct = Math.max(0, Math.min(100, p.risk)); + return ( + '
    ' + + '
    ' + + '' + escapeHtml(p.name) + '' + + '' + escapeHtml(p.grade || '?') + '' + + '
    ' + + '
    ' + + '
    ' + + 'Risk ' + p.risk + ' · ' + p.findings + ' funn' + + (trend ? '' + escapeHtml(trend.d_risk) + '' : '') + + '
    ' + + (p.worst_category ? '
    Verst: ' + escapeHtml(p.worst_category) + '
    ' : '') + + '
    ' + ); + }).join('') : ''; + const gridHtml = tiles ? '
    ' + tiles + '
    ' : renderEmptyState('Ingen prosjekter funnet.'); + // Errors + const errorsHtml = (data.errors && data.errors.length) ? ( + '

    Errors

    ' + + '' + + data.errors.map(function (e) { return ''; }).join('') + + '
    ProsjektFeil
    ' + escapeHtml(e.project) + '' + escapeHtml(e.error) + '
    ' + + '
    ' + ) : ''; + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = gridHtml + errorsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'DASHBOARD', + title: data.title || 'Cross-project dashboard', + lede: data.lede || 'Maskin-grade = svakeste lenke. Aggregert posture-skann per prosjekt.', + verdict: data.verdict || inferVerdict(data, 'dashboard-fleet'), + keyStats: data.keyStats || inferKeyStats(data, 'dashboard-fleet') + }, body); +} +RENDERERS.renderDashboard = renderDashboard; + +function renderHarden(data, slot) { + const recs = data.recommendations || []; + // Diff-blokker per recommendation — DS Tier 3 recommendation-card med data-severity (v7.6.0 fase 5f). + // CREATE → positive (ny grade A-fil), APPEND → medium (eksisterende fil utvides), + // MERGE → low (allerede satt, kun normalisering), SKIP → low (ingen handling). + const diffHtml = recs.map(function (r, idx) { + const isCreate = /create/i.test(r.action); + const isAppend = /append/i.test(r.action); + const isMerge = /merge/i.test(r.action); + const isNone = /none|skip/i.test(r.action); + const actionLabel = isCreate ? 'CREATE' : isAppend ? 'APPEND' : isMerge ? 'MERGE' : 'SKIP'; + const sev = mapSeverityToCardLevel(actionLabel); + return ( + '
    ' + + '' + actionLabel + ' · ' + escapeHtml(String(r.num)) + '. ' + escapeHtml(r.category) + '' + + '
    ' + + '
    ' + escapeHtml(r.file) + '
    ' + + (r.content_preview ? '
    ' + escapeHtml(r.content_preview).slice(0, 600) + (r.content_preview.length > 600 ? '…' : '') + '
    ' : '') + + '
    ' + + '
    ' + ); + }).join(''); + // Diff summary footer + const summaryRows = (data.diff_summary || []).map(function (d) { + return '
    ' + escapeHtml(d.file) + '' + escapeHtml(d.action) + ' · ' + escapeHtml(d.lines) + '
    '; + }).join(''); + const summaryHtml = summaryRows ? '
    ' + summaryRows + '
    ' : ''; + const introSev = (function () { + const g = String(data.current_grade || '?').toUpperCase(); + if (g === 'F' || g === 'D') return 'critical'; + if (g === 'C') return 'high'; + if (g === 'B') return 'medium'; + if (g === 'A') return 'positive'; + return 'medium'; + })(); + const intro = ( + '
    ' + + 'Snapshot · grade ' + escapeHtml(data.current_grade || '?') + '' + + '

    Prosjekt-type: ' + escapeHtml(data.project_type || '?') + ' · ' + data.actionable + '/' + data.total + ' anbefalinger · Modus: ' + escapeHtml(data.mode || 'dry-run') + '

    ' + + '
    ' + ); + const body = intro + (diffHtml || renderEmptyState('Ingen anbefalinger.')) + summaryHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'HARDEN', + title: data.title || 'Grade A reference config', + lede: data.lede || 'Diff-forhåndsvisning av settings.json, CLAUDE.md og .gitignore-endringer.', + verdict: data.verdict || inferVerdict(data, 'diff-report'), + keyStats: data.keyStats || [ + { label: 'NÅ-GRADE', value: String(data.current_grade || '?') }, + { label: 'AKSJONER', value: data.actionable + '/' + data.total }, + { label: 'MODUS', value: data.mode || 'dry-run' } + ] + }, body); +} +RENDERERS.renderHarden = renderHarden; + +function renderRedTeam(data, slot) { + const meterHtml = renderRiskMeter(100 - (data.defense_score || 0), data.riskBand); + // Per-category small-multiples + const cats = (data.categories || []).map(function (c) { + const total = (c.pass || 0) + (c.fail || 0); + const score = total ? Math.round((c.pass / total) * 5) : 0; + const grade = total === 0 ? '?' : c.fail === 0 ? 'A' : c.fail <= 1 ? 'B' : c.fail <= 3 ? 'C' : 'D'; + return { + name: c.category, + score: score, + max: 5, + grade: grade, + status: c.pass + ' pass · ' + c.fail + ' fail' + }; + }); + const smHtml = renderSmallMultiples(cats); + // Failed scenarios som findings + const scnFindings = (data.scenarios || []).map(function (s) { + return { + id: s.id, + severity: s.severity, + category: s.category, + description: s.payload_class + ' — ' + s.reason, + owasp: '' + }; + }); + const findingsHtml = renderFindingsBlock(scnFindings, 'Failed scenarios'); + // History + const historyRows = (data.history || []).map(function (h) { + return '' + escapeHtml(h.run) + '' + escapeHtml(h.date) + '' + h.defense_score + '%' + escapeHtml(h.delta) + ''; + }).join(''); + const historyHtml = historyRows ? ( + '

    Defense score-historikk

    ' + + '' + historyRows + '
    RunDatoScoreΔ
    ' + + '
    ' + ) : ''; + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = meterHtml + smHtml + findingsHtml + historyHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'RED-TEAM', + title: data.title || 'Attack-simulasjon', + lede: data.lede || (data.adaptive ? 'Adaptive mode aktiv (mutation-based evasion).' : 'Statisk mode — 64 deterministiske scenarios.'), + verdict: data.verdict || inferVerdict(data, 'red-team-results'), + keyStats: data.keyStats || inferKeyStats(data, 'red-team-results') + }, body); +} +RENDERERS.renderRedTeam = renderRedTeam; + +// ============================================================ +// FASE 3: 8 RENDERERS — én per gjenstående kommando. +// ============================================================ +function renderMcpInspect(data, slot) { + const invRows = (data.server_inventory || []).map(function (s) { + return '' + + '' + escapeHtml(s.server) + '' + + '' + escapeHtml(s.transport) + '' + + '' + s.tools + '' + + '' + escapeHtml(s.status) + '' + + '' + (s.connected ? 'ja' : 'nei') + '' + + ''; + }).join(''); + const invHtml = invRows ? ( + '

    Server-inventar

    ' + + '' + invRows + '
    ServerTransportToolsStatusConnected
    ' + + '
    ' + ) : ''; + const cpHtml = renderCodepointReveal(data.codepoints || []); + const fs = (data.findings || []).map(function (f) { + return Object.assign({}, f, { + file: f.server || f.file || '', + category: f.category || '' + }); + }); + const findingsHtml = renderFindingsBlock(fs, 'MCP-inspect funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = invHtml + cpHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'MCP-INSPECT', + title: data.title || 'MCP live-inspect', + lede: data.lede || 'Runtime tool-deskripsjoner — drift, tool shadowing, codepoint reveal.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderMcpInspect = renderMcpInspect; + +function renderSupplyCheck(data, slot) { + // Ecosystem-cards (small-multiples-mønster) + const ecos = (data.ecosystems || []).filter(function (e) { return Number(e.packages) > 0 || Number(e.osv_hits) > 0 || Number(e.typosquats) > 0; }); + const ecoCards = ecos.length ? '
    ' + ecos.map(function (e) { + const issues = (Number(e.osv_hits) || 0) + (Number(e.typosquats) || 0); + const grade = issues === 0 ? 'A' : issues <= 1 ? 'B' : issues <= 3 ? 'C' : issues <= 6 ? 'D' : 'F'; + const score = Math.max(0, 5 - Math.min(5, issues)); + const fillPct = (score / 5) * 100; + return '
    ' + + '
    ' + + '' + escapeHtml(e.ecosystem) + '' + + '' + escapeHtml(grade) + '' + + '
    ' + + '
    ' + + '' + e.packages + ' pakker · ' + e.osv_hits + ' OSV · ' + e.typosquats + ' typosquats' + + '
    '; + }).join('') + '
    ' : ''; + const findingsHtml = renderFindingsBlock(data.findings || [], 'Supply-chain funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = ecoCards + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'SUPPLY-CHECK', + title: data.title || 'Supply-chain recheck', + lede: data.lede || 'Re-audit lockfiler mot blocklists, OSV.dev og typosquat-deteksjon.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderSupplyCheck = renderSupplyCheck; + +function renderPreDeploy(data, slot) { + const lights = data.traffic_lights || []; + const sevForStatus = function (s) { + const u = String(s || '').toUpperCase(); + if (u === 'PASS' || u === 'GO') return 'low'; + if (u === 'PASS-WITH-NOTES' || u === 'WARNING' || u === 'PARTIAL') return 'medium'; + if (u === 'FAIL' || u === 'BLOCK' || u === 'NO-GO') return 'critical'; + return 'info'; + }; + // v7.6.1 fix: sm-card__grade er fast 28×28 px (designet for én A-F-bokstav), så + // "PASS"/"PASS-WITH-NOTES"/"FAIL" ble kuttet til "AS"/"PASS-WITH-..."/"FA". Bytt + // til en bredde-tilpasset status-pill via inline styling (ingen DS-klasse-endring). + const cards = lights.map(function (l) { + const sev = sevForStatus(l.status); + const pillBg = sev === 'low' ? 'var(--color-severity-low-soft)' + : sev === 'medium' ? 'var(--color-severity-medium-soft)' + : sev === 'critical' ? 'var(--color-severity-critical-soft)' + : 'var(--color-bg-soft)'; + const pillFg = sev === 'low' ? 'var(--color-severity-low-on)' + : sev === 'medium' ? 'var(--color-severity-medium-on)' + : sev === 'critical' ? 'var(--color-severity-critical-on)' + : 'var(--color-text-secondary)'; + const statusPill = '' + escapeHtml(l.status) + ''; + return '
    ' + + '
    ' + + '' + escapeHtml(l.category) + '' + + statusPill + + '
    ' + + (l.notes ? '' + escapeHtml(l.notes) + '' : '') + + '
    '; + }).join(''); + const lightsHtml = cards ? '

    Traffic-light kategorier

    ' + cards + '
    ' : ''; + const condHtml = (data.conditions && data.conditions.length) ? ( + '
    ' + + 'Vilkår å løse' + + '
      ' + data.conditions.map(function (c) { return '
    1. ' + escapeHtml(c) + '
    2. '; }).join('') + '
    ' + + '
    ' + ) : ''; + const apprRows = (data.approvals || []).map(function (a) { + const isPending = /pending|—/i.test(a.approver) || !a.approver.trim(); + return '' + escapeHtml(a.role) + '' + (isPending ? '(venter)' : escapeHtml(a.approver)) + '' + escapeHtml(a.date || '—') + '' + escapeHtml(a.notes) + ''; + }).join(''); + const apprHtml = apprRows ? ( + '

    Godkjenninger

    ' + + '' + apprRows + '
    RolleGodkjennerDatoNotater
    ' + + '
    ' + ) : ''; + const findingsHtml = renderFindingsBlock(data.findings || [], 'Pre-deploy funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = lightsHtml + condHtml + apprHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'PRE-DEPLOY', + title: data.title || 'Pre-deploy security checklist', + lede: data.lede || 'Enterprise-gate + production readiness — 13 kategorier.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderPreDeploy = renderPreDeploy; + +function renderDiff(data, slot) { + const newItems = data['new'] || []; + const resolvedItems = data.resolved || []; + const unchangedItems = data.unchanged || []; + const movedItems = data.moved || []; + const gradeBadge = function (g) { + return g ? '' + escapeHtml(g) + '' : '?'; + }; + const headerHtml = ( + '

    Grade-bevegelse

    ' + + '
    ' + + '
    ' + + 'BASELINE ' + escapeHtml(data.baseline_date || '') + '' + + '' + gradeBadge(data.baseline_grade) + '' + + '
    ' + + '' + + '
    ' + + '' + + '' + gradeBadge(data.current_grade) + '' + + '
    ' + + '
    ' + + '
    ' + ); + const renderRowItem = function (it, action) { + const sev = it.severity || 'info'; + const sevClass = 'card--severity-' + sev; + const meta = [it.category, it.file, it.resolution, it.notes].filter(Boolean).join(' · '); + const cellClass = action === 'new' ? 'diff__cell--added' : + action === 'resolved' ? 'diff__cell--unchanged' : + 'diff__cell--unchanged'; + return '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + escapeHtml(it.id || '—') + '
    ' + + '
    ' + escapeHtml(it.description || it.resolution || it.notes || '') + '
    ' + + (meta ? '
    ' + escapeHtml(meta) + '
    ' : '') + + '
    ' + + '
    ' + + '
    ' + + '
    '; + }; + const sectionFor = function (label, items, action) { + if (!items.length) return ''; + return '

    ' + escapeHtml(label) + ' (' + items.length + ')

    ' + + '
    ' + items.map(function (it) { return renderRowItem(it, action); }).join('') + '
    ' + + '
    '; + }; + const newHtml = sectionFor('Nye funn', newItems, 'new'); + const resHtml = sectionFor('Løste funn', resolvedItems, 'resolved'); + const unchHtml = sectionFor('Uendret', unchangedItems, 'unchanged'); + const movHtml = (movedItems.length) ? sectionFor('Flyttet', movedItems.map(function (m) { + return { id: m.id, severity: 'info', description: m.from + ' → ' + m.to }; + }), 'moved') : ''; + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = headerHtml + newHtml + resHtml + unchHtml + movHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'DIFF', + title: data.title || 'Scan diff mot baseline', + lede: data.lede || 'Sammenligner nåværende scan mot lagret baseline.', + verdict: data.verdict || inferVerdict(data, 'diff-report'), + keyStats: data.keyStats || inferKeyStats(data, 'diff-report') + }, body); +} +RENDERERS.renderDiff = renderDiff; + +function renderWatch(data, slot) { + const meter = data.live_meter || {}; + const meterRows = Object.keys(meter).map(function (k) { + return '' + escapeHtml(k.replace(/_/g, ' ')) + '' + escapeHtml(meter[k]) + ''; + }).join(''); + const meterHtml = meterRows ? ( + '

    Live-meter

    ' + + '' + meterRows + '
    ' + + '
    ' + ) : ''; + const histRows = (data.history || []).map(function (h) { + const isCurrent = /^current/i.test(h.run); + return '' + + '' + escapeHtml(h.run) + '' + + '' + escapeHtml(h.time) + '' + + '' + escapeHtml(h.grade || '?') + '' + + '' + h.risk_score + '' + + '' + escapeHtml(h.delta || '—') + '' + + ''; + }).join(''); + const histHtml = histRows ? ( + '

    Siste runs

    ' + + '' + histRows + '
    RunTidGradeRiskΔ
    ' + + '
    ' + ) : ''; + const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn (siste run)'); + const notRows = (data.notify_events || []).map(function (n) { + return '' + escapeHtml(n.time) + '' + escapeHtml(n.event) + '' + escapeHtml(n.channel) + '' + escapeHtml(n.status) + ''; + }).join(''); + const notHtml = notRows ? ( + '

    Notify-eventer

    ' + + '' + notRows + '
    TidEventChannelStatus
    ' + + '
    ' + ) : ''; + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = meterHtml + histHtml + findingsHtml + notHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'WATCH', + title: data.title || 'Continuous monitoring', + lede: data.lede || 'Kjører diff på rekursivt intervall via /loop. Notify ved nye funn.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderWatch = renderWatch; + +function renderRegistry(data, slot) { + const stats = data.stats || {}; + const statsRows = Object.keys(stats).map(function (k) { + return '' + escapeHtml(k.replace(/_/g, ' ')) + '' + escapeHtml(stats[k]) + ''; + }).join(''); + const statsHtml = statsRows ? ( + '

    Registry-stats

    ' + + '' + statsRows + '
    ' + + '
    ' + ) : ''; + const sigRows = (data.signatures || []).map(function (s) { + const isBad = /known-?bad|malicious/i.test(s.status); + const isDrift = /drift/i.test(s.status); + const isUnknown = /unknown/i.test(s.status); + const sev = isBad ? 'critical' : isDrift ? 'medium' : isUnknown ? 'low' : 'info'; + return '' + + '' + escapeHtml(s.skill) + '' + + '' + escapeHtml(s.source) + '' + + '' + escapeHtml(s.fingerprint) + '' + + '' + escapeHtml(s.status) + '' + + '' + escapeHtml(s.first_seen) + '' + + ''; + }).join(''); + const sigHtml = sigRows ? ( + '

    Signaturer

    ' + + '' + sigRows + '
    SkillKildeFingerprintStatusFørste sett
    ' + + '
    ' + ) : ''; + const fs = (data.findings || []).map(function (f) { + return Object.assign({}, f, { + file: f.skill || f.file || '', + category: f.category || '' + }); + }); + const findingsHtml = renderFindingsBlock(fs, 'Registry-funn'); + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = statsHtml + sigHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'REGISTRY', + title: data.title || 'Skill-signature registry', + lede: data.lede || 'Lokal fingerprint-database — kjente goder og kjente onde signaturer.', + verdict: data.verdict || inferVerdict(data, 'findings'), + keyStats: data.keyStats || inferKeyStats(data, 'findings') + }, body); +} +RENDERERS.renderRegistry = renderRegistry; + +function renderClean(data, slot) { + const buckets = data.buckets || { auto: [], 'semi-auto': [], manual: [], suppressed: [] }; + const cardFor = function (bucket, label, sev) { + const items = buckets[bucket] || []; + const cards = items.length ? items.map(function (it) { + return '
    ' + + '
    ' + escapeHtml(it.id || '—') + ' — ' + escapeHtml(it.action || '') + '
    ' + + (it.description ? '
    ' + escapeHtml(it.description) + '
    ' : '') + + '
    '; + }).join('') : '
    Ingen
    '; + return '
    ' + + '
    ' + + '' + escapeHtml(label) + '' + + '' + items.length + '' + + '
    ' + cards + '
    '; + }; + const kanbanHtml = '
    ' + + cardFor('auto', 'Auto', 'low') + + cardFor('semi-auto', 'Semi-auto', 'medium') + + cardFor('manual', 'Manual', 'high') + + cardFor('suppressed', 'Undertrykt', 'info') + + '
    '; + // Advisory recommendation-cards per bucket — DS Tier 3 data-severity (v7.6.0 fase 5f). + // Hver bucket med items > 0 får én recommendation-card med severity-tinted border + label. + const bucketAdvisoryDefs = [ + { key: 'auto', label: 'Auto-fixable', sev: 'positive', desc: 'Plugin kan fikse disse uten ekstra bekreftelse — deterministiske, lavrisiko-handlinger.' }, + { key: 'semi-auto', label: 'Semi-auto — krever bekreftelse', sev: 'medium', desc: 'Foreslåtte tiltak vises som diff. Bruker bekrefter per finding før endring anvendes.' }, + { key: 'manual', label: 'Manual remediation', sev: 'high', desc: 'Krever menneskelig vurdering — kontekst, scope eller side-effekter er ikke deterministisk avgjørbare.' }, + { key: 'suppressed', label: 'Undertrykt', sev: 'low', desc: 'Allowlist-treff via .llm-security-ignore — ingen handling.' } + ]; + const advisoryHtml = bucketAdvisoryDefs.map(function (b) { + const items = buckets[b.key] || []; + if (!items.length) return ''; + return ( + '
    ' + + '' + escapeHtml(b.label) + ' · ' + items.length + '' + + '

    ' + escapeHtml(b.desc) + '

    ' + + '
    ' + ); + }).join(''); + const findingsHtml = renderFindingsBlock(data.findings || [], 'Tilknyttede funn'); + const recHtml = renderRecommendationsList(data.recommendations || [], 'Anbefalinger', 'medium'); + const isDry = ((data.mode || '').toLowerCase() === 'dry-run'); + const intro = data.mode ? ( + '
    ' + + 'Modus · ' + escapeHtml(data.mode) + '' + + '

    ' + (isDry ? 'Dry-run: ingen filer endres. Forhåndsvis tiltak før --apply.' : 'Fixes anvendes med automatisk backup i .llm-security-backup/.') + '

    ' + + '
    ' + ) : ''; + const body = intro + advisoryHtml + kanbanHtml + findingsHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'CLEAN', + title: data.title || 'Remediation-kanban', + lede: data.lede || 'Funn fordelt på Auto / Semi-auto / Manual / Undertrykt.', + verdict: data.verdict || inferVerdict(data, 'kanban-buckets'), + keyStats: data.keyStats || inferKeyStats(data, 'kanban-buckets') + }, body); +} +RENDERERS.renderClean = renderClean; + +function renderThreatModel(data, slot) { + // Matrix-rendering — 5×5 + const cells = data.matrix_cells || []; + const byPC = {}; + cells.forEach(function (c) { + const k = c.prob + '_' + c.cons; + if (!byPC[k]) byPC[k] = []; + byPC[k].push(c); + }); + const probSize = 5; + const consMax = 5; + let matrixHtml = '
    Konsekvens
    '; + matrixHtml += '
    '; + for (let cons = consMax; cons >= 1; cons--) { + matrixHtml += '
    ' + cons + '
    '; + for (let prob = 1; prob <= probSize; prob++) { + const score = prob * cons; + const items = byPC[prob + '_' + cons] || []; + // v7.6.1 fix: bobler er nå '; + }).join('') + + (items.length > 3 ? '' : '') + + '
    ' + : ''; + matrixHtml += '
    ' + + '' + score + '' + bubblesHtml + + '
    '; + } + } + matrixHtml += '
    '; + for (let prob = 1; prob <= probSize; prob++) { + matrixHtml += '
    ' + prob + '
    '; + } + matrixHtml += '
    Sannsynlighet
    '; + // Threats table + const threatsRows = (data.threats || []).map(function (t) { + return '' + + '' + escapeHtml(t.id) + '' + + '' + escapeHtml(t.description) + '' + + ' ' + escapeHtml(t.severity) + '' + + '' + escapeHtml(t.mitigation) + '' + + ''; + }).join(''); + const threatsHtml = threatsRows ? ( + '

    Trusler

    ' + + '' + threatsRows + '
    IDBeskrivelseSeverityTiltak
    ' + + '
    ' + ) : ''; + // STRIDE / MAESTRO coverage as side-by-side bar lists + const coverageBlock = function (rows, label) { + if (!rows || !rows.length) return ''; + const max = Math.max.apply(null, rows.map(function (r) { return Number(r.count) || 0; })) || 1; + const items = rows.map(function (r) { + const pct = ((Number(r.count) || 0) / max) * 100; + const labelKey = r.category || r.layer || ''; + return '
    ' + + '
    ' + + '' + escapeHtml(labelKey) + '' + + '' + r.count + '' + + '
    ' + + '
    ' + + (r.notes ? '' + escapeHtml(r.notes) + '' : '') + + '
    '; + }).join(''); + return '

    ' + escapeHtml(label) + '

    ' + items + '
    '; + }; + const strideHtml = coverageBlock(data.stride, 'STRIDE-dekning'); + const maestroHtml = coverageBlock(data.maestro, 'MAESTRO-dekning'); + // Roadmap + const roadRows = (data.roadmap || []).map(function (r) { + return '' + escapeHtml(r.priority) + '' + escapeHtml(r.threat_id) + '' + escapeHtml(r.mitigation) + '' + escapeHtml(r.owner) + '' + escapeHtml(r.eta) + ''; + }).join(''); + const roadHtml = roadRows ? ( + '

    Mitigation roadmap

    ' + + '' + roadRows + '
    PrioritetTrusselTiltakEierETA
    ' + + '
    ' + ) : ''; + const recHtml = renderRecommendationsList(data.recommendations || []); + const body = matrixHtml + threatsHtml + strideHtml + maestroHtml + roadHtml + recHtml; + slot.innerHTML = renderPageShell({ + eyebrow: 'THREAT-MODEL', + title: data.title || 'Threat model · STRIDE + MAESTRO', + lede: data.lede || 'Trusselmodellering med risikomatrise og mitigation-roadmap.', + verdict: data.verdict || inferVerdict(data, 'matrix-risk'), + keyStats: data.keyStats || inferKeyStats(data, 'matrix-risk') + }, body); +} +RENDERERS.renderThreatModel = renderThreatModel; + +// ============================================================ +// EXPORTS — single block; functions remain top-level declarations +// for parity with the inline playground copy. +// ============================================================ +export { + // escape + escapeHtml, escapeAttr, + // verdict + key-stats inference + normalizeVerdict, inferVerdict, KEY_STATS_CONFIG, inferKeyStats, + // page-shell helpers + renderVerdictPill, renderKeyStatsGrid, renderPageShell, + // parser helpers + parseTableRow, parseTable, parseAllTables, parseSections, + extractField, intOrZero, emptyInput, normalizeSeverity, + normalizeVerdictText, gradeFromText, + parseRiskDashboard, parseFindingsTables, parseRecommendations, + safeOk, parseNarrativeAudit, + // 18 parsers + parseScan, parseDeepScan, parsePluginAudit, parseMcpAudit, + parseMcpInspect, parseIdeScan, parseSupplyCheck, parsePosture, + parseAudit, parseDashboard, parsePreDeploy, parseDiff, parseWatch, + parseRegistry, parseClean, parseHarden, parseThreatModel, parseRedTeam, + // routing maps + PARSERS, RENDERERS, + // renderer helpers + renderEmptyState, renderFindingsBlock, renderRecommendationsList, + mapSeverityToCardLevel, renderRiskMeter, renderSmallMultiples, + renderRadarSvg, renderToxicFlow, renderMatLadder, renderSuppressedGroup, + renderCodepointReveal, renderTopRisks, + // 18 renderers + renderScan, renderDeepScan, renderPluginAudit, renderMcpAudit, + renderIdeScan, renderPosture, renderAudit, renderDashboard, + renderHarden, renderRedTeam, renderMcpInspect, renderSupplyCheck, + renderPreDeploy, renderDiff, renderWatch, renderRegistry, + renderClean, renderThreatModel +};