feat(llm-security): playground v7.6.0 fase 5e-h — Tier 3 spesialkomponenter (del 2) [skip-docs]

- top-risks + top-risk: rangert top-funn-listing per rapport
  (renderTopRisks helper, integrert i renderScan, renderDeepScan,
  renderPluginAudit, renderPosture, renderAudit — ekskluderer info-funn,
  default 5 toppfunn med data-severity-tinted left-border)
- recommendation-card: data-severity-attributtet utvidet på alle
  inline-bruk (Trust-verdict, Quick wins, Action plan tiers, Vilkår)
  pluss /security clean (per-bucket advisory-cards) og /security harden
  (intro snapshot + per-recommendation diff-cards med action-type-mapping
  CREATE→positive / APPEND→medium / MERGE→low / SKIP→low)
- risk-meter: lagt til på renderDeepScan og renderAudit conditional på
  data.risk_score — utvider eksisterende bruk (renderScan, renderPluginAudit,
  renderRedTeam) til 5 archetypes
- card--severity-{level}: severity-color border-modifier på .findings__item
  i renderFindingsBlock (delt helper) pluss inline-bruk i renderAudit
  category-cards og renderDiff row-items

Ny helper-funksjon mapSeverityToCardLevel(input) normaliserer severity-
strenger og action-types til DS Tier 3-konvensjonene
(critical/high/medium/low/positive). renderRecommendationsList får valgfri
severity-param som default fall-back til 'low'.

Verifisering bekreftet:
- top-risks: 5 forekomster (≥1 ✓)
- recommendation-card: 32 (≥1 ✓ — utvidet fra 4)
- risk-meter: 7 (≥3 ✓ — 5 archetypes bruker helper)
- card--severity-: 4 (≥4 ✓ — findings__item + 2 inline-steder)
- Sesjon 2-3 anker intakte (verdict-pill-lg 20, fp-step 12,
  badge--scope-security 5, tfa-flow 3, mat-ladder 2, suppressed-group 8,
  codepoint-reveal 12)
- Window-globaler intakt
- JS parse: OK (node --check på ekstrahert main JS)
- demo-state JSON parse: OK (3 prosjekter, 18 rapporter)
- HTML-balanse: 3 script / 3 /script / 1 style
- Smoke-test mot demo-data: 5/7 renderere viser komplett markup;
  renderDeepScan og renderAudit har tomme findings-arrays i demo så
  top-risks/card--severity rendrer korrekt tomt (defensiv design,
  bevisst per Sesjon 3 observasjon 2)

Filendring: 10545 → 10677 linjer (+132 netto).

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

View file

