From db80854830b63c9f6c1f326dabeed8a7d252f225 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 18 May 2026 12:56:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(llm-security):=20playground=20v7.6.2-dev?= =?UTF-8?q?=20=E2=80=94=20render-report=20CLI=20+=20wire=204=20skills=20(s?= =?UTF-8?q?can,=20audit,=20posture,=20deep-scan)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New scripts/render-report.mjs CLI: stdin/file/stdout modes, ESM import from ./lib/report-renderers.mjs, kebab→camel renderer-name lookup so any of the 18 PARSERS works - Standalone HTML wrap: inlines 6 DS stylesheets (tokens, base, components, tier2, tier3, tier3-supplement) + local .report-table CSS. Skips fonts.css → system-ui fallback via tokens.css (~137 KB self-contained vs ~1 MB with woff2 bundled) - 4 skill files wired: commands/{scan,audit,posture,deep-scan}.md — new step instructs Claude to Write the markdown report to a temp file, invoke the CLI, and print a markdown-formatted file:// link - Absolute file:// paths in stdout for Ghostty cmd-click compatibility - Default output: reports/-.html relative to CWD - Smoke-tested: stdin→stdout, file→file roundtrip, all 4 commands produce valid HTML with DS-aligned page-shell (page__title, verdict-pill-lg, risk-meter, key-stats, findings__item, recommendation-card) - Tests 1820/1820 green (same baseline; pre-compact-scan perf-flake from NEXT-SESSION-PROMPT did not fire on retry) - Playground untouched (2 scripts, 0 parse failures), report-renderers.mjs untouched (74 exports, 18 PARSERS, 18 RENDERERS) Sesjon 4 av 5. v7.7.0 release + 9 remaining skill wirings = sesjon 5. Co-Authored-By: Claude Opus 4.7 --- plugins/llm-security/commands/audit.md | 20 ++ plugins/llm-security/commands/deep-scan.md | 20 ++ plugins/llm-security/commands/posture.md | 20 ++ plugins/llm-security/commands/scan.md | 20 ++ .../llm-security/scripts/render-report.mjs | 192 ++++++++++++++++++ 5 files changed, 272 insertions(+) create mode 100644 plugins/llm-security/scripts/render-report.mjs diff --git a/plugins/llm-security/commands/audit.md b/plugins/llm-security/commands/audit.md index 0904c5e..9ab1e8e 100644 --- a/plugins/llm-security/commands/audit.md +++ b/plugins/llm-security/commands/audit.md @@ -48,3 +48,23 @@ Recalculate `risk_score = riskScore(counts)` (severity-dominated v2 model — se Output: Risk Dashboard, Executive Summary, 10 Category Sections (use scanner evidence + agent narrative), Summary Table, Action Items (IMMEDIATE → HIGH → MEDIUM). Close with top 2-3 action items. If grade C or lower: suggest `/security threat-model`. + +## Step 6: HTML Report + +After producing the markdown audit report above: + +1. Compute a temp markdown path: + ```bash + node -p "require('path').join(require('os').tmpdir(), 'sec-audit-' + Date.now() + '.md')" + ``` +2. Use the Write tool to save the **entire markdown report you just produced** (Risk Dashboard + Executive Summary + Category Sections + Summary Table + Action Items) to that temp path. Verbatim. +3. Run the renderer: + ```bash + node /scripts/render-report.mjs audit --in "" + ``` + The CLI writes `reports/audit-.html` relative to CWD and prints `file:///abs/path.html` on stdout. +4. Append to your response (markdown link, no bare URL): + + > **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html) + +If the CLI exits non-zero, mention the error but do not block — the markdown audit above is the primary deliverable. diff --git a/plugins/llm-security/commands/deep-scan.md b/plugins/llm-security/commands/deep-scan.md index b13aa27..d0f115d 100644 --- a/plugins/llm-security/commands/deep-scan.md +++ b/plugins/llm-security/commands/deep-scan.md @@ -40,3 +40,23 @@ Spawn `subagent_type: "llm-security:deep-scan-synthesizer-agent"`, `model: "sonn > Produce complete report with actionable insights. Don't pad. Output the synthesizer's report. If it fails, show banner + CRITICAL/HIGH findings from JSON. + +## Step 5: HTML Report + +After producing the markdown deep-scan report (banner + synthesizer output or fallback): + +1. Compute a temp markdown path: + ```bash + node -p "require('path').join(require('os').tmpdir(), 'sec-deepscan-' + Date.now() + '.md')" + ``` +2. Use the Write tool to save the **entire markdown report you just produced** (banner + synthesizer narrative + scanner sections + findings) to that temp path. Verbatim. +3. Run the renderer: + ```bash + node /scripts/render-report.mjs deep-scan --in "" + ``` + The CLI writes `reports/deep-scan-.html` relative to CWD and prints `file:///abs/path.html` on stdout. +4. Append to your response (markdown link, no bare URL): + + > **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html) + +If the CLI exits non-zero, mention the error but do not block — the markdown report above is the primary deliverable. diff --git a/plugins/llm-security/commands/posture.md b/plugins/llm-security/commands/posture.md index d313dc5..0b6adfc 100644 --- a/plugins/llm-security/commands/posture.md +++ b/plugins/llm-security/commands/posture.md @@ -58,3 +58,23 @@ Present the results as a scorecard: - Grade A/B: "Posture solid. Re-run after major changes." - Grade C: "Run `/security audit` for detailed findings." - Grade D/F: "Significant exposure. Run `/security audit` before production use." + +## Step 4: HTML Report + +After producing the markdown scorecard above: + +1. Compute a temp markdown path: + ```bash + node -p "require('path').join(require('os').tmpdir(), 'sec-posture-' + Date.now() + '.md')" + ``` +2. Use the Write tool to save the **entire markdown scorecard you just produced** (header + Category Scorecard table + Top Findings + Quick Wins + closing) to that temp path. Verbatim. +3. Run the renderer: + ```bash + node /scripts/render-report.mjs posture --in "" + ``` + The CLI writes `reports/posture-.html` relative to CWD and prints `file:///abs/path.html` on stdout. +4. Append to your response (markdown link, no bare URL): + + > **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html) + +If the CLI exits non-zero, mention the error but do not block — the markdown scorecard above is the primary deliverable. diff --git a/plugins/llm-security/commands/scan.md b/plugins/llm-security/commands/scan.md index e30b0ed..e783c45 100644 --- a/plugins/llm-security/commands/scan.md +++ b/plugins/llm-security/commands/scan.md @@ -155,3 +155,23 @@ If `clone_path != null`: If `evidence_file != null`: Run: `node /scanners/lib/fs-utils.mjs cleanup ""` + +## Step 8: HTML Report + +After producing the markdown report (Step 5) and any cleanup (Step 7): + +1. Compute a temp markdown path: + ```bash + node -p "require('path').join(require('os').tmpdir(), 'sec-scan-' + Date.now() + '.md')" + ``` +2. Use the Write tool to save the **entire markdown report you just produced** (banner + all findings sections + any Deep Scan section) to that temp path. Do not summarize — write it verbatim. +3. Run the renderer: + ```bash + node /scripts/render-report.mjs scan --in "" + ``` + The CLI writes `reports/scan-.html` relative to CWD and prints `file:///abs/path.html` on stdout (one line). +4. Append this to your response (markdown link, no bare URL): + + > **HTML-rapport:** [Åpne i nettleser](file:///abs/path.html) + +If the CLI exits non-zero, mention the error but do not block — the markdown report above is the primary deliverable. diff --git a/plugins/llm-security/scripts/render-report.mjs b/plugins/llm-security/scripts/render-report.mjs new file mode 100644 index 0000000..8912533 --- /dev/null +++ b/plugins/llm-security/scripts/render-report.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/* + * render-report.mjs — convert llm-security markdown reports into self-contained + * HTML files. Zero npm deps. Reads markdown from stdin or a file, parses via + * PARSERS, renders via RENDERERS, wraps the output in a standalone HTML doc + * with inlined Playground Design System CSS. + * + * Usage: + * node render-report.mjs --in (file → reports/-.html) + * node render-report.mjs --in --out + * node render-report.mjs --in - (stdin → reports/-.html) + * node render-report.mjs --in --out - (file → stdout) + * node render-report.mjs --in - --out - (stdin → stdout) + * + * Exit codes: + * 0 success · 1 parser error or empty input · 2 renderer threw · 3 file I/O failed + * + * Standalone HTML wraps in
so DS Tier 3 page-shell styling + * matches the playground render exactly. Output uses absolute file:// URLs to + * stdout (Ghostty cmd-click requires absolute paths). + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve, dirname, isAbsolute, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PARSERS, RENDERERS } from './lib/report-renderers.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(SCRIPT_DIR, '..'); +const VENDOR_CSS_DIR = join(PLUGIN_ROOT, 'playground', 'vendor', 'playground-design-system'); + +// DS stylesheets to inline. Skip fonts.css — tokens.css has system-ui +// fallbacks (-apple-system, BlinkMacSystemFont, system-ui, sans-serif), +// so reports stay self-contained without bundling ~940 KB of woff2. +const CSS_FILES = [ + 'tokens.css', + 'base.css', + 'components.css', + 'components-tier2.css', + 'components-tier3.css', + 'components-tier3-supplement.css' +]; + +// .report-table is a playground-local DS supplement (DS doesn't ship it). Same +// styling lives in playground inline + + +
+${bodyHtml} +
+ + +`; +} + +function fail(code, msg) { + process.stderr.write(`render-report: ${msg}\n`); + process.exit(code); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.commandId) fail(1, 'missing (e.g. scan, audit, posture, deep-scan)'); + if (!args.in) fail(1, 'missing --in '); + + const parser = PARSERS[args.commandId]; + if (!parser) fail(1, `unknown commandId "${args.commandId}". Valid: ${Object.keys(PARSERS).join(', ')}`); + + const rendererName = commandToRendererName(args.commandId); + const renderer = RENDERERS[rendererName]; + if (typeof renderer !== 'function') fail(1, `no renderer found for "${args.commandId}" (looked up ${rendererName})`); + + let md; + try { + md = args.in === '-' ? readStdinSync() : readFileSync(resolve(args.in), 'utf8'); + } catch (e) { + fail(1, `cannot read input: ${e.message}`); + } + if (!md || !md.trim()) fail(1, 'input markdown is empty'); + + let data; + try { + data = parser(md); + } catch (e) { + fail(1, `parser threw: ${e.message}`); + } + if (!data || typeof data !== 'object') fail(1, 'parser returned non-object'); + + const slot = { innerHTML: '' }; + try { + renderer(data, slot); + } catch (e) { + fail(2, `renderer threw: ${e.message}`); + } + if (!slot.innerHTML) fail(2, 'renderer produced empty HTML'); + + const dsCss = loadDsCss(); + const html = wrapHtml(args.commandId, slot.innerHTML, dsCss); + + if (args.out === '-') { + process.stdout.write(html); + return; + } + + let outPath; + if (args.out) { + outPath = isAbsolute(args.out) ? args.out : resolve(args.out); + } else { + const reportsDir = resolve('reports'); + try { + mkdirSync(reportsDir, { recursive: true }); + } catch (e) { + fail(3, `cannot create reports dir: ${e.message}`); + } + outPath = join(reportsDir, `${args.commandId}-${timestamp()}.html`); + } + + try { + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, html, 'utf8'); + } catch (e) { + fail(3, `cannot write ${outPath}: ${e.message}`); + } + + process.stdout.write(`file://${outPath}\n`); +} + +main();