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>
10753 lines
552 KiB
HTML
10753 lines
552 KiB
HTML
<!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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
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>
|