ktg-plugin-marketplace/plugins/llm-security/scripts/lib/report-renderers.mjs
Kjell Tore Guttormsen fa5fb48a33 refactor(llm-security): playground v7.6.2-dev — extract 18 renderers til scripts/lib/report-renderers.mjs [skip-docs]
Ny scripts/lib/report-renderers.mjs ESM-modul (3042 linjer, 74 named
exports + PARSERS/RENDERERS routing-maps + KEY_STATS_CONFIG):

- 18 main renderers (renderScan, renderDeepScan, renderPluginAudit,
  renderMcpAudit, renderIdeScan, renderPosture, renderAudit,
  renderDashboard, renderHarden, renderRedTeam, renderMcpInspect,
  renderSupplyCheck, renderPreDeploy, renderDiff, renderWatch,
  renderRegistry, renderClean, renderThreatModel)
- 12 renderer helpers (renderEmptyState, renderFindingsBlock,
  renderRecommendationsList, mapSeverityToCardLevel, renderRiskMeter,
  renderSmallMultiples, renderRadarSvg, renderToxicFlow, renderMatLadder,
  renderSuppressedGroup, renderCodepointReveal, renderTopRisks)
- 3 page-shell helpers (renderPageShell, renderVerdictPill,
  renderKeyStatsGrid)
- 18 parsers + 15 parser helpers (parseTableRow, parseTable, parseSections,
  extractField, parseRiskDashboard, parseFindingsTables, etc.)
- Verdict + key-stats inference (normalizeVerdict, inferVerdict,
  KEY_STATS_CONFIG, inferKeyStats)
- escapeHtml / escapeAttr

Canonical source for sesjon 4 CLI (scripts/render-report.mjs).

playground/llm-security-playground.html beholdes UENDRET (Fallback 2 fra
brief): file:// + ESM import er blokkert i Chrome/Firefox uten flags, så
playground beholder inline-kopi for single-file file:// distribusjon.
Sync-invariant dokumentert i modul-header.

Bit-identisk verifisering: alle 18 renderer-bodies character-for-character
identiske mellom .mjs og playground inline (extract → dedent 4-space →
diff). Smoke-test: parseScan + renderScan/renderPosture/renderAudit
produserer forventet DS-aligned HTML.

Tester: 1819/1820 grønne (samme baseline som sesjon 2; kjent pre-existing
flaky pre-compact-scan perf-test). JS-parse av playground: 0 failures.
2026-05-18 12:42:28 +02:00

