-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 '' +
+ '
i
' +
+ '
' +
+ '
' + 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 (
+ ''
+ );
+}
+
+/**
+ * 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 + '
' +
+ '
0 50 100
' +
+ '
'
+ );
+}
+
+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.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 (
+ '' +
+ '' + escapeHtml(leg.label) + ' ' +
+ '' + escapeHtml(leg.name) + ' ' +
+ '' + escapeHtml(leg.source) + ' ' +
+ '' + escapeHtml(leg.mitText) + ' ' +
+ ' '
+ );
+ };
+ const arrowHtml = '';
+ return (
+ ''
+ );
+}
+
+/**
+ * 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(icon) + '
' +
+ '
' +
+ '
' + escapeHtml(s.name) + pill + '
' +
+ '
' + escapeHtml(s.desc) + '
' +
+ progress +
+ '
' +
+ '
'
+ );
+ }).join('');
+ return (
+ ''
+ );
+}
+
+/**
+ * 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 (
+ ''
+ );
+}
+
+/**
+ * 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 (
+ ''
+ );
+}
+
+/**
+ * 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 (
+ ''
+ );
+}
+
+// ============================================================
+// 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) ? (
+ ''
+ ) : '';
+ const supplyHtml = (data.supply_chain && data.supply_chain.length) ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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 ? '' : '';
+ const compHtml = (data.components && data.components.length) ? (
+ ''
+ ) : '';
+ const permHtml = (data.permissions && data.permissions.length) ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ // 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(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 ? (
+ ''
+ ) : '';
+ // 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 '' + escapeHtml(w) + ' '; }).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 ? (
+ ''
+ ) : '';
+ 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) ? (
+ ''
+ ) : '';
+ // 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 '' + escapeHtml(a) + ' '; }).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) ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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 '
' +
+ '' +
+ '
' +
+ '
' + 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 '' +
+ '' +
+ (l.notes ? '' + escapeHtml(l.notes) + ' ' : '') +
+ '
';
+ }).join('');
+ const lightsHtml = cards ? '' : '';
+ const condHtml = (data.conditions && data.conditions.length) ? (
+ '' +
+ 'Vilkår å løse ' +
+ '' + data.conditions.map(function (c) { return '' + escapeHtml(c) + ' '; }).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 ? (
+ ''
+ ) : '';
+ 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 = (
+ ''
+ );
+ 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 '';
+ };
+ 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 ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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 ? (
+ ''
+ ) : '';
+ 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å
så de er klikkbare og fokuserbare.
+ // data-threat-id lar event-handler senere mappe til detalj-modal.
+ const bubblesHtml = items.length
+ ? '' +
+ items.slice(0, 3).map(function (it, i) {
+ return '' + (i + 1) + ' ';
+ }).join('') +
+ (items.length > 3 ? '+' + (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 ? (
+ ''
+ ) : '';
+ // 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 '' +
+ '' +
+ '
' +
+ (r.notes ? '
' + escapeHtml(r.notes) + ' ' : '') +
+ '
';
+ }).join('');
+ return '';
+ };
+ 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 ? (
+ ''
+ ) : '';
+ 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
+};