@ -8798,14 +8798,20 @@
return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9); return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9);
}); });
const items = sorted.map(function (f) { const items = sorted.map(function (f) {
const sev = String(f.severity || 'info').toLowerCase();
// DS Tier 3 (v7.6.0 fase 5h): card--severity-{level} modifier på outer
// .findings__item gir severity-tinted left-border. Beholdes ved siden av
// den eksisterende .findings__item-severity-dot for ARIA + visuell
// redundans (border-farge + dot-fyll signaliserer samme severity).
const sevClass = 'card--severity-' + (sev === 'info' ? 'info' : sev);
const meta = [ const meta = [
f.file ? f.file + (f.line ? ':' + f.line : '') : '', f.file ? f.file + (f.line ? ':' + f.line : '') : '',
f.category || '', f.category || '',
f.owasp || '' f.owasp || ''
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
return ( return (
'<div class="findings__item">' + '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(f.severity || 'info') + '"></div>' + '<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' + '<div>' +
'<div class="findings__item-id">' + escapeHtml(f.id || '—') + '</div>' + '<div class="findings__item-id">' + escapeHtml(f.id || '—') + '</div>' +
'<div class="findings__item-title">' + escapeHtml(f.description || f.title || '') + '</div>' + '<div class="findings__item-title">' + escapeHtml(f.description || f.title || '') + '</div>' +
@ -8822,17 +8828,46 @@
); );
} }
function renderRecommendationsList(recs, label) { /**
* 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 ''; if (!recs || !recs.length) return '';
const sev = severity || 'low';
const items = recs.map(function (r) { return '<li>' + escapeHtml(r) + '</li>'; }).join(''); const items = recs.map(function (r) { return '<li>' + escapeHtml(r) + '</li>'; }).join('');
return ( return (
'<section class="recommendation-card">' + '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(label || 'Anbefalinger') + '</span>' + '<span class="recommendation-card__label">' + escapeHtml(label || 'Anbefalinger') + '</span>' +
'<ol class="recommendation-card__body">' + items + '</ol>' + '<ol class="recommendation-card__body">' + items + '</ol>' +
'</section>' '</section>'
); );
} }
/**
* Map severity-string til DS-tier3 recommendation-card data-severity.
* Aksepterer både severity-konvensjoner (critical/high/medium/low/info)
* og action-types (CREATE/APPEND/MERGE/SKIP/NONE).
*/
function mapSeverityToCardLevel(input) {
const s = String(input || '').toLowerCase().trim();
if (!s) return 'low';
if (s === 'critical' || s === 'crit') return 'critical';
if (s === 'high') return 'high';
if (s === 'medium' || s === 'med') return 'medium';
if (s === 'low') return 'low';
if (s === 'info') return 'low';
if (s === 'positive' || s === 'success' || s === 'ok' || s === 'pass') return 'positive';
// Action-types fra renderHarden
if (s === 'create') return 'positive';
if (s === 'append') return 'medium';
if (s === 'merge') return 'low';
if (s === 'skip' || s === 'none') return 'low';
return 'low';
}
function renderRiskMeter(score, band) { function renderRiskMeter(score, band) {
const s = Math.max(0, Math.min(100, Number(score) || 0)); const s = Math.max(0, Math.min(100, Number(score) || 0));
const bands = [ const bands = [
@ -9163,6 +9198,54 @@
); );
} }
/**
* Render top-risks + top-risk for rangert top-funn-listing.
* Tar de N (default 5) høyeste alvorlighetsnivåene fra findings og
* viser dem som ordnet liste. Bruker `.top-risks` / `.top-risk` med
* `data-severity` for severity-tinted left-border per DS Tier 3-supplement.
* Returnerer tom streng hvis ingen findings (eller kun info-funn).
*/
function renderTopRisks(findings, n) {
if (!findings || !findings.length) return '';
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
const max = typeof n === 'number' && n > 0 ? n : 5;
// Filtrer ut info-only — top-risks viser reelle risker, ikke observability-noise
const filtered = findings.filter(function (f) {
return (f.severity || 'info').toLowerCase() !== 'info';
});
if (!filtered.length) return '';
const sorted = filtered.slice().sort(function (a, b) {
return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9);
});
const top = sorted.slice(0, max);
const items = top.map(function (f, idx) {
const sev = String(f.severity || 'info').toLowerCase();
const sevLabel = sev.toUpperCase();
const meta = [
f.file ? f.file + (f.line ? ':' + f.line : '') : '',
f.id || '',
f.owasp || ''
].filter(Boolean).join(' · ');
const title = f.description || f.title || '—';
return (
'<li class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
'<div class="top-risk__rank">' + (idx + 1) + '</div>' +
'<div class="top-risk__desc">' +
'<div>' + escapeHtml(title) + '</div>' +
(meta ? '<div style="font-family: var(--font-family-mono); font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px;">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'<span class="top-risk__score" data-severity="' + escapeAttr(sev) + '">' + escapeHtml(sevLabel) + '</span>' +
'</li>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4 class="top-risks__heading">Top ' + top.length + ' risks</h4>' +
'<ol class="top-risks">' + items + '</ol>' +
'</section>'
);
}
// ============================================================ // ============================================================
// 10 RENDERERS — én per høy-prio kommando. // 10 RENDERERS — én per høy-prio kommando.
// ============================================================ // ============================================================
@ -9189,9 +9272,10 @@
'</tbody></table>' + '</tbody></table>' +
'</section>' '</section>'
) : ''; ) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn'); const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const recHtml = renderRecommendationsList(data.recommendations || []); const recHtml = renderRecommendationsList(data.recommendations || []);
const body = meterHtml + suppressedHtml + toxicHtml + owaspHtml + supplyHtml + findingsHtml + recHtml; const body = meterHtml + suppressedHtml + toxicHtml + topRisksHtml + owaspHtml + supplyHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'SKANNING', eyebrow: 'SKANNING',
title: data.title || 'Security Scan', title: data.title || 'Security Scan',
@ -9231,11 +9315,13 @@
matrixRows + '</tbody></table>' + matrixRows + '</tbody></table>' +
'</section>' '</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 findingsHtml = renderFindingsBlock(data.findings || [], 'Findings (utvalg)');
const recHtml = renderRecommendationsList(data.recommendations || []); const recHtml = renderRecommendationsList(data.recommendations || []);
const suppressedHtml = renderSuppressedGroup(data); const suppressedHtml = renderSuppressedGroup(data);
const toxicHtml = renderToxicFlow(data.findings || []); const toxicHtml = renderToxicFlow(data.findings || []);
const body = suppressedHtml + toxicHtml + smHtml + matrixHtml + findingsHtml + recHtml; const body = meterHtml + suppressedHtml + toxicHtml + smHtml + matrixHtml + topRisksHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'DEEP-SCAN', eyebrow: 'DEEP-SCAN',
title: data.title || 'Deterministisk deep-scan', title: data.title || 'Deterministisk deep-scan',
@ -9273,15 +9359,23 @@
'</tbody></table>' + '</tbody></table>' +
'</section>' '</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 ? ( const trustHtml = data.trust_verdict_text ? (
'<section class="recommendation-card">' + '<section class="recommendation-card" data-severity="' + escapeAttr(trustSev) + '">' +
'<span class="recommendation-card__label">Trust-verdict</span>' + '<span class="recommendation-card__label">Trust-verdict</span>' +
'<p class="recommendation-card__body">' + escapeHtml(data.trust_verdict_text).replace(/\n/g, '<br>') + '</p>' + '<p class="recommendation-card__body">' + escapeHtml(data.trust_verdict_text).replace(/\n/g, '<br>') + '</p>' +
'</section>' '</section>'
) : ''; ) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn'); const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const recHtml = renderRecommendationsList(data.recommendations || []); const recHtml = renderRecommendationsList(data.recommendations || []);
const body = renderRiskMeter(data.risk_score, data.riskBand) + metaHtml + compHtml + permHtml + trustHtml + findingsHtml + recHtml; const body = renderRiskMeter(data.risk_score, data.riskBand) + metaHtml + compHtml + permHtml + trustHtml + topRisksHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'PLUGIN-AUDIT', eyebrow: 'PLUGIN-AUDIT',
title: data.title || 'Plugin trust-vurdering', title: data.title || 'Plugin trust-vurdering',
@ -9405,19 +9499,20 @@
const ladderHtml = renderMatLadder(data.categories || [], data.posture_score, data.posture_applicable); const ladderHtml = renderMatLadder(data.categories || [], data.posture_score, data.posture_applicable);
// Quick wins // Quick wins
const quickHtml = (data.quick_wins && data.quick_wins.length) ? ( const quickHtml = (data.quick_wins && data.quick_wins.length) ? (
'<section class="recommendation-card">' + '<section class="recommendation-card" data-severity="positive">' +
'<span class="recommendation-card__label">Quick wins</span>' + '<span class="recommendation-card__label">Quick wins</span>' +
'<ol class="recommendation-card__body">' + '<ol class="recommendation-card__body">' +
data.quick_wins.map(function (w) { return '<li>' + escapeHtml(w) + '</li>'; }).join('') + data.quick_wins.map(function (w) { return '<li>' + escapeHtml(w) + '</li>'; }).join('') +
'</ol>' + '</ol>' +
'</section>' '</section>'
) : ''; ) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Top findings'); const findingsHtml = renderFindingsBlock(data.findings || [], 'Top findings');
const recHtml = renderRecommendationsList(data.recommendations || []); const recHtml = renderRecommendationsList(data.recommendations || []);
const overall = data.posture_score != null ? ( 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>' '<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 + findingsHtml + recHtml; const body = overall + ladderHtml + smHtml + quickHtml + topRisksHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'POSTURE', eyebrow: 'POSTURE',
title: data.title || 'Security posture', title: data.title || 'Security posture',
@ -9435,7 +9530,8 @@
'<section class="report-meta"><h4>Kategori-vurdering</h4>' + '<section class="report-meta"><h4>Kategori-vurdering</h4>' +
'<div class="findings__items">' + data.categories.map(function (c) { '<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 sev = c.status === 'FAIL' ? 'critical' : c.status === 'PARTIAL' ? 'medium' : c.status === 'PASS' ? 'low' : 'info';
return '<div class="findings__item">' + 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 class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' + '<div>' +
'<div class="findings__item-id">Kat. ' + c.num + '</div>' + '<div class="findings__item-id">Kat. ' + c.num + '</div>' +
@ -9447,17 +9543,19 @@
'</section>' '</section>'
) : ''; ) : '';
// Action Plan tre-tier // Action Plan tre-tier
const tierHtml = function (tier, label) { const tierHtml = function (tier, label, sev) {
const items = (data.action_plan && data.action_plan[tier]) || []; const items = (data.action_plan && data.action_plan[tier]) || [];
if (!items.length) return ''; if (!items.length) return '';
return '<section class="recommendation-card">' + return '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(label) + '</span>' + '<span class="recommendation-card__label">' + escapeHtml(label) + '</span>' +
'<ol class="recommendation-card__body">' + items.map(function (a) { return '<li>' + escapeHtml(a) + '</li>'; }).join('') + '</ol>' + '<ol class="recommendation-card__body">' + items.map(function (a) { return '<li>' + escapeHtml(a) + '</li>'; }).join('') + '</ol>' +
'</section>'; '</section>';
}; };
const actionHtml = tierHtml('immediate', 'Umiddelbar') + tierHtml('high', 'Høy prioritet') + tierHtml('medium', 'Medium prioritet'); const actionHtml = tierHtml('immediate', 'Umiddelbar', 'critical') + tierHtml('high', 'Høy prioritet', 'high') + tierHtml('medium', 'Medium prioritet', 'medium');
const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn'); const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const body = radarHtml + catHtml + actionHtml + findingsHtml; const body = meterHtml + radarHtml + catHtml + actionHtml + topRisksHtml + findingsHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'AUDIT', eyebrow: 'AUDIT',
title: data.title || 'Full security audit', title: data.title || 'Full security audit',
@ -9522,22 +9620,24 @@
function renderHarden(data, slot) { function renderHarden(data, slot) {
const recs = data.recommendations || []; const recs = data.recommendations || [];
// Diff-blokker per recommendation // 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 diffHtml = recs.map(function (r, idx) {
const isCreate = /create/i.test(r.action); const isCreate = /create/i.test(r.action);
const isAppend = /append/i.test(r.action); const isAppend = /append/i.test(r.action);
const isMerge = /merge/i.test(r.action); const isMerge = /merge/i.test(r.action);
const isNone = /none|skip/i.test(r.action); const isNone = /none|skip/i.test(r.action);
const cellClass = isCreate ? 'diff__cell--added' : (isAppend ? 'diff__cell--added' : (isMerge ? 'diff__cell--unchanged' : 'diff__cell--unchanged'));
const actionLabel = isCreate ? 'CREATE' : isAppend ? 'APPEND' : isMerge ? 'MERGE' : 'SKIP'; const actionLabel = isCreate ? 'CREATE' : isAppend ? 'APPEND' : isMerge ? 'MERGE' : 'SKIP';
const sev = mapSeverityToCardLevel(actionLabel);
return ( return (
'<div class="diff__row">' + '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<div class="diff__cell ' + cellClass + '">' + '<span class="recommendation-card__label">' + actionLabel + ' · ' + escapeHtml(String(r.num)) + '. ' + escapeHtml(r.category) + '</span>' +
'<strong>' + r.num + '. ' + escapeHtml(r.category) + '</strong><code>' + escapeHtml(r.file) + '</code>' + '<div class="recommendation-card__body">' +
'<div class="diff__cell"><strong>Action:</strong> ' + actionLabel + '</div>' + '<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>' : '') + (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>' + '</div>' +
'</div>' '</section>'
); );
}).join(''); }).join('');
// Diff summary footer // Diff summary footer
@ -9545,12 +9645,21 @@
return '<div class="diff__summary-item"><span>' + escapeHtml(d.file) + '</span><span class="diff__summary-count">' + escapeHtml(d.action) + ' · ' + escapeHtml(d.lines) + '</span></div>'; 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(''); }).join('');
const summaryHtml = summaryRows ? '<div class="diff__summary">' + summaryRows + '</div>' : ''; 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 = ( const intro = (
'<section class="report-meta"><h4>Snapshot</h4>' + '<section class="recommendation-card" data-severity="' + escapeAttr(introSev) + '">' +
'<p>Prosjekt-type: <strong>' + escapeHtml(data.project_type || '?') + '</strong> · Nåværende grade: <strong>' + escapeHtml(data.current_grade || '?') + '</strong> · ' + data.actionable + '/' + data.total + ' anbefalinger · Modus: <em>' + escapeHtml(data.mode || 'dry-run') + '</em></p>' + '<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>' '</section>'
); );
const body = intro + (diffHtml ? '<div class="diff">' + diffHtml + '</div>' : renderEmptyState('Ingen anbefalinger.')) + summaryHtml; const body = intro + (diffHtml || renderEmptyState('Ingen anbefalinger.')) + summaryHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'HARDEN', eyebrow: 'HARDEN',
title: data.title || 'Grade A reference config', title: data.title || 'Grade A reference config',
@ -9703,7 +9812,7 @@
}).join(''); }).join('');
const lightsHtml = cards ? '<section class="report-meta"><h4>Traffic-light kategorier</h4><div class="small-multiples">' + cards + '</div></section>' : ''; 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) ? ( const condHtml = (data.conditions && data.conditions.length) ? (
'<section class="recommendation-card">' + '<section class="recommendation-card" data-severity="high">' +
'<span class="recommendation-card__label">Vilkår å løse</span>' + '<span class="recommendation-card__label">Vilkår å løse</span>' +
'<ol class="recommendation-card__body">' + data.conditions.map(function (c) { return '<li>' + escapeHtml(c) + '</li>'; }).join('') + '</ol>' + '<ol class="recommendation-card__body">' + data.conditions.map(function (c) { return '<li>' + escapeHtml(c) + '</li>'; }).join('') + '</ol>' +
'</section>' '</section>'
@ -9755,13 +9864,14 @@
); );
const renderRowItem = function (it, action) { const renderRowItem = function (it, action) {
const sev = it.severity || 'info'; const sev = it.severity || 'info';
const sevClass = 'card--severity-' + sev;
const meta = [it.category, it.file, it.resolution, it.notes].filter(Boolean).join(' · '); const meta = [it.category, it.file, it.resolution, it.notes].filter(Boolean).join(' · ');
const cellClass = action === 'new' ? 'diff__cell--added' : const cellClass = action === 'new' ? 'diff__cell--added' :
action === 'resolved' ? 'diff__cell--unchanged' : action === 'resolved' ? 'diff__cell--unchanged' :
'diff__cell--unchanged'; 'diff__cell--unchanged';
return '<div class="diff__row">' + return '<div class="diff__row">' +
'<div class="diff__cell ' + cellClass + '">' + '<div class="diff__cell ' + cellClass + '">' +
'<div class="findings__item">' + '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' + '<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' + '<div>' +
'<div class="findings__item-id">' + escapeHtml(it.id || '—') + '</div>' + '<div class="findings__item-id">' + escapeHtml(it.id || '—') + '</div>' +
@ -9911,12 +10021,34 @@
cardFor('manual', 'Manual', 'high') + cardFor('manual', 'Manual', 'high') +
cardFor('suppressed', 'Undertrykt', 'info') + cardFor('suppressed', 'Undertrykt', 'info') +
'</div>'; '</div>';
// Advisory recommendation-cards per bucket — DS Tier 3 data-severity (v7.6.0 fase 5f).
// Hver bucket med items > 0 får én recommendation-card med severity-tinted border + label.
const bucketAdvisoryDefs = [
{ key: 'auto', label: 'Auto-fixable', sev: 'positive', desc: 'Plugin kan fikse disse uten ekstra bekreftelse — deterministiske, lavrisiko-handlinger.' },
{ key: 'semi-auto', label: 'Semi-auto — krever bekreftelse', sev: 'medium', desc: 'Foreslåtte tiltak vises som diff. Bruker bekrefter per finding før endring anvendes.' },
{ key: 'manual', label: 'Manual remediation', sev: 'high', desc: 'Krever menneskelig vurdering — kontekst, scope eller side-effekter er ikke deterministisk avgjørbare.' },
{ key: 'suppressed', label: 'Undertrykt', sev: 'low', desc: 'Allowlist-treff via .llm-security-ignore — ingen handling.' }
];
const advisoryHtml = bucketAdvisoryDefs.map(function (b) {
const items = buckets[b.key] || [];
if (!items.length) return '';
return (
'<section class="recommendation-card" data-severity="' + escapeAttr(b.sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(b.label) + ' · ' + items.length + '</span>' +
'<p class="recommendation-card__body">' + escapeHtml(b.desc) + '</p>' +
'</section>'
);
}).join('');
const findingsHtml = renderFindingsBlock(data.findings || [], 'Tilknyttede funn'); const findingsHtml = renderFindingsBlock(data.findings || [], 'Tilknyttede funn');
const recHtml = renderRecommendationsList(data.recommendations || []); const recHtml = renderRecommendationsList(data.recommendations || [], 'Anbefalinger', 'medium');
const isDry = ((data.mode || '').toLowerCase() === 'dry-run');
const intro = data.mode ? ( const intro = data.mode ? (
'<section class="report-meta"><h4>Modus</h4><p><strong>' + escapeHtml(data.mode) + '</strong> — ' + ((data.mode || '').toLowerCase() === 'dry-run' ? 'ingen filer endres.' : 'fixes anvendes med backup.') + '</p></section>' '<section class="recommendation-card" data-severity="' + (isDry ? 'low' : 'medium') + '">' +
'<span class="recommendation-card__label">Modus · ' + escapeHtml(data.mode) + '</span>' +
'<p class="recommendation-card__body">' + (isDry ? 'Dry-run: ingen filer endres. Forhåndsvis tiltak før <code>--apply</code>.' : 'Fixes anvendes med automatisk backup i <code>.llm-security-backup/</code>.') + '</p>' +
'</section>'
) : ''; ) : '';
const body = intro + kanbanHtml + findingsHtml + recHtml; const body = intro + advisoryHtml + kanbanHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({ slot.innerHTML = renderPageShell({
eyebrow: 'CLEAN', eyebrow: 'CLEAN',
title: data.title || 'Remediation-kanban', title: data.title || 'Remediation-kanban',