3042 lines
136 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* report-renderers.mjs — llm-security playground rapport-renderere + parsers.
*
* CANONICAL SOURCE. The same code lives INLINE in
* playground/llm-security-playground.html as a duplicated copy. The two
* MUST stay in sync. file:// + ESM `import` is blocked in Chrome and
* Firefox without flags, so the playground retains an inline copy to
* remain a single-file file:// distribution. This module is the canonical
* source for the upcoming session 4 CLI (scripts/render-report.mjs).
*
* 45 exported functions + PARSERS routing-map + RENDERERS routing-map +
* KEY_STATS_CONFIG. See the export-block at the bottom of this file.
*
* Renderer API: render*(data, slot) — mutates slot.innerHTML and returns
* undefined. For Node CLI use, pass slot = { innerHTML: '' } and read
* slot.innerHTML after the call. This matches the playground exactly
* (bit-identical output by construction; identical code lives in both).
*/
// ============================================================
// ESCAPE HELPERS
// ============================================================
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ============================================================
// PAGE-SHELL HELPERS (DS Tier 3)
// ============================================================
function renderVerdictPill(verdict, sub) {
const v = String(verdict || 'n-a').toLowerCase();
const labels = {
'go': 'GO',
'go-with-conditions': 'BETINGET',
'block': 'BLOKKERT',
'approved': 'GODKJENT',
'failed': 'UNDERKJENT',
'allow': 'TILLATT',
'warning': 'ADVARSEL',
'n-a': 'IKKE VURDERT'
};
const dsVerdict = (
v === 'failed' ? 'block' :
v === 'go-with-conditions' ? 'warning' :
v === 'go' || v === 'approved' ? 'allow' :
v
);
const subHtml = sub
? '<span class="verdict-pill-lg__sub">' + escapeHtml(String(sub)) + '</span>'
: '';
return (
'<div class="verdict-pill-lg" data-verdict="' + escapeAttr(dsVerdict) + '">' +
'<span class="verdict-pill-lg__verdict">' + escapeHtml(labels[v] || v.toUpperCase()) + '</span>' +
subHtml +
'</div>'
);
}
function renderKeyStatsGrid(stats) {
if (!stats || !stats.length) return '';
const items = stats.map(function (s) {
const cls = 'key-stat' + (s.modifier ? ' key-stat--' + escapeAttr(s.modifier) : '');
const hint = s.hint ? '<span class="key-stat__hint">' + escapeHtml(s.hint) + '</span>' : '';
return '<div class="' + cls + '">' +
'<span class="key-stat__label">' + escapeHtml(s.label || '') + '</span>' +
'<span class="key-stat__value">' + escapeHtml(String(s.value)) + '</span>' +
hint +
'</div>';
}).join('');
return '<div class="key-stats">' + items + '</div>';
}
/**
* Render page-shell — DS Tier 3 page__header-klyngen brukt på alle 4 overflater:
* - onboarding: page__eyebrow="ONBOARDING · n av 5 grupper komplette"
* - home: page__eyebrow="HJEM" (m/ hero-modifier for editorial type-hierarki)
* - catalog: page__eyebrow="KATALOG"
* - project: page__eyebrow="PROSJEKT · <TARGET>"
* Pluss alle 18 rapport-renderere (eyebrow per archetype).
* Verdict-rendering via renderVerdictPill — produserer DS verdict-pill-lg.
* opts: { eyebrow, title, lede, meta:[], verdict, verdictSub, hero, keyStats }
*/
function renderPageShell(opts, bodyHtml) {
opts = opts || {};
const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
const title = '<h1 class="page__title">' + escapeHtml(opts.title || '') + '</h1>';
const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
const meta = (opts.meta && opts.meta.length)
? '<div class="page__meta">' + opts.meta.map(function (m) { return '<span>' + escapeHtml(m) + '</span>'; }).join('') + '</div>'
: '';
const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict, opts.verdictSub) : '';
const aside = verdict ? '<div class="page__header-aside">' + verdict + '</div>' : '';
const stats = renderKeyStatsGrid(opts.keyStats);
const heroClass = opts.hero ? ' page__header--hero' : '';
return (
'<header class="page__header' + heroClass + '">' +
'<div class="page__header-main">' + eyebrow + title + lede + meta + '</div>' +
aside +
'</header>' +
stats +
(bodyHtml || '')
);
}
// ============================================================
// INFER VERDICT + KEY-STATS PER ARCHETYPE
// ============================================================
function normalizeVerdict(v) {
const s = String(v || '').toLowerCase().trim();
const map = {
'block': 'block', 'blokk': 'block', 'blokkert': 'block', 'failed': 'failed', 'underkjent': 'failed',
'warning': 'warning', 'advarsel': 'warning',
'go-with-conditions': 'go-with-conditions', 'betinget': 'go-with-conditions', 'conditional': 'go-with-conditions',
'go': 'go', 'tillatt': 'allow', 'allow': 'allow', 'approved': 'approved', 'godkjent': 'approved',
'n-a': 'n-a', 'na': 'n-a', 'ikke-vurdert': 'n-a'
};
return map[s] || s || 'n-a';
}
function inferVerdict(data, archetype) {
if (!data) return 'n-a';
if (data.verdict) return normalizeVerdict(data.verdict);
switch (archetype) {
case 'findings': {
const fs = data.findings || [];
if (!fs.length) return 'allow';
const crit = fs.some(function (f) { return /crit|kritisk/i.test(f.severity || ''); });
return crit ? 'block' : 'warning';
}
case 'findings-grade': {
const g = String(data.grade || '').toUpperCase();
if (g === 'A' || g === 'B') return 'allow';
if (g === 'C' || g === 'D') return 'warning';
if (g === 'F') return 'block';
return 'n-a';
}
case 'posture-cards': {
const g = String(data.grade || '').toUpperCase();
if (g === 'A' || g === 'B') return 'allow';
if (g === 'C' || g === 'D') return 'warning';
if (g === 'F') return 'block';
return 'n-a';
}
case 'risk-score-meter': {
const score = Number(data.risk_score);
if (isNaN(score)) return 'n-a';
if (score >= 65) return 'block';
if (score >= 15) return 'warning';
return 'allow';
}
case 'dashboard-fleet': {
const g = String(data.machine_grade || '').toUpperCase();
if (g === 'A' || g === 'B') return 'allow';
if (g === 'C' || g === 'D') return 'warning';
if (g === 'F') return 'block';
return 'n-a';
}
case 'red-team-results': {
const fail = Number(data.fail_count) || 0;
if (fail > 5) return 'block';
if (fail > 0) return 'warning';
return 'allow';
}
case 'diff-report': {
const newCount = (data['new'] || []).length;
if (newCount > 0) return 'warning';
return 'allow';
}
case 'kanban-buckets': {
const remove = (data.remove || []).length;
if (remove > 0) return 'warning';
return 'allow';
}
case 'matrix-risk': {
const threats = data.threats || data.findings || [];
const hasCritical = threats.some(function (t) { return /crit|kritisk/i.test(t.severity || ''); });
if (hasCritical) return 'block';
if (threats.length) return 'warning';
return 'n-a';
}
default:
return 'n-a';
}
}
const KEY_STATS_CONFIG = {
'findings': function (d) {
const fs = d.findings || [];
const crit = fs.filter(function (f) { return /crit|kritisk/i.test(f.severity || ''); }).length;
const high = fs.filter(function (f) { return /^high|^høy/i.test(f.severity || ''); }).length;
return [
{ label: 'TOTALT', value: fs.length },
{ label: 'KRITISK', value: crit, modifier: crit > 0 ? 'critical' : null },
{ label: 'HØY', value: high, modifier: high > 0 ? 'high' : null }
];
},
'findings-grade': function (d) {
const out = [];
if (d.grade) out.push({ label: 'GRADE', value: String(d.grade).toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') });
if (d.score != null) out.push({ label: 'SCORE', value: d.score });
if (d.findings) out.push({ label: 'FUNN', value: d.findings.length });
return out;
},
'risk-score-meter': function (d) {
const out = [];
if (d.risk_score != null) {
const mod = d.risk_score >= 65 ? 'critical' : (d.risk_score >= 15 ? 'medium' : 'low');
out.push({ label: 'RISK SCORE', value: d.risk_score, modifier: mod });
}
if (d.riskBand) out.push({ label: 'BAND', value: d.riskBand });
return out;
},
'red-team-results': function (d) {
return [
{ label: 'TOTALT', value: d.total || 0 },
{ label: 'PASS', value: d.pass_count || 0, modifier: 'low' },
{ label: 'FAIL', value: d.fail_count || 0, modifier: (d.fail_count > 0 ? 'critical' : null) }
];
},
'dashboard-fleet': function (d) {
return [
{ label: 'PROSJEKTER', value: (d.projects || []).length },
{ label: 'MASKINKLASSE', value: String(d.machine_grade || 'n/a').toUpperCase() },
{ label: 'SVAKEST', value: d.weakest_link || '' }
];
},
'posture-cards': function (d) {
const cats = d.categories || [];
const pass = cats.filter(function (c) { return c.status === 'PASS'; }).length;
const fail = cats.filter(function (c) { return c.status === 'FAIL'; }).length;
return [
{ label: 'GRADE', value: String(d.grade || '?').toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') },
{ label: 'PASS', value: pass, modifier: 'low' },
{ label: 'FAIL', value: fail, modifier: fail > 0 ? 'critical' : 'low' }
];
},
'diff-report': function (d) {
const newCount = (d['new'] || []).length;
const unchangedCount = (d.unchanged || []).length;
return [
{ label: 'NÅ-GRADE', value: String(d.current_grade || '?').toUpperCase() },
{ label: 'AKSJONER', value: newCount, modifier: newCount > 0 ? 'medium' : 'low' },
{ label: 'SKIPPED', value: unchangedCount }
];
},
'kanban-buckets': function (d) {
const auto = (d.buckets && d.buckets.auto) || d.auto || [];
const semi = (d.buckets && (d.buckets['semi-auto'] || d.buckets.semi_auto)) || d['semi-auto'] || d.semi_auto || [];
const manual = (d.buckets && d.buckets.manual) || d.manual || [];
return [
{ label: 'AUTO', value: auto.length, modifier: 'low' },
{ label: 'SEMI-AUTO', value: semi.length, modifier: semi.length ? 'medium' : 'low' },
{ label: 'MANUAL', value: manual.length, modifier: manual.length ? 'high' : 'low' }
];
},
'matrix-risk': function (d) {
const threats = d.threats || d.findings || [];
const cells = d.matrix_cells || [];
const maxScore = cells.length ? Math.max.apply(null, cells.map(function (c) { return Number(c.score) || 0; })) : 0;
const sev = maxScore >= 16 ? 'critical' : maxScore >= 9 ? 'high' : maxScore >= 4 ? 'medium' : 'low';
return [
{ label: 'TRUSLER', value: threats.length },
{ label: 'MAKS SCORE', value: maxScore || '', modifier: sev },
{ label: 'CELLER', value: cells.length }
];
}
};
function inferKeyStats(data, archetype) {
if (!data) return [];
if (Array.isArray(data.keyStats)) return data.keyStats;
const fn = KEY_STATS_CONFIG[archetype];
if (typeof fn !== 'function') return [];
try {
const out = fn(data);
return Array.isArray(out) ? out : [];
} catch (e) { return []; }
}
// ============================================================
// PARSER HELPERS (markdown → struktur)
// ============================================================
function parseTableRow(line) {
const inner = line.replace(/^\|/, '').replace(/\|$/, '');
return inner.split('|').map(function (c) { return c.trim(); });
}
function parseTable(md, anchorRegex) {
if (typeof md !== 'string') return null;
let body = md;
if (anchorRegex) {
const m = anchorRegex.exec(md);
if (!m) return null;
body = md.slice(m.index + m[0].length);
}
const lines = body.split(/\r?\n/);
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
const next = (lines[i + 1] || '').trim();
if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
const headers = parseTableRow(line);
const rows = [];
for (let j = i + 2; j < lines.length; j++) {
const rowLine = lines[j].trim();
if (rowLine.indexOf('|') !== 0) break;
const cells = parseTableRow(rowLine);
if (cells.length === 0) break;
const row = {};
for (let k = 0; k < headers.length; k++) {
row[headers[k]] = (cells[k] || '').trim();
}
rows.push(row);
}
return { headers: headers, rows: rows };
}
}
return null;
}
function parseAllTables(md, anchorRegex) {
// Returnerer alle tabeller etter (valgfri) anchor til neste H2
// Brukt av parsers som har flere severity-tabeller (### Critical, ### High osv).
if (typeof md !== 'string') return [];
let body = md;
if (anchorRegex) {
const m = anchorRegex.exec(md);
if (!m) return [];
body = md.slice(m.index + m[0].length);
}
const out = [];
const lines = body.split(/\r?\n/);
let i = 0;
while (i < lines.length - 1) {
const line = lines[i].trim();
const next = (lines[i + 1] || '').trim();
if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
const headers = parseTableRow(line);
const rows = [];
let j = i + 2;
for (; j < lines.length; j++) {
const rowLine = lines[j].trim();
if (rowLine.indexOf('|') !== 0) break;
const cells = parseTableRow(rowLine);
if (cells.length === 0) break;
const row = {};
for (let k = 0; k < headers.length; k++) {
row[headers[k]] = (cells[k] || '').trim();
}
rows.push(row);
}
out.push({ headers: headers, rows: rows });
i = j;
} else {
i++;
}
}
return out;
}
function parseSections(md) {
if (typeof md !== 'string') return [];
const sections = [];
const lines = md.split(/\r?\n/);
let current = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = /^##\s+(.+)$/.exec(line);
if (m && line.charAt(2) === ' ') {
if (current) sections.push(current);
current = { heading: m[1].trim(), body: '' };
} else if (current) {
current.body += (current.body ? '\n' : '') + line;
}
}
if (current) sections.push(current);
return sections.map(function (s) {
return { heading: s.heading, body: s.body.trim() };
});
}
function extractField(md, label) {
if (typeof md !== 'string') return null;
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Markdown-tabellrader: | **Label** | value | OR | Label | value |
const tblRe = new RegExp('^\\s*\\|\\s*\\**\\s*' + escaped + '\\s*\\**\\s*\\|\\s*([^|]+?)\\s*\\|', 'mi');
const tbl = tblRe.exec(md);
if (tbl) return tbl[1].trim();
// **Label:** value OR Label: value
const re = new RegExp('^\\s*\\**\\s*' + escaped + '\\**\\s*:\\s*(.+)$', 'mi');
const m = re.exec(md);
return m ? m[1].trim() : null;
}
function intOrZero(s) {
if (s == null) return 0;
if (typeof s !== 'string') s = String(s);
const v = parseInt(s.replace(/[^\d-]/g, ''), 10);
return isNaN(v) ? 0 : v;
}
function emptyInput(md) {
return !md || typeof md !== 'string' || !md.trim();
}
function normalizeSeverity(s) {
const v = String(s || '').toLowerCase().trim();
if (/crit|kritisk/.test(v)) return 'critical';
if (/^high|^høy/.test(v)) return 'high';
if (/medium|moderat/.test(v)) return 'medium';
if (/^low|^lav/.test(v)) return 'low';
if (/^info|^observ/.test(v)) return 'info';
return v || 'info';
}
function normalizeVerdictText(s) {
const v = String(s || '').toUpperCase().trim();
if (/BLOCK|BLOKK|UNDERKJENT|FAIL/.test(v)) return 'block';
if (/GO[-\s]WITH[-\s]CONDITIONS|CONDITIONAL|BETINGET/.test(v)) return 'go-with-conditions';
if (/WARNING|ADVARSEL/.test(v)) return 'warning';
if (/ALLOW|TILLATT|GO|PASS|GODKJENT/.test(v)) return 'allow';
if (/N\/?A|IKKE/.test(v)) return 'n-a';
return '';
}
function gradeFromText(s) {
const m = /\b([A-F])\b/.exec(String(s || '').toUpperCase());
return m ? m[1] : null;
}
// Hjelper: parse Risk Dashboard-tabellen (fellesmønster)
function parseRiskDashboard(md) {
const out = {};
const score = extractField(md, 'Risk Score');
if (score) {
const m = /(\d+)\s*\/\s*100/.exec(score);
if (m) out.risk_score = parseInt(m[1], 10);
else out.risk_score = intOrZero(score);
}
const band = extractField(md, 'Risk Band');
if (band) out.riskBand = band;
const grade = extractField(md, 'Grade');
if (grade) out.grade = gradeFromText(grade);
const verdict = extractField(md, 'Verdict');
if (verdict) {
const norm = normalizeVerdictText(verdict);
if (norm) out.verdict = norm;
}
const rationale = extractField(md, 'Verdict rationale');
if (rationale) out.verdict_rationale = rationale;
// Severity counts-tabell (Severity | Count) — etter Risk Dashboard-headeren
const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*Count/i);
if (sevTbl && sevTbl.rows.length) {
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0, total: 0 };
sevTbl.rows.forEach(function (row) {
const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, '');
const n = intOrZero(row[sevTbl.headers[1]] || '0');
if (/^critical|^kritisk/.test(label)) counts.critical = n;
else if (/^high|^høy/.test(label)) counts.high = n;
else if (/^medium/.test(label)) counts.medium = n;
else if (/^low|^lav/.test(label)) counts.low = n;
else if (/^info/.test(label)) counts.info = n;
else if (/^total/.test(label)) counts.total = n;
});
if (!counts.total) {
counts.total = counts.critical + counts.high + counts.medium + counts.low + counts.info;
}
out.severity_counts = counts;
}
return out;
}
// Hjelper: parse alle findings-tabeller (### Critical / High / Medium / Low / Info)
function parseFindingsTables(md) {
const findings = [];
// Match alle ### <Severity>-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 '<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<p class="guide-panel__text">' + escapeHtml(message || 'Ingen data å vise.') + '</p>' +
'</div>' +
'</div>';
}
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 (
'<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' +
'<div class="findings__item-id">' + escapeHtml(f.id || '—') + '</div>' +
'<div class="findings__item-title">' + escapeHtml(f.description || f.title || '') + '</div>' +
(meta ? '<div class="findings__item-meta">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'</div>'
);
}).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 (
'<section class="report-meta">' +
'<h4>' + escapeHtml(label || 'Funn') + '</h4>' +
'<div class="findings__list" style="max-height: none;">' +
'<div class="findings__group">' +
'<div class="findings__group-header">' + escapeHtml(label || 'Funn') + ' (' + findings.length + ')</div>' +
'<div class="findings__items">' + items + '</div>' +
'</div>' +
'</div>' +
'</section>'
);
}
/**
* 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 '<li>' + escapeHtml(r) + '</li>'; }).join('');
return (
'<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(label || 'Anbefalinger') + '</span>' +
'<ol class="recommendation-card__body">' + items + '</ol>' +
'</section>'
);
}
/**
* 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 '<span class="risk-meter__band-label" data-band="' + escapeAttr(b.label.toLowerCase()) + '" style="flex: ' + w + '; text-align: center; min-width: 0;">' + escapeHtml(b.label) + '</span>';
}).join('');
return (
'<div class="risk-meter">' +
'<div class="risk-meter__readout"><span class="risk-meter__score">' + s + '</span><span> / 100 · ' + escapeHtml(band || '') + '</span></div>' +
'<div class="risk-meter__track"><div class="risk-meter__pointer" style="left: ' + s + '%"></div></div>' +
'<div class="risk-meter__bands">' + labels + '</div>' +
'<div class="risk-meter__scale"><span>0</span><span>50</span><span>100</span></div>' +
'</div>'
);
}
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 (
'<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(it.name || '') + '</span>' +
(grade ? '<span class="sm-card__grade"' + gradeAttr + '>' + escapeHtml(grade) + '</span>' : '') +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + pct.toFixed(0) + '%"></div></div>' +
'<span class="sm-card__status">' + escapeHtml(it.status || (score + ' / ' + max)) + '</span>' +
'</div>'
);
}).join('');
return '<div class="small-multiples">' + cards + '</div>';
}
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 '<div class="radar__score-row"><span>' + escapeHtml(a.name) + '</span><strong>' + escapeHtml(String(a.score || 0)) + '/5</strong></div>';
}).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 '<text class="radar__label" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '" text-anchor="' + anchor + '" dominant-baseline="middle">' + escapeHtml(a.name) + '</text>';
}).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 '<polygon class="radar__grid-line" points="' + pts + '" fill="none" stroke-opacity="' + (0.15 + k * 0.05) + '"/>';
}).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 (
'<div class="radar">' +
'<div class="radar__chart">' +
'<svg class="radar__svg" viewBox="0 0 ' + size + ' ' + size + '" width="100%" height="' + size + '">' +
grids + labelHtml +
'<polygon class="radar__series" points="' + pts + '" fill-opacity="0.25" stroke-width="2"/>' +
'</svg>' +
'</div>' +
'<div class="radar__scores">' + axisRows + '</div>' +
'</div>'
);
}
// ============================================================
// 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 (
'<button class="tfa-leg" type="button" data-severity="' + escapeAttr(sev) + '" aria-label="' + escapeAttr(leg.label + ': ' + leg.name) + '">' +
'<span class="tfa-leg__label">' + escapeHtml(leg.label) + '</span>' +
'<span class="tfa-leg__name">' + escapeHtml(leg.name) + '</span>' +
'<span class="tfa-leg__source">' + escapeHtml(leg.source) + '</span>' +
'<span class="tfa-leg__status" data-mit="' + escapeAttr(leg.mit) + '">' + escapeHtml(leg.mitText) + '</span>' +
'</button>'
);
};
const arrowHtml = '<div class="tfa-arrow" data-severity="' + escapeAttr(sev) + '" aria-hidden="true"><div class="tfa-arrow__line"></div></div>';
return (
'<section class="report-meta">' +
'<h4>Toxic flow — Lethal trifecta-kjede</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Den fulle 3-leddete kjeden som overskrider Rule of Two. Hver leg er umitigert — ingen hook bryter kjeden.</p>' +
'<div class="tfa-flow" role="group" aria-label="Lethal trifecta-kjede">' +
'<div class="tfa-flow__verdict" data-verdict="' + escapeAttr(verdict) + '">' + escapeHtml(verdict) + '</div>' +
legHtml(legs[0]) + arrowHtml + legHtml(legs[1]) + arrowHtml + legHtml(legs[2]) +
'</div>' +
'</section>'
);
}
/**
* 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 ? '<span class="' + pillCls.trim() + '">' + escapeHtml(pillText) + '</span>' : '';
const progress = state === 'current' ? (
'<div class="mat-step__progress">' +
'<div class="mat-step__progress-bar"><div class="mat-step__progress-fill" style="width: ' + pct + '%"></div></div>' +
'<span>' + passCount + ' / ' + total + ' kategorier</span>' +
'</div>'
) : '';
return (
'<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon" aria-hidden="true">' + escapeHtml(icon) + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(s.name) + pill + '</div>' +
'<div class="mat-step__desc">' + escapeHtml(s.desc) + '</div>' +
progress +
'</div>' +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Modenhetsstige — posture-progresjon</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Posture-score på ' + passCount + ' av ' + total + ' kategorier (' + pct + '%) plasserer dette prosjektet på trinn ' + (currentIdx + 1) + ' av 5.</p>' +
'<div class="mat-ladder" role="list" aria-label="Posture-modenhet over 5 trinn">' + stepHtml + '</div>' +
'</section>'
);
}
/**
* 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 ? (
'<div class="suppressed-group__examples">' +
'<span class="suppressed-group__example">' + escapeHtml(g.example) + '</span>' +
'</div>'
) : '';
return (
'<div class="suppressed-group">' +
'<div class="suppressed-group__head">' +
'<span class="suppressed-group__reason">' + escapeHtml(g.reason) + '</span>' +
'<span class="suppressed-group__count">' + g.count + ' ' + (g.count === 1 ? 'forekomst' : 'forekomster') + '</span>' +
'</div>' +
example +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Narrative audit — supprimerte signaler</h4>' +
'<p class="suppressed-group__desc">' + totalCount + ' signaler ble supprimert pre-rapport (v7.1.1 narrative_audit). Disse er ikke false-positives walked-back i prosa, men auto-suppress før klassifisering.</p>' +
groupsHtml +
'</section>'
);
}
/**
* 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 '<span class="' + tagFor(m) + '">' + m + '</span>';
});
const headRisk = isClean
? '<span style="font-size: 11px; color: var(--color-state-success);">Ren — ingen non-ASCII</span>'
: '<span style="font-size: 11px; font-weight: var(--font-weight-semibold); color: var(--color-severity-' + sev + ');">' + escapeHtml(risk) + ' risk</span>';
const visibleCol = isClean
? '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + '</div>'
: '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + ' <span style="opacity: 0.6;">(rendert visuelt)</span></div>';
const decodedCol = isClean
? '<div class="codepoint-reveal__decoded">(ingen suspekte codepoints)</div>'
: '<div class="codepoint-reveal__decoded">' + highlighted + '</div>';
return (
'<div class="codepoint-reveal">' +
'<div class="codepoint-reveal__head">' +
'<strong>' + escapeHtml(c.server || '—') + ' · <code>' + escapeHtml(c.tool || '—') + '</code></strong>' +
headRisk +
'</div>' +
'<div class="codepoint-reveal__body">' +
'<div class="codepoint-reveal__col">' +
'<span class="codepoint-reveal__col-label">Synlig (rendret tekst)</span>' +
visibleCol +
'</div>' +
'<div class="codepoint-reveal__col">' +
'<span class="codepoint-reveal__col-label">Decoded (codepoints)</span>' +
decodedCol +
'</div>' +
'</div>' +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Codepoint-reveal — Unicode-steganografi</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Tools med non-ASCII codepoints i deskripsjoner — zero-width / homoglyph / bidi-override. Side-ved-side: synlig form vs. dekoded codepoints.</p>' +
'<div style="display: flex; flex-direction: column; gap: var(--space-3);">' + blocks + '</div>' +
'</section>'
);
}
/**
* 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 (
'<li class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
'<div class="top-risk__rank">' + (idx + 1) + '</div>' +
'<div class="top-risk__desc">' +
'<div>' + escapeHtml(title) + '</div>' +
(meta ? '<div style="font-family: var(--font-family-mono); font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px;">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'<span class="top-risk__score" data-severity="' + escapeAttr(sev) + '">' + escapeHtml(sevLabel) + '</span>' +
'</li>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4 class="top-risks__heading">Top ' + top.length + ' risks</h4>' +
'<ol class="top-risks">' + items + '</ol>' +
'</section>'
);
}
// ============================================================
// 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) ? (
'<section class="report-meta"><h4>OWASP-kategorier</h4>' +
'<table class="report-table"><thead><tr><th>Kategori</th><th>Funn</th><th>Maks severity</th><th>Skannere</th></tr></thead><tbody>' +
data.owasp.map(function (o) {
return '<tr><td>' + escapeHtml(o.category) + '</td><td>' + o.findings + '</td><td>' + escapeHtml(o.max_severity) + '</td><td>' + escapeHtml(o.scanners) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
const supplyHtml = (data.supply_chain && data.supply_chain.length) ? (
'<section class="report-meta"><h4>Supply chain</h4>' +
'<table class="report-table"><thead><tr><th>Komponent</th><th>Type</th><th>Kilde</th><th>Trust</th><th>Notater</th></tr></thead><tbody>' +
data.supply_chain.map(function (s) {
return '<tr><td>' + escapeHtml(s.component) + '</td><td>' + escapeHtml(s.type) + '</td><td>' + escapeHtml(s.source) + '</td><td>' + escapeHtml(s.trust) + '</td><td>' + escapeHtml(s.notes) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
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 '<tr><td>' + escapeHtml(r.scanner) + '</td>' +
'<td>' + r.critical + '</td>' +
'<td>' + r.high + '</td>' +
'<td>' + r.medium + '</td>' +
'<td>' + r.low + '</td>' +
'<td>' + r.info + '</td></tr>';
}).join('');
const matrixHtml = matrixRows ? (
'<section class="report-meta"><h4>Scanner Risk Matrix</h4>' +
'<table class="report-table"><thead><tr><th>Scanner</th><th>CRIT</th><th>HIGH</th><th>MED</th><th>LOW</th><th>INFO</th></tr></thead><tbody>' +
matrixRows + '</tbody></table>' +
'</section>'
) : '';
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 '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(meta[k]) + '</td></tr>';
}).join('');
const metaHtml = metaRows ? '<section class="report-meta"><h4>Plugin-metadata</h4><table class="report-table"><tbody>' + metaRows + '</tbody></table></section>' : '';
const compHtml = (data.components && data.components.length) ? (
'<section class="report-meta"><h4>Komponenter</h4>' +
'<table class="report-table"><thead><tr><th>Komponent</th><th>Antall</th><th>Notater</th></tr></thead><tbody>' +
data.components.map(function (c) {
return '<tr><td>' + escapeHtml(c.component) + '</td><td>' + c.count + '</td><td>' + escapeHtml(c.notes) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
const permHtml = (data.permissions && data.permissions.length) ? (
'<section class="report-meta"><h4>Permission-matrise</h4>' +
'<table class="report-table"><thead><tr><th>Verktøy</th><th>Krevet av</th><th>Begrunnet</th></tr></thead><tbody>' +
data.permissions.map(function (p) {
const isYes = /^yes|^ja/i.test(p.justified);
const isNo = /^no$|^nei/i.test(p.justified);
const cls = isYes ? 'low' : (isNo ? 'critical' : 'medium');
return '<tr><td>' + escapeHtml(p.tool) + '</td><td>' + escapeHtml(p.required_by) + '</td><td><span class="key-stat__value" style="color: var(--color-' + cls + ')">' + escapeHtml(p.justified) + '</span></td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
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 ? (
'<section class="recommendation-card" data-severity="' + escapeAttr(trustSev) + '">' +
'<span class="recommendation-card__label">Trust-verdict</span>' +
'<p class="recommendation-card__body">' + escapeHtml(data.trust_verdict_text).replace(/\n/g, '<br>') + '</p>' +
'</section>'
) : '';
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 '<tr>' +
'<td>' + escapeHtml(s.server) + '</td>' +
'<td>' + escapeHtml(s.type) + '</td>' +
'<td>' + escapeHtml(s.trust) + '</td>' +
'<td>' + s.tools + '</td>' +
'<td>' + (s.active ? '<span class="key-stat__value" style="color: var(--color-low)">aktiv</span>' : '<span class="key-stat__value" style="color: var(--color-medium)">dormant</span>') + '</td>' +
'</tr>';
}).join('');
const landHtml = landRows ? (
'<section class="report-meta"><h4>MCP-landskap</h4>' +
'<table class="report-table"><thead><tr><th>Server</th><th>Type</th><th>Trust</th><th>Tools</th><th>Status</th></tr></thead><tbody>' + landRows + '</tbody></table>' +
'</section>'
) : '';
// Per-server som critique-cards
const psHtml = (data.per_server && data.per_server.length) ? (
'<div class="critique-cards">' + 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 '<div class="critique-card" data-severity="' + escapeAttr(sev) + '">' +
'<div class="critique-card__header">' +
'<div class="critique-card__title">' + escapeHtml(p.name) + '</div>' +
(p.note ? '<div class="critique-card__meta"><span class="critique-card__id">' + escapeHtml(p.note) + '</span></div>' : '') +
'</div>' +
'<div class="critique-card__recommendation">' + escapeHtml(lines.slice(0, 360)) + (lines.length > 360 ? '…' : '') + '</div>' +
'</div>';
}).join('') + '</div>'
) : '';
// 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 '<div class="kanban-card">' +
'<div class="kanban-card__name">' + escapeHtml(it.server) + '</div>' +
(it.reason ? '<div class="kanban-card__meta">' + escapeHtml(it.reason) + '</div>' : '') +
'</div>';
}).join('') : '<div class="kanban-col__empty">Ingen</div>';
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
'<div class="kanban-col__head">' +
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
'<span class="kanban-col__count">' + items.length + '</span>' +
'</div>' + cards + '</div>';
};
const kanbanHtml = '<div class="kanban-board">' +
cardFor('keep', 'Keep') +
cardFor('review', 'Review') +
cardFor('remove', 'Remove') +
'</div>';
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 '<tr><td>' + escapeHtml(c.ide) + '</td><td>' + c.extensions + '</td><td>' + c.findings + '</td></tr>';
}).join('');
const covHtml = covRows ? (
'<section class="report-meta"><h4>Scan-dekning</h4>' +
'<table class="report-table"><thead><tr><th>IDE</th><th>Extensions</th><th>Funn</th></tr></thead><tbody>' + covRows + '</tbody></table>' +
'</section>'
) : '';
// 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) ? (
'<section class="recommendation-card" data-severity="positive">' +
'<span class="recommendation-card__label">Quick wins</span>' +
'<ol class="recommendation-card__body">' +
data.quick_wins.map(function (w) { return '<li>' + escapeHtml(w) + '</li>'; }).join('') +
'</ol>' +
'</section>'
) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Top findings');
const recHtml = renderRecommendationsList(data.recommendations || []);
const overall = data.posture_score != null ? (
'<section class="report-meta"><h4>Overall score</h4><p><strong>' + data.posture_score + ' / ' + (data.posture_applicable || '?') + ' kategorier dekket</strong> — Grade ' + escapeHtml(data.grade || '?') + '.</p></section>'
) : '';
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) ? (
'<section class="report-meta"><h4>Kategori-vurdering</h4>' +
'<div class="findings__items">' + data.categories.map(function (c) {
const sev = c.status === 'FAIL' ? 'critical' : c.status === 'PARTIAL' ? 'medium' : c.status === 'PASS' ? 'low' : 'info';
const sevClass = 'card--severity-' + sev;
return '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' +
'<div class="findings__item-id">Kat. ' + c.num + '</div>' +
'<div class="findings__item-title">' + escapeHtml(c.name) + '</div>' +
'<div class="findings__item-meta">Status: <strong>' + escapeHtml(c.status || '—') + '</strong></div>' +
'</div>' +
'</div>';
}).join('') + '</div>' +
'</section>'
) : '';
// Action Plan tre-tier
const tierHtml = function (tier, label, sev) {
const items = (data.action_plan && data.action_plan[tier]) || [];
if (!items.length) return '';
return '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(label) + '</span>' +
'<ol class="recommendation-card__body">' + items.map(function (a) { return '<li>' + escapeHtml(a) + '</li>'; }).join('') + '</ol>' +
'</section>';
};
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 (
'<div class="fleet-tile" data-severity="' + escapeAttr(sevForGrade(p.grade)) + '">' +
'<div class="fleet-tile__row">' +
'<span class="fleet-tile__name" title="' + escapeAttr(p.name) + '">' + escapeHtml(p.name) + '</span>' +
'<span class="fleet-tile__grade" data-grade="' + escapeAttr(p.grade || '') + '">' + escapeHtml(p.grade || '?') + '</span>' +
'</div>' +
'<div class="fleet-tile__meter"><div class="fleet-tile__meter-fill" style="width: ' + fillPct + '%"></div></div>' +
'<div class="fleet-tile__meta">' +
'<span>Risk ' + p.risk + ' · ' + p.findings + ' funn</span>' +
(trend ? '<span class="' + trendCls + '">' + escapeHtml(trend.d_risk) + '</span>' : '') +
'</div>' +
(p.worst_category ? '<div class="fleet-tile__meta"><span class="fleet-tile__chip">Verst: ' + escapeHtml(p.worst_category) + '</span></div>' : '') +
'</div>'
);
}).join('') : '';
const gridHtml = tiles ? '<div class="fleet-grid">' + tiles + '</div>' : renderEmptyState('Ingen prosjekter funnet.');
// Errors
const errorsHtml = (data.errors && data.errors.length) ? (
'<section class="report-meta"><h4>Errors</h4>' +
'<table class="report-table"><thead><tr><th>Prosjekt</th><th>Feil</th></tr></thead><tbody>' +
data.errors.map(function (e) { return '<tr><td>' + escapeHtml(e.project) + '</td><td>' + escapeHtml(e.error) + '</td></tr>'; }).join('') +
'</tbody></table>' +
'</section>'
) : '';
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 (
'<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + actionLabel + ' · ' + escapeHtml(String(r.num)) + '. ' + escapeHtml(r.category) + '</span>' +
'<div class="recommendation-card__body">' +
'<div><code>' + escapeHtml(r.file) + '</code></div>' +
(r.content_preview ? '<pre style="margin: var(--space-2) 0; font-size: var(--font-size-sm); white-space: pre-wrap; opacity: ' + (isNone ? '0.6' : '0.9') + '">' + escapeHtml(r.content_preview).slice(0, 600) + (r.content_preview.length > 600 ? '…' : '') + '</pre>' : '') +
'</div>' +
'</section>'
);
}).join('');
// Diff summary footer
const summaryRows = (data.diff_summary || []).map(function (d) {
return '<div class="diff__summary-item"><span>' + escapeHtml(d.file) + '</span><span class="diff__summary-count">' + escapeHtml(d.action) + ' · ' + escapeHtml(d.lines) + '</span></div>';
}).join('');
const summaryHtml = summaryRows ? '<div class="diff__summary">' + summaryRows + '</div>' : '';
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 = (
'<section class="recommendation-card" data-severity="' + escapeAttr(introSev) + '">' +
'<span class="recommendation-card__label">Snapshot · grade ' + escapeHtml(data.current_grade || '?') + '</span>' +
'<p class="recommendation-card__body">Prosjekt-type: <strong>' + escapeHtml(data.project_type || '?') + '</strong> · ' + data.actionable + '/' + data.total + ' anbefalinger · Modus: <em>' + escapeHtml(data.mode || 'dry-run') + '</em></p>' +
'</section>'
);
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 '<tr><td>' + escapeHtml(h.run) + '</td><td>' + escapeHtml(h.date) + '</td><td>' + h.defense_score + '%</td><td>' + escapeHtml(h.delta) + '</td></tr>';
}).join('');
const historyHtml = historyRows ? (
'<section class="report-meta"><h4>Defense score-historikk</h4>' +
'<table class="report-table"><thead><tr><th>Run</th><th>Dato</th><th>Score</th><th>Δ</th></tr></thead><tbody>' + historyRows + '</tbody></table>' +
'</section>'
) : '';
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 '<tr>' +
'<td>' + escapeHtml(s.server) + '</td>' +
'<td>' + escapeHtml(s.transport) + '</td>' +
'<td>' + s.tools + '</td>' +
'<td>' + escapeHtml(s.status) + '</td>' +
'<td>' + (s.connected ? '<span class="key-stat__value" style="color: var(--color-low)">ja</span>' : '<span class="key-stat__value" style="color: var(--color-medium)">nei</span>') + '</td>' +
'</tr>';
}).join('');
const invHtml = invRows ? (
'<section class="report-meta"><h4>Server-inventar</h4>' +
'<table class="report-table"><thead><tr><th>Server</th><th>Transport</th><th>Tools</th><th>Status</th><th>Connected</th></tr></thead><tbody>' + invRows + '</tbody></table>' +
'</section>'
) : '';
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 ? '<div class="small-multiples">' + 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 '<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(e.ecosystem) + '</span>' +
'<span class="sm-card__grade" data-grade="' + escapeAttr(grade) + '">' + escapeHtml(grade) + '</span>' +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + fillPct.toFixed(0) + '%"></div></div>' +
'<span class="sm-card__status">' + e.packages + ' pakker · ' + e.osv_hits + ' OSV · ' + e.typosquats + ' typosquats</span>' +
'</div>';
}).join('') + '</div>' : '';
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 = '<span style="font-family: var(--font-family-mono); font-size: 11px; font-weight: var(--font-weight-bold); letter-spacing: 0.04em; padding: 3px 8px; border-radius: var(--radius-sm); background: ' + pillBg + '; color: ' + pillFg + '; white-space: nowrap;">' + escapeHtml(l.status) + '</span>';
return '<div class="sm-card" data-severity="' + escapeAttr(sev) + '" style="border-left: 3px solid var(--color-severity-' + (sev === 'low' ? 'low' : sev === 'medium' ? 'medium' : sev === 'critical' ? 'critical' : 'low') + '); padding-left: var(--space-3);">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(l.category) + '</span>' +
statusPill +
'</div>' +
(l.notes ? '<span class="sm-card__status">' + escapeHtml(l.notes) + '</span>' : '') +
'</div>';
}).join('');
const lightsHtml = cards ? '<section class="report-meta"><h4>Traffic-light kategorier</h4><div class="small-multiples">' + cards + '</div></section>' : '';
const condHtml = (data.conditions && data.conditions.length) ? (
'<section class="recommendation-card" data-severity="high">' +
'<span class="recommendation-card__label">Vilkår å løse</span>' +
'<ol class="recommendation-card__body">' + data.conditions.map(function (c) { return '<li>' + escapeHtml(c) + '</li>'; }).join('') + '</ol>' +
'</section>'
) : '';
const apprRows = (data.approvals || []).map(function (a) {
const isPending = /pending|—/i.test(a.approver) || !a.approver.trim();
return '<tr><td>' + escapeHtml(a.role) + '</td><td>' + (isPending ? '<em>(venter)</em>' : escapeHtml(a.approver)) + '</td><td>' + escapeHtml(a.date || '—') + '</td><td>' + escapeHtml(a.notes) + '</td></tr>';
}).join('');
const apprHtml = apprRows ? (
'<section class="report-meta"><h4>Godkjenninger</h4>' +
'<table class="report-table"><thead><tr><th>Rolle</th><th>Godkjenner</th><th>Dato</th><th>Notater</th></tr></thead><tbody>' + apprRows + '</tbody></table>' +
'</section>'
) : '';
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 ? '<span class="sm-card__grade" data-grade="' + escapeAttr(g) + '">' + escapeHtml(g) + '</span>' : '<span class="sm-card__grade" data-grade="?">?</span>';
};
const headerHtml = (
'<section class="report-meta"><h4>Grade-bevegelse</h4>' +
'<div class="pair-before-after">' +
'<div class="pair-before-after__cell">' +
'<span class="pair-before-after__cell-label">BASELINE ' + escapeHtml(data.baseline_date || '') + '</span>' +
'<span class="pair-before-after__cell-value">' + gradeBadge(data.baseline_grade) + '</span>' +
'</div>' +
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
'<div class="pair-before-after__cell">' +
'<span class="pair-before-after__cell-label">NÅ</span>' +
'<span class="pair-before-after__cell-value">' + gradeBadge(data.current_grade) + '</span>' +
'</div>' +
'</div>' +
'</section>'
);
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 '<div class="diff__row">' +
'<div class="diff__cell ' + cellClass + '">' +
'<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' +
'<div class="findings__item-id">' + escapeHtml(it.id || '—') + '</div>' +
'<div class="findings__item-title">' + escapeHtml(it.description || it.resolution || it.notes || '') + '</div>' +
(meta ? '<div class="findings__item-meta">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'</div>' +
'</div>' +
'</div>';
};
const sectionFor = function (label, items, action) {
if (!items.length) return '';
return '<section class="report-meta"><h4>' + escapeHtml(label) + ' (' + items.length + ')</h4>' +
'<div class="diff">' + items.map(function (it) { return renderRowItem(it, action); }).join('') + '</div>' +
'</section>';
};
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 '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(meter[k]) + '</td></tr>';
}).join('');
const meterHtml = meterRows ? (
'<section class="report-meta"><h4>Live-meter</h4>' +
'<table class="report-table"><tbody>' + meterRows + '</tbody></table>' +
'</section>'
) : '';
const histRows = (data.history || []).map(function (h) {
const isCurrent = /^current/i.test(h.run);
return '<tr' + (isCurrent ? ' style="font-weight: 600;"' : '') + '>' +
'<td>' + escapeHtml(h.run) + '</td>' +
'<td>' + escapeHtml(h.time) + '</td>' +
'<td><span class="sm-card__grade" data-grade="' + escapeAttr(h.grade || '?') + '">' + escapeHtml(h.grade || '?') + '</span></td>' +
'<td>' + h.risk_score + '</td>' +
'<td>' + escapeHtml(h.delta || '—') + '</td>' +
'</tr>';
}).join('');
const histHtml = histRows ? (
'<section class="report-meta"><h4>Siste runs</h4>' +
'<table class="report-table"><thead><tr><th>Run</th><th>Tid</th><th>Grade</th><th>Risk</th><th>Δ</th></tr></thead><tbody>' + histRows + '</tbody></table>' +
'</section>'
) : '';
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn (siste run)');
const notRows = (data.notify_events || []).map(function (n) {
return '<tr><td>' + escapeHtml(n.time) + '</td><td>' + escapeHtml(n.event) + '</td><td>' + escapeHtml(n.channel) + '</td><td>' + escapeHtml(n.status) + '</td></tr>';
}).join('');
const notHtml = notRows ? (
'<section class="report-meta"><h4>Notify-eventer</h4>' +
'<table class="report-table"><thead><tr><th>Tid</th><th>Event</th><th>Channel</th><th>Status</th></tr></thead><tbody>' + notRows + '</tbody></table>' +
'</section>'
) : '';
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 '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(stats[k]) + '</td></tr>';
}).join('');
const statsHtml = statsRows ? (
'<section class="report-meta"><h4>Registry-stats</h4>' +
'<table class="report-table"><tbody>' + statsRows + '</tbody></table>' +
'</section>'
) : '';
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 '<tr>' +
'<td>' + escapeHtml(s.skill) + '</td>' +
'<td>' + escapeHtml(s.source) + '</td>' +
'<td><code>' + escapeHtml(s.fingerprint) + '</code></td>' +
'<td><span class="key-stat__value" style="color: var(--color-' + sev + ')">' + escapeHtml(s.status) + '</span></td>' +
'<td>' + escapeHtml(s.first_seen) + '</td>' +
'</tr>';
}).join('');
const sigHtml = sigRows ? (
'<section class="report-meta"><h4>Signaturer</h4>' +
'<table class="report-table"><thead><tr><th>Skill</th><th>Kilde</th><th>Fingerprint</th><th>Status</th><th>Første sett</th></tr></thead><tbody>' + sigRows + '</tbody></table>' +
'</section>'
) : '';
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 '<div class="kanban-card" data-severity="' + escapeAttr(sev) + '">' +
'<div class="kanban-card__name">' + escapeHtml(it.id || '—') + ' — ' + escapeHtml(it.action || '') + '</div>' +
(it.description ? '<div class="kanban-card__meta">' + escapeHtml(it.description) + '</div>' : '') +
'</div>';
}).join('') : '<div class="kanban-col__empty">Ingen</div>';
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
'<div class="kanban-col__head">' +
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
'<span class="kanban-col__count">' + items.length + '</span>' +
'</div>' + cards + '</div>';
};
const kanbanHtml = '<div class="kanban-board" style="grid-template-columns: repeat(4, 1fr);">' +
cardFor('auto', 'Auto', 'low') +
cardFor('semi-auto', 'Semi-auto', 'medium') +
cardFor('manual', 'Manual', 'high') +
cardFor('suppressed', 'Undertrykt', 'info') +
'</div>';
// 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 (
'<section class="recommendation-card" data-severity="' + escapeAttr(b.sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(b.label) + ' · ' + items.length + '</span>' +
'<p class="recommendation-card__body">' + escapeHtml(b.desc) + '</p>' +
'</section>'
);
}).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 ? (
'<section class="recommendation-card" data-severity="' + (isDry ? 'low' : 'medium') + '">' +
'<span class="recommendation-card__label">Modus · ' + escapeHtml(data.mode) + '</span>' +
'<p class="recommendation-card__body">' + (isDry ? 'Dry-run: ingen filer endres. Forhåndsvis tiltak før <code>--apply</code>.' : 'Fixes anvendes med automatisk backup i <code>.llm-security-backup/</code>.') + '</p>' +
'</section>'
) : '';
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 = '<div class="matrix"><div class="matrix__y-label">Konsekvens</div><div class="matrix__main">';
matrixHtml += '<div class="matrix__grid" style="grid-template-rows: repeat(' + consMax + ', 1fr) 32px;">';
for (let cons = consMax; cons >= 1; cons--) {
matrixHtml += '<div class="matrix__y-tick">' + cons + '</div>';
for (let prob = 1; prob <= probSize; prob++) {
const score = prob * cons;
const items = byPC[prob + '_' + cons] || [];
// v7.6.1 fix: bobler er nå <button> så de er klikkbare og fokuserbare.
// data-threat-id lar event-handler senere mappe til detalj-modal.
const bubblesHtml = items.length
? '<div class="matrix__cell-bubbles">' +
items.slice(0, 3).map(function (it, i) {
return '<button type="button" class="matrix__bubble" data-threat-id="' + escapeAttr(it.id || it.label || '') + '" title="' + escapeAttr(it.label || '') + '" aria-label="Trussel: ' + escapeAttr(it.label || it.id || '') + '">' + (i + 1) + '</button>';
}).join('') +
(items.length > 3 ? '<button type="button" class="matrix__bubble matrix__bubble--count" aria-label="' + (items.length - 3) + ' flere trusler">+' + (items.length - 3) + '</button>' : '') +
'</div>'
: '';
matrixHtml += '<div class="matrix__cell" data-score="' + score + '">' +
'<span class="matrix__cell-score">' + score + '</span>' + bubblesHtml +
'</div>';
}
}
matrixHtml += '<div class="matrix__corner"></div>';
for (let prob = 1; prob <= probSize; prob++) {
matrixHtml += '<div class="matrix__x-tick">' + prob + '</div>';
}
matrixHtml += '</div><div class="matrix__x-label">Sannsynlighet</div></div></div>';
// Threats table
const threatsRows = (data.threats || []).map(function (t) {
return '<tr>' +
'<td>' + escapeHtml(t.id) + '</td>' +
'<td>' + escapeHtml(t.description) + '</td>' +
'<td><span class="findings__item-severity-dot" data-severity="' + escapeAttr(t.severity || 'info') + '" style="display: inline-block; vertical-align: middle;"></span> ' + escapeHtml(t.severity) + '</td>' +
'<td>' + escapeHtml(t.mitigation) + '</td>' +
'</tr>';
}).join('');
const threatsHtml = threatsRows ? (
'<section class="report-meta"><h4>Trusler</h4>' +
'<table class="report-table"><thead><tr><th>ID</th><th>Beskrivelse</th><th>Severity</th><th>Tiltak</th></tr></thead><tbody>' + threatsRows + '</tbody></table>' +
'</section>'
) : '';
// 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 '<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(labelKey) + '</span>' +
'<span class="sm-card__grade" data-grade="' + (r.count === 0 ? '?' : r.count <= 1 ? 'A' : r.count <= 3 ? 'B' : 'C') + '">' + r.count + '</span>' +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + pct.toFixed(0) + '%"></div></div>' +
(r.notes ? '<span class="sm-card__status">' + escapeHtml(r.notes) + '</span>' : '') +
'</div>';
}).join('');
return '<section class="report-meta"><h4>' + escapeHtml(label) + '</h4><div class="small-multiples">' + items + '</div></section>';
};
const strideHtml = coverageBlock(data.stride, 'STRIDE-dekning');
const maestroHtml = coverageBlock(data.maestro, 'MAESTRO-dekning');
// Roadmap
const roadRows = (data.roadmap || []).map(function (r) {
return '<tr><td>' + escapeHtml(r.priority) + '</td><td>' + escapeHtml(r.threat_id) + '</td><td>' + escapeHtml(r.mitigation) + '</td><td>' + escapeHtml(r.owner) + '</td><td>' + escapeHtml(r.eta) + '</td></tr>';
}).join('');
const roadHtml = roadRows ? (
'<section class="report-meta"><h4>Mitigation roadmap</h4>' +
'<table class="report-table"><thead><tr><th>Prioritet</th><th>Trussel</th><th>Tiltak</th><th>Eier</th><th>ETA</th></tr></thead><tbody>' + roadRows + '</tbody></table>' +
'</section>'
) : '';
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
};