feat(llm-security): playground v7.6.0 fase 5a-d — Tier 3 spesialkomponenter (del 1) [skip-docs]

Integrer fire llm-security-spesifikke Tier 3-komponenter:
- tfa-flow + tfa-leg + tfa-arrow: visualiserer lethal-trifecta-kjede
  i toxic-flow-rapport (untrusted-input → sensitive-access → exfil-sink)
- mat-ladder + mat-step: posture-modenhet over kategorier i posture-rapport
- suppressed-group: narrative-audit (v7.1.1) i scan-rapport executive summary
- codepoint-reveal + cp-tag: side-ved-side reveal for Unicode-steganografi
  i mcp-inspect-rapport (visible vs decoded)

Endringer:
- Fire nye render-helpers (renderToxicFlow, renderMatLadder,
  renderSuppressedGroup, renderCodepointReveal) i hovedscriptet, plassert
  før renderScan/Deep/Posture/MCP-Inspect.
- parseScan + parseDeepScan utvidet med narrative_audit-felt via ny
  parseNarrativeAudit-helper som ekstraherer "**Suppressed signals:**"-
  blokken fra raw_markdown.
- renderScan: meterHtml + suppressedHtml + toxicHtml + owaspHtml + ...
- renderDeepScan: suppressedHtml + toxicHtml + smHtml + matrixHtml + ...
- renderPosture: overall + ladderHtml + smHtml + quickHtml + ...
- renderMcpInspect: invHtml + cpHtml (rebuilt via renderCodepointReveal)

Verifisert:
- tfa-flow=3, mat-ladder=2, suppressed-group=8, codepoint-reveal=12 i HTML
- verdict-pill-lg=20, fp-step=12, scope-security=5 (Sesjon 2-kriterier intakte)
- form-progress__step strict singular=0 (DS canonical bevart)
- Window-globaler intakt (24 unike __-prefiksede globaler)
- JS parse OK (node --check), JSON-state parse OK (3 prosjekter, 18 rapporter)
- HTML-balanse OK (3 script-tags, 1 style-blokk)
- Smoke-test mot demo-data: alle 4 helpers rendrer non-empty HTML med
  forventede DS-klasser

Master-plan: plugins/llm-security/playground/V7.6.0-PLAN.local.md (Sesjon 3 av 5).
Sesjon 4 (fase 5e-h: top-risks, recommendation-card, risk-meter, card--severity-*)
neste, deretter Sesjon 5 (verifisering, docs, release).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-06 13:25:35 +02:00
commit fbda041522

View file

