ktg-plugin-marketplace/plugins/llm-security/playground/llm-security-playground.html
Kjell Tore Guttormsen f9b555aa64 fix(llm-security): playground v7.6.1 — visuelle bugs i v7.6.0
Seks bugs fanget av maintainer ved manuell verifisering i nettleser etter
v7.6.0-release. Alle skyldes mismatch mellom DS-klasser og hvordan
playground-rendrere brukte dem, eller manglende DS-implementasjoner av
klasser playground-rendrere antok eksisterte.

Fixes:
- renderFindingsBlock brukte .findings outer-class som DS har som
  2-kolonners grid (360px list + 1fr detail-panel) — headeren havnet
  i venstre kolonne, items i høyre, brutt layout i alle 18 rapporter
  med findings. Erstattet med .report-meta + h4 + findings__list >
  findings__group + findings__group-header + findings__items
  (korrekt DS-mønster, kun list-delen).
- .report-table manglet helt i DS men brukes i 7+ rendrere (OWASP,
  Supply chain, Scanner Risk Matrix, Plugin-meta, Permission-matrise,
  Live-meter, Siste runs, Godkjenninger, Mitigation roadmap). Lagt
  lokal CSS-implementasjon i playground-HTML style-blokk: border-
  collapse, zebra-hover, header-styling. Komplementerer DS-tokens
  uten å modifisere vendor.
- renderPreDeploy traffic-lights brukte .sm-card__grade som er fast
  28x28 px (én A-F-bokstav) — kuttet PASS til AS og PASS-WITH-NOTES
  til PASS-WITH-... i alle traffic-light-cards. Erstattet med
  bredde-tilpasset status-pill via inline styling (severity-soft +
  on tokens).
- Threat-model matrix-bobler ikke klikkbare. Erstattet span med
  button type=button data-threat-id + aria-label. Click-handler
  scroller til tilsvarende rad i Trusler-tabellen og fremhever
  den i 1.6 sek.
- Radar-labels overlappet ved 6+ akser fordi alle brukte
  text-anchor=middle. Økt SVG-størrelse 280 → 380, radius 105 → 125.
  Bytter text-anchor fra middle til start/end basert på horisontal-
  posisjon.
- recommendation-card__body tekstoverflyt på lange single-line tekster
  (vilkår, owner-tags, dato). Lagt overflow-wrap: anywhere;
  word-break: break-word i lokal style-blokk.

Verifisering:
- 4/4 fix-spesifikke smoke-tester passerer
- 18/18 renderere produserer fortsatt komplett HTML mot
  dft-komplett-demo (regresjons-test)
- Filendring playground.html 10677 → 10753 linjer (+76 netto)

Versjonsbump v7.6.0 → v7.6.1 (patch — bugfix-only, ingen scanner- eller
hook-atferdsendringer):
- plugins/llm-security/.claude-plugin/plugin.json
- plugins/llm-security/package.json
- plugins/llm-security/README.md (badge)
- plugins/llm-security/CHANGELOG.md ([7.6.1] entry)
- plugins/llm-security/playground/llm-security-playground.html (footer)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 14:33:19 +02:00

