ktg-plugin-marketplace/plugins/llm-security/playground/llm-security-playground.html
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

10872 lines
558 KiB
HTML
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.

<!DOCTYPE html>
<html lang="nb" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llm-security — Playground v1</title>
<!-- playground-design-system v0.1 (vendored) -->
<!-- Theme bootstrap. Must run before stylesheets parse to avoid a
flash-of-wrong-theme (FOUC). Priority order:
1) saved choice (localStorage 'llm-security-theme')
2) OS preference via matchMedia('(prefers-color-scheme: dark)')
3) the HTML attribute default ('dark')
Sets both data-theme + colorScheme for native form controls and scrollbars.
Wrapped in try/catch — file:// + private mode can block localStorage. -->
<script>
(function () {
var theme = null;
try {
var saved = localStorage.getItem('llm-security-theme');
if (saved === 'light' || saved === 'dark') theme = saved;
} catch (e) { /* localStorage unavailable */ }
if (!theme && window.matchMedia) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
})();
</script>
<!-- Vendored design-system. The source is shared/playground-design-system/ — synced via
scripts/sync-design-system.mjs at the marketplace root. Never edit files under
playground/vendor/ directly; changes go in shared/ + re-sync. -->
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
<link rel="stylesheet" href="vendor/playground-design-system/print.css" media="print">
<style>
/* Playground-specific layout. Every component CSS rule with a DS equivalent was
removed in v7.6.0 phases 1-4 — the DS Tier 3 supplement wins the cascade. What
remains here is page-specific layout grid (sidebar+main, modals), playground-only
components (.tracks, .field-from-tag), and deliberate overrides the DS does not
cover (.expansion__body markup, the .multi-select fieldset border, the
.checkbox-row accent-color). Phase 3 (session 2): the playground's local
verdict-pill block was removed — the DS covers it via Tier 2 (block/warning/allow)
+ the Tier 3 supplement (severity bands). Phase 4: the form-progress steps were
replaced by the DS fp-step pattern. */
main#app { min-height: 100vh; padding: 0; }
[hidden] { display: none !important; }
/* Onboarding layout: sidebar + main */
.onboarding-layout { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-6); align-items: start; }
@media (max-width: 880px) { .onboarding-layout { grid-template-columns: 1fr; } .form-progress { position: static; width: auto; } }
.onboarding-groups { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-6); }
.onboarding-fields { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-2) 0; }
.onboarding-actions { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; flex-wrap: wrap; }
.onboarding-help { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
/* Home + project list */
.home-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: var(--space-6) 0 var(--space-3); }
.home-section-head h2 { font-size: var(--font-size-xl); }
.home-section-head .home-section-meta { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
/* Project surface */
.project-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); flex-wrap: wrap; }
.project-tab { background: transparent; border: 0; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-secondary); border-bottom: 2px solid transparent; margin-bottom: -1px; }
.project-tab:hover { color: var(--color-text-primary); }
.project-tab[aria-current="true"] { color: var(--color-text-primary); border-bottom-color: var(--color-scope-security, var(--color-primary-500)); }
.project-tab__count { display: inline-block; margin-left: 6px; padding: 1px 6px; background: var(--color-bg-soft); border-radius: 10px; font-size: 11px; color: var(--color-text-tertiary); }
.command-cards { display: flex; flex-direction: column; gap: var(--space-4); }
.sub-zone { border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-3); }
.sub-zone__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
/* Collapsible command sub-cards (Rapporter-tab) */
.command-subcard { padding: 0; overflow: hidden; }
.command-subcard .card__head--toggle {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
margin: 0;
padding: var(--space-4) var(--space-5);
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
color: inherit;
}
.command-subcard .card__head--toggle:hover { background: var(--color-bg-soft); }
.command-subcard .card__head--toggle:focus-visible { outline: 2px solid var(--color-primary-500); outline-offset: -2px; }
.command-subcard .card__head-text { flex: 1; min-width: 0; }
.command-subcard .card__head-meta { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; flex-shrink: 0; }
.command-subcard .subcard-chev {
display: inline-block;
font-size: 0.875rem;
color: var(--color-text-tertiary);
transform: rotate(-90deg);
transition: transform 0.15s ease;
align-self: center;
flex-shrink: 0;
width: 1em;
text-align: center;
}
.command-subcard .card__head--toggle[aria-expanded="true"] .subcard-chev { transform: rotate(0deg); }
.command-subcard .subcard-body {
padding: 0 var(--space-5) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* App header — split nav groups */
.app-header__nav-group { display: flex; align-items: center; gap: var(--space-2); }
.app-header__nav-sep {
width: 1px;
align-self: stretch;
background: var(--color-border-subtle);
margin: 0 var(--space-2);
}
.paste-import-row { display: flex; flex-direction: column; gap: var(--space-2); }
.paste-import-row__actions { display: flex; gap: var(--space-2); align-items: center; }
.form-zone-placeholder { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
.report-slot { min-height: 24px; }
.report-slot:empty::before { content: "No imported report yet."; font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
/* Project header chips */
.project-header__chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--color-bg-soft); color: var(--color-text-secondary); font-size: var(--font-size-xs); font-family: var(--font-family-mono); }
.scenario-tag { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--color-scope-security-soft, var(--color-primary-100)); color: var(--color-scope-security-on, var(--color-primary-700)); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); }
/* Command form patterns (playground-only — DS dekker ikke command-form) */
.command-form { display: flex; flex-direction: column; gap: var(--space-3); }
.command-form__fields { display: flex; flex-direction: column; gap: var(--space-3); }
.command-form__actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.command-form__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-left: auto; }
.command-form__copy-confirm { font-size: var(--font-size-xs); color: var(--color-state-success); font-weight: var(--font-weight-medium); }
.form-preview { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); }
.form-preview__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
.code-block { font-family: var(--font-family-mono); font-size: var(--font-size-sm); color: var(--color-text-primary); margin: 0; white-space: pre-wrap; word-break: break-all; }
/* Field utility (DS dekker .field-row/.field-label/.field-help/.required-mark/.checkbox-row) */
.field-from-tag { font-size: 10px; font-weight: var(--font-weight-medium); padding: 1px 6px; border-radius: var(--radius-pill); background: var(--color-scope-security-soft, var(--color-primary-100)); color: var(--color-scope-security-on, var(--color-primary-700)); text-transform: uppercase; letter-spacing: 0.06em; cursor: help; margin-left: 6px; vertical-align: middle; }
.input, .textarea, .select { font-family: inherit; font-size: var(--font-size-sm); padding: 8px 10px; border: 1px solid var(--color-border-moderate); border-radius: var(--radius-sm); background: var(--color-surface); color: var(--color-text-primary); transition: border-color 120ms ease, box-shadow 120ms ease; }
.input:hover, .textarea:hover, .select:hover { border-color: var(--color-border-strong); }
.input:focus, .textarea:focus, .select:focus { outline: 2px solid var(--color-scope-security, var(--color-primary-500)); outline-offset: 1px; border-color: var(--color-border-strong); }
.textarea { resize: vertical; font-family: inherit; }
/* Multi-select: bevisst input-box-look (border + padding) — overstyrer DS' flate liste */
.multi-select { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--color-border-moderate); border-radius: var(--radius-sm); background: var(--color-surface); }
.multi-select:hover { border-color: var(--color-border-strong); }
/* Checkbox accent-color: playground-spesifikk styling. .checkbox-row selve dekkes av DS. */
.checkbox-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--color-scope-security, var(--color-primary-500)); border: 1px solid var(--color-border-strong); }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Catalog */
.catalog-search { width: 100%; max-width: 480px; margin-bottom: 0; }
.catalog-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3); margin-top: var(--space-3); }
.catalog-tool-notice { padding: 8px 12px; background: var(--color-bg-soft); border-left: 3px solid var(--color-state-info, var(--color-primary-300)); font-size: var(--font-size-xs); color: var(--color-text-secondary); border-radius: var(--radius-sm); }
/* Expansion-body: playground-markup mangler .expansion__body-inner-wrapping
som DS' grid-template-rows-animasjon krever. Beholdes til markup-en
evt. oppgraderes (out-of-scope for v7.6.0). */
.expansion__body { padding: 0 var(--space-4) var(--space-4); border-top: 1px solid var(--color-border-subtle); }
.expansion[aria-expanded="false"] .expansion__body { display: none; }
/* Catalog v7.6.2: filter-chips + list-view + builder-pane */
.catalog-filter-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: 0; }
.catalog-chip { font-family: inherit; font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--color-border-moderate); background: var(--color-surface); color: var(--color-text-secondary); cursor: pointer; transition: background 120ms ease, border-color 120ms ease, color 120ms ease; display: inline-flex; align-items: center; gap: 6px; }
.catalog-chip:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); }
.catalog-chip:focus-visible { outline: 2px solid var(--color-scope-security, var(--color-primary-500)); outline-offset: 2px; }
.catalog-chip--active { background: var(--color-scope-security, var(--color-primary-500)); border-color: var(--color-scope-security, var(--color-primary-500)); color: var(--color-text-on-primary, #fff); }
.catalog-chip--active:hover { color: var(--color-text-on-primary, #fff); }
.catalog-chip__count { font-size: 10px; opacity: 0.85; padding: 1px 6px; border-radius: var(--radius-pill); background: rgba(0,0,0,0.08); }
.catalog-chip--active .catalog-chip__count { background: rgba(255,255,255,0.18); }
.catalog-list { display: flex; flex-direction: column; gap: 1px; background: var(--color-border-subtle); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow: hidden; }
.catalog-row { display: flex; align-items: stretch; gap: var(--space-4); padding: var(--space-3) var(--space-4); background: var(--color-surface); border: 0; text-align: left; font-family: inherit; cursor: pointer; transition: background 120ms ease; width: 100%; }
.catalog-row:hover { background: var(--color-bg-soft); }
.catalog-row:focus-visible { outline: 2px solid var(--color-scope-security, var(--color-primary-500)); outline-offset: -2px; }
.catalog-row__main { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.catalog-row__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
.catalog-row__id { font-family: var(--font-family-mono); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); }
.catalog-row__label { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
.catalog-row__desc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); line-height: var(--line-height-snug); }
.catalog-row__hint { font-family: var(--font-family-mono); font-size: 11px; color: var(--color-text-tertiary); background: var(--color-bg-soft); padding: 1px 6px; border-radius: var(--radius-sm); }
.catalog-row__meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; font-size: var(--font-size-xs); }
.catalog-row__category { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); font-weight: var(--font-weight-semibold); }
.catalog-row__fields { color: var(--color-text-tertiary); }
.catalog-empty { padding: var(--space-5); text-align: center; color: var(--color-text-tertiary); border: 1px dashed var(--color-border-subtle); border-radius: var(--radius-md); }
/* Builder-pane: bigger modal, layout-tuned for live-preview workflow */
.builder-modal { max-width: 880px; }
.builder-modal__lede { color: var(--color-text-secondary); margin: 0; font-size: var(--font-size-sm); }
.builder-modal .form-preview { background: var(--color-bg-soft); border: 1px solid var(--color-border-subtle); }
.builder-modal__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin: 0; }
@media (max-width: 720px) {
.catalog-row { flex-direction: column; gap: var(--space-2); }
.catalog-row__meta { flex-direction: row; align-items: center; }
}
/* Modal (playground-only — DS har ikke modal-pattern enda) */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; padding: var(--space-5); }
.modal { background: var(--color-surface); border-radius: var(--radius-md); max-width: 720px; width: 100%; max-height: 90vh; overflow: auto; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.modal__head { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
.modal__title { font-size: var(--font-size-xl); font-weight: var(--font-weight-semibold); margin: 0; }
.modal__close { background: transparent; border: 0; cursor: pointer; font-size: 24px; line-height: 1; padding: 4px 8px; color: var(--color-text-tertiary); }
.modal__close:hover { color: var(--color-text-primary); }
/* Page-shell hero-modifier — clamp font-size for home-overflate.
DS' .page__title er 3xl (~32px); hero-modifier vipper opp til 36-56px
med editorial letter-spacing. */
.page__header--hero .page__title { font-size: clamp(36px, 5vw, 56px); letter-spacing: -0.025em; }
/* v7.6.0 fase 3: lokal verdict-pill-blokk fjernet (DS Tier 2 + Tier 3 sup
overstyrer). Gjengis av renderVerdictPill() med data-verdict-mapping. */
/* v7.6.0 fase 4: lokal form-progress-stegblokk fjernet — DS Tier 3 sup
leverer .form-progress__steps + .fp-step + .fp-step__num/__name. */
/* v7.6.1 fix: .report-table — DS har ikke implementert denne klassen, men
playground-rendrere bruker den i 7+ rapporter (OWASP-kategorier, Supply
chain, Scanner Risk Matrix, Plugin-meta, Permission-matrise, Live-meter,
Siste runs, Godkjenninger, Mitigation roadmap). Lokal styling som
komplementerer DS-tokens. */
.report-table { width: 100%; border-collapse: collapse; margin: var(--space-3) 0; font-size: var(--font-size-sm); }
.report-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--color-border-moderate); background: var(--color-bg-soft); font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
.report-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border-subtle); vertical-align: top; color: var(--color-text-primary); }
.report-table tr:last-child td { border-bottom: none; }
.report-table tbody tr:hover { background: var(--color-bg-soft); }
.report-table code { font-family: var(--font-family-mono); font-size: 12px; background: var(--color-surface-sunken); padding: 1px 6px; border-radius: var(--radius-sm); }
/* v7.6.1 fix: recommendation-card body can contain long single-line
text (conditions, owner-tags, dates). Force word-wrap so the grid
cell (auto + 1fr) does not push content off-viewport. */
.recommendation-card__body { overflow-wrap: anywhere; word-break: break-word; }
/* v7.6.1 fix: matrix bubbles need to be clickable. The DS has hover on the
cells, but the bubbles are <span> with no cursor. Make the bubble
cursor:pointer + focus. */
.matrix__bubble { cursor: pointer; transition: transform var(--duration-fast) var(--ease-default); }
.matrix__bubble:hover { transform: scale(1.15); }
.matrix__bubble:focus-visible { outline: 2px solid var(--color-primary-500); outline-offset: 2px; }
</style>
</head>
<body>
<!-- Walking-skeleton: 4 placeholder-overflater. Drevet av state.activeSurface.
Bare én er aktiv om gangen. -->
<main id="app">
<section id="surface-onboarding" data-surface="onboarding" hidden></section>
<section id="surface-home" data-surface="home" hidden></section>
<section id="surface-catalog" data-surface="catalog" hidden></section>
<section id="surface-project" data-surface="project" hidden></section>
</main>
<!-- Modal-portal — vises kun ved aktiv modal -->
<div id="modal-root"></div>
<!-- Inlined demo-state for the "Load demo data" button.
Mirror of the shared/playground-examples/security-direktorat.html scenario.
In Phase 2/3 this is extended with fully parsed reports; here in Phase 1
reports{} is empty on both projects. -->
<script type="application/json" id="demo-state-v1">
{
"schemaVersion": 1,
"dataVersion": 2,
"shared": {
"organization": {
"name": "Direktoratet for digital tjenesteutvikling",
"sector": "Statlig",
"size": "1 200",
"description": "Direktorat med ansvar for digitaliseringspolitikk og fellesløsninger for offentlig sektor. Har 38 produksjonssatte AI-tjenester og fungerer som referansevirksomhet for sikkerhets-praksis."
},
"scope": {
"typical_paths": "~/repos/dft-platform, ~/repos/dft-shared-services, ~/.claude/plugins/marketplaces/dft",
"exclude_patterns": "node_modules, dist, build, *.test.ts, fixtures/, vendor/",
"github_orgs": "dft-norge, dft-shared, dft-experiments",
"mcp_servers": ["filesystem", "github", "memory", "fetch"],
"ide_in_use": ["VS Code", "IntelliJ IDEA", "Cursor"]
},
"profile": {
"severity_threshold": "high",
"strict_mode": true,
"ci_failon": "high",
"suppress_categories": ["docs-only-changes"]
},
"platform": {
"ide_list": ["VS Code", "IntelliJ IDEA", "Cursor"],
"mcp_count": 4,
"ci_system": "GitHub Actions",
"runtime_envs": ["macOS", "Linux", "Docker"]
},
"compliance": {
"frameworks": ["OWASP LLM Top 10", "OWASP Agentic (ASI)", "OWASP Skills (AST)", "OWASP MCP", "EU AI Act", "NIST AI RMF"],
"datatilsynet_consulted": true,
"gdpr_role": "controller",
"ai_act_role": "deployer"
}
},
"projects": [
{
"id": "dft-marketplace-scan",
"name": "DFT marketplace baseline-skann",
"description": "Komplett scan av eget plugin-marketplace (8 plugins, 47 commands, 23 hooks, 12 MCP-tilkoblinger). Skal etablere Grade A-baseline før neste release.",
"target_type": "codebase",
"target_path": "~/repos/dft-marketplace",
"scenarios": ["pre-deploy", "compliance-audit"],
"createdAt": "2026-05-04T08:00:00.000Z",
"reports": {
"scan": {
"input": {
"target": "~/repos/example-app",
"deep_mode": false,
"severity_threshold": "high",
"branch": "",
"frameworks": [
"OWASP LLM Top 10",
"OWASP MCP"
]
},
"raw_markdown": "# Security Scan Report\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | scan |\n| **Target** | ~/repos/example-app |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | skill scan + MCP scan |\n| **Frameworks** | OWASP LLM Top 10, OWASP MCP |\n| **Triggered by** | /security scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 72/100 |\n| **Risk Band** | Critical |\n| **Grade** | D |\n| **Verdict** | BLOCK |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 2 |\n| High | 4 |\n| Medium | 7 |\n| Low | 3 |\n| Info | 5 |\n| **Total** | **21** |\n\n**Verdict rationale:** 2 critical findings (hardcoded API key + lethal trifecta in agent definition) cross the BLOCK threshold. High-severity prompt-injection vector in tool description compounds the risk.\n\n---\n\n## Executive Summary\n\nScan found 21 issues across 7 files in the `commands/` and `agents/` directories. Two critical findings require immediate remediation before this plugin is shipped: a hardcoded API key in `agents/data-analyst.md` (line 47) and a lethal trifecta agent (`agents/web-helper.md`) with `[Bash, Read, WebFetch]` and no hook guards. The four high-severity findings concentrate on prompt-injection patterns in MCP tool descriptions.\n\n### Narrative Audit\n\n**Suppressed signals:** 3 (entropy: 2 GLSL fragments, frontmatter: 1 framework env-var reference)\n\n---\n\n## Findings\n\nFindings sorted Critical → High → Medium → Low → Info.\n\n### Critical\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-001 | Secrets | agents/data-analyst.md | 47 | Hardcoded API key (sk-prod-...) | LLM02 |\n| SCN-002 | Excessive Agency | agents/web-helper.md | 3 | Lethal trifecta: [Bash, Read, WebFetch] without hook guards | ASI01, LLM06 |\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-003 | Injection | commands/research.md | 22 | Prompt-injection vector in user-input interpolation | LLM01 |\n| SCN-004 | MCP Trust | .mcp.json | 12 | MCP server description contains hidden imperative | MCP05 |\n| SCN-005 | Output Handling | agents/notes.md | 89 | Markdown link-title injection sink | LLM01 |\n| SCN-006 | Permissions | .claude/settings.json | 5 | Wildcard `Bash(*)` permission grant | ASI04 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-007 | Supply Chain | package.json | 15 | Dependency `lefthook@1.4.2` flagged by OSV.dev | LLM03 |\n| SCN-008 | Output Handling | agents/notes.md | 102 | HTML comment node passes through unvalidated | LLM01 |\n| SCN-009 | Other | CLAUDE.md | 34 | Memory-poisoning pattern: encoded base64 imperative | LLM06 |\n| SCN-010 | Injection | commands/summarize.md | 14 | Indirect injection via WebFetch result | LLM01 |\n| SCN-011 | Permissions | agents/test-runner.md | 5 | Tool list includes `Edit` without rationale | ASI04 |\n| SCN-012 | MCP Trust | .mcp.json | 28 | Per-update drift on `airbnb-mcp` tool description (12.3%) | MCP05 |\n| SCN-013 | Other | scripts/setup.sh | 3 | curl|sh pattern in install hint | LLM03 |\n\n### Low\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-014 | Other | README.md | 88 | Suspicious URL pattern in example | — |\n| SCN-015 | Other | docs/setup.md | 21 | Outdated security advisory link | — |\n| SCN-016 | Other | tests/fixtures/poisoned.md | 1 | Test fixture flagged (likely intentional) | — |\n\n### Info\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-017 | Other | .gitignore | — | No `.env*` exclusion rule | — |\n| SCN-018 | Other | LICENSE | — | License missing | — |\n| SCN-019 | Other | CHANGELOG.md | — | No CHANGELOG present | — |\n| SCN-020 | Other | SECURITY.md | — | No SECURITY.md disclosure policy | — |\n| SCN-021 | Other | CONTRIBUTING.md | — | No CONTRIBUTING guidelines | — |\n\n---\n\n## OWASP Categorization\n\n| OWASP Category | Findings | Max Severity | Scanners |\n|----------------|----------|-------------|----------|\n| LLM01 — Prompt Injection | 4 | High | skill-scanner, post-mcp-verify |\n| LLM02 — Sensitive Info Disclosure | 1 | Critical | secrets |\n| LLM03 — Supply Chain | 2 | Medium | dep-audit |\n| LLM06 — Excessive Agency | 2 | Critical | toxic-flow, memory |\n| MCP05 — Tool Description Drift | 2 | High | mcp-cache |\n| ASI01 — Lethal Trifecta | 1 | Critical | toxic-flow |\n| ASI04 — Permission Sprawl | 2 | High | permission |\n\n---\n\n## Supply Chain Assessment\n\n| Component | Type | Source | Trust Score | Notes |\n|-----------|------|--------|-------------|-------|\n| lefthook | npm | registry | 6/10 | OSV-2024-1234 (medium) |\n| typescript | npm | registry | 9/10 | clean |\n| @airbnb/mcp-server | npm | registry | 7/10 | per-update drift detected |\n\n**Source verification:** registry-only, no Git/private deps detected.\n\n**Permissions analysis:**\n- Requested tools: Bash, Read, Write, Edit, WebFetch, Task\n- Minimum necessary: Read, Bash\n- Over-permissioned: Write, Edit, WebFetch, Task\n\n**Supply chain risk summary:** One medium-severity CVE on a build-tool dependency. Recommend bumping `lefthook` to 1.5.0+.\n\n---\n\n## Recommendations\n\n1. **Immediate:** Rotate `sk-prod-...` API key and remove from `agents/data-analyst.md`. Replace with environment-variable reference.\n2. **Immediate:** Rewrite `agents/web-helper.md` to drop one of `[Bash, Read, WebFetch]` OR add a hook policy that blocks the trifecta.\n3. **High:** Update MCP server description in `.mcp.json` (line 12) and run `/security mcp-baseline-reset` after legitimate update.\n4. **High:** Replace `Bash(*)` with explicit allowlist in `.claude/settings.json`.\n5. **Medium:** Bump `lefthook` to 1.5.0+ to clear OSV-2024-1234.\n\nRun `/security clean .` to auto-fix deterministic issues. Re-scan after fixes to confirm BLOCK → WARNING → ALLOW progression.\n\n---\n\n*Scan complete. 21 findings across 7 files, 12.4 seconds.*\n",
"parsed": {
"risk_score": 72,
"riskBand": "Critical",
"grade": "D",
"verdict": "block",
"verdict_rationale": "** 2 critical findings (hardcoded API key + lethal trifecta in agent definition) cross the BLOCK threshold. High-severity prompt-injection vector in tool description compounds the risk.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"findings": [
{
"id": "SCN-001",
"severity": "critical",
"category": "Secrets",
"file": "agents/data-analyst.md",
"line": "47",
"description": "Hardcoded API key (sk-prod-...)",
"owasp": "LLM02"
},
{
"id": "SCN-002",
"severity": "critical",
"category": "Excessive Agency",
"file": "agents/web-helper.md",
"line": "3",
"description": "Lethal trifecta: [Bash, Read, WebFetch] without hook guards",
"owasp": "ASI01, LLM06"
},
{
"id": "SCN-003",
"severity": "high",
"category": "Injection",
"file": "commands/research.md",
"line": "22",
"description": "Prompt-injection vector in user-input interpolation",
"owasp": "LLM01"
},
{
"id": "SCN-004",
"severity": "high",
"category": "MCP Trust",
"file": ".mcp.json",
"line": "12",
"description": "MCP server description contains hidden imperative",
"owasp": "MCP05"
},
{
"id": "SCN-005",
"severity": "high",
"category": "Output Handling",
"file": "agents/notes.md",
"line": "89",
"description": "Markdown link-title injection sink",
"owasp": "LLM01"
},
{
"id": "SCN-006",
"severity": "high",
"category": "Permissions",
"file": ".claude/settings.json",
"line": "5",
"description": "Wildcard `Bash(*)` permission grant",
"owasp": "ASI04"
},
{
"id": "SCN-007",
"severity": "medium",
"category": "Supply Chain",
"file": "package.json",
"line": "15",
"description": "Dependency `lefthook@1.4.2` flagged by OSV.dev",
"owasp": "LLM03"
},
{
"id": "SCN-008",
"severity": "medium",
"category": "Output Handling",
"file": "agents/notes.md",
"line": "102",
"description": "HTML comment node passes through unvalidated",
"owasp": "LLM01"
},
{
"id": "SCN-009",
"severity": "medium",
"category": "Other",
"file": "CLAUDE.md",
"line": "34",
"description": "Memory-poisoning pattern: encoded base64 imperative",
"owasp": "LLM06"
},
{
"id": "SCN-010",
"severity": "medium",
"category": "Injection",
"file": "commands/summarize.md",
"line": "14",
"description": "Indirect injection via WebFetch result",
"owasp": "LLM01"
},
{
"id": "SCN-011",
"severity": "medium",
"category": "Permissions",
"file": "agents/test-runner.md",
"line": "5",
"description": "Tool list includes `Edit` without rationale",
"owasp": "ASI04"
},
{
"id": "SCN-012",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"line": "28",
"description": "Per-update drift on `airbnb-mcp` tool description (12.3%)",
"owasp": "MCP05"
},
{
"id": "SCN-013",
"severity": "medium",
"category": "Other",
"file": "scripts/setup.sh",
"line": "3",
"description": "curl",
"owasp": "sh pattern in install hint"
},
{
"id": "SCN-014",
"severity": "low",
"category": "Other",
"file": "README.md",
"line": "88",
"description": "Suspicious URL pattern in example",
"owasp": "—"
},
{
"id": "SCN-015",
"severity": "low",
"category": "Other",
"file": "docs/setup.md",
"line": "21",
"description": "Outdated security advisory link",
"owasp": "—"
},
{
"id": "SCN-016",
"severity": "low",
"category": "Other",
"file": "tests/fixtures/poisoned.md",
"line": "1",
"description": "Test fixture flagged (likely intentional)",
"owasp": "—"
},
{
"id": "SCN-017",
"severity": "info",
"category": "Other",
"file": ".gitignore",
"line": "—",
"description": "No `.env*` exclusion rule",
"owasp": "—"
},
{
"id": "SCN-018",
"severity": "info",
"category": "Other",
"file": "LICENSE",
"line": "—",
"description": "License missing",
"owasp": "—"
},
{
"id": "SCN-019",
"severity": "info",
"category": "Other",
"file": "CHANGELOG.md",
"line": "—",
"description": "No CHANGELOG present",
"owasp": "—"
},
{
"id": "SCN-020",
"severity": "info",
"category": "Other",
"file": "SECURITY.md",
"line": "—",
"description": "No SECURITY.md disclosure policy",
"owasp": "—"
},
{
"id": "SCN-021",
"severity": "info",
"category": "Other",
"file": "CONTRIBUTING.md",
"line": "—",
"description": "No CONTRIBUTING guidelines",
"owasp": "—"
}
],
"owasp": [
{
"category": "LLM01 — Prompt Injection",
"findings": 4,
"max_severity": "high",
"scanners": "skill-scanner, post-mcp-verify"
},
{
"category": "LLM02 — Sensitive Info Disclosure",
"findings": 1,
"max_severity": "critical",
"scanners": "secrets"
},
{
"category": "LLM03 — Supply Chain",
"findings": 2,
"max_severity": "medium",
"scanners": "dep-audit"
},
{
"category": "LLM06 — Excessive Agency",
"findings": 2,
"max_severity": "critical",
"scanners": "toxic-flow, memory"
},
{
"category": "MCP05 — Tool Description Drift",
"findings": 2,
"max_severity": "high",
"scanners": "mcp-cache"
},
{
"category": "ASI01 — Lethal Trifecta",
"findings": 1,
"max_severity": "critical",
"scanners": "toxic-flow"
},
{
"category": "ASI04 — Permission Sprawl",
"findings": 2,
"max_severity": "high",
"scanners": "permission"
}
],
"supply_chain": [
{
"component": "lefthook",
"type": "npm",
"source": "registry",
"trust": "6/10",
"notes": "OSV-2024-1234 (medium)"
},
{
"component": "typescript",
"type": "npm",
"source": "registry",
"trust": "9/10",
"notes": "clean"
},
{
"component": "@airbnb/mcp-server",
"type": "npm",
"source": "registry",
"trust": "7/10",
"notes": "per-update drift detected"
}
],
"executive_summary": "Scan found 21 issues across 7 files in the `commands/` and `agents/` directories. Two critical findings require immediate remediation before this plugin is shipped: a hardcoded API key in `agents/data-analyst.md` (line 47) and a lethal trifecta agent (`agents/web-helper.md`) with `[Bash, Read, WebFetch]` and no hook guards. The four high-severity findings concentrate on prompt-injection patterns in MCP tool descriptions.",
"recommendations": [
"Rotate `sk-prod-...` API key and remove from `agents/data-analyst.md`. Replace with environment-variable reference.",
"Rewrite `agents/web-helper.md` to drop one of `[Bash, Read, WebFetch]` OR add a hook policy that blocks the trifecta.",
"Update MCP server description in `.mcp.json` (line 12) and run `/security mcp-baseline-reset` after legitimate update.",
"Replace `Bash(*)` with explicit allowlist in `.claude/settings.json`.",
"Bump `lefthook` to 1.5.0+ to clear OSV-2024-1234."
],
"keyStats": [
{
"label": "RISK SCORE",
"value": 72,
"modifier": "crit"
},
{
"label": "BAND",
"value": "Critical"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"deep-scan": {
"input": {
"target": "~/repos/example-app",
"output_format": "compact",
"fail_on": "high",
"baseline_diff": false
},
"raw_markdown": "# Deep-Scan Report — 10 deterministic scanners\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | deep-scan |\n| **Target** | ~/repos/example-app |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | full repository |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic, OWASP MCP |\n| **Triggered by** | /security deep-scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 58/100 |\n| **Risk Band** | High |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 6 |\n| Medium | 11 |\n| Low | 8 |\n| Info | 14 |\n| **Total** | **39** |\n\n**Verdict rationale:** No critical findings. 6 high-severity findings (4 from taint, 2 from memory-poisoning) push score to 58.\n\n---\n\n## Executive Summary\n\nThe 10-scanner orchestrator produced 39 findings in 4.7 seconds. Highest concentration is in taint-tracer (untrusted input flowing to dangerous sinks in `commands/research.md`) and memory-poisoning-scanner (encoded imperatives in `CLAUDE.md`). No critical findings. Toxic-flow correlator did not detect a complete trifecta — the agent set has hook guards that intervene before the third leg.\n\n---\n\n## Scanner Results\n\n### 1. Unicode Analysis (UNI)\n**Status:** ok | **Files:** 47 | **Findings:** 2 | **Time:** 142ms\n\nDetected 2 instances of zero-width characters in `agents/notes.md`. PUA-A range clear.\n\n### 2. Entropy Analysis (ENT)\n**Status:** ok | **Files:** 89 | **Findings:** 5 | **Time:** 387ms\n\n5 high-entropy strings flagged. 2 suppressed (GLSL keywords in `shaders/blur.glsl`). 3 reported (potential secrets in test fixtures).\n\n### 3. Permission Mapping (PRM)\n**Status:** ok | **Files:** 12 | **Findings:** 4 | **Time:** 89ms\n\n4 over-permissioned agents (tool list includes `Write`/`Edit` without justification). One wildcard Bash grant in settings.json.\n\n### 4. Dependency Audit (DEP)\n**Status:** ok | **Files:** 3 | **Findings:** 3 | **Time:** 1230ms\n\n3 dependencies flagged: 1 OSV-CVE-2024-1234 medium, 2 typosquat suspicions (Levenshtein ≤2 vs official packages).\n\n### 5. Taint Tracing (TNT)\n**Status:** ok | **Files:** 23 | **Findings:** 12 | **Time:** 487ms\n\n12 taint flows detected. 4 reach high-risk sinks (Bash interpolation, WebFetch URL construction).\n\n### 6. Git Forensics (GIT)\n**Status:** ok | **Files:** — | **Findings:** 2 | **Time:** 678ms\n\n2 historical secrets in git history (since rotated, but blob still reachable via reflog).\n\n### 7. Network Mapping (NET)\n**Status:** ok | **Files:** 56 | **Findings:** 3 | **Time:** 412ms\n\n3 suspicious URLs found (1 typosquat domain, 2 raw IP addresses in code comments).\n\n### 8. Memory Poisoning (MEM)\n**Status:** ok | **Files:** 8 | **Findings:** 4 | **Time:** 67ms\n\n4 memory-poisoning patterns in `CLAUDE.md` and 2 agent files: encoded base64 imperatives, suspicious permission expansion, hidden URLs.\n\n### 9. Supply-Chain Recheck (SCR)\n**Status:** ok | **Files:** 2 | **Findings:** 2 | **Time:** 1845ms\n\nOSV.dev returned 2 advisories on installed lockfile entries.\n\n### 10. Toxic-Flow Analyzer (TFA)\n**Status:** ok | **Files:** — | **Findings:** 2 | **Time:** 23ms\n\n2 partial-trifecta agents (2 of 3 legs each). No complete trifectas detected.\n\n---\n\n## Scanner Risk Matrix\n\n| Scanner | CRITICAL | HIGH | MEDIUM | LOW | INFO |\n|---------|----------|------|--------|-----|------|\n| Unicode (UNI) | 0 | 0 | 1 | 1 | 0 |\n| Entropy (ENT) | 0 | 1 | 2 | 1 | 1 |\n| Permission (PRM) | 0 | 1 | 1 | 1 | 1 |\n| Dependency (DEP) | 0 | 0 | 2 | 1 | 0 |\n| Taint (TNT) | 0 | 4 | 3 | 2 | 3 |\n| Git (GIT) | 0 | 0 | 1 | 1 | 0 |\n| Network (NET) | 0 | 0 | 1 | 0 | 2 |\n| Memory (MEM) | 0 | 2 | 0 | 1 | 1 |\n| Supply-Chain (SCR) | 0 | 0 | 1 | 0 | 1 |\n| Toxic-Flow (TFA) | 0 | 0 | 1 | 1 | 0 |\n| **TOTAL** | **0** | **6** | **11** | **8** | **14** |\n\n---\n\n## Methodology\n\n10 deterministic Node.js scanners (zero external dependencies). Results are factual and reproducible. Toxic-flow runs LAST as a post-correlator across prior scanners. See `scanners/lib/severity.mjs` for risk-score formula.\n\n---\n\n## Recommendations\n\n1. **High priority:** Address 4 taint-tracer findings in `commands/research.md` and `agents/notes.md` — sanitize before sink, or add hook gate.\n2. **High priority:** Clean up `CLAUDE.md` memory-poisoning patterns (lines 12, 34, 67).\n3. **Medium:** Bump dependencies to clear OSV advisories.\n4. **Medium:** Force-push history rewrite to remove historical secrets, then rotate keys.\n\nRe-run with `--baseline-diff` against last green run to track progress.\n\n---\n\n*Deep-scan complete. 39 findings, 10 scanners, 4.7 seconds.*\n",
"parsed": {
"risk_score": 58,
"riskBand": "High",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** No critical findings. 6 high-severity findings (4 from taint, 2 from memory-poisoning) push score to 58.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"scanners": [
{
"tag": "UNI",
"name": "Unicode Analysis",
"status": "ok",
"files": "47",
"findings": 2,
"duration_ms": 142,
"details": "Detected 2 instances of zero-width characters in `agents/notes.md`. PUA-A range clear."
},
{
"tag": "ENT",
"name": "Entropy Analysis",
"status": "ok",
"files": "89",
"findings": 5,
"duration_ms": 387,
"details": "5 high-entropy strings flagged. 2 suppressed (GLSL keywords in `shaders/blur.glsl`). 3 reported (potential secrets in test fixtures)."
},
{
"tag": "PRM",
"name": "Permission Mapping",
"status": "ok",
"files": "12",
"findings": 4,
"duration_ms": 89,
"details": "4 over-permissioned agents (tool list includes `Write`/`Edit` without justification). One wildcard Bash grant in settings.json."
},
{
"tag": "DEP",
"name": "Dependency Audit",
"status": "ok",
"files": "3",
"findings": 3,
"duration_ms": 1230,
"details": "3 dependencies flagged: 1 OSV-CVE-2024-1234 medium, 2 typosquat suspicions (Levenshtein ≤2 vs official packages)."
},
{
"tag": "TNT",
"name": "Taint Tracing",
"status": "ok",
"files": "23",
"findings": 12,
"duration_ms": 487,
"details": "12 taint flows detected. 4 reach high-risk sinks (Bash interpolation, WebFetch URL construction)."
},
{
"tag": "GIT",
"name": "Git Forensics",
"status": "ok",
"files": "—",
"findings": 2,
"duration_ms": 678,
"details": "2 historical secrets in git history (since rotated, but blob still reachable via reflog)."
},
{
"tag": "NET",
"name": "Network Mapping",
"status": "ok",
"files": "56",
"findings": 3,
"duration_ms": 412,
"details": "3 suspicious URLs found (1 typosquat domain, 2 raw IP addresses in code comments)."
},
{
"tag": "MEM",
"name": "Memory Poisoning",
"status": "ok",
"files": "8",
"findings": 4,
"duration_ms": 67,
"details": "4 memory-poisoning patterns in `CLAUDE.md` and 2 agent files: encoded base64 imperatives, suspicious permission expansion, hidden URLs."
},
{
"tag": "SCR",
"name": "Supply-Chain Recheck",
"status": "ok",
"files": "2",
"findings": 2,
"duration_ms": 1845,
"details": "OSV.dev returned 2 advisories on installed lockfile entries."
},
{
"tag": "TFA",
"name": "Toxic-Flow Analyzer",
"status": "ok",
"files": "—",
"findings": 2,
"duration_ms": 23,
"details": "2 partial-trifecta agents (2 of 3 legs each). No complete trifectas detected. ---"
}
],
"scanner_matrix": [
{
"scanner": "Unicode (UNI)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 1,
"info": 0
},
{
"scanner": "Entropy (ENT)",
"critical": 0,
"high": 1,
"medium": 2,
"low": 1,
"info": 1
},
{
"scanner": "Permission (PRM)",
"critical": 0,
"high": 1,
"medium": 1,
"low": 1,
"info": 1
},
{
"scanner": "Dependency (DEP)",
"critical": 0,
"high": 0,
"medium": 2,
"low": 1,
"info": 0
},
{
"scanner": "Taint (TNT)",
"critical": 0,
"high": 4,
"medium": 3,
"low": 2,
"info": 3
},
{
"scanner": "Git (GIT)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 1,
"info": 0
},
{
"scanner": "Network (NET)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 0,
"info": 2
},
{
"scanner": "Memory (MEM)",
"critical": 0,
"high": 2,
"medium": 0,
"low": 1,
"info": 1
},
{
"scanner": "Supply-Chain (SCR)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 0,
"info": 1
},
{
"scanner": "Toxic-Flow (TFA)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 1,
"info": 0
}
],
"score": 58,
"findings": [],
"recommendations": [
"Address 4 taint-tracer findings in `commands/research.md` and `agents/notes.md` — sanitize before sink, or add hook gate.",
"Clean up `CLAUDE.md` memory-poisoning patterns (lines 12, 34, 67).",
"Bump dependencies to clear OSV advisories.",
"Force-push history rewrite to remove historical secrets, then rotate keys."
],
"keyStats": [
{
"label": "GRADE",
"value": "C",
"modifier": "med"
},
{
"label": "SCORE",
"value": 58
},
{
"label": "FUNN",
"value": 0
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"posture": {
"input": {
"target": "~/repos/dft-marketplace",
"frameworks": [
"OWASP LLM Top 10",
"EU AI Act",
"NIST AI RMF"
],
"include_compliance_extras": true
},
"raw_markdown": "# Security Posture — DFT marketplace\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | posture |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 16 categories (13 applicable) |\n| **Frameworks** | OWASP LLM Top 10, EU AI Act, NIST AI RMF |\n| **Triggered by** | /security posture |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 22/100 |\n| **Risk Band** | Medium |\n| **Grade** | B |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 3 |\n| Low | 4 |\n| Info | 6 |\n| **Total** | **14** |\n\n---\n\n## Overall Score\n\n**11 / 13 categories covered (Grade B)**\n\n```\n████████████████████░░░░ 84%\n```\n\n**Risk Score:** 22/100 (Medium)\n\n**Verdict:** WARNING — close one high-severity gap to reach Grade A.\n\n---\n\n## Category Scorecard\n\n| # | Category | Status | Findings |\n|---|----------|--------|---------:|\n| 1 | Deny-First Configuration | PASS | 0 |\n| 2 | Hook Coverage | PASS | 0 |\n| 3 | MCP Server Trust | PARTIAL | 2 |\n| 4 | Secret Management | PASS | 0 |\n| 5 | Permission Hygiene | PARTIAL | 1 |\n| 6 | Memory Hygiene | PASS | 0 |\n| 7 | Supply-Chain Defense | PASS | 1 |\n| 8 | Plugin Trust | PASS | 0 |\n| 9 | IDE Extension Hygiene | PASS | 0 |\n| 10 | Skill Hygiene | PARTIAL | 3 |\n| 11 | Logging & Audit | FAIL | 4 |\n| 12 | Documentation | PASS | 1 |\n| 13 | EU AI Act Coverage | PARTIAL | 2 |\n| 14 | NIST AI RMF Mapping | N-A | 0 |\n| 15 | ISO 42001 Mapping | N-A | 0 |\n| 16 | Datatilsynet Compliance | N-A | 0 |\n\n---\n\n## Top Findings\n\n### High\n\n| ID | Category | File | Description |\n|----|----------|------|-------------|\n| PST-001 | Logging & Audit | settings.json | No audit-trail configured (`audit.log_path` unset) |\n\n### Medium\n\n| ID | Category | File | Description |\n|----|----------|------|-------------|\n| PST-002 | Skill Hygiene | skills/data-summary/SKILL.md | Description >150 chars (verbose) |\n| PST-003 | EU AI Act | (project-level) | No AI Act risk classification documented |\n| PST-004 | MCP Trust | .mcp.json | airbnb-mcp drift advisory pending |\n\n---\n\n## Quick Wins\n\n1. **Enable audit trail** — set `audit.log_path` in `.llm-security/policy.json` (closes PST-001).\n2. **Document AI Act classification** — add risk-level to `CLAUDE.md` (closes PST-003).\n3. **Reset airbnb-mcp baseline** — after legitimate review (closes PST-004).\n\n---\n\n## Baseline Comparison\n\nNo baseline saved. Run `/security posture --save-baseline` to track future drift.\n\n---\n\n## Recommendations\n\n1. **High:** Enable audit logging — single setting closes the only high-severity gap.\n2. **Medium:** Add AI Act risk classification.\n3. **Medium:** Trim verbose skill descriptions in 3 skills.\n\nEstimated effort to Grade A: 30 minutes.\n\n---\n\n*Posture complete. Grade B, 14 findings, 1.2 seconds.*\n",
"parsed": {
"risk_score": 22,
"riskBand": "Medium",
"grade": "B",
"verdict": "warning",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"score": 11,
"posture_score": 11,
"posture_applicable": 13,
"categories": [
{
"num": 1,
"name": "Deny-First Configuration",
"status": "PASS",
"findings": 0
},
{
"num": 2,
"name": "Hook Coverage",
"status": "PASS",
"findings": 0
},
{
"num": 3,
"name": "MCP Server Trust",
"status": "PARTIAL",
"findings": 2
},
{
"num": 4,
"name": "Secret Management",
"status": "PASS",
"findings": 0
},
{
"num": 5,
"name": "Permission Hygiene",
"status": "PARTIAL",
"findings": 1
},
{
"num": 6,
"name": "Memory Hygiene",
"status": "PASS",
"findings": 0
},
{
"num": 7,
"name": "Supply-Chain Defense",
"status": "PASS",
"findings": 1
},
{
"num": 8,
"name": "Plugin Trust",
"status": "PASS",
"findings": 0
},
{
"num": 9,
"name": "IDE Extension Hygiene",
"status": "PASS",
"findings": 0
},
{
"num": 10,
"name": "Skill Hygiene",
"status": "PARTIAL",
"findings": 3
},
{
"num": 11,
"name": "Logging & Audit",
"status": "FAIL",
"findings": 4
},
{
"num": 12,
"name": "Documentation",
"status": "PASS",
"findings": 1
},
{
"num": 13,
"name": "EU AI Act Coverage",
"status": "PARTIAL",
"findings": 2
},
{
"num": 14,
"name": "NIST AI RMF Mapping",
"status": "N-A",
"findings": 0
},
{
"num": 15,
"name": "ISO 42001 Mapping",
"status": "N-A",
"findings": 0
},
{
"num": 16,
"name": "Datatilsynet Compliance",
"status": "N-A",
"findings": 0
}
],
"findings": [
{
"id": "PST-001",
"severity": "high",
"category": "Logging & Audit",
"file": "settings.json",
"description": "No audit-trail configured (`audit.log_path` unset)"
},
{
"id": "PST-002",
"severity": "medium",
"category": "Skill Hygiene",
"file": "skills/data-summary/SKILL.md",
"description": "Description >150 chars (verbose)"
},
{
"id": "PST-003",
"severity": "medium",
"category": "EU AI Act",
"file": "(project-level)",
"description": "No AI Act risk classification documented"
},
{
"id": "PST-004",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"description": "airbnb-mcp drift advisory pending"
}
],
"quick_wins": [
"set `audit.log_path` in `.llm-security/policy.json` (closes PST-001).",
"add risk-level to `CLAUDE.md` (closes PST-003).",
"after legitimate review (closes PST-004)."
],
"recommendations": [
"Enable audit logging — single setting closes the only high-severity gap.",
"Add AI Act risk classification.",
"Trim verbose skill descriptions in 3 skills."
],
"keyStats": []
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"audit": {
"input": {
"target": "~/repos/dft-marketplace",
"frameworks": [
"OWASP LLM Top 10",
"OWASP Agentic (ASI)"
],
"severity_threshold": "high",
"include_remediation": true
},
"raw_markdown": "# Full Security Audit — DFT marketplace\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | audit |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 7 audit dimensions, 10 OWASP categories |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic |\n| **Triggered by** | /security audit |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 31/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 4 |\n| Medium | 8 |\n| Low | 7 |\n| Info | 9 |\n| **Total** | **28** |\n\n**Verdict rationale:** Posture base grade B downgraded to C after agent-level findings (4 high). No critical, but `Logging & Audit` and `Permission Hygiene` need attention.\n\n---\n\n## Executive Summary\n\nFull audit combined posture-scanner output with skill-scanner-agent and mcp-scanner-agent narratives. 28 findings across 14 files. Most concentrated in agent definitions (over-permissioned tool lists) and `.claude/settings.json` (missing audit log + wildcard Bash). Recommendation: address top 3 actions to reach Grade B; six more to reach Grade A.\n\n---\n\n## Radar Axes\n\n| Axis | Score |\n|------|------:|\n| Deny-First Configuration | 4 |\n| Hook Coverage | 5 |\n| MCP Trust | 3 |\n| Secrets Management | 5 |\n| Permission Hygiene | 2 |\n| Supply-Chain Defense | 4 |\n| Logging & Audit | 1 |\n\n---\n\n## Category Assessment\n\n### Category 1 — Deny-First Configuration\n\n| Status | PASS |\n\n**Evidence:** `.claude/settings.json` has `permissions.defaultMode: \"deny\"`. Explicit allow-list in place.\n\n**Recommendations:** None — Grade A on this axis.\n\n### Category 2 — Hook Coverage\n\n| Status | PASS |\n\n**Evidence:** 9 hooks active (PreToolUse: 4, PostToolUse: 2, UserPromptSubmit: 1, PreCompact: 1, others: 1).\n\n**Recommendations:** Consider adding PreCompact-poisoning detection if not already covered.\n\n### Category 5 — Permission Hygiene\n\n| Status | PARTIAL |\n\n**Evidence:** 3 agents have `Write` in tool list. 1 has `Bash` without sub-command restriction.\n\n**Recommendations:** Tighten tool lists to minimum-necessary set. Use `Bash(git:*)` instead of `Bash(*)`.\n\n### Category 11 — Logging & Audit\n\n| Status | FAIL |\n\n**Evidence:** No `audit.log_path` configured. No SIEM integration. No JSONL audit-trail.\n\n**Recommendations:** Enable `audit.log_path` immediately — closes 1 high + 3 medium findings.\n\n(Categories 3, 4, 6-10, 12-13 follow same format — see envelope JSON for full breakdown)\n\n---\n\n## Risk Matrix (Likelihood × Impact)\n\n| Category | Likelihood | Impact | Score |\n|----------|-----------:|-------:|------:|\n| Logging gap (PST-001) | 4 | 4 | 16 |\n| Permission sprawl | 3 | 4 | 12 |\n| MCP drift (airbnb-mcp) | 3 | 3 | 9 |\n| AI Act classification missing | 2 | 3 | 6 |\n\n---\n\n## Action Plan\n\n### IMMEDIATE (this week)\n\n1. Enable audit-trail: set `audit.log_path` in `.llm-security/policy.json`\n2. Tighten 3 over-permissioned agents (drop `Write` where unused)\n3. Investigate airbnb-mcp drift — reset baseline only after review\n\n### HIGH (this month)\n\n4. Document AI Act risk classification in `CLAUDE.md`\n5. Replace `Bash(*)` with `Bash(git:*, npm:*)` in `.claude/settings.json`\n6. Bump 2 dependencies to clear OSV advisories\n\n### MEDIUM (next quarter)\n\n7. Add SECURITY.md disclosure policy\n8. Trim verbose skill descriptions (3 files)\n9. Document hook rationale in plugin CLAUDE.md\n\n---\n\n## Positive Findings\n\n- All hooks active and non-bypassed\n- No critical findings\n- Posture scanner runtime < 2s (well-tuned)\n- Memory hygiene clean\n\n---\n\n*Audit complete. 28 findings, Grade C, 14.7 seconds.*\n",
"parsed": {
"risk_score": 31,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** Posture base grade B downgraded to C after agent-level findings (4 high). No critical, but `Logging & Audit` and `Permission Hygiene` need attention.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"score": 31,
"radar_axes": [
{
"name": "Deny-First Configuration",
"score": 4
},
{
"name": "Hook Coverage",
"score": 5
},
{
"name": "MCP Trust",
"score": 3
},
{
"name": "Secrets Management",
"score": 5
},
{
"name": "Permission Hygiene",
"score": 2
},
{
"name": "Supply-Chain Defense",
"score": 4
},
{
"name": "Logging & Audit",
"score": 1
}
],
"categories": [
{
"num": 1,
"name": "Deny-First Configuration",
"status": "PASS"
},
{
"num": 2,
"name": "Hook Coverage",
"status": "PASS"
},
{
"num": 5,
"name": "Permission Hygiene",
"status": "PARTIAL"
},
{
"num": 11,
"name": "Logging & Audit",
"status": "FAIL"
}
],
"risk_matrix": [
{
"category": "Logging gap (PST-001)",
"likelihood": 4,
"impact": 4,
"score": 16
},
{
"category": "Permission sprawl",
"likelihood": 3,
"impact": 4,
"score": 12
},
{
"category": "MCP drift (airbnb-mcp)",
"likelihood": 3,
"impact": 3,
"score": 9
},
{
"category": "AI Act classification missing",
"likelihood": 2,
"impact": 3,
"score": 6
}
],
"action_plan": {
"immediate": [
"Enable audit-trail: set `audit.log_path` in `.llm-security/policy.json`",
"Tighten 3 over-permissioned agents (drop `Write` where unused)",
"Investigate airbnb-mcp drift — reset baseline only after review"
],
"high": [
"Document AI Act risk classification in `CLAUDE.md`",
"Replace `Bash(*)` with `Bash(git:*, npm:*)` in `.claude/settings.json`",
"Bump 2 dependencies to clear OSV advisories"
],
"medium": [
"Add SECURITY.md disclosure policy",
"Trim verbose skill descriptions (3 files)",
"Document hook rationale in plugin CLAUDE.md"
]
},
"findings": [],
"executive_summary": "Full audit combined posture-scanner output with skill-scanner-agent and mcp-scanner-agent narratives. 28 findings across 14 files. Most concentrated in agent definitions (over-permissioned tool lists) and `.claude/settings.json` (missing audit log + wildcard Bash). Recommendation: address top 3 actions to reach Grade B; six more to reach Grade A.\n\n---",
"keyStats": [
{
"label": "GRADE",
"value": "C",
"modifier": "med"
},
{
"label": "SCORE",
"value": 31
},
{
"label": "FUNN",
"value": 0
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"dashboard": {
"input": {
"no_cache": false,
"max_depth": 3
},
"raw_markdown": "# Security Dashboard — Machine-wide\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | dashboard |\n| **Target** | machine-wide (5 projects) |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | all Claude Code projects under ~/ + ~/.claude/plugins/ |\n| **Frameworks** | OWASP LLM Top 10 |\n| **Triggered by** | /security dashboard |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Machine Grade** | C (weakest link) |\n| **Projects Scanned** | 5 |\n| **Total Findings** | 87 |\n| **Scan Time** | 8.4s |\n| **Cache** | Cached (3h old) |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 1 |\n| High | 12 |\n| Medium | 28 |\n| Low | 24 |\n| Info | 22 |\n| **Total** | **87** |\n\n**Verdict rationale:** Machine grade is weakest-link rule. The `from-ai-to-chitta` project (Grade D) drags machine to C. Resolving that project would lift machine to B.\n\n---\n\n## Project Overview\n\n| Project | Grade | Risk | Worst Category | Findings |\n|---------|-------|------:|----------------|---------:|\n| from-ai-to-chitta | D | 56 | MCP Trust | 32 |\n| dft-marketplace | C | 31 | Logging & Audit | 28 |\n| airbnb-mcp-plugin | C | 41 | Permissions | 14 |\n| ktg-plugin-marketplace | B | 22 | Skill Hygiene | 9 |\n| nightly-utils | A | 4 | — | 4 |\n\n---\n\n## Trend (since last scan)\n\n| Project | Trend | Δ Risk | Δ Findings |\n|---------|:-----:|-------:|-----------:|\n| from-ai-to-chitta | worse | +12 | +6 |\n| dft-marketplace | stable | 0 | -1 |\n| airbnb-mcp-plugin | stable | -2 | 0 |\n| ktg-plugin-marketplace | better | -7 | -3 |\n| nightly-utils | stable | 0 | 0 |\n\n---\n\n## Errors\n\nNo projects failed to scan in this run.\n\n---\n\n## Recommendations\n\n1. **Priority:** Investigate `from-ai-to-chitta` — only Grade D project. Run `/security audit ~/repos/from-ai-to-chitta` for category-level breakdown.\n2. **Quick win:** Apply audit-trail fix to `dft-marketplace` (already identified, 30 min) → likely lifts to Grade B.\n3. **Maintenance:** Re-run `/security plugin-audit` on `airbnb-mcp-plugin` after maintainer responds to permission-clarification issue.\n\nEstimated effort to Machine Grade B: 4 hours (focused on from-ai-to-chitta + dft-marketplace).\n\n---\n\n*Dashboard complete. 5 projects, machine grade C.*\n",
"parsed": {
"verdict_rationale": "** Machine grade is weakest-link rule. The `from-ai-to-chitta` project (Grade D) drags machine to C. Resolving that project would lift machine to B.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"machine_grade": "C",
"projects_scanned": 5,
"total_findings": 87,
"cache": "Cached (3h old)",
"projects": [
{
"name": "from-ai-to-chitta",
"grade": "D",
"risk": 56,
"worst_category": "MCP Trust",
"findings": 32
},
{
"name": "dft-marketplace",
"grade": "C",
"risk": 31,
"worst_category": "Logging & Audit",
"findings": 28
},
{
"name": "airbnb-mcp-plugin",
"grade": "C",
"risk": 41,
"worst_category": "Permissions",
"findings": 14
},
{
"name": "ktg-plugin-marketplace",
"grade": "B",
"risk": 22,
"worst_category": "Skill Hygiene",
"findings": 9
},
{
"name": "nightly-utils",
"grade": "A",
"risk": 4,
"worst_category": "—",
"findings": 4
}
],
"trends": [
{
"name": "from-ai-to-chitta",
"trend": "worse",
"d_risk": "+12",
"d_findings": "+6"
},
{
"name": "dft-marketplace",
"trend": "stable",
"d_risk": "0",
"d_findings": "-1"
},
{
"name": "airbnb-mcp-plugin",
"trend": "stable",
"d_risk": "-2",
"d_findings": "0"
},
{
"name": "ktg-plugin-marketplace",
"trend": "better",
"d_risk": "-7",
"d_findings": "-3"
},
{
"name": "nightly-utils",
"trend": "stable",
"d_risk": "0",
"d_findings": "0"
}
],
"errors": [],
"weakest_link": "from-ai-to-chitta",
"recommendations": [
"Investigate `from-ai-to-chitta` — only Grade D project. Run `/security audit ~/repos/from-ai-to-chitta` for category-level breakdown.",
"Apply audit-trail fix to `dft-marketplace` (already identified, 30 min) → likely lifts to Grade B.",
"Re-run `/security plugin-audit` on `airbnb-mcp-plugin` after maintainer responds to permission-clarification issue."
],
"verdict": "warning",
"keyStats": [
{
"label": "PROSJEKTER",
"value": 5
},
{
"label": "MASKINKLASSE",
"value": "C"
},
{
"label": "SVAKEST",
"value": "from-ai-to-chitta"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
}
}
},
{
"id": "dft-mcp-airbnb-audit",
"name": "MCP-server-audit: airbnb-mcp",
"description": "Tredjeparts MCP-server vurdert for innføring. Trust-vurdering, supply-chain-sjekk og live tool-deskripsjons-skann.",
"target_type": "mcp-server",
"target_path": "https://github.com/airbnb/mcp-server",
"scenarios": ["mcp-supply-chain", "plugin-trust"],
"createdAt": "2026-05-05T10:30:00.000Z",
"reports": {
"plugin-audit": {
"input": {
"target": "https://github.com/airbnb-example/airbnb-mcp-plugin",
"install_intent": false,
"strict_mode": true
},
"raw_markdown": "# Plugin-Audit — airbnb-mcp-plugin\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | plugin-audit |\n| **Target** | https://github.com/airbnb-example/airbnb-mcp-plugin |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | plugin trust assessment |\n| **Frameworks** | OWASP MCP, OWASP LLM Top 10 |\n| **Triggered by** | /security plugin-audit |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 41/100 |\n| **Risk Band** | High |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 3 |\n| Medium | 5 |\n| Low | 4 |\n| Info | 2 |\n| **Total** | **14** |\n\n**Verdict rationale:** Plugin requests broad permissions (Bash, Write, WebFetch) with limited justification. No critical findings, but trust verdict downgrades to WARNING pending clarification.\n\n---\n\n## Executive Summary\n\nThird-party Claude Code plugin distributed via GitHub. Implements 4 MCP tools (search, book, cancel, list-reservations). Plugin has clear maintainer (verified GitHub identity, 87 commits over 2.3 years). Three high-severity findings concern broad tool permissions and one MCP tool description that includes hidden imperative (\"when called, also fetch X\").\n\n---\n\n## Plugin Metadata\n\n| Field | Value |\n|-------|-------|\n| **Name** | airbnb-mcp-plugin |\n| **Version** | 1.4.2 |\n| **Author** | airbnb-example (verified) |\n| **License** | MIT |\n| **Source** | https://github.com/airbnb-example/airbnb-mcp-plugin |\n| **First commit** | 2024-01-15 |\n| **Last commit** | 2026-04-22 |\n| **Commits** | 87 |\n| **Stars** | 247 |\n\n---\n\n## Component Inventory\n\n| Component | Count | Notes |\n|-----------|------:|-------|\n| Commands | 3 | book.md, cancel.md, list.md |\n| Agents | 1 | search-agent.md |\n| MCP Servers | 1 | airbnb-mcp (4 tools) |\n| Hooks | 0 | (none) |\n| Skills | 0 | (none) |\n\n---\n\n## Permission Matrix\n\n| Tool | Required by | Justified |\n|------|-------------|-----------|\n| Read | search-agent | Yes — needs to read user filters |\n| WebFetch | search-agent | Yes — Airbnb API |\n| Bash | book.md | Partial — only used for date math |\n| Write | search-agent | No — appears unused |\n| Edit | (none) | — |\n\n---\n\n## Hook Safety\n\nNo hooks defined. Plugin operates entirely through MCP tools and agent definitions. No PreToolUse/PostToolUse mechanisms to verify.\n\n---\n\n## Trust Verdict\n\n**Verdict:** WARNING — install with caution\n\n**Rationale:**\n- Maintainer is verifiable (GitHub identity, history)\n- License is MIT (permissive, OK)\n- Permission grant is broader than necessary (Write tool unused)\n- One MCP tool description (`book`) contains an implicit instruction outside its declared purpose\n\n**Recommended action:** Open issue with maintainer requesting (a) drop unused `Write` permission, (b) clarify `book` tool description. Re-audit after maintainer response.\n\n---\n\n## Findings\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PA-001 | Permissions | search-agent.md | 5 | Tool list includes `Write` with no apparent use | ASI04 |\n| PA-002 | MCP Trust | mcp-tools/book.json | 14 | Description has hidden imperative outside scope | MCP05 |\n| PA-003 | Permissions | book.md | 8 | Bash permission not minimized to specific commands | ASI04 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PA-004 | Supply Chain | package.json | 12 | Dependency `@airbnb/utils@2.1.0` outdated | LLM03 |\n| PA-005 | Output Handling | search-agent.md | 34 | API response inserted as markdown without sanitization | LLM01 |\n| PA-006 | Other | README.md | — | No security disclosure policy | — |\n| PA-007 | Other | CHANGELOG.md | — | Last 3 releases lack security notes | — |\n| PA-008 | Permissions | .claude/settings.json | 5 | Settings file commits hooks=null (acceptable) | — |\n\n### Low\n\n(4 low + 2 info findings — see envelope JSON for full list)\n\n---\n\n## Recommendations\n\n1. **High:** Open issue with maintainer about `Write` permission removal.\n2. **High:** Request clarification of `book` tool description.\n3. **Medium:** Bump `@airbnb/utils` to current.\n4. **Medium:** Add SECURITY.md.\n\nIf maintainer response is satisfactory: re-audit. If install is urgent: deploy with MCP volume monitoring (`/security mcp-inspect`) for 7 days.\n\n---\n\n*Plugin-audit complete. 14 findings, trust verdict WARNING.*\n",
"parsed": {
"risk_score": 41,
"riskBand": "High",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** Plugin requests broad permissions (Bash, Write, WebFetch) with limited justification. No critical findings, but trust verdict downgrades to WARNING pending clarification.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"plugin_metadata": {
"name": "airbnb-mcp-plugin",
"version": "1.4.2",
"author": "airbnb-example (verified)",
"license": "MIT",
"source": "https://github.com/airbnb-example/airbnb-mcp-plugin",
"first_commit": "2024-01-15",
"last_commit": "2026-04-22",
"commits": "87",
"stars": "247"
},
"components": [
{
"component": "Commands",
"count": 3,
"notes": "book.md, cancel.md, list.md"
},
{
"component": "Agents",
"count": 1,
"notes": "search-agent.md"
},
{
"component": "MCP Servers",
"count": 1,
"notes": "airbnb-mcp (4 tools)"
},
{
"component": "Hooks",
"count": 0,
"notes": "(none)"
},
{
"component": "Skills",
"count": 0,
"notes": "(none)"
}
],
"permissions": [
{
"tool": "Read",
"required_by": "search-agent",
"justified": "Yes — needs to read user filters"
},
{
"tool": "WebFetch",
"required_by": "search-agent",
"justified": "Yes — Airbnb API"
},
{
"tool": "Bash",
"required_by": "book.md",
"justified": "Partial — only used for date math"
},
{
"tool": "Write",
"required_by": "search-agent",
"justified": "No — appears unused"
},
{
"tool": "Edit",
"required_by": "(none)",
"justified": "—"
}
],
"trust_verdict_text": "**Verdict:** WARNING — install with caution\n\n**Rationale:**\n- Maintainer is verifiable (GitHub identity, history)\n- License is MIT (permissive, OK)\n- Permission grant is broader than necessary (Write tool unused)\n- One MCP tool description (`book`) contains an implicit instruction outside its declared purpose\n\n**Recommended action:** Open issue with maintainer requesting (a) drop unused `Write` permission, (b) clarify `book` tool description. Re-audit after maintainer response.\n\n---",
"trust_verdict": "warning",
"findings": [
{
"id": "PA-001",
"severity": "high",
"category": "Permissions",
"file": "search-agent.md",
"line": "5",
"description": "Tool list includes `Write` with no apparent use",
"owasp": "ASI04"
},
{
"id": "PA-002",
"severity": "high",
"category": "MCP Trust",
"file": "mcp-tools/book.json",
"line": "14",
"description": "Description has hidden imperative outside scope",
"owasp": "MCP05"
},
{
"id": "PA-003",
"severity": "high",
"category": "Permissions",
"file": "book.md",
"line": "8",
"description": "Bash permission not minimized to specific commands",
"owasp": "ASI04"
},
{
"id": "PA-004",
"severity": "medium",
"category": "Supply Chain",
"file": "package.json",
"line": "12",
"description": "Dependency `@airbnb/utils@2.1.0` outdated",
"owasp": "LLM03"
},
{
"id": "PA-005",
"severity": "medium",
"category": "Output Handling",
"file": "search-agent.md",
"line": "34",
"description": "API response inserted as markdown without sanitization",
"owasp": "LLM01"
},
{
"id": "PA-006",
"severity": "medium",
"category": "Other",
"file": "README.md",
"line": "—",
"description": "No security disclosure policy",
"owasp": "—"
},
{
"id": "PA-007",
"severity": "medium",
"category": "Other",
"file": "CHANGELOG.md",
"line": "—",
"description": "Last 3 releases lack security notes",
"owasp": "—"
},
{
"id": "PA-008",
"severity": "medium",
"category": "Permissions",
"file": ".claude/settings.json",
"line": "5",
"description": "Settings file commits hooks=null (acceptable)",
"owasp": "—"
}
],
"recommendations": [
"Open issue with maintainer about `Write` permission removal.",
"Request clarification of `book` tool description.",
"Bump `@airbnb/utils` to current.",
"Add SECURITY.md."
],
"keyStats": [
{
"label": "RISK SCORE",
"value": 41,
"modifier": "med"
},
{
"label": "BAND",
"value": "High"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"mcp-audit": {
"input": {
"live_inspection": false,
"config_paths": "~/.claude/.mcp.json"
},
"raw_markdown": "# MCP Config Audit\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | mcp-audit |\n| **Target** | ~/.claude/.mcp.json + per-project configs |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 5 MCP servers (3 active, 2 dormant) |\n| **Frameworks** | OWASP MCP |\n| **Triggered by** | /security mcp-audit |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 33/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 2 |\n| Medium | 6 |\n| Low | 3 |\n| Info | 4 |\n| **Total** | **15** |\n\n**Verdict rationale:** No critical findings. Two high findings: airbnb-mcp tool description drift (per-update + cumulative) and tavily-mcp grants `process.env` read which is unjustified for search use case.\n\n---\n\n## MCP Landscape\n\n| Server | Type | Trust | Tools | Active |\n|--------|------|-------|-------|-------:|\n| airbnb-mcp | local-stdio | medium | 4 | yes |\n| tavily-mcp | http-sse | low | 6 | yes |\n| microsoft-learn | http-sse | high | 3 | yes |\n| gemini-mcp | local-stdio | high | 4 | dormant |\n| mermaid-chart | http-sse | medium | 17 | dormant |\n\n---\n\n## Per-Server Analysis\n\n### airbnb-mcp\n\n- **Path:** `~/.claude/mcp-servers/airbnb-mcp/`\n- **Origin:** GitHub (airbnb-example, MIT)\n- **Tool description drift:** per-update 12.3% (alert), cumulative 27% from baseline (advisory)\n- **Permissions:** Bash, WebFetch, Read\n- **Verdict:** WARNING — drift indicates possible upgrade or rug-pull. Investigate before reset.\n\n### tavily-mcp\n\n- **Path:** remote (HTTP-SSE)\n- **Origin:** tavily.ai\n- **Tool description drift:** none\n- **Permissions:** WebFetch, env-vars (TAVILY_API_KEY)\n- **Verdict:** WARNING — env-var read scope is broader than needed. Confirm only TAVILY_API_KEY is exposed.\n\n### microsoft-learn\n\n- **Path:** remote (HTTP-SSE)\n- **Origin:** Microsoft\n- **Tool description drift:** none\n- **Permissions:** WebFetch\n- **Verdict:** ALLOW — minimal surface, well-scoped.\n\n### gemini-mcp (dormant)\n\n- **Path:** `~/.claude/mcp-servers/gemini-mcp/`\n- **Origin:** local-built\n- **Verdict:** N/A (dormant)\n\n### mermaid-chart (dormant)\n\n- **Path:** remote (HTTP-SSE)\n- **Verdict:** N/A (dormant)\n\n---\n\n## MCP Risk Assessment\n\n3 active servers, 17 total tools across active set. Risk concentration: airbnb-mcp (description drift) + tavily-mcp (env-var scope). One server (microsoft-learn) is well-scoped baseline.\n\n---\n\n## Keep / Review / Remove\n\n| Decision | Server | Reason |\n|----------|--------|--------|\n| Keep | microsoft-learn | Well-scoped, official source |\n| Keep | gemini-mcp | Dormant but trusted, retain |\n| Review | airbnb-mcp | Description drift requires investigation |\n| Review | tavily-mcp | Env-var scope overly broad |\n| Remove | mermaid-chart | Dormant 87 days, no usage |\n\n---\n\n## Findings\n\n### High\n\n| ID | Server | Description | OWASP |\n|----|--------|-------------|-------|\n| MA-001 | airbnb-mcp | Cumulative drift 27% from baseline (sticky) | MCP05 |\n| MA-002 | tavily-mcp | env-var read includes more than declared keys | MCP06 |\n\n### Medium\n\n| ID | Server | Description | OWASP |\n|----|--------|-------------|-------|\n| MA-003 | airbnb-mcp | Per-update drift 12.3% on `book` tool | MCP05 |\n| MA-004 | airbnb-mcp | Tool `book` returns large payloads without size cap | MCP09 |\n| MA-005 | tavily-mcp | TLS cert pinning not enforced | MCP08 |\n| MA-006 | mermaid-chart | Dormant > 90 days, suggest removal | — |\n| MA-007 | airbnb-mcp | Description includes implicit instruction | MCP05 |\n| MA-008 | tavily-mcp | Rate-limit not configured client-side | MCP09 |\n\n### Low / Info\n\n(7 lower-severity findings — see envelope)\n\n---\n\n## Recommendations\n\n1. **High:** Run `/security mcp-baseline-reset --target airbnb-mcp` only AFTER manual review of new description.\n2. **High:** Restrict `tavily-mcp` env-var scope to `TAVILY_API_KEY` exclusively (settings.local.json).\n3. **Medium:** Remove dormant `mermaid-chart` server unless re-activated within 14 days.\n4. **Medium:** Add response-size caps for `airbnb-mcp` `book` tool.\n\n---\n\n*MCP-audit complete. 5 servers, 15 findings, verdict WARNING.*\n",
"parsed": {
"risk_score": 33,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** No critical findings. Two high findings: airbnb-mcp tool description drift (per-update + cumulative) and tavily-mcp grants `process.env` read which is unjustified for search use case.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"mcp_servers": [
{
"server": "airbnb-mcp",
"type": "local-stdio",
"trust": "medium",
"tools": 4,
"active": true
},
{
"server": "tavily-mcp",
"type": "http-sse",
"trust": "low",
"tools": 6,
"active": true
},
{
"server": "microsoft-learn",
"type": "http-sse",
"trust": "high",
"tools": 3,
"active": true
},
{
"server": "gemini-mcp",
"type": "local-stdio",
"trust": "high",
"tools": 4,
"active": false
},
{
"server": "mermaid-chart",
"type": "http-sse",
"trust": "medium",
"tools": 17,
"active": false
}
],
"per_server": [
{
"name": "airbnb-mcp",
"note": "",
"body": "- **Path:** `~/.claude/mcp-servers/airbnb-mcp/`\n- **Origin:** GitHub (airbnb-example, MIT)\n- **Tool description drift:** per-update 12.3% (alert), cumulative 27% from baseline (advisory)\n- **Permissions:** Bash, WebFetch, Read\n- **Verdict:** WARNING — drift indicates possible upgrade or rug-pull. Investigate before reset."
},
{
"name": "tavily-mcp",
"note": "",
"body": "- **Path:** remote (HTTP-SSE)\n- **Origin:** tavily.ai\n- **Tool description drift:** none\n- **Permissions:** WebFetch, env-vars (TAVILY_API_KEY)\n- **Verdict:** WARNING — env-var read scope is broader than needed. Confirm only TAVILY_API_KEY is exposed."
},
{
"name": "microsoft-learn",
"note": "",
"body": "- **Path:** remote (HTTP-SSE)\n- **Origin:** Microsoft\n- **Tool description drift:** none\n- **Permissions:** WebFetch\n- **Verdict:** ALLOW — minimal surface, well-scoped."
},
{
"name": "gemini-mcp",
"note": "dormant",
"body": "- **Path:** `~/.claude/mcp-servers/gemini-mcp/`\n- **Origin:** local-built\n- **Verdict:** N/A (dormant)"
},
{
"name": "mermaid-chart",
"note": "dormant",
"body": "- **Path:** remote (HTTP-SSE)\n- **Verdict:** N/A (dormant)\n\n---"
}
],
"buckets": {
"keep": [
{
"server": "microsoft-learn",
"reason": "Well-scoped, official source"
},
{
"server": "gemini-mcp",
"reason": "Dormant but trusted, retain"
}
],
"review": [
{
"server": "airbnb-mcp",
"reason": "Description drift requires investigation"
},
{
"server": "tavily-mcp",
"reason": "Env-var scope overly broad"
}
],
"remove": [
{
"server": "mermaid-chart",
"reason": "Dormant 87 days, no usage"
}
]
},
"findings": [
{
"id": "MA-001",
"severity": "high",
"server": "airbnb-mcp",
"description": "Cumulative drift 27% from baseline (sticky)",
"owasp": "MCP05"
},
{
"id": "MA-002",
"severity": "high",
"server": "tavily-mcp",
"description": "env-var read includes more than declared keys",
"owasp": "MCP06"
},
{
"id": "MA-003",
"severity": "medium",
"server": "airbnb-mcp",
"description": "Per-update drift 12.3% on `book` tool",
"owasp": "MCP05"
},
{
"id": "MA-004",
"severity": "medium",
"server": "airbnb-mcp",
"description": "Tool `book` returns large payloads without size cap",
"owasp": "MCP09"
},
{
"id": "MA-005",
"severity": "medium",
"server": "tavily-mcp",
"description": "TLS cert pinning not enforced",
"owasp": "MCP08"
},
{
"id": "MA-006",
"severity": "medium",
"server": "mermaid-chart",
"description": "Dormant > 90 days, suggest removal",
"owasp": "—"
},
{
"id": "MA-007",
"severity": "medium",
"server": "airbnb-mcp",
"description": "Description includes implicit instruction",
"owasp": "MCP05"
},
{
"id": "MA-008",
"severity": "medium",
"server": "tavily-mcp",
"description": "Rate-limit not configured client-side",
"owasp": "MCP09"
}
],
"recommendations": [
"Run `/security mcp-baseline-reset --target airbnb-mcp` only AFTER manual review of new description.",
"Restrict `tavily-mcp` env-var scope to `TAVILY_API_KEY` exclusively (settings.local.json).",
"Remove dormant `mermaid-chart` server unless re-activated within 14 days.",
"Add response-size caps for `airbnb-mcp` `book` tool."
],
"keyStats": [
{
"label": "TOTALT",
"value": 8
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 2,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"ide-scan": {
"input": {
"target": "",
"vscode_only": false,
"intellij_only": false,
"include_builtin": false,
"online": false
},
"raw_markdown": "# IDE-Extension Scan\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | ide-scan |\n| **Target** | installed VS Code + JetBrains extensions |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 47 VS Code extensions + 12 JetBrains plugins |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic |\n| **Triggered by** | /security ide-scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 28/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 4 |\n| Low | 7 |\n| Info | 12 |\n| **Total** | **24** |\n\n**Verdict rationale:** One high-severity finding: a JetBrains plugin (`acme-helper`) declares `Premain-Class` (javaagent retransform) which is the riskiest IDE-extension pattern.\n\n---\n\n## Scan Coverage\n\n| IDE | Extensions Scanned | Findings |\n|-----|-------------------:|---------:|\n| VS Code | 47 | 8 |\n| Cursor | 12 (subset of VS Code) | 2 |\n| IntelliJ IDEA | 12 | 14 |\n| **Total** | **59** | **24** |\n\n---\n\n## Findings\n\n### High\n\n| ID | Extension | IDE | Description | OWASP |\n|----|-----------|-----|-------------|-------|\n| IDE-001 | acme-helper | IntelliJ | Declares `Premain-Class` — javaagent retransform attack surface | ASI04 |\n\n### Medium\n\n| ID | Extension | IDE | Description | OWASP |\n|----|-----------|-----|-------------|-------|\n| IDE-002 | dark-theme-pro | VS Code | Theme contains `extension.js` (theme-with-code) | LLM06 |\n| IDE-003 | rest-client-typo | VS Code | Typosquat: Levenshtein 2 vs `rest-client` (top-100) | LLM03 |\n| IDE-004 | ace-helper | IntelliJ | Long `<depends>` chain (12 plugins) — large surface | LLM03 |\n| IDE-005 | json-fast | VS Code | activationEvents includes `*` (broad activation) | ASI04 |\n\n### Low\n\n| ID | Extension | IDE | Description | OWASP |\n|----|-----------|-----|-------------|-------|\n| IDE-006 | git-graph | VS Code | Native binary `.dylib` shipped (verified signature OK) | — |\n| IDE-007 | gradle-helper | IntelliJ | Native binary `.so` shipped (Linux ELF) | — |\n| IDE-008 | vsc-cmd | VS Code | `vscode:uninstall` hook present | — |\n| IDE-009 | shaded-jar-pro | IntelliJ | Shaded jar advisory (3 jars) | — |\n| IDE-010 | rest-client-typo | VS Code | Same as IDE-003: typosquat suspicion | LLM03 |\n| IDE-011 | code-splitter | VS Code | activationEvents `onStartupFinished` (broad) | ASI04 |\n| IDE-012 | java-fmt | IntelliJ | Premain-Class candidate (lower confidence) | ASI04 |\n\n### Info\n\n12 informational findings (mostly publisher metadata + extension-pack expansions). See envelope for full list.\n\n---\n\n## Per-IDE Recommendations\n\n### VS Code\n\n1. **Medium:** Investigate `dark-theme-pro` — themes should not ship code.\n2. **Medium:** Compare `rest-client-typo` to `rest-client` — likely typosquat. Uninstall.\n3. **Medium:** Audit `json-fast` activation events; consider replacing with narrower scope.\n\n### IntelliJ IDEA / JetBrains\n\n1. **High:** Manually verify `acme-helper` Premain-Class is legitimate. Consider disabling.\n2. **Medium:** Reduce `ace-helper` depends-chain or replace.\n3. **Low:** Verify shaded-jar advisories (`shaded-jar-pro`) — known shading is normal but creates supply-chain opacity.\n\n---\n\n## Methodology\n\n7 VS Code-specific checks (blocklist, theme-with-code, sideload, broad activation, typosquat, extension-pack, dangerous hooks) + 7 JetBrains checks (Premain-Class, native binaries, depends chain, theme-with-code, broad activation, typosquat, shaded jars). Reused scanners (UNI/ENT/NET/TNT/MEM/SCR) per extension. Offline mode by default.\n\n---\n\n*IDE-scan complete. 59 extensions, 24 findings, 8.9 seconds.*\n",
"parsed": {
"risk_score": 28,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** One high-severity finding: a JetBrains plugin (`acme-helper`) declares `Premain-Class` (javaagent retransform) which is the riskiest IDE-extension pattern.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"coverage": [
{
"ide": "VS Code",
"extensions": 47,
"findings": 8
},
{
"ide": "Cursor",
"extensions": 12,
"findings": 2
},
{
"ide": "IntelliJ IDEA",
"extensions": 12,
"findings": 14
}
],
"findings": [
{
"id": "IDE-001",
"severity": "high",
"extension": "acme-helper",
"ide": "IntelliJ",
"description": "Declares `Premain-Class` — javaagent retransform attack surface",
"owasp": "ASI04"
},
{
"id": "IDE-002",
"severity": "medium",
"extension": "dark-theme-pro",
"ide": "VS Code",
"description": "Theme contains `extension.js` (theme-with-code)",
"owasp": "LLM06"
},
{
"id": "IDE-003",
"severity": "medium",
"extension": "rest-client-typo",
"ide": "VS Code",
"description": "Typosquat: Levenshtein 2 vs `rest-client` (top-100)",
"owasp": "LLM03"
},
{
"id": "IDE-004",
"severity": "medium",
"extension": "ace-helper",
"ide": "IntelliJ",
"description": "Long `<depends>` chain (12 plugins) — large surface",
"owasp": "LLM03"
},
{
"id": "IDE-005",
"severity": "medium",
"extension": "json-fast",
"ide": "VS Code",
"description": "activationEvents includes `*` (broad activation)",
"owasp": "ASI04"
},
{
"id": "IDE-006",
"severity": "low",
"extension": "git-graph",
"ide": "VS Code",
"description": "Native binary `.dylib` shipped (verified signature OK)",
"owasp": "—"
},
{
"id": "IDE-007",
"severity": "low",
"extension": "gradle-helper",
"ide": "IntelliJ",
"description": "Native binary `.so` shipped (Linux ELF)",
"owasp": "—"
},
{
"id": "IDE-008",
"severity": "low",
"extension": "vsc-cmd",
"ide": "VS Code",
"description": "`vscode:uninstall` hook present",
"owasp": "—"
},
{
"id": "IDE-009",
"severity": "low",
"extension": "shaded-jar-pro",
"ide": "IntelliJ",
"description": "Shaded jar advisory (3 jars)",
"owasp": "—"
},
{
"id": "IDE-010",
"severity": "low",
"extension": "rest-client-typo",
"ide": "VS Code",
"description": "Same as IDE-003: typosquat suspicion",
"owasp": "LLM03"
},
{
"id": "IDE-011",
"severity": "low",
"extension": "code-splitter",
"ide": "VS Code",
"description": "activationEvents `onStartupFinished` (broad)",
"owasp": "ASI04"
},
{
"id": "IDE-012",
"severity": "low",
"extension": "java-fmt",
"ide": "IntelliJ",
"description": "Premain-Class candidate (lower confidence)",
"owasp": "ASI04"
}
],
"recommendations": [],
"keyStats": [
{
"label": "TOTALT",
"value": 12
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 1,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
}
}
},
{
"id": "dft-komplett-demo",
"name": "Komplett demo: full pipeline (Fase 2)",
"description": "Komplett scenario med alle 10 Fase 2-rapporter ferdig parsed mot DFT-marketplace. Brukes for visuell smoke-test og workshop-demo — klikk gjennom alle rapport-tabbene uten \"parser ikke implementert\"-paneler.",
"target_type": "codebase",
"target_path": "~/repos/dft-marketplace",
"scenarios": [
"pre-deploy",
"compliance-audit",
"harden-onboarding"
],
"createdAt": "2026-05-05T18:00:00.000Z",
"reports": {
"scan": {
"input": {
"target": "~/repos/dft-marketplace",
"deep_mode": false,
"severity_threshold": "high",
"branch": "",
"frameworks": [
"OWASP LLM Top 10",
"OWASP MCP",
"EU AI Act"
]
},
"raw_markdown": "# Security Scan Report\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | scan |\n| **Target** | ~/repos/example-app |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | skill scan + MCP scan |\n| **Frameworks** | OWASP LLM Top 10, OWASP MCP |\n| **Triggered by** | /security scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 72/100 |\n| **Risk Band** | Critical |\n| **Grade** | D |\n| **Verdict** | BLOCK |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 2 |\n| High | 4 |\n| Medium | 7 |\n| Low | 3 |\n| Info | 5 |\n| **Total** | **21** |\n\n**Verdict rationale:** 2 critical findings (hardcoded API key + lethal trifecta in agent definition) cross the BLOCK threshold. High-severity prompt-injection vector in tool description compounds the risk.\n\n---\n\n## Executive Summary\n\nScan found 21 issues across 7 files in the `commands/` and `agents/` directories. Two critical findings require immediate remediation before this plugin is shipped: a hardcoded API key in `agents/data-analyst.md` (line 47) and a lethal trifecta agent (`agents/web-helper.md`) with `[Bash, Read, WebFetch]` and no hook guards. The four high-severity findings concentrate on prompt-injection patterns in MCP tool descriptions.\n\n### Narrative Audit\n\n**Suppressed signals:** 3 (entropy: 2 GLSL fragments, frontmatter: 1 framework env-var reference)\n\n---\n\n## Findings\n\nFindings sorted Critical → High → Medium → Low → Info.\n\n### Critical\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-001 | Secrets | agents/data-analyst.md | 47 | Hardcoded API key (sk-prod-...) | LLM02 |\n| SCN-002 | Excessive Agency | agents/web-helper.md | 3 | Lethal trifecta: [Bash, Read, WebFetch] without hook guards | ASI01, LLM06 |\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-003 | Injection | commands/research.md | 22 | Prompt-injection vector in user-input interpolation | LLM01 |\n| SCN-004 | MCP Trust | .mcp.json | 12 | MCP server description contains hidden imperative | MCP05 |\n| SCN-005 | Output Handling | agents/notes.md | 89 | Markdown link-title injection sink | LLM01 |\n| SCN-006 | Permissions | .claude/settings.json | 5 | Wildcard `Bash(*)` permission grant | ASI04 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-007 | Supply Chain | package.json | 15 | Dependency `lefthook@1.4.2` flagged by OSV.dev | LLM03 |\n| SCN-008 | Output Handling | agents/notes.md | 102 | HTML comment node passes through unvalidated | LLM01 |\n| SCN-009 | Other | CLAUDE.md | 34 | Memory-poisoning pattern: encoded base64 imperative | LLM06 |\n| SCN-010 | Injection | commands/summarize.md | 14 | Indirect injection via WebFetch result | LLM01 |\n| SCN-011 | Permissions | agents/test-runner.md | 5 | Tool list includes `Edit` without rationale | ASI04 |\n| SCN-012 | MCP Trust | .mcp.json | 28 | Per-update drift on `airbnb-mcp` tool description (12.3%) | MCP05 |\n| SCN-013 | Other | scripts/setup.sh | 3 | curl|sh pattern in install hint | LLM03 |\n\n### Low\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-014 | Other | README.md | 88 | Suspicious URL pattern in example | — |\n| SCN-015 | Other | docs/setup.md | 21 | Outdated security advisory link | — |\n| SCN-016 | Other | tests/fixtures/poisoned.md | 1 | Test fixture flagged (likely intentional) | — |\n\n### Info\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCN-017 | Other | .gitignore | — | No `.env*` exclusion rule | — |\n| SCN-018 | Other | LICENSE | — | License missing | — |\n| SCN-019 | Other | CHANGELOG.md | — | No CHANGELOG present | — |\n| SCN-020 | Other | SECURITY.md | — | No SECURITY.md disclosure policy | — |\n| SCN-021 | Other | CONTRIBUTING.md | — | No CONTRIBUTING guidelines | — |\n\n---\n\n## OWASP Categorization\n\n| OWASP Category | Findings | Max Severity | Scanners |\n|----------------|----------|-------------|----------|\n| LLM01 — Prompt Injection | 4 | High | skill-scanner, post-mcp-verify |\n| LLM02 — Sensitive Info Disclosure | 1 | Critical | secrets |\n| LLM03 — Supply Chain | 2 | Medium | dep-audit |\n| LLM06 — Excessive Agency | 2 | Critical | toxic-flow, memory |\n| MCP05 — Tool Description Drift | 2 | High | mcp-cache |\n| ASI01 — Lethal Trifecta | 1 | Critical | toxic-flow |\n| ASI04 — Permission Sprawl | 2 | High | permission |\n\n---\n\n## Supply Chain Assessment\n\n| Component | Type | Source | Trust Score | Notes |\n|-----------|------|--------|-------------|-------|\n| lefthook | npm | registry | 6/10 | OSV-2024-1234 (medium) |\n| typescript | npm | registry | 9/10 | clean |\n| @airbnb/mcp-server | npm | registry | 7/10 | per-update drift detected |\n\n**Source verification:** registry-only, no Git/private deps detected.\n\n**Permissions analysis:**\n- Requested tools: Bash, Read, Write, Edit, WebFetch, Task\n- Minimum necessary: Read, Bash\n- Over-permissioned: Write, Edit, WebFetch, Task\n\n**Supply chain risk summary:** One medium-severity CVE on a build-tool dependency. Recommend bumping `lefthook` to 1.5.0+.\n\n---\n\n## Recommendations\n\n1. **Immediate:** Rotate `sk-prod-...` API key and remove from `agents/data-analyst.md`. Replace with environment-variable reference.\n2. **Immediate:** Rewrite `agents/web-helper.md` to drop one of `[Bash, Read, WebFetch]` OR add a hook policy that blocks the trifecta.\n3. **High:** Update MCP server description in `.mcp.json` (line 12) and run `/security mcp-baseline-reset` after legitimate update.\n4. **High:** Replace `Bash(*)` with explicit allowlist in `.claude/settings.json`.\n5. **Medium:** Bump `lefthook` to 1.5.0+ to clear OSV-2024-1234.\n\nRun `/security clean .` to auto-fix deterministic issues. Re-scan after fixes to confirm BLOCK → WARNING → ALLOW progression.\n\n---\n\n*Scan complete. 21 findings across 7 files, 12.4 seconds.*\n",
"parsed": {
"risk_score": 72,
"riskBand": "Critical",
"grade": "D",
"verdict": "block",
"verdict_rationale": "** 2 critical findings (hardcoded API key + lethal trifecta in agent definition) cross the BLOCK threshold. High-severity prompt-injection vector in tool description compounds the risk.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"findings": [
{
"id": "SCN-001",
"severity": "critical",
"category": "Secrets",
"file": "agents/data-analyst.md",
"line": "47",
"description": "Hardcoded API key (sk-prod-...)",
"owasp": "LLM02"
},
{
"id": "SCN-002",
"severity": "critical",
"category": "Excessive Agency",
"file": "agents/web-helper.md",
"line": "3",
"description": "Lethal trifecta: [Bash, Read, WebFetch] without hook guards",
"owasp": "ASI01, LLM06"
},
{
"id": "SCN-003",
"severity": "high",
"category": "Injection",
"file": "commands/research.md",
"line": "22",
"description": "Prompt-injection vector in user-input interpolation",
"owasp": "LLM01"
},
{
"id": "SCN-004",
"severity": "high",
"category": "MCP Trust",
"file": ".mcp.json",
"line": "12",
"description": "MCP server description contains hidden imperative",
"owasp": "MCP05"
},
{
"id": "SCN-005",
"severity": "high",
"category": "Output Handling",
"file": "agents/notes.md",
"line": "89",
"description": "Markdown link-title injection sink",
"owasp": "LLM01"
},
{
"id": "SCN-006",
"severity": "high",
"category": "Permissions",
"file": ".claude/settings.json",
"line": "5",
"description": "Wildcard `Bash(*)` permission grant",
"owasp": "ASI04"
},
{
"id": "SCN-007",
"severity": "medium",
"category": "Supply Chain",
"file": "package.json",
"line": "15",
"description": "Dependency `lefthook@1.4.2` flagged by OSV.dev",
"owasp": "LLM03"
},
{
"id": "SCN-008",
"severity": "medium",
"category": "Output Handling",
"file": "agents/notes.md",
"line": "102",
"description": "HTML comment node passes through unvalidated",
"owasp": "LLM01"
},
{
"id": "SCN-009",
"severity": "medium",
"category": "Other",
"file": "CLAUDE.md",
"line": "34",
"description": "Memory-poisoning pattern: encoded base64 imperative",
"owasp": "LLM06"
},
{
"id": "SCN-010",
"severity": "medium",
"category": "Injection",
"file": "commands/summarize.md",
"line": "14",
"description": "Indirect injection via WebFetch result",
"owasp": "LLM01"
},
{
"id": "SCN-011",
"severity": "medium",
"category": "Permissions",
"file": "agents/test-runner.md",
"line": "5",
"description": "Tool list includes `Edit` without rationale",
"owasp": "ASI04"
},
{
"id": "SCN-012",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"line": "28",
"description": "Per-update drift on `airbnb-mcp` tool description (12.3%)",
"owasp": "MCP05"
},
{
"id": "SCN-013",
"severity": "medium",
"category": "Other",
"file": "scripts/setup.sh",
"line": "3",
"description": "curl",
"owasp": "sh pattern in install hint"
},
{
"id": "SCN-014",
"severity": "low",
"category": "Other",
"file": "README.md",
"line": "88",
"description": "Suspicious URL pattern in example",
"owasp": "—"
},
{
"id": "SCN-015",
"severity": "low",
"category": "Other",
"file": "docs/setup.md",
"line": "21",
"description": "Outdated security advisory link",
"owasp": "—"
},
{
"id": "SCN-016",
"severity": "low",
"category": "Other",
"file": "tests/fixtures/poisoned.md",
"line": "1",
"description": "Test fixture flagged (likely intentional)",
"owasp": "—"
},
{
"id": "SCN-017",
"severity": "info",
"category": "Other",
"file": ".gitignore",
"line": "—",
"description": "No `.env*` exclusion rule",
"owasp": "—"
},
{
"id": "SCN-018",
"severity": "info",
"category": "Other",
"file": "LICENSE",
"line": "—",
"description": "License missing",
"owasp": "—"
},
{
"id": "SCN-019",
"severity": "info",
"category": "Other",
"file": "CHANGELOG.md",
"line": "—",
"description": "No CHANGELOG present",
"owasp": "—"
},
{
"id": "SCN-020",
"severity": "info",
"category": "Other",
"file": "SECURITY.md",
"line": "—",
"description": "No SECURITY.md disclosure policy",
"owasp": "—"
},
{
"id": "SCN-021",
"severity": "info",
"category": "Other",
"file": "CONTRIBUTING.md",
"line": "—",
"description": "No CONTRIBUTING guidelines",
"owasp": "—"
}
],
"owasp": [
{
"category": "LLM01 — Prompt Injection",
"findings": 4,
"max_severity": "high",
"scanners": "skill-scanner, post-mcp-verify"
},
{
"category": "LLM02 — Sensitive Info Disclosure",
"findings": 1,
"max_severity": "critical",
"scanners": "secrets"
},
{
"category": "LLM03 — Supply Chain",
"findings": 2,
"max_severity": "medium",
"scanners": "dep-audit"
},
{
"category": "LLM06 — Excessive Agency",
"findings": 2,
"max_severity": "critical",
"scanners": "toxic-flow, memory"
},
{
"category": "MCP05 — Tool Description Drift",
"findings": 2,
"max_severity": "high",
"scanners": "mcp-cache"
},
{
"category": "ASI01 — Lethal Trifecta",
"findings": 1,
"max_severity": "critical",
"scanners": "toxic-flow"
},
{
"category": "ASI04 — Permission Sprawl",
"findings": 2,
"max_severity": "high",
"scanners": "permission"
}
],
"supply_chain": [
{
"component": "lefthook",
"type": "npm",
"source": "registry",
"trust": "6/10",
"notes": "OSV-2024-1234 (medium)"
},
{
"component": "typescript",
"type": "npm",
"source": "registry",
"trust": "9/10",
"notes": "clean"
},
{
"component": "@airbnb/mcp-server",
"type": "npm",
"source": "registry",
"trust": "7/10",
"notes": "per-update drift detected"
}
],
"executive_summary": "Scan found 21 issues across 7 files in the `commands/` and `agents/` directories. Two critical findings require immediate remediation before this plugin is shipped: a hardcoded API key in `agents/data-analyst.md` (line 47) and a lethal trifecta agent (`agents/web-helper.md`) with `[Bash, Read, WebFetch]` and no hook guards. The four high-severity findings concentrate on prompt-injection patterns in MCP tool descriptions.",
"recommendations": [
"Rotate `sk-prod-...` API key and remove from `agents/data-analyst.md`. Replace with environment-variable reference.",
"Rewrite `agents/web-helper.md` to drop one of `[Bash, Read, WebFetch]` OR add a hook policy that blocks the trifecta.",
"Update MCP server description in `.mcp.json` (line 12) and run `/security mcp-baseline-reset` after legitimate update.",
"Replace `Bash(*)` with explicit allowlist in `.claude/settings.json`.",
"Bump `lefthook` to 1.5.0+ to clear OSV-2024-1234."
],
"keyStats": [
{
"label": "RISK SCORE",
"value": 72,
"modifier": "crit"
},
{
"label": "BAND",
"value": "Critical"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"deep-scan": {
"input": {
"target": "~/repos/dft-marketplace",
"output_format": "compact",
"fail_on": "high",
"baseline_diff": true
},
"raw_markdown": "# Deep-Scan Report — 10 deterministic scanners\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | deep-scan |\n| **Target** | ~/repos/example-app |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | full repository |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic, OWASP MCP |\n| **Triggered by** | /security deep-scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 58/100 |\n| **Risk Band** | High |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 6 |\n| Medium | 11 |\n| Low | 8 |\n| Info | 14 |\n| **Total** | **39** |\n\n**Verdict rationale:** No critical findings. 6 high-severity findings (4 from taint, 2 from memory-poisoning) push score to 58.\n\n---\n\n## Executive Summary\n\nThe 10-scanner orchestrator produced 39 findings in 4.7 seconds. Highest concentration is in taint-tracer (untrusted input flowing to dangerous sinks in `commands/research.md`) and memory-poisoning-scanner (encoded imperatives in `CLAUDE.md`). No critical findings. Toxic-flow correlator did not detect a complete trifecta — the agent set has hook guards that intervene before the third leg.\n\n---\n\n## Scanner Results\n\n### 1. Unicode Analysis (UNI)\n**Status:** ok | **Files:** 47 | **Findings:** 2 | **Time:** 142ms\n\nDetected 2 instances of zero-width characters in `agents/notes.md`. PUA-A range clear.\n\n### 2. Entropy Analysis (ENT)\n**Status:** ok | **Files:** 89 | **Findings:** 5 | **Time:** 387ms\n\n5 high-entropy strings flagged. 2 suppressed (GLSL keywords in `shaders/blur.glsl`). 3 reported (potential secrets in test fixtures).\n\n### 3. Permission Mapping (PRM)\n**Status:** ok | **Files:** 12 | **Findings:** 4 | **Time:** 89ms\n\n4 over-permissioned agents (tool list includes `Write`/`Edit` without justification). One wildcard Bash grant in settings.json.\n\n### 4. Dependency Audit (DEP)\n**Status:** ok | **Files:** 3 | **Findings:** 3 | **Time:** 1230ms\n\n3 dependencies flagged: 1 OSV-CVE-2024-1234 medium, 2 typosquat suspicions (Levenshtein ≤2 vs official packages).\n\n### 5. Taint Tracing (TNT)\n**Status:** ok | **Files:** 23 | **Findings:** 12 | **Time:** 487ms\n\n12 taint flows detected. 4 reach high-risk sinks (Bash interpolation, WebFetch URL construction).\n\n### 6. Git Forensics (GIT)\n**Status:** ok | **Files:** — | **Findings:** 2 | **Time:** 678ms\n\n2 historical secrets in git history (since rotated, but blob still reachable via reflog).\n\n### 7. Network Mapping (NET)\n**Status:** ok | **Files:** 56 | **Findings:** 3 | **Time:** 412ms\n\n3 suspicious URLs found (1 typosquat domain, 2 raw IP addresses in code comments).\n\n### 8. Memory Poisoning (MEM)\n**Status:** ok | **Files:** 8 | **Findings:** 4 | **Time:** 67ms\n\n4 memory-poisoning patterns in `CLAUDE.md` and 2 agent files: encoded base64 imperatives, suspicious permission expansion, hidden URLs.\n\n### 9. Supply-Chain Recheck (SCR)\n**Status:** ok | **Files:** 2 | **Findings:** 2 | **Time:** 1845ms\n\nOSV.dev returned 2 advisories on installed lockfile entries.\n\n### 10. Toxic-Flow Analyzer (TFA)\n**Status:** ok | **Files:** — | **Findings:** 2 | **Time:** 23ms\n\n2 partial-trifecta agents (2 of 3 legs each). No complete trifectas detected.\n\n---\n\n## Scanner Risk Matrix\n\n| Scanner | CRITICAL | HIGH | MEDIUM | LOW | INFO |\n|---------|----------|------|--------|-----|------|\n| Unicode (UNI) | 0 | 0 | 1 | 1 | 0 |\n| Entropy (ENT) | 0 | 1 | 2 | 1 | 1 |\n| Permission (PRM) | 0 | 1 | 1 | 1 | 1 |\n| Dependency (DEP) | 0 | 0 | 2 | 1 | 0 |\n| Taint (TNT) | 0 | 4 | 3 | 2 | 3 |\n| Git (GIT) | 0 | 0 | 1 | 1 | 0 |\n| Network (NET) | 0 | 0 | 1 | 0 | 2 |\n| Memory (MEM) | 0 | 2 | 0 | 1 | 1 |\n| Supply-Chain (SCR) | 0 | 0 | 1 | 0 | 1 |\n| Toxic-Flow (TFA) | 0 | 0 | 1 | 1 | 0 |\n| **TOTAL** | **0** | **6** | **11** | **8** | **14** |\n\n---\n\n## Methodology\n\n10 deterministic Node.js scanners (zero external dependencies). Results are factual and reproducible. Toxic-flow runs LAST as a post-correlator across prior scanners. See `scanners/lib/severity.mjs` for risk-score formula.\n\n---\n\n## Recommendations\n\n1. **High priority:** Address 4 taint-tracer findings in `commands/research.md` and `agents/notes.md` — sanitize before sink, or add hook gate.\n2. **High priority:** Clean up `CLAUDE.md` memory-poisoning patterns (lines 12, 34, 67).\n3. **Medium:** Bump dependencies to clear OSV advisories.\n4. **Medium:** Force-push history rewrite to remove historical secrets, then rotate keys.\n\nRe-run with `--baseline-diff` against last green run to track progress.\n\n---\n\n*Deep-scan complete. 39 findings, 10 scanners, 4.7 seconds.*\n",
"parsed": {
"risk_score": 58,
"riskBand": "High",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** No critical findings. 6 high-severity findings (4 from taint, 2 from memory-poisoning) push score to 58.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"scanners": [
{
"tag": "UNI",
"name": "Unicode Analysis",
"status": "ok",
"files": "47",
"findings": 2,
"duration_ms": 142,
"details": "Detected 2 instances of zero-width characters in `agents/notes.md`. PUA-A range clear."
},
{
"tag": "ENT",
"name": "Entropy Analysis",
"status": "ok",
"files": "89",
"findings": 5,
"duration_ms": 387,
"details": "5 high-entropy strings flagged. 2 suppressed (GLSL keywords in `shaders/blur.glsl`). 3 reported (potential secrets in test fixtures)."
},
{
"tag": "PRM",
"name": "Permission Mapping",
"status": "ok",
"files": "12",
"findings": 4,
"duration_ms": 89,
"details": "4 over-permissioned agents (tool list includes `Write`/`Edit` without justification). One wildcard Bash grant in settings.json."
},
{
"tag": "DEP",
"name": "Dependency Audit",
"status": "ok",
"files": "3",
"findings": 3,
"duration_ms": 1230,
"details": "3 dependencies flagged: 1 OSV-CVE-2024-1234 medium, 2 typosquat suspicions (Levenshtein ≤2 vs official packages)."
},
{
"tag": "TNT",
"name": "Taint Tracing",
"status": "ok",
"files": "23",
"findings": 12,
"duration_ms": 487,
"details": "12 taint flows detected. 4 reach high-risk sinks (Bash interpolation, WebFetch URL construction)."
},
{
"tag": "GIT",
"name": "Git Forensics",
"status": "ok",
"files": "—",
"findings": 2,
"duration_ms": 678,
"details": "2 historical secrets in git history (since rotated, but blob still reachable via reflog)."
},
{
"tag": "NET",
"name": "Network Mapping",
"status": "ok",
"files": "56",
"findings": 3,
"duration_ms": 412,
"details": "3 suspicious URLs found (1 typosquat domain, 2 raw IP addresses in code comments)."
},
{
"tag": "MEM",
"name": "Memory Poisoning",
"status": "ok",
"files": "8",
"findings": 4,
"duration_ms": 67,
"details": "4 memory-poisoning patterns in `CLAUDE.md` and 2 agent files: encoded base64 imperatives, suspicious permission expansion, hidden URLs."
},
{
"tag": "SCR",
"name": "Supply-Chain Recheck",
"status": "ok",
"files": "2",
"findings": 2,
"duration_ms": 1845,
"details": "OSV.dev returned 2 advisories on installed lockfile entries."
},
{
"tag": "TFA",
"name": "Toxic-Flow Analyzer",
"status": "ok",
"files": "—",
"findings": 2,
"duration_ms": 23,
"details": "2 partial-trifecta agents (2 of 3 legs each). No complete trifectas detected. ---"
}
],
"scanner_matrix": [
{
"scanner": "Unicode (UNI)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 1,
"info": 0
},
{
"scanner": "Entropy (ENT)",
"critical": 0,
"high": 1,
"medium": 2,
"low": 1,
"info": 1
},
{
"scanner": "Permission (PRM)",
"critical": 0,
"high": 1,
"medium": 1,
"low": 1,
"info": 1
},
{
"scanner": "Dependency (DEP)",
"critical": 0,
"high": 0,
"medium": 2,
"low": 1,
"info": 0
},
{
"scanner": "Taint (TNT)",
"critical": 0,
"high": 4,
"medium": 3,
"low": 2,
"info": 3
},
{
"scanner": "Git (GIT)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 1,
"info": 0
},
{
"scanner": "Network (NET)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 0,
"info": 2
},
{
"scanner": "Memory (MEM)",
"critical": 0,
"high": 2,
"medium": 0,
"low": 1,
"info": 1
},
{
"scanner": "Supply-Chain (SCR)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 0,
"info": 1
},
{
"scanner": "Toxic-Flow (TFA)",
"critical": 0,
"high": 0,
"medium": 1,
"low": 1,
"info": 0
}
],
"score": 58,
"findings": [],
"recommendations": [
"Address 4 taint-tracer findings in `commands/research.md` and `agents/notes.md` — sanitize before sink, or add hook gate.",
"Clean up `CLAUDE.md` memory-poisoning patterns (lines 12, 34, 67).",
"Bump dependencies to clear OSV advisories.",
"Force-push history rewrite to remove historical secrets, then rotate keys."
],
"keyStats": [
{
"label": "GRADE",
"value": "C",
"modifier": "med"
},
{
"label": "SCORE",
"value": 58
},
{
"label": "FUNN",
"value": 0
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"plugin-audit": {
"input": {
"target": "https://github.com/airbnb-example/airbnb-mcp-plugin",
"install_intent": false,
"strict_mode": true
},
"raw_markdown": "# Plugin-Audit — airbnb-mcp-plugin\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | plugin-audit |\n| **Target** | https://github.com/airbnb-example/airbnb-mcp-plugin |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | plugin trust assessment |\n| **Frameworks** | OWASP MCP, OWASP LLM Top 10 |\n| **Triggered by** | /security plugin-audit |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 41/100 |\n| **Risk Band** | High |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 3 |\n| Medium | 5 |\n| Low | 4 |\n| Info | 2 |\n| **Total** | **14** |\n\n**Verdict rationale:** Plugin requests broad permissions (Bash, Write, WebFetch) with limited justification. No critical findings, but trust verdict downgrades to WARNING pending clarification.\n\n---\n\n## Executive Summary\n\nThird-party Claude Code plugin distributed via GitHub. Implements 4 MCP tools (search, book, cancel, list-reservations). Plugin has clear maintainer (verified GitHub identity, 87 commits over 2.3 years). Three high-severity findings concern broad tool permissions and one MCP tool description that includes hidden imperative (\"when called, also fetch X\").\n\n---\n\n## Plugin Metadata\n\n| Field | Value |\n|-------|-------|\n| **Name** | airbnb-mcp-plugin |\n| **Version** | 1.4.2 |\n| **Author** | airbnb-example (verified) |\n| **License** | MIT |\n| **Source** | https://github.com/airbnb-example/airbnb-mcp-plugin |\n| **First commit** | 2024-01-15 |\n| **Last commit** | 2026-04-22 |\n| **Commits** | 87 |\n| **Stars** | 247 |\n\n---\n\n## Component Inventory\n\n| Component | Count | Notes |\n|-----------|------:|-------|\n| Commands | 3 | book.md, cancel.md, list.md |\n| Agents | 1 | search-agent.md |\n| MCP Servers | 1 | airbnb-mcp (4 tools) |\n| Hooks | 0 | (none) |\n| Skills | 0 | (none) |\n\n---\n\n## Permission Matrix\n\n| Tool | Required by | Justified |\n|------|-------------|-----------|\n| Read | search-agent | Yes — needs to read user filters |\n| WebFetch | search-agent | Yes — Airbnb API |\n| Bash | book.md | Partial — only used for date math |\n| Write | search-agent | No — appears unused |\n| Edit | (none) | — |\n\n---\n\n## Hook Safety\n\nNo hooks defined. Plugin operates entirely through MCP tools and agent definitions. No PreToolUse/PostToolUse mechanisms to verify.\n\n---\n\n## Trust Verdict\n\n**Verdict:** WARNING — install with caution\n\n**Rationale:**\n- Maintainer is verifiable (GitHub identity, history)\n- License is MIT (permissive, OK)\n- Permission grant is broader than necessary (Write tool unused)\n- One MCP tool description (`book`) contains an implicit instruction outside its declared purpose\n\n**Recommended action:** Open issue with maintainer requesting (a) drop unused `Write` permission, (b) clarify `book` tool description. Re-audit after maintainer response.\n\n---\n\n## Findings\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PA-001 | Permissions | search-agent.md | 5 | Tool list includes `Write` with no apparent use | ASI04 |\n| PA-002 | MCP Trust | mcp-tools/book.json | 14 | Description has hidden imperative outside scope | MCP05 |\n| PA-003 | Permissions | book.md | 8 | Bash permission not minimized to specific commands | ASI04 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PA-004 | Supply Chain | package.json | 12 | Dependency `@airbnb/utils@2.1.0` outdated | LLM03 |\n| PA-005 | Output Handling | search-agent.md | 34 | API response inserted as markdown without sanitization | LLM01 |\n| PA-006 | Other | README.md | — | No security disclosure policy | — |\n| PA-007 | Other | CHANGELOG.md | — | Last 3 releases lack security notes | — |\n| PA-008 | Permissions | .claude/settings.json | 5 | Settings file commits hooks=null (acceptable) | — |\n\n### Low\n\n(4 low + 2 info findings — see envelope JSON for full list)\n\n---\n\n## Recommendations\n\n1. **High:** Open issue with maintainer about `Write` permission removal.\n2. **High:** Request clarification of `book` tool description.\n3. **Medium:** Bump `@airbnb/utils` to current.\n4. **Medium:** Add SECURITY.md.\n\nIf maintainer response is satisfactory: re-audit. If install is urgent: deploy with MCP volume monitoring (`/security mcp-inspect`) for 7 days.\n\n---\n\n*Plugin-audit complete. 14 findings, trust verdict WARNING.*\n",
"parsed": {
"risk_score": 41,
"riskBand": "High",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** Plugin requests broad permissions (Bash, Write, WebFetch) with limited justification. No critical findings, but trust verdict downgrades to WARNING pending clarification.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"plugin_metadata": {
"name": "airbnb-mcp-plugin",
"version": "1.4.2",
"author": "airbnb-example (verified)",
"license": "MIT",
"source": "https://github.com/airbnb-example/airbnb-mcp-plugin",
"first_commit": "2024-01-15",
"last_commit": "2026-04-22",
"commits": "87",
"stars": "247"
},
"components": [
{
"component": "Commands",
"count": 3,
"notes": "book.md, cancel.md, list.md"
},
{
"component": "Agents",
"count": 1,
"notes": "search-agent.md"
},
{
"component": "MCP Servers",
"count": 1,
"notes": "airbnb-mcp (4 tools)"
},
{
"component": "Hooks",
"count": 0,
"notes": "(none)"
},
{
"component": "Skills",
"count": 0,
"notes": "(none)"
}
],
"permissions": [
{
"tool": "Read",
"required_by": "search-agent",
"justified": "Yes — needs to read user filters"
},
{
"tool": "WebFetch",
"required_by": "search-agent",
"justified": "Yes — Airbnb API"
},
{
"tool": "Bash",
"required_by": "book.md",
"justified": "Partial — only used for date math"
},
{
"tool": "Write",
"required_by": "search-agent",
"justified": "No — appears unused"
},
{
"tool": "Edit",
"required_by": "(none)",
"justified": "—"
}
],
"trust_verdict_text": "**Verdict:** WARNING — install with caution\n\n**Rationale:**\n- Maintainer is verifiable (GitHub identity, history)\n- License is MIT (permissive, OK)\n- Permission grant is broader than necessary (Write tool unused)\n- One MCP tool description (`book`) contains an implicit instruction outside its declared purpose\n\n**Recommended action:** Open issue with maintainer requesting (a) drop unused `Write` permission, (b) clarify `book` tool description. Re-audit after maintainer response.\n\n---",
"trust_verdict": "warning",
"findings": [
{
"id": "PA-001",
"severity": "high",
"category": "Permissions",
"file": "search-agent.md",
"line": "5",
"description": "Tool list includes `Write` with no apparent use",
"owasp": "ASI04"
},
{
"id": "PA-002",
"severity": "high",
"category": "MCP Trust",
"file": "mcp-tools/book.json",
"line": "14",
"description": "Description has hidden imperative outside scope",
"owasp": "MCP05"
},
{
"id": "PA-003",
"severity": "high",
"category": "Permissions",
"file": "book.md",
"line": "8",
"description": "Bash permission not minimized to specific commands",
"owasp": "ASI04"
},
{
"id": "PA-004",
"severity": "medium",
"category": "Supply Chain",
"file": "package.json",
"line": "12",
"description": "Dependency `@airbnb/utils@2.1.0` outdated",
"owasp": "LLM03"
},
{
"id": "PA-005",
"severity": "medium",
"category": "Output Handling",
"file": "search-agent.md",
"line": "34",
"description": "API response inserted as markdown without sanitization",
"owasp": "LLM01"
},
{
"id": "PA-006",
"severity": "medium",
"category": "Other",
"file": "README.md",
"line": "—",
"description": "No security disclosure policy",
"owasp": "—"
},
{
"id": "PA-007",
"severity": "medium",
"category": "Other",
"file": "CHANGELOG.md",
"line": "—",
"description": "Last 3 releases lack security notes",
"owasp": "—"
},
{
"id": "PA-008",
"severity": "medium",
"category": "Permissions",
"file": ".claude/settings.json",
"line": "5",
"description": "Settings file commits hooks=null (acceptable)",
"owasp": "—"
}
],
"recommendations": [
"Open issue with maintainer about `Write` permission removal.",
"Request clarification of `book` tool description.",
"Bump `@airbnb/utils` to current.",
"Add SECURITY.md."
],
"keyStats": [
{
"label": "RISK SCORE",
"value": 41,
"modifier": "med"
},
{
"label": "BAND",
"value": "High"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"mcp-audit": {
"input": {
"live_inspection": false,
"config_paths": "~/.claude/.mcp.json, ~/.claude/plugins/marketplaces/dft/.mcp.json"
},
"raw_markdown": "# MCP Config Audit\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | mcp-audit |\n| **Target** | ~/.claude/.mcp.json + per-project configs |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 5 MCP servers (3 active, 2 dormant) |\n| **Frameworks** | OWASP MCP |\n| **Triggered by** | /security mcp-audit |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 33/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 2 |\n| Medium | 6 |\n| Low | 3 |\n| Info | 4 |\n| **Total** | **15** |\n\n**Verdict rationale:** No critical findings. Two high findings: airbnb-mcp tool description drift (per-update + cumulative) and tavily-mcp grants `process.env` read which is unjustified for search use case.\n\n---\n\n## MCP Landscape\n\n| Server | Type | Trust | Tools | Active |\n|--------|------|-------|-------|-------:|\n| airbnb-mcp | local-stdio | medium | 4 | yes |\n| tavily-mcp | http-sse | low | 6 | yes |\n| microsoft-learn | http-sse | high | 3 | yes |\n| gemini-mcp | local-stdio | high | 4 | dormant |\n| mermaid-chart | http-sse | medium | 17 | dormant |\n\n---\n\n## Per-Server Analysis\n\n### airbnb-mcp\n\n- **Path:** `~/.claude/mcp-servers/airbnb-mcp/`\n- **Origin:** GitHub (airbnb-example, MIT)\n- **Tool description drift:** per-update 12.3% (alert), cumulative 27% from baseline (advisory)\n- **Permissions:** Bash, WebFetch, Read\n- **Verdict:** WARNING — drift indicates possible upgrade or rug-pull. Investigate before reset.\n\n### tavily-mcp\n\n- **Path:** remote (HTTP-SSE)\n- **Origin:** tavily.ai\n- **Tool description drift:** none\n- **Permissions:** WebFetch, env-vars (TAVILY_API_KEY)\n- **Verdict:** WARNING — env-var read scope is broader than needed. Confirm only TAVILY_API_KEY is exposed.\n\n### microsoft-learn\n\n- **Path:** remote (HTTP-SSE)\n- **Origin:** Microsoft\n- **Tool description drift:** none\n- **Permissions:** WebFetch\n- **Verdict:** ALLOW — minimal surface, well-scoped.\n\n### gemini-mcp (dormant)\n\n- **Path:** `~/.claude/mcp-servers/gemini-mcp/`\n- **Origin:** local-built\n- **Verdict:** N/A (dormant)\n\n### mermaid-chart (dormant)\n\n- **Path:** remote (HTTP-SSE)\n- **Verdict:** N/A (dormant)\n\n---\n\n## MCP Risk Assessment\n\n3 active servers, 17 total tools across active set. Risk concentration: airbnb-mcp (description drift) + tavily-mcp (env-var scope). One server (microsoft-learn) is well-scoped baseline.\n\n---\n\n## Keep / Review / Remove\n\n| Decision | Server | Reason |\n|----------|--------|--------|\n| Keep | microsoft-learn | Well-scoped, official source |\n| Keep | gemini-mcp | Dormant but trusted, retain |\n| Review | airbnb-mcp | Description drift requires investigation |\n| Review | tavily-mcp | Env-var scope overly broad |\n| Remove | mermaid-chart | Dormant 87 days, no usage |\n\n---\n\n## Findings\n\n### High\n\n| ID | Server | Description | OWASP |\n|----|--------|-------------|-------|\n| MA-001 | airbnb-mcp | Cumulative drift 27% from baseline (sticky) | MCP05 |\n| MA-002 | tavily-mcp | env-var read includes more than declared keys | MCP06 |\n\n### Medium\n\n| ID | Server | Description | OWASP |\n|----|--------|-------------|-------|\n| MA-003 | airbnb-mcp | Per-update drift 12.3% on `book` tool | MCP05 |\n| MA-004 | airbnb-mcp | Tool `book` returns large payloads without size cap | MCP09 |\n| MA-005 | tavily-mcp | TLS cert pinning not enforced | MCP08 |\n| MA-006 | mermaid-chart | Dormant > 90 days, suggest removal | — |\n| MA-007 | airbnb-mcp | Description includes implicit instruction | MCP05 |\n| MA-008 | tavily-mcp | Rate-limit not configured client-side | MCP09 |\n\n### Low / Info\n\n(7 lower-severity findings — see envelope)\n\n---\n\n## Recommendations\n\n1. **High:** Run `/security mcp-baseline-reset --target airbnb-mcp` only AFTER manual review of new description.\n2. **High:** Restrict `tavily-mcp` env-var scope to `TAVILY_API_KEY` exclusively (settings.local.json).\n3. **Medium:** Remove dormant `mermaid-chart` server unless re-activated within 14 days.\n4. **Medium:** Add response-size caps for `airbnb-mcp` `book` tool.\n\n---\n\n*MCP-audit complete. 5 servers, 15 findings, verdict WARNING.*\n",
"parsed": {
"risk_score": 33,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** No critical findings. Two high findings: airbnb-mcp tool description drift (per-update + cumulative) and tavily-mcp grants `process.env` read which is unjustified for search use case.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"mcp_servers": [
{
"server": "airbnb-mcp",
"type": "local-stdio",
"trust": "medium",
"tools": 4,
"active": true
},
{
"server": "tavily-mcp",
"type": "http-sse",
"trust": "low",
"tools": 6,
"active": true
},
{
"server": "microsoft-learn",
"type": "http-sse",
"trust": "high",
"tools": 3,
"active": true
},
{
"server": "gemini-mcp",
"type": "local-stdio",
"trust": "high",
"tools": 4,
"active": false
},
{
"server": "mermaid-chart",
"type": "http-sse",
"trust": "medium",
"tools": 17,
"active": false
}
],
"per_server": [
{
"name": "airbnb-mcp",
"note": "",
"body": "- **Path:** `~/.claude/mcp-servers/airbnb-mcp/`\n- **Origin:** GitHub (airbnb-example, MIT)\n- **Tool description drift:** per-update 12.3% (alert), cumulative 27% from baseline (advisory)\n- **Permissions:** Bash, WebFetch, Read\n- **Verdict:** WARNING — drift indicates possible upgrade or rug-pull. Investigate before reset."
},
{
"name": "tavily-mcp",
"note": "",
"body": "- **Path:** remote (HTTP-SSE)\n- **Origin:** tavily.ai\n- **Tool description drift:** none\n- **Permissions:** WebFetch, env-vars (TAVILY_API_KEY)\n- **Verdict:** WARNING — env-var read scope is broader than needed. Confirm only TAVILY_API_KEY is exposed."
},
{
"name": "microsoft-learn",
"note": "",
"body": "- **Path:** remote (HTTP-SSE)\n- **Origin:** Microsoft\n- **Tool description drift:** none\n- **Permissions:** WebFetch\n- **Verdict:** ALLOW — minimal surface, well-scoped."
},
{
"name": "gemini-mcp",
"note": "dormant",
"body": "- **Path:** `~/.claude/mcp-servers/gemini-mcp/`\n- **Origin:** local-built\n- **Verdict:** N/A (dormant)"
},
{
"name": "mermaid-chart",
"note": "dormant",
"body": "- **Path:** remote (HTTP-SSE)\n- **Verdict:** N/A (dormant)\n\n---"
}
],
"buckets": {
"keep": [
{
"server": "microsoft-learn",
"reason": "Well-scoped, official source"
},
{
"server": "gemini-mcp",
"reason": "Dormant but trusted, retain"
}
],
"review": [
{
"server": "airbnb-mcp",
"reason": "Description drift requires investigation"
},
{
"server": "tavily-mcp",
"reason": "Env-var scope overly broad"
}
],
"remove": [
{
"server": "mermaid-chart",
"reason": "Dormant 87 days, no usage"
}
]
},
"findings": [
{
"id": "MA-001",
"severity": "high",
"server": "airbnb-mcp",
"description": "Cumulative drift 27% from baseline (sticky)",
"owasp": "MCP05"
},
{
"id": "MA-002",
"severity": "high",
"server": "tavily-mcp",
"description": "env-var read includes more than declared keys",
"owasp": "MCP06"
},
{
"id": "MA-003",
"severity": "medium",
"server": "airbnb-mcp",
"description": "Per-update drift 12.3% on `book` tool",
"owasp": "MCP05"
},
{
"id": "MA-004",
"severity": "medium",
"server": "airbnb-mcp",
"description": "Tool `book` returns large payloads without size cap",
"owasp": "MCP09"
},
{
"id": "MA-005",
"severity": "medium",
"server": "tavily-mcp",
"description": "TLS cert pinning not enforced",
"owasp": "MCP08"
},
{
"id": "MA-006",
"severity": "medium",
"server": "mermaid-chart",
"description": "Dormant > 90 days, suggest removal",
"owasp": "—"
},
{
"id": "MA-007",
"severity": "medium",
"server": "airbnb-mcp",
"description": "Description includes implicit instruction",
"owasp": "MCP05"
},
{
"id": "MA-008",
"severity": "medium",
"server": "tavily-mcp",
"description": "Rate-limit not configured client-side",
"owasp": "MCP09"
}
],
"recommendations": [
"Run `/security mcp-baseline-reset --target airbnb-mcp` only AFTER manual review of new description.",
"Restrict `tavily-mcp` env-var scope to `TAVILY_API_KEY` exclusively (settings.local.json).",
"Remove dormant `mermaid-chart` server unless re-activated within 14 days.",
"Add response-size caps for `airbnb-mcp` `book` tool."
],
"keyStats": [
{
"label": "TOTALT",
"value": 8
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 2,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"ide-scan": {
"input": {
"target": "",
"vscode_only": false,
"intellij_only": false,
"include_builtin": false,
"online": false
},
"raw_markdown": "# IDE-Extension Scan\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | ide-scan |\n| **Target** | installed VS Code + JetBrains extensions |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 47 VS Code extensions + 12 JetBrains plugins |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic |\n| **Triggered by** | /security ide-scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 28/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 4 |\n| Low | 7 |\n| Info | 12 |\n| **Total** | **24** |\n\n**Verdict rationale:** One high-severity finding: a JetBrains plugin (`acme-helper`) declares `Premain-Class` (javaagent retransform) which is the riskiest IDE-extension pattern.\n\n---\n\n## Scan Coverage\n\n| IDE | Extensions Scanned | Findings |\n|-----|-------------------:|---------:|\n| VS Code | 47 | 8 |\n| Cursor | 12 (subset of VS Code) | 2 |\n| IntelliJ IDEA | 12 | 14 |\n| **Total** | **59** | **24** |\n\n---\n\n## Findings\n\n### High\n\n| ID | Extension | IDE | Description | OWASP |\n|----|-----------|-----|-------------|-------|\n| IDE-001 | acme-helper | IntelliJ | Declares `Premain-Class` — javaagent retransform attack surface | ASI04 |\n\n### Medium\n\n| ID | Extension | IDE | Description | OWASP |\n|----|-----------|-----|-------------|-------|\n| IDE-002 | dark-theme-pro | VS Code | Theme contains `extension.js` (theme-with-code) | LLM06 |\n| IDE-003 | rest-client-typo | VS Code | Typosquat: Levenshtein 2 vs `rest-client` (top-100) | LLM03 |\n| IDE-004 | ace-helper | IntelliJ | Long `<depends>` chain (12 plugins) — large surface | LLM03 |\n| IDE-005 | json-fast | VS Code | activationEvents includes `*` (broad activation) | ASI04 |\n\n### Low\n\n| ID | Extension | IDE | Description | OWASP |\n|----|-----------|-----|-------------|-------|\n| IDE-006 | git-graph | VS Code | Native binary `.dylib` shipped (verified signature OK) | — |\n| IDE-007 | gradle-helper | IntelliJ | Native binary `.so` shipped (Linux ELF) | — |\n| IDE-008 | vsc-cmd | VS Code | `vscode:uninstall` hook present | — |\n| IDE-009 | shaded-jar-pro | IntelliJ | Shaded jar advisory (3 jars) | — |\n| IDE-010 | rest-client-typo | VS Code | Same as IDE-003: typosquat suspicion | LLM03 |\n| IDE-011 | code-splitter | VS Code | activationEvents `onStartupFinished` (broad) | ASI04 |\n| IDE-012 | java-fmt | IntelliJ | Premain-Class candidate (lower confidence) | ASI04 |\n\n### Info\n\n12 informational findings (mostly publisher metadata + extension-pack expansions). See envelope for full list.\n\n---\n\n## Per-IDE Recommendations\n\n### VS Code\n\n1. **Medium:** Investigate `dark-theme-pro` — themes should not ship code.\n2. **Medium:** Compare `rest-client-typo` to `rest-client` — likely typosquat. Uninstall.\n3. **Medium:** Audit `json-fast` activation events; consider replacing with narrower scope.\n\n### IntelliJ IDEA / JetBrains\n\n1. **High:** Manually verify `acme-helper` Premain-Class is legitimate. Consider disabling.\n2. **Medium:** Reduce `ace-helper` depends-chain or replace.\n3. **Low:** Verify shaded-jar advisories (`shaded-jar-pro`) — known shading is normal but creates supply-chain opacity.\n\n---\n\n## Methodology\n\n7 VS Code-specific checks (blocklist, theme-with-code, sideload, broad activation, typosquat, extension-pack, dangerous hooks) + 7 JetBrains checks (Premain-Class, native binaries, depends chain, theme-with-code, broad activation, typosquat, shaded jars). Reused scanners (UNI/ENT/NET/TNT/MEM/SCR) per extension. Offline mode by default.\n\n---\n\n*IDE-scan complete. 59 extensions, 24 findings, 8.9 seconds.*\n",
"parsed": {
"risk_score": 28,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** One high-severity finding: a JetBrains plugin (`acme-helper`) declares `Premain-Class` (javaagent retransform) which is the riskiest IDE-extension pattern.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"coverage": [
{
"ide": "VS Code",
"extensions": 47,
"findings": 8
},
{
"ide": "Cursor",
"extensions": 12,
"findings": 2
},
{
"ide": "IntelliJ IDEA",
"extensions": 12,
"findings": 14
}
],
"findings": [
{
"id": "IDE-001",
"severity": "high",
"extension": "acme-helper",
"ide": "IntelliJ",
"description": "Declares `Premain-Class` — javaagent retransform attack surface",
"owasp": "ASI04"
},
{
"id": "IDE-002",
"severity": "medium",
"extension": "dark-theme-pro",
"ide": "VS Code",
"description": "Theme contains `extension.js` (theme-with-code)",
"owasp": "LLM06"
},
{
"id": "IDE-003",
"severity": "medium",
"extension": "rest-client-typo",
"ide": "VS Code",
"description": "Typosquat: Levenshtein 2 vs `rest-client` (top-100)",
"owasp": "LLM03"
},
{
"id": "IDE-004",
"severity": "medium",
"extension": "ace-helper",
"ide": "IntelliJ",
"description": "Long `<depends>` chain (12 plugins) — large surface",
"owasp": "LLM03"
},
{
"id": "IDE-005",
"severity": "medium",
"extension": "json-fast",
"ide": "VS Code",
"description": "activationEvents includes `*` (broad activation)",
"owasp": "ASI04"
},
{
"id": "IDE-006",
"severity": "low",
"extension": "git-graph",
"ide": "VS Code",
"description": "Native binary `.dylib` shipped (verified signature OK)",
"owasp": "—"
},
{
"id": "IDE-007",
"severity": "low",
"extension": "gradle-helper",
"ide": "IntelliJ",
"description": "Native binary `.so` shipped (Linux ELF)",
"owasp": "—"
},
{
"id": "IDE-008",
"severity": "low",
"extension": "vsc-cmd",
"ide": "VS Code",
"description": "`vscode:uninstall` hook present",
"owasp": "—"
},
{
"id": "IDE-009",
"severity": "low",
"extension": "shaded-jar-pro",
"ide": "IntelliJ",
"description": "Shaded jar advisory (3 jars)",
"owasp": "—"
},
{
"id": "IDE-010",
"severity": "low",
"extension": "rest-client-typo",
"ide": "VS Code",
"description": "Same as IDE-003: typosquat suspicion",
"owasp": "LLM03"
},
{
"id": "IDE-011",
"severity": "low",
"extension": "code-splitter",
"ide": "VS Code",
"description": "activationEvents `onStartupFinished` (broad)",
"owasp": "ASI04"
},
{
"id": "IDE-012",
"severity": "low",
"extension": "java-fmt",
"ide": "IntelliJ",
"description": "Premain-Class candidate (lower confidence)",
"owasp": "ASI04"
}
],
"recommendations": [],
"keyStats": [
{
"label": "TOTALT",
"value": 12
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 1,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"posture": {
"input": {
"target": "~/repos/dft-marketplace",
"frameworks": [
"OWASP LLM Top 10",
"EU AI Act",
"NIST AI RMF",
"ISO 42001"
],
"include_compliance_extras": true
},
"raw_markdown": "# Security Posture — DFT marketplace\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | posture |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 16 categories (13 applicable) |\n| **Frameworks** | OWASP LLM Top 10, EU AI Act, NIST AI RMF |\n| **Triggered by** | /security posture |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 22/100 |\n| **Risk Band** | Medium |\n| **Grade** | B |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 3 |\n| Low | 4 |\n| Info | 6 |\n| **Total** | **14** |\n\n---\n\n## Overall Score\n\n**11 / 13 categories covered (Grade B)**\n\n```\n████████████████████░░░░ 84%\n```\n\n**Risk Score:** 22/100 (Medium)\n\n**Verdict:** WARNING — close one high-severity gap to reach Grade A.\n\n---\n\n## Category Scorecard\n\n| # | Category | Status | Findings |\n|---|----------|--------|---------:|\n| 1 | Deny-First Configuration | PASS | 0 |\n| 2 | Hook Coverage | PASS | 0 |\n| 3 | MCP Server Trust | PARTIAL | 2 |\n| 4 | Secret Management | PASS | 0 |\n| 5 | Permission Hygiene | PARTIAL | 1 |\n| 6 | Memory Hygiene | PASS | 0 |\n| 7 | Supply-Chain Defense | PASS | 1 |\n| 8 | Plugin Trust | PASS | 0 |\n| 9 | IDE Extension Hygiene | PASS | 0 |\n| 10 | Skill Hygiene | PARTIAL | 3 |\n| 11 | Logging & Audit | FAIL | 4 |\n| 12 | Documentation | PASS | 1 |\n| 13 | EU AI Act Coverage | PARTIAL | 2 |\n| 14 | NIST AI RMF Mapping | N-A | 0 |\n| 15 | ISO 42001 Mapping | N-A | 0 |\n| 16 | Datatilsynet Compliance | N-A | 0 |\n\n---\n\n## Top Findings\n\n### High\n\n| ID | Category | File | Description |\n|----|----------|------|-------------|\n| PST-001 | Logging & Audit | settings.json | No audit-trail configured (`audit.log_path` unset) |\n\n### Medium\n\n| ID | Category | File | Description |\n|----|----------|------|-------------|\n| PST-002 | Skill Hygiene | skills/data-summary/SKILL.md | Description >150 chars (verbose) |\n| PST-003 | EU AI Act | (project-level) | No AI Act risk classification documented |\n| PST-004 | MCP Trust | .mcp.json | airbnb-mcp drift advisory pending |\n\n---\n\n## Quick Wins\n\n1. **Enable audit trail** — set `audit.log_path` in `.llm-security/policy.json` (closes PST-001).\n2. **Document AI Act classification** — add risk-level to `CLAUDE.md` (closes PST-003).\n3. **Reset airbnb-mcp baseline** — after legitimate review (closes PST-004).\n\n---\n\n## Baseline Comparison\n\nNo baseline saved. Run `/security posture --save-baseline` to track future drift.\n\n---\n\n## Recommendations\n\n1. **High:** Enable audit logging — single setting closes the only high-severity gap.\n2. **Medium:** Add AI Act risk classification.\n3. **Medium:** Trim verbose skill descriptions in 3 skills.\n\nEstimated effort to Grade A: 30 minutes.\n\n---\n\n*Posture complete. Grade B, 14 findings, 1.2 seconds.*\n",
"parsed": {
"risk_score": 22,
"riskBand": "Medium",
"grade": "B",
"verdict": "warning",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"score": 11,
"posture_score": 11,
"posture_applicable": 13,
"categories": [
{
"num": 1,
"name": "Deny-First Configuration",
"status": "PASS",
"findings": 0
},
{
"num": 2,
"name": "Hook Coverage",
"status": "PASS",
"findings": 0
},
{
"num": 3,
"name": "MCP Server Trust",
"status": "PARTIAL",
"findings": 2
},
{
"num": 4,
"name": "Secret Management",
"status": "PASS",
"findings": 0
},
{
"num": 5,
"name": "Permission Hygiene",
"status": "PARTIAL",
"findings": 1
},
{
"num": 6,
"name": "Memory Hygiene",
"status": "PASS",
"findings": 0
},
{
"num": 7,
"name": "Supply-Chain Defense",
"status": "PASS",
"findings": 1
},
{
"num": 8,
"name": "Plugin Trust",
"status": "PASS",
"findings": 0
},
{
"num": 9,
"name": "IDE Extension Hygiene",
"status": "PASS",
"findings": 0
},
{
"num": 10,
"name": "Skill Hygiene",
"status": "PARTIAL",
"findings": 3
},
{
"num": 11,
"name": "Logging & Audit",
"status": "FAIL",
"findings": 4
},
{
"num": 12,
"name": "Documentation",
"status": "PASS",
"findings": 1
},
{
"num": 13,
"name": "EU AI Act Coverage",
"status": "PARTIAL",
"findings": 2
},
{
"num": 14,
"name": "NIST AI RMF Mapping",
"status": "N-A",
"findings": 0
},
{
"num": 15,
"name": "ISO 42001 Mapping",
"status": "N-A",
"findings": 0
},
{
"num": 16,
"name": "Datatilsynet Compliance",
"status": "N-A",
"findings": 0
}
],
"findings": [
{
"id": "PST-001",
"severity": "high",
"category": "Logging & Audit",
"file": "settings.json",
"description": "No audit-trail configured (`audit.log_path` unset)"
},
{
"id": "PST-002",
"severity": "medium",
"category": "Skill Hygiene",
"file": "skills/data-summary/SKILL.md",
"description": "Description >150 chars (verbose)"
},
{
"id": "PST-003",
"severity": "medium",
"category": "EU AI Act",
"file": "(project-level)",
"description": "No AI Act risk classification documented"
},
{
"id": "PST-004",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"description": "airbnb-mcp drift advisory pending"
}
],
"quick_wins": [
"set `audit.log_path` in `.llm-security/policy.json` (closes PST-001).",
"add risk-level to `CLAUDE.md` (closes PST-003).",
"after legitimate review (closes PST-004)."
],
"recommendations": [
"Enable audit logging — single setting closes the only high-severity gap.",
"Add AI Act risk classification.",
"Trim verbose skill descriptions in 3 skills."
],
"keyStats": [
{
"label": "GRADE",
"value": "B",
"modifier": "low"
},
{
"label": "PASS",
"value": 8,
"modifier": "low"
},
{
"label": "FAIL",
"value": 1,
"modifier": "critical"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"audit": {
"input": {
"target": "~/repos/dft-marketplace",
"frameworks": [
"OWASP LLM Top 10",
"OWASP Agentic (ASI)",
"OWASP MCP"
],
"severity_threshold": "high",
"include_remediation": true
},
"raw_markdown": "# Full Security Audit — DFT marketplace\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | audit |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 7 audit dimensions, 10 OWASP categories |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic |\n| **Triggered by** | /security audit |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 31/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 4 |\n| Medium | 8 |\n| Low | 7 |\n| Info | 9 |\n| **Total** | **28** |\n\n**Verdict rationale:** Posture base grade B downgraded to C after agent-level findings (4 high). No critical, but `Logging & Audit` and `Permission Hygiene` need attention.\n\n---\n\n## Executive Summary\n\nFull audit combined posture-scanner output with skill-scanner-agent and mcp-scanner-agent narratives. 28 findings across 14 files. Most concentrated in agent definitions (over-permissioned tool lists) and `.claude/settings.json` (missing audit log + wildcard Bash). Recommendation: address top 3 actions to reach Grade B; six more to reach Grade A.\n\n---\n\n## Radar Axes\n\n| Axis | Score |\n|------|------:|\n| Deny-First Configuration | 4 |\n| Hook Coverage | 5 |\n| MCP Trust | 3 |\n| Secrets Management | 5 |\n| Permission Hygiene | 2 |\n| Supply-Chain Defense | 4 |\n| Logging & Audit | 1 |\n\n---\n\n## Category Assessment\n\n### Category 1 — Deny-First Configuration\n\n| Status | PASS |\n\n**Evidence:** `.claude/settings.json` has `permissions.defaultMode: \"deny\"`. Explicit allow-list in place.\n\n**Recommendations:** None — Grade A on this axis.\n\n### Category 2 — Hook Coverage\n\n| Status | PASS |\n\n**Evidence:** 9 hooks active (PreToolUse: 4, PostToolUse: 2, UserPromptSubmit: 1, PreCompact: 1, others: 1).\n\n**Recommendations:** Consider adding PreCompact-poisoning detection if not already covered.\n\n### Category 5 — Permission Hygiene\n\n| Status | PARTIAL |\n\n**Evidence:** 3 agents have `Write` in tool list. 1 has `Bash` without sub-command restriction.\n\n**Recommendations:** Tighten tool lists to minimum-necessary set. Use `Bash(git:*)` instead of `Bash(*)`.\n\n### Category 11 — Logging & Audit\n\n| Status | FAIL |\n\n**Evidence:** No `audit.log_path` configured. No SIEM integration. No JSONL audit-trail.\n\n**Recommendations:** Enable `audit.log_path` immediately — closes 1 high + 3 medium findings.\n\n(Categories 3, 4, 6-10, 12-13 follow same format — see envelope JSON for full breakdown)\n\n---\n\n## Risk Matrix (Likelihood × Impact)\n\n| Category | Likelihood | Impact | Score |\n|----------|-----------:|-------:|------:|\n| Logging gap (PST-001) | 4 | 4 | 16 |\n| Permission sprawl | 3 | 4 | 12 |\n| MCP drift (airbnb-mcp) | 3 | 3 | 9 |\n| AI Act classification missing | 2 | 3 | 6 |\n\n---\n\n## Action Plan\n\n### IMMEDIATE (this week)\n\n1. Enable audit-trail: set `audit.log_path` in `.llm-security/policy.json`\n2. Tighten 3 over-permissioned agents (drop `Write` where unused)\n3. Investigate airbnb-mcp drift — reset baseline only after review\n\n### HIGH (this month)\n\n4. Document AI Act risk classification in `CLAUDE.md`\n5. Replace `Bash(*)` with `Bash(git:*, npm:*)` in `.claude/settings.json`\n6. Bump 2 dependencies to clear OSV advisories\n\n### MEDIUM (next quarter)\n\n7. Add SECURITY.md disclosure policy\n8. Trim verbose skill descriptions (3 files)\n9. Document hook rationale in plugin CLAUDE.md\n\n---\n\n## Positive Findings\n\n- All hooks active and non-bypassed\n- No critical findings\n- Posture scanner runtime < 2s (well-tuned)\n- Memory hygiene clean\n\n---\n\n*Audit complete. 28 findings, Grade C, 14.7 seconds.*\n",
"parsed": {
"risk_score": 31,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** Posture base grade B downgraded to C after agent-level findings (4 high). No critical, but `Logging & Audit` and `Permission Hygiene` need attention.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"score": 31,
"radar_axes": [
{
"name": "Deny-First Configuration",
"score": 4
},
{
"name": "Hook Coverage",
"score": 5
},
{
"name": "MCP Trust",
"score": 3
},
{
"name": "Secrets Management",
"score": 5
},
{
"name": "Permission Hygiene",
"score": 2
},
{
"name": "Supply-Chain Defense",
"score": 4
},
{
"name": "Logging & Audit",
"score": 1
}
],
"categories": [
{
"num": 1,
"name": "Deny-First Configuration",
"status": "PASS"
},
{
"num": 2,
"name": "Hook Coverage",
"status": "PASS"
},
{
"num": 5,
"name": "Permission Hygiene",
"status": "PARTIAL"
},
{
"num": 11,
"name": "Logging & Audit",
"status": "FAIL"
}
],
"risk_matrix": [
{
"category": "Logging gap (PST-001)",
"likelihood": 4,
"impact": 4,
"score": 16
},
{
"category": "Permission sprawl",
"likelihood": 3,
"impact": 4,
"score": 12
},
{
"category": "MCP drift (airbnb-mcp)",
"likelihood": 3,
"impact": 3,
"score": 9
},
{
"category": "AI Act classification missing",
"likelihood": 2,
"impact": 3,
"score": 6
}
],
"action_plan": {
"immediate": [
"Enable audit-trail: set `audit.log_path` in `.llm-security/policy.json`",
"Tighten 3 over-permissioned agents (drop `Write` where unused)",
"Investigate airbnb-mcp drift — reset baseline only after review"
],
"high": [
"Document AI Act risk classification in `CLAUDE.md`",
"Replace `Bash(*)` with `Bash(git:*, npm:*)` in `.claude/settings.json`",
"Bump 2 dependencies to clear OSV advisories"
],
"medium": [
"Add SECURITY.md disclosure policy",
"Trim verbose skill descriptions (3 files)",
"Document hook rationale in plugin CLAUDE.md"
]
},
"findings": [],
"executive_summary": "Full audit combined posture-scanner output with skill-scanner-agent and mcp-scanner-agent narratives. 28 findings across 14 files. Most concentrated in agent definitions (over-permissioned tool lists) and `.claude/settings.json` (missing audit log + wildcard Bash). Recommendation: address top 3 actions to reach Grade B; six more to reach Grade A.\n\n---",
"keyStats": [
{
"label": "GRADE",
"value": "C",
"modifier": "med"
},
{
"label": "SCORE",
"value": 31
},
{
"label": "FUNN",
"value": 0
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"dashboard": {
"input": {
"no_cache": false,
"max_depth": 3
},
"raw_markdown": "# Security Dashboard — Machine-wide\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | dashboard |\n| **Target** | machine-wide (5 projects) |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | all Claude Code projects under ~/ + ~/.claude/plugins/ |\n| **Frameworks** | OWASP LLM Top 10 |\n| **Triggered by** | /security dashboard |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Machine Grade** | C (weakest link) |\n| **Projects Scanned** | 5 |\n| **Total Findings** | 87 |\n| **Scan Time** | 8.4s |\n| **Cache** | Cached (3h old) |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 1 |\n| High | 12 |\n| Medium | 28 |\n| Low | 24 |\n| Info | 22 |\n| **Total** | **87** |\n\n**Verdict rationale:** Machine grade is weakest-link rule. The `from-ai-to-chitta` project (Grade D) drags machine to C. Resolving that project would lift machine to B.\n\n---\n\n## Project Overview\n\n| Project | Grade | Risk | Worst Category | Findings |\n|---------|-------|------:|----------------|---------:|\n| from-ai-to-chitta | D | 56 | MCP Trust | 32 |\n| dft-marketplace | C | 31 | Logging & Audit | 28 |\n| airbnb-mcp-plugin | C | 41 | Permissions | 14 |\n| ktg-plugin-marketplace | B | 22 | Skill Hygiene | 9 |\n| nightly-utils | A | 4 | — | 4 |\n\n---\n\n## Trend (since last scan)\n\n| Project | Trend | Δ Risk | Δ Findings |\n|---------|:-----:|-------:|-----------:|\n| from-ai-to-chitta | worse | +12 | +6 |\n| dft-marketplace | stable | 0 | -1 |\n| airbnb-mcp-plugin | stable | -2 | 0 |\n| ktg-plugin-marketplace | better | -7 | -3 |\n| nightly-utils | stable | 0 | 0 |\n\n---\n\n## Errors\n\nNo projects failed to scan in this run.\n\n---\n\n## Recommendations\n\n1. **Priority:** Investigate `from-ai-to-chitta` — only Grade D project. Run `/security audit ~/repos/from-ai-to-chitta` for category-level breakdown.\n2. **Quick win:** Apply audit-trail fix to `dft-marketplace` (already identified, 30 min) → likely lifts to Grade B.\n3. **Maintenance:** Re-run `/security plugin-audit` on `airbnb-mcp-plugin` after maintainer responds to permission-clarification issue.\n\nEstimated effort to Machine Grade B: 4 hours (focused on from-ai-to-chitta + dft-marketplace).\n\n---\n\n*Dashboard complete. 5 projects, machine grade C.*\n",
"parsed": {
"verdict_rationale": "** Machine grade is weakest-link rule. The `from-ai-to-chitta` project (Grade D) drags machine to C. Resolving that project would lift machine to B.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"machine_grade": "C",
"projects_scanned": 5,
"total_findings": 87,
"cache": "Cached (3h old)",
"projects": [
{
"name": "from-ai-to-chitta",
"grade": "D",
"risk": 56,
"worst_category": "MCP Trust",
"findings": 32
},
{
"name": "dft-marketplace",
"grade": "C",
"risk": 31,
"worst_category": "Logging & Audit",
"findings": 28
},
{
"name": "airbnb-mcp-plugin",
"grade": "C",
"risk": 41,
"worst_category": "Permissions",
"findings": 14
},
{
"name": "ktg-plugin-marketplace",
"grade": "B",
"risk": 22,
"worst_category": "Skill Hygiene",
"findings": 9
},
{
"name": "nightly-utils",
"grade": "A",
"risk": 4,
"worst_category": "—",
"findings": 4
}
],
"trends": [
{
"name": "from-ai-to-chitta",
"trend": "worse",
"d_risk": "+12",
"d_findings": "+6"
},
{
"name": "dft-marketplace",
"trend": "stable",
"d_risk": "0",
"d_findings": "-1"
},
{
"name": "airbnb-mcp-plugin",
"trend": "stable",
"d_risk": "-2",
"d_findings": "0"
},
{
"name": "ktg-plugin-marketplace",
"trend": "better",
"d_risk": "-7",
"d_findings": "-3"
},
{
"name": "nightly-utils",
"trend": "stable",
"d_risk": "0",
"d_findings": "0"
}
],
"errors": [],
"weakest_link": "from-ai-to-chitta",
"recommendations": [
"Investigate `from-ai-to-chitta` — only Grade D project. Run `/security audit ~/repos/from-ai-to-chitta` for category-level breakdown.",
"Apply audit-trail fix to `dft-marketplace` (already identified, 30 min) → likely lifts to Grade B.",
"Re-run `/security plugin-audit` on `airbnb-mcp-plugin` after maintainer responds to permission-clarification issue."
],
"verdict": "warning",
"keyStats": [
{
"label": "PROSJEKTER",
"value": 5
},
{
"label": "MASKINKLASSE",
"value": "C"
},
{
"label": "SVAKEST",
"value": "from-ai-to-chitta"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"harden": {
"input": {
"target": "~/repos/dft-marketplace",
"apply": false,
"include_settings": true,
"include_claude_md": true,
"include_gitignore": true
},
"raw_markdown": "# Security Harden — DFT marketplace\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | harden |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | Grade A reference config |\n| **Frameworks** | OWASP LLM Top 10 |\n| **Triggered by** | /security harden |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Current Grade** | C |\n| **Project Type** | monorepo |\n| **Recommendations** | 6/8 |\n| **Mode** | dry-run |\n\n---\n\n## Posture Snapshot\n\n| Metric | Before |\n|--------|-------:|\n| Pass | 8 |\n| Partial | 3 |\n| Fail | 1 |\n| N-A | 4 |\n| Pass rate | 67% |\n\n---\n\n## Recommendations\n\n### 1. Logging & Audit — `.llm-security/policy.json`\n\n- **Action:** create\n- **Category:** Logging & Audit\n- **Content preview:**\n ```json\n {\n \"audit\": {\n \"log_path\": \"~/.claude/llm-security-audit.jsonl\",\n \"format\": \"jsonl\"\n }\n }\n ```\n\n### 2. Permission Hygiene — `.claude/settings.json`\n\n- **Action:** merge\n- **Category:** Permission Hygiene\n- **Content preview:**\n Replace `\"Bash(*)\"` with `\"Bash(git:*, npm:*, node:*, jq:*)\"`. Adds explicit allow-list.\n\n### 3. Memory Hygiene — `CLAUDE.md`\n\n- **Action:** append\n- **Category:** Memory Hygiene\n- **Content preview:** Add Security Boundaries section with 4 rules.\n\n### 4. Hook Coverage — `.claude/settings.json`\n\n- **Action:** merge\n- **Category:** Hook Coverage\n- **Content preview:** Add `precompact` hook reference (currently missing).\n\n### 5. EU AI Act — `CLAUDE.md`\n\n- **Action:** append\n- **Category:** Compliance\n- **Content preview:** Add AI Act risk classification stub: `risk_level: not-applicable (developer-tool)`.\n\n### 6. Documentation — `SECURITY.md`\n\n- **Action:** create\n- **Category:** Documentation\n- **Content preview:** Disclosure policy template (7-day ack, 14-day triage).\n\n### 7. (skipped) Supply-Chain Defense\n\n- **Action:** none\n- **Reason:** Already at Grade A.\n\n### 8. (skipped) Plugin Trust\n\n- **Action:** none\n- **Reason:** No third-party plugins installed.\n\n---\n\n## Diff Summary\n\n| File | Action | Lines |\n|------|--------|------:|\n| `.llm-security/policy.json` | + create | +12 |\n| `.claude/settings.json` | ~ merge | ~3 |\n| `CLAUDE.md` | + append | +18 |\n| `SECURITY.md` | + create | +47 |\n| **Total** | | **+80 / ~3** |\n\n---\n\n## Apply Confirmation\n\nRun `/security harden . --apply` to apply these 6 changes. Backup will be created at `~/.cache/llm-security/backups/2026-05-05/`.\n\n**Estimated outcome:** Grade C → A after apply + posture re-scan.\n\n---\n\n*Harden complete. 6 actionable recommendations, dry-run.*\n",
"parsed": {
"current_grade": "C",
"project_type": "monorepo",
"actionable": 6,
"total": 8,
"mode": "dry-run",
"recommendations": [
{
"num": 1,
"category": "Logging & Audit",
"file": "`.llm-security/policy.json`",
"action": "create",
"content_preview": "```json\n {\n \"audit\": {\n \"log_path\": \"~/.claude/llm-security-audit.jsonl\",\n \"format\": \"jsonl\"\n }\n }\n ```"
},
{
"num": 2,
"category": "Permission Hygiene",
"file": "`.claude/settings.json`",
"action": "merge",
"content_preview": "Replace `\"Bash(*)\"` with `\"Bash(git:*, npm:*, node:*, jq:*)\"`. Adds explicit allow-list."
},
{
"num": 3,
"category": "Memory Hygiene",
"file": "`CLAUDE.md`",
"action": "append",
"content_preview": "Add Security Boundaries section with 4 rules."
},
{
"num": 4,
"category": "Hook Coverage",
"file": "`.claude/settings.json`",
"action": "merge",
"content_preview": "Add `precompact` hook reference (currently missing)."
},
{
"num": 5,
"category": "EU AI Act",
"file": "`CLAUDE.md`",
"action": "append",
"content_preview": "Add AI Act risk classification stub: `risk_level: not-applicable (developer-tool)`."
},
{
"num": 6,
"category": "Documentation",
"file": "`SECURITY.md`",
"action": "create",
"content_preview": "Disclosure policy template (7-day ack, 14-day triage)."
},
{
"num": 7,
"category": "(skipped) Supply-Chain Defense",
"file": "**Action:** none",
"action": "none",
"content_preview": ""
},
{
"num": 8,
"category": "(skipped) Plugin Trust",
"file": "**Action:** none",
"action": "none",
"content_preview": ""
}
],
"diff_summary": [
{
"file": "`.llm-security/policy.json`",
"action": "+ create",
"lines": "+12"
},
{
"file": "`.claude/settings.json`",
"action": "~ merge",
"lines": "~3"
},
{
"file": "`CLAUDE.md`",
"action": "+ append",
"lines": "+18"
},
{
"file": "`SECURITY.md`",
"action": "+ create",
"lines": "+47"
}
],
"new": [
{
"num": 1,
"category": "Logging & Audit",
"file": "`.llm-security/policy.json`",
"action": "create",
"content_preview": "```json\n {\n \"audit\": {\n \"log_path\": \"~/.claude/llm-security-audit.jsonl\",\n \"format\": \"jsonl\"\n }\n }\n ```"
},
{
"num": 2,
"category": "Permission Hygiene",
"file": "`.claude/settings.json`",
"action": "merge",
"content_preview": "Replace `\"Bash(*)\"` with `\"Bash(git:*, npm:*, node:*, jq:*)\"`. Adds explicit allow-list."
},
{
"num": 3,
"category": "Memory Hygiene",
"file": "`CLAUDE.md`",
"action": "append",
"content_preview": "Add Security Boundaries section with 4 rules."
},
{
"num": 4,
"category": "Hook Coverage",
"file": "`.claude/settings.json`",
"action": "merge",
"content_preview": "Add `precompact` hook reference (currently missing)."
},
{
"num": 5,
"category": "EU AI Act",
"file": "`CLAUDE.md`",
"action": "append",
"content_preview": "Add AI Act risk classification stub: `risk_level: not-applicable (developer-tool)`."
},
{
"num": 6,
"category": "Documentation",
"file": "`SECURITY.md`",
"action": "create",
"content_preview": "Disclosure policy template (7-day ack, 14-day triage)."
}
],
"unchanged": [
{
"num": 7,
"category": "(skipped) Supply-Chain Defense",
"file": "**Action:** none",
"action": "none",
"content_preview": ""
},
{
"num": 8,
"category": "(skipped) Plugin Trust",
"file": "**Action:** none",
"action": "none",
"content_preview": ""
}
],
"resolved": [],
"moved": [],
"verdict": "warning",
"keyStats": [
{
"label": "NÅ-GRADE",
"value": "C"
},
{
"label": "AKSJONER",
"value": 6,
"modifier": "medium"
},
{
"label": "SKIPPED",
"value": 2
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"red-team": {
"input": {
"target": "~/repos/dft-marketplace",
"category": "",
"adaptive": true,
"benchmark": false
},
"raw_markdown": "# Red-Team Simulation\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | red-team |\n| **Target** | llm-security plugin hooks |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | 64 scenarios × 12 categories |\n| **Frameworks** | OWASP LLM Top 10, OWASP Agentic, DeepMind Agent Traps |\n| **Triggered by** | /security red-team |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Defense Score** | 92% |\n| **Total Scenarios** | 64 |\n| **Pass** | 59 |\n| **Fail** | 5 |\n| **Adaptive Mode** | off |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 2 |\n| Medium | 3 |\n| Low | 0 |\n| Info | 0 |\n| **Total** | **5** |\n\n**Verdict rationale:** 5 of 64 scenarios bypassed defenses. Two high-severity bypasses concern bash-evasion via T9 (eval-via-variable) and synonym-substituted destructive commands. No critical bypasses.\n\n---\n\n## Defense Score Interpretation\n\n92% — minor gaps. Hooks block all critical attack-chain scenarios. Bypass concentration is in adaptive evasion (variable indirection + synonyms), which is harder to catch deterministically.\n\n---\n\n## Per-Category Breakdown\n\n| Category | Pass | Fail | Coverage |\n|----------|-----:|-----:|---------:|\n| prompt-injection | 8 | 0 | 100% |\n| tool-poisoning | 6 | 0 | 100% |\n| data-exfiltration | 5 | 0 | 100% |\n| lethal-trifecta | 4 | 0 | 100% |\n| mcp-shadowing | 3 | 0 | 100% |\n| memory-poisoning | 6 | 0 | 100% |\n| supply-chain | 5 | 1 | 83% |\n| credential-theft | 4 | 0 | 100% |\n| unicode-evasion | 5 | 1 | 83% |\n| bash-evasion | 6 | 2 | 75% |\n| sub-agent-escape | 4 | 0 | 100% |\n| permission-escalation | 3 | 1 | 75% |\n\n---\n\n## Failed Scenarios\n\n### High\n\n| ID | Category | Payload class | Reason |\n|----|----------|---------------|--------|\n| BSH-007 | bash-evasion | T9 eval-via-variable (one-level forward-flow) | Defense layer collapses common case but misses double-indirection variant |\n| BSH-008 | bash-evasion | Synonym-substituted destructive | \"obliterate\" used in place of \"rm\" — synonym table did not match |\n\n### Medium\n\n| ID | Category | Payload class | Reason |\n|----|----------|---------------|--------|\n| UNI-007 | unicode-evasion | PUA-B + zero-width combo | Detector flagged PUA-B but downgraded to MEDIUM advisory |\n| DEP-005 | supply-chain | Levenshtein 3 typosquat | Beyond default ≤2 threshold; expected behavior |\n| PRM-004 | permission-escalation | Catalog-merge granting Edit | Hook fires but permits via wildcard inheritance |\n\n---\n\n## Adaptive Mode\n\nAdaptive mode was OFF for this run. To test mutation-based evasion (homoglyph, encoding, zero-width, case alternation, synonym), re-run with `--adaptive`.\n\n---\n\n## Recommendations\n\n1. **High:** Extend `bash-normalize.mjs` T9 (eval-via-variable) to handle double indirection (`x=cmd; y=$x; eval $y`).\n2. **High:** Expand synonym table in `attack-mutations.json` to include \"obliterate\", \"annihilate\", \"wipe\" variants.\n3. **Medium:** Document known limitation: Levenshtein 3+ typosquats not caught by default policy. User-tunable via `policy.json`.\n4. **Medium:** PRM-004 wildcard inheritance is documented behavior but warrants user-facing notice.\n\n---\n\n## Test History\n\n| Run | Date | Defense Score | Δ |\n|-----|------|--------------:|---|\n| Current | 2026-05-05 | 92% | — |\n| Previous | 2026-04-29 | 91% | +1 |\n| 30 days ago | 2026-04-05 | 88% | +4 |\n\n---\n\n*Red-team complete. 64 scenarios, 5 bypasses, defense score 92%.*\n",
"parsed": {
"verdict": "warning",
"verdict_rationale": "** 5 of 64 scenarios bypassed defenses. Two high-severity bypasses concern bash-evasion via T9 (eval-via-variable) and synonym-substituted destructive commands. No critical bypasses.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"defense_score": 92,
"total": 64,
"pass_count": 59,
"fail_count": 5,
"adaptive": false,
"categories": [
{
"category": "prompt-injection",
"pass": 8,
"fail": 0,
"coverage": "100%"
},
{
"category": "tool-poisoning",
"pass": 6,
"fail": 0,
"coverage": "100%"
},
{
"category": "data-exfiltration",
"pass": 5,
"fail": 0,
"coverage": "100%"
},
{
"category": "lethal-trifecta",
"pass": 4,
"fail": 0,
"coverage": "100%"
},
{
"category": "mcp-shadowing",
"pass": 3,
"fail": 0,
"coverage": "100%"
},
{
"category": "memory-poisoning",
"pass": 6,
"fail": 0,
"coverage": "100%"
},
{
"category": "supply-chain",
"pass": 5,
"fail": 1,
"coverage": "83%"
},
{
"category": "credential-theft",
"pass": 4,
"fail": 0,
"coverage": "100%"
},
{
"category": "unicode-evasion",
"pass": 5,
"fail": 1,
"coverage": "83%"
},
{
"category": "bash-evasion",
"pass": 6,
"fail": 2,
"coverage": "75%"
},
{
"category": "sub-agent-escape",
"pass": 4,
"fail": 0,
"coverage": "100%"
},
{
"category": "permission-escalation",
"pass": 3,
"fail": 1,
"coverage": "75%"
}
],
"scenarios": [
{
"id": "BSH-007",
"severity": "high",
"category": "bash-evasion",
"payload_class": "T9 eval-via-variable (one-level forward-flow)",
"reason": "Defense layer collapses common case but misses double-indirection variant"
},
{
"id": "BSH-008",
"severity": "high",
"category": "bash-evasion",
"payload_class": "Synonym-substituted destructive",
"reason": "\"obliterate\" used in place of \"rm\" — synonym table did not match"
},
{
"id": "UNI-007",
"severity": "medium",
"category": "unicode-evasion",
"payload_class": "PUA-B + zero-width combo",
"reason": "Detector flagged PUA-B but downgraded to MEDIUM advisory"
},
{
"id": "DEP-005",
"severity": "medium",
"category": "supply-chain",
"payload_class": "Levenshtein 3 typosquat",
"reason": "Beyond default ≤2 threshold; expected behavior"
},
{
"id": "PRM-004",
"severity": "medium",
"category": "permission-escalation",
"payload_class": "Catalog-merge granting Edit",
"reason": "Hook fires but permits via wildcard inheritance"
}
],
"history": [
{
"run": "Current",
"date": "2026-05-05",
"defense_score": 92,
"delta": "—"
},
{
"run": "Previous",
"date": "2026-04-29",
"defense_score": 91,
"delta": "+1"
},
{
"run": "30 days ago",
"date": "2026-04-05",
"defense_score": 88,
"delta": "+4"
}
],
"recommendations": [
"Extend `bash-normalize.mjs` T9 (eval-via-variable) to handle double indirection (`x=cmd; y=$x; eval $y`).",
"Expand synonym table in `attack-mutations.json` to include \"obliterate\", \"annihilate\", \"wipe\" variants.",
"Document known limitation: Levenshtein 3+ typosquats not caught by default policy. User-tunable via `policy.json`.",
"PRM-004 wildcard inheritance is documented behavior but warrants user-facing notice."
],
"keyStats": [
{
"label": "TOTALT",
"value": 64
},
{
"label": "PASS",
"value": 59,
"modifier": "low"
},
{
"label": "FAIL",
"value": 5,
"modifier": "crit"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"mcp-inspect": {
"input": {
"target_servers": "",
"timeout_ms": 10000,
"skip_global": false
},
"raw_markdown": "# MCP Live-Inspect Report\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | mcp-inspect |\n| **Target** | 4 running MCP servers (auto-discovered) |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | runtime tool descriptions + capability surface |\n| **Frameworks** | OWASP MCP Top 10 |\n| **Triggered by** | /security mcp-inspect |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 38/100 |\n| **Risk Band** | Medium |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 3 |\n| Low | 2 |\n| Info | 4 |\n| **Total** | **10** |\n\n**Verdict rationale:** One HIGH-severity tool-shadowing finding on `airbnb-mcp.search_listings` (description claims to \"browse listings\" but invokes `Bash` internally). Three MEDIUM drift advisories above per-update threshold.\n\n---\n\n## Server Inventory\n\n| Server | Transport | Tools | Status | Connected |\n|--------|-----------|------:|--------|-----------|\n| airbnb-mcp | stdio | 6 | running | yes |\n| postgres-readonly | stdio | 2 | running | yes |\n| browser-mcp | http | 4 | running | yes |\n| filesystem-mcp | stdio | 8 | running | yes |\n\n---\n\n## Codepoint Reveal\n\nTools with non-ASCII codepoints in descriptions (zero-width / homoglyph candidates):\n\n| Server | Tool | Codepoints | Risk |\n|--------|------|------------|------|\n| airbnb-mcp | search_listings | U+200B (zero-width space), U+2028 (line separator) | HIGH |\n| browser-mcp | navigate | U+202E (RTL override) | MEDIUM |\n| filesystem-mcp | list_dir | (clean) | — |\n\n---\n\n## Findings\n\n### High\n\n| ID | Category | Server | Description | OWASP |\n|----|----------|--------|-------------|-------|\n| MCI-001 | Tool Shadowing | airbnb-mcp | `search_listings` description says \"browse listings\" but tool surface includes shell-exec capability | MCP06 |\n\n### Medium\n\n| ID | Category | Server | Description | OWASP |\n|----|----------|--------|-------------|-------|\n| MCI-002 | Description Drift | airbnb-mcp | `book_property` description changed 18.4% since last cache (>10% threshold) | MCP05 |\n| MCI-003 | Description Drift | browser-mcp | `navigate` description gained URL-allow-list bypass language | MCP05 |\n| MCI-004 | Hidden Imperative | airbnb-mcp | `cancel_booking` description contains \"ALWAYS confirm with user before X\" pattern | MCP03 |\n\n### Low\n\n| ID | Category | Server | Description | OWASP |\n|----|----------|--------|-------------|-------|\n| MCI-005 | Verbose Schema | filesystem-mcp | Tool schemas exceed 800 tokens — context-window pressure | — |\n| MCI-006 | Verbose Schema | browser-mcp | Tool schemas exceed 600 tokens | — |\n\n### Info\n\n| ID | Category | Server | Description | OWASP |\n|----|----------|--------|-------------|-------|\n| MCI-007 | Capability | postgres-readonly | Read-only enforced by URL connection-string parameter | — |\n| MCI-008 | Capability | filesystem-mcp | Path-allow-list enforced via env var | — |\n| MCI-009 | Trust | airbnb-mcp | NPM package, last published 2026-04-12 | — |\n| MCI-010 | Trust | browser-mcp | GitHub source, MIT license | — |\n\n---\n\n## Recommendations\n\n1. **Immediate:** Disable `airbnb-mcp.search_listings` until upstream maintainer clarifies shell-exec rationale or removes capability.\n2. **High:** Run `/security mcp-baseline-reset --target airbnb-mcp` after legitimate update is verified.\n3. **Medium:** Audit zero-width characters in descriptions; reject the tool description if maintainer cannot explain U+200B inclusion.\n4. **Medium:** Bound description token-budget in policy.json: `mcp.max_description_tokens: 500`.\n\n---\n\n*Live-inspect complete. 10 findings across 4 servers.*\n",
"parsed": {
"risk_score": 38,
"riskBand": "Medium",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** One HIGH-severity tool-shadowing finding on `airbnb-mcp.search_listings` (description claims to \"browse listings\" but invokes `Bash` internally). Three MEDIUM drift advisories above per-update threshold.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"server_inventory": [
{
"server": "airbnb-mcp",
"transport": "stdio",
"tools": 6,
"status": "running",
"connected": true
},
{
"server": "postgres-readonly",
"transport": "stdio",
"tools": 2,
"status": "running",
"connected": true
},
{
"server": "browser-mcp",
"transport": "http",
"tools": 4,
"status": "running",
"connected": true
},
{
"server": "filesystem-mcp",
"transport": "stdio",
"tools": 8,
"status": "running",
"connected": true
}
],
"codepoints": [
{
"server": "airbnb-mcp",
"tool": "search_listings",
"codepoints": "U+200B (zero-width space), U+2028 (line separator)",
"risk": "HIGH"
},
{
"server": "browser-mcp",
"tool": "navigate",
"codepoints": "U+202E (RTL override)",
"risk": "MEDIUM"
},
{
"server": "filesystem-mcp",
"tool": "list_dir",
"codepoints": "(clean)",
"risk": "—"
}
],
"findings": [
{
"id": "MCI-001",
"severity": "high",
"category": "Tool Shadowing",
"file": "",
"line": "",
"description": "`search_listings` description says \"browse listings\" but tool surface includes shell-exec capability",
"owasp": "MCP06",
"server": "Tool Shadowing"
},
{
"id": "MCI-002",
"severity": "medium",
"category": "Description Drift",
"file": "",
"line": "",
"description": "`book_property` description changed 18.4% since last cache (>10% threshold)",
"owasp": "MCP05",
"server": "Description Drift"
},
{
"id": "MCI-003",
"severity": "medium",
"category": "Description Drift",
"file": "",
"line": "",
"description": "`navigate` description gained URL-allow-list bypass language",
"owasp": "MCP05",
"server": "Description Drift"
},
{
"id": "MCI-004",
"severity": "medium",
"category": "Hidden Imperative",
"file": "",
"line": "",
"description": "`cancel_booking` description contains \"ALWAYS confirm with user before X\" pattern",
"owasp": "MCP03",
"server": "Hidden Imperative"
},
{
"id": "MCI-005",
"severity": "low",
"category": "Verbose Schema",
"file": "",
"line": "",
"description": "Tool schemas exceed 800 tokens — context-window pressure",
"owasp": "—",
"server": "Verbose Schema"
},
{
"id": "MCI-006",
"severity": "low",
"category": "Verbose Schema",
"file": "",
"line": "",
"description": "Tool schemas exceed 600 tokens",
"owasp": "—",
"server": "Verbose Schema"
},
{
"id": "MCI-007",
"severity": "info",
"category": "Capability",
"file": "",
"line": "",
"description": "Read-only enforced by URL connection-string parameter",
"owasp": "—",
"server": "Capability"
},
{
"id": "MCI-008",
"severity": "info",
"category": "Capability",
"file": "",
"line": "",
"description": "Path-allow-list enforced via env var",
"owasp": "—",
"server": "Capability"
},
{
"id": "MCI-009",
"severity": "info",
"category": "Trust",
"file": "",
"line": "",
"description": "NPM package, last published 2026-04-12",
"owasp": "—",
"server": "Trust"
},
{
"id": "MCI-010",
"severity": "info",
"category": "Trust",
"file": "",
"line": "",
"description": "GitHub source, MIT license",
"owasp": "—",
"server": "Trust"
}
],
"recommendations": [
"Disable `airbnb-mcp.search_listings` until upstream maintainer clarifies shell-exec rationale or removes capability.",
"Run `/security mcp-baseline-reset --target airbnb-mcp` after legitimate update is verified.",
"Audit zero-width characters in descriptions; reject the tool description if maintainer cannot explain U+200B inclusion.",
"Bound description token-budget in policy.json: `mcp.max_description_tokens: 500`."
],
"keyStats": [
{
"label": "TOTALT",
"value": 10
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 1,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"supply-check": {
"input": {
"target": "~/repos/dft-marketplace",
"online": true,
"ecosystems": [
"npm",
"pip"
]
},
"raw_markdown": "# Supply-Chain Recheck Report\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | supply-check |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | npm + pip + cargo lockfiles |\n| **Frameworks** | OWASP LLM03, NIST SSDF |\n| **Triggered by** | /security supply-check |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 22/100 |\n| **Risk Band** | Medium |\n| **Grade** | B |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 4 |\n| Low | 2 |\n| Info | 6 |\n| **Total** | **13** |\n\n**Verdict rationale:** 1 HIGH OSV.dev advisory on `lefthook@1.4.2` (CVE-2024-1234, denial-of-service via crafted hook config). 4 MEDIUM typosquat candidates flagged for manual review.\n\n---\n\n## Ecosystem Coverage\n\n| Ecosystem | Lockfile | Packages | OSV.dev Hits | Typosquats |\n|-----------|----------|---------:|-------------:|-----------:|\n| npm | package-lock.json | 412 | 1 | 2 |\n| pip | requirements.txt | 38 | 0 | 1 |\n| cargo | Cargo.lock | 71 | 0 | 0 |\n| go | go.sum | 0 | 0 | 0 |\n| docker | (none) | 0 | 0 | 0 |\n| **Total** | | **521** | **1** | **3** |\n\n---\n\n## Findings\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCR-001 | OSV.dev CVE | package-lock.json | 8421 | lefthook@1.4.2 → CVE-2024-1234 (DoS via crafted hook config) | LLM03 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCR-002 | Typosquat | package-lock.json | 1247 | `expresss` (3 s's) Levenshtein 1 vs `express` | LLM03 |\n| SCR-003 | Typosquat | package-lock.json | 2891 | `lodahs` Levenshtein 2 vs `lodash` | LLM03 |\n| SCR-004 | Typosquat | requirements.txt | 22 | `requests-mock` legitimate, `request-mock` (no s) Levenshtein 1 — manual review | LLM03 |\n| SCR-005 | Recent | package-lock.json | 5103 | `colorette@3.1.0` published 71 hours ago (<72h gate) | LLM03 |\n\n### Low\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCR-006 | Maintenance | package-lock.json | — | 18 packages with last-published > 730 days | — |\n| SCR-007 | License | requirements.txt | 12 | `chardet==3.0.4` LGPL-2.1 — verify compatibility | — |\n\n### Info\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| SCR-008 | Provenance | package-lock.json | — | 412/412 packages have npm-registry provenance | — |\n| SCR-009 | Provenance | Cargo.lock | — | All 71 crates from crates.io | — |\n| SCR-010 | Coverage | go.sum | — | No Go dependencies detected | — |\n| SCR-011 | Coverage | (docker) | — | No Dockerfile detected | — |\n| SCR-012 | Cache | OSV.dev | — | 521 packages queried, 510 cached, 11 fresh lookups | — |\n| SCR-013 | Cache | TTL | — | OSV cache TTL: 6 hours, hit-rate 97.9% | — |\n\n---\n\n## Recommendations\n\n1. **Immediate:** Bump `lefthook` to ≥1.5.0 to clear CVE-2024-1234. Run `npm install lefthook@latest`.\n2. **High:** Verify `expresss` and `lodahs` are not legitimate packages. Both look like typosquat-bait.\n3. **Medium:** Wait 24h before pinning `colorette@3.1.0` (currently <72h since publish — supply-chain attack window).\n4. **Low:** Audit LGPL-2.1 dependency `chardet==3.0.4` for license-compatibility with project license.\n\n---\n\n*Supply-chain recheck complete. 521 packages across 3 ecosystems, 13 findings.*\n",
"parsed": {
"risk_score": 22,
"riskBand": "Medium",
"grade": "B",
"verdict": "warning",
"verdict_rationale": "** 1 HIGH OSV.dev advisory on `lefthook@1.4.2` (CVE-2024-1234, denial-of-service via crafted hook config). 4 MEDIUM typosquat candidates flagged for manual review.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"ecosystems": [
{
"ecosystem": "npm",
"lockfile": "package-lock.json",
"packages": 412,
"osv_hits": 1,
"typosquats": 2
},
{
"ecosystem": "pip",
"lockfile": "requirements.txt",
"packages": 38,
"osv_hits": 0,
"typosquats": 1
},
{
"ecosystem": "cargo",
"lockfile": "Cargo.lock",
"packages": 71,
"osv_hits": 0,
"typosquats": 0
},
{
"ecosystem": "go",
"lockfile": "go.sum",
"packages": 0,
"osv_hits": 0,
"typosquats": 0
},
{
"ecosystem": "docker",
"lockfile": "(none)",
"packages": 0,
"osv_hits": 0,
"typosquats": 0
}
],
"findings": [
{
"id": "SCR-001",
"severity": "high",
"category": "OSV.dev CVE",
"file": "package-lock.json",
"line": "8421",
"description": "lefthook@1.4.2 → CVE-2024-1234 (DoS via crafted hook config)",
"owasp": "LLM03"
},
{
"id": "SCR-002",
"severity": "medium",
"category": "Typosquat",
"file": "package-lock.json",
"line": "1247",
"description": "`expresss` (3 s's) Levenshtein 1 vs `express`",
"owasp": "LLM03"
},
{
"id": "SCR-003",
"severity": "medium",
"category": "Typosquat",
"file": "package-lock.json",
"line": "2891",
"description": "`lodahs` Levenshtein 2 vs `lodash`",
"owasp": "LLM03"
},
{
"id": "SCR-004",
"severity": "medium",
"category": "Typosquat",
"file": "requirements.txt",
"line": "22",
"description": "`requests-mock` legitimate, `request-mock` (no s) Levenshtein 1 — manual review",
"owasp": "LLM03"
},
{
"id": "SCR-005",
"severity": "medium",
"category": "Recent",
"file": "package-lock.json",
"line": "5103",
"description": "`colorette@3.1.0` published 71 hours ago (<72h gate)",
"owasp": "LLM03"
},
{
"id": "SCR-006",
"severity": "low",
"category": "Maintenance",
"file": "package-lock.json",
"line": "—",
"description": "18 packages with last-published > 730 days",
"owasp": "—"
},
{
"id": "SCR-007",
"severity": "low",
"category": "License",
"file": "requirements.txt",
"line": "12",
"description": "`chardet==3.0.4` LGPL-2.1 — verify compatibility",
"owasp": "—"
},
{
"id": "SCR-008",
"severity": "info",
"category": "Provenance",
"file": "package-lock.json",
"line": "—",
"description": "412/412 packages have npm-registry provenance",
"owasp": "—"
},
{
"id": "SCR-009",
"severity": "info",
"category": "Provenance",
"file": "Cargo.lock",
"line": "—",
"description": "All 71 crates from crates.io",
"owasp": "—"
},
{
"id": "SCR-010",
"severity": "info",
"category": "Coverage",
"file": "go.sum",
"line": "—",
"description": "No Go dependencies detected",
"owasp": "—"
},
{
"id": "SCR-011",
"severity": "info",
"category": "Coverage",
"file": "(docker)",
"line": "—",
"description": "No Dockerfile detected",
"owasp": "—"
},
{
"id": "SCR-012",
"severity": "info",
"category": "Cache",
"file": "OSV.dev",
"line": "—",
"description": "521 packages queried, 510 cached, 11 fresh lookups",
"owasp": "—"
},
{
"id": "SCR-013",
"severity": "info",
"category": "Cache",
"file": "TTL",
"line": "—",
"description": "OSV cache TTL: 6 hours, hit-rate 97.9%",
"owasp": "—"
}
],
"recommendations": [
"Bump `lefthook` to ≥1.5.0 to clear CVE-2024-1234. Run `npm install lefthook@latest`.",
"Verify `expresss` and `lodahs` are not legitimate packages. Both look like typosquat-bait.",
"Wait 24h before pinning `colorette@3.1.0` (currently <72h since publish — supply-chain attack window).",
"Audit LGPL-2.1 dependency `chardet==3.0.4` for license-compatibility with project license."
],
"keyStats": [
{
"label": "TOTALT",
"value": 13
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 1,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"pre-deploy": {
"input": {
"organisation_name": "Direktoratet for digital tjenesteutvikling",
"frameworks": [
"OWASP LLM Top 10",
"EU AI Act"
],
"production_environment": "Cloud (Azure)",
"data_classification": "Fortrolig"
},
"raw_markdown": "# Pre-Deploy Security Checklist\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | pre-deploy |\n| **Target** | DFT data-platform release v3.2.0 |\n| **Date** | 2026-05-05 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | enterprise gate + production readiness |\n| **Frameworks** | OWASP LLM Top 10, EU AI Act, NSM Grunnprinsipper |\n| **Triggered by** | /security pre-deploy |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 12/100 |\n| **Risk Band** | Low |\n| **Grade** | A |\n| **Verdict** | GO-WITH-CONDITIONS |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 0 |\n| Medium | 2 |\n| Low | 3 |\n| Info | 5 |\n| **Total** | **10** |\n\n**Verdict rationale:** All gates PASS or PASS-WITH-NOTES. 2 medium conditions: pending Datatilsynet ack on DPIA addendum (expected 2026-05-08) + missing logging-aggregator wire-up. Conditional approval — deployment may proceed once both are resolved.\n\n---\n\n## Traffic Light Categories\n\n| Category | Status | Notes |\n|----------|--------|-------|\n| Identity & Access | PASS | OIDC + MFA, 89% coverage |\n| Network Isolation | PASS | Private endpoints + NSG |\n| Data Protection | PASS-WITH-NOTES | Customer-managed keys; rotation policy verified |\n| Logging & Audit | FAIL | Logging aggregator not wired (M1 finding) |\n| Compliance | PASS-WITH-NOTES | DPIA pending Datatilsynet ack (M2) |\n| Secrets Management | PASS | Key Vault + managed identity |\n| Hooks Coverage | PASS | All 9 hooks active |\n| MCP Security | PASS | 0 untrusted servers |\n| Supply Chain | PASS | 0 critical, 0 high CVEs |\n| Plugin Trust | PASS | Only first-party plugins |\n| Permission Hygiene | PASS | No wildcard Bash |\n| Memory Hygiene | PASS | CLAUDE.md scanned, no poisoning |\n| Performance | PASS | <500ms hook latency |\n\n---\n\n## Findings\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PRD-001 | Logging | infrastructure/observability.bicep | 12 | Logging aggregator export endpoint missing | — |\n| PRD-002 | Compliance | docs/DPIA-2026-04-15.md | — | Datatilsynet ack pending (submitted 2026-04-22, expected response 2026-05-08) | — |\n\n### Low\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PRD-003 | Documentation | docs/SECURITY.md | — | SLA for security-disclosure response not documented | — |\n| PRD-004 | Documentation | docs/RUNBOOK.md | — | Incident-response runbook missing rollback section | — |\n| PRD-005 | Performance | hooks/post-mcp-verify.mjs | — | P95 latency 412ms (target <500ms) — within budget but monitoring needed | — |\n\n### Info\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| PRD-006 | Coverage | (env) | — | Production env: Azure North Europe |\n| PRD-007 | Coverage | (env) | — | Data-classification: Fortrolig |\n| PRD-008 | Coverage | (compliance) | — | Frameworks: OWASP LLM, EU AI Act, NSM |\n| PRD-009 | Coverage | (gate) | — | Pre-deploy run by: ci/release.yml |\n| PRD-010 | Coverage | (history) | — | 4 prior pre-deploy runs in last 90 days, all PASS |\n\n---\n\n## Conditions to Resolve\n\n1. **PRD-001 (medium):** Wire logging aggregator before deployment. Owner: platform-ops. Blocker.\n2. **PRD-002 (medium):** Receive Datatilsynet ack OR document silent-period acceptance. Owner: privacy-officer. Blocker until 2026-05-08.\n\n---\n\n## Approvals\n\n| Role | Approver | Date | Notes |\n|------|----------|------|-------|\n| Security Lead | (pending) | — | After PRD-001 resolved |\n| Privacy Officer | (pending) | — | After PRD-002 resolved |\n| Platform Owner | A. Nilsen | 2026-05-04 | Signed off subject to conditions |\n\n---\n\n## Recommendations\n\n1. **Immediate:** Resolve PRD-001 (logging aggregator) before deploying.\n2. **High:** Confirm Datatilsynet ack OR escalate silent-period exception (PRD-002).\n3. **Medium:** Document SLA in SECURITY.md (PRD-003) post-deploy — non-blocking.\n4. **Medium:** Add rollback section to RUNBOOK.md (PRD-004) post-deploy.\n\n---\n\n*Pre-deploy complete. 13 categories, 1 FAIL pending wire-up, conditional GO.*\n",
"parsed": {
"risk_score": 12,
"riskBand": "Low",
"grade": "A",
"verdict": "go-with-conditions",
"verdict_rationale": "** All gates PASS or PASS-WITH-NOTES. 2 medium conditions: pending Datatilsynet ack on DPIA addendum (expected 2026-05-08) + missing logging-aggregator wire-up. Conditional approval — deployment may proceed once both are resolved.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"traffic_lights": [
{
"category": "Identity & Access",
"status": "PASS",
"notes": "OIDC + MFA, 89% coverage"
},
{
"category": "Network Isolation",
"status": "PASS",
"notes": "Private endpoints + NSG"
},
{
"category": "Data Protection",
"status": "PASS-WITH-NOTES",
"notes": "Customer-managed keys; rotation policy verified"
},
{
"category": "Logging & Audit",
"status": "FAIL",
"notes": "Logging aggregator not wired (M1 finding)"
},
{
"category": "Compliance",
"status": "PASS-WITH-NOTES",
"notes": "DPIA pending Datatilsynet ack (M2)"
},
{
"category": "Secrets Management",
"status": "PASS",
"notes": "Key Vault + managed identity"
},
{
"category": "Hooks Coverage",
"status": "PASS",
"notes": "All 9 hooks active"
},
{
"category": "MCP Security",
"status": "PASS",
"notes": "0 untrusted servers"
},
{
"category": "Supply Chain",
"status": "PASS",
"notes": "0 critical, 0 high CVEs"
},
{
"category": "Plugin Trust",
"status": "PASS",
"notes": "Only first-party plugins"
},
{
"category": "Permission Hygiene",
"status": "PASS",
"notes": "No wildcard Bash"
},
{
"category": "Memory Hygiene",
"status": "PASS",
"notes": "CLAUDE.md scanned, no poisoning"
},
{
"category": "Performance",
"status": "PASS",
"notes": "<500ms hook latency"
}
],
"conditions": [
"Wire logging aggregator before deployment. Owner: platform-ops. Blocker.",
"Receive Datatilsynet ack OR document silent-period acceptance. Owner: privacy-officer. Blocker until 2026-05-08."
],
"approvals": [
{
"role": "Security Lead",
"approver": "(pending)",
"date": "—",
"notes": "After PRD-001 resolved"
},
{
"role": "Privacy Officer",
"approver": "(pending)",
"date": "—",
"notes": "After PRD-002 resolved"
},
{
"role": "Platform Owner",
"approver": "A. Nilsen",
"date": "2026-05-04",
"notes": "Signed off subject to conditions"
}
],
"findings": [
{
"id": "PRD-001",
"severity": "medium",
"category": "Logging",
"file": "infrastructure/observability.bicep",
"line": "12",
"description": "Logging aggregator export endpoint missing",
"owasp": "—"
},
{
"id": "PRD-002",
"severity": "medium",
"category": "Compliance",
"file": "docs/DPIA-2026-04-15.md",
"line": "—",
"description": "Datatilsynet ack pending (submitted 2026-04-22, expected response 2026-05-08)",
"owasp": "—"
},
{
"id": "PRD-003",
"severity": "low",
"category": "Documentation",
"file": "docs/SECURITY.md",
"line": "—",
"description": "SLA for security-disclosure response not documented",
"owasp": "—"
},
{
"id": "PRD-004",
"severity": "low",
"category": "Documentation",
"file": "docs/RUNBOOK.md",
"line": "—",
"description": "Incident-response runbook missing rollback section",
"owasp": "—"
},
{
"id": "PRD-005",
"severity": "low",
"category": "Performance",
"file": "hooks/post-mcp-verify.mjs",
"line": "—",
"description": "P95 latency 412ms (target <500ms) — within budget but monitoring needed",
"owasp": "—"
},
{
"id": "PRD-006",
"severity": "info",
"category": "Coverage",
"file": "(env)",
"line": "—",
"description": "Production env: Azure North Europe",
"owasp": ""
},
{
"id": "PRD-007",
"severity": "info",
"category": "Coverage",
"file": "(env)",
"line": "—",
"description": "Data-classification: Fortrolig",
"owasp": ""
},
{
"id": "PRD-008",
"severity": "info",
"category": "Coverage",
"file": "(compliance)",
"line": "—",
"description": "Frameworks: OWASP LLM, EU AI Act, NSM",
"owasp": ""
},
{
"id": "PRD-009",
"severity": "info",
"category": "Coverage",
"file": "(gate)",
"line": "—",
"description": "Pre-deploy run by: ci/release.yml",
"owasp": ""
},
{
"id": "PRD-010",
"severity": "info",
"category": "Coverage",
"file": "(history)",
"line": "—",
"description": "4 prior pre-deploy runs in last 90 days, all PASS",
"owasp": ""
}
],
"recommendations": [
"Resolve PRD-001 (logging aggregator) before deploying.",
"Confirm Datatilsynet ack OR escalate silent-period exception (PRD-002).",
"Document SLA in SECURITY.md (PRD-003) post-deploy — non-blocking.",
"Add rollback section to RUNBOOK.md (PRD-004) post-deploy."
],
"keyStats": [
{
"label": "TOTALT",
"value": 10
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 0,
"modifier": null
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"diff": {
"input": {
"target": "~/repos/dft-marketplace",
"baseline_id": "",
"show_unchanged": true
},
"raw_markdown": "# Scan Diff Against Baseline\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | diff |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Baseline** | 2026-04-29 |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | scan + posture diff |\n| **Triggered by** | /security diff . |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Current Grade** | B |\n| **Baseline Grade** | C |\n| **Risk Score** | 28/100 |\n| **Risk Band** | Medium |\n| **Verdict** | WARNING |\n\n| Severity | New | Resolved | Unchanged |\n|----------|----:|---------:|----------:|\n| Critical | 0 | 1 | 0 |\n| High | 1 | 2 | 1 |\n| Medium | 2 | 3 | 4 |\n| Low | 0 | 1 | 2 |\n| Info | 1 | 0 | 5 |\n| **Total** | **4** | **7** | **12** |\n\n**Verdict rationale:** Net improvement (7 resolved, 4 new). Baseline had 1 CRITICAL (resolved), 2 HIGH (resolved). Grade C → B. One new HIGH on permission scope warrants review before celebrating.\n\n---\n\n## New (4)\n\n| ID | Severity | Category | File | Description | OWASP |\n|----|----------|----------|------|-------------|-------|\n| DIF-001 | high | Permissions | .claude/settings.json | New `Edit(*)` wildcard added in commit 4a8c1f | ASI04 |\n| DIF-002 | medium | Injection | commands/research-v2.md | New command introduced indirect-injection vector | LLM01 |\n| DIF-003 | medium | Supply Chain | package-lock.json | New dependency `husky@9.0.11` (no prior baseline) | LLM03 |\n| DIF-004 | info | Documentation | docs/CHANGELOG.md | Changelog gained sensitive path reference (not exploitable) | — |\n\n---\n\n## Resolved (7)\n\n| ID | Severity | Category | File | Resolution |\n|----|----------|----------|------|-----------|\n| BAS-001 | critical | Secrets | agents/data-analyst.md | API key removed, env-var reference added |\n| BAS-002 | high | Excessive Agency | agents/web-helper.md | Hook policy added blocking [Bash, Read, WebFetch] trifecta |\n| BAS-003 | high | MCP Trust | .mcp.json | airbnb-mcp removed |\n| BAS-004 | medium | Output Handling | agents/notes.md | Markdown link-title sink sanitized |\n| BAS-005 | medium | Memory | CLAUDE.md | Encoded base64 imperative removed |\n| BAS-006 | medium | Injection | commands/summarize.md | Indirect-injection wrapped in Trust-Bus |\n| BAS-007 | low | Documentation | README.md | Suspicious URL pattern in example removed |\n\n---\n\n## Unchanged (12)\n\n| ID | Severity | Category | File | Notes |\n|----|----------|----------|------|-------|\n| BAS-008 | high | Permissions | .claude/settings.json | Bash wildcard remains — pending grant-narrowing |\n| BAS-009 | medium | Permissions | agents/test-runner.md | Tool list still includes Edit |\n| BAS-010 | medium | MCP Trust | .mcp.json | Per-update drift on `postgres-readonly` (12.3% > 10%) |\n| BAS-011 | medium | Other | scripts/setup.sh | curl|sh pattern in install hint |\n| BAS-012 | medium | Other | tests/fixtures/poisoned.md | Test fixture flagged (intentional) |\n| BAS-013 | low | Documentation | docs/setup.md | Outdated security-advisory link |\n| BAS-014 | low | Documentation | LICENSE | License file present but old SPDX format |\n| BAS-015 | info | Other | .gitignore | Still missing `.env*` exclusion rule |\n| BAS-016 | info | Other | LICENSE | (info-level note) |\n| BAS-017 | info | Other | CHANGELOG.md | Format compliance note |\n| BAS-018 | info | Other | SECURITY.md | Still missing |\n| BAS-019 | info | Other | CONTRIBUTING.md | Still missing |\n\n---\n\n## Moved (0)\n\nNo findings shifted file-locations between baseline and current.\n\n---\n\n## Recommendations\n\n1. **High:** Audit DIF-001 — `Edit(*)` wildcard adds Edit-to-anywhere capability. Replace with explicit allow-list.\n2. **Medium:** Review DIF-002 (commands/research-v2.md) and DIF-003 (husky pin) before merge.\n3. **Medium:** Continue working on the 12 unchanged findings — BAS-008 (Bash wildcard) is the highest-impact remaining item.\n\n---\n\n*Diff complete. Net improvement: -3 findings (4 new, 7 resolved). Grade C → B.*\n",
"parsed": {
"risk_score": 28,
"riskBand": "Medium",
"verdict": "warning",
"verdict_rationale": "** Net improvement (7 resolved, 4 new). Baseline had 1 CRITICAL (resolved), 2 HIGH (resolved). Grade C → B. One new HIGH on permission scope warrants review before celebrating.",
"current_grade": "B",
"baseline_grade": "C",
"baseline_date": "2026-04-29",
"severity_matrix": {
"critical": {
"new": 0,
"resolved": 0,
"unchanged": 0
},
"high": {
"new": 0,
"resolved": 0,
"unchanged": 0
},
"medium": {
"new": 0,
"resolved": 0,
"unchanged": 0
},
"low": {
"new": 0,
"resolved": 0,
"unchanged": 0
},
"info": {
"new": 0,
"resolved": 0,
"unchanged": 0
}
},
"new": [
{
"id": "DIF-001",
"severity": "high",
"category": "Permissions",
"file": ".claude/settings.json",
"description": "New `Edit(*)` wildcard added in commit 4a8c1f",
"owasp": "ASI04"
},
{
"id": "DIF-002",
"severity": "medium",
"category": "Injection",
"file": "commands/research-v2.md",
"description": "New command introduced indirect-injection vector",
"owasp": "LLM01"
},
{
"id": "DIF-003",
"severity": "medium",
"category": "Supply Chain",
"file": "package-lock.json",
"description": "New dependency `husky@9.0.11` (no prior baseline)",
"owasp": "LLM03"
},
{
"id": "DIF-004",
"severity": "info",
"category": "Documentation",
"file": "docs/CHANGELOG.md",
"description": "Changelog gained sensitive path reference (not exploitable)",
"owasp": "—"
}
],
"resolved": [
{
"id": "BAS-001",
"severity": "critical",
"category": "Secrets",
"file": "agents/data-analyst.md",
"resolution": "API key removed, env-var reference added"
},
{
"id": "BAS-002",
"severity": "high",
"category": "Excessive Agency",
"file": "agents/web-helper.md",
"resolution": "Hook policy added blocking [Bash, Read, WebFetch] trifecta"
},
{
"id": "BAS-003",
"severity": "high",
"category": "MCP Trust",
"file": ".mcp.json",
"resolution": "airbnb-mcp removed"
},
{
"id": "BAS-004",
"severity": "medium",
"category": "Output Handling",
"file": "agents/notes.md",
"resolution": "Markdown link-title sink sanitized"
},
{
"id": "BAS-005",
"severity": "medium",
"category": "Memory",
"file": "CLAUDE.md",
"resolution": "Encoded base64 imperative removed"
},
{
"id": "BAS-006",
"severity": "medium",
"category": "Injection",
"file": "commands/summarize.md",
"resolution": "Indirect-injection wrapped in Trust-Bus"
},
{
"id": "BAS-007",
"severity": "low",
"category": "Documentation",
"file": "README.md",
"resolution": "Suspicious URL pattern in example removed"
}
],
"unchanged": [
{
"id": "BAS-008",
"severity": "high",
"category": "Permissions",
"file": ".claude/settings.json",
"notes": "Bash wildcard remains — pending grant-narrowing"
},
{
"id": "BAS-009",
"severity": "medium",
"category": "Permissions",
"file": "agents/test-runner.md",
"notes": "Tool list still includes Edit"
},
{
"id": "BAS-010",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"notes": "Per-update drift on `postgres-readonly` (12.3% > 10%)"
},
{
"id": "BAS-011",
"severity": "medium",
"category": "Other",
"file": "scripts/setup.sh",
"notes": "curl"
},
{
"id": "BAS-012",
"severity": "medium",
"category": "Other",
"file": "tests/fixtures/poisoned.md",
"notes": "Test fixture flagged (intentional)"
},
{
"id": "BAS-013",
"severity": "low",
"category": "Documentation",
"file": "docs/setup.md",
"notes": "Outdated security-advisory link"
},
{
"id": "BAS-014",
"severity": "low",
"category": "Documentation",
"file": "LICENSE",
"notes": "License file present but old SPDX format"
},
{
"id": "BAS-015",
"severity": "info",
"category": "Other",
"file": ".gitignore",
"notes": "Still missing `.env*` exclusion rule"
},
{
"id": "BAS-016",
"severity": "info",
"category": "Other",
"file": "LICENSE",
"notes": "(info-level note)"
},
{
"id": "BAS-017",
"severity": "info",
"category": "Other",
"file": "CHANGELOG.md",
"notes": "Format compliance note"
},
{
"id": "BAS-018",
"severity": "info",
"category": "Other",
"file": "SECURITY.md",
"notes": "Still missing"
},
{
"id": "BAS-019",
"severity": "info",
"category": "Other",
"file": "CONTRIBUTING.md",
"notes": "Still missing"
}
],
"moved": [],
"recommendations": [
"Audit DIF-001 — `Edit(*)` wildcard adds Edit-to-anywhere capability. Replace with explicit allow-list.",
"Review DIF-002 (commands/research-v2.md) and DIF-003 (husky pin) before merge.",
"Continue working on the 12 unchanged findings — BAS-008 (Bash wildcard) is the highest-impact remaining item."
],
"keyStats": [
{
"label": "NÅ-GRADE",
"value": "B"
},
{
"label": "AKSJONER",
"value": 4,
"modifier": "medium"
},
{
"label": "SKIPPED",
"value": 12
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"watch": {
"input": {
"target": "~/repos/dft-marketplace",
"interval": "6h",
"notify_on": [
"new-findings",
"severity-increase"
]
},
"raw_markdown": "# Watch — Continuous Monitoring\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | watch |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Last Run** | 2026-05-05 14:32 |\n| **Interval** | 6h |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | recurring scan diff |\n| **Triggered by** | /security watch . --interval 6h |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 31/100 |\n| **Risk Band** | Medium |\n| **Grade** | B |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 3 |\n| Low | 1 |\n| Info | 4 |\n| **Total** | **9** |\n\n**Verdict rationale:** Latest scan introduced 1 HIGH (new `Edit(*)` permission) compared to baseline 6h ago. Watch sent notify event to configured channels.\n\n---\n\n## Live Meter\n\n| Metric | Value |\n|--------|-------|\n| **Active** | yes |\n| **Runs (last 24h)** | 4 |\n| **Last delta** | +1 high, +0 medium |\n| **Next run** | 2026-05-05 20:32 |\n| **Notify channels** | email, webhook |\n\n---\n\n## Recent History\n\n| Run | Time | Grade | Risk Score | Δ vs prev |\n|-----|------|-------|-----------:|-----------|\n| Current | 2026-05-05 14:32 | B | 31 | +6 |\n| -6h | 2026-05-05 08:32 | B | 25 | -2 |\n| -12h | 2026-05-05 02:32 | B | 27 | 0 |\n| -18h | 2026-05-04 20:32 | B | 27 | -3 |\n| -24h | 2026-05-04 14:32 | B | 30 | — |\n\n---\n\n## Findings\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| WAT-001 | Permissions | .claude/settings.json | 8 | Newly-introduced `Edit(*)` wildcard (last commit: 4a8c1f, 23min ago) | ASI04 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| WAT-002 | Injection | commands/research-v2.md | 22 | New command file added | LLM01 |\n| WAT-003 | MCP Trust | .mcp.json | 28 | Per-update drift continues on `postgres-readonly` | MCP05 |\n| WAT-004 | Supply Chain | package-lock.json | 5103 | New dep `husky@9.0.11` < 72h old | LLM03 |\n\n### Low\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| WAT-005 | Documentation | docs/CHANGELOG.md | 144 | Sensitive path reference added (not exploitable) | — |\n\n### Info\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| WAT-006 | Cron | (config) | — | Cron handle: 4f8c (PID 12842) | — |\n| WAT-007 | Cron | (config) | — | Run-script: ~/.cache/llm-security/watch/run.sh | — |\n| WAT-008 | Coverage | (target) | — | Lines scanned: 18420 | — |\n| WAT-009 | Coverage | (target) | — | Files scanned: 312 | — |\n\n---\n\n## Notify Events\n\n| Time | Event | Channel | Status |\n|------|-------|---------|--------|\n| 2026-05-05 14:32 | new-finding (high) | email | sent |\n| 2026-05-05 14:32 | new-finding (high) | webhook | 200 OK |\n\n---\n\n## Recommendations\n\n1. **Immediate:** Investigate commit 4a8c1f — `Edit(*)` wildcard addition warrants reverting or scope-narrowing.\n2. **High:** Review newly-added `commands/research-v2.md` for injection-vector placement.\n3. **Medium:** Drift on `postgres-readonly` has been continuous for 4 runs — may be legitimate upstream change. Run `/security mcp-baseline-reset --target postgres-readonly` after manual verification.\n4. **Medium:** Wait 24h before pinning `husky@9.0.11` (currently <72h since publish).\n\n---\n\n*Watch active. Next run scheduled 2026-05-05 20:32 (6h interval).*\n",
"parsed": {
"risk_score": 31,
"riskBand": "Medium",
"grade": "B",
"verdict": "warning",
"verdict_rationale": "** Latest scan introduced 1 HIGH (new `Edit(*)` permission) compared to baseline 6h ago. Watch sent notify event to configured channels.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"live_meter": {
"active": "yes",
"runs_(last_24h)": "4",
"last_delta": "+1 high, +0 medium",
"next_run": "2026-05-05 20:32",
"notify_channels": "email, webhook"
},
"history": [
{
"run": "Current",
"time": "2026-05-05 14:32",
"grade": "B",
"risk_score": 31,
"delta": "+6"
},
{
"run": "-6h",
"time": "2026-05-05 08:32",
"grade": "B",
"risk_score": 25,
"delta": "-2"
},
{
"run": "-12h",
"time": "2026-05-05 02:32",
"grade": "B",
"risk_score": 27,
"delta": "0"
},
{
"run": "-18h",
"time": "2026-05-04 20:32",
"grade": "B",
"risk_score": 27,
"delta": "-3"
},
{
"run": "-24h",
"time": "2026-05-04 14:32",
"grade": "B",
"risk_score": 30,
"delta": "—"
}
],
"notify_events": [
{
"time": "2026-05-05 14:32",
"event": "new-finding (high)",
"channel": "email",
"status": "sent"
},
{
"time": "2026-05-05 14:32",
"event": "new-finding (high)",
"channel": "webhook",
"status": "200 OK"
}
],
"findings": [
{
"id": "WAT-001",
"severity": "high",
"category": "Permissions",
"file": ".claude/settings.json",
"line": "8",
"description": "Newly-introduced `Edit(*)` wildcard (last commit: 4a8c1f, 23min ago)",
"owasp": "ASI04"
},
{
"id": "WAT-002",
"severity": "medium",
"category": "Injection",
"file": "commands/research-v2.md",
"line": "22",
"description": "New command file added",
"owasp": "LLM01"
},
{
"id": "WAT-003",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"line": "28",
"description": "Per-update drift continues on `postgres-readonly`",
"owasp": "MCP05"
},
{
"id": "WAT-004",
"severity": "medium",
"category": "Supply Chain",
"file": "package-lock.json",
"line": "5103",
"description": "New dep `husky@9.0.11` < 72h old",
"owasp": "LLM03"
},
{
"id": "WAT-005",
"severity": "low",
"category": "Documentation",
"file": "docs/CHANGELOG.md",
"line": "144",
"description": "Sensitive path reference added (not exploitable)",
"owasp": "—"
},
{
"id": "WAT-006",
"severity": "info",
"category": "Cron",
"file": "(config)",
"line": "—",
"description": "Cron handle: 4f8c (PID 12842)",
"owasp": "—"
},
{
"id": "WAT-007",
"severity": "info",
"category": "Cron",
"file": "(config)",
"line": "—",
"description": "Run-script: ~/.cache/llm-security/watch/run.sh",
"owasp": "—"
},
{
"id": "WAT-008",
"severity": "info",
"category": "Coverage",
"file": "(target)",
"line": "—",
"description": "Lines scanned: 18420",
"owasp": "—"
},
{
"id": "WAT-009",
"severity": "info",
"category": "Coverage",
"file": "(target)",
"line": "—",
"description": "Files scanned: 312",
"owasp": "—"
}
],
"recommendations": [
"Investigate commit 4a8c1f — `Edit(*)` wildcard addition warrants reverting or scope-narrowing.",
"Review newly-added `commands/research-v2.md` for injection-vector placement.",
"Drift on `postgres-readonly` has been continuous for 4 runs — may be legitimate upstream change. Run `/security mcp-baseline-reset --target postgres-readonly` after manual verification.",
"Wait 24h before pinning `husky@9.0.11` (currently <72h since publish)."
],
"interval": "6h",
"last_run": "2026-05-05 14:32",
"keyStats": [
{
"label": "TOTALT",
"value": 9
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 1,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"registry": {
"input": {
"mode": "scan",
"query": "",
"target": "~/.claude/skills"
},
"raw_markdown": "# Skill Signature Registry\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | registry |\n| **Target** | ~/.claude/skills (local registry) |\n| **Date** | 2026-05-05 |\n| **Mode** | scan |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | skill-signature fingerprint registry |\n| **Triggered by** | /security registry scan |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 18/100 |\n| **Risk Band** | Medium |\n| **Grade** | B |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 0 |\n| High | 1 |\n| Medium | 2 |\n| Low | 2 |\n| Info | 5 |\n| **Total** | **10** |\n\n**Verdict rationale:** 1 HIGH on a known-malicious skill fingerprint match (`malicious-pdf-helper@1.0.0`). 2 MEDIUM on signature drift for previously-trusted skills.\n\n---\n\n## Registry Stats\n\n| Metric | Value |\n|--------|------:|\n| **Skills tracked** | 87 |\n| **Known-good fingerprints** | 79 |\n| **Known-bad fingerprints** | 4 |\n| **Unknown fingerprints** | 4 |\n| **Drift events (30d)** | 7 |\n| **Registry file** | reports/skill-registry.json |\n\n---\n\n## Signature Table\n\n| Skill | Source | Fingerprint (SHA-256, 8-hex) | Status | First seen |\n|-------|--------|------------------------------|--------|-----------|\n| pdf-helper | builtin | a8f3e21d | known-good | 2026-01-12 |\n| story | user | 4c2b89f0 | known-good | 2026-02-08 |\n| malicious-pdf-helper | npm | 7e91d3a4 | KNOWN-BAD | 2026-04-22 |\n| story-v2 | user | 9f1c2e8b | DRIFT (was 4c2b89f0) | 2026-05-04 |\n| audit-helper | community | b3a7f29c | DRIFT (was c814e7a1) | 2026-05-03 |\n| pptx | builtin | d7e4a1f3 | known-good | 2026-01-12 |\n| capability-auditor | community | e2f9b483 | unknown (new) | 2026-05-05 |\n| persona-creator | builtin | 1a4c8e07 | known-good | 2026-01-12 |\n\n---\n\n## Findings\n\n### High\n\n| ID | Category | Skill | File | Description | OWASP |\n|----|----------|-------|------|-------------|-------|\n| REG-001 | Known-bad | malicious-pdf-helper | ~/.claude/skills/malicious-pdf-helper/SKILL.md | Fingerprint matches 2026-04-22 advisory (data exfiltration via PDF metadata) | LLM05 |\n\n### Medium\n\n| ID | Category | Skill | File | Description | OWASP |\n|----|----------|-------|------|-------------|-------|\n| REG-002 | Drift | story-v2 | ~/.claude/skills/story-v2/SKILL.md | Fingerprint changed since registry — verify legitimacy | LLM05 |\n| REG-003 | Drift | audit-helper | ~/.claude/skills/audit-helper/SKILL.md | Fingerprint changed since registry — verify legitimacy | LLM05 |\n\n### Low\n\n| ID | Category | Skill | File | Description | OWASP |\n|----|----------|-------|------|-------------|-------|\n| REG-004 | Unknown | capability-auditor | ~/.claude/skills/capability-auditor/SKILL.md | New community skill, no prior fingerprint — recommend manual review | — |\n| REG-005 | Stale | unused-skill | ~/.claude/skills/unused-skill/SKILL.md | No invocations in 90 days — candidate for removal | — |\n\n### Info\n\n| ID | Category | Skill | File | Description | OWASP |\n|----|----------|-------|------|-------------|-------|\n| REG-006 | Coverage | (registry) | reports/skill-registry.json | 87 skills tracked across 4 sources (builtin/user/community/npm) | — |\n| REG-007 | Coverage | (cache) | ~/.cache/llm-security/registry/ | Cache size: 412 KB | — |\n| REG-008 | Coverage | (cache) | (TTL) | Registry cache TTL: 24h | — |\n| REG-009 | Coverage | (cache) | (next sync) | 17h until next registry sync | — |\n| REG-010 | History | (audit) | reports/registry-audit.jsonl | 7 drift events in last 30 days, all on community skills | — |\n\n---\n\n## Recommendations\n\n1. **Immediate:** Disable or remove `malicious-pdf-helper` skill. Cross-reference with `~/.claude/skills/` and check if any agents reference it.\n2. **High:** Investigate signature drift on `story-v2` and `audit-helper`. Compare against last-known-good fingerprint and re-register if legitimate update.\n3. **Medium:** Manually review `capability-auditor` (new, unknown). Run `/security scan ~/.claude/skills/capability-auditor` for full analysis.\n4. **Low:** Audit unused skills — `unused-skill` has had no invocations in 90d.\n\n---\n\n*Registry scan complete. 87 skills, 1 known-bad, 2 drift events.*\n",
"parsed": {
"risk_score": 18,
"riskBand": "Medium",
"grade": "B",
"verdict": "warning",
"verdict_rationale": "** 1 HIGH on a known-malicious skill fingerprint match (`malicious-pdf-helper@1.0.0`). 2 MEDIUM on signature drift for previously-trusted skills.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"stats": {
"skills_tracked": "87",
"known-good_fingerprints": "79",
"known-bad_fingerprints": "4",
"unknown_fingerprints": "4",
"drift_events_(30d)": "7",
"registry_file": "reports/skill-registry.json"
},
"signatures": [
{
"skill": "pdf-helper",
"source": "builtin",
"fingerprint": "a8f3e21d",
"status": "KNOWN-GOOD",
"first_seen": "2026-01-12"
},
{
"skill": "story",
"source": "user",
"fingerprint": "4c2b89f0",
"status": "KNOWN-GOOD",
"first_seen": "2026-02-08"
},
{
"skill": "malicious-pdf-helper",
"source": "npm",
"fingerprint": "7e91d3a4",
"status": "KNOWN-BAD",
"first_seen": "2026-04-22"
},
{
"skill": "story-v2",
"source": "user",
"fingerprint": "9f1c2e8b",
"status": "DRIFT (WAS 4C2B89F0)",
"first_seen": "2026-05-04"
},
{
"skill": "audit-helper",
"source": "community",
"fingerprint": "b3a7f29c",
"status": "DRIFT (WAS C814E7A1)",
"first_seen": "2026-05-03"
},
{
"skill": "pptx",
"source": "builtin",
"fingerprint": "d7e4a1f3",
"status": "KNOWN-GOOD",
"first_seen": "2026-01-12"
},
{
"skill": "capability-auditor",
"source": "community",
"fingerprint": "e2f9b483",
"status": "UNKNOWN (NEW)",
"first_seen": "2026-05-05"
},
{
"skill": "persona-creator",
"source": "builtin",
"fingerprint": "1a4c8e07",
"status": "KNOWN-GOOD",
"first_seen": "2026-01-12"
}
],
"findings": [
{
"id": "REG-001",
"severity": "high",
"category": "Known-bad",
"file": "~/.claude/skills/malicious-pdf-helper/SKILL.md",
"line": "",
"description": "Fingerprint matches 2026-04-22 advisory (data exfiltration via PDF metadata)",
"owasp": "LLM05",
"skill": "Known-bad"
},
{
"id": "REG-002",
"severity": "medium",
"category": "Drift",
"file": "~/.claude/skills/story-v2/SKILL.md",
"line": "",
"description": "Fingerprint changed since registry — verify legitimacy",
"owasp": "LLM05",
"skill": "Drift"
},
{
"id": "REG-003",
"severity": "medium",
"category": "Drift",
"file": "~/.claude/skills/audit-helper/SKILL.md",
"line": "",
"description": "Fingerprint changed since registry — verify legitimacy",
"owasp": "LLM05",
"skill": "Drift"
},
{
"id": "REG-004",
"severity": "low",
"category": "Unknown",
"file": "~/.claude/skills/capability-auditor/SKILL.md",
"line": "",
"description": "New community skill, no prior fingerprint — recommend manual review",
"owasp": "—",
"skill": "Unknown"
},
{
"id": "REG-005",
"severity": "low",
"category": "Stale",
"file": "~/.claude/skills/unused-skill/SKILL.md",
"line": "",
"description": "No invocations in 90 days — candidate for removal",
"owasp": "—",
"skill": "Stale"
},
{
"id": "REG-006",
"severity": "info",
"category": "Coverage",
"file": "reports/skill-registry.json",
"line": "",
"description": "87 skills tracked across 4 sources (builtin/user/community/npm)",
"owasp": "—",
"skill": "Coverage"
},
{
"id": "REG-007",
"severity": "info",
"category": "Coverage",
"file": "~/.cache/llm-security/registry/",
"line": "",
"description": "Cache size: 412 KB",
"owasp": "—",
"skill": "Coverage"
},
{
"id": "REG-008",
"severity": "info",
"category": "Coverage",
"file": "(TTL)",
"line": "",
"description": "Registry cache TTL: 24h",
"owasp": "—",
"skill": "Coverage"
},
{
"id": "REG-009",
"severity": "info",
"category": "Coverage",
"file": "(next sync)",
"line": "",
"description": "17h until next registry sync",
"owasp": "—",
"skill": "Coverage"
},
{
"id": "REG-010",
"severity": "info",
"category": "History",
"file": "reports/registry-audit.jsonl",
"line": "",
"description": "7 drift events in last 30 days, all on community skills",
"owasp": "—",
"skill": "History"
}
],
"recommendations": [
"Disable or remove `malicious-pdf-helper` skill. Cross-reference with `~/.claude/skills/` and check if any agents reference it.",
"Investigate signature drift on `story-v2` and `audit-helper`. Compare against last-known-good fingerprint and re-register if legitimate update.",
"Manually review `capability-auditor` (new, unknown). Run `/security scan ~/.claude/skills/capability-auditor` for full analysis.",
"Audit unused skills — `unused-skill` has had no invocations in 90d."
],
"keyStats": [
{
"label": "TOTALT",
"value": 10
},
{
"label": "KRITISK",
"value": 0,
"modifier": null
},
{
"label": "HØY",
"value": 1,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"clean": {
"input": {
"target": "~/repos/dft-marketplace",
"auto_apply": false,
"dry_run": true,
"interactive": true
},
"raw_markdown": "# Clean — Auto + Semi-Auto + Manual Remediation\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | clean |\n| **Target** | ~/repos/dft-marketplace |\n| **Date** | 2026-05-05 |\n| **Mode** | dry-run |\n| **Version** | llm-security v7.4.0 |\n| **Scope** | scan + remediation buckets |\n| **Triggered by** | /security clean . --dry-run |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 45/100 |\n| **Risk Band** | High |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 1 |\n| High | 3 |\n| Medium | 4 |\n| Low | 2 |\n| Info | 3 |\n| **Total** | **13** |\n\n**Verdict rationale:** 13 findings classified by remediation tier. 4 auto-fixable, 5 semi-auto (require user confirmation), 3 manual (architecture-level), 1 suppressed (waiver registered).\n\n---\n\n## Remediation Summary\n\n| Bucket | Count | Action |\n|--------|------:|--------|\n| Auto | 4 | Apply deterministic fixes (no user input) |\n| Semi-auto | 5 | Generate proposals, confirm with user |\n| Manual | 3 | Architecture-level — human decision required |\n| Suppressed | 1 | Waiver registered in `.llm-security-ignore` |\n| **Total** | **13** | |\n\n---\n\n## Findings\n\n### Critical\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| CLN-001 | Secrets | agents/data-analyst.md | 47 | Hardcoded API key | LLM02 |\n\n### High\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| CLN-002 | Excessive Agency | agents/web-helper.md | 3 | Lethal trifecta tool combination | ASI01 |\n| CLN-003 | Permissions | .claude/settings.json | 5 | Wildcard `Bash(*)` permission | ASI04 |\n| CLN-004 | Injection | commands/research.md | 22 | Indirect-injection vector | LLM01 |\n\n### Medium\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| CLN-005 | MCP Trust | .mcp.json | 12 | Hidden imperative in MCP description | MCP05 |\n| CLN-006 | Documentation | LICENSE | — | License file missing | — |\n| CLN-007 | Documentation | SECURITY.md | — | Disclosure policy missing | — |\n| CLN-008 | Output Handling | agents/notes.md | 89 | Markdown link-title injection sink | LLM01 |\n\n### Low\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| CLN-009 | Documentation | README.md | 88 | Suspicious URL in example | — |\n| CLN-010 | Documentation | CHANGELOG.md | — | Missing changelog file | — |\n\n### Info\n\n| ID | Category | File | Line | Description | OWASP |\n|----|----------|------|------|-------------|-------|\n| CLN-011 | Documentation | CONTRIBUTING.md | — | Missing contributing guidelines | — |\n| CLN-012 | Documentation | .gitignore | — | Missing `.env*` exclusion | — |\n| CLN-013 | Documentation | LICENSE | — | License header in source files | — |\n\n---\n\n## Auto\n\n| ID | Action | Description |\n|----|--------|-------------|\n| CLN-001 | replace-with-env-var | Replace hardcoded `sk-prod-...` with `${API_KEY}`, log replacement to .llm-security-audit.jsonl |\n| CLN-006 | create-file | Create `LICENSE` file (MIT, default) |\n| CLN-012 | append-line | Append `.env*` to `.gitignore` |\n| CLN-013 | add-license-header | Add MIT license header to top of source files |\n\n---\n\n## Semi-auto\n\n| ID | Action | Description |\n|----|--------|-------------|\n| CLN-003 | propose-allowlist | Propose explicit Bash allow-list based on actual usage patterns |\n| CLN-004 | propose-trust-bus | Propose Trust-Bus wrapper around indirect-injection vector |\n| CLN-005 | propose-rewrite | Propose rewritten MCP description without imperative pattern |\n| CLN-007 | scaffold-template | Generate SECURITY.md template; user confirms ownership/SLA terms |\n| CLN-008 | propose-sanitizer | Propose sanitizer for Markdown link-title sink |\n\n---\n\n## Manual\n\n| ID | Action | Description |\n|----|--------|-------------|\n| CLN-002 | architectural-review | Lethal trifecta requires architecture-level decision: split agent OR add hook policy |\n| CLN-009 | manual-edit | Suspicious URL in README example — requires editorial judgment |\n| CLN-010 | manual-write | CHANGELOG.md content requires reviewing git history |\n\n---\n\n## Suppressed\n\n| ID | Reason | Waiver |\n|----|--------|--------|\n| CLN-011 | Repo policy: solo project, no external contributions | `.llm-security-ignore` rule `category:documentation/contributing` |\n\n---\n\n## Recommendations\n\n1. **Immediate:** Run with `--apply` to execute the 4 auto-fixes.\n2. **High:** Walk through 5 semi-auto proposals interactively (`--interactive`).\n3. **Medium:** Schedule architecture review for the 3 manual items (CLN-002, CLN-009, CLN-010).\n4. **Low:** Review the suppressed item (CLN-011) annually to confirm policy still applies.\n\n---\n\n*Clean dry-run complete. 13 findings: 4 auto, 5 semi-auto, 3 manual, 1 suppressed.*\n",
"parsed": {
"risk_score": 45,
"riskBand": "High",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** 13 findings classified by remediation tier. 4 auto-fixable, 5 semi-auto (require user confirmation), 3 manual (architecture-level), 1 suppressed (waiver registered).",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"summary": {
"auto": {
"count": 4,
"action": "Apply deterministic fixes (no user input)"
},
"semi_auto": {
"count": 5,
"action": "Generate proposals, confirm with user"
},
"manual": {
"count": 3,
"action": "Architecture-level — human decision required"
},
"suppressed": {
"count": 1,
"action": "Waiver registered in `.llm-security-ignore`"
}
},
"buckets": {
"auto": [
{
"id": "CLN-001",
"action": "replace-with-env-var",
"description": "Replace hardcoded `sk-prod-...` with `${API_KEY}`, log replacement to .llm-security-audit.jsonl"
},
{
"id": "CLN-006",
"action": "create-file",
"description": "Create `LICENSE` file (MIT, default)"
},
{
"id": "CLN-012",
"action": "append-line",
"description": "Append `.env*` to `.gitignore`"
},
{
"id": "CLN-013",
"action": "add-license-header",
"description": "Add MIT license header to top of source files"
}
],
"semi-auto": [
{
"id": "CLN-003",
"action": "propose-allowlist",
"description": "Propose explicit Bash allow-list based on actual usage patterns"
},
{
"id": "CLN-004",
"action": "propose-trust-bus",
"description": "Propose Trust-Bus wrapper around indirect-injection vector"
},
{
"id": "CLN-005",
"action": "propose-rewrite",
"description": "Propose rewritten MCP description without imperative pattern"
},
{
"id": "CLN-007",
"action": "scaffold-template",
"description": "Generate SECURITY.md template; user confirms ownership/SLA terms"
},
{
"id": "CLN-008",
"action": "propose-sanitizer",
"description": "Propose sanitizer for Markdown link-title sink"
}
],
"manual": [
{
"id": "CLN-002",
"action": "architectural-review",
"description": "Lethal trifecta requires architecture-level decision: split agent OR add hook policy"
},
{
"id": "CLN-009",
"action": "manual-edit",
"description": "Suspicious URL in README example — requires editorial judgment"
},
{
"id": "CLN-010",
"action": "manual-write",
"description": "CHANGELOG.md content requires reviewing git history"
}
],
"suppressed": [
{
"id": "CLN-011",
"action": "Repo policy: solo project, no external contributions",
"description": "`.llm-security-ignore` rule `category:documentation/contributing`"
}
]
},
"findings": [
{
"id": "CLN-001",
"severity": "critical",
"category": "Secrets",
"file": "agents/data-analyst.md",
"line": "47",
"description": "Hardcoded API key",
"owasp": "LLM02"
},
{
"id": "CLN-002",
"severity": "high",
"category": "Excessive Agency",
"file": "agents/web-helper.md",
"line": "3",
"description": "Lethal trifecta tool combination",
"owasp": "ASI01"
},
{
"id": "CLN-003",
"severity": "high",
"category": "Permissions",
"file": ".claude/settings.json",
"line": "5",
"description": "Wildcard `Bash(*)` permission",
"owasp": "ASI04"
},
{
"id": "CLN-004",
"severity": "high",
"category": "Injection",
"file": "commands/research.md",
"line": "22",
"description": "Indirect-injection vector",
"owasp": "LLM01"
},
{
"id": "CLN-005",
"severity": "medium",
"category": "MCP Trust",
"file": ".mcp.json",
"line": "12",
"description": "Hidden imperative in MCP description",
"owasp": "MCP05"
},
{
"id": "CLN-006",
"severity": "medium",
"category": "Documentation",
"file": "LICENSE",
"line": "—",
"description": "License file missing",
"owasp": "—"
},
{
"id": "CLN-007",
"severity": "medium",
"category": "Documentation",
"file": "SECURITY.md",
"line": "—",
"description": "Disclosure policy missing",
"owasp": "—"
},
{
"id": "CLN-008",
"severity": "medium",
"category": "Output Handling",
"file": "agents/notes.md",
"line": "89",
"description": "Markdown link-title injection sink",
"owasp": "LLM01"
},
{
"id": "CLN-009",
"severity": "low",
"category": "Documentation",
"file": "README.md",
"line": "88",
"description": "Suspicious URL in example",
"owasp": "—"
},
{
"id": "CLN-010",
"severity": "low",
"category": "Documentation",
"file": "CHANGELOG.md",
"line": "—",
"description": "Missing changelog file",
"owasp": "—"
},
{
"id": "CLN-011",
"severity": "info",
"category": "Documentation",
"file": "CONTRIBUTING.md",
"line": "—",
"description": "Missing contributing guidelines",
"owasp": "—"
},
{
"id": "CLN-012",
"severity": "info",
"category": "Documentation",
"file": ".gitignore",
"line": "—",
"description": "Missing `.env*` exclusion",
"owasp": "—"
},
{
"id": "CLN-013",
"severity": "info",
"category": "Documentation",
"file": "LICENSE",
"line": "—",
"description": "License header in source files",
"owasp": "—"
}
],
"recommendations": [
"Run with `--apply` to execute the 4 auto-fixes.",
"Walk through 5 semi-auto proposals interactively (`--interactive`).",
"Schedule architecture review for the 3 manual items (CLN-002, CLN-009, CLN-010).",
"Review the suppressed item (CLN-011) annually to confirm policy still applies."
],
"mode": "dry-run",
"keyStats": [
{
"label": "AUTO",
"value": 4,
"modifier": "low"
},
{
"label": "SEMI-AUTO",
"value": 5,
"modifier": "medium"
},
{
"label": "MANUAL",
"value": 3,
"modifier": "high"
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
},
"threat-model": {
"input": {
"organisation_name": "Direktoratet for digital tjenesteutvikling",
"system_name": "rag-platform v3.2.0",
"system_description": "Multi-tenant RAG-system for kommunal rådgivning",
"framework": "STRIDE + MAESTRO",
"components": "Azure AI Search\nAzure OpenAI\nApp Service"
},
"raw_markdown": "# Threat Model — STRIDE + MAESTRO\n\n---\n\n## Header\n\n| Field | Value |\n|-------|-------|\n| **Report type** | threat-model |\n| **Target** | DFT data-platform RAG-system |\n| **System** | rag-platform v3.2.0 |\n| **Date** | 2026-05-05 |\n| **Framework** | STRIDE + MAESTRO |\n| **Version** | llm-security v7.4.0 |\n| **Triggered by** | /security threat-model |\n\n---\n\n## Risk Dashboard\n\n| Metric | Value |\n|--------|-------|\n| **Risk Score** | 52/100 |\n| **Risk Band** | High |\n| **Grade** | C |\n| **Verdict** | WARNING |\n\n| Severity | Count |\n|----------|------:|\n| Critical | 1 |\n| High | 3 |\n| Medium | 4 |\n| Low | 2 |\n| Info | 0 |\n| **Total** | **10** |\n\n**Verdict rationale:** 1 CRITICAL on token-theft via cross-tenant context bleed (M5/MAESTRO authorization). 3 HIGH on prompt-injection chains and source-document tampering. Threat model produced; mitigations pending architectural sign-off.\n\n---\n\n## Risikomatrise (5×5)\n\n| Trussel | Sannsynlighet | Konsekvens | Score |\n|---------|--------------:|-----------:|------:|\n| TM-001 — Cross-tenant context bleed via index sharing | 4 | 5 | 20 |\n| TM-002 — Prompt injection via source documents | 4 | 4 | 16 |\n| TM-003 — Source document tampering (pre-ingest) | 3 | 4 | 12 |\n| TM-004 — Embedding inversion attack | 2 | 5 | 10 |\n| TM-005 — RAG output exfil via tool call | 3 | 3 | 9 |\n| TM-006 — DOS via expensive query patterns | 4 | 2 | 8 |\n| TM-007 — Authorization bypass on retrieval | 2 | 4 | 8 |\n| TM-008 — Logging gap for prompt history | 3 | 2 | 6 |\n| TM-009 — Side-channel via response timing | 2 | 3 | 6 |\n| TM-010 — Stale embeddings post-rotation | 2 | 2 | 4 |\n\n---\n\n## Trusler\n\n| ID | Beskrivelse | Severity | Mitigation |\n|----|-------------|----------|-----------|\n| TM-001 | Cross-tenant context bleed via index sharing — single Azure AI Search index across all tenants | critical | Tenant-isolated indexes OR row-level security with tenant_id filter |\n| TM-002 | Prompt injection via source documents — adversarial PDF in corpus | high | Trust-Bus wrapper + Constrained Markdown parser + pre-ingest scanning |\n| TM-003 | Source document tampering pre-ingest — supply chain on doc pipeline | high | Signed manifests + SHA-256 verification at ingest |\n| TM-004 | Embedding inversion attack — recover source text from embeddings | medium | Use private embedding model OR add noise to stored embeddings |\n| TM-005 | RAG output exfil via tool call (Bash, WebFetch chained from RAG output) | high | Hook-level data-flow tracking (post-session-guard.mjs trifecta) |\n| TM-006 | DOS via expensive query patterns | medium | Query budget + per-tenant rate limit |\n| TM-007 | Authorization bypass on retrieval | medium | Validate tenant_id from auth claim, not request payload |\n| TM-008 | Logging gap for prompt history | medium | Append-only audit log, retain 90d |\n| TM-009 | Side-channel via response timing | low | Constant-time response shaping for sensitive paths |\n| TM-010 | Stale embeddings post-rotation | low | Embedding version tag + rotation playbook |\n\n---\n\n## STRIDE Coverage\n\n| Category | Count | Notes |\n|----------|------:|-------|\n| Spoofing | 1 | TM-007 |\n| Tampering | 2 | TM-003, TM-010 |\n| Repudiation | 1 | TM-008 |\n| Information Disclosure | 3 | TM-001, TM-004, TM-009 |\n| Denial of Service | 1 | TM-006 |\n| Elevation of Privilege | 2 | TM-002, TM-005 |\n\n---\n\n## MAESTRO Coverage\n\n| Layer | Count | Notes |\n|-------|------:|-------|\n| L1 Foundation Models | 0 | Out of scope for this assessment |\n| L2 Data Operations | 4 | TM-001, TM-003, TM-004, TM-010 |\n| L3 Agentic Frameworks | 0 | RAG only, no agents in this layer |\n| L4 Deployment & Infra | 1 | TM-006 |\n| L5 Evaluation & Observability | 1 | TM-008 |\n| L6 Security & Compliance | 1 | TM-009 |\n| L7 Agent Ecosystem | 3 | TM-002, TM-005, TM-007 |\n\n---\n\n## Mitigation Roadmap\n\n| Priority | Trussel | Mitigation | Owner | ETA |\n|----------|---------|-----------|-------|-----|\n| P0 | TM-001 | Tenant-isolated indexes | platform-eng | 2026-05-15 |\n| P0 | TM-002 | Trust-Bus + Constrained Markdown | ai-platform | 2026-05-22 |\n| P1 | TM-003 | Signed manifests + ingest verification | data-eng | 2026-05-29 |\n| P1 | TM-005 | Hook-level data-flow tracking | security-eng | 2026-05-22 |\n| P2 | TM-006, TM-007, TM-008 | Rate limit + auth + audit log | platform-eng | 2026-06-15 |\n| P3 | TM-004, TM-009, TM-010 | Embedding hardening | research | 2026-Q3 |\n\n---\n\n## Recommendations\n\n1. **Immediate (P0):** Tenant-isolated indexes — TM-001 is THE critical risk for this multi-tenant RAG.\n2. **Immediate (P0):** Trust-Bus wrapper and Constrained Markdown parser — TM-002 closes the highest-volume injection vector.\n3. **High (P1):** Signed-manifest pipeline (TM-003) and hook-level data-flow tracking (TM-005).\n4. **Medium (P2):** Rate limit + auth fix + audit log — bundled together for one platform-eng sprint.\n\n---\n\n*Threat model complete. 10 threats across STRIDE + MAESTRO frameworks. 2 P0, 2 P1.*\n",
"parsed": {
"risk_score": 52,
"riskBand": "High",
"grade": "C",
"verdict": "warning",
"verdict_rationale": "** 1 CRITICAL on token-theft via cross-tenant context bleed (M5/MAESTRO authorization). 3 HIGH on prompt-injection chains and source-document tampering. Threat model produced; mitigations pending architectural sign-off.",
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"info": 0,
"total": 0
},
"matrix_cells": [
{
"label": "TM-001 — Cross-tenant context bleed via index sharing",
"prob": 4,
"cons": 5,
"score": 20
},
{
"label": "TM-002 — Prompt injection via source documents",
"prob": 4,
"cons": 4,
"score": 16
},
{
"label": "TM-003 — Source document tampering (pre-ingest)",
"prob": 3,
"cons": 4,
"score": 12
},
{
"label": "TM-004 — Embedding inversion attack",
"prob": 2,
"cons": 5,
"score": 10
},
{
"label": "TM-005 — RAG output exfil via tool call",
"prob": 3,
"cons": 3,
"score": 9
},
{
"label": "TM-006 — DOS via expensive query patterns",
"prob": 4,
"cons": 2,
"score": 8
},
{
"label": "TM-007 — Authorization bypass on retrieval",
"prob": 2,
"cons": 4,
"score": 8
},
{
"label": "TM-008 — Logging gap for prompt history",
"prob": 3,
"cons": 2,
"score": 6
},
{
"label": "TM-009 — Side-channel via response timing",
"prob": 2,
"cons": 3,
"score": 6
},
{
"label": "TM-010 — Stale embeddings post-rotation",
"prob": 2,
"cons": 2,
"score": 4
}
],
"threats": [
{
"id": "TM-001",
"description": "Cross-tenant context bleed via index sharing — single Azure AI Search index across all tenants",
"severity": "critical",
"mitigation": "Tenant-isolated indexes OR row-level security with tenant_id filter"
},
{
"id": "TM-002",
"description": "Prompt injection via source documents — adversarial PDF in corpus",
"severity": "high",
"mitigation": "Trust-Bus wrapper + Constrained Markdown parser + pre-ingest scanning"
},
{
"id": "TM-003",
"description": "Source document tampering pre-ingest — supply chain on doc pipeline",
"severity": "high",
"mitigation": "Signed manifests + SHA-256 verification at ingest"
},
{
"id": "TM-004",
"description": "Embedding inversion attack — recover source text from embeddings",
"severity": "medium",
"mitigation": "Use private embedding model OR add noise to stored embeddings"
},
{
"id": "TM-005",
"description": "RAG output exfil via tool call (Bash, WebFetch chained from RAG output)",
"severity": "high",
"mitigation": "Hook-level data-flow tracking (post-session-guard.mjs trifecta)"
},
{
"id": "TM-006",
"description": "DOS via expensive query patterns",
"severity": "medium",
"mitigation": "Query budget + per-tenant rate limit"
},
{
"id": "TM-007",
"description": "Authorization bypass on retrieval",
"severity": "medium",
"mitigation": "Validate tenant_id from auth claim, not request payload"
},
{
"id": "TM-008",
"description": "Logging gap for prompt history",
"severity": "medium",
"mitigation": "Append-only audit log, retain 90d"
},
{
"id": "TM-009",
"description": "Side-channel via response timing",
"severity": "low",
"mitigation": "Constant-time response shaping for sensitive paths"
},
{
"id": "TM-010",
"description": "Stale embeddings post-rotation",
"severity": "low",
"mitigation": "Embedding version tag + rotation playbook"
}
],
"stride": [
{
"category": "Spoofing",
"count": 1,
"notes": "TM-007"
},
{
"category": "Tampering",
"count": 2,
"notes": "TM-003, TM-010"
},
{
"category": "Repudiation",
"count": 1,
"notes": "TM-008"
},
{
"category": "Information Disclosure",
"count": 3,
"notes": "TM-001, TM-004, TM-009"
},
{
"category": "Denial of Service",
"count": 1,
"notes": "TM-006"
},
{
"category": "Elevation of Privilege",
"count": 2,
"notes": "TM-002, TM-005"
}
],
"maestro": [
{
"layer": "L1 Foundation Models",
"count": 0,
"notes": "Out of scope for this assessment"
},
{
"layer": "L2 Data Operations",
"count": 4,
"notes": "TM-001, TM-003, TM-004, TM-010"
},
{
"layer": "L3 Agentic Frameworks",
"count": 0,
"notes": "RAG only, no agents in this layer"
},
{
"layer": "L4 Deployment & Infra",
"count": 1,
"notes": "TM-006"
},
{
"layer": "L5 Evaluation & Observability",
"count": 1,
"notes": "TM-008"
},
{
"layer": "L6 Security & Compliance",
"count": 1,
"notes": "TM-009"
},
{
"layer": "L7 Agent Ecosystem",
"count": 3,
"notes": "TM-002, TM-005, TM-007"
}
],
"roadmap": [
{
"priority": "P0",
"threat_id": "TM-001",
"mitigation": "Tenant-isolated indexes",
"owner": "platform-eng",
"eta": "2026-05-15"
},
{
"priority": "P0",
"threat_id": "TM-002",
"mitigation": "Trust-Bus + Constrained Markdown",
"owner": "ai-platform",
"eta": "2026-05-22"
},
{
"priority": "P1",
"threat_id": "TM-003",
"mitigation": "Signed manifests + ingest verification",
"owner": "data-eng",
"eta": "2026-05-29"
},
{
"priority": "P1",
"threat_id": "TM-005",
"mitigation": "Hook-level data-flow tracking",
"owner": "security-eng",
"eta": "2026-05-22"
},
{
"priority": "P2",
"threat_id": "TM-006, TM-007, TM-008",
"mitigation": "Rate limit + auth + audit log",
"owner": "platform-eng",
"eta": "2026-06-15"
},
{
"priority": "P3",
"threat_id": "TM-004, TM-009, TM-010",
"mitigation": "Embedding hardening",
"owner": "research",
"eta": "2026-Q3"
}
],
"recommendations": [
"Tenant-isolated indexes — TM-001 is THE critical risk for this multi-tenant RAG.",
"Trust-Bus wrapper and Constrained Markdown parser — TM-002 closes the highest-volume injection vector.",
"Signed-manifest pipeline (TM-003) and hook-level data-flow tracking (TM-005).",
"Rate limit + auth fix + audit log — bundled together for one platform-eng sprint."
],
"framework": "STRIDE + MAESTRO",
"keyStats": [
{
"label": "TRUSLER",
"value": 10
},
{
"label": "MAKS SCORE",
"value": 20,
"modifier": "critical"
},
{
"label": "CELLER",
"value": 10
}
]
},
"updatedAt": "2026-05-05T18:00:00.000Z"
}
}
}
],
"activeProjectId": "dft-komplett-demo",
"activeSurface": "project",
"preferences": {
"theme": "dark"
}
}
</script>
<!--
Classic script (not type="module") for two reasons:
1. External <script type="module" src="..."> fails on file:// in Chrome+Firefox.
2. Single-file deployment per the brief Constraints — no build step.
Phase 1 delivers the skeleton: state, persistence, surface router, and the
onboarding/home/catalog/project stub.
Phase 2 extends PARSERS + RENDERERS to 10 high-priority commands.
Phase 3 adds the remaining 10 + screenshots + the 3-doc update.
-->
<script>
(function () {
'use strict';
// ============================================================
// CONSTANTS
// ============================================================
const STATE_KEY = 'llm-security-state-v1';
const SCHEMA_VERSION = 1;
const APP_ID = 'llm-security-playground';
const PLUGIN_VERSION = '7.5.0-alpha';
window.__STATE_KEY = STATE_KEY;
window.__SCHEMA_VERSION = SCHEMA_VERSION;
window.__APP_ID = APP_ID;
// ============================================================
// STATE MODULE — Proxy + EventTarget med microtask-batch
// ============================================================
class StateBus extends EventTarget {}
const sharedBus = new StateBus();
const INITIAL_STATE = {
schemaVersion: SCHEMA_VERSION,
dataVersion: 2,
shared: {
organization: {},
scope: {},
profile: {},
platform: {},
compliance: {}
},
projects: [],
activeProjectId: null,
activeSurface: 'home',
preferences: { theme: 'dark' }
};
function makeBatchedDispatcher(bus) {
let pending = false;
const changedPaths = new Set();
return function dispatch(path) {
changedPaths.add(path);
if (pending) return;
pending = true;
queueMicrotask(function () {
pending = false;
const paths = Array.from(changedPaths);
changedPaths.clear();
bus.dispatchEvent(new CustomEvent('change', { detail: { paths: paths } }));
});
};
}
function deepProxy(target, dispatch, path) {
path = path || '';
const cache = new WeakMap();
function makeHandler(p) {
return {
get: function (o, k) {
const v = o[k];
if (v !== null && typeof v === 'object' && !(v instanceof Date)) {
if (cache.has(v)) return cache.get(v);
const childPath = p ? p + '.' + String(k) : String(k);
const wrapped = new Proxy(v, makeHandler(childPath));
cache.set(v, wrapped);
return wrapped;
}
return v;
},
set: function (o, k, v) {
o[k] = v;
dispatch(p ? p + '.' + String(k) : String(k));
return true;
},
deleteProperty: function (o, k) {
delete o[k];
dispatch(p ? p + '.' + String(k) : String(k));
return true;
}
};
}
return new Proxy(target, makeHandler(path));
}
function createStore(initial, bus) {
const dispatch = makeBatchedDispatcher(bus);
const proxied = deepProxy(initial, dispatch, '');
return {
state: proxied,
raw: initial,
subscribe: function (handler) { bus.addEventListener('change', handler); },
unsubscribe: function (handler) { bus.removeEventListener('change', handler); }
};
}
function makeThrottledWriter(persist) {
let timer = null;
return function schedule() {
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
timer = null;
persist().catch(function (err) {
console.error('[llm-security playground] persist failed:', err);
});
}, 300);
};
}
// ============================================================
// PERSISTENCE — IDB primary, localStorage fallback
// ============================================================
function openDB(name, version) {
return new Promise(function (resolve, reject) {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB ikke tilgjengelig'));
return;
}
const req = indexedDB.open(name, version);
req.onupgradeneeded = function (ev) {
const db = req.result;
const oldVersion = ev.oldVersion;
if (oldVersion < 1) {
if (!db.objectStoreNames.contains('shared')) db.createObjectStore('shared');
if (!db.objectStoreNames.contains('projects')) db.createObjectStore('projects', { keyPath: 'id' });
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta');
}
};
req.onsuccess = function () {
const db = req.result;
db.onversionchange = function () {
db.close();
console.warn('[llm-security playground] IDB versionchange — closed for upgrade');
};
resolve(db);
};
req.onerror = function () { reject(req.error); };
req.onblocked = function () {
console.warn('[llm-security playground] IDB open blocked');
};
});
}
async function makePersistence() {
const DB_NAME = 'llm-security-playground-v1';
const DB_VERSION = 1;
try {
const db = await openDB(DB_NAME, DB_VERSION);
return {
backend: 'idb',
load: function () {
return new Promise(function (resolve, reject) {
const tx = db.transaction(['shared', 'projects', 'meta'], 'readonly');
const sharedReq = tx.objectStore('shared').get('shared');
const projectsReq = tx.objectStore('projects').getAll();
const metaReq = tx.objectStore('meta').get('meta');
tx.oncomplete = function () {
resolve({
schemaVersion: (metaReq.result && metaReq.result.schemaVersion) || SCHEMA_VERSION,
dataVersion: (metaReq.result && metaReq.result.dataVersion) || 2,
shared: sharedReq.result || INITIAL_STATE.shared,
projects: projectsReq.result || [],
activeProjectId: (metaReq.result && metaReq.result.activeProjectId) || null,
activeSurface: (metaReq.result && metaReq.result.activeSurface) || 'home',
preferences: (metaReq.result && metaReq.result.preferences) || INITIAL_STATE.preferences
});
};
tx.onerror = function () { reject(tx.error); };
});
},
save: function (state) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(['shared', 'projects', 'meta'], 'readwrite');
tx.objectStore('shared').put(state.shared, 'shared');
const projectStore = tx.objectStore('projects');
projectStore.clear();
for (let i = 0; i < state.projects.length; i++) {
projectStore.put(state.projects[i]);
}
tx.objectStore('meta').put({
schemaVersion: state.schemaVersion,
dataVersion: state.dataVersion,
activeProjectId: state.activeProjectId,
activeSurface: state.activeSurface,
preferences: state.preferences
}, 'meta');
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
}
};
} catch (err) {
console.warn('[llm-security playground] IDB ikke tilgjengelig, faller tilbake til localStorage:', err && err.message);
return makeLocalStorageFallback();
}
}
function makeLocalStorageFallback() {
return {
backend: 'localStorage',
load: function () {
try {
const raw = localStorage.getItem(STATE_KEY);
if (!raw) return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
return Promise.resolve(JSON.parse(raw));
} catch (err) {
console.error('[llm-security playground] localStorage parse-feil, returnerer initial state:', err);
return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
}
},
save: function (state) {
try {
const payload = JSON.stringify(state);
if (payload.length > 4.5 * 1024 * 1024) {
console.warn('[llm-security playground] State is approaching the localStorage 5 MiB cap.');
}
localStorage.setItem(STATE_KEY, payload);
return Promise.resolve();
} catch (err) {
return Promise.reject(err);
}
}
};
}
// ============================================================
// BOOTSTRAP
// ============================================================
let store = null;
let persistence = null;
let scheduleWrite = null;
async function bootstrap() {
persistence = await makePersistence();
const loaded = await persistence.load();
if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION;
if (!loaded.dataVersion) loaded.dataVersion = 2;
try { migrateDataVersion(loaded, defaultArchetypeFor); }
catch (e) { console.warn('[llm-security playground] migrateDataVersion failed:', e); }
store = createStore(loaded, sharedBus);
scheduleWrite = makeThrottledWriter(function () {
return persistence.save(store.raw);
});
store.subscribe(function () { scheduleWrite(); });
window.__store = store;
window.__persistence = persistence;
// v7.7.1: first visit + every subsequent visit lands on catalog.
// The home/onboarding/project surfaces remain in source but are not
// routable until the feature is restored.
store.state.activeSurface = 'catalog';
scheduleRender();
}
// ============================================================
// EXPORT / IMPORT
// ============================================================
function buildEnvelope() {
const snapshot = store ? JSON.parse(JSON.stringify(store.raw)) : JSON.parse(JSON.stringify(INITIAL_STATE));
return {
appId: APP_ID,
appVersion: PLUGIN_VERSION,
schemaVersion: snapshot.schemaVersion || SCHEMA_VERSION,
dataVersion: snapshot.dataVersion || 2,
exportedAt: new Date().toISOString(),
shared: snapshot.shared,
projects: snapshot.projects,
activeProjectId: snapshot.activeProjectId,
activeSurface: snapshot.activeSurface,
preferences: snapshot.preferences
};
}
function exportState() {
const env = buildEnvelope();
const payload = JSON.stringify(env, null, 2);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'llm-security-state-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
}
async function importState(file) {
const text = await file.text();
const env = JSON.parse(text);
if (env.appId !== APP_ID) {
throw new Error('Filen er ikke en llm-security-state-eksport (appId mismatch).');
}
const migrated = {
schemaVersion: env.schemaVersion || SCHEMA_VERSION,
dataVersion: env.dataVersion || 2,
shared: env.shared || INITIAL_STATE.shared,
projects: env.projects || [],
activeProjectId: env.activeProjectId || null,
activeSurface: env.activeSurface || 'home',
preferences: env.preferences || INITIAL_STATE.preferences
};
try { migrateDataVersion(migrated, defaultArchetypeFor); }
catch (e) { console.warn('[llm-security playground] migrateDataVersion (import) failed:', e); }
// Erstatt hele state-tre. Trigger persist via subscribe.
Object.keys(store.raw).forEach(function (k) { delete store.raw[k]; });
Object.keys(migrated).forEach(function (k) { store.raw[k] = migrated[k]; });
// Rebuild the store on import (the proxy cache is skeleton-bound to the old raw)
store = createStore(store.raw, sharedBus);
window.__store = store;
scheduleRender();
}
function loadDemoState() {
const el = document.getElementById('demo-state-v1');
if (!el) return;
const env = JSON.parse(el.textContent);
Object.keys(store.raw).forEach(function (k) { delete store.raw[k]; });
Object.keys(env).forEach(function (k) { store.raw[k] = env[k]; });
store = createStore(store.raw, sharedBus);
window.__store = store;
scheduleRender();
}
window.__loadDemoState = loadDemoState;
// ============================================================
// UTILITIES
// ============================================================
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); }
function uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'p-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
}
function findProject(id) {
const list = (store && store.state && store.state.projects) || [];
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
// ============================================================
// SHARED FIELD-SHORTHANDS + KATALOG
// ============================================================
const FIELD_TYPES = {
TEXT: 'text',
TEXTAREA: 'textarea',
SELECT: 'select',
MULTI_SELECT: 'multiSelect',
BOOLEAN: 'boolean',
NUMBER: 'number'
};
const SEVERITY_LEVELS = ['low', 'medium', 'high', 'critical'];
const FRAMEWORK_OPTIONS = [
'OWASP LLM Top 10', 'OWASP Agentic (ASI)', 'OWASP Skills (AST)', 'OWASP MCP',
'EU AI Act', 'NIST AI RMF', 'ISO 42001', 'Datatilsynet'
];
const IDE_OPTIONS = [
'VS Code', 'Cursor', 'Windsurf', 'VSCodium', 'IntelliJ IDEA', 'PyCharm',
'GoLand', 'WebStorm', 'RubyMine', 'PhpStorm', 'CLion', 'Android Studio', 'Annet'
];
const RUNTIME_OPTIONS = ['macOS', 'Linux', 'Windows', 'Docker', 'WSL'];
const CI_OPTIONS = ['GitHub Actions', 'GitLab CI', 'Azure Pipelines', 'Jenkins', 'CircleCI', 'Forgejo Actions', 'Ingen', 'Annet'];
const SECTOR_OPTIONS = ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Privat', 'Frivillig', 'Annet'];
const SUPPRESS_CATEGORIES = ['docs-only-changes', 'test-fixtures', 'examples', 'archived-rules', 'experimental-features'];
const SHARED = {
organisation_name: { id: 'organisation_name', label: 'Virksomhet', type: 'text', from: 'shared', shared_path: 'organization.name' },
sector: { id: 'sector', label: 'Sektor', type: 'select', from: 'shared', shared_path: 'organization.sector', options: SECTOR_OPTIONS },
severity_threshold: { id: 'severity_threshold', label: 'Severity-terskel', type: 'select', from: 'shared', shared_path: 'profile.severity_threshold', options: SEVERITY_LEVELS },
strict_mode: { id: 'strict_mode', label: 'Strict mode', type: 'boolean', from: 'shared', shared_path: 'profile.strict_mode' },
ci_failon: { id: 'ci_failon', label: 'CI fail-on severity', type: 'select', from: 'shared', shared_path: 'profile.ci_failon', options: SEVERITY_LEVELS },
frameworks: { id: 'frameworks', label: 'Compliance-rammeverk', type: 'multiSelect', from: 'shared', shared_path: 'compliance.frameworks', options: FRAMEWORK_OPTIONS },
ide_in_use: { id: 'ide_in_use', label: 'IDE-er i bruk', type: 'multiSelect', from: 'shared', shared_path: 'platform.ide_list', options: IDE_OPTIONS },
ci_system: { id: 'ci_system', label: 'CI/CD-system', type: 'select', from: 'shared', shared_path: 'platform.ci_system', options: CI_OPTIONS }
};
const TARGET_TYPES = ['codebase', 'plugin', 'mcp-server', 'ide-extension', 'github-url'];
const SCENARIOS = [
{ id: 'pre-deploy', name: 'Pre-deploy security-gate' },
{ id: 'continuous-monitor', name: 'Kontinuerlig monitorering (watch + diff)' },
{ id: 'plugin-trust', name: 'Trust-vurdering av tredjeparts-plugin' },
{ id: 'mcp-supply-chain', name: 'MCP supply-chain audit' },
{ id: 'ide-extension-risk', name: 'IDE-extension supply-chain risk' },
{ id: 'red-team-baseline', name: 'Red-team baseline mot hooks' },
{ id: 'compliance-audit', name: 'Compliance-audit (OWASP/AI Act)' },
{ id: 'harden-onboarding', name: 'Hardening + grade-A onboarding' }
];
// CATALOG: alle 20 commands. produces_report=true → har parser+renderer
// (implemented in Phase 2/3). Tool commands have null parser/renderer.
const CATALOG = {
version: '1.0',
generated_for_schema: SCHEMA_VERSION,
categories: [
{ id: 'discover', label: 'Oppdag', count: 7 },
{ id: 'posture', label: 'Posture', count: 4 },
{ id: 'findings-ops', label: 'Findings', count: 4 },
{ id: 'hardening', label: 'Hardening', count: 2 },
{ id: 'adversarial', label: 'Red-team', count: 1 },
{ id: 'mcp-ops', label: 'MCP ops', count: 2 }
],
commands: [
// ===== DISCOVER (7) =====
{
id: 'scan',
category: 'discover',
label: 'Skanning',
description: 'Scan skills/MCP/directories/GitHub repos. Detects secrets, injection, supply-chain risk, and OWASP LLM patterns.',
argument_hint: '[path|url] [--deep]',
calls_agent: 'scan-orchestrator + skill-scanner-agent + mcp-scanner-agent',
produces_report: true,
report_archetype: 'risk-score-meter',
report_root_class: 'findings',
renderer: 'renderScan',
input_fields: [
{ id: 'target', label: 'Target (path eller GitHub-URL)', type: 'text', from: 'local', required: true },
{ id: 'deep_mode', label: 'Deep mode (10 deterministiske scannere)', type: 'boolean', from: 'local' },
SHARED.severity_threshold,
{ id: 'branch', label: 'Branch (for GitHub-URL)', type: 'text', from: 'local' },
SHARED.frameworks
]
},
{
id: 'deep-scan',
category: 'discover',
label: 'Deep-scan',
description: '10 deterministiske Node.js scannere — Unicode, entropy, permissions, dep-audit, taint, git-forensics, network, memory, supply-chain-recheck, toxic-flow.',
argument_hint: '[path]',
calls_agent: 'deep-scan-synthesizer-agent',
produces_report: true,
report_archetype: 'findings-grade',
report_root_class: 'small-multiples',
renderer: 'renderDeepScan',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'output_format', label: 'Output-format', type: 'select', from: 'local', options: ['compact', 'json', 'sarif'] },
{ id: 'fail_on', label: 'Fail-on severity', type: 'select', from: 'local', options: SEVERITY_LEVELS },
{ id: 'baseline_diff', label: 'Diff mot baseline', type: 'boolean', from: 'local' }
]
},
{
id: 'plugin-audit',
category: 'discover',
label: 'Plugin-audit',
description: 'Trust-vurdering av Claude Code plugin (lokal eller GitHub URL). Sjekker permissions, hooks, agents, signatur.',
argument_hint: '[path|url]',
calls_agent: 'skill-scanner-agent + posture-assessor-agent',
produces_report: true,
report_archetype: 'risk-score-meter',
report_root_class: 'verdict-pill-lg',
renderer: 'renderPluginAudit',
input_fields: [
{ id: 'target', label: 'Plugin-path eller GitHub-URL', type: 'text', from: 'local', required: true },
{ id: 'install_intent', label: 'Skal installeres etter audit?', type: 'boolean', from: 'local' },
SHARED.strict_mode
]
},
{
id: 'mcp-audit',
category: 'discover',
label: 'MCP config-audit',
description: 'Audit alle installerte MCP server-konfigurasjoner. Permissions, trust, network exposure.',
argument_hint: '[--live]',
calls_agent: 'mcp-scanner-agent',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderMcpAudit',
input_fields: [
{ id: 'live_inspection', label: 'Live inspection (JSON-RPC against running servers)', type: 'boolean', from: 'local' },
{ id: 'config_paths', label: 'Config-stier (én per linje)', type: 'textarea', from: 'local' }
]
},
{
id: 'mcp-inspect',
category: 'discover',
label: 'MCP live-inspect',
description: 'Connect to running MCP servers and scan tool descriptions for injection/shadowing/drift.',
argument_hint: '[server-url eller name]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderMcpInspect',
input_fields: [
{ id: 'target_servers', label: 'Server-navn (én per linje, tom = alle)', type: 'textarea', from: 'local' },
{ id: 'timeout_ms', label: 'Timeout (ms)', type: 'number', from: 'local' },
{ id: 'skip_global', label: 'Hopp over globale config-er', type: 'boolean', from: 'local' }
]
},
{
id: 'ide-scan',
category: 'discover',
label: 'IDE-extension-scan',
description: 'Skann installerte VS Code + JetBrains extensions/plugins. 7 VS Code-sjekker + 7 JetBrains-spesifikke sjekker.',
argument_hint: '[target|url]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderIdeScan',
input_fields: [
{ id: 'target', label: 'Target (path, marketplace-URL eller tom for alle installerte)', type: 'text', from: 'local' },
{ id: 'vscode_only', label: 'Kun VS Code', type: 'boolean', from: 'local' },
{ id: 'intellij_only', label: 'Kun JetBrains', type: 'boolean', from: 'local' },
{ id: 'include_builtin', label: 'Inkluder builtins', type: 'boolean', from: 'local' },
{ id: 'online', label: 'Online-modus (Marketplace + OSV.dev)', type: 'boolean', from: 'local' }
]
},
{
id: 'supply-check',
category: 'discover',
label: 'Supply-chain-recheck',
description: 'Re-audit installerte dependencies — lockfiles vs blocklists, OSV.dev CVEs, typosquats.',
argument_hint: '[path]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderSupplyCheck',
input_fields: [
{ id: 'target', label: 'Target path (root med lockfiles)', type: 'text', from: 'local', required: true },
{ id: 'online', label: 'Online OSV.dev-oppslag', type: 'boolean', from: 'local' },
{ id: 'ecosystems', label: 'Ekosystemer', type: 'multiSelect', from: 'local', options: ['npm', 'pip', 'cargo', 'go', 'gem', 'docker', 'brew'] }
]
},
// ===== POSTURE (4) =====
{
id: 'posture',
category: 'posture',
label: 'Posture-quick',
description: 'Quick scorecard across 13/16 categories. Includes EU AI Act, NIST AI RMF, and ISO 42001 when selected.',
argument_hint: '[path]',
calls_agent: 'posture-scanner.mjs (deterministisk)',
produces_report: true,
report_archetype: 'posture-cards',
report_root_class: 'small-multiples',
renderer: 'renderPosture',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
SHARED.frameworks,
{ id: 'include_compliance_extras', label: 'Inkluder compliance-ekstra (EU AI Act, NIST, ISO)', type: 'boolean', from: 'local' }
]
},
{
id: 'audit',
category: 'posture',
label: 'Full audit (A-F)',
description: 'Full prosjekt-audit med OWASP LLM Top 10-vurdering, scoring og remediation-plan.',
argument_hint: '[path]',
calls_agent: 'posture-assessor-agent',
produces_report: true,
report_archetype: 'findings-grade',
report_root_class: 'radar',
renderer: 'renderAudit',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
SHARED.frameworks,
SHARED.severity_threshold,
{ id: 'include_remediation', label: 'Inkluder remediation-plan', type: 'boolean', from: 'local' }
]
},
{
id: 'dashboard',
category: 'posture',
label: 'Cross-project dashboard',
description: 'Maskinkrysjende dashboard. Posture-skanner per oppdaget Claude Code-prosjekt, aggregert til machine-grade.',
argument_hint: '',
calls_agent: 'dashboard-aggregator.mjs (deterministisk)',
produces_report: true,
report_archetype: 'dashboard-fleet',
report_root_class: 'fleet-grid',
renderer: 'renderDashboard',
input_fields: [
{ id: 'no_cache', label: 'Forbi cache (full re-scan)', type: 'boolean', from: 'local' },
{ id: 'max_depth', label: 'Max search depth', type: 'number', from: 'local' }
]
},
{
id: 'pre-deploy',
category: 'posture',
label: 'Pre-deploy checklist',
description: 'Pre-deployment sikkerhetssjekkliste — verifiser enterprise-kontroller, compliance, produksjons-readiness.',
argument_hint: '',
calls_agent: 'posture-assessor-agent',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'traffic-light',
renderer: 'renderPreDeploy',
input_fields: [
SHARED.organisation_name,
SHARED.frameworks,
{ id: 'production_environment', label: 'Production environment', type: 'select', from: 'local', options: ['Cloud (Azure)', 'Cloud (AWS)', 'Cloud (GCP)', 'On-prem', 'Hybrid', 'Air-gapped'] },
{ id: 'data_classification', label: 'Data classification', type: 'select', from: 'local', options: ['Open', 'Internal', 'Confidential', 'Strictly confidential'] }
]
},
// ===== FINDINGS-OPS (4) =====
{
id: 'diff',
category: 'findings-ops',
label: 'Diff mot baseline',
description: 'Compare scan results against the stored baseline — shows new, resolved, unchanged, and moved findings.',
argument_hint: '[path]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'diff-report',
report_root_class: 'diff',
renderer: 'renderDiff',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'baseline_id', label: 'Baseline-ID (tom = siste)', type: 'text', from: 'local' },
{ id: 'show_unchanged', label: 'Vis uendrede funn', type: 'boolean', from: 'local' }
]
},
{
id: 'watch',
category: 'findings-ops',
label: 'Watch (kontinuerlig)',
description: 'Continuous monitoring — runs diff on a recurring interval via /loop.',
argument_hint: '[path] [--interval 6h]',
calls_agent: 'watch-cron.mjs',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'live-meter',
renderer: 'renderWatch',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'interval', label: 'Intervall', type: 'select', from: 'local', options: ['1h', '4h', '6h', '12h', '24h', '7d'] },
{ id: 'notify_on', label: 'Varsle ved', type: 'multiSelect', from: 'local', options: ['new-findings', 'resolved', 'severity-increase', 'all'] }
]
},
{
id: 'registry',
category: 'findings-ops',
label: 'Skill-registry',
description: 'Skill signature registry — show stats, scan and register skills, search known fingerprints.',
argument_hint: '[scan|search]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderRegistry',
input_fields: [
{ id: 'mode', label: 'Modus', type: 'select', from: 'local', options: ['stats', 'scan', 'search'] },
{ id: 'query', label: 'Search string (search only)', type: 'text', from: 'local' },
{ id: 'target', label: 'Target path (kun scan)', type: 'text', from: 'local' }
]
},
{
id: 'clean',
category: 'findings-ops',
label: 'Clean (auto+semi+manual)',
description: 'Skann og remediere funn — auto-fix deterministiske, bekreft semi-auto med bruker, rapporter manuelle.',
argument_hint: '[path]',
calls_agent: 'cleaner-agent',
produces_report: true,
report_archetype: 'kanban-buckets',
report_root_class: 'kanban',
renderer: 'renderClean',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'auto_apply', label: 'Auto-apply deterministiske fixes', type: 'boolean', from: 'local' },
{ id: 'dry_run', label: 'Dry-run (ingen endringer)', type: 'boolean', from: 'local' },
{ id: 'interactive', label: 'Interaktiv bekreftelse for semi-auto', type: 'boolean', from: 'local' }
]
},
// ===== HARDENING (2) =====
{
id: 'harden',
category: 'hardening',
label: 'Harden (Grade A config)',
description: 'Generer Grade A sikkerhetskonfigurasjon — settings.json, CLAUDE.md security-seksjon, .gitignore.',
argument_hint: '[path]',
calls_agent: '(deterministisk generator)',
produces_report: true,
report_archetype: 'diff-report',
report_root_class: 'diff',
renderer: 'renderHarden',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'project_type', label: 'Prosjekt-type', type: 'select', from: 'local', options: ['plugin', 'monorepo', 'standalone', 'auto-detect'] },
{ id: 'apply', label: 'Anvend endringene direkte', type: 'boolean', from: 'local' },
{ id: 'skip_existing', label: 'Hopp over filer som allerede er Grade A', type: 'boolean', from: 'local' }
]
},
{
id: 'threat-model',
category: 'hardening',
label: 'Threat-model (STRIDE/MAESTRO)',
description: 'Interaktiv threat modeling — STRIDE og MAESTRO frameworks for arkitektur-analyse.',
argument_hint: '',
calls_agent: 'threat-modeler-agent',
produces_report: true,
report_archetype: 'matrix-risk',
report_root_class: 'matrix',
renderer: 'renderThreatModel',
input_fields: [
SHARED.organisation_name,
{ id: 'system_name', label: 'System-navn', type: 'text', from: 'local', required: true },
{ id: 'system_description', label: 'System-beskrivelse', type: 'textarea', from: 'local', required: true },
{ id: 'framework', label: 'Framework', type: 'select', from: 'local', options: ['STRIDE', 'MAESTRO', 'STRIDE + MAESTRO'] },
{ id: 'components', label: 'Komponenter (én per linje)', type: 'textarea', from: 'local' }
]
},
// ===== ADVERSARIAL (1) =====
{
id: 'red-team',
category: 'adversarial',
label: 'Red-team simulasjon',
description: '64 attack scenarios across 12 categories against plugin hooks. --adaptive for mutation-based evasion.',
argument_hint: '[--category <name>] [--adaptive]',
calls_agent: 'attack-simulator.mjs (data-drevet)',
produces_report: true,
report_archetype: 'red-team-results',
report_root_class: 'risk-meter',
renderer: 'renderRedTeam',
input_fields: [
{ id: 'category', label: 'Kategori (tom = alle 12)', type: 'select', from: 'local', options: ['', 'prompt-injection', 'tool-poisoning', 'data-exfiltration', 'lethal-trifecta', 'mcp-shadowing', 'memory-poisoning', 'supply-chain', 'credential-theft', 'unicode-evasion', 'bash-evasion', 'sub-agent-escape', 'permission-escalation'] },
{ id: 'adaptive', label: 'Adaptive (mutasjon-basert evasion)', type: 'boolean', from: 'local' },
{ id: 'verbose', label: 'Verbose output', type: 'boolean', from: 'local' },
{ id: 'benchmark', label: 'Benchmark-modus', type: 'boolean', from: 'local' }
]
},
// ===== MCP-OPS (2) =====
{
id: 'mcp-baseline-reset',
category: 'mcp-ops',
label: 'MCP-baseline-reset',
description: 'Reset MCP description baseline cache. Etter legitim MCP-server-oppgradering.',
argument_hint: '[--target <tool>] [--list]',
calls_agent: '(deterministic tool)',
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'mode', label: 'Modus', type: 'select', from: 'local', options: ['list', 'target', 'clear-all'] },
{ id: 'target_tool', label: 'Tool-navn (kun target)', type: 'text', from: 'local' }
]
},
{
id: 'security',
category: 'mcp-ops',
label: 'Security-router',
description: 'Router command — lists available sub-commands. Tool for navigation, produces no report.',
argument_hint: '',
calls_agent: '(router)',
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: []
}
]
};
window.__CATALOG = CATALOG;
window.__SHARED = SHARED;
window.__SCENARIOS = SCENARIOS;
// ============================================================
// COMMAND FORM RENDERER + buildCommand
// ============================================================
function resolveSharedPath(path) {
if (!path || !store || !store.state || !store.state.shared) return undefined;
const parts = String(path).split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length; i++) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = cur[parts[i]];
}
return cur;
}
function isFilledArg(v, type) {
if (v == null) return false;
if (type === 'multiSelect' || Array.isArray(v)) return Array.isArray(v) && v.length > 0;
if (type === 'boolean' || typeof v === 'boolean') return v === true;
if (type === 'number' || typeof v === 'number') return !isNaN(v);
return String(v).trim() !== '';
}
function serializeArgValue(v) {
if (Array.isArray(v)) {
return '"' + v.map(function (x) { return String(x).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }).join(',') + '"';
}
if (typeof v === 'boolean') return String(v);
if (typeof v === 'number') return String(v);
const s = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return '"' + s + '"';
}
function buildCommand(commandId, formData) {
formData = formData || {};
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
const args = {};
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (f.from === 'shared' && f.shared_path) {
const v = resolveSharedPath(f.shared_path);
if (isFilledArg(v, f.type)) args[f.id] = v;
}
});
}
Object.keys(formData).forEach(function (k) {
const v = formData[k];
if (isFilledArg(v)) args[k] = v;
else delete args[k];
});
const orderedKeys = [];
const seen = {};
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (Object.prototype.hasOwnProperty.call(args, f.id) && !seen[f.id]) {
orderedKeys.push(f.id); seen[f.id] = true;
}
});
}
Object.keys(args).forEach(function (k) {
if (!seen[k]) { orderedKeys.push(k); seen[k] = true; }
});
const parts = ['/security:' + commandId];
orderedKeys.forEach(function (k) {
parts.push(k + '=' + serializeArgValue(args[k]));
});
return parts.join(' ');
}
window.__buildCommand = buildCommand;
function renderCommandFormField(field, domId, value) {
const fromAttr = field.from === 'shared' ? 'shared' : 'local';
const dataAttrs = 'data-cf-field="' + escapeAttr(field.id) + '" data-cf-from="' + fromAttr + '" data-cf-type="' + escapeAttr(field.type) + '"';
const fromTag = field.from === 'shared'
? '<span class="field-from-tag" title="Prefilled from onboarding (state.shared.' + escapeAttr(field.shared_path || '') + ')">shared</span>'
: '';
const requiredMark = field.required ? '<span class="required-mark" aria-label="required">*</span>' : '';
const labelHtml = '<label for="' + domId + '" class="field-label">' + escapeHtml(field.label) + requiredMark + fromTag + '</label>';
let inputHtml = '';
if (field.type === 'text') {
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
} else if (field.type === 'textarea') {
inputHtml = '<textarea id="' + domId + '" ' + dataAttrs + ' class="textarea" rows="3">' + escapeHtml(value == null ? '' : String(value)) + '</textarea>';
} else if (field.type === 'number') {
inputHtml = '<input type="number" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null || value === '' ? '' : String(value)) + '" class="input">';
} else if (field.type === 'select') {
const opts = ['<option value="">(velg)</option>'].concat((field.options || []).map(function (o) {
const sel = (o === value) ? ' selected' : '';
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
})).join('');
inputHtml = '<select id="' + domId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
} else if (field.type === 'multiSelect') {
const arr = Array.isArray(value) ? value : [];
const opts = (field.options || []).map(function (o, i) {
const checked = arr.indexOf(o) >= 0 ? ' checked' : '';
const cbId = domId + '-' + i;
return (
'<label class="checkbox-row" for="' + cbId + '">' +
'<input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-cf-multi="' + escapeAttr(o) + '"' + checked + '>' +
'<span>' + escapeHtml(o) + '</span>' +
'</label>'
);
}).join('');
inputHtml = '<fieldset class="multi-select" aria-labelledby="' + domId + '-legend"><legend id="' + domId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' + opts + '</fieldset>';
} else if (field.type === 'boolean') {
const checked = value === true ? ' checked' : '';
inputHtml = (
'<label class="checkbox-row" for="' + domId + '">' +
'<input type="checkbox" id="' + domId + '" ' + dataAttrs + checked + '>' +
'<span>Ja</span>' +
'</label>'
);
} else {
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
}
return '<div class="field-row" data-cf-field-row="' + escapeAttr(field.id) + '">' + labelHtml + inputHtml + '</div>';
}
function renderCommandForm(commandId, opts) {
opts = opts || {};
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
if (!cmd) {
return '<div class="guide-panel guide-panel--warn"><div class="guide-panel__icon" aria-hidden="true">!</div><div class="guide-panel__body"><p class="guide-panel__text">Ukjent command: ' + escapeHtml(commandId) + '</p></div></div>';
}
const project = opts.projectId ? findProject(opts.projectId) : null;
const savedInput = (project && project.reports && project.reports[commandId] && project.reports[commandId].input) || {};
const scope = opts.scope || 'p';
const fieldRows = (cmd.input_fields || []).map(function (f) {
const domId = 'cf-' + scope + '-' + cmd.id + '-' + f.id;
let value;
if (f.from === 'shared' && f.shared_path) value = resolveSharedPath(f.shared_path);
if (value === undefined || value === null || value === '') {
if (Object.prototype.hasOwnProperty.call(savedInput, f.id)) value = savedInput[f.id];
}
return renderCommandFormField(f, domId, value);
}).join('');
const sharedCount = (cmd.input_fields || []).filter(function (f) { return f.from === 'shared'; }).length;
const fieldCount = (cmd.input_fields || []).length;
return (
'<form class="command-form" data-command-form="' + escapeAttr(cmd.id) + '" data-command-form-scope="' + escapeAttr(scope) + '" autocomplete="off" onsubmit="return false;">' +
'<div class="command-form__fields">' + fieldRows + '</div>' +
'<div class="command-form__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="copy-command" data-command="' + escapeAttr(cmd.id) + '">Kopier kommando</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="preview-command" data-command="' + escapeAttr(cmd.id) + '">Preview</button>' +
'<span class="command-form__hint">' + fieldCount + ' felt' + (fieldCount === 1 ? '' : 'er') + ' (' + sharedCount + ' fra shared).</span>' +
'<span class="command-form__copy-confirm" data-copy-confirm hidden></span>' +
'</div>' +
'<div class="form-preview" data-form-preview hidden>' +
'<h5 class="form-preview__heading">Pipeline-streng</h5>' +
'<pre class="code-block" data-form-preview-text></pre>' +
'</div>' +
'</form>'
);
}
function readCommandFormValues(formEl) {
const data = {};
if (!formEl) return data;
const cmdId = formEl.dataset.commandForm;
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (f.type === 'multiSelect') data[f.id] = [];
});
}
const inputs = formEl.querySelectorAll('[data-cf-field]');
for (let i = 0; i < inputs.length; i++) {
const el = inputs[i];
const id = el.dataset.cfField;
if (el.matches('input[type="checkbox"][data-cf-multi]')) {
if (el.checked) {
if (!Array.isArray(data[id])) data[id] = [];
data[id].push(el.dataset.cfMulti);
}
} else if (el.matches('input[type="checkbox"]')) {
data[id] = el.checked;
} else if (el.matches('input[type="number"]')) {
if (el.value === '' || el.value == null) data[id] = null;
else { const n = Number(el.value); data[id] = isNaN(n) ? null : n; }
} else {
data[id] = el.value;
}
}
return data;
}
function showCommandPreview(formEl, str) {
if (!formEl) return;
const box = formEl.querySelector('[data-form-preview]');
const text = formEl.querySelector('[data-form-preview-text]');
if (!box || !text) return;
text.textContent = str;
box.hidden = false;
}
function flashCopyConfirm(formEl, message) {
if (!formEl) return;
const tag = formEl.querySelector('[data-copy-confirm]');
if (!tag) return;
tag.textContent = message || 'Kopiert til utklippstavle.';
tag.hidden = false;
clearTimeout(tag.__hideTimer);
tag.__hideTimer = setTimeout(function () { tag.hidden = true; }, 2400);
}
// ============================================================
// SURFACE ROUTING
// ============================================================
function getSurfaceEl(name) {
return document.querySelector('[data-surface="' + name + '"]');
}
function showSurface(name) {
const surfaces = document.querySelectorAll('main#app > [data-surface]');
for (let i = 0; i < surfaces.length; i++) {
surfaces[i].hidden = (surfaces[i].dataset.surface !== name);
}
}
let renderQueued = false;
function scheduleRender() {
if (renderQueued) return;
renderQueued = true;
queueMicrotask(function () {
renderQueued = false;
renderActive();
});
}
function renderActive() {
if (!store) return;
// v7.7.1: catalog is the only routable surface. Onboarding/home/project
// are preserved in source, but the router always forces catalog until
// the feature is restored.
if (store.state.activeSurface !== 'catalog') {
store.state.activeSurface = 'catalog';
}
showSurface('catalog');
renderCatalogSurface();
}
function navigate(surface) {
store.state.activeSurface = surface;
scheduleRender();
}
// Eksponerte funksjoner for testing + screenshots-automasjon
window.__navigate = navigate;
window.__scheduleRender = scheduleRender;
// ============================================================
// TOPBAR (felles for home/catalog/project)
// ============================================================
function renderTopbar(crumb) {
// v7.7.1: onboarding is gone, so the orgName from shared.organization
// er ikke lenger meningsfullt i breadcrumb. Bruker statisk
// "llm-security" som scope-anker pluss valgfri crumb.
const breadcrumbInner = 'llm-security' + (crumb ? ' · ' + crumb : '');
const breadcrumbHtml =
'<nav class="app-header__breadcrumb" aria-label="Breadcrumb">' + breadcrumbInner + '</nav>';
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const themeLabel = currentTheme === 'light' ? 'Light' : 'Dark';
const themeNext = currentTheme === 'light' ? 'dark' : 'light';
return (
'<header class="app-header">' +
'<div class="app-header__brand">' +
'<span class="app-header__brand-mark" aria-hidden="true">S</span>' +
'<span class="badge badge--scope-security">llm-security</span>' +
'</div>' +
breadcrumbHtml +
'<div class="app-header__spacer"></div>' +
'<div class="app-header__actions" role="group" aria-label="Hovednavigasjon">' +
'<div class="app-header__nav-group" role="group" aria-label="Primary navigation">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-catalog">Katalog</button>' +
'</div>' +
'<span class="app-header__nav-sep" aria-hidden="true"></span>' +
'<div class="app-header__nav-group" role="group" aria-label="State og tema">' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="export-state" aria-label="Eksporter state til JSON">Eksporter</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="import-state" aria-label="Importer state fra JSON">Importer</button>' +
'<input type="file" accept="application/json,.json" data-import-input hidden>' +
'<button type="button" class="theme-toggle" data-action="toggle-theme" aria-label="Switch to ' + themeNext + ' mode">' +
'<span data-theme-label>' + themeLabel + '</span>' +
'</button>' +
'</div>' +
'</div>' +
'</header>'
);
}
// ============================================================
// ONBOARDING SURFACE
// ============================================================
let onboardingActiveStep = 'organization';
const ONBOARDING_GROUPS = [
{
id: 'organization',
label: 'Organisasjon',
fields: [
{ id: 'organization.name', label: 'Virksomhet', type: 'text', required: true },
{ id: 'organization.sector', label: 'Sektor', type: 'select', options: SECTOR_OPTIONS, required: true },
{ id: 'organization.size', label: 'Antall ansatte', type: 'text' },
{ id: 'organization.description', label: 'Kort beskrivelse', type: 'textarea' }
]
},
{
id: 'scope',
label: 'Scope',
fields: [
{ id: 'scope.typical_paths', label: 'Typiske scan-targets (paths, kommaseparert eller én per linje)', type: 'textarea' },
{ id: 'scope.exclude_patterns', label: 'Exclude-patterns (kommaseparert eller én per linje)', type: 'textarea' },
{ id: 'scope.github_orgs', label: 'GitHub-orgs (kommaseparert)', type: 'text' },
{ id: 'scope.mcp_servers', label: 'MCP-servere i bruk', type: 'multiSelect', options: ['filesystem', 'github', 'memory', 'fetch', 'sqlite', 'postgres', 'puppeteer', 'sequentialthinking', 'time', 'weather', 'Annet'] }
]
},
{
id: 'profile',
label: 'Profil',
fields: [
{ id: 'profile.severity_threshold', label: 'Severity-terskel for fail', type: 'select', options: SEVERITY_LEVELS, required: true },
{ id: 'profile.strict_mode', label: 'Strict mode', type: 'boolean' },
{ id: 'profile.ci_failon', label: 'CI fail-on severity', type: 'select', options: SEVERITY_LEVELS },
{ id: 'profile.suppress_categories', label: 'Suppress kategorier', type: 'multiSelect', options: SUPPRESS_CATEGORIES }
]
},
{
id: 'platform',
label: 'Plattform',
fields: [
{ id: 'platform.ide_list', label: 'IDE-er i bruk', type: 'multiSelect', options: IDE_OPTIONS },
{ id: 'platform.mcp_count', label: 'Antall MCP-servere konfigurert', type: 'number' },
{ id: 'platform.ci_system', label: 'CI/CD-system', type: 'select', options: CI_OPTIONS },
{ id: 'platform.runtime_envs', label: 'Runtime environments', type: 'multiSelect', options: RUNTIME_OPTIONS }
]
},
{
id: 'compliance',
label: 'Compliance',
fields: [
{ id: 'compliance.frameworks', label: 'Compliance-rammeverk', type: 'multiSelect', options: FRAMEWORK_OPTIONS },
{ id: 'compliance.datatilsynet_consulted', label: 'Datatilsynet konsultert', type: 'boolean' },
{ id: 'compliance.gdpr_role', label: 'GDPR-rolle', type: 'select', options: ['controller', 'processor', 'joint-controller', 'usikker'] },
{ id: 'compliance.ai_act_role', label: 'AI Act-rolle', type: 'select', options: ['provider', 'deployer', 'distributor', 'importer', 'usikker'] }
]
}
];
function getOnboardingValue(path) {
const parts = path.split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length; i++) {
if (cur == null) return undefined;
cur = cur[parts[i]];
}
return cur;
}
function setOnboardingValue(path, value) {
const parts = path.split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function isOnboardingGroupComplete(group) {
return group.fields.every(function (f) {
if (!f.required) return true;
const v = getOnboardingValue(f.id);
if (f.type === 'multiSelect') return Array.isArray(v) && v.length > 0;
if (f.type === 'boolean') return v === true || v === false;
return v != null && String(v).trim() !== '';
});
}
/**
* Render onboarding-fremdrift via DS Tier 3 form-progress + fp-step.
*
* fp-step renders (DS .fp-step in vendor/components-tier3-supplement.css:779):
* 1. Organization-group fp-step (data-state pending|in-progress|done)
* 2. Scope-group fp-step
* 3. Profile-group fp-step
* 4. Platform-group fp-step
* 5. Compliance-group fp-step
* Plus the form-progress__steps wrapper container per the DS pattern.
*/
function renderOnboardingProgress() {
const completedCount = ONBOARDING_GROUPS.filter(isOnboardingGroupComplete).length;
const items = ONBOARDING_GROUPS.map(function (g, i) {
const isActive = onboardingActiveStep === g.id;
const done = isOnboardingGroupComplete(g);
const state = done ? 'done' : (isActive ? 'in-progress' : 'pending');
const ariaCurrent = isActive ? ' aria-current="step"' : '';
const marker = done ? '✓' : String(i + 1);
return (
'<button type="button" class="fp-step" data-state="' + state + '"' + ariaCurrent + ' data-action="onboarding-step" data-step="' + escapeAttr(g.id) + '">' +
'<span class="fp-step__num" aria-hidden="true">' + marker + '</span>' +
'<span class="fp-step__name">' + escapeHtml(g.label) + '</span>' +
'</button>'
);
}).join('');
return (
'<aside class="form-progress" aria-label="Onboarding-fremdrift">' +
'<div class="form-progress__autosave">' +
'<span class="badge badge--scope-security">llm-security</span>' +
'<span class="form-progress__autosave-dot" aria-hidden="true"></span>' +
'<span>Onboarding · ' + completedCount + '/' + ONBOARDING_GROUPS.length + '</span>' +
'</div>' +
'<div class="form-progress__steps" role="list">' + items + '</div>' +
'</aside>'
);
}
function renderOnboardingFieldRow(field, scope) {
const domId = 'ob-' + scope + '-' + field.id.replace(/\./g, '-');
const value = getOnboardingValue(field.id);
const fieldDef = {
id: field.id,
label: field.label,
type: field.type,
from: 'local',
options: field.options,
required: field.required
};
// Reuse the command form field renderer with onboarding-specific data-attrs
const html = renderCommandFormField(fieldDef, domId, value);
return html.replace('data-cf-field="' + escapeAttr(field.id) + '"', 'data-cf-field="' + escapeAttr(field.id) + '" data-onboarding-field="1"');
}
function renderOnboardingSurface() {
const root = getSurfaceEl('onboarding');
if (!root) return;
const group = ONBOARDING_GROUPS.find(function (g) { return g.id === onboardingActiveStep; }) || ONBOARDING_GROUPS[0];
const fieldsHtml = group.fields.map(function (f) { return renderOnboardingFieldRow(f, 'main'); }).join('');
const allCompleteCount = ONBOARDING_GROUPS.filter(isOnboardingGroupComplete).length;
const isLast = ONBOARDING_GROUPS[ONBOARDING_GROUPS.length - 1].id === group.id;
const isFirst = ONBOARDING_GROUPS[0].id === group.id;
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const isReturning = !!orgName;
const headerHtml = renderPageShell({
eyebrow: (isReturning ? 'RE-ONBOARDING' : 'ONBOARDING') + ' · ' + allCompleteCount + ' av ' + ONBOARDING_GROUPS.length + ' grupper komplette',
title: isReturning ? 'Oppdater fellesfeltene' : 'Velkommen — la oss sette opp llm-security for ' + (orgName || 'din virksomhet'),
lede: 'These 5 groups are shared state. They prefill every command form for new projects, so you do not have to re-enter the same info.',
meta: ['Gruppe ' + (ONBOARDING_GROUPS.findIndex(function (g) { return g.id === group.id; }) + 1) + ' av ' + ONBOARDING_GROUPS.length, group.title || group.id]
}, '');
const stepNavHtml = (
'<div class="onboarding-actions">' +
(isFirst ? '' : '<button type="button" class="btn btn--ghost" data-action="onboarding-prev">← Forrige</button>') +
(isLast
? '<button type="button" class="btn btn--primary" data-action="onboarding-finish">Done — go to home</button>'
: '<button type="button" class="btn btn--primary" data-action="onboarding-next">Neste →</button>'
) +
'<span class="onboarding-help">Tipset: alle felter kan endres senere via Re-onboard.</span>' +
'</div>'
);
root.innerHTML = (
renderTopbar(isReturning ? 'Re-onboarding' : 'Onboarding') +
'<div class="app-shell">' +
headerHtml +
'<div class="onboarding-layout">' +
renderOnboardingProgress() +
'<form class="onboarding-fields" data-onboarding-form="' + escapeAttr(group.id) + '" autocomplete="off" onsubmit="return false;">' +
'<h2 style="margin: 0 0 var(--space-3); font-size: var(--font-size-xl);">' + escapeHtml(group.label) + '</h2>' +
fieldsHtml +
stepNavHtml +
'</form>' +
'</div>' +
'</div>'
);
}
// ============================================================
// HOME SURFACE
// ============================================================
function projectReportCount(p) {
if (!p || !p.reports) return 0;
let count = 0;
for (const k in p.reports) {
if (p.reports[k] && p.reports[k].parsed) count++;
}
return count;
}
function inferProjectVerdict(project) {
const reports = (project && project.reports) || {};
const verdicts = [];
for (const k in reports) {
const v = reports[k] && reports[k].parsed && reports[k].parsed.verdict;
if (v) verdicts.push(String(v).toLowerCase());
}
if (verdicts.length === 0) return 'n-a';
for (let i = 0; i < verdicts.length; i++) {
if (verdicts[i] === 'block' || verdicts[i] === 'failed') return 'block';
}
for (let i = 0; i < verdicts.length; i++) {
const v = verdicts[i];
if (v === 'go-with-conditions' || v === 'warning') return 'go-with-conditions';
}
let allGo = true;
for (let i = 0; i < verdicts.length; i++) {
const v = verdicts[i];
if (v !== 'go' && v !== 'approved' && v !== 'allow') { allGo = false; break; }
}
return allGo ? 'approved' : 'n-a';
}
function inferProjectLastUpdated(project) {
const reports = (project && project.reports) || {};
let latest = null;
for (const k in reports) {
const r = reports[k];
if (r && r.updatedAt) { if (!latest || r.updatedAt > latest) latest = r.updatedAt; }
}
const ts = latest || (project && project.createdAt) || '';
return ts ? String(ts).slice(0, 10) : '';
}
function projectMeterBand(filled, total) {
if (total === 0) return '4';
const pct = filled / total;
if (pct >= 0.8) return '1';
if (pct >= 0.5) return '2';
if (pct >= 0.2) return '3';
return '4';
}
function renderHomeSurface() {
const root = getSurfaceEl('home');
if (!root) return;
const projects = store.state.projects || [];
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
const tracksHtml = (
'<div class="tracks">' +
'<button type="button" class="tracks__card" data-action="goto-onboarding">' +
'<span class="tracks__card-icon" aria-hidden="true">⚙︎</span>' +
'<h3 class="tracks__card-title">Re-onboard</h3>' +
'<p class="tracks__card-desc">Update the shared fields that prefill every command form.</p>' +
'<span class="tracks__card-meta"><span>Shared state</span><span class="tracks__card-cta">Open →</span></span>' +
'</button>' +
'<button type="button" class="tracks__card" data-action="new-project">' +
'<span class="tracks__card-icon" aria-hidden="true"></span>' +
'<h3 class="tracks__card-title">Nytt prosjekt</h3>' +
'<p class="tracks__card-desc">Start nytt sikkerhetsprosjekt — codebase, plugin, MCP-server, IDE-extension eller GitHub-URL.</p>' +
'<span class="tracks__card-meta"><span>Per-prosjekt state</span><span class="tracks__card-cta">Opprett →</span></span>' +
'</button>' +
'<button type="button" class="tracks__card" data-action="goto-catalog">' +
'<span class="tracks__card-icon" aria-hidden="true">◇</span>' +
'<h3 class="tracks__card-title">Command-katalog</h3>' +
'<p class="tracks__card-desc">Browse all ' + CATALOG.commands.length + ' commands grouped by category. Generate pipeline strings without a project.</p>' +
'<span class="tracks__card-meta"><span>' + CATALOG.commands.length + ' commands</span><span class="tracks__card-cta">Bla →</span></span>' +
'</button>' +
'</div>'
);
const projectListHtml = (function () {
if (projects.length === 0) {
return (
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">You have no projects yet</h3>' +
'<p class="guide-panel__text">Create your first project to start security scanning and auditing. Or load demo data to see how it looks.</p>' +
'<div class="guide-panel__action" style="display:flex; gap: var(--space-2); flex-wrap: wrap;">' +
'<button type="button" class="btn btn--primary" data-action="new-project">Create first project</button>' +
'<button type="button" class="btn btn--secondary" data-action="load-demo">Last inn demo-data</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
const tiles = projects.map(function (p) {
const filled = projectReportCount(p);
const band = projectMeterBand(filled, reportTotal);
const pct = reportTotal ? Math.round(100 * filled / reportTotal) : 0;
const scenarios = Array.isArray(p.scenarios) ? p.scenarios : [];
const scenarioName = scenarios.length > 0 ? (SCENARIOS.find(function (s) { return s.id === scenarios[0]; }) || { name: scenarios[0] }).name : '';
const chip = scenarios.length > 0
? '<span class="fleet-tile__chip" title="' + escapeAttr(scenarioName) + '">' + escapeHtml(scenarioName.length > 24 ? scenarioName.slice(0, 22) + '…' : scenarioName) + (scenarios.length > 1 ? ' +' + (scenarios.length - 1) : '') + '</span>'
: '<span class="fleet-tile__chip">Uten scenario</span>';
const targetTypeLabel = (p.target_type || 'codebase').replace('-', ' ');
return (
'<button type="button" class="fleet-tile" data-action="open-project" data-project-id="' + escapeAttr(p.id) + '">' +
'<div class="fleet-tile__row">' +
'<span class="fleet-tile__name" title="' + escapeAttr(p.name) + '">' + escapeHtml(p.name) + '</span>' +
chip +
'</div>' +
'<div class="fleet-tile__meter" aria-label="Rapport-fremdrift">' +
'<span class="fleet-tile__meter-fill" data-band="' + band + '" style="width:' + Math.max(pct, 4) + '%"></span>' +
'</div>' +
'<div class="fleet-tile__meta">' +
'<span class="badge badge--scope-security">llm-security</span>' +
'<span>' + escapeHtml(targetTypeLabel) + ' · ' + filled + '/' + reportTotal + ' rapporter · ' + pct + '%</span>' +
'</div>' +
'</button>'
);
}).join('');
return '<div class="fleet-grid">' + tiles + '</div>';
})();
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const activeReportCount = projects.reduce(function (a, p) { return a + projectReportCount(p); }, 0);
const homeShell = renderPageShell({
eyebrow: 'HJEM',
title: 'Hei, ' + (orgName || 'venn'),
lede: orgName
? 'Pick a work track or browse existing projects. Shared state is active and prefills forms.'
: 'Single-file security scanning + auditing for Claude Code projects. Start with onboarding to activate shared state.',
verdict: 'n-a',
hero: true,
meta: [
'Plugin v7.7.2',
projects.length + ' project' + (projects.length === 1 ? '' : 's'),
CATALOG.commands.length + ' commands'
],
keyStats: [
{ label: 'PROSJEKTER', value: projects.length },
{ label: 'AKTIVE RAPPORTER', value: activeReportCount },
{ label: 'KOMMANDOER', value: CATALOG.commands.length }
]
},
'<div class="stack-lg">' +
tracksHtml +
'<section class="home-projects">' +
'<span class="eyebrow">PROSJEKTER · ' + projects.length + '</span>' +
'<div class="home-section-head">' +
'<h2>Mine prosjekter</h2>' +
'<span class="home-section-meta">' + projects.length + ' prosjekt' + (projects.length === 1 ? '' : 'er') + ' · maks ' + reportTotal + ' rapporter per prosjekt</span>' +
'</div>' +
projectListHtml +
(projects.length > 0 ? '<div class="onboarding-actions" style="margin-top: var(--space-4);"><button type="button" class="btn btn--primary" data-action="new-project">Nytt prosjekt</button> <button type="button" class="btn btn--secondary" data-action="load-demo">Last inn demo-data (overskriver)</button></div>' : '') +
'</section>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Hjem') +
'<div class="app-shell">' + homeShell + '</div>'
);
}
// ============================================================
// CATALOG SURFACE
// ============================================================
let catalogSearchQuery = '';
let catalogFilter = 'all'; // 'all' | 'report' | 'tool' | <category-id>
function catalogMatches(cmd, q) {
if (!q) return true;
const hay = ((cmd.id || '') + ' ' + (cmd.label || '') + ' ' + (cmd.description || '') + ' ' + (cmd.argument_hint || '')).toLowerCase();
return hay.indexOf(q) >= 0;
}
function categoryLabelById(id) {
const c = (CATALOG.categories || []).find(function (x) { return x.id === id; });
return c ? c.label : id;
}
function filteredCatalogCommands() {
const q = catalogSearchQuery.toLowerCase().trim();
return (CATALOG.commands || []).filter(function (c) {
if (!catalogMatches(c, q)) return false;
if (catalogFilter === 'all') return true;
if (catalogFilter === 'report') return !!c.produces_report;
if (catalogFilter === 'tool') return !c.produces_report;
return c.category === catalogFilter;
});
}
function renderCatalogRowHtml(cmd) {
const isVerktoy = !cmd.produces_report;
const pill = isVerktoy ? '<span class="card__pill">Tool</span>' : '<span class="card__pill">Report</span>';
const hintHtml = cmd.argument_hint ? ' <code class="catalog-row__hint">' + escapeHtml(cmd.argument_hint) + '</code>' : '';
const fieldCount = (cmd.input_fields || []).length;
const fieldLabel = fieldCount + ' felt' + (fieldCount === 1 ? '' : 'er');
return (
'<button type="button" class="catalog-row" data-action="catalog-open-form" data-command="' + escapeAttr(cmd.id) + '" aria-label="Open builder for ' + escapeAttr(cmd.label) + '">' +
'<span class="catalog-row__main">' +
'<span class="catalog-row__head">' +
'<span class="catalog-row__id">/security:' + escapeHtml(cmd.id) + '</span>' +
'<span class="catalog-row__label">' + escapeHtml(cmd.label) + '</span>' +
hintHtml +
'</span>' +
'<span class="catalog-row__desc">' + escapeHtml(cmd.description) + '</span>' +
'</span>' +
'<span class="catalog-row__meta">' +
'<span class="catalog-row__category">' + escapeHtml(categoryLabelById(cmd.category)) + '</span>' +
pill +
'<span class="catalog-row__fields">' + fieldLabel + '</span>' +
'</span>' +
'</button>'
);
}
function renderCatalogChipsHtml() {
const total = (CATALOG.commands || []).length;
const reportCount = (CATALOG.commands || []).filter(function (c) { return c.produces_report; }).length;
const toolCount = total - reportCount;
const baseChips = [
{ id: 'all', label: 'Alle', count: total },
{ id: 'report', label: 'Rapport-produserende', count: reportCount },
{ id: 'tool', label: 'Tools', count: toolCount }
];
const categoryChips = (CATALOG.categories || []).map(function (cat) {
return { id: cat.id, label: cat.label, count: cat.count };
});
return baseChips.concat(categoryChips).map(function (chip) {
const active = catalogFilter === chip.id;
return (
'<button type="button" class="catalog-chip' + (active ? ' catalog-chip--active' : '') + '" data-action="catalog-filter" data-filter="' + escapeAttr(chip.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' +
escapeHtml(chip.label) + ' <span class="catalog-chip__count">' + chip.count + '</span>' +
'</button>'
);
}).join('');
}
function renderCatalogListBodyHtml() {
const cmds = filteredCatalogCommands();
if (cmds.length === 0) {
return '<div class="catalog-empty">No matches. Try a different search or filter.</div>';
}
return '<div class="catalog-list">' + cmds.map(renderCatalogRowHtml).join('') + '</div>';
}
function renderCatalogSurface() {
const root = getSurfaceEl('catalog');
if (!root) return;
const total = (CATALOG.commands || []).length;
const reportCount = (CATALOG.commands || []).filter(function (c) { return c.produces_report; }).length;
const toolCount = total - reportCount;
const catalogShell = renderPageShell({
eyebrow: 'KATALOG',
title: 'Command-katalog',
lede: 'All ' + total + ' commands. Search, filter, click a row to build the command string.',
verdict: 'n-a',
meta: [
total + ' kommandoer',
reportCount + ' rapport-produserende',
toolCount + ' tools'
],
keyStats: [
{ label: 'TOTALT', value: total },
{ label: 'RAPPORT-KOMMANDOER', value: reportCount },
{ label: 'TOOLS', value: toolCount }
]
},
'<div class="stack-lg">' +
'<input type="search" class="input catalog-search" placeholder="Search commands (id, label, description, argument hint) …" data-catalog-search value="' + escapeAttr(catalogSearchQuery) + '" aria-label="Search the command catalog">' +
'<div class="catalog-filter-chips" role="group" aria-label="Filtre">' + renderCatalogChipsHtml() + '</div>' +
'<div data-catalog-list>' + renderCatalogListBodyHtml() + '</div>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Katalog') +
'<div class="app-shell">' + catalogShell + '</div>'
);
}
// ============================================================
// PROJECT SURFACE (stub i Fase 1 — full report-render i Fase 2/3)
// ============================================================
let currentProjectTab = 'discover';
// Tracks which sub-cards are expanded — key: projectId + '::' + cmdId.
// Persists across re-renders so paste-import etc. doesn't collapse them.
const expandedSubcards = new Set();
function subcardKey(projectId, cmdId) { return projectId + '::' + cmdId; }
function renderCommandSubCard(cmd, projectId) {
const project = findProject(projectId);
const report = project && project.reports && project.reports[cmd.id];
const hasReport = !!(report && report.parsed);
const isExpanded = expandedSubcards.has(subcardKey(projectId, cmd.id));
const bodyId = 'subcard-body-' + cmd.id.replace(/[^a-zA-Z0-9_-]/g, '_');
const formZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Skjema</h4>' +
renderCommandForm(cmd.id, { projectId: projectId, scope: 'p' }) +
'</div>'
);
let pasteZone = '';
let reportZone = '';
if (cmd.produces_report) {
const sampleHint = 'Lim inn output fra <code>' + escapeHtml('/security ' + cmd.id) + '</code> her, eller bruk fixture-import (Fase 2/3).';
pasteZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Paste-import</h4>' +
'<div class="paste-import-row" data-paste-import="' + escapeAttr(cmd.id) + '" data-project-id="' + escapeAttr(projectId) + '">' +
'<textarea class="textarea" rows="4" placeholder="Lim inn markdown-output fra slash-kommandoen…" data-paste-text></textarea>' +
'<div class="paste-import-row__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="parse-paste" data-command="' + escapeAttr(cmd.id) + '">Parse og rendre</button>' +
(hasReport ? '<button type="button" class="btn btn--ghost btn--sm" data-action="clear-report" data-command="' + escapeAttr(cmd.id) + '">Fjern rapport</button>' : '') +
'<span style="font-size: var(--font-size-xs); color: var(--color-text-tertiary);">' + sampleHint + '</span>' +
'</div>' +
'</div>' +
'</div>'
);
reportZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Rapport</h4>' +
'<div class="report-slot" data-report-slot="' + escapeAttr(cmd.id) + '"></div>' +
'</div>'
);
} else {
reportZone = (
'<div class="sub-zone">' +
'<div class="catalog-tool-notice">Tool — this command does not produce a report. The form builds a pipeline string to run in the terminal.</div>' +
'</div>'
);
}
return (
'<article class="card command-subcard" data-command-subcard data-command-id="' + escapeAttr(cmd.id) + '">' +
'<button type="button" class="card__head card__head--toggle" data-action="toggle-subcard" data-command="' + escapeAttr(cmd.id) + '" data-project-id="' + escapeAttr(projectId) + '" aria-expanded="' + (isExpanded ? 'true' : 'false') + '" aria-controls="' + escapeAttr(bodyId) + '">' +
'<div class="card__head-text">' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
'<div class="card__head-meta">' +
'<span class="badge badge--scope-security">llm-security</span>' +
(cmd.produces_report
? '<span class="card__pill">' + (hasReport ? '✓ Rapport' : 'Rapport') + '</span>'
: '<span class="card__pill">Tool</span>'
) +
'</div>' +
'<span class="subcard-chev" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="subcard-body" id="' + escapeAttr(bodyId) + '"' + (isExpanded ? '' : ' hidden') + '>' +
formZone +
pasteZone +
reportZone +
'</div>' +
'</article>'
);
}
function renderProjectSurface() {
const root = getSurfaceEl('project');
if (!root) return;
const project = findProject(store.state.activeProjectId);
if (!project) { navigate('home'); return; }
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
const reportFilled = projectReportCount(project);
const actionBar = (
'<div class="onboarding-actions" style="justify-content: flex-end; margin-bottom: var(--space-4);">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">← Tilbake</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett</button>' +
'</div>'
);
const tabsHtml = '<div class="project-tabs" role="tablist">' + CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
return '<button type="button" class="project-tab" role="tab"' + (isActive ? ' aria-current="true"' : '') + ' data-action="project-tab" data-tab="' + escapeAttr(cat.id) + '">' + escapeHtml(cat.label) + '<span class="project-tab__count">' + cat.count + '</span></button>';
}).join('') + '</div>';
const panelsHtml = CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
const cards = CATALOG.commands.filter(function (c) { return c.category === cat.id; }).map(function (c) { return renderCommandSubCard(c, project.id); }).join('');
return '<div class="command-cards" role="tabpanel" data-tab-panel="' + escapeAttr(cat.id) + '"' + (isActive ? '' : ' hidden') + '>' + cards + '</div>';
}).join('');
const projectShell = renderPageShell({
eyebrow: 'PROSJEKT · ' + escapeHtml((project.target_type || 'codebase').toUpperCase()),
title: project.name,
lede: project.description || '',
verdict: inferProjectVerdict(project),
meta: [
'Target: ' + (project.target || project.target_path || '—'),
'Sist oppdatert: ' + inferProjectLastUpdated(project),
(project.scenario || project.template || 'standard')
],
keyStats: [
{ label: 'RAPPORTER', value: reportFilled + '/' + reportTotal },
{ label: 'SIST OPPDATERT', value: inferProjectLastUpdated(project) },
{ label: 'TARGET', value: (project.target_type || 'codebase') }
]
},
'<div class="stack-lg">' + actionBar + tabsHtml + panelsHtml + '</div>'
);
root.innerHTML = renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
'<div class="app-shell app-shell--wide">' + projectShell + '</div>';
queueMicrotask(rehydratePasteImports);
}
// ============================================================
// PAGE SHELL + VERDICT-PILL + KEY-STATS
// ============================================================
/**
* Render DS verdict-pill-lg (Tier 2 + Tier 3 supplement) — replaces v7.5.0 .verdict-pill.
*
* Produces a vertically-stacked verdict-pill-lg with optional sub-tekst:
* <div class="verdict-pill-lg" data-verdict="..."><span class="verdict-pill-lg__verdict">...</span><span class="verdict-pill-lg__sub">...</span></div>
*
* Use sites in playground (each renderPageShell-call producing verdict-pill-lg markup):
* 1. onboarding surface — verdict-pill-lg (n-a, hidden)
* 2. home surface — verdict-pill-lg from inferProjectVerdict-aggregate
* 3. catalog surface — verdict-pill-lg (n-a, hidden)
* 4. project surface — verdict-pill-lg from inferProjectVerdict
* 5-22. 18 archetype-renderere — verdict-pill-lg per rapport-type:
* scan: verdict-pill-lg, audit: verdict-pill-lg, deep-scan: verdict-pill-lg,
* posture: verdict-pill-lg, ros: verdict-pill-lg, plugin-audit: verdict-pill-lg,
* mcp-audit: verdict-pill-lg, mcp-inspect: verdict-pill-lg, threat-model: verdict-pill-lg,
* red-team: verdict-pill-lg, dashboard: verdict-pill-lg, ide-scan: verdict-pill-lg,
* diff: verdict-pill-lg, watch: verdict-pill-lg, supply-check: verdict-pill-lg,
* clean: verdict-pill-lg, harden: verdict-pill-lg, pre-deploy: verdict-pill-lg.
*
* data-verdict mapping (playground-keys → DS-keys):
* block, failed → 'block' (Tier 2)
* warning, go-with-conditions → 'warning' (Tier 2)
* go, approved, allow → 'allow' (Tier 2)
* n-a → 'n-a' (Tier 3 supplement)
*/
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 || '')
);
}
window.__renderPageShell = renderPageShell;
window.__renderVerdictPill = renderVerdictPill;
window.__renderKeyStatsGrid = renderKeyStatsGrid;
// ============================================================
// INFER VERDICT + KEY-STATS PER ARCHETYPE
// (Fase 2/3 utvider med flere archetypes)
// ============================================================
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: 'THREATS', value: threats.length },
{ label: 'MAX SCORE', value: maxScore || '', modifier: sev },
{ label: 'CELLS', 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 []; }
}
window.__inferVerdict = inferVerdict;
window.__inferKeyStats = inferKeyStats;
window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG;
// ============================================================
// DATA-VERSION MIGRATION (mirror av ms-ai-architect v1->v2)
// ============================================================
function migrateDataVersion(state, archetypeFor) {
if (!state) return state;
if (state.dataVersion === 2) return state;
const projects = state.projects || [];
for (let i = 0; i < projects.length; i++) {
const reports = (projects[i] && projects[i].reports) || {};
const ids = Object.keys(reports);
for (let j = 0; j < ids.length; j++) {
const cmdId = ids[j];
const r = reports[cmdId];
if (!r || !r.parsed) continue;
const arche = typeof archetypeFor === 'function' ? archetypeFor(cmdId) : null;
if (!arche) continue;
if (r.parsed.verdict == null) r.parsed.verdict = inferVerdict(r.parsed, arche);
if (!Array.isArray(r.parsed.keyStats)) r.parsed.keyStats = inferKeyStats(r.parsed, arche);
}
}
state.dataVersion = 2;
return state;
}
function defaultArchetypeFor(commandId) {
const cmds = (CATALOG && CATALOG.commands) || [];
for (let i = 0; i < cmds.length; i++) {
if (cmds[i].id === commandId) return cmds[i].report_archetype || null;
}
return null;
}
window.__migrateDataVersion = migrateDataVersion;
window.__defaultArchetypeFor = defaultArchetypeFor;
// ============================================================
// PARSER HELPERS (markdown → struktur)
// Phase 2: pattern copied from ms-ai-architect-playground.html lines 2469-2545.
// ============================================================
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) }] }; }
};
}
// ============================================================
// 10 PARSERS — one per high-priority command.
// Returner { ok: true, data: { ...domain-specific } } eller
// { 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);
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 + RENDERERS — routing-objekter
// Phase 2 had 10 high-priority parsers/renderers.
// Fase 3 utvider med 8 til (mcp-inspect, supply-check, pre-deploy,
// diff, watch, registry, clean, threat-model). Total 18 = alle
// produces_report=true-kommandoer i CATALOG.
// ============================================================
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-objekt populeres inline etter hver 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;
window.__PARSERS = PARSERS;
window.__RENDERERS = RENDERERS;
function handlePasteImport(commandId, markdown) {
const project = findProject(store.state.activeProjectId);
if (!project) return { ok: false, error: 'Mistet aktivt prosjekt' };
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
if (!cmd) return { ok: false, error: 'Ukjent command: ' + commandId };
if (!cmd.produces_report) return { ok: false, error: 'Tool commands do not produce a report' };
const parser = PARSERS[commandId];
if (typeof parser !== 'function') {
// Phase 1: parsers not yet implemented. Show placeholder.
const slot = document.querySelector('[data-report-slot="' + CSS.escape(commandId) + '"]');
if (slot) {
slot.innerHTML = (
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Parser not implemented yet</h3>' +
'<p class="guide-panel__text">Kommandoen <code>' + escapeHtml(commandId) + '</code> har ikke parser/renderer i Fase 1. Implementeres i Fase 2 eller 3 (se ARCHITECTURE.local.md, §4 «Kommando-katalog»).</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);">Received ' + markdown.length + ' characters of input. Stored as raw markdown in the project at <code>reports.' + escapeHtml(commandId) + '.raw_markdown</code>.</p>' +
'</div>' +
'</div>'
);
}
// Store the raw markdown (without parsing) — gives some state to export
if (!project.reports) project.reports = {};
project.reports[commandId] = {
input: (project.reports[commandId] && project.reports[commandId].input) || {},
raw_markdown: markdown,
parsed: null,
updatedAt: new Date().toISOString()
};
return { ok: false, deferred: true };
}
// Parser exists — run it (Phase 2/3)
const result = parser(markdown);
if (!result || result.ok === false) {
const slot = document.querySelector('[data-report-slot="' + CSS.escape(commandId) + '"]');
if (slot) {
const errors = (result && result.errors) || [{ section: 'unknown', reason: 'Ukjent parser-feil' }];
slot.innerHTML = '<div class="error-summary"><h3>Parser-feil</h3><ul>' +
errors.map(function (e) { return '<li><strong>' + escapeHtml(e.section) + ':</strong> ' + escapeHtml(e.reason) + '</li>'; }).join('') +
'</ul></div>';
}
return { ok: false, errors: result && result.errors };
}
// Berik med inferred verdict + key-stats hvis ikke allerede satt
if (result.data.verdict == null) result.data.verdict = inferVerdict(result.data, cmd.report_archetype);
if (!Array.isArray(result.data.keyStats)) result.data.keyStats = inferKeyStats(result.data, cmd.report_archetype);
const renderer = RENDERERS[cmd.renderer];
const slot = document.querySelector('[data-report-slot="' + CSS.escape(commandId) + '"]');
if (!renderer || !slot) {
if (slot) slot.innerHTML = '<div class="error-summary"><h3>Renderer ikke funnet: ' + escapeHtml(cmd.renderer || '(none)') + '</h3></div>';
return { ok: false, error: 'Mangler renderer' };
}
try { renderer(result.data, slot); }
catch (err) {
slot.innerHTML = '<div class="error-summary"><h3>Renderer kastet feil</h3><pre>' + escapeHtml(String(err)) + '</pre></div>';
return { ok: false, error: String(err) };
}
// Lagre i state
if (!project.reports) project.reports = {};
project.reports[commandId] = {
input: (project.reports[commandId] && project.reports[commandId].input) || {},
raw_markdown: markdown,
parsed: result.data,
updatedAt: new Date().toISOString()
};
return { ok: true };
}
function rehydratePasteImports() {
// Re-render eksisterende parsed-rapporter etter en surface-render
const project = findProject(store.state.activeProjectId);
if (!project || !project.reports) return;
Object.keys(project.reports).forEach(function (cmdId) {
const r = project.reports[cmdId];
if (!r || !r.parsed) return;
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
if (!cmd) return;
const renderer = RENDERERS[cmd.renderer];
const slot = document.querySelector('[data-report-slot="' + CSS.escape(cmdId) + '"]');
if (!renderer || !slot) return;
try { renderer(r.parsed, slot); } catch (e) { /* ignorer i rehydrate */ }
});
}
window.__handlePasteImport = handlePasteImport;
window.__rehydratePasteImports = rehydratePasteImports;
// ============================================================
// ACTION HANDLERS (delegated)
// ============================================================
function readFormDataFromCommandForm(commandId) {
const formEl = document.querySelector('form.command-form[data-command-form="' + CSS.escape(commandId) + '"]');
return readCommandFormValues(formEl);
}
function getProjectCommandFormEl(commandId) {
return document.querySelector('form.command-form[data-command-form="' + CSS.escape(commandId) + '"]');
}
async function copyCommandToClipboard(text) {
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) { /* fall through */ }
// Fallback for file:// uten clipboard-API
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
function openModal(html) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = '<div class="modal-backdrop" data-modal-backdrop>' + html + '</div>';
}
function closeModal() {
const root = document.getElementById('modal-root');
if (root) root.innerHTML = '';
}
function renderNewProjectModal() {
const scenarioCheckboxes = SCENARIOS.map(function (s, i) {
return (
'<label class="checkbox-row" for="np-scenario-' + i + '">' +
'<input type="checkbox" id="np-scenario-' + i + '" data-np-scenario="' + escapeAttr(s.id) + '">' +
'<span>' + escapeHtml(s.name) + '</span>' +
'</label>'
);
}).join('');
const targetTypeOpts = TARGET_TYPES.map(function (t) { return '<option value="' + escapeAttr(t) + '">' + escapeHtml(t) + '</option>'; }).join('');
return (
'<div class="modal" role="dialog" aria-labelledby="np-title">' +
'<div class="modal__head">' +
'<h2 id="np-title" class="modal__title">Nytt prosjekt</h2>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<form data-np-form autocomplete="off" onsubmit="return false;" style="display:flex; flex-direction:column; gap: var(--space-3);">' +
'<div class="field-row">' +
'<label class="field-label" for="np-name">Prosjektnavn<span class="required-mark">*</span></label>' +
'<input class="input" id="np-name" type="text" required>' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label" for="np-target-type">Target-type</label>' +
'<select class="select" id="np-target-type">' + targetTypeOpts + '</select>' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label" for="np-target-path">Target (path eller URL)</label>' +
'<input class="input" id="np-target-path" type="text" placeholder="~/repos/min-app eller https://github.com/org/repo">' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label" for="np-description">Beskrivelse (valgfri)</label>' +
'<textarea class="textarea" id="np-description" rows="2"></textarea>' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label">Scenarioer (kryss av relevante)</label>' +
'<fieldset class="multi-select">' + scenarioCheckboxes + '</fieldset>' +
'</div>' +
'<div style="display:flex; gap: var(--space-2); margin-top: var(--space-2);">' +
'<button type="button" class="btn btn--primary" data-action="np-create">Opprett</button>' +
'<button type="button" class="btn btn--ghost" data-action="close-modal">Avbryt</button>' +
'</div>' +
'</form>' +
'</div>'
);
}
function renderDeleteProjectModal(project) {
return (
'<div class="modal" role="dialog" aria-labelledby="dp-title">' +
'<div class="modal__head">' +
'<h2 id="dp-title" class="modal__title">Slett prosjekt?</h2>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<p>You are deleting the project <strong>' + escapeHtml(project.name) + '</strong>. All reports in the project will be lost. The operation cannot be undone.</p>' +
'<div style="display:flex; gap: var(--space-2);">' +
'<button type="button" class="btn btn--primary" data-action="dp-confirm" data-project-id="' + escapeAttr(project.id) + '" style="background: var(--color-severity-critical); border-color: var(--color-severity-critical);">Ja, slett</button>' +
'<button type="button" class="btn btn--ghost" data-action="close-modal">Avbryt</button>' +
'</div>' +
'</div>'
);
}
function renderCatalogFormModal(cmd) {
const formHtml = renderCommandForm(cmd.id, { scope: 'cat' });
const sharedCount = (cmd.input_fields || []).filter(function (f) { return f.from === 'shared'; }).length;
const sharedHint = sharedCount > 0
? '<p class="builder-modal__hint">Fields marked <span class="field-from-tag" style="cursor:default;">shared</span> are prefilled from onboarding (' + sharedCount + ' of ' + (cmd.input_fields || []).length + '). Changes here do not affect onboarding state.</p>'
: '<p class="builder-modal__hint">Fyll ut argumenter — pipeline-strengen oppdateres mens du skriver.</p>';
return (
'<div class="modal builder-modal" role="dialog" aria-labelledby="cf-title" data-builder-pane>' +
'<div class="modal__head">' +
'<div>' +
'<h2 id="cf-title" class="modal__title">' + escapeHtml(cmd.label) + ' <span style="font-family: var(--font-family-mono); font-size: var(--font-size-md); color: var(--color-text-tertiary); font-weight: var(--font-weight-regular);">/security:' + escapeHtml(cmd.id) + '</span></h2>' +
'</div>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<p class="builder-modal__lede">' + escapeHtml(cmd.description) + '</p>' +
sharedHint +
formHtml +
'</div>'
);
}
function attachActionHandlers() {
// v7.6.1 fix: matrix-bobler klikkbare. Klikk scroller til tilsvarende rad
// i Trusler-tabellen og fremhever den kort. Bruker data-threat-id som anker.
document.addEventListener('click', function (ev) {
const bubble = ev.target.closest('.matrix__bubble[data-threat-id]');
if (!bubble) return;
const threatId = bubble.getAttribute('data-threat-id');
if (!threatId) return;
// Find the row in the Threats table (TM-XXX in the first column)
const tables = document.querySelectorAll('table.report-table');
for (let t = 0; t < tables.length; t++) {
const rows = tables[t].querySelectorAll('tbody tr');
for (let r = 0; r < rows.length; r++) {
const firstCell = rows[r].querySelector('td');
if (firstCell && firstCell.textContent.trim() === threatId) {
rows[r].scrollIntoView({ behavior: 'smooth', block: 'center' });
const orig = rows[r].style.background;
rows[r].style.background = 'var(--color-primary-100, var(--color-bg-soft))';
rows[r].style.transition = 'background var(--duration-base) var(--ease-default)';
setTimeout(function () { rows[r].style.background = orig; }, 1600);
return;
}
}
}
});
document.addEventListener('click', function (ev) {
const target = ev.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const cmdId = target.dataset.command;
// Navigation
if (action === 'goto-home') return navigate('home');
if (action === 'goto-catalog') return navigate('catalog');
if (action === 'goto-onboarding') {
onboardingActiveStep = ONBOARDING_GROUPS[0].id;
return navigate('onboarding');
}
// Theme toggle
if (action === 'toggle-theme') {
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const next = cur === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
document.documentElement.style.colorScheme = next;
try { localStorage.setItem('llm-security-theme', next); } catch (e) {}
if (store && store.state && store.state.preferences) store.state.preferences.theme = next;
scheduleRender();
return;
}
// Export / import
if (action === 'export-state') return exportState();
if (action === 'import-state') {
const inp = document.querySelector('[data-import-input]');
if (inp) inp.click();
return;
}
// Demo data
if (action === 'load-demo') {
loadDemoState();
return;
}
// Onboarding
if (action === 'onboarding-step') {
onboardingActiveStep = target.dataset.step;
scheduleRender();
return;
}
if (action === 'onboarding-next') {
const idx = ONBOARDING_GROUPS.findIndex(function (g) { return g.id === onboardingActiveStep; });
if (idx < ONBOARDING_GROUPS.length - 1) {
onboardingActiveStep = ONBOARDING_GROUPS[idx + 1].id;
scheduleRender();
}
return;
}
if (action === 'onboarding-prev') {
const idx = ONBOARDING_GROUPS.findIndex(function (g) { return g.id === onboardingActiveStep; });
if (idx > 0) {
onboardingActiveStep = ONBOARDING_GROUPS[idx - 1].id;
scheduleRender();
}
return;
}
if (action === 'onboarding-finish') {
if (!store.state.activeSurface || store.state.activeSurface === 'onboarding') {
navigate('home');
}
return;
}
// Project tabs
if (action === 'project-tab') {
currentProjectTab = target.dataset.tab;
scheduleRender();
return;
}
// Sub-card toggle (Rapporter-tab) — direct DOM manipulation to preserve form-field state
if (action === 'toggle-subcard') {
const cmdId = target.dataset.command;
const projectId = target.dataset.projectId;
const article = target.closest('[data-command-subcard]');
const body = article ? article.querySelector('.subcard-body') : null;
if (!body) return;
const key = projectId + '::' + cmdId;
const willExpand = body.hasAttribute('hidden');
if (willExpand) {
expandedSubcards.add(key);
body.removeAttribute('hidden');
target.setAttribute('aria-expanded', 'true');
} else {
expandedSubcards.delete(key);
body.setAttribute('hidden', '');
target.setAttribute('aria-expanded', 'false');
}
return;
}
// Project lifecycle
if (action === 'open-project') {
const pid = target.dataset.projectId;
store.state.activeProjectId = pid;
currentProjectTab = 'discover';
navigate('project');
return;
}
if (action === 'new-project') {
openModal(renderNewProjectModal());
return;
}
if (action === 'delete-project') {
const pid = target.dataset.projectId;
const p = findProject(pid);
if (p) openModal(renderDeleteProjectModal(p));
return;
}
if (action === 'dp-confirm') {
const pid = target.dataset.projectId;
const list = store.state.projects;
for (let i = 0; i < list.length; i++) {
if (list[i].id === pid) { list.splice(i, 1); break; }
}
if (store.state.activeProjectId === pid) store.state.activeProjectId = null;
closeModal();
navigate('home');
return;
}
if (action === 'np-create') {
const modal = target.closest('.modal');
const name = modal.querySelector('#np-name').value.trim();
if (!name) { alert('Project name is required.'); return; }
const targetType = modal.querySelector('#np-target-type').value;
const targetPath = modal.querySelector('#np-target-path').value.trim();
const description = modal.querySelector('#np-description').value.trim();
const scenarios = Array.from(modal.querySelectorAll('[data-np-scenario]')).filter(function (el) { return el.checked; }).map(function (el) { return el.dataset.npScenario; });
const project = {
id: uuid(),
name: name,
description: description,
target_type: targetType,
target_path: targetPath,
scenarios: scenarios,
createdAt: new Date().toISOString(),
reports: {}
};
store.state.projects.push(project);
store.state.activeProjectId = project.id;
currentProjectTab = 'discover';
closeModal();
navigate('project');
return;
}
// Modal close
if (action === 'close-modal') {
closeModal();
return;
}
// Catalog
if (action === 'catalog-filter') {
const f = target.dataset.filter || 'all';
if (catalogFilter === f) return;
catalogFilter = f;
// Re-render in-place: chips (active state) + list body
const root = getSurfaceEl('catalog');
if (root) {
const chipsEl = root.querySelector('.catalog-filter-chips');
if (chipsEl) chipsEl.innerHTML = renderCatalogChipsHtml();
const listEl = root.querySelector('[data-catalog-list]');
if (listEl) listEl.innerHTML = renderCatalogListBodyHtml();
}
return;
}
if (action === 'catalog-open-form') {
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
if (!cmd) return;
openModal(renderCatalogFormModal(cmd));
// Initial live-preview: vis pipeline-streng med shared-prefill
const formEl = document.querySelector('[data-builder-pane] form.command-form[data-command-form="' + CSS.escape(cmd.id) + '"]');
if (formEl) {
const data = readCommandFormValues(formEl);
const str = buildCommand(cmd.id, data);
showCommandPreview(formEl, str);
// Auto-focus the first input for keyboard flow
const firstInput = formEl.querySelector('input:not([type="hidden"]), select, textarea');
if (firstInput) {
try { firstInput.focus(); } catch (e) { /* ignore */ }
}
}
return;
}
// Command form actions
if (action === 'preview-command') {
const formEl = ev.target.closest('form.command-form');
if (!formEl) return;
const data = readCommandFormValues(formEl);
const cid = formEl.dataset.commandForm;
const str = buildCommand(cid, data);
showCommandPreview(formEl, str);
// Save input on project forms (scope=p)
if (formEl.dataset.commandFormScope === 'p') {
const project = findProject(store.state.activeProjectId);
if (project) {
if (!project.reports) project.reports = {};
project.reports[cid] = project.reports[cid] || {};
project.reports[cid].input = data;
}
}
return;
}
if (action === 'copy-command') {
const formEl = ev.target.closest('form.command-form');
if (!formEl) return;
const data = readCommandFormValues(formEl);
const cid = formEl.dataset.commandForm;
const str = buildCommand(cid, data);
copyCommandToClipboard(str).then(function (ok) {
flashCopyConfirm(formEl, ok ? 'Copied to clipboard.' : 'Copy failed — use preview.');
showCommandPreview(formEl, str);
});
if (formEl.dataset.commandFormScope === 'p') {
const project = findProject(store.state.activeProjectId);
if (project) {
if (!project.reports) project.reports = {};
project.reports[cid] = project.reports[cid] || {};
project.reports[cid].input = data;
}
}
return;
}
// Paste-import
if (action === 'parse-paste') {
const row = target.closest('[data-paste-import]');
if (!row) return;
const ta = row.querySelector('[data-paste-text]');
if (!ta || !ta.value.trim()) return;
handlePasteImport(cmdId, ta.value);
return;
}
if (action === 'clear-report') {
const project = findProject(store.state.activeProjectId);
if (project && project.reports && project.reports[cmdId]) {
delete project.reports[cmdId];
const slot = document.querySelector('[data-report-slot="' + CSS.escape(cmdId) + '"]');
if (slot) slot.innerHTML = '';
scheduleRender();
}
return;
}
});
// Modal backdrop click closes
document.addEventListener('click', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-modal-backdrop]')) {
closeModal();
}
});
// ESC closes modal
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') closeModal();
});
// Catalog search + live builder-pane preview
document.addEventListener('input', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-catalog-search]')) {
catalogSearchQuery = ev.target.value;
const listEl = document.querySelector('[data-catalog-list]');
if (listEl) listEl.innerHTML = renderCatalogListBodyHtml();
return;
}
// Live preview inside builder-pane (catalog modal)
if (ev.target && ev.target.matches && ev.target.matches('[data-builder-pane] [data-cf-field]')) {
const formEl = ev.target.closest('form.command-form');
if (formEl) {
const data = readCommandFormValues(formEl);
const str = buildCommand(formEl.dataset.commandForm, data);
showCommandPreview(formEl, str);
}
// Fall through to onboarding handling below in case selector overlaps
}
// Onboarding fields persist on input (debounced via throttledWriter)
if (ev.target && ev.target.matches && ev.target.matches('[data-onboarding-field]')) {
const path = ev.target.dataset.cfField;
const t = ev.target.dataset.cfType;
let val;
if (t === 'multiSelect') {
const form = ev.target.closest('form');
val = Array.from(form.querySelectorAll('[data-cf-field="' + CSS.escape(path) + '"]')).filter(function (el) { return el.checked; }).map(function (el) { return el.dataset.cfMulti; });
} else if (t === 'boolean') {
val = ev.target.checked;
} else if (t === 'number') {
val = ev.target.value === '' ? null : Number(ev.target.value);
} else {
val = ev.target.value;
}
setOnboardingValue(path, val);
}
});
// Onboarding change for select/checkbox (input-event covers most, but
// some browsers fire 'change' instead for select)
document.addEventListener('change', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-onboarding-field]')) {
// Trigger same handling as input
const evt = new Event('input', { bubbles: true });
ev.target.dispatchEvent(evt);
// Re-render progress sidebar (cheap)
if (store.state.activeSurface === 'onboarding') {
scheduleRender();
}
}
// Builder-pane: select/checkbox change → live preview
if (ev.target && ev.target.matches && ev.target.matches('[data-builder-pane] [data-cf-field]')) {
const formEl = ev.target.closest('form.command-form');
if (formEl) {
const data = readCommandFormValues(formEl);
const str = buildCommand(formEl.dataset.commandForm, data);
showCommandPreview(formEl, str);
}
}
});
// Import file picker
document.addEventListener('change', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-import-input]')) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
importState(f).catch(function (err) {
alert('Import feilet: ' + err.message);
});
ev.target.value = ''; // reset input so the same file can be picked again
}
});
}
// ============================================================
// ENTRY POINT
// ============================================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
bootstrap().then(attachActionHandlers).catch(function (err) {
console.error('[llm-security playground] bootstrap failed:', err);
document.body.innerHTML = '<div class="app-shell" style="padding: var(--space-8);"><h1>Bootstrap-feil</h1><pre>' + escapeHtml(String(err)) + '</pre></div>';
});
});
} else {
bootstrap().then(attachActionHandlers).catch(function (err) {
console.error('[llm-security playground] bootstrap failed:', err);
document.body.innerHTML = '<div class="app-shell" style="padding: var(--space-8);"><h1>Bootstrap-feil</h1><pre>' + escapeHtml(String(err)) + '</pre></div>';
});
}
})();
</script>
</body>
</html>