@ -7717,6 +7717,39 @@
// { ok: false, errors: [{ section, reason }] }
// ============================================================
/**
* 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 };
}
const parseScan = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const findings = parseFindingsTables(md);
@ -7740,11 +7773,13 @@
};
}) : [];
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)
}) };
});
@ -7790,11 +7825,15 @@
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)
}) };
});
@ -8882,12 +8921,256 @@
);
}
// ============================================================
// TIER 3 SPESIALKOMPONENTER — DS-helpers (v7.6.0 fase 5a-d).
// ============================================================
/**
* Render tfa-flow + tfa-leg + tfa-arrow for et lethal trifecta-funn.
* Brukes på scan + deep-scan-rapporter når findings inneholder
* en trifecta-pattern (f.eks. SCN-002 "Lethal trifecta: [Bash, Read, WebFetch]").
* Synthesiserer 3-leddet kjede: untrusted-input → sensitive-access → exfil-sink.
*/
function renderToxicFlow(findings) {
if (!findings || !findings.length) return '';
const trifectaFinding = findings.find(function (f) {
const desc = String(f.description || '');
const cat = String(f.category || '');
const owasp = String(f.owasp || '');
return /trifecta/i.test(desc) || /trifecta/i.test(cat) ||
/excessive\s*agency/i.test(cat) ||
/ASI01/i.test(owasp);
});
if (!trifectaFinding) return '';
const sev = String(trifectaFinding.severity || 'critical').toLowerCase();
const verdictMap = { critical: 'BLOCK', high: 'BLOCK', medium: 'WARN', low: 'ALLOW' };
const verdict = verdictMap[sev] || 'BLOCK';
const fileLine = trifectaFinding.file
? trifectaFinding.file + (trifectaFinding.line ? ':' + trifectaFinding.line : '')
: 'agent definition';
// Default trifecta-bensin: WebFetch + Read + Bash. Override hvis description nevner andre.
const desc = String(trifectaFinding.description || '');
const m = desc.match(/\[([^\]]+)\]/);
let tools = ['WebFetch', 'Read', 'Bash'];
if (m) {
const parsed = m[1].split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (parsed.length === 3) tools = parsed;
}
const legs = [
{ label: 'Untrusted input', name: tools[0], source: fileLine, mit: 'unmitigated', mitText: 'Ingen pre-prompt-inject-scan eller post-mcp-verify guard' },
{ label: 'Sensitive access', name: tools[1], source: '.env / credentials / git-history', mit: 'unmitigated', mitText: 'Ingen pre-write-pathguard på sti' },
{ label: 'Exfil sink', name: tools[2], source: 'curl / fetch til ekstern host', mit: 'unmitigated', mitText: 'Ingen post-session-guard trifecta-deteksjon' }
];
const legHtml = function (leg) {
return (
'<button class="tfa-leg" type="button" data-severity="' + escapeAttr(sev) + '" aria-label="' + escapeAttr(leg.label + ': ' + leg.name) + '">' +
'<span class="tfa-leg__label">' + escapeHtml(leg.label) + '</span>' +
'<span class="tfa-leg__name">' + escapeHtml(leg.name) + '</span>' +
'<span class="tfa-leg__source">' + escapeHtml(leg.source) + '</span>' +
'<span class="tfa-leg__status" data-mit="' + escapeAttr(leg.mit) + '">' + escapeHtml(leg.mitText) + '</span>' +
'</button>'
);
};
const arrowHtml = '<div class="tfa-arrow" data-severity="' + escapeAttr(sev) + '" aria-hidden="true"><div class="tfa-arrow__line"></div></div>';
return (
'<section class="report-meta">' +
'<h4>Toxic flow — Lethal trifecta-kjede</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Den fulle 3-leddete kjeden som overskrider Rule of Two. Hver leg er umitigert — ingen hook bryter kjeden.</p>' +
'<div class="tfa-flow" role="group" aria-label="Lethal trifecta-kjede">' +
'<div class="tfa-flow__verdict" data-verdict="' + escapeAttr(verdict) + '">' + escapeHtml(verdict) + '</div>' +
legHtml(legs[0]) + arrowHtml + legHtml(legs[1]) + arrowHtml + legHtml(legs[2]) +
'</div>' +
'</section>'
);
}
/**
* Render mat-ladder + mat-step for posture-modenhet.
* Mapper antall PASS-kategorier til 5 modenhetstrinn (Initial → Optimized).
*/
function renderMatLadder(categories, postureScore, postureApplicable) {
if (!categories || !categories.length) return '';
const passCount = postureScore != null
? Number(postureScore)
: categories.filter(function (c) { return c.status === 'PASS'; }).length;
const total = postureApplicable != null
? Number(postureApplicable)
: categories.filter(function (c) { return c.status !== 'N-A' && c.status !== 'N/A'; }).length;
const pct = total > 0 ? Math.round((passCount / total) * 100) : 0;
// 5 modenhetstrinn — terskler basert på % PASS
const steps = [
{ num: 1, name: 'Initial', threshold: 0, desc: 'Bare bones — ingen hooks eller minimal posture.' },
{ num: 2, name: 'Aware', threshold: 25, desc: 'Posture-skanning aktiv, kjenner risikoene.' },
{ num: 3, name: 'Defensive', threshold: 50, desc: 'Hooks engasjert på kritiske flater (PreToolUse, UserPromptSubmit).' },
{ num: 4, name: 'Mature', threshold: 75, desc: 'De fleste 16 kategoriene dekket; trifecta-deteksjon på.' },
{ num: 5, name: 'Optimized', threshold: 95, desc: 'Full coverage; A-grade på posture; aktiv overvåking.' }
];
const currentIdx = steps.reduce(function (acc, s, i) {
return pct >= s.threshold ? i : acc;
}, 0);
const stepHtml = steps.map(function (s, i) {
const state = i < currentIdx ? 'completed' : i === currentIdx ? 'current' : 'future';
const icon = state === 'completed' ? '✓' : String(s.num);
const pillCls = state === 'current' ? ' mat-step__pill mat-step__pill--current' :
state === 'completed' ? ' mat-step__pill mat-step__pill--complete' : '';
const pillText = state === 'current' ? 'Du er her' : state === 'completed' ? 'Oppnådd' : '';
const pill = pillText ? '<span class="' + pillCls.trim() + '">' + escapeHtml(pillText) + '</span>' : '';
const progress = state === 'current' ? (
'<div class="mat-step__progress">' +
'<div class="mat-step__progress-bar"><div class="mat-step__progress-fill" style="width: ' + pct + '%"></div></div>' +
'<span>' + passCount + ' / ' + total + ' kategorier</span>' +
'</div>'
) : '';
return (
'<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon" aria-hidden="true">' + escapeHtml(icon) + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(s.name) + pill + '</div>' +
'<div class="mat-step__desc">' + escapeHtml(s.desc) + '</div>' +
progress +
'</div>' +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Modenhetsstige — posture-progresjon</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Posture-score på ' + passCount + ' av ' + total + ' kategorier (' + pct + '%) plasserer dette prosjektet på trinn ' + (currentIdx + 1) + ' av 5.</p>' +
'<div class="mat-ladder" role="list" aria-label="Posture-modenhet over 5 trinn">' + stepHtml + '</div>' +
'</section>'
);
}
/**
* Render suppressed-group fra v7.1.1 narrative-audit.
* Parser executive_summary-tekst for "Suppressed signals: N (reason1: count examples, ...)"
* eller bruker data.narrative_audit.suppressed_findings hvis strukturert.
*/
function renderSuppressedGroup(data) {
if (!data) return '';
const audit = data.narrative_audit || {};
const sf = audit.suppressed_findings || {};
let groups = [];
let totalCount = 0;
if (sf.by_category && typeof sf.by_category === 'object') {
totalCount = Number(sf.count || 0);
groups = Object.keys(sf.by_category).map(function (k) {
return { reason: k, count: Number(sf.by_category[k]) || 0, example: '' };
});
} else {
// Fall back: parse fra executive_summary
const summary = String(data.executive_summary || '');
const m = summary.match(/Suppressed signals:\s*\*?\*?\s*(\d+)\s*\(([^)]+)\)/i);
if (!m) return '';
totalCount = Number(m[1]) || 0;
groups = m[2].split(',').map(function (part) {
const seg = part.trim();
const colonIdx = seg.indexOf(':');
if (colonIdx < 0) return { reason: seg, count: 1, example: '' };
const reason = seg.slice(0, colonIdx).trim();
const rest = seg.slice(colonIdx + 1).trim();
const cm = rest.match(/^(\d+)\s+(.*)$/);
if (cm) {
return { reason: reason, count: Number(cm[1]) || 1, example: cm[2].trim() };
}
return { reason: reason, count: 1, example: rest };
});
}
if (!groups.length) return '';
const groupsHtml = groups.map(function (g) {
const example = g.example ? (
'<div class="suppressed-group__examples">' +
'<span class="suppressed-group__example">' + escapeHtml(g.example) + '</span>' +
'</div>'
) : '';
return (
'<div class="suppressed-group">' +
'<div class="suppressed-group__head">' +
'<span class="suppressed-group__reason">' + escapeHtml(g.reason) + '</span>' +
'<span class="suppressed-group__count">' + g.count + ' ' + (g.count === 1 ? 'forekomst' : 'forekomster') + '</span>' +
'</div>' +
example +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Narrative audit — supprimerte signaler</h4>' +
'<p class="suppressed-group__desc">' + totalCount + ' signaler ble supprimert pre-rapport (v7.1.1 narrative_audit). Disse er ikke false-positives walked-back i prosa, men auto-suppress før klassifisering.</p>' +
groupsHtml +
'</section>'
);
}
/**
* Render codepoint-reveal + cp-tag for Unicode-steganografi (UNI-funn).
* Brukes på mcp-inspect-rapporter — bytter plain table mot side-by-side
* "synlig vs. decoded codepoint"-visning per tool.
*/
function renderCodepointReveal(codepoints) {
if (!codepoints || !codepoints.length) return '';
const tagFor = function (code) {
// U+200B/200C/200D/FEFF = zero-width
if (/U\+(200[B-D]|FEFF|2060|180E)/i.test(code)) return 'cp-zw';
// U+202E/202D/2066-2069 = bidi/RTL
if (/U\+(202[ADE]|206[6-9])/i.test(code)) return 'cp-bidi';
// Other = generic cp-tag (warning class)
return 'cp-tag';
};
const blocks = codepoints.map(function (c) {
const risk = String(c.risk || '').trim();
const sev = /high/i.test(risk) ? 'critical' : /medium/i.test(risk) ? 'medium' : 'low';
const isClean = /clean|—|^-$/i.test(c.codepoints || '') || risk === '—' || risk === '-';
const cps = String(c.codepoints || '');
// Highlight U+XXXX-mønstre
const highlighted = cps.replace(/U\+[0-9A-Fa-f]{4,6}/g, function (m) {
return '<span class="' + tagFor(m) + '">' + m + '</span>';
});
const headRisk = isClean
? '<span style="font-size: 11px; color: var(--color-state-success);">Ren — ingen non-ASCII</span>'
: '<span style="font-size: 11px; font-weight: var(--font-weight-semibold); color: var(--color-severity-' + sev + ');">' + escapeHtml(risk) + ' risk</span>';
const visibleCol = isClean
? '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + '</div>'
: '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + ' <span style="opacity: 0.6;">(rendert visuelt)</span></div>';
const decodedCol = isClean
? '<div class="codepoint-reveal__decoded">(ingen suspekte codepoints)</div>'
: '<div class="codepoint-reveal__decoded">' + highlighted + '</div>';
return (
'<div class="codepoint-reveal">' +
'<div class="codepoint-reveal__head">' +
'<strong>' + escapeHtml(c.server || '—') + ' · <code>' + escapeHtml(c.tool || '—') + '</code></strong>' +
headRisk +
'</div>' +
'<div class="codepoint-reveal__body">' +
'<div class="codepoint-reveal__col">' +
'<span class="codepoint-reveal__col-label">Synlig (rendret tekst)</span>' +
visibleCol +
'</div>' +
'<div class="codepoint-reveal__col">' +
'<span class="codepoint-reveal__col-label">Decoded (codepoints)</span>' +
decodedCol +
'</div>' +
'</div>' +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Codepoint-reveal — Unicode-steganografi</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Tools med non-ASCII codepoints i deskripsjoner — zero-width / homoglyph / bidi-override. Side-ved-side: synlig form vs. dekoded codepoints.</p>' +
'<div style="display: flex; flex-direction: column; gap: var(--space-3);">' + blocks + '</div>' +
'</section>'
);
}
// ============================================================
// 10 RENDERERS — én per høy-prio kommando.
// ============================================================
function renderScan(data, slot) {
const meterHtml = renderRiskMeter(data.risk_score, data.riskBand);
const suppressedHtml = renderSuppressedGroup(data);
const toxicHtml = renderToxicFlow(data.findings || []);
const owaspHtml = (data.owasp && data.owasp.length) ? (
'<section class="report-meta"><h4>OWASP-kategorier</h4>' +
'<table class="report-table"><thead><tr><th>Kategori</th><th>Funn</th><th>Maks severity</th><th>Skannere</th></tr></thead><tbody>' +
@ -8908,7 +9191,7 @@
) : '';
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = meterHtml + owaspHtml + supplyHtml + findingsHtml + recHtml;
const body = meterHtml + suppressedHtml + toxicHtml + owaspHtml + supplyHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'SKANNING',
title: data.title || 'Security Scan',
@ -8950,7 +9233,9 @@
) : '';
const findingsHtml = renderFindingsBlock(data.findings || [], 'Findings (utvalg)');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = smHtml + matrixHtml + findingsHtml + recHtml;
const suppressedHtml = renderSuppressedGroup(data);
const toxicHtml = renderToxicFlow(data.findings || []);
const body = suppressedHtml + toxicHtml + smHtml + matrixHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'DEEP-SCAN',
title: data.title || 'Deterministisk deep-scan',
@ -9117,6 +9402,7 @@
};
});
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">' +
@ -9131,7 +9417,7 @@
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 + smHtml + quickHtml + findingsHtml + recHtml;
const body = overall + ladderHtml + smHtml + quickHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'POSTURE',
title: data.title || 'Security posture',
@ -9346,16 +9632,7 @@
'<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 cpRows = (data.codepoints || []).map(function (c) {
const sev = /high/i.test(c.risk) ? 'critical' : /medium/i.test(c.risk) ? 'medium' : 'low';
return '<tr><td>' + escapeHtml(c.server) + '</td><td><code>' + escapeHtml(c.tool) + '</code></td><td>' + escapeHtml(c.codepoints) + '</td><td><span class="key-stat__value" style="color: var(--color-' + sev + ')">' + escapeHtml(c.risk) + '</span></td></tr>';
}).join('');
const cpHtml = cpRows ? (
'<section class="report-meta"><h4>Codepoint-reveal</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78;">Tools med non-ASCII codepoints i deskripsjoner — zero-width / homoglyph-kandidater.</p>' +
'<table class="report-table"><thead><tr><th>Server</th><th>Tool</th><th>Codepoints</th><th>Risk</th></tr></thead><tbody>' + cpRows + '</tbody></table>' +
'</section>'
) : '';
const cpHtml = renderCodepointReveal(data.codepoints || []);
const fs = (data.findings || []).map(function (f) {
return Object.assign({}, f, {
file: f.server || f.file || '',