10753 lines
552 KiB
HTML
Raw Permalink 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. Må kjøre før stylesheets parses for å unngå
flash-of-wrong-theme (FOUC). Prioritet:
1) lagret valg (localStorage 'llm-security-theme')
2) OS-preferanse via matchMedia('(prefers-color-scheme: dark)')
3) HTML-attributtets default ('dark')
Setter både data-theme + colorScheme for native form-controls/scrollbars.
Wrappes i try/catch — file:// + privatmodus kan blokkere localStorage. -->
<script>
(function () {
var theme = null;
try {
var saved = localStorage.getItem('llm-security-theme');
if (saved === 'light' || saved === 'dark') theme = saved;
} catch (e) { /* localStorage utilgjengelig */ }
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. Kilden er shared/playground-design-system/ — synces via
scripts/sync-design-system.mjs ved marketplace-rot. Aldri rediger filer under
playground/vendor/ direkte; endringer går i 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-spesifikk layout. Alt komponent-CSS som har en DS-pendant er
fjernet i v7.6.0 fase 1-4 — DS Tier 3-supplement vinner cascade. Her bor
kun side-spesifikk layout-grid (sidebar+main, modals), playground-only
komponenter (.tracks, .field-from-tag), og bevisste overskrivinger som
DS ikke dekker (.expansion__body markup, .multi-select fieldset-ramme,
.checkbox-row accent-color). Fase 3 (sesjon 2): playground-ens lokale
verdict-pill-blokk er fjernet — DS dekker via Tier 2 (block/warning/allow)
+ Tier 3 supplement (severity-bands). Fase 4: form-progress steg er
erstattet av DS fp-step-mønster. */
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); }
.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: "Ingen importert rapport ennå."; 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: var(--space-5); }
.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; }
/* 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 kan inneholde lange single-line
tekster (vilkår, owner-tags, dato). Tving word-wrap så grid-celle
(auto + 1fr) ikke skubber innhold utenfor viewport. */
.recommendation-card__body { overflow-wrap: anywhere; word-break: break-word; }
/* v7.6.1 fix: matrix-bobler skal være klikkbare. DS har hover på cellene,
men bobler er <span> uten cursor. Gjør bubble til 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 "Last inn demo-data"-knapp.
Mirror av shared/playground-examples/security-direktorat.html-scenario.
I Fase 2/3 utvides denne med fulle parsed-rapporter; her i Fase 1 er
reports{} tom på begge prosjekter. -->
<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>
<!--
Klassisk script (ikke type="module") av to grunner:
1. External <script type="module" src="..."> feiler på file:// i Chrome+Firefox.
2. Single-file deployment per brief Constraints — ingen build-step.
Fase 1 leverer skjelett: state, persistens, surface-router, onboarding/home/catalog/project-stub.
Fase 2 utvider PARSERS + RENDERERS for 10 høy-prio kommandoer.
Fase 3 utvider med resterende 10 + screenshots + 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 primær, 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 nærmer seg 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;
// Initial-surface heuristikk
const orgName = store.state.shared && store.state.shared.organization && store.state.shared.organization.name;
if (!orgName) store.state.activeSurface = 'onboarding';
else if (!store.state.activeSurface) store.state.activeSurface = 'home';
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 store på import (proxy-cache er skjelett-bundet til gammel 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
// (implementeres i Fase 2/3). Verktøy-commands har 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: 'Skann skills/MCP/directories/GitHub repos. Detekterer secrets, injection, supply-chain-risiko, OWASP LLM-mønstre.',
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-inspeksjon (JSON-RPC mot kjørende servere)', 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: 'Koble til kjørende MCP-servere og skann tool-deskripsjoner 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: 'Rask scorecard på 13/16 kategorier. Inkluderer EU AI Act, NIST AI RMF, ISO 42001 hvis valgt.',
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: 'Maks søke-dybde', 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: 'Produksjonsmiljø', type: 'select', from: 'local', options: ['Cloud (Azure)', 'Cloud (AWS)', 'Cloud (GCP)', 'On-prem', 'Hybrid', 'Air-gapped'] },
{ id: 'data_classification', label: 'Dataklassifisering', type: 'select', from: 'local', options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig'] }
]
},
// ===== FINDINGS-OPS (4) =====
{
id: 'diff',
category: 'findings-ops',
label: 'Diff mot baseline',
description: 'Sammenlign scan-resultat mot lagret baseline — viser nye, løste, uendrede og flyttede funn.',
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: 'Kontinuerlig monitorering — kjør diff på rekursivt intervall 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 — vis stats, skann og registrer skills, søk kjente 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: 'Søkestreng (kun search)', 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-scenarier på tvers av 12 kategorier mot plugin hooks. --adaptive for mutasjon-basert 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: '(deterministisk verktøy)',
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-kommando — viser tilgjengelige sub-commands. Verktøy for navigasjon, ingen rapport.',
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="Forhåndsutfylt fra onboarding (state.shared.' + escapeAttr(field.shared_path || '') + ')">felles</span>'
: '';
const requiredMark = field.required ? '<span class="required-mark" aria-label="påkrevd">*</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) + '">Forhåndsvis</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;
const active = store.state.activeSurface || 'home';
showSurface(active);
if (active === 'onboarding') renderOnboardingSurface();
else if (active === 'home') renderHomeSurface();
else if (active === 'project') renderProjectSurface();
else if (active === '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) {
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const breadcrumbInner = (orgName ? escapeHtml(orgName) : '') + (orgName && crumb ? ' · ' : '') + (crumb || '');
const breadcrumbHtml = breadcrumbInner
? '<nav class="app-header__breadcrumb" aria-label="Brødsmuler">' + breadcrumbInner + '</nav>'
: '';
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const themeLabel = currentTheme === 'light' ? 'Lys' : 'Mørk';
const themeNext = currentTheme === 'light' ? 'mørk' : 'lys';
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">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">Hjem</button>' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-catalog">Katalog</button>' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding">Re-onboard</button>' +
'<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="Bytt til ' + themeNext + ' modus">' +
'<span data-theme-label>' + themeLabel + '</span>' +
'</button>' +
'</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-miljøer', 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 form-progress__steps wrapper-container per DS-mønster.
*/
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: 'Disse 5 gruppene er felles state. De forhåndsutfyller alle command-skjemaer for nye prosjekter, så du slipper å re-skrive samme 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">Ferdig — gå til hjem</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">Oppdater fellesfeltene som forhåndsutfyller alle command-skjemaer.</p>' +
'<span class="tracks__card-meta"><span>Felles state</span><span class="tracks__card-cta">Åpne →</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">Bla i alle ' + CATALOG.commands.length + ' commands gruppert på kategori. Generer pipeline-strenger uten et prosjekt.</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">Du har ingen prosjekter ennå</h3>' +
'<p class="guide-panel__text">Opprett ditt første prosjekt for å starte sikkerhetsskanning og auditing. Eller last inn demo-data for å se hvordan det ser ut.</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">Opprett første prosjekt</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
? 'Velg arbeidsspor eller utforsk eksisterende prosjekter. Felles state er aktiv og forhåndsutfyller skjemaer.'
: 'Single-file sikkerhetsskanning + auditing for Claude Code-prosjekter. Start med onboarding for å aktivere felles state.',
verdict: 'n-a',
hero: true,
meta: [
'Plugin v7.6.1',
projects.length + ' prosjekt' + (projects.length === 1 ? '' : 'er'),
CATALOG.commands.length + ' kommandoer'
],
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 = '';
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 renderCatalogCardHtml(cmd) {
const isVerktoy = !cmd.produces_report;
const pill = isVerktoy ? '<span class="card__pill">Verktøy</span>' : '<span class="card__pill">Rapport</span>';
const hintHtml = cmd.argument_hint ? '<span class="card__hint">' + escapeHtml(cmd.argument_hint) + '</span>' : '';
const verktoyNotice = isVerktoy ? '<div class="catalog-tool-notice">Verktøy — ingen rapport-import. Skjema bygger pipeline-streng som kjøres i terminalen.</div>' : '';
return (
'<article class="card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
'<div class="card__head">' +
'<div>' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
hintHtml +
'</div>' +
'<div style="display:flex; flex-direction:column; gap:6px; align-items:flex-end;">' +
'<span class="badge badge--scope-security">llm-security</span>' +
pill +
'</div>' +
'</div>' +
verktoyNotice +
'<div class="card__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="catalog-open-form" data-command="' + escapeAttr(cmd.id) + '">Åpne skjema</button>' +
'<span style="font-size: var(--font-size-xs); color: var(--color-text-tertiary);">' + (cmd.input_fields || []).length + ' felter</span>' +
'</div>' +
'</article>'
);
}
function renderCatalogGroupsHtml() {
const q = catalogSearchQuery.toLowerCase().trim();
return CATALOG.categories.map(function (cat) {
const cmds = CATALOG.commands.filter(function (c) { return c.category === cat.id && catalogMatches(c, q); });
if (cmds.length === 0 && q) return ''; // skjul tomme grupper ved aktiv søk
const isOpen = q !== '' || cat.id === 'discover'; // discover åpen som default
const cardsHtml = cmds.length > 0
? '<div class="catalog-cards-grid">' + cmds.map(renderCatalogCardHtml).join('') + '</div>'
: '<p style="color: var(--color-text-tertiary); margin: var(--space-3) 0;">Ingen kommandoer i denne kategorien.</p>';
return (
'<div class="expansion" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
'<button type="button" class="expansion__head" data-action="catalog-toggle-group" data-group="' + escapeAttr(cat.id) + '">' +
'<span class="expansion__title">' +
'<span class="expansion__title-main">' + escapeHtml(cat.label) + '</span>' +
'<span class="expansion__title-sub">' + cmds.length + ' av ' + cat.count + ' kommandoer' + (q ? ' (filtrert)' : '') + '</span>' +
'</span>' +
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="expansion__body">' + cardsHtml + '</div>' +
'</div>'
);
}).join('');
}
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: 'Alle ' + total + ' kommandoer gruppert på kategori. Bygg pipeline-strenger uten et aktivt prosjekt.',
verdict: 'n-a',
meta: [
total + ' kommandoer',
reportCount + ' rapport-produserende',
toolCount + ' verktøy'
],
keyStats: [
{ label: 'TOTALT', value: total },
{ label: 'RAPPORT-KOMMANDOER', value: reportCount },
{ label: 'VERKTØY', value: toolCount }
]
},
'<div class="stack-lg">' +
'<input type="search" class="input catalog-search" placeholder="Søk i kommandoer (id, label, beskrivelse, argument-hint) …" data-catalog-search value="' + escapeAttr(catalogSearchQuery) + '" aria-label="Søk i kommando-katalogen">' +
'<div data-catalog-groups>' + renderCatalogGroupsHtml() + '</div>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Katalog') +
'<div class="app-shell">' + catalogShell + '</div>'
);
// Bevarer fokus i søkefeltet under re-render
const searchEl = root.querySelector('[data-catalog-search]');
if (searchEl && document.activeElement !== searchEl && catalogSearchQuery) {
// Ikke stjel fokus med mindre brukeren akkurat skrev — håndteres i action handler
}
}
// ============================================================
// PROJECT SURFACE (stub i Fase 1 — full report-render i Fase 2/3)
// ============================================================
let currentProjectTab = 'discover';
let currentProjectScreen = 'rapporter';
function renderCommandSubCard(cmd, projectId) {
const project = findProject(projectId);
const report = project && project.reports && project.reports[cmd.id];
const hasReport = !!(report && report.parsed);
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">Verktøy — denne kommandoen produserer ikke en rapport. Skjemaet bygger en pipeline-streng som kjøres i terminalen.</div>' +
'</div>'
);
}
return (
'<article class="card" data-command-subcard data-command-id="' + escapeAttr(cmd.id) + '">' +
'<div class="card__head">' +
'<div>' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
'<div style="display:flex; flex-direction:column; gap:6px; align-items:flex-end;">' +
'<span class="badge badge--scope-security">llm-security</span>' +
(cmd.produces_report
? '<span class="card__pill">' + (hasReport ? '✓ Rapport' : 'Rapport') + '</span>'
: '<span class="card__pill">Verktøy</span>'
) +
'</div>' +
'</div>' +
formZone +
pasteZone +
reportZone +
'</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 SCREENS = [
{ id: 'oversikt', label: 'Oversikt' },
{ id: 'rapporter', label: 'Rapporter' },
{ id: 'kontekst', label: 'Kontekst' },
{ id: 'eksport', label: 'Eksport' }
];
const screenTabsHtml = '<nav class="tab-list" role="tablist" aria-label="Prosjekt-skjermer">' + SCREENS.map(function (s) {
const isActive = currentProjectScreen === s.id;
return '<button type="button" class="tab" role="tab" aria-current="' + (isActive ? 'true' : 'false') + '" data-action="project-screen" data-screen="' + escapeAttr(s.id) + '">' + escapeHtml(s.label) + '</button>';
}).join('') + '</nav>';
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 scenarioChipsList = (project.scenarios || []).map(function (sid) {
const s = SCENARIOS.find(function (x) { return x.id === sid; });
return '<li>' + escapeHtml(s ? s.name : sid) + '</li>';
}).join('');
const oversiktHtml = (
'<div class="tab-panel" data-screen-id="oversikt"' + (currentProjectScreen === 'oversikt' ? '' : ' hidden') + '>' +
'<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">Oversikt</h3>' +
'<p class="guide-panel__text">Opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + '. ' + reportFilled + ' av ' + reportTotal + ' rapporter generert.</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);">Target: <code>' + escapeHtml(project.target_path || '—') + '</code> (<em>' + escapeHtml(project.target_type || 'codebase') + '</em>)</p>' +
(scenarioChipsList ? '<p class="guide-panel__text" style="margin-top: var(--space-2);"><strong>Scenarioer:</strong></p><ul style="margin: 0; padding-left: var(--space-4); color: var(--color-text-secondary);">' + scenarioChipsList + '</ul>' : '') +
'<p class="guide-panel__text" style="margin-top: var(--space-3);"><em>Fase 2-3: aggregert verdict-pille, top-funn på tvers av rapporter, og recommended-next-actions vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const rapporterHtml = '<div class="tab-panel" data-screen-id="rapporter"' + (currentProjectScreen === 'rapporter' ? '' : ' hidden') + '>' + tabsHtml + panelsHtml + '</div>';
const kontekstHtml = (
'<div class="tab-panel" data-screen-id="kontekst"' + (currentProjectScreen === 'kontekst' ? '' : ' hidden') + '>' +
'<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">Kontekst</h3>' +
'<p class="guide-panel__text">Fellesfeltene fra onboarding gjenbrukes automatisk i alle command-skjemaer. Bruk <button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding" style="display:inline;">Re-onboard</button> for å oppdatere.</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);"><em>Fase 2-3: snapshot av de 5 fellesgruppene og hvilke felt som prefilles per kommando vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const eksportHtml = (
'<div class="tab-panel" data-screen-id="eksport"' + (currentProjectScreen === 'eksport' ? '' : ' hidden') + '>' +
'<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">Eksport</h3>' +
'<p class="guide-panel__text">Bruk <strong>Eksporter</strong> i toppmenyen for hele state. Per-prosjekt PDF/Markdown-eksport kommer i Fase 3.</p>' +
'</div>' +
'</div>' +
'</div>'
);
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 + screenTabsHtml + oversiktHtml + rapporterHtml + kontekstHtml + eksportHtml + '</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 page-shell — DS Tier 3 page__header-klyngen brukt på alle 4 overflater:
* - onboarding: page__eyebrow="ONBOARDING · n av 5 grupper komplette"
* - home: page__eyebrow="HJEM" (m/ hero-modifier for editorial type-hierarki)
* - catalog: page__eyebrow="KATALOG"
* - project: page__eyebrow="PROSJEKT · <TARGET>"
* Pluss alle 18 rapport-renderere (eyebrow per archetype).
* Verdict-rendering via renderVerdictPill — produserer DS verdict-pill-lg.
* opts: { eyebrow, title, lede, meta:[], verdict, verdictSub, hero, keyStats }
*/
function renderPageShell(opts, bodyHtml) {
opts = opts || {};
const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
const title = '<h1 class="page__title">' + escapeHtml(opts.title || '') + '</h1>';
const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
const meta = (opts.meta && opts.meta.length)
? '<div class="page__meta">' + opts.meta.map(function (m) { return '<span>' + escapeHtml(m) + '</span>'; }).join('') + '</div>'
: '';
const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict, opts.verdictSub) : '';
const aside = verdict ? '<div class="page__header-aside">' + verdict + '</div>' : '';
const stats = renderKeyStatsGrid(opts.keyStats);
const heroClass = opts.hero ? ' page__header--hero' : '';
return (
'<header class="page__header' + heroClass + '">' +
'<div class="page__header-main">' + eyebrow + title + lede + meta + '</div>' +
aside +
'</header>' +
stats +
(bodyHtml || '')
);
}
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: 'TOTALT', value: fs.length },
{ label: 'KRITISK', value: crit, modifier: crit > 0 ? 'critical' : null },
{ label: 'HØY', value: high, modifier: high > 0 ? 'high' : null }
];
},
'findings-grade': function (d) {
const out = [];
if (d.grade) out.push({ label: 'GRADE', value: String(d.grade).toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') });
if (d.score != null) out.push({ label: 'SCORE', value: d.score });
if (d.findings) out.push({ label: 'FUNN', value: d.findings.length });
return out;
},
'risk-score-meter': function (d) {
const out = [];
if (d.risk_score != null) {
const mod = d.risk_score >= 65 ? 'critical' : (d.risk_score >= 15 ? 'medium' : 'low');
out.push({ label: 'RISK SCORE', value: d.risk_score, modifier: mod });
}
if (d.riskBand) out.push({ label: 'BAND', value: d.riskBand });
return out;
},
'red-team-results': function (d) {
return [
{ label: 'TOTALT', value: d.total || 0 },
{ label: 'PASS', value: d.pass_count || 0, modifier: 'low' },
{ label: 'FAIL', value: d.fail_count || 0, modifier: (d.fail_count > 0 ? 'critical' : null) }
];
},
'dashboard-fleet': function (d) {
return [
{ label: 'PROSJEKTER', value: (d.projects || []).length },
{ label: 'MASKINKLASSE', value: String(d.machine_grade || 'n/a').toUpperCase() },
{ label: 'SVAKEST', value: d.weakest_link || '' }
];
},
'posture-cards': function (d) {
const cats = d.categories || [];
const pass = cats.filter(function (c) { return c.status === 'PASS'; }).length;
const fail = cats.filter(function (c) { return c.status === 'FAIL'; }).length;
return [
{ label: 'GRADE', value: String(d.grade || '?').toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') },
{ label: 'PASS', value: pass, modifier: 'low' },
{ label: 'FAIL', value: fail, modifier: fail > 0 ? 'critical' : 'low' }
];
},
'diff-report': function (d) {
const newCount = (d['new'] || []).length;
const unchangedCount = (d.unchanged || []).length;
return [
{ label: 'NÅ-GRADE', value: String(d.current_grade || '?').toUpperCase() },
{ label: 'AKSJONER', value: newCount, modifier: newCount > 0 ? 'medium' : 'low' },
{ label: 'SKIPPED', value: unchangedCount }
];
},
'kanban-buckets': function (d) {
const auto = (d.buckets && d.buckets.auto) || d.auto || [];
const semi = (d.buckets && (d.buckets['semi-auto'] || d.buckets.semi_auto)) || d['semi-auto'] || d.semi_auto || [];
const manual = (d.buckets && d.buckets.manual) || d.manual || [];
return [
{ label: 'AUTO', value: auto.length, modifier: 'low' },
{ label: 'SEMI-AUTO', value: semi.length, modifier: semi.length ? 'medium' : 'low' },
{ label: 'MANUAL', value: manual.length, modifier: manual.length ? 'high' : 'low' }
];
},
'matrix-risk': function (d) {
const threats = d.threats || d.findings || [];
const cells = d.matrix_cells || [];
const maxScore = cells.length ? Math.max.apply(null, cells.map(function (c) { return Number(c.score) || 0; })) : 0;
const sev = maxScore >= 16 ? 'critical' : maxScore >= 9 ? 'high' : maxScore >= 4 ? 'medium' : 'low';
return [
{ label: 'TRUSLER', value: threats.length },
{ label: 'MAKS SCORE', value: maxScore || '', modifier: sev },
{ label: 'CELLER', value: cells.length }
];
}
};
function inferKeyStats(data, archetype) {
if (!data) return [];
if (Array.isArray(data.keyStats)) return data.keyStats;
const fn = KEY_STATS_CONFIG[archetype];
if (typeof fn !== 'function') return [];
try {
const out = fn(data);
return Array.isArray(out) ? out : [];
} catch (e) { return []; }
}
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)
// Fase 2: kopiert mønster fra ms-ai-architect-playground.html linjer 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;
}
// Hjelper: parse Risk Dashboard-tabellen (fellesmønster)
function parseRiskDashboard(md) {
const out = {};
const score = extractField(md, 'Risk Score');
if (score) {
const m = /(\d+)\s*\/\s*100/.exec(score);
if (m) out.risk_score = parseInt(m[1], 10);
else out.risk_score = intOrZero(score);
}
const band = extractField(md, 'Risk Band');
if (band) out.riskBand = band;
const grade = extractField(md, 'Grade');
if (grade) out.grade = gradeFromText(grade);
const verdict = extractField(md, 'Verdict');
if (verdict) {
const norm = normalizeVerdictText(verdict);
if (norm) out.verdict = norm;
}
const rationale = extractField(md, 'Verdict rationale');
if (rationale) out.verdict_rationale = rationale;
// Severity counts-tabell (Severity | Count) — etter Risk Dashboard-headeren
const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*Count/i);
if (sevTbl && sevTbl.rows.length) {
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0, total: 0 };
sevTbl.rows.forEach(function (row) {
const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, '');
const n = intOrZero(row[sevTbl.headers[1]] || '0');
if (/^critical|^kritisk/.test(label)) counts.critical = n;
else if (/^high|^høy/.test(label)) counts.high = n;
else if (/^medium/.test(label)) counts.medium = n;
else if (/^low|^lav/.test(label)) counts.low = n;
else if (/^info/.test(label)) counts.info = n;
else if (/^total/.test(label)) counts.total = n;
});
if (!counts.total) {
counts.total = counts.critical + counts.high + counts.medium + counts.low + counts.info;
}
out.severity_counts = counts;
}
return out;
}
// Hjelper: parse alle findings-tabeller (### Critical / High / Medium / Low / Info)
function parseFindingsTables(md) {
const findings = [];
// Match alle ### <Severity>-headere innenfor ## Findings
const findingsSection = parseSections(md).find(function (s) {
return /^findings$/i.test(s.heading) || /^funn$/i.test(s.heading);
});
if (!findingsSection) return findings;
const body = findingsSection.body;
// Splitt på ### -headere
const subRe = /^###\s+(.+)$/gm;
const matches = [];
let m;
while ((m = subRe.exec(body)) !== null) {
matches.push({ severity: m[1].trim(), index: m.index });
}
for (let i = 0; i < matches.length; i++) {
const start = matches[i].index;
const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
const chunk = body.slice(start, end);
const tbl = parseTable(chunk);
if (!tbl || !tbl.rows.length) continue;
const sev = matches[i].severity.split(/[\s/,]/)[0]; // "Low / Info" → "Low"
tbl.rows.forEach(function (row) {
const idKey = tbl.headers[0];
const catKey = tbl.headers.find(function (h) { return /category|kategori/i.test(h); });
const fileKey = tbl.headers.find(function (h) { return /file|fil/i.test(h); });
const lineKey = tbl.headers.find(function (h) { return /^line$|linje/i.test(h); });
const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); });
const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); });
findings.push({
id: row[idKey] || '',
severity: normalizeSeverity(sev),
category: catKey ? row[catKey] : '',
file: fileKey ? row[fileKey] : '',
line: lineKey ? row[lineKey] : '',
description: descKey ? row[descKey] : '',
owasp: owaspKey ? row[owaspKey] : ''
});
});
}
return findings;
}
function parseRecommendations(md) {
const sec = parseSections(md).find(function (s) { return /^recommendations$|^anbefalinger$/i.test(s.heading); });
if (!sec) return [];
const out = [];
const lines = sec.body.split(/\r?\n/);
lines.forEach(function (line) {
const m = /^\s*(?:\d+\.|[-*])\s+(.+)$/.exec(line);
if (m) out.push(m[1].replace(/^\*\*[^*]+\*\*[:]?\s*/, '').trim());
});
return out;
}
function safeOk(parser) {
return function (md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
try { return parser(md); }
catch (e) { return { ok: false, errors: [{ section: 'parser', reason: String(e && e.message || e) }] }; }
};
}
// ============================================================
// 10 PARSERS — én per høy-prio kommando.
// 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 = første prosjekt sortert worst-first (allerede sortert i fixture)
const weakest = projects.length ? projects[0].name : '';
return { ok: true, data: Object.assign({}, dash, {
machine_grade: machine_grade,
projects_scanned: projects_scanned,
total_findings: total_findings,
cache: cache,
projects: projects,
trends: trends,
errors: errors,
weakest_link: weakest,
recommendations: parseRecommendations(md)
}) };
});
const parseHarden = safeOk(function (md) {
const current_grade = gradeFromText(extractField(md, 'Current Grade') || '');
const project_type = extractField(md, 'Project Type') || '';
const recRaw = extractField(md, 'Recommendations') || '';
let actionable = 0, total = 0;
const recMatch = /(\d+)\s*\/\s*(\d+)/.exec(recRaw);
if (recMatch) { actionable = parseInt(recMatch[1], 10); total = parseInt(recMatch[2], 10); }
const mode = extractField(md, 'Mode') || 'dry-run';
// Recommendations: ### N. Category — File med Action / Content preview
const sections = parseSections(md);
const recSec = sections.find(function (s) { return /^recommendations$/i.test(s.heading); });
const recommendations = [];
if (recSec) {
const body = recSec.body;
const subRe = /^###\s+(\d+)\.\s+(.+?)\s+[—-]\s+(.+)$/gm;
const heads = [];
let m;
while ((m = subRe.exec(body)) !== null) {
heads.push({ num: parseInt(m[1], 10), category: m[2].trim(), file: m[3].trim(), index: m.index });
}
for (let i = 0; i < heads.length; i++) {
const start = heads[i].index;
const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
const chunk = body.slice(start, end);
const actionMatch = /-\s+\*\*Action:\*\*\s*(.+)$/im.exec(chunk);
const contentMatch = /-\s+\*\*Content preview:\*\*\s*([\s\S]*?)(?=\n-\s+\*\*|\n###|\n##|$)/i.exec(chunk);
recommendations.push({
num: heads[i].num,
category: heads[i].category,
file: heads[i].file,
action: actionMatch ? actionMatch[1].trim() : '',
content_preview: contentMatch ? contentMatch[1].trim() : ''
});
}
}
// Diff Summary-tabell
const diffTbl = parseTable(md, /##\s+Diff\s+Summary/i);
const diff_summary = diffTbl ? diffTbl.rows
.filter(function (row) { return !/^\s*\*\*total/i.test(row[diffTbl.headers[0]] || ''); })
.map(function (row) {
return {
file: row[diffTbl.headers[0]] || '',
action: row[diffTbl.headers[1]] || '',
lines: row[diffTbl.headers[2]] || ''
};
}) : [];
// Map til diff-archetype: new = create, resolved = (none), unchanged = skipped
const newItems = recommendations.filter(function (r) { return /create|append|merge/i.test(r.action); });
const skippedItems = recommendations.filter(function (r) { return /none|skip/i.test(r.action); });
return { ok: true, data: {
current_grade: current_grade,
project_type: project_type,
actionable: actionable,
total: total,
mode: mode,
recommendations: recommendations,
diff_summary: diff_summary,
'new': newItems,
unchanged: skippedItems,
resolved: [],
moved: []
} };
});
const parseRedTeam = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const defenseRaw = extractField(md, 'Defense Score') || '';
const defense_score = intOrZero(defenseRaw);
const total = intOrZero(extractField(md, 'Total Scenarios') || '0');
const pass_count = intOrZero(extractField(md, 'Pass') || '0');
const fail_count = intOrZero(extractField(md, 'Fail') || '0');
const adaptive = /^on/i.test(String(extractField(md, 'Adaptive Mode') || ''));
// Per-Category Breakdown-tabell
const catTbl = parseTable(md, /##\s+Per-Category\s+Breakdown/i);
const categories = catTbl ? catTbl.rows.map(function (row) {
return {
category: row[catTbl.headers[0]] || '',
pass: intOrZero(row[catTbl.headers[1]] || '0'),
fail: intOrZero(row[catTbl.headers[2]] || '0'),
coverage: row[catTbl.headers[3]] || ''
};
}) : [];
// Failed Scenarios med severity-grupper
const sections = parseSections(md);
const failSec = sections.find(function (s) { return /failed\s+scenarios/i.test(s.heading); });
const scenarios = [];
if (failSec) {
const body = failSec.body;
const subRe = /^###\s+(.+)$/gm;
const heads = [];
let m;
while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index });
for (let i = 0; i < heads.length; i++) {
const start = heads[i].index;
const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
const chunk = body.slice(start, end);
const tbl = parseTable(chunk);
if (!tbl || !tbl.rows.length) continue;
tbl.rows.forEach(function (row) {
scenarios.push({
id: row[tbl.headers[0]] || '',
severity: normalizeSeverity(heads[i].severity),
category: row[tbl.headers[1]] || '',
payload_class: row[tbl.headers[2]] || '',
reason: row[tbl.headers[3]] || ''
});
});
}
}
// Test History
const histTbl = parseTable(md, /##\s+Test\s+History/i);
const history = histTbl ? histTbl.rows.map(function (row) {
return {
run: row[histTbl.headers[0]] || '',
date: row[histTbl.headers[1]] || '',
defense_score: intOrZero(row[histTbl.headers[2]] || '0'),
delta: row[histTbl.headers[3]] || ''
};
}) : [];
return { ok: true, data: Object.assign({}, dash, {
defense_score: defense_score,
total: total,
pass_count: pass_count,
fail_count: fail_count,
adaptive: adaptive,
categories: categories,
scenarios: scenarios,
history: history,
recommendations: parseRecommendations(md)
}) };
});
// ============================================================
// FASE 3: 8 PARSERS — én per gjenstående produces_report-kommando.
// Mønstre gjenbrukes fra Fase 2 (parseRiskDashboard + parseFindingsTables
// + safeOk). Matrix-risk-parsing er kopiert fra ms-ai-architect.
// ============================================================
const parseMcpInspect = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const invTbl = parseTable(md, /##\s+Server\s+Inventory/i);
const server_inventory = invTbl ? invTbl.rows.map(function (row) {
return {
server: row[invTbl.headers[0]] || '',
transport: row[invTbl.headers[1]] || '',
tools: intOrZero(row[invTbl.headers[2]] || '0'),
status: row[invTbl.headers[3]] || '',
connected: /^yes|^ja/i.test(String(row[invTbl.headers[4]] || ''))
};
}) : [];
const cpTbl = parseTable(md, /##\s+Codepoint\s+Reveal/i);
const codepoints = cpTbl ? cpTbl.rows.map(function (row) {
return {
server: row[cpTbl.headers[0]] || '',
tool: row[cpTbl.headers[1]] || '',
codepoints: row[cpTbl.headers[2]] || '',
risk: row[cpTbl.headers[3]] || ''
};
}) : [];
// Findings: merge default finding-shape med server-spesifikk meta
const findingsRaw = parseFindingsTables(md);
const findings = findingsRaw.map(function (f) {
// Severity-tabellene bruker «Server» som kolonne → category=Server, file=tom
return Object.assign({}, f, {
server: f.category || f.file || '',
file: f.file || ''
});
});
return { ok: true, data: Object.assign({}, dash, {
server_inventory: server_inventory,
codepoints: codepoints,
findings: findings,
recommendations: parseRecommendations(md)
}) };
});
const parseSupplyCheck = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const ecoTbl = parseTable(md, /##\s+Ecosystem\s+Coverage/i);
const ecosystems = ecoTbl ? ecoTbl.rows
.filter(function (row) { return !/^\s*\*\*total/i.test(row[ecoTbl.headers[0]] || ''); })
.map(function (row) {
return {
ecosystem: row[ecoTbl.headers[0]] || '',
lockfile: row[ecoTbl.headers[1]] || '',
packages: intOrZero(row[ecoTbl.headers[2]] || '0'),
osv_hits: intOrZero(row[ecoTbl.headers[3]] || '0'),
typosquats: intOrZero(row[ecoTbl.headers[4]] || '0')
};
}) : [];
return { ok: true, data: Object.assign({}, dash, {
ecosystems: ecosystems,
findings: parseFindingsTables(md),
recommendations: parseRecommendations(md)
}) };
});
const parsePreDeploy = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const lightTbl = parseTable(md, /##\s+Traffic\s+Light\s+Categories/i);
const traffic_lights = lightTbl ? lightTbl.rows.map(function (row) {
const status = String(row[lightTbl.headers[1]] || '').toUpperCase().trim();
return {
category: row[lightTbl.headers[0]] || '',
status: status,
notes: row[lightTbl.headers[2]] || ''
};
}) : [];
const condSec = parseSections(md).find(function (s) { return /^conditions/i.test(s.heading); });
const conditions = condSec ? condSec.body.split(/\r?\n/).map(function (l) {
const m = /^\s*\d+\.\s+(.+)$/.exec(l);
return m ? m[1].replace(/^\*\*[^*]+\*\*\s*[—:-]?\s*/, '').trim() : null;
}).filter(Boolean) : [];
const apprTbl = parseTable(md, /##\s+Approvals/i);
const approvals = apprTbl ? apprTbl.rows.map(function (row) {
return {
role: row[apprTbl.headers[0]] || '',
approver: row[apprTbl.headers[1]] || '',
date: row[apprTbl.headers[2]] || '',
notes: row[apprTbl.headers[3]] || ''
};
}) : [];
return { ok: true, data: Object.assign({}, dash, {
traffic_lights: traffic_lights,
conditions: conditions,
approvals: approvals,
findings: parseFindingsTables(md),
recommendations: parseRecommendations(md)
}) };
});
const parseDiff = safeOk(function (md) {
// NB: diff har egen severity-tabell (New/Resolved/Unchanged) — bruker
// ikke parseRiskDashboard sin Count-kolonne.
const dash = parseRiskDashboard(md);
const current_grade = gradeFromText(extractField(md, 'Current Grade') || dash.grade || '');
const baseline_grade = gradeFromText(extractField(md, 'Baseline Grade') || '');
const baseline_date = extractField(md, 'Baseline') || '';
// Per-severity matrix (Severity | New | Resolved | Unchanged)
const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*New\s*\|\s*Resolved/i);
const severity_matrix = { critical: {}, high: {}, medium: {}, low: {}, info: {} };
if (sevTbl) {
sevTbl.rows.forEach(function (row) {
const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, '');
const key = /^crit/.test(label) ? 'critical' :
/^high/.test(label) ? 'high' :
/^medium/.test(label) ? 'medium' :
/^low/.test(label) ? 'low' :
/^info/.test(label) ? 'info' : null;
if (!key) return;
severity_matrix[key] = {
'new': intOrZero(row[sevTbl.headers[1]] || '0'),
resolved: intOrZero(row[sevTbl.headers[2]] || '0'),
unchanged: intOrZero(row[sevTbl.headers[3]] || '0')
};
});
}
// Per-bucket finding-tabeller
const newTbl = parseTable(md, /##\s+New\s*\(?\d*\)?/i);
const newItems = newTbl ? newTbl.rows.map(function (row) {
const idKey = newTbl.headers[0];
const sevKey = newTbl.headers.find(function (h) { return /severity/i.test(h); });
const catKey = newTbl.headers.find(function (h) { return /category|kategori/i.test(h); });
const fileKey = newTbl.headers.find(function (h) { return /file|fil/i.test(h); });
const descKey = newTbl.headers.find(function (h) { return /description|beskriv/i.test(h); });
const owaspKey = newTbl.headers.find(function (h) { return /owasp/i.test(h); });
return {
id: row[idKey] || '',
severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
category: catKey ? row[catKey] : '',
file: fileKey ? row[fileKey] : '',
description: descKey ? row[descKey] : '',
owasp: owaspKey ? row[owaspKey] : ''
};
}) : [];
const resolvedTbl = parseTable(md, /##\s+Resolved\s*\(?\d*\)?/i);
const resolvedItems = resolvedTbl ? resolvedTbl.rows.map(function (row) {
const idKey = resolvedTbl.headers[0];
const sevKey = resolvedTbl.headers.find(function (h) { return /severity/i.test(h); });
const catKey = resolvedTbl.headers.find(function (h) { return /category|kategori/i.test(h); });
const fileKey = resolvedTbl.headers.find(function (h) { return /file|fil/i.test(h); });
const resKey = resolvedTbl.headers.find(function (h) { return /resolution|løsning/i.test(h); });
return {
id: row[idKey] || '',
severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
category: catKey ? row[catKey] : '',
file: fileKey ? row[fileKey] : '',
resolution: resKey ? row[resKey] : ''
};
}) : [];
const unchangedTbl = parseTable(md, /##\s+Unchanged\s*\(?\d*\)?/i);
const unchangedItems = unchangedTbl ? unchangedTbl.rows.map(function (row) {
const idKey = unchangedTbl.headers[0];
const sevKey = unchangedTbl.headers.find(function (h) { return /severity/i.test(h); });
const catKey = unchangedTbl.headers.find(function (h) { return /category|kategori/i.test(h); });
const fileKey = unchangedTbl.headers.find(function (h) { return /file|fil/i.test(h); });
const noteKey = unchangedTbl.headers.find(function (h) { return /notes|note|merknad/i.test(h); });
return {
id: row[idKey] || '',
severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
category: catKey ? row[catKey] : '',
file: fileKey ? row[fileKey] : '',
notes: noteKey ? row[noteKey] : ''
};
}) : [];
const movedTbl = parseTable(md, /##\s+Moved\s*\(?\d*\)?/i);
const movedItems = movedTbl ? movedTbl.rows.map(function (row) {
return {
id: row[movedTbl.headers[0]] || '',
from: row[movedTbl.headers[1]] || '',
to: row[movedTbl.headers[2]] || ''
};
}) : [];
return { ok: true, data: Object.assign({}, dash, {
current_grade: current_grade,
baseline_grade: baseline_grade,
baseline_date: baseline_date,
severity_matrix: severity_matrix,
'new': newItems,
resolved: resolvedItems,
unchanged: unchangedItems,
moved: movedItems,
recommendations: parseRecommendations(md)
}) };
});
const parseWatch = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const meterTbl = parseTable(md, /##\s+Live\s+Meter/i);
const live_meter = {};
if (meterTbl) {
meterTbl.rows.forEach(function (row) {
const k = String(row[meterTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_');
live_meter[k] = row[meterTbl.headers[1]] || '';
});
}
const histTbl = parseTable(md, /##\s+Recent\s+History/i);
const history = histTbl ? histTbl.rows.map(function (row) {
return {
run: row[histTbl.headers[0]] || '',
time: row[histTbl.headers[1]] || '',
grade: gradeFromText(row[histTbl.headers[2]] || ''),
risk_score: intOrZero(row[histTbl.headers[3]] || '0'),
delta: row[histTbl.headers[4]] || ''
};
}) : [];
const notTbl = parseTable(md, /##\s+Notify\s+Events/i);
const notify_events = notTbl ? notTbl.rows.map(function (row) {
return {
time: row[notTbl.headers[0]] || '',
event: row[notTbl.headers[1]] || '',
channel: row[notTbl.headers[2]] || '',
status: row[notTbl.headers[3]] || ''
};
}) : [];
return { ok: true, data: Object.assign({}, dash, {
live_meter: live_meter,
history: history,
notify_events: notify_events,
findings: parseFindingsTables(md),
recommendations: parseRecommendations(md),
interval: extractField(md, 'Interval') || '',
last_run: extractField(md, 'Last Run') || ''
}) };
});
const parseRegistry = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const statsTbl = parseTable(md, /##\s+Registry\s+Stats/i);
const stats = {};
if (statsTbl) {
statsTbl.rows.forEach(function (row) {
const k = String(row[statsTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_');
stats[k] = row[statsTbl.headers[1]] || '';
});
}
const sigTbl = parseTable(md, /##\s+Signature\s+Table/i);
const signatures = sigTbl ? sigTbl.rows.map(function (row) {
return {
skill: row[sigTbl.headers[0]] || '',
source: row[sigTbl.headers[1]] || '',
fingerprint: row[sigTbl.headers[2]] || '',
status: String(row[sigTbl.headers[3]] || '').toUpperCase().trim(),
first_seen: row[sigTbl.headers[4]] || ''
};
}) : [];
// Findings — bruk renderFindingsBlock men med skill+file som meta
const findingsRaw = parseFindingsTables(md);
const findings = findingsRaw.map(function (f) {
// Tabell-header: «Skill» som 3. kolonne maps til category i parseFindingsTables
return Object.assign({}, f, {
skill: f.category || '',
file: f.file || ''
});
});
return { ok: true, data: Object.assign({}, dash, {
stats: stats,
signatures: signatures,
findings: findings,
recommendations: parseRecommendations(md)
}) };
});
const parseClean = safeOk(function (md) {
const dash = parseRiskDashboard(md);
const sumTbl = parseTable(md, /##\s+Remediation\s+Summary/i);
const summary = {};
if (sumTbl) {
sumTbl.rows
.filter(function (row) { return !/^\s*\*\*total/i.test(row[sumTbl.headers[0]] || ''); })
.forEach(function (row) {
const k = String(row[sumTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/[\s-]/g, '_');
summary[k] = {
count: intOrZero(row[sumTbl.headers[1]] || '0'),
action: row[sumTbl.headers[2]] || ''
};
});
}
// Per-bucket-tabeller (Auto / Semi-auto / Manual / Suppressed)
const bucketParse = function (heading) {
const tbl = parseTable(md, new RegExp('##\\s+' + heading + '\\s*$', 'mi'));
if (!tbl || !tbl.rows.length) return [];
return tbl.rows.map(function (row) {
const idKey = tbl.headers[0];
const actKey = tbl.headers[1];
const descKey = tbl.headers[2];
return {
id: row[idKey] || '',
action: row[actKey] || '',
description: row[descKey] || ''
};
});
};
const buckets = {
auto: bucketParse('Auto'),
'semi-auto': bucketParse('Semi-auto'),
manual: bucketParse('Manual'),
suppressed: bucketParse('Suppressed')
};
return { ok: true, data: Object.assign({}, dash, {
summary: summary,
buckets: buckets,
findings: parseFindingsTables(md),
recommendations: parseRecommendations(md),
mode: extractField(md, 'Mode') || ''
}) };
});
const parseThreatModel = safeOk(function (md) {
const dash = parseRiskDashboard(md);
// Risikomatrise: Trussel | Sannsynlighet | Konsekvens | Score
const matrixTbl = parseTable(md, /##\s+Risikomatrise/i);
const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) {
const labelKey = matrixTbl.headers[0];
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); }) || matrixTbl.headers[1];
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); }) || matrixTbl.headers[2];
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); }) || matrixTbl.headers[3];
return {
label: row[labelKey] || '',
prob: intOrZero(row[sannKey] || '0'),
cons: intOrZero(row[konsKey] || '0'),
score: intOrZero(row[scoreKey] || '0')
};
}) : [];
// Trusler: ID | Beskrivelse | Severity | Mitigation
const threatsTbl = parseTable(md, /##\s+Trusler/i);
const threats = threatsTbl ? threatsTbl.rows.map(function (row) {
const idKey = threatsTbl.headers[0];
const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1];
const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
return {
id: row[idKey] || '',
description: row[descKey] || '',
severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
mitigation: mitKey ? row[mitKey] : ''
};
}) : [];
// STRIDE / MAESTRO Coverage
const strideTbl = parseTable(md, /##\s+STRIDE\s+Coverage/i);
const stride = strideTbl ? strideTbl.rows.map(function (row) {
return {
category: row[strideTbl.headers[0]] || '',
count: intOrZero(row[strideTbl.headers[1]] || '0'),
notes: row[strideTbl.headers[2]] || ''
};
}) : [];
const maestroTbl = parseTable(md, /##\s+MAESTRO\s+Coverage/i);
const maestro = maestroTbl ? maestroTbl.rows.map(function (row) {
return {
layer: row[maestroTbl.headers[0]] || '',
count: intOrZero(row[maestroTbl.headers[1]] || '0'),
notes: row[maestroTbl.headers[2]] || ''
};
}) : [];
// Mitigation Roadmap
const roadTbl = parseTable(md, /##\s+Mitigation\s+Roadmap/i);
const roadmap = roadTbl ? roadTbl.rows.map(function (row) {
return {
priority: row[roadTbl.headers[0]] || '',
threat_id: row[roadTbl.headers[1]] || '',
mitigation: row[roadTbl.headers[2]] || '',
owner: row[roadTbl.headers[3]] || '',
eta: row[roadTbl.headers[4]] || ''
};
}) : [];
return { ok: true, data: Object.assign({}, dash, {
matrix_cells: matrix_cells,
threats: threats,
stride: stride,
maestro: maestro,
roadmap: roadmap,
recommendations: parseRecommendations(md),
framework: extractField(md, 'Framework') || ''
}) };
});
// ============================================================
// PARSERS + RENDERERS — routing-objekter
// Fase 2 hadde 10 høy-prio parsere/renderere.
// 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 || 'Ingen data å vise.') + '</p>' +
'</div>' +
'</div>';
}
function renderFindingsBlock(findings, label) {
if (!findings || !findings.length) return '';
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
const sorted = findings.slice().sort(function (a, b) {
return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9);
});
const items = sorted.map(function (f) {
const sev = String(f.severity || 'info').toLowerCase();
// DS Tier 3 (v7.6.0 fase 5h): card--severity-{level} modifier på outer
// .findings__item gir severity-tinted left-border. Beholdes ved siden av
// den eksisterende .findings__item-severity-dot for ARIA + visuell
// redundans (border-farge + dot-fyll signaliserer samme severity).
const sevClass = 'card--severity-' + (sev === 'info' ? 'info' : sev);
const meta = [
f.file ? f.file + (f.line ? ':' + f.line : '') : '',
f.category || '',
f.owasp || ''
].filter(Boolean).join(' · ');
return (
'<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' +
'<div class="findings__item-id">' + escapeHtml(f.id || '—') + '</div>' +
'<div class="findings__item-title">' + escapeHtml(f.description || f.title || '') + '</div>' +
(meta ? '<div class="findings__item-meta">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'</div>'
);
}).join('');
// DS .findings outer-class er et 2-kolonners grid (360px list + 1fr detail-panel) —
// playgroundet bruker bare list-delen, så vi wrapper i .findings__list (uten outer
// .findings) for å unngå at headeren ender i venstre 360px-kolonne. v7.6.1 fix.
return (
'<section class="report-meta">' +
'<h4>' + escapeHtml(label || 'Funn') + '</h4>' +
'<div class="findings__list" style="max-height: none;">' +
'<div class="findings__group">' +
'<div class="findings__group-header">' + escapeHtml(label || 'Funn') + ' (' + findings.length + ')</div>' +
'<div class="findings__items">' + items + '</div>' +
'</div>' +
'</div>' +
'</section>'
);
}
/**
* Render recommendation-card med ordnet liste av anbefalinger.
* Tredje argument (severity) styrer DS-tier3 `data-severity`-attributtet:
* 'critical' / 'high' / 'medium' / 'low' / 'positive'. Default 'low'
* (info-tonet). Mapping: severity → border-left-farge + label-bakgrunn.
*/
function renderRecommendationsList(recs, label, severity) {
if (!recs || !recs.length) return '';
const sev = severity || 'low';
const items = recs.map(function (r) { return '<li>' + escapeHtml(r) + '</li>'; }).join('');
return (
'<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(label || 'Anbefalinger') + '</span>' +
'<ol class="recommendation-card__body">' + items + '</ol>' +
'</section>'
);
}
/**
* Map severity-string til DS-tier3 recommendation-card data-severity.
* Aksepterer både severity-konvensjoner (critical/high/medium/low/info)
* og action-types (CREATE/APPEND/MERGE/SKIP/NONE).
*/
function mapSeverityToCardLevel(input) {
const s = String(input || '').toLowerCase().trim();
if (!s) return 'low';
if (s === 'critical' || s === 'crit') return 'critical';
if (s === 'high') return 'high';
if (s === 'medium' || s === 'med') return 'medium';
if (s === 'low') return 'low';
if (s === 'info') return 'low';
if (s === 'positive' || s === 'success' || s === 'ok' || s === 'pass') return 'positive';
// Action-types fra renderHarden
if (s === 'create') return 'positive';
if (s === 'append') return 'medium';
if (s === 'merge') return 'low';
if (s === 'skip' || s === 'none') return 'low';
return 'low';
}
function renderRiskMeter(score, band) {
const s = Math.max(0, Math.min(100, Number(score) || 0));
const bands = [
{ label: 'Low', from: 0, to: 14 },
{ label: 'Medium', from: 15, to: 39 },
{ label: 'High', from: 40, to: 64 },
{ label: 'Critical', from: 65, to: 84 },
{ label: 'Extreme', from: 85, to: 100 }
];
const labels = bands.map(function (b) {
const w = (b.to - b.from + 1);
return '<span class="risk-meter__band-label" data-band="' + escapeAttr(b.label.toLowerCase()) + '" style="flex: ' + w + '; text-align: center; min-width: 0;">' + escapeHtml(b.label) + '</span>';
}).join('');
return (
'<div class="risk-meter">' +
'<div class="risk-meter__readout"><span class="risk-meter__score">' + s + '</span><span> / 100 · ' + escapeHtml(band || '') + '</span></div>' +
'<div class="risk-meter__track"><div class="risk-meter__pointer" style="left: ' + s + '%"></div></div>' +
'<div class="risk-meter__bands">' + labels + '</div>' +
'<div class="risk-meter__scale"><span>0</span><span>50</span><span>100</span></div>' +
'</div>'
);
}
function renderSmallMultiples(items) {
// items: [{ name, score, max, grade?, status? }]
if (!items || !items.length) return '';
const cards = items.map(function (it) {
const score = Number(it.score) || 0;
const max = Number(it.max) || 5;
const pct = Math.max(0, Math.min(100, (score / max) * 100));
const grade = it.grade || '';
const gradeAttr = grade ? ' data-grade="' + escapeAttr(grade) + '"' : '';
return (
'<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(it.name || '') + '</span>' +
(grade ? '<span class="sm-card__grade"' + gradeAttr + '>' + escapeHtml(grade) + '</span>' : '') +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + pct.toFixed(0) + '%"></div></div>' +
'<span class="sm-card__status">' + escapeHtml(it.status || (score + ' / ' + max)) + '</span>' +
'</div>'
);
}).join('');
return '<div class="small-multiples">' + cards + '</div>';
}
function renderRadarSvg(axes) {
// axes: [{ name, score (0-5) }]
if (!axes || axes.length < 3) return '';
// v7.6.1 fix: øk SVG-bredden fra 280 til 380 og r fra 105 til 125 for å gi
// labels mer plass. Bruk text-anchor basert på horisontal-posisjon for å
// unngå at bottom-labels overlapper hverandre ved 6+ akser.
const size = 380, cx = size / 2, cy = size / 2, r = 125;
const n = axes.length;
const axisRows = axes.map(function (a) {
return '<div class="radar__score-row"><span>' + escapeHtml(a.name) + '</span><strong>' + escapeHtml(String(a.score || 0)) + '/5</strong></div>';
}).join('');
const angle = function (i) { return -Math.PI / 2 + (i * 2 * Math.PI / n); };
const labelHtml = axes.map(function (a, i) {
const ang = angle(i);
const lx = cx + Math.cos(ang) * (r + 28);
const ly = cy + Math.sin(ang) * (r + 28);
// Velg text-anchor basert på posisjon: ankerene til venstre/høyre snur.
const dx = Math.cos(ang);
const anchor = Math.abs(dx) < 0.2 ? 'middle' : (dx > 0 ? 'start' : 'end');
return '<text class="radar__label" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '" text-anchor="' + anchor + '" dominant-baseline="middle">' + escapeHtml(a.name) + '</text>';
}).join('');
const grids = [1, 2, 3, 4, 5].map(function (k) {
const rk = (r * k) / 5;
const pts = axes.map(function (a, i) {
const ang = angle(i);
return (cx + Math.cos(ang) * rk).toFixed(1) + ',' + (cy + Math.sin(ang) * rk).toFixed(1);
}).join(' ');
return '<polygon class="radar__grid-line" points="' + pts + '" fill="none" stroke-opacity="' + (0.15 + k * 0.05) + '"/>';
}).join('');
const pts = axes.map(function (a, i) {
const ang = angle(i);
const sc = Math.max(0, Math.min(5, Number(a.score) || 0));
const rs = (r * sc) / 5;
return (cx + Math.cos(ang) * rs).toFixed(1) + ',' + (cy + Math.sin(ang) * rs).toFixed(1);
}).join(' ');
return (
'<div class="radar">' +
'<div class="radar__chart">' +
'<svg class="radar__svg" viewBox="0 0 ' + size + ' ' + size + '" width="100%" height="' + size + '">' +
grids + labelHtml +
'<polygon class="radar__series" points="' + pts + '" fill-opacity="0.25" stroke-width="2"/>' +
'</svg>' +
'</div>' +
'<div class="radar__scores">' + axisRows + '</div>' +
'</div>'
);
}
// ============================================================
// TIER 3 SPESIALKOMPONENTER — DS-helpers (v7.6.0 fase 5a-d).
// ============================================================
/**
* Render tfa-flow + tfa-leg + tfa-arrow for et lethal trifecta-funn.
* Brukes på scan + deep-scan-rapporter når findings inneholder
* en trifecta-pattern (f.eks. SCN-002 "Lethal trifecta: [Bash, Read, WebFetch]").
* Synthesiserer 3-leddet kjede: untrusted-input → sensitive-access → exfil-sink.
*/
function renderToxicFlow(findings) {
if (!findings || !findings.length) return '';
const trifectaFinding = findings.find(function (f) {
const desc = String(f.description || '');
const cat = String(f.category || '');
const owasp = String(f.owasp || '');
return /trifecta/i.test(desc) || /trifecta/i.test(cat) ||
/excessive\s*agency/i.test(cat) ||
/ASI01/i.test(owasp);
});
if (!trifectaFinding) return '';
const sev = String(trifectaFinding.severity || 'critical').toLowerCase();
const verdictMap = { critical: 'BLOCK', high: 'BLOCK', medium: 'WARN', low: 'ALLOW' };
const verdict = verdictMap[sev] || 'BLOCK';
const fileLine = trifectaFinding.file
? trifectaFinding.file + (trifectaFinding.line ? ':' + trifectaFinding.line : '')
: 'agent definition';
// Default trifecta-bensin: WebFetch + Read + Bash. Override hvis description nevner andre.
const desc = String(trifectaFinding.description || '');
const m = desc.match(/\[([^\]]+)\]/);
let tools = ['WebFetch', 'Read', 'Bash'];
if (m) {
const parsed = m[1].split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (parsed.length === 3) tools = parsed;
}
const legs = [
{ label: 'Untrusted input', name: tools[0], source: fileLine, mit: 'unmitigated', mitText: 'Ingen pre-prompt-inject-scan eller post-mcp-verify guard' },
{ label: 'Sensitive access', name: tools[1], source: '.env / credentials / git-history', mit: 'unmitigated', mitText: 'Ingen pre-write-pathguard på sti' },
{ label: 'Exfil sink', name: tools[2], source: 'curl / fetch til ekstern host', mit: 'unmitigated', mitText: 'Ingen post-session-guard trifecta-deteksjon' }
];
const legHtml = function (leg) {
return (
'<button class="tfa-leg" type="button" data-severity="' + escapeAttr(sev) + '" aria-label="' + escapeAttr(leg.label + ': ' + leg.name) + '">' +
'<span class="tfa-leg__label">' + escapeHtml(leg.label) + '</span>' +
'<span class="tfa-leg__name">' + escapeHtml(leg.name) + '</span>' +
'<span class="tfa-leg__source">' + escapeHtml(leg.source) + '</span>' +
'<span class="tfa-leg__status" data-mit="' + escapeAttr(leg.mit) + '">' + escapeHtml(leg.mitText) + '</span>' +
'</button>'
);
};
const arrowHtml = '<div class="tfa-arrow" data-severity="' + escapeAttr(sev) + '" aria-hidden="true"><div class="tfa-arrow__line"></div></div>';
return (
'<section class="report-meta">' +
'<h4>Toxic flow — Lethal trifecta-kjede</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Den fulle 3-leddete kjeden som overskrider Rule of Two. Hver leg er umitigert — ingen hook bryter kjeden.</p>' +
'<div class="tfa-flow" role="group" aria-label="Lethal trifecta-kjede">' +
'<div class="tfa-flow__verdict" data-verdict="' + escapeAttr(verdict) + '">' + escapeHtml(verdict) + '</div>' +
legHtml(legs[0]) + arrowHtml + legHtml(legs[1]) + arrowHtml + legHtml(legs[2]) +
'</div>' +
'</section>'
);
}
/**
* Render mat-ladder + mat-step for posture-modenhet.
* Mapper antall PASS-kategorier til 5 modenhetstrinn (Initial → Optimized).
*/
function renderMatLadder(categories, postureScore, postureApplicable) {
if (!categories || !categories.length) return '';
const passCount = postureScore != null
? Number(postureScore)
: categories.filter(function (c) { return c.status === 'PASS'; }).length;
const total = postureApplicable != null
? Number(postureApplicable)
: categories.filter(function (c) { return c.status !== 'N-A' && c.status !== 'N/A'; }).length;
const pct = total > 0 ? Math.round((passCount / total) * 100) : 0;
// 5 modenhetstrinn — terskler basert på % PASS
const steps = [
{ num: 1, name: 'Initial', threshold: 0, desc: 'Bare bones — ingen hooks eller minimal posture.' },
{ num: 2, name: 'Aware', threshold: 25, desc: 'Posture-skanning aktiv, kjenner risikoene.' },
{ num: 3, name: 'Defensive', threshold: 50, desc: 'Hooks engasjert på kritiske flater (PreToolUse, UserPromptSubmit).' },
{ num: 4, name: 'Mature', threshold: 75, desc: 'De fleste 16 kategoriene dekket; trifecta-deteksjon på.' },
{ num: 5, name: 'Optimized', threshold: 95, desc: 'Full coverage; A-grade på posture; aktiv overvåking.' }
];
const currentIdx = steps.reduce(function (acc, s, i) {
return pct >= s.threshold ? i : acc;
}, 0);
const stepHtml = steps.map(function (s, i) {
const state = i < currentIdx ? 'completed' : i === currentIdx ? 'current' : 'future';
const icon = state === 'completed' ? '✓' : String(s.num);
const pillCls = state === 'current' ? ' mat-step__pill mat-step__pill--current' :
state === 'completed' ? ' mat-step__pill mat-step__pill--complete' : '';
const pillText = state === 'current' ? 'Du er her' : state === 'completed' ? 'Oppnådd' : '';
const pill = pillText ? '<span class="' + pillCls.trim() + '">' + escapeHtml(pillText) + '</span>' : '';
const progress = state === 'current' ? (
'<div class="mat-step__progress">' +
'<div class="mat-step__progress-bar"><div class="mat-step__progress-fill" style="width: ' + pct + '%"></div></div>' +
'<span>' + passCount + ' / ' + total + ' kategorier</span>' +
'</div>'
) : '';
return (
'<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon" aria-hidden="true">' + escapeHtml(icon) + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(s.name) + pill + '</div>' +
'<div class="mat-step__desc">' + escapeHtml(s.desc) + '</div>' +
progress +
'</div>' +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Modenhetsstige — posture-progresjon</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Posture-score på ' + passCount + ' av ' + total + ' kategorier (' + pct + '%) plasserer dette prosjektet på trinn ' + (currentIdx + 1) + ' av 5.</p>' +
'<div class="mat-ladder" role="list" aria-label="Posture-modenhet over 5 trinn">' + stepHtml + '</div>' +
'</section>'
);
}
/**
* Render suppressed-group fra v7.1.1 narrative-audit.
* Parser executive_summary-tekst for "Suppressed signals: N (reason1: count examples, ...)"
* eller bruker data.narrative_audit.suppressed_findings hvis strukturert.
*/
function renderSuppressedGroup(data) {
if (!data) return '';
const audit = data.narrative_audit || {};
const sf = audit.suppressed_findings || {};
let groups = [];
let totalCount = 0;
if (sf.by_category && typeof sf.by_category === 'object') {
totalCount = Number(sf.count || 0);
groups = Object.keys(sf.by_category).map(function (k) {
return { reason: k, count: Number(sf.by_category[k]) || 0, example: '' };
});
} else {
// Fall back: parse fra executive_summary
const summary = String(data.executive_summary || '');
const m = summary.match(/Suppressed signals:\s*\*?\*?\s*(\d+)\s*\(([^)]+)\)/i);
if (!m) return '';
totalCount = Number(m[1]) || 0;
groups = m[2].split(',').map(function (part) {
const seg = part.trim();
const colonIdx = seg.indexOf(':');
if (colonIdx < 0) return { reason: seg, count: 1, example: '' };
const reason = seg.slice(0, colonIdx).trim();
const rest = seg.slice(colonIdx + 1).trim();
const cm = rest.match(/^(\d+)\s+(.*)$/);
if (cm) {
return { reason: reason, count: Number(cm[1]) || 1, example: cm[2].trim() };
}
return { reason: reason, count: 1, example: rest };
});
}
if (!groups.length) return '';
const groupsHtml = groups.map(function (g) {
const example = g.example ? (
'<div class="suppressed-group__examples">' +
'<span class="suppressed-group__example">' + escapeHtml(g.example) + '</span>' +
'</div>'
) : '';
return (
'<div class="suppressed-group">' +
'<div class="suppressed-group__head">' +
'<span class="suppressed-group__reason">' + escapeHtml(g.reason) + '</span>' +
'<span class="suppressed-group__count">' + g.count + ' ' + (g.count === 1 ? 'forekomst' : 'forekomster') + '</span>' +
'</div>' +
example +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Narrative audit — supprimerte signaler</h4>' +
'<p class="suppressed-group__desc">' + totalCount + ' signaler ble supprimert pre-rapport (v7.1.1 narrative_audit). Disse er ikke false-positives walked-back i prosa, men auto-suppress før klassifisering.</p>' +
groupsHtml +
'</section>'
);
}
/**
* Render codepoint-reveal + cp-tag for Unicode-steganografi (UNI-funn).
* Brukes på mcp-inspect-rapporter — bytter plain table mot side-by-side
* "synlig vs. decoded codepoint"-visning per tool.
*/
function renderCodepointReveal(codepoints) {
if (!codepoints || !codepoints.length) return '';
const tagFor = function (code) {
// U+200B/200C/200D/FEFF = zero-width
if (/U\+(200[B-D]|FEFF|2060|180E)/i.test(code)) return 'cp-zw';
// U+202E/202D/2066-2069 = bidi/RTL
if (/U\+(202[ADE]|206[6-9])/i.test(code)) return 'cp-bidi';
// Other = generic cp-tag (warning class)
return 'cp-tag';
};
const blocks = codepoints.map(function (c) {
const risk = String(c.risk || '').trim();
const sev = /high/i.test(risk) ? 'critical' : /medium/i.test(risk) ? 'medium' : 'low';
const isClean = /clean|—|^-$/i.test(c.codepoints || '') || risk === '—' || risk === '-';
const cps = String(c.codepoints || '');
// Highlight U+XXXX-mønstre
const highlighted = cps.replace(/U\+[0-9A-Fa-f]{4,6}/g, function (m) {
return '<span class="' + tagFor(m) + '">' + m + '</span>';
});
const headRisk = isClean
? '<span style="font-size: 11px; color: var(--color-state-success);">Ren — ingen non-ASCII</span>'
: '<span style="font-size: 11px; font-weight: var(--font-weight-semibold); color: var(--color-severity-' + sev + ');">' + escapeHtml(risk) + ' risk</span>';
const visibleCol = isClean
? '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + '</div>'
: '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + ' <span style="opacity: 0.6;">(rendert visuelt)</span></div>';
const decodedCol = isClean
? '<div class="codepoint-reveal__decoded">(ingen suspekte codepoints)</div>'
: '<div class="codepoint-reveal__decoded">' + highlighted + '</div>';
return (
'<div class="codepoint-reveal">' +
'<div class="codepoint-reveal__head">' +
'<strong>' + escapeHtml(c.server || '—') + ' · <code>' + escapeHtml(c.tool || '—') + '</code></strong>' +
headRisk +
'</div>' +
'<div class="codepoint-reveal__body">' +
'<div class="codepoint-reveal__col">' +
'<span class="codepoint-reveal__col-label">Synlig (rendret tekst)</span>' +
visibleCol +
'</div>' +
'<div class="codepoint-reveal__col">' +
'<span class="codepoint-reveal__col-label">Decoded (codepoints)</span>' +
decodedCol +
'</div>' +
'</div>' +
'</div>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4>Codepoint-reveal — Unicode-steganografi</h4>' +
'<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Tools med non-ASCII codepoints i deskripsjoner — zero-width / homoglyph / bidi-override. Side-ved-side: synlig form vs. dekoded codepoints.</p>' +
'<div style="display: flex; flex-direction: column; gap: var(--space-3);">' + blocks + '</div>' +
'</section>'
);
}
/**
* Render top-risks + top-risk for rangert top-funn-listing.
* Tar de N (default 5) høyeste alvorlighetsnivåene fra findings og
* viser dem som ordnet liste. Bruker `.top-risks` / `.top-risk` med
* `data-severity` for severity-tinted left-border per DS Tier 3-supplement.
* Returnerer tom streng hvis ingen findings (eller kun info-funn).
*/
function renderTopRisks(findings, n) {
if (!findings || !findings.length) return '';
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
const max = typeof n === 'number' && n > 0 ? n : 5;
// Filtrer ut info-only — top-risks viser reelle risker, ikke observability-noise
const filtered = findings.filter(function (f) {
return (f.severity || 'info').toLowerCase() !== 'info';
});
if (!filtered.length) return '';
const sorted = filtered.slice().sort(function (a, b) {
return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9);
});
const top = sorted.slice(0, max);
const items = top.map(function (f, idx) {
const sev = String(f.severity || 'info').toLowerCase();
const sevLabel = sev.toUpperCase();
const meta = [
f.file ? f.file + (f.line ? ':' + f.line : '') : '',
f.id || '',
f.owasp || ''
].filter(Boolean).join(' · ');
const title = f.description || f.title || '—';
return (
'<li class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
'<div class="top-risk__rank">' + (idx + 1) + '</div>' +
'<div class="top-risk__desc">' +
'<div>' + escapeHtml(title) + '</div>' +
(meta ? '<div style="font-family: var(--font-family-mono); font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px;">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'<span class="top-risk__score" data-severity="' + escapeAttr(sev) + '">' + escapeHtml(sevLabel) + '</span>' +
'</li>'
);
}).join('');
return (
'<section class="report-meta">' +
'<h4 class="top-risks__heading">Top ' + top.length + ' risks</h4>' +
'<ol class="top-risks">' + items + '</ol>' +
'</section>'
);
}
// ============================================================
// 10 RENDERERS — én per høy-prio kommando.
// ============================================================
function renderScan(data, slot) {
const meterHtml = renderRiskMeter(data.risk_score, data.riskBand);
const suppressedHtml = renderSuppressedGroup(data);
const toxicHtml = renderToxicFlow(data.findings || []);
const owaspHtml = (data.owasp && data.owasp.length) ? (
'<section class="report-meta"><h4>OWASP-kategorier</h4>' +
'<table class="report-table"><thead><tr><th>Kategori</th><th>Funn</th><th>Maks severity</th><th>Skannere</th></tr></thead><tbody>' +
data.owasp.map(function (o) {
return '<tr><td>' + escapeHtml(o.category) + '</td><td>' + o.findings + '</td><td>' + escapeHtml(o.max_severity) + '</td><td>' + escapeHtml(o.scanners) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
const supplyHtml = (data.supply_chain && data.supply_chain.length) ? (
'<section class="report-meta"><h4>Supply chain</h4>' +
'<table class="report-table"><thead><tr><th>Komponent</th><th>Type</th><th>Kilde</th><th>Trust</th><th>Notater</th></tr></thead><tbody>' +
data.supply_chain.map(function (s) {
return '<tr><td>' + escapeHtml(s.component) + '</td><td>' + escapeHtml(s.type) + '</td><td>' + escapeHtml(s.source) + '</td><td>' + escapeHtml(s.trust) + '</td><td>' + escapeHtml(s.notes) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = meterHtml + suppressedHtml + toxicHtml + topRisksHtml + owaspHtml + supplyHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'SKANNING',
title: data.title || 'Security Scan',
lede: data.lede || (data.executive_summary ? data.executive_summary.split('\n')[0].slice(0, 220) : 'Skann av skills, MCP-konfig, kataloger eller GitHub-URL.'),
verdict: data.verdict || inferVerdict(data, 'risk-score-meter'),
keyStats: data.keyStats || inferKeyStats(data, 'risk-score-meter')
}, body);
}
RENDERERS.renderScan = renderScan;
function renderDeepScan(data, slot) {
// Per-scanner small-multiples
const sm = (data.scanners || []).map(function (s) {
const okStatus = /ok/i.test(s.status || '') ? 'ok' : (s.status || 'unknown');
const grade = (s.findings === 0) ? 'A' : (s.findings <= 3) ? 'B' : (s.findings <= 8) ? 'C' : (s.findings <= 15) ? 'D' : 'F';
return {
name: s.tag + ' · ' + s.name,
score: Math.max(0, 5 - Math.min(5, Math.floor((s.findings || 0) / 3))),
max: 5,
grade: grade,
status: s.findings + ' funn · ' + (s.duration_ms || 0) + 'ms · ' + okStatus
};
});
const smHtml = renderSmallMultiples(sm);
// Scanner Risk Matrix-tabell
const matrixRows = (data.scanner_matrix || []).map(function (r) {
return '<tr><td>' + escapeHtml(r.scanner) + '</td>' +
'<td>' + r.critical + '</td>' +
'<td>' + r.high + '</td>' +
'<td>' + r.medium + '</td>' +
'<td>' + r.low + '</td>' +
'<td>' + r.info + '</td></tr>';
}).join('');
const matrixHtml = matrixRows ? (
'<section class="report-meta"><h4>Scanner Risk Matrix</h4>' +
'<table class="report-table"><thead><tr><th>Scanner</th><th>CRIT</th><th>HIGH</th><th>MED</th><th>LOW</th><th>INFO</th></tr></thead><tbody>' +
matrixRows + '</tbody></table>' +
'</section>'
) : '';
const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Findings (utvalg)');
const recHtml = renderRecommendationsList(data.recommendations || []);
const suppressedHtml = renderSuppressedGroup(data);
const toxicHtml = renderToxicFlow(data.findings || []);
const body = meterHtml + suppressedHtml + toxicHtml + smHtml + matrixHtml + topRisksHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'DEEP-SCAN',
title: data.title || 'Deterministisk deep-scan',
lede: data.lede || '10 deterministiske Node.js-scannere, ingen LLM-invokasjon.',
verdict: data.verdict || inferVerdict(data, 'findings-grade'),
keyStats: data.keyStats || inferKeyStats(data, 'findings-grade')
}, body);
}
RENDERERS.renderDeepScan = renderDeepScan;
function renderPluginAudit(data, slot) {
const meta = data.plugin_metadata || {};
const metaRows = Object.keys(meta).map(function (k) {
return '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(meta[k]) + '</td></tr>';
}).join('');
const metaHtml = metaRows ? '<section class="report-meta"><h4>Plugin-metadata</h4><table class="report-table"><tbody>' + metaRows + '</tbody></table></section>' : '';
const compHtml = (data.components && data.components.length) ? (
'<section class="report-meta"><h4>Komponenter</h4>' +
'<table class="report-table"><thead><tr><th>Komponent</th><th>Antall</th><th>Notater</th></tr></thead><tbody>' +
data.components.map(function (c) {
return '<tr><td>' + escapeHtml(c.component) + '</td><td>' + c.count + '</td><td>' + escapeHtml(c.notes) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
const permHtml = (data.permissions && data.permissions.length) ? (
'<section class="report-meta"><h4>Permission-matrise</h4>' +
'<table class="report-table"><thead><tr><th>Verktøy</th><th>Krevet av</th><th>Begrunnet</th></tr></thead><tbody>' +
data.permissions.map(function (p) {
const isYes = /^yes|^ja/i.test(p.justified);
const isNo = /^no$|^nei/i.test(p.justified);
const cls = isYes ? 'low' : (isNo ? 'critical' : 'medium');
return '<tr><td>' + escapeHtml(p.tool) + '</td><td>' + escapeHtml(p.required_by) + '</td><td><span class="key-stat__value" style="color: var(--color-' + cls + ')">' + escapeHtml(p.justified) + '</span></td></tr>';
}).join('') +
'</tbody></table>' +
'</section>'
) : '';
const trustSev = (function () {
const t = String(data.trust_verdict_text || '').toLowerCase();
if (/block|fail|critical|do\s*not\s*install/i.test(t)) return 'critical';
if (/warn|caution|review|conditional/i.test(t)) return 'high';
if (/allow|trust|verified|pass/i.test(t)) return 'positive';
return 'medium';
})();
const trustHtml = data.trust_verdict_text ? (
'<section class="recommendation-card" data-severity="' + escapeAttr(trustSev) + '">' +
'<span class="recommendation-card__label">Trust-verdict</span>' +
'<p class="recommendation-card__body">' + escapeHtml(data.trust_verdict_text).replace(/\n/g, '<br>') + '</p>' +
'</section>'
) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = renderRiskMeter(data.risk_score, data.riskBand) + metaHtml + compHtml + permHtml + trustHtml + topRisksHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'PLUGIN-AUDIT',
title: data.title || 'Plugin trust-vurdering',
lede: data.lede || 'Trust-verdikt basert på maintainer, lisens, permissions og MCP-deskripsjoner.',
verdict: data.verdict || inferVerdict(data, 'risk-score-meter'),
keyStats: data.keyStats || inferKeyStats(data, 'risk-score-meter')
}, body);
}
RENDERERS.renderPluginAudit = renderPluginAudit;
function renderMcpAudit(data, slot) {
const landRows = (data.mcp_servers || []).map(function (s) {
return '<tr>' +
'<td>' + escapeHtml(s.server) + '</td>' +
'<td>' + escapeHtml(s.type) + '</td>' +
'<td>' + escapeHtml(s.trust) + '</td>' +
'<td>' + s.tools + '</td>' +
'<td>' + (s.active ? '<span class="key-stat__value" style="color: var(--color-low)">aktiv</span>' : '<span class="key-stat__value" style="color: var(--color-medium)">dormant</span>') + '</td>' +
'</tr>';
}).join('');
const landHtml = landRows ? (
'<section class="report-meta"><h4>MCP-landskap</h4>' +
'<table class="report-table"><thead><tr><th>Server</th><th>Type</th><th>Trust</th><th>Tools</th><th>Status</th></tr></thead><tbody>' + landRows + '</tbody></table>' +
'</section>'
) : '';
// Per-server som critique-cards
const psHtml = (data.per_server && data.per_server.length) ? (
'<div class="critique-cards">' + data.per_server.map(function (p) {
const sev = /(verdict:.*BLOCK|verdict:.*FAIL|critical)/i.test(p.body) ? 'critical' :
/(verdict:.*WARNING|warn|medium|drift)/i.test(p.body) ? 'medium' :
'low';
const lines = p.body.split(/\r?\n/).slice(0, 6).join(' ');
return '<div class="critique-card" data-severity="' + escapeAttr(sev) + '">' +
'<div class="critique-card__header">' +
'<div class="critique-card__title">' + escapeHtml(p.name) + '</div>' +
(p.note ? '<div class="critique-card__meta"><span class="critique-card__id">' + escapeHtml(p.note) + '</span></div>' : '') +
'</div>' +
'<div class="critique-card__recommendation">' + escapeHtml(lines.slice(0, 360)) + (lines.length > 360 ? '…' : '') + '</div>' +
'</div>';
}).join('') + '</div>'
) : '';
// Keep / Review / Remove kanban
const buckets = data.buckets || { keep: [], review: [], remove: [] };
const cardFor = function (bucket, label) {
const items = buckets[bucket] || [];
const cards = items.length ? items.map(function (it) {
return '<div class="kanban-card">' +
'<div class="kanban-card__name">' + escapeHtml(it.server) + '</div>' +
(it.reason ? '<div class="kanban-card__meta">' + escapeHtml(it.reason) + '</div>' : '') +
'</div>';
}).join('') : '<div class="kanban-col__empty">Ingen</div>';
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
'<div class="kanban-col__head">' +
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
'<span class="kanban-col__count">' + items.length + '</span>' +
'</div>' + cards + '</div>';
};
const kanbanHtml = '<div class="kanban-board">' +
cardFor('keep', 'Keep') +
cardFor('review', 'Review') +
cardFor('remove', 'Remove') +
'</div>';
const findingsHtml = renderFindingsBlock(data.findings || [], 'MCP-funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = landHtml + psHtml + kanbanHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'MCP-AUDIT',
title: data.title || 'MCP-konfig audit',
lede: data.lede || 'Permissions, trust og deskripsjon-drift på tvers av installerte MCP-servere.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderMcpAudit = renderMcpAudit;
function renderIdeScan(data, slot) {
const covRows = (data.coverage || []).map(function (c) {
return '<tr><td>' + escapeHtml(c.ide) + '</td><td>' + c.extensions + '</td><td>' + c.findings + '</td></tr>';
}).join('');
const covHtml = covRows ? (
'<section class="report-meta"><h4>Scan-dekning</h4>' +
'<table class="report-table"><thead><tr><th>IDE</th><th>Extensions</th><th>Funn</th></tr></thead><tbody>' + covRows + '</tbody></table>' +
'</section>'
) : '';
// Findings — bruk renderFindingsBlock men med extension+ide som meta
const fs = (data.findings || []).map(function (f) {
return Object.assign({}, f, {
file: f.extension || f.file || '',
category: f.ide || ''
});
});
const findingsHtml = renderFindingsBlock(fs, 'IDE-extension funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = covHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'IDE-SCAN',
title: data.title || 'IDE-extension scan',
lede: data.lede || 'VS Code + JetBrains supply-chain-sjekk, blocklist + typosquat + obfuskering.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderIdeScan = renderIdeScan;
function renderPosture(data, slot) {
// Small-multiples per kategori
const items = (data.categories || []).filter(function (c) {
return c.status !== 'N-A' && c.status !== 'N/A';
}).map(function (c) {
const score = c.status === 'PASS' ? 5 : c.status === 'PARTIAL' ? 3 : c.status === 'FAIL' ? 1 : 0;
const grade = c.status === 'PASS' ? 'A' : c.status === 'PARTIAL' ? 'C' : c.status === 'FAIL' ? 'F' : '';
return {
name: c.num + '. ' + c.name,
score: score,
max: 5,
grade: grade,
status: c.status + (c.findings ? ' · ' + c.findings + ' funn' : '')
};
});
const smHtml = renderSmallMultiples(items);
const ladderHtml = renderMatLadder(data.categories || [], data.posture_score, data.posture_applicable);
// Quick wins
const quickHtml = (data.quick_wins && data.quick_wins.length) ? (
'<section class="recommendation-card" data-severity="positive">' +
'<span class="recommendation-card__label">Quick wins</span>' +
'<ol class="recommendation-card__body">' +
data.quick_wins.map(function (w) { return '<li>' + escapeHtml(w) + '</li>'; }).join('') +
'</ol>' +
'</section>'
) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Top findings');
const recHtml = renderRecommendationsList(data.recommendations || []);
const overall = data.posture_score != null ? (
'<section class="report-meta"><h4>Overall score</h4><p><strong>' + data.posture_score + ' / ' + (data.posture_applicable || '?') + ' kategorier dekket</strong> — Grade ' + escapeHtml(data.grade || '?') + '.</p></section>'
) : '';
const body = overall + ladderHtml + smHtml + quickHtml + topRisksHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'POSTURE',
title: data.title || 'Security posture',
lede: data.lede || 'Rask scorecard, deterministisk scanner, <2s.',
verdict: data.verdict || inferVerdict(data, 'posture-cards'),
keyStats: data.keyStats || inferKeyStats(data, 'posture-cards')
}, body);
}
RENDERERS.renderPosture = renderPosture;
function renderAudit(data, slot) {
const radarHtml = renderRadarSvg(data.radar_axes || []);
// Category Assessment som expansion-kort
const catHtml = (data.categories && data.categories.length) ? (
'<section class="report-meta"><h4>Kategori-vurdering</h4>' +
'<div class="findings__items">' + data.categories.map(function (c) {
const sev = c.status === 'FAIL' ? 'critical' : c.status === 'PARTIAL' ? 'medium' : c.status === 'PASS' ? 'low' : 'info';
const sevClass = 'card--severity-' + sev;
return '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' +
'<div class="findings__item-id">Kat. ' + c.num + '</div>' +
'<div class="findings__item-title">' + escapeHtml(c.name) + '</div>' +
'<div class="findings__item-meta">Status: <strong>' + escapeHtml(c.status || '—') + '</strong></div>' +
'</div>' +
'</div>';
}).join('') + '</div>' +
'</section>'
) : '';
// Action Plan tre-tier
const tierHtml = function (tier, label, sev) {
const items = (data.action_plan && data.action_plan[tier]) || [];
if (!items.length) return '';
return '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(label) + '</span>' +
'<ol class="recommendation-card__body">' + items.map(function (a) { return '<li>' + escapeHtml(a) + '</li>'; }).join('') + '</ol>' +
'</section>';
};
const actionHtml = tierHtml('immediate', 'Umiddelbar', 'critical') + tierHtml('high', 'Høy prioritet', 'high') + tierHtml('medium', 'Medium prioritet', 'medium');
const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : '';
const topRisksHtml = renderTopRisks(data.findings || [], 5);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
const body = meterHtml + radarHtml + catHtml + actionHtml + topRisksHtml + findingsHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'AUDIT',
title: data.title || 'Full security audit',
lede: data.lede || 'OWASP LLM Top 10-vurdering, A-F grading, action plan.',
verdict: data.verdict || inferVerdict(data, 'findings-grade'),
keyStats: data.keyStats || inferKeyStats(data, 'findings-grade')
}, body);
}
RENDERERS.renderAudit = renderAudit;
function renderDashboard(data, slot) {
// Fleet-grid med fleet-tile per prosjekt
const projects = data.projects || [];
const sevForGrade = function (g) {
const u = String(g || '').toUpperCase();
if (u === 'A') return 'low';
if (u === 'B') return 'low';
if (u === 'C') return 'medium';
if (u === 'D') return 'high';
if (u === 'F') return 'critical';
return 'info';
};
const tiles = projects.length ? projects.map(function (p) {
const trend = (data.trends || []).find(function (t) { return t.name === p.name; });
const trendCls = trend ? ('fleet-tile__trend--' + trend.trend) : 'fleet-tile__trend--stable';
const fillPct = Math.max(0, Math.min(100, p.risk));
return (
'<div class="fleet-tile" data-severity="' + escapeAttr(sevForGrade(p.grade)) + '">' +
'<div class="fleet-tile__row">' +
'<span class="fleet-tile__name" title="' + escapeAttr(p.name) + '">' + escapeHtml(p.name) + '</span>' +
'<span class="fleet-tile__grade" data-grade="' + escapeAttr(p.grade || '') + '">' + escapeHtml(p.grade || '?') + '</span>' +
'</div>' +
'<div class="fleet-tile__meter"><div class="fleet-tile__meter-fill" style="width: ' + fillPct + '%"></div></div>' +
'<div class="fleet-tile__meta">' +
'<span>Risk ' + p.risk + ' · ' + p.findings + ' funn</span>' +
(trend ? '<span class="' + trendCls + '">' + escapeHtml(trend.d_risk) + '</span>' : '') +
'</div>' +
(p.worst_category ? '<div class="fleet-tile__meta"><span class="fleet-tile__chip">Verst: ' + escapeHtml(p.worst_category) + '</span></div>' : '') +
'</div>'
);
}).join('') : '';
const gridHtml = tiles ? '<div class="fleet-grid">' + tiles + '</div>' : renderEmptyState('Ingen prosjekter funnet.');
// Errors
const errorsHtml = (data.errors && data.errors.length) ? (
'<section class="report-meta"><h4>Errors</h4>' +
'<table class="report-table"><thead><tr><th>Prosjekt</th><th>Feil</th></tr></thead><tbody>' +
data.errors.map(function (e) { return '<tr><td>' + escapeHtml(e.project) + '</td><td>' + escapeHtml(e.error) + '</td></tr>'; }).join('') +
'</tbody></table>' +
'</section>'
) : '';
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = gridHtml + errorsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'DASHBOARD',
title: data.title || 'Cross-project dashboard',
lede: data.lede || 'Maskin-grade = svakeste lenke. Aggregert posture-skann per prosjekt.',
verdict: data.verdict || inferVerdict(data, 'dashboard-fleet'),
keyStats: data.keyStats || inferKeyStats(data, 'dashboard-fleet')
}, body);
}
RENDERERS.renderDashboard = renderDashboard;
function renderHarden(data, slot) {
const recs = data.recommendations || [];
// Diff-blokker per recommendation — DS Tier 3 recommendation-card med data-severity (v7.6.0 fase 5f).
// CREATE → positive (ny grade A-fil), APPEND → medium (eksisterende fil utvides),
// MERGE → low (allerede satt, kun normalisering), SKIP → low (ingen handling).
const diffHtml = recs.map(function (r, idx) {
const isCreate = /create/i.test(r.action);
const isAppend = /append/i.test(r.action);
const isMerge = /merge/i.test(r.action);
const isNone = /none|skip/i.test(r.action);
const actionLabel = isCreate ? 'CREATE' : isAppend ? 'APPEND' : isMerge ? 'MERGE' : 'SKIP';
const sev = mapSeverityToCardLevel(actionLabel);
return (
'<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
'<span class="recommendation-card__label">' + actionLabel + ' · ' + escapeHtml(String(r.num)) + '. ' + escapeHtml(r.category) + '</span>' +
'<div class="recommendation-card__body">' +
'<div><code>' + escapeHtml(r.file) + '</code></div>' +
(r.content_preview ? '<pre style="margin: var(--space-2) 0; font-size: var(--font-size-sm); white-space: pre-wrap; opacity: ' + (isNone ? '0.6' : '0.9') + '">' + escapeHtml(r.content_preview).slice(0, 600) + (r.content_preview.length > 600 ? '…' : '') + '</pre>' : '') +
'</div>' +
'</section>'
);
}).join('');
// Diff summary footer
const summaryRows = (data.diff_summary || []).map(function (d) {
return '<div class="diff__summary-item"><span>' + escapeHtml(d.file) + '</span><span class="diff__summary-count">' + escapeHtml(d.action) + ' · ' + escapeHtml(d.lines) + '</span></div>';
}).join('');
const summaryHtml = summaryRows ? '<div class="diff__summary">' + summaryRows + '</div>' : '';
const introSev = (function () {
const g = String(data.current_grade || '?').toUpperCase();
if (g === 'F' || g === 'D') return 'critical';
if (g === 'C') return 'high';
if (g === 'B') return 'medium';
if (g === 'A') return 'positive';
return 'medium';
})();
const intro = (
'<section class="recommendation-card" data-severity="' + escapeAttr(introSev) + '">' +
'<span class="recommendation-card__label">Snapshot · grade ' + escapeHtml(data.current_grade || '?') + '</span>' +
'<p class="recommendation-card__body">Prosjekt-type: <strong>' + escapeHtml(data.project_type || '?') + '</strong> · ' + data.actionable + '/' + data.total + ' anbefalinger · Modus: <em>' + escapeHtml(data.mode || 'dry-run') + '</em></p>' +
'</section>'
);
const body = intro + (diffHtml || renderEmptyState('Ingen anbefalinger.')) + summaryHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'HARDEN',
title: data.title || 'Grade A reference config',
lede: data.lede || 'Diff-forhåndsvisning av settings.json, CLAUDE.md og .gitignore-endringer.',
verdict: data.verdict || inferVerdict(data, 'diff-report'),
keyStats: data.keyStats || [
{ label: 'NÅ-GRADE', value: String(data.current_grade || '?') },
{ label: 'AKSJONER', value: data.actionable + '/' + data.total },
{ label: 'MODUS', value: data.mode || 'dry-run' }
]
}, body);
}
RENDERERS.renderHarden = renderHarden;
function renderRedTeam(data, slot) {
const meterHtml = renderRiskMeter(100 - (data.defense_score || 0), data.riskBand);
// Per-category small-multiples
const cats = (data.categories || []).map(function (c) {
const total = (c.pass || 0) + (c.fail || 0);
const score = total ? Math.round((c.pass / total) * 5) : 0;
const grade = total === 0 ? '?' : c.fail === 0 ? 'A' : c.fail <= 1 ? 'B' : c.fail <= 3 ? 'C' : 'D';
return {
name: c.category,
score: score,
max: 5,
grade: grade,
status: c.pass + ' pass · ' + c.fail + ' fail'
};
});
const smHtml = renderSmallMultiples(cats);
// Failed scenarios som findings
const scnFindings = (data.scenarios || []).map(function (s) {
return {
id: s.id,
severity: s.severity,
category: s.category,
description: s.payload_class + ' — ' + s.reason,
owasp: ''
};
});
const findingsHtml = renderFindingsBlock(scnFindings, 'Failed scenarios');
// History
const historyRows = (data.history || []).map(function (h) {
return '<tr><td>' + escapeHtml(h.run) + '</td><td>' + escapeHtml(h.date) + '</td><td>' + h.defense_score + '%</td><td>' + escapeHtml(h.delta) + '</td></tr>';
}).join('');
const historyHtml = historyRows ? (
'<section class="report-meta"><h4>Defense score-historikk</h4>' +
'<table class="report-table"><thead><tr><th>Run</th><th>Dato</th><th>Score</th><th>Δ</th></tr></thead><tbody>' + historyRows + '</tbody></table>' +
'</section>'
) : '';
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = meterHtml + smHtml + findingsHtml + historyHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'RED-TEAM',
title: data.title || 'Attack-simulasjon',
lede: data.lede || (data.adaptive ? 'Adaptive mode aktiv (mutation-based evasion).' : 'Statisk mode — 64 deterministiske scenarios.'),
verdict: data.verdict || inferVerdict(data, 'red-team-results'),
keyStats: data.keyStats || inferKeyStats(data, 'red-team-results')
}, body);
}
RENDERERS.renderRedTeam = renderRedTeam;
// ============================================================
// FASE 3: 8 RENDERERS — én per gjenstående kommando.
// ============================================================
function renderMcpInspect(data, slot) {
const invRows = (data.server_inventory || []).map(function (s) {
return '<tr>' +
'<td>' + escapeHtml(s.server) + '</td>' +
'<td>' + escapeHtml(s.transport) + '</td>' +
'<td>' + s.tools + '</td>' +
'<td>' + escapeHtml(s.status) + '</td>' +
'<td>' + (s.connected ? '<span class="key-stat__value" style="color: var(--color-low)">ja</span>' : '<span class="key-stat__value" style="color: var(--color-medium)">nei</span>') + '</td>' +
'</tr>';
}).join('');
const invHtml = invRows ? (
'<section class="report-meta"><h4>Server-inventar</h4>' +
'<table class="report-table"><thead><tr><th>Server</th><th>Transport</th><th>Tools</th><th>Status</th><th>Connected</th></tr></thead><tbody>' + invRows + '</tbody></table>' +
'</section>'
) : '';
const cpHtml = renderCodepointReveal(data.codepoints || []);
const fs = (data.findings || []).map(function (f) {
return Object.assign({}, f, {
file: f.server || f.file || '',
category: f.category || ''
});
});
const findingsHtml = renderFindingsBlock(fs, 'MCP-inspect funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = invHtml + cpHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'MCP-INSPECT',
title: data.title || 'MCP live-inspect',
lede: data.lede || 'Runtime tool-deskripsjoner — drift, tool shadowing, codepoint reveal.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderMcpInspect = renderMcpInspect;
function renderSupplyCheck(data, slot) {
// Ecosystem-cards (small-multiples-mønster)
const ecos = (data.ecosystems || []).filter(function (e) { return Number(e.packages) > 0 || Number(e.osv_hits) > 0 || Number(e.typosquats) > 0; });
const ecoCards = ecos.length ? '<div class="small-multiples">' + ecos.map(function (e) {
const issues = (Number(e.osv_hits) || 0) + (Number(e.typosquats) || 0);
const grade = issues === 0 ? 'A' : issues <= 1 ? 'B' : issues <= 3 ? 'C' : issues <= 6 ? 'D' : 'F';
const score = Math.max(0, 5 - Math.min(5, issues));
const fillPct = (score / 5) * 100;
return '<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(e.ecosystem) + '</span>' +
'<span class="sm-card__grade" data-grade="' + escapeAttr(grade) + '">' + escapeHtml(grade) + '</span>' +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + fillPct.toFixed(0) + '%"></div></div>' +
'<span class="sm-card__status">' + e.packages + ' pakker · ' + e.osv_hits + ' OSV · ' + e.typosquats + ' typosquats</span>' +
'</div>';
}).join('') + '</div>' : '';
const findingsHtml = renderFindingsBlock(data.findings || [], 'Supply-chain funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = ecoCards + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'SUPPLY-CHECK',
title: data.title || 'Supply-chain recheck',
lede: data.lede || 'Re-audit lockfiler mot blocklists, OSV.dev og typosquat-deteksjon.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderSupplyCheck = renderSupplyCheck;
function renderPreDeploy(data, slot) {
const lights = data.traffic_lights || [];
const sevForStatus = function (s) {
const u = String(s || '').toUpperCase();
if (u === 'PASS' || u === 'GO') return 'low';
if (u === 'PASS-WITH-NOTES' || u === 'WARNING' || u === 'PARTIAL') return 'medium';
if (u === 'FAIL' || u === 'BLOCK' || u === 'NO-GO') return 'critical';
return 'info';
};
// v7.6.1 fix: sm-card__grade er fast 28×28 px (designet for én A-F-bokstav), så
// "PASS"/"PASS-WITH-NOTES"/"FAIL" ble kuttet til "AS"/"PASS-WITH-..."/"FA". Bytt
// til en bredde-tilpasset status-pill via inline styling (ingen DS-klasse-endring).
const cards = lights.map(function (l) {
const sev = sevForStatus(l.status);
const pillBg = sev === 'low' ? 'var(--color-severity-low-soft)'
: sev === 'medium' ? 'var(--color-severity-medium-soft)'
: sev === 'critical' ? 'var(--color-severity-critical-soft)'
: 'var(--color-bg-soft)';
const pillFg = sev === 'low' ? 'var(--color-severity-low-on)'
: sev === 'medium' ? 'var(--color-severity-medium-on)'
: sev === 'critical' ? 'var(--color-severity-critical-on)'
: 'var(--color-text-secondary)';
const statusPill = '<span style="font-family: var(--font-family-mono); font-size: 11px; font-weight: var(--font-weight-bold); letter-spacing: 0.04em; padding: 3px 8px; border-radius: var(--radius-sm); background: ' + pillBg + '; color: ' + pillFg + '; white-space: nowrap;">' + escapeHtml(l.status) + '</span>';
return '<div class="sm-card" data-severity="' + escapeAttr(sev) + '" style="border-left: 3px solid var(--color-severity-' + (sev === 'low' ? 'low' : sev === 'medium' ? 'medium' : sev === 'critical' ? 'critical' : 'low') + '); padding-left: var(--space-3);">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(l.category) + '</span>' +
statusPill +
'</div>' +
(l.notes ? '<span class="sm-card__status">' + escapeHtml(l.notes) + '</span>' : '') +
'</div>';
}).join('');
const lightsHtml = cards ? '<section class="report-meta"><h4>Traffic-light kategorier</h4><div class="small-multiples">' + cards + '</div></section>' : '';
const condHtml = (data.conditions && data.conditions.length) ? (
'<section class="recommendation-card" data-severity="high">' +
'<span class="recommendation-card__label">Vilkår å løse</span>' +
'<ol class="recommendation-card__body">' + data.conditions.map(function (c) { return '<li>' + escapeHtml(c) + '</li>'; }).join('') + '</ol>' +
'</section>'
) : '';
const apprRows = (data.approvals || []).map(function (a) {
const isPending = /pending|—/i.test(a.approver) || !a.approver.trim();
return '<tr><td>' + escapeHtml(a.role) + '</td><td>' + (isPending ? '<em>(venter)</em>' : escapeHtml(a.approver)) + '</td><td>' + escapeHtml(a.date || '—') + '</td><td>' + escapeHtml(a.notes) + '</td></tr>';
}).join('');
const apprHtml = apprRows ? (
'<section class="report-meta"><h4>Godkjenninger</h4>' +
'<table class="report-table"><thead><tr><th>Rolle</th><th>Godkjenner</th><th>Dato</th><th>Notater</th></tr></thead><tbody>' + apprRows + '</tbody></table>' +
'</section>'
) : '';
const findingsHtml = renderFindingsBlock(data.findings || [], 'Pre-deploy funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = lightsHtml + condHtml + apprHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'PRE-DEPLOY',
title: data.title || 'Pre-deploy security checklist',
lede: data.lede || 'Enterprise-gate + production readiness — 13 kategorier.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderPreDeploy = renderPreDeploy;
function renderDiff(data, slot) {
const newItems = data['new'] || [];
const resolvedItems = data.resolved || [];
const unchangedItems = data.unchanged || [];
const movedItems = data.moved || [];
const gradeBadge = function (g) {
return g ? '<span class="sm-card__grade" data-grade="' + escapeAttr(g) + '">' + escapeHtml(g) + '</span>' : '<span class="sm-card__grade" data-grade="?">?</span>';
};
const headerHtml = (
'<section class="report-meta"><h4>Grade-bevegelse</h4>' +
'<div class="pair-before-after">' +
'<div class="pair-before-after__cell">' +
'<span class="pair-before-after__cell-label">BASELINE ' + escapeHtml(data.baseline_date || '') + '</span>' +
'<span class="pair-before-after__cell-value">' + gradeBadge(data.baseline_grade) + '</span>' +
'</div>' +
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
'<div class="pair-before-after__cell">' +
'<span class="pair-before-after__cell-label">NÅ</span>' +
'<span class="pair-before-after__cell-value">' + gradeBadge(data.current_grade) + '</span>' +
'</div>' +
'</div>' +
'</section>'
);
const renderRowItem = function (it, action) {
const sev = it.severity || 'info';
const sevClass = 'card--severity-' + sev;
const meta = [it.category, it.file, it.resolution, it.notes].filter(Boolean).join(' · ');
const cellClass = action === 'new' ? 'diff__cell--added' :
action === 'resolved' ? 'diff__cell--unchanged' :
'diff__cell--unchanged';
return '<div class="diff__row">' +
'<div class="diff__cell ' + cellClass + '">' +
'<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
'<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
'<div>' +
'<div class="findings__item-id">' + escapeHtml(it.id || '—') + '</div>' +
'<div class="findings__item-title">' + escapeHtml(it.description || it.resolution || it.notes || '') + '</div>' +
(meta ? '<div class="findings__item-meta">' + escapeHtml(meta) + '</div>' : '') +
'</div>' +
'</div>' +
'</div>' +
'</div>';
};
const sectionFor = function (label, items, action) {
if (!items.length) return '';
return '<section class="report-meta"><h4>' + escapeHtml(label) + ' (' + items.length + ')</h4>' +
'<div class="diff">' + items.map(function (it) { return renderRowItem(it, action); }).join('') + '</div>' +
'</section>';
};
const newHtml = sectionFor('Nye funn', newItems, 'new');
const resHtml = sectionFor('Løste funn', resolvedItems, 'resolved');
const unchHtml = sectionFor('Uendret', unchangedItems, 'unchanged');
const movHtml = (movedItems.length) ? sectionFor('Flyttet', movedItems.map(function (m) {
return { id: m.id, severity: 'info', description: m.from + ' → ' + m.to };
}), 'moved') : '';
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = headerHtml + newHtml + resHtml + unchHtml + movHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'DIFF',
title: data.title || 'Scan diff mot baseline',
lede: data.lede || 'Sammenligner nåværende scan mot lagret baseline.',
verdict: data.verdict || inferVerdict(data, 'diff-report'),
keyStats: data.keyStats || inferKeyStats(data, 'diff-report')
}, body);
}
RENDERERS.renderDiff = renderDiff;
function renderWatch(data, slot) {
const meter = data.live_meter || {};
const meterRows = Object.keys(meter).map(function (k) {
return '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(meter[k]) + '</td></tr>';
}).join('');
const meterHtml = meterRows ? (
'<section class="report-meta"><h4>Live-meter</h4>' +
'<table class="report-table"><tbody>' + meterRows + '</tbody></table>' +
'</section>'
) : '';
const histRows = (data.history || []).map(function (h) {
const isCurrent = /^current/i.test(h.run);
return '<tr' + (isCurrent ? ' style="font-weight: 600;"' : '') + '>' +
'<td>' + escapeHtml(h.run) + '</td>' +
'<td>' + escapeHtml(h.time) + '</td>' +
'<td><span class="sm-card__grade" data-grade="' + escapeAttr(h.grade || '?') + '">' + escapeHtml(h.grade || '?') + '</span></td>' +
'<td>' + h.risk_score + '</td>' +
'<td>' + escapeHtml(h.delta || '—') + '</td>' +
'</tr>';
}).join('');
const histHtml = histRows ? (
'<section class="report-meta"><h4>Siste runs</h4>' +
'<table class="report-table"><thead><tr><th>Run</th><th>Tid</th><th>Grade</th><th>Risk</th><th>Δ</th></tr></thead><tbody>' + histRows + '</tbody></table>' +
'</section>'
) : '';
const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn (siste run)');
const notRows = (data.notify_events || []).map(function (n) {
return '<tr><td>' + escapeHtml(n.time) + '</td><td>' + escapeHtml(n.event) + '</td><td>' + escapeHtml(n.channel) + '</td><td>' + escapeHtml(n.status) + '</td></tr>';
}).join('');
const notHtml = notRows ? (
'<section class="report-meta"><h4>Notify-eventer</h4>' +
'<table class="report-table"><thead><tr><th>Tid</th><th>Event</th><th>Channel</th><th>Status</th></tr></thead><tbody>' + notRows + '</tbody></table>' +
'</section>'
) : '';
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = meterHtml + histHtml + findingsHtml + notHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'WATCH',
title: data.title || 'Continuous monitoring',
lede: data.lede || 'Kjører diff på rekursivt intervall via /loop. Notify ved nye funn.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderWatch = renderWatch;
function renderRegistry(data, slot) {
const stats = data.stats || {};
const statsRows = Object.keys(stats).map(function (k) {
return '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(stats[k]) + '</td></tr>';
}).join('');
const statsHtml = statsRows ? (
'<section class="report-meta"><h4>Registry-stats</h4>' +
'<table class="report-table"><tbody>' + statsRows + '</tbody></table>' +
'</section>'
) : '';
const sigRows = (data.signatures || []).map(function (s) {
const isBad = /known-?bad|malicious/i.test(s.status);
const isDrift = /drift/i.test(s.status);
const isUnknown = /unknown/i.test(s.status);
const sev = isBad ? 'critical' : isDrift ? 'medium' : isUnknown ? 'low' : 'info';
return '<tr>' +
'<td>' + escapeHtml(s.skill) + '</td>' +
'<td>' + escapeHtml(s.source) + '</td>' +
'<td><code>' + escapeHtml(s.fingerprint) + '</code></td>' +
'<td><span class="key-stat__value" style="color: var(--color-' + sev + ')">' + escapeHtml(s.status) + '</span></td>' +
'<td>' + escapeHtml(s.first_seen) + '</td>' +
'</tr>';
}).join('');
const sigHtml = sigRows ? (
'<section class="report-meta"><h4>Signaturer</h4>' +
'<table class="report-table"><thead><tr><th>Skill</th><th>Kilde</th><th>Fingerprint</th><th>Status</th><th>Første sett</th></tr></thead><tbody>' + sigRows + '</tbody></table>' +
'</section>'
) : '';
const fs = (data.findings || []).map(function (f) {
return Object.assign({}, f, {
file: f.skill || f.file || '',
category: f.category || ''
});
});
const findingsHtml = renderFindingsBlock(fs, 'Registry-funn');
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = statsHtml + sigHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'REGISTRY',
title: data.title || 'Skill-signature registry',
lede: data.lede || 'Lokal fingerprint-database — kjente goder og kjente onde signaturer.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: data.keyStats || inferKeyStats(data, 'findings')
}, body);
}
RENDERERS.renderRegistry = renderRegistry;
function renderClean(data, slot) {
const buckets = data.buckets || { auto: [], 'semi-auto': [], manual: [], suppressed: [] };
const cardFor = function (bucket, label, sev) {
const items = buckets[bucket] || [];
const cards = items.length ? items.map(function (it) {
return '<div class="kanban-card" data-severity="' + escapeAttr(sev) + '">' +
'<div class="kanban-card__name">' + escapeHtml(it.id || '—') + ' — ' + escapeHtml(it.action || '') + '</div>' +
(it.description ? '<div class="kanban-card__meta">' + escapeHtml(it.description) + '</div>' : '') +
'</div>';
}).join('') : '<div class="kanban-col__empty">Ingen</div>';
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
'<div class="kanban-col__head">' +
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
'<span class="kanban-col__count">' + items.length + '</span>' +
'</div>' + cards + '</div>';
};
const kanbanHtml = '<div class="kanban-board" style="grid-template-columns: repeat(4, 1fr);">' +
cardFor('auto', 'Auto', 'low') +
cardFor('semi-auto', 'Semi-auto', 'medium') +
cardFor('manual', 'Manual', 'high') +
cardFor('suppressed', 'Undertrykt', 'info') +
'</div>';
// Advisory recommendation-cards per bucket — DS Tier 3 data-severity (v7.6.0 fase 5f).
// Hver bucket med items > 0 får én recommendation-card med severity-tinted border + label.
const bucketAdvisoryDefs = [
{ key: 'auto', label: 'Auto-fixable', sev: 'positive', desc: 'Plugin kan fikse disse uten ekstra bekreftelse — deterministiske, lavrisiko-handlinger.' },
{ key: 'semi-auto', label: 'Semi-auto — krever bekreftelse', sev: 'medium', desc: 'Foreslåtte tiltak vises som diff. Bruker bekrefter per finding før endring anvendes.' },
{ key: 'manual', label: 'Manual remediation', sev: 'high', desc: 'Krever menneskelig vurdering — kontekst, scope eller side-effekter er ikke deterministisk avgjørbare.' },
{ key: 'suppressed', label: 'Undertrykt', sev: 'low', desc: 'Allowlist-treff via .llm-security-ignore — ingen handling.' }
];
const advisoryHtml = bucketAdvisoryDefs.map(function (b) {
const items = buckets[b.key] || [];
if (!items.length) return '';
return (
'<section class="recommendation-card" data-severity="' + escapeAttr(b.sev) + '">' +
'<span class="recommendation-card__label">' + escapeHtml(b.label) + ' · ' + items.length + '</span>' +
'<p class="recommendation-card__body">' + escapeHtml(b.desc) + '</p>' +
'</section>'
);
}).join('');
const findingsHtml = renderFindingsBlock(data.findings || [], 'Tilknyttede funn');
const recHtml = renderRecommendationsList(data.recommendations || [], 'Anbefalinger', 'medium');
const isDry = ((data.mode || '').toLowerCase() === 'dry-run');
const intro = data.mode ? (
'<section class="recommendation-card" data-severity="' + (isDry ? 'low' : 'medium') + '">' +
'<span class="recommendation-card__label">Modus · ' + escapeHtml(data.mode) + '</span>' +
'<p class="recommendation-card__body">' + (isDry ? 'Dry-run: ingen filer endres. Forhåndsvis tiltak før <code>--apply</code>.' : 'Fixes anvendes med automatisk backup i <code>.llm-security-backup/</code>.') + '</p>' +
'</section>'
) : '';
const body = intro + advisoryHtml + kanbanHtml + findingsHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'CLEAN',
title: data.title || 'Remediation-kanban',
lede: data.lede || 'Funn fordelt på Auto / Semi-auto / Manual / Undertrykt.',
verdict: data.verdict || inferVerdict(data, 'kanban-buckets'),
keyStats: data.keyStats || inferKeyStats(data, 'kanban-buckets')
}, body);
}
RENDERERS.renderClean = renderClean;
function renderThreatModel(data, slot) {
// Matrix-rendering — 5×5
const cells = data.matrix_cells || [];
const byPC = {};
cells.forEach(function (c) {
const k = c.prob + '_' + c.cons;
if (!byPC[k]) byPC[k] = [];
byPC[k].push(c);
});
const probSize = 5;
const consMax = 5;
let matrixHtml = '<div class="matrix"><div class="matrix__y-label">Konsekvens</div><div class="matrix__main">';
matrixHtml += '<div class="matrix__grid" style="grid-template-rows: repeat(' + consMax + ', 1fr) 32px;">';
for (let cons = consMax; cons >= 1; cons--) {
matrixHtml += '<div class="matrix__y-tick">' + cons + '</div>';
for (let prob = 1; prob <= probSize; prob++) {
const score = prob * cons;
const items = byPC[prob + '_' + cons] || [];
// v7.6.1 fix: bobler er nå <button> så de er klikkbare og fokuserbare.
// data-threat-id lar event-handler senere mappe til detalj-modal.
const bubblesHtml = items.length
? '<div class="matrix__cell-bubbles">' +
items.slice(0, 3).map(function (it, i) {
return '<button type="button" class="matrix__bubble" data-threat-id="' + escapeAttr(it.id || it.label || '') + '" title="' + escapeAttr(it.label || '') + '" aria-label="Trussel: ' + escapeAttr(it.label || it.id || '') + '">' + (i + 1) + '</button>';
}).join('') +
(items.length > 3 ? '<button type="button" class="matrix__bubble matrix__bubble--count" aria-label="' + (items.length - 3) + ' flere trusler">+' + (items.length - 3) + '</button>' : '') +
'</div>'
: '';
matrixHtml += '<div class="matrix__cell" data-score="' + score + '">' +
'<span class="matrix__cell-score">' + score + '</span>' + bubblesHtml +
'</div>';
}
}
matrixHtml += '<div class="matrix__corner"></div>';
for (let prob = 1; prob <= probSize; prob++) {
matrixHtml += '<div class="matrix__x-tick">' + prob + '</div>';
}
matrixHtml += '</div><div class="matrix__x-label">Sannsynlighet</div></div></div>';
// Threats table
const threatsRows = (data.threats || []).map(function (t) {
return '<tr>' +
'<td>' + escapeHtml(t.id) + '</td>' +
'<td>' + escapeHtml(t.description) + '</td>' +
'<td><span class="findings__item-severity-dot" data-severity="' + escapeAttr(t.severity || 'info') + '" style="display: inline-block; vertical-align: middle;"></span> ' + escapeHtml(t.severity) + '</td>' +
'<td>' + escapeHtml(t.mitigation) + '</td>' +
'</tr>';
}).join('');
const threatsHtml = threatsRows ? (
'<section class="report-meta"><h4>Trusler</h4>' +
'<table class="report-table"><thead><tr><th>ID</th><th>Beskrivelse</th><th>Severity</th><th>Tiltak</th></tr></thead><tbody>' + threatsRows + '</tbody></table>' +
'</section>'
) : '';
// STRIDE / MAESTRO coverage as side-by-side bar lists
const coverageBlock = function (rows, label) {
if (!rows || !rows.length) return '';
const max = Math.max.apply(null, rows.map(function (r) { return Number(r.count) || 0; })) || 1;
const items = rows.map(function (r) {
const pct = ((Number(r.count) || 0) / max) * 100;
const labelKey = r.category || r.layer || '';
return '<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(labelKey) + '</span>' +
'<span class="sm-card__grade" data-grade="' + (r.count === 0 ? '?' : r.count <= 1 ? 'A' : r.count <= 3 ? 'B' : 'C') + '">' + r.count + '</span>' +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + pct.toFixed(0) + '%"></div></div>' +
(r.notes ? '<span class="sm-card__status">' + escapeHtml(r.notes) + '</span>' : '') +
'</div>';
}).join('');
return '<section class="report-meta"><h4>' + escapeHtml(label) + '</h4><div class="small-multiples">' + items + '</div></section>';
};
const strideHtml = coverageBlock(data.stride, 'STRIDE-dekning');
const maestroHtml = coverageBlock(data.maestro, 'MAESTRO-dekning');
// Roadmap
const roadRows = (data.roadmap || []).map(function (r) {
return '<tr><td>' + escapeHtml(r.priority) + '</td><td>' + escapeHtml(r.threat_id) + '</td><td>' + escapeHtml(r.mitigation) + '</td><td>' + escapeHtml(r.owner) + '</td><td>' + escapeHtml(r.eta) + '</td></tr>';
}).join('');
const roadHtml = roadRows ? (
'<section class="report-meta"><h4>Mitigation roadmap</h4>' +
'<table class="report-table"><thead><tr><th>Prioritet</th><th>Trussel</th><th>Tiltak</th><th>Eier</th><th>ETA</th></tr></thead><tbody>' + roadRows + '</tbody></table>' +
'</section>'
) : '';
const recHtml = renderRecommendationsList(data.recommendations || []);
const body = matrixHtml + threatsHtml + strideHtml + maestroHtml + roadHtml + recHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'THREAT-MODEL',
title: data.title || 'Threat model · STRIDE + MAESTRO',
lede: data.lede || 'Trusselmodellering med risikomatrise og mitigation-roadmap.',
verdict: data.verdict || inferVerdict(data, 'matrix-risk'),
keyStats: data.keyStats || inferKeyStats(data, 'matrix-risk')
}, body);
}
RENDERERS.renderThreatModel = renderThreatModel;
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: 'Verktøy-kommandoer produserer ikke rapport' };
const parser = PARSERS[commandId];
if (typeof parser !== 'function') {
// Fase 1: parsers ikke implementert ennå. Vis 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 ikke implementert ennå</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);">Mottok ' + markdown.length + ' tegn input. Lagret som rå markdown i prosjektets <code>reports.' + escapeHtml(commandId) + '.raw_markdown</code>.</p>' +
'</div>' +
'</div>'
);
}
// Lagre rå markdown (uten parsing) — gir noe state å eksportere
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 finnes — kjør (Fase 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>Du sletter prosjektet <strong>' + escapeHtml(project.name) + '</strong>. Alle rapporter i prosjektet går tapt. Operasjonen kan ikke angres.</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' });
return (
'<div class="modal" role="dialog" aria-labelledby="cf-title">' +
'<div class="modal__head">' +
'<h2 id="cf-title" class="modal__title">' + escapeHtml(cmd.label) + '</h2>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<p style="color: var(--color-text-secondary); margin: 0;">' + escapeHtml(cmd.description) + '</p>' +
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;
// Finn raden i Trusler-tabellen (TM-XXX i første kolonne)
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-screen') {
currentProjectScreen = target.dataset.screen;
scheduleRender();
return;
}
if (action === 'project-tab') {
currentProjectTab = target.dataset.tab;
scheduleRender();
return;
}
// Project lifecycle
if (action === 'open-project') {
const pid = target.dataset.projectId;
store.state.activeProjectId = pid;
currentProjectScreen = 'rapporter';
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('Prosjektnavn er påkrevd.'); 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;
currentProjectScreen = 'rapporter';
currentProjectTab = 'discover';
closeModal();
navigate('project');
return;
}
// Modal close
if (action === 'close-modal') {
closeModal();
return;
}
// Catalog
if (action === 'catalog-toggle-group') {
const grp = target.dataset.group;
const exp = target.closest('.expansion');
if (exp) {
const open = exp.getAttribute('aria-expanded') === 'true';
exp.setAttribute('aria-expanded', open ? 'false' : 'true');
}
return;
}
if (action === 'catalog-open-form') {
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
if (cmd) openModal(renderCatalogFormModal(cmd));
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);
// Lagre input på prosjekt-skjemaer (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 ? 'Kopiert til utklippstavle.' : 'Kopiering feilet — bruk forhåndsvisning.');
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
document.addEventListener('input', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-catalog-search]')) {
catalogSearchQuery = ev.target.value;
const groupsEl = document.querySelector('[data-catalog-groups]');
if (groupsEl) groupsEl.innerHTML = renderCatalogGroupsHtml();
return;
}
// 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();
}
}
});
// 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 så samme fil kan velges igjen
}
});
}
// ============================================================
// 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>