ktg-plugin-marketplace/plugins/llm-security/scripts/lib/report-renderers.mjs
Kjell Tore Guttormsen 03b8885b6e chore(llm-security): v7.7.2 — language consistency pass
~/.claude/CLAUDE.md specifies English for code and documentation,
Norwegian for dialog only. Norwegian had crept into surface text
across v7.5-v7.7. Translated to English in eight surfaces.

No scanner, hook, or behavior changes — purely surface text.

- 18 skill commands: the HTML Report-step now reads "HTML report:
  [Open in browser]" instead of "HTML-rapport: [Åpne i nettleser]"
- scripts/lib/report-renderers.mjs: key-stat labels, lede defaults,
  table headers, maturity-ladder descriptions, action-tier labels,
  clean buckets, dry-run/apply copy, and JS comments. Regex
  alternations /^high|^høy/ and /resolution|løsning/i preserved.
- playground/llm-security-playground.html: same renderer changes
  mirrored bit-identical, plus playground-only UI strings (catalog,
  breadcrumb aria-label, theme toggle, builder-modal hint,
  guide-panel "no projects yet", delete confirmation, alert/copy).
  Demo-state fixture content for dft-komplett-demo preserved
  (intentional Norwegian persona).
- agents/skill-scanner-agent.md + agents/mcp-scanner-agent.md:
  Generaliseringsgrense + Parallell Read-strategi sections translated
  to Generalization boundary + Parallel Read strategy.
- README.md: playground architecture prose + Recent versions table
  (v7.5.0 — v7.7.1).
- CLAUDE.md: v7.7.1 highlights translated, new v7.7.2 highlights
  added.
- ../../README.md: llm-security v7.5.0 — v7.7.1 bullets.
- ../../CLAUDE.md: llm-security catalog entry.
- docs/scanner-reference.md: six runnable-examples table cells.
- docs/version-history.md: new v7.7.2 entry. v7.5-v7.7 narrative
  sections left in original language (deferred per operator).
- Version bumped 7.7.1 → 7.7.2 in package.json,
  .claude-plugin/plugin.json, README badge + Recent versions,
  CLAUDE.md header + state, docs/version-history.md, playground
  renderHome hardcoded string, root README + CLAUDE.md llm-security
  entries.

Tests: 1820/1820 green. CLI smoke-test: 18/18 commandIds produce
>138 KB self-contained HTML. Browser-dogfood verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 06:47:44 +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, '&amp;')
.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 the page-shell — the DS Tier 3 page__header cluster used on all 4 surfaces:
* - 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: 'TOTAL', value: fs.length },
{ label: 'CRITICAL', value: crit, modifier: crit > 0 ? 'critical' : null },
{ label: 'HIGH', 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: 'FINDINGS', 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: 'TOTAL', 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: 'PROJECTS', value: (d.projects || []).length },
{ label: 'MACHINE GRADE', value: String(d.machine_grade || 'n/a').toUpperCase() },
{ label: 'WEAKEST', 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: 'CURRENT GRADE', value: String(d.current_grade || '?').toUpperCase() },
{ label: 'ACTIONS', 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;
}
// Helper: parse the Risk Dashboard table (shared pattern)
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;
// Split on ### headers
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 — one per high-priority command.
// 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 = first project, sorted worst-first (already sorted in the 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)
}) };
});
// ============================================================
// PHASE 3: 8 PARSERS — one per remaining produces_report command.
// Patterns are reused from Phase 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 || 'No data to display.') + '</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 phase 5h): card--severity-{level} modifier on the 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) —
// the playground uses only the list part, so we wrap in .findings__list (no outer
// .findings) to avoid the header landing in the left 360px column. 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.
* Accepts both severity conventions (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: widen the SVG from 280 to 380 and r from 105 to 125 to give
// labels more room. Use text-anchor based on horizontal position to keep
// bottom labels from overlapping each other at 6+ axes.
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);
// Pick text-anchor based on position: left/right anchors flip.
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.
* Used on scan + deep-scan reports when findings contain
* 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: 'No pre-write-pathguard on path' },
{ 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 maturity steps — thresholds based on % PASS
const steps = [
{ num: 1, name: 'Initial', threshold: 0, desc: 'Bare bones — no hooks or minimal posture.' },
{ num: 2, name: 'Aware', threshold: 25, desc: 'Posture scanning is active and the risks are known.' },
{ num: 3, name: 'Defensive', threshold: 50, desc: 'Hooks engaged on critical surfaces (PreToolUse, UserPromptSubmit).' },
{ num: 4, name: 'Mature', threshold: 75, desc: 'Most of the 16 categories covered; trifecta detection on.' },
{ num: 5, name: 'Optimized', threshold: 95, desc: 'Full coverage; A-grade on posture; active monitoring.' }
];
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' ? 'You are here' : state === 'completed' ? 'Reached' : '';
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);">A posture score of ' + passCount + ' of ' + total + ' categories (' + pct + '%) places this project at step ' + (currentIdx + 1) + ' of 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 + ' signals were suppressed pre-report (v7.1.1 narrative_audit). These are not false-positives walked back in prose — they were auto-suppressed before classification.</p>' +
groupsHtml +
'</section>'
);
}
/**
* Render codepoint-reveal + cp-tag for Unicode-steganografi (UNI-funn).
* Used on mcp-inspect reports — swaps a plain table for a 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 patterns
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.
* Takes the N (default 5) highest-severity findings and
* 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 — one per high-priority command.
// ============================================================
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>Tool</th><th>Required by</th><th>Justified</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 verdict based on maintainer, license, permissions, and MCP descriptions.',
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, and description drift across installed MCP servers.',
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', 'Immediate', 'critical') + tierHtml('high', 'High priority', 'high') + tierHtml('medium', 'Medium priority', '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 preview of settings.json, CLAUDE.md, and .gitignore changes.',
verdict: data.verdict || inferVerdict(data, 'diff-report'),
keyStats: data.keyStats || [
{ label: 'CURRENT GRADE', value: String(data.current_grade || '?') },
{ label: 'ACTIONS', value: data.actionable + '/' + data.total },
{ label: 'MODE', 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;
// ============================================================
// PHASE 3: 8 RENDERERS — one per remaining command.
// ============================================================
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 pattern)
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 is fixed at 28×28 px (designed for one A-F letter), so
// "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">Conditions to resolve</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">NOW</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('Resolved findings', 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 || 'Compares the current scan against the stored 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 || 'Runs diff on a recurring interval via /loop. Notifies on new findings.',
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>Source</th><th>Fingerprint</th><th>Status</th><th>First seen</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).
// Every bucket with items > 0 gets one recommendation-card with a 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 — requires confirmation', sev: 'medium', desc: 'Proposed changes are shown as a diff. The user confirms per finding before the change is applied.' },
{ key: 'manual', label: 'Manual remediation', sev: 'high', desc: 'Requires human judgement — context, scope, or side-effects are not deterministically decidable.' },
{ 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: no files are modified. Preview the actions before <code>--apply</code>.' : 'Fixes are applied with an automatic backup in <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 || 'Findings split across Auto / Semi-auto / Manual / Suppressed.',
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: bubbles are now <button> so they are clickable and focusable.
// 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
};