From 1ae0a142f8525410a688f0309fd003cc5b1d92de Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Wed, 12 Aug 2026 15:06:43 +0530 Subject: [PATCH] feat(worker): render PDF security reports via Typst (#421) --- CLAUDE.md | 2 +- Dockerfile | 18 + apps/cli/src/commands/start.ts | 4 +- apps/cli/src/paths.ts | 6 +- apps/worker/src/paths.ts | 10 +- apps/worker/src/services/pdf-renderer.ts | 98 ++++ .../src/services/report-json-adapter.ts | 293 ++++++++++ .../src/services/report-output-schema.ts | 157 +++++ apps/worker/src/services/reporting.ts | 18 +- apps/worker/src/services/sarif-renderer.ts | 2 +- apps/worker/src/temporal/activities.ts | 27 + apps/worker/src/temporal/worker.ts | 13 +- .../templates/typst/assets/keygraph-logo.png | Bin 0 -> 63498 bytes apps/worker/templates/typst/report.typ | 535 ++++++++++++++++++ docs/configuration.md | 2 +- docs/development.md | 2 +- docs/workspaces.md | 2 +- llms-full.txt | 6 +- 18 files changed, 1168 insertions(+), 27 deletions(-) create mode 100644 apps/worker/src/services/pdf-renderer.ts create mode 100644 apps/worker/src/services/report-json-adapter.ts create mode 100644 apps/worker/src/services/report-output-schema.ts create mode 100644 apps/worker/templates/typst/assets/keygraph-logo.png create mode 100644 apps/worker/templates/typst/report.typ diff --git a/CLAUDE.md b/CLAUDE.md index df6f7e3..0b53db7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig - **Prompts** — Per-phase templates in `apps/worker/prompts/` with variable substitution (`{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`). Shared partials in `apps/worker/prompts/shared/` via `apps/worker/src/services/prompt-manager.ts`, including `_code-path-rules.txt` (focus/avoid `[FILE]`/`[GLOB]` routing) and `_rules-of-engagement.txt` (free-text engagement rules). When `exploit: false`, `apps/worker/src/services/findings-renderer.ts` deterministically converts each `*_exploitation_queue.json` into a `*_findings.md` for report assembly — no LLM in the loop - **Agent Harness (pi)** — Uses the **pi harness** (`@earendil-works/pi-coding-agent`, requires Node ≥ 22.19) via `apps/worker/src/ai/pi/pi-executor.ts` (`runPiPrompt` → `createAgentSession`). Retry is split in `apps/worker/src/ai/pi/retry-settings.ts`: pi's agent-level loop is off so Temporal owns agent restarts, while `provider.maxRetries` stays on — pi reads the `provider` block independently of the `enabled` flag — so transport faults are absorbed in-session rather than costing a full agent re-run. `maxRetryDelayMs` is left at pi's 60s default. One model runs every phase, named by `SHANNON_AI_MODEL=:` (default `anthropic:claude-sonnet-4-6`). `apps/worker/src/ai/models.ts` parses the spec — splitting on the **first** colon only, so Bedrock IDs keep theirs — and resolves it through pi's `ModelRuntime`. pi ships the `CredentialStore` interface but no in-memory implementation (its own reads `auth.json` from disk), so `RuntimeCredentialStore` in that file supplies one: credentials arrive as env vars in an ephemeral container and must never touch disk. `createModelRuntime(providerId, apiKey)` builds the runtime; `allowModelNetwork` stays at its default `false` so a scan never blocks on a catalog refresh. `resolveModelSelection()` is **async** because `ModelRuntime.create()` is. Any pi-ai provider id is accepted — `parseModelSpec` no longer rejects against a hardcoded list, so pi's registry is the authority (an unknown provider/model surfaces as a clear "not found in pi registry" error at preflight, which points to the browsable catalogue at `pi.dev/models` — `PI_CATALOG_URL` in `apps/worker/src/ai/models.ts`, appended to the not-found errors and shown in the setup wizard's "Other provider" hint). Four providers are **curated** (`CURATED_PROVIDERS`: `anthropic`, `openai`, `xai`, `amazon-bedrock`) with their own credential variables, config sections, and setup flows; each provider's API key env var is declared once in `PROVIDER_API_KEY_ENV` — Shannon uses each vendor's own variable name (`OPENAI_API_KEY`, `XAI_API_KEY`, …), never an invented one; Bedrock's entry is `AWS_BEARER_TOKEN_BEDROCK`, paired with `AWS_REGION`, which preflight requires separately as provider config rather than a credential. Any other provider uses the **generic** credential path: `SHANNON_AI_API_KEY` (`GENERIC_API_KEY_ENV`) supplies the key for any provider whose credential is a plain API key. Curated providers' own variables take precedence over it, and it also works as a fallback for them — Bedrock is the sole exception (it authenticates through its AWS_ variables, so the generic key never stands in for it). The CLI forwards `SHANNON_AI_API_KEY` in `COMMON_FORWARD_VARS` (it is provider-neutral, binding to whatever `SHANNON_AI_MODEL` names, so the "only one provider configured" guard counts only named credentials), and stores it under a generic `[provider]` config.toml section (`provider.api_key`). `npx @keygraph/shannon setup` exposes this as the "Other provider" option: free-text provider id + model id + key (a curated provider id is rejected there, since it has its own option). `SHANNON_AI_BASE_URL` overrides the endpoint for any provider (proxies/gateways); the credential is unchanged. `pointAtGateway` (`apps/worker/src/ai/models.ts`) applies the one dialect change: behind a base URL, `openai` follows `SHANNON_AI_OPENAI_FORMAT` (`chat-completions` default, or `responses`). On `chat-completions` it switches the API to `openai-completions` and drops the catalogue's Responses-shaped `compat` block so pi's `detectCompat` derives completions settings; on `responses` the descriptor is unchanged but for the endpoint. `resolveGatewayFormat` rejects the variable when the provider is not `openai` or no base URL is set, since it cannot take effect there. All other providers keep their API. The CLI mirrors the accepted values in `apps/cli/src/model-spec.ts`, forwards the variable in `COMMON_FORWARD_VARS`, and maps it to `openai.format` in config.toml. `buildEnvFlags` forwards only the selected provider's credential into the worker container. The CLI mirrors the parse rule and the provider/credential tables in `apps/cli/src/model-spec.ts` (it cannot import from the worker package); the two must stay in sync. pi ships no JSON-schema output or `Task`/`TodoWrite` built-ins, so structured queues are captured via a `submit_exploitation_queue` custom tool (`apps/worker/src/ai/queue-schemas.ts`), and `task` (child sessions scoped to `read`, `grep`, `find`, `ls`, `write`, and `bash` — no nested `task` or collector tools; `CHILD_TOOLS` in `apps/worker/src/ai/pi/task-tool.ts`) + `todo_write` (`apps/worker/src/ai/pi/session-tools.ts`) are provided as custom tools; the per-phase collectors are pi custom tools (TypeBox `defineTool` in `apps/worker/src/collectors/`). Shannon sets no thinking configuration at all — no `thinkingLevel` is passed to any `createAgentSession` call, so pi's own default applies. There is no adaptive-thinking support and no `CLAUDE_ADAPTIVE_THINKING` / `core.adaptive_thinking` setting. Browser automation via `playwright-cli` with session isolation (`-s=`). TOTP generation via `generate-totp` CLI tool. Login flow template at `apps/worker/prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth. On authenticated whitebox scans, the `validate-authentication` preflight performs the single real login and saves the browser session to `auth-state.json` in the per-session audit directory (path from `authStateFile()` in `apps/worker/src/audit/utils.ts`, derived from `generateAuditPath()`). The validation activity (`apps/worker/src/services/validate-authentication.ts`) removes any stale file from a prior run before the agent runs and verifies the file parses and contains cookies or storage before the preflight is marked complete; `logWorkflowComplete` deletes it when the workflow ends so authenticated cookies don't sit on disk between scans. Agent prompts opt in to session reuse by `@include(shared/_shared-session.txt)` before their `` block — the partial restores the session and falls through to the full login flow if verification fails. `vuln-auth`/`exploit-auth` omit the include and own their own login - **Pi Credential Reuse** — `SHANNON_USE_PI_AUTH=1` opts into reusing the host's Pi login, including an `openai-codex` ChatGPT Plus/Pro subscription selected with `SHANNON_AI_MODEL=openai-codex:`. `apps/cli/src/env.ts` requires `~/.pi/agent/auth.json`; `start.ts` passes its path to `spawnWorker`, which mounts only that file read-write at `/tmp/.pi/agent/auth.json`. The flag itself is not forwarded: the worker detects the file with `piAuthPresent()` and passes its path to `ModelRuntime.create`. CLI and worker API-key presence checks are skipped on this path, but the normal preflight model probe still validates the credential. The image and UID-remapping entrypoint keep `/tmp/.pi/agent` owned by `pentest` so adjacent Pi/Shannon configuration remains writable. Refreshed OAuth state is persisted to the host for subsequent scans. -- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. The run directory's top level holds only the human-facing report (`Security-Assessment-Report.md`, `FINAL_REPORT_FILENAME` in `apps/worker/src/paths.ts`); everything else — deliverables, per-agent logs, prompts, `session.json`, `workflow.log`, and browser artifacts — is nested under a hidden `.shannon/` internals dir (`INTERNAL_DIR`) so a customer sees only the report. Audit path helpers route through `generateInternalPath` (`apps/worker/src/audit/utils.ts`); the CLI nests the overlay backing dirs under the same `.shannon/` (`apps/cli/src/docker.ts`, `start.ts`). `session.json`/`workflow.log` reads use dual-read resolvers (`resolveSessionJsonPath`, `resolveRunFile`) that prefer `.shannon/` and fall back to the legacy run-root layout, so pre-restructure workspaces stay listable (`workspaces`/`logs`) without migration. Resuming a pre-restructure workspace upgrades it in place first: `migrateLegacyWorkspaceLayout` (`apps/cli/src/commands/start.ts`) renames the flat deliverables/logs/session entries into `.shannon/` (carrying the deliverables `.git` along) before the overlay dirs are mounted, so resume finds the old checkpoints instead of re-running every agent. The report is surfaced by copying the assembled `comprehensive_security_assessment_report.md` from the deliverables dir to the run root (`copyReportToRunRoot` in `apps/worker/src/services/reporting.ts`). WorkflowLogger (`apps/worker/src/audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`apps/worker/src/audit/log-stream.ts`) shared stream primitive +- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. The run directory's top level holds only the human-facing PDF report (`Security-Assessment-Report.pdf`, `FINAL_REPORT_PDF_FILENAME` in `apps/worker/src/paths.ts`); everything else — deliverables, per-agent logs, prompts, `session.json`, `workflow.log`, and browser artifacts — is nested under a hidden `.shannon/` internals dir (`INTERNAL_DIR`) so a customer sees only the report. Audit path helpers route through `generateInternalPath` (`apps/worker/src/audit/utils.ts`); the CLI nests the overlay backing dirs under the same `.shannon/` (`apps/cli/src/docker.ts`, `start.ts`). `session.json`/`workflow.log` reads use dual-read resolvers (`resolveSessionJsonPath`, `resolveRunFile`) that prefer `.shannon/` and fall back to the legacy run-root layout, so pre-restructure workspaces stay listable (`workspaces`/`logs`) without migration. Resuming a pre-restructure workspace upgrades it in place first: `migrateLegacyWorkspaceLayout` (`apps/cli/src/commands/start.ts`) renames the flat deliverables/logs/session entries into `.shannon/` (carrying the deliverables `.git` along) before the overlay dirs are mounted, so resume finds the old checkpoints instead of re-running every agent. The report agent writes structured findings to `report.json`, from which `report-renderer.ts` renders the assembled markdown and `report-json-adapter.ts` produces the Typst-shaped JSON that `pdf-renderer.ts` compiles into `comprehensive_security_assessment_report.pdf` using the bundled `apps/worker/templates/typst/report.typ` template (the `typst` binary is installed in the worker image). `copyReportToRunRoot` (`apps/worker/src/services/reporting.ts`) surfaces the PDF to the run root as `Security-Assessment-Report.pdf`; the markdown stays in the deliverables dir and is not surfaced. PDF compilation is best-effort — a failure is logged and the run still completes. WorkflowLogger (`apps/worker/src/audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`apps/worker/src/audit/log-stream.ts`) shared stream primitive - **Deliverables** — Saved to `.shannon/deliverables/` in the target repo via the `save-deliverable` CLI script (`apps/worker/src/scripts/save-deliverable.ts`) - **Workspaces & Resume** — Named workspaces via `-w ` or auto-named from URL+timestamp. Resume detects completed agents via `session.json`. `loadResumeState()` in `apps/worker/src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `apps/worker/src/temporal/workspaces.ts` diff --git a/Dockerfile b/Dockerfile index cca3fef..42b7f7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,8 @@ RUN apk update && apk add --no-cache \ curl \ ca-certificates \ shadow \ + # Typst tarball decompression + xz \ # Language runtimes (minimal) nodejs-22 \ npm \ @@ -73,6 +75,22 @@ RUN apk update && apk add --no-cache \ # Font rendering fontconfig +# Install Typst (report PDF compilation) +ARG TYPST_VERSION=0.14.2 +RUN case "$(uname -m)" in \ + x86_64) TYPST_ARCH=x86_64-unknown-linux-musl ;; \ + aarch64) TYPST_ARCH=aarch64-unknown-linux-musl ;; \ + *) echo "unsupported arch $(uname -m)" && exit 1 ;; \ + esac && \ + mkdir -p /tmp/typst-dl /usr/local/bin && cd /tmp/typst-dl && \ + curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" -o typst.tar.xz && \ + xz -d typst.tar.xz && \ + tar -xf typst.tar && \ + mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \ + chmod +x /usr/local/bin/typst && \ + cd / && rm -rf /tmp/typst-dl && \ + typst --version + # Create non-root user RUN addgroup -g 1001 pentest && \ adduser -u 1001 -G pentest -s /bin/bash -D pentest diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index eac3665..dbc36f2 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -13,7 +13,7 @@ import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validat import { getWorkspacesDir, initHome } from '../home.js'; import { isLocal } from '../mode.js'; import { resolveModelSpec } from '../model-spec.js'; -import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js'; +import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js'; import { displaySplash } from '../splash.js'; import { stdoutIsTerminal } from '../tty.js'; @@ -249,7 +249,7 @@ function printInfo( workspacesDir: string, ): void { const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`; - const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_FILENAME); + const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME); console.log(' Scan started — it runs in the background, so you can close this terminal.'); console.log(''); diff --git a/apps/cli/src/paths.ts b/apps/cli/src/paths.ts index dcbdc49..c26ec85 100644 --- a/apps/cli/src/paths.ts +++ b/apps/cli/src/paths.ts @@ -23,10 +23,10 @@ export interface MountPair { export const INTERNAL_DIR = '.shannon'; /** - * Filename of the human-facing final report surfaced at the run directory root. - * Must match FINAL_REPORT_FILENAME in the worker package. + * Filename of the human-facing PDF report surfaced at the run directory root. + * Must match FINAL_REPORT_PDF_FILENAME in the worker package. */ -export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md'; +export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf'; /** * Resolve a run-directory file (e.g. session.json, workflow.log), preferring the diff --git a/apps/worker/src/paths.ts b/apps/worker/src/paths.ts index 4e93c04..8d53a70 100644 --- a/apps/worker/src/paths.ts +++ b/apps/worker/src/paths.ts @@ -9,6 +9,9 @@ const WORKER_ROOT = path.resolve(import.meta.dirname, '..'); export const PROMPTS_DIR = path.join(WORKER_ROOT, 'prompts'); export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs'); +/** Bundled Typst template that renders report.json into the PDF report. */ +export const TYPST_TEMPLATE = path.join(WORKER_ROOT, 'templates', 'typst', 'report.typ'); + /** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */ export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout'); @@ -28,8 +31,11 @@ export const INTERNAL_DIR = '.shannon'; /** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */ export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md'; -/** Filename of the human-facing final report surfaced at the run directory root */ -export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md'; +/** Filename of the compiled PDF report inside the deliverables dir (internal, source of the surfaced copy) */ +export const ASSEMBLED_REPORT_PDF_FILENAME = 'comprehensive_security_assessment_report.pdf'; + +/** Filename of the human-facing PDF report surfaced at the run directory root */ +export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf'; /** Structured findings the report agent emits; the markdown report is rendered from it. */ export const REPORT_JSON_FILENAME = 'report.json'; diff --git a/apps/worker/src/services/pdf-renderer.ts b/apps/worker/src/services/pdf-renderer.ts new file mode 100644 index 0000000..7144231 --- /dev/null +++ b/apps/worker/src/services/pdf-renderer.ts @@ -0,0 +1,98 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Typst PDF renderer. + * + * Adapts the structured report.json into the Typst-shaped schema and compiles + * it to a PDF with the bundled report.typ template. Compilation runs in an + * isolated temp dir: the template is copied in and the adapted JSON is written + * beside it so `--root` can scope every file read to that dir, matching how the + * template resolves `--input data=/data.json`. + * + * The `typst` binary is installed in the worker image and resolved from PATH. + */ + +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { adaptReportToTypst } from './report-json-adapter.js'; +import type { ReportData } from './report-renderer.js'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TESTER = 'Shannon'; +const DEFAULT_BRAND = 'Shannon | AI Pentester by Keygraph'; + +const DATA_FILENAME = 'data.json'; +const TEMPLATE_FILENAME = 'report.typ'; +const OUTPUT_FILENAME = 'report.pdf'; + +export interface RenderReportPdfOptions { + /** Structured report data (report.json contents), pre-assembly. */ + readonly reportData: ReportData; + /** Absolute path to the bundled report.typ template. */ + readonly templatePath: string; + /** Absolute path where the compiled PDF should be written. */ + readonly outputPath: string; + /** Name shown on the cover/footer. Defaults to "Shannon". */ + readonly tester?: string; + /** Wordmark shown on the cover. Defaults to "Shannon | AI Pentester by Keygraph". */ + readonly brand?: string; +} + +/** + * Compile the report to a PDF at `outputPath`. + * + * Throws if adaptation or `typst compile` fails; callers treat the PDF as a + * secondary artifact and should not let a failure here fail the run. + */ +export async function renderReportPdf(options: RenderReportPdfOptions): Promise { + const { reportData, templatePath, outputPath } = options; + const tester = options.tester ?? DEFAULT_TESTER; + const brand = options.brand ?? DEFAULT_BRAND; + + const typstData = adaptReportToTypst(reportData); + + const workDir = await mkdtemp(path.join(tmpdir(), 'shannon-typst-')); + try { + const templateInWorkDir = path.join(workDir, TEMPLATE_FILENAME); + const dataInWorkDir = path.join(workDir, DATA_FILENAME); + const pdfInWorkDir = path.join(workDir, OUTPUT_FILENAME); + + await copyFile(templatePath, templateInWorkDir); + + // Ship the template's assets (e.g. the cover logo) so `--root`-scoped image reads resolve. + const assetsDir = path.join(path.dirname(templatePath), 'assets'); + if (existsSync(assetsDir)) { + await cp(assetsDir, path.join(workDir, 'assets'), { recursive: true }); + } + + await writeFile(dataInWorkDir, JSON.stringify(typstData), 'utf-8'); + + await execFileAsync('typst', [ + 'compile', + '--root', + workDir, + '--input', + `data=/${DATA_FILENAME}`, + '--input', + `tester=${tester}`, + '--input', + `brand=${brand}`, + templateInWorkDir, + pdfInWorkDir, + ]); + + await mkdir(path.dirname(outputPath), { recursive: true }); + await copyFile(pdfInWorkDir, outputPath); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} diff --git a/apps/worker/src/services/report-json-adapter.ts b/apps/worker/src/services/report-json-adapter.ts new file mode 100644 index 0000000..4becb78 --- /dev/null +++ b/apps/worker/src/services/report-json-adapter.ts @@ -0,0 +1,293 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Programmatic adapter: report.json → Typst ReportData JSON. + * + * Converts the renderer-neutral structured report output (produced by the + * finding-collector + set-report-meta CLI) into the Typst-specific schema that + * report.typ consumes. + * + * All Typst-specific concepts (PascalCase enums, computed aggregations, + * exploitedByType grouping) are confined to this file. The rest of the + * pipeline knows nothing about the Typst shape. + */ + +import type { AddFindingInput, AdditionalSection, StepItem, StructuredStep } from '../collectors/finding-collector.js'; +import type { + ExploitsReportData, + FindingsReportData, + TypstCategory, + TypstConfidence, + ReportData as TypstReportData, + TypstSeverity, + TypstStatus, +} from './report-output-schema.js'; +import type { ReportData } from './report-renderer.js'; + +// ============================================================================ +// CASING TRANSFORMS +// ============================================================================ + +const SEVERITY_MAP: Record = { + critical: 'Critical', + high: 'High', + medium: 'Medium', + low: 'Low', +}; + +const STATUS_MAP: Record = { + exploited: 'Exploited', + out_of_scope: 'OutOfScope', + blocked_by_constraints: 'BlockedByConstraints', + false_positive: 'FalsePositive', +}; + +const CONFIDENCE_MAP: Record = { + high: 'High', + medium: 'Medium', + low: 'Low', +}; + +const VALID_CATEGORIES = new Set([ + 'Authentication', + 'Authorization', + 'XSS', + 'Injection', + 'SSRF', + 'Other', +]); + +function toTypstSeverity(s: string): TypstSeverity { + return SEVERITY_MAP[s] ?? 'Low'; +} + +function toTypstStatus(s: string): TypstStatus { + return STATUS_MAP[s] ?? 'Exploited'; +} + +function toTypstConfidence(s: string): TypstConfidence { + return CONFIDENCE_MAP[s] ?? 'Medium'; +} + +function toTypstCategory(s: string): TypstCategory { + if (VALID_CATEGORIES.has(s as TypstCategory)) return s as TypstCategory; + return 'Other'; +} + +// ============================================================================ +// STEP / ITEM TRANSFORMS +// ============================================================================ + +function adaptStepItem(item: StepItem): StepItem { + return item; +} + +function adaptStep(step: StructuredStep, index: number): { number: number; title?: string; items: StepItem[] } { + return { + number: index + 1, + ...(step.title && { title: step.title }), + items: step.items.map(adaptStepItem), + }; +} + +function adaptAdditionalSection(section: AdditionalSection): { heading: string; items: StepItem[] } { + return { + heading: section.heading, + items: section.items.map(adaptStepItem), + }; +} + +// ============================================================================ +// AGGREGATION HELPERS +// ============================================================================ + +interface CategoryGroup { + category: TypstCategory; + findings: AddFindingInput[]; +} + +function groupByCategory(findings: readonly AddFindingInput[]): CategoryGroup[] { + const map = new Map(); + for (const f of findings) { + const cat = toTypstCategory(f.category); + const list = map.get(cat) ?? []; + list.push(f); + map.set(cat, list); + } + return Array.from(map.entries()).map(([category, fs]) => ({ category, findings: fs })); +} + +function countBySeverity(findings: readonly AddFindingInput[]): Record { + const counts: Record = { + Critical: 0, + High: 0, + Medium: 0, + Low: 0, + }; + for (const f of findings) { + const sev = toTypstSeverity(f.severity); + counts[sev] = (counts[sev] ?? 0) + 1; + } + return counts as Record; +} + +// ============================================================================ +// EXPLOIT MODE ADAPTER +// ============================================================================ + +function adaptExploitsMode(data: ReportData): ExploitsReportData { + const { report_meta, findings } = data; + const groups = groupByCategory(findings); + const sevCounts = countBySeverity(findings); + + const statusCounts = { Exploited: 0, OutOfScope: 0, BlockedByConstraints: 0, FalsePositive: 0 }; + for (const f of findings) { + const s = toTypstStatus(f.status ?? 'exploited'); + statusCounts[s]++; + } + + const exploitedFindings = findings.filter((f) => (f.status ?? 'exploited') === 'exploited'); + + return { + mode: 'exploits' as const, + meta: { + target: report_meta.target, + assessmentDate: report_meta.assessment_date, + classification: 'CONFIDENTIAL', + }, + scope: report_meta.scope, + exploitedByType: groups.map((g) => { + const exploited = g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited'); + if (exploited.length === 0) { + return { + category: g.category, + narrative: `No ${g.category.toLowerCase()} vulnerabilities were successfully exploited during this assessment.`, + }; + } + return { + category: g.category, + bullets: exploited.map((f) => ({ id: f.finding_id, description: f.title })), + }; + }), + summary: { + totalIdentified: findings.length, + successfullyExploited: exploitedFindings.length, + exploitedBreakdown: groups + .map((g) => ({ + category: g.category, + count: g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited').length, + })) + .filter((e) => e.count > 0), + criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`), + }, + findings: findings.map((f) => ({ + id: f.finding_id, + title: f.title, + category: toTypstCategory(f.category), + severity: toTypstSeverity(f.severity), + status: toTypstStatus(f.status ?? 'exploited'), + summary: { + vulnerableLocation: f.vulnerable_location, + overview: f.overview, + impact: f.impact, + }, + // This branch only runs for an exploitative report, where the schema made these + // required. The fallbacks keep the superset type honest rather than assuming. + prerequisites: f.prerequisites ?? '', + exploitationSteps: (f.exploitation_steps ?? []).map(adaptStep), + proofOfImpact: (f.proof_of_impact ?? []).map(adaptStepItem), + ...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }), + ...(f.additional_sections && + f.additional_sections.length > 0 && { + additionalSections: f.additional_sections.map(adaptAdditionalSection), + }), + })), + derivedCounts: { + bySeverity: sevCounts, + byStatus: statusCounts, + }, + }; +} + +// ============================================================================ +// FINDINGS MODE ADAPTER +// ============================================================================ + +function adaptFindingsMode(data: ReportData): FindingsReportData { + const { report_meta, findings } = data; + const groups = groupByCategory(findings); + const sevCounts = countBySeverity(findings); + + const confidenceCounts = { High: 0, Medium: 0, Low: 0 }; + for (const f of findings) { + const c = toTypstConfidence(f.confidence ?? 'medium'); + confidenceCounts[c]++; + } + + return { + mode: 'findings' as const, + meta: { + target: report_meta.target, + assessmentDate: report_meta.assessment_date, + classification: 'CONFIDENTIAL', + }, + scope: report_meta.scope, + identifiedByType: groups.map((g) => { + if (g.findings.length === 0) { + return { + category: g.category, + narrative: `No ${g.category.toLowerCase()} vulnerabilities were identified during this assessment.`, + }; + } + return { + category: g.category, + bullets: g.findings.map((f) => ({ id: f.finding_id, description: f.title })), + }; + }), + summary: { + totalIdentified: findings.length, + identifiedBreakdown: groups.map((g) => ({ + category: g.category, + count: g.findings.length, + })), + criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`), + }, + findings: findings.map((f) => ({ + id: f.finding_id, + title: f.title, + category: toTypstCategory(f.category), + severity: toTypstSeverity(f.severity), + confidence: toTypstConfidence(f.confidence ?? 'medium'), + summary: { + vulnerableLocation: f.vulnerable_location, + overview: f.overview, + impact: f.impact, + }, + ...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }), + ...(f.additional_sections && + f.additional_sections.length > 0 && { + additionalSections: f.additional_sections.map(adaptAdditionalSection), + }), + })), + derivedCounts: { + bySeverity: sevCounts, + byConfidence: confidenceCounts, + }, + }; +} + +// ============================================================================ +// PUBLIC API +// ============================================================================ + +export function adaptReportToTypst(data: ReportData): TypstReportData { + const exploitEnabled = data.report_meta.exploit ?? true; + if (exploitEnabled) { + return adaptExploitsMode(data); + } + return adaptFindingsMode(data); +} diff --git a/apps/worker/src/services/report-output-schema.ts b/apps/worker/src/services/report-output-schema.ts new file mode 100644 index 0000000..3f53ac9 --- /dev/null +++ b/apps/worker/src/services/report-output-schema.ts @@ -0,0 +1,157 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * TypeScript types for the structured report the Typst template consumes, in two + * shapes keyed by a `mode` discriminator: `exploits` (exploit=true) and `findings` + * (exploit=false, analysis-only). Types only — the object is built programmatically + * in report-json-adapter.ts, so these exist to keep the adapter and report.typ in sync. + */ + +// === Shared primitives === + +export type TypstSeverity = 'Critical' | 'High' | 'Medium' | 'Low'; +export type TypstStatus = 'Exploited' | 'OutOfScope' | 'BlockedByConstraints' | 'FalsePositive'; +export type TypstConfidence = 'High' | 'Medium' | 'Low'; +export type TypstCategory = 'Authentication' | 'Authorization' | 'XSS' | 'Injection' | 'SSRF' | 'Other'; + +export interface CodeBlock { + readonly language: string; + readonly content: string; +} + +export type StepItem = + | { readonly kind: 'prose'; readonly text: string } + | { readonly kind: 'code'; readonly block: CodeBlock }; + +export interface Step { + readonly number: number; + readonly title?: string; + readonly items: readonly StepItem[]; +} + +export interface AdditionalSection { + readonly heading: string; + readonly items: readonly StepItem[]; +} + +export interface FindingSummary { + readonly vulnerableLocation: string; + readonly overview: string; + readonly impact: string; +} + +export interface Meta { + readonly target: string; + readonly assessmentDate: string; + readonly tester?: string; + readonly application?: string; + readonly classification: string; +} + +export interface CategoryCount { + readonly category: TypstCategory; + readonly count: number; + readonly note?: string; +} + +export type SeverityCounts = Record; +export type StatusCounts = Record; +export type ConfidenceCounts = Record; + +export interface TypeEntryBullet { + readonly id: string; + readonly description: string; +} + +// === Exploits-mode schema === + +export interface ExploitFinding { + readonly id: string; + readonly title: string; + readonly category: TypstCategory; + readonly severity: TypstSeverity; + readonly status: TypstStatus; + readonly summary: FindingSummary; + readonly prerequisites: string; + readonly exploitationSteps: readonly Step[]; + readonly proofOfImpact: readonly StepItem[]; + readonly notes?: readonly StepItem[]; + readonly additionalSections?: readonly AdditionalSection[]; +} + +export interface ExploitedByTypeEntry { + readonly category: TypstCategory; + readonly bullets?: readonly TypeEntryBullet[]; + readonly narrative?: string; +} + +export interface ExploitsReportData { + readonly mode: 'exploits'; + readonly meta: Meta; + readonly scope: string; + readonly exploitedByType: readonly ExploitedByTypeEntry[]; + readonly summary: { + readonly totalIdentified: number; + readonly successfullyExploited: number; + readonly exploitedBreakdown: readonly CategoryCount[]; + readonly outOfScope?: { + readonly total: number; + readonly breakdown?: readonly CategoryCount[]; + readonly note?: string; + }; + readonly blockedByConstraints?: { + readonly total: number; + readonly note?: string; + }; + readonly criticalFindings: readonly string[]; + }; + readonly findings: readonly ExploitFinding[]; + readonly derivedCounts: { + readonly bySeverity: SeverityCounts; + readonly byStatus: StatusCounts; + }; +} + +// === Findings-mode schema (analysis-only, exploit=false runs) === + +export interface AnalysisFinding { + readonly id: string; + readonly title: string; + readonly category: TypstCategory; + readonly severity: TypstSeverity; + readonly confidence: TypstConfidence; + readonly summary: FindingSummary; + readonly notes?: readonly StepItem[]; + readonly additionalSections?: readonly AdditionalSection[]; +} + +export interface IdentifiedByTypeEntry { + readonly category: TypstCategory; + readonly bullets?: readonly TypeEntryBullet[]; + readonly narrative?: string; +} + +export interface FindingsReportData { + readonly mode: 'findings'; + readonly meta: Meta; + readonly scope: string; + readonly identifiedByType: readonly IdentifiedByTypeEntry[]; + readonly summary: { + readonly totalIdentified: number; + readonly identifiedBreakdown: readonly CategoryCount[]; + readonly criticalFindings: readonly string[]; + }; + readonly findings: readonly AnalysisFinding[]; + readonly derivedCounts: { + readonly bySeverity: SeverityCounts; + readonly byConfidence: ConfidenceCounts; + }; +} + +// === Discriminated union for downstream consumers that handle both === + +export type ReportData = ExploitsReportData | FindingsReportData; diff --git a/apps/worker/src/services/reporting.ts b/apps/worker/src/services/reporting.ts index b8ca00c..8d98e5e 100644 --- a/apps/worker/src/services/reporting.ts +++ b/apps/worker/src/services/reporting.ts @@ -7,8 +7,9 @@ import { fs, path } from 'zx'; import { ASSEMBLED_REPORT_FILENAME, + ASSEMBLED_REPORT_PDF_FILENAME, deliverablesDir, - FINAL_REPORT_FILENAME, + FINAL_REPORT_PDF_FILENAME, resolveSessionJsonPath, SARIF_FILENAME, } from '../paths.js'; @@ -174,7 +175,8 @@ export async function injectModelIntoReport( /** * Surface the run's deliverables at the run directory's top level, so a customer opening the run * folder sees the report without digging through internals. Sources stay in the deliverables dir - * (git-checkpointed, used by resume). + * (git-checkpointed, used by resume). The PDF is the customer-facing report surfaced here; the + * markdown remains in the deliverables dir but is not surfaced. * * The SARIF log is surfaced beside it when present, since a CI step consuming it needs a stable * path and cannot be expected to reach into the internals directory. It is absent whenever the @@ -188,13 +190,13 @@ export async function copyReportToRunRoot( ): Promise { const dir = deliverablesDir(repoPath, deliverablesSubdir); - const source = path.join(dir, ASSEMBLED_REPORT_FILENAME); - if (await fs.pathExists(source)) { - const destination = path.join(runDir, FINAL_REPORT_FILENAME); - await fs.copy(source, destination, { overwrite: true }); - logger.info(`Surfaced report at ${destination}`); + const pdfSource = path.join(dir, ASSEMBLED_REPORT_PDF_FILENAME); + if (await fs.pathExists(pdfSource)) { + const destination = path.join(runDir, FINAL_REPORT_PDF_FILENAME); + await fs.copy(pdfSource, destination, { overwrite: true }); + logger.info(`Surfaced PDF report at ${destination}`); } else { - logger.warn(`Final report not found, skipping ${FINAL_REPORT_FILENAME}`); + logger.warn(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`); } const sarifSource = path.join(dir, SARIF_FILENAME); diff --git a/apps/worker/src/services/sarif-renderer.ts b/apps/worker/src/services/sarif-renderer.ts index 4d693ca..3a892a8 100644 --- a/apps/worker/src/services/sarif-renderer.ts +++ b/apps/worker/src/services/sarif-renderer.ts @@ -154,7 +154,7 @@ function buildMessageMarkdown(finding: AddFindingInput): string { parts.push('', '**Remediation**', '', finding.remediation); // Exploitation steps and proof of impact are deliberately absent: SARIF has no structural home // for them, and flattening them into prose would imply this file carries the evidence. - parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.md`'); + parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.pdf`'); return parts.join('\n'); } diff --git a/apps/worker/src/temporal/activities.ts b/apps/worker/src/temporal/activities.ts index 3fb9a44..f121093 100644 --- a/apps/worker/src/temporal/activities.ts +++ b/apps/worker/src/temporal/activities.ts @@ -27,11 +27,13 @@ import type { WorkflowSummary } from '../audit/workflow-logger.js'; import type { CheckpointContext } from '../interfaces/checkpoint-provider.js'; import { ASSEMBLED_REPORT_FILENAME, + ASSEMBLED_REPORT_PDF_FILENAME, DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir, REPORT_JSON_FILENAME, resolveSessionJsonPath, SARIF_FILENAME, + TYPST_TEMPLATE, } from '../paths.js'; import { getAgentGitPaths } from '../services/agent-git-paths.js'; import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js'; @@ -477,6 +479,30 @@ async function writeSarifIfEnabled( } } +/** + * Compile the PDF report from the assembled report data. + * + * Failures are logged and swallowed — the PDF is a secondary artifact and must not fail a run + * whose report is already written. + */ +async function writePdfReport( + reportData: ReportData, + deliverablesPath: string, + logger: ReturnType, +): Promise { + try { + const { renderReportPdf } = await import('../services/pdf-renderer.js'); + await renderReportPdf({ + reportData, + templatePath: TYPST_TEMPLATE, + outputPath: path.join(deliverablesPath, ASSEMBLED_REPORT_PDF_FILENAME), + }); + logger.info(`Wrote ${ASSEMBLED_REPORT_PDF_FILENAME}`); + } catch (error) { + logger.warn(`Failed to write ${ASSEMBLED_REPORT_PDF_FILENAME}: ${(error as Error).message}`); + } +} + export async function runReportAgent(input: ActivityInput, exploit: boolean): Promise { const { createFindingCollector } = await import('../collectors/finding-collector.js'); const { renderReport } = await import('../services/report-renderer.js'); @@ -532,6 +558,7 @@ export async function runReportAgent(input: ActivityInput, exploit: boolean): Pr await atomicWrite(path.join(deliverablesPath, ASSEMBLED_REPORT_FILENAME), renderReport(reportData)); logger.info(`Wrote ${ASSEMBLED_REPORT_FILENAME} from structured data`); + await writePdfReport(reportData, deliverablesPath, logger); await writeSarifIfEnabled(input, exploit, reportData, deliverablesPath, logger); }; diff --git a/apps/worker/src/temporal/worker.ts b/apps/worker/src/temporal/worker.ts index c70205b..c3b1983 100644 --- a/apps/worker/src/temporal/worker.ts +++ b/apps/worker/src/temporal/worker.ts @@ -35,7 +35,12 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker import dotenv from 'dotenv'; import { sanitizeHostname } from '../audit/utils.js'; import { parseConfig } from '../config-parser.js'; -import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js'; +import { + ASSEMBLED_REPORT_PDF_FILENAME, + deliverablesDir, + FINAL_REPORT_PDF_FILENAME, + resolveSessionJsonPath, +} from '../paths.js'; import type { VulnClass } from '../types/config.js'; import { fileExists, readJson } from '../utils/file-io.js'; import * as activities from './activities.js'; @@ -389,9 +394,9 @@ function copyDeliverables(repoPath: string, outputPath: string): void { } // Surface the report under its human-facing name alongside the raw deliverables - const assembledReport = path.join(outputDir, ASSEMBLED_REPORT_FILENAME); - if (fs.existsSync(assembledReport)) { - fs.copyFileSync(assembledReport, path.join(outputPath, FINAL_REPORT_FILENAME)); + const assembledPdf = path.join(outputDir, ASSEMBLED_REPORT_PDF_FILENAME); + if (fs.existsSync(assembledPdf)) { + fs.copyFileSync(assembledPdf, path.join(outputPath, FINAL_REPORT_PDF_FILENAME)); } console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`); diff --git a/apps/worker/templates/typst/assets/keygraph-logo.png b/apps/worker/templates/typst/assets/keygraph-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b0bd97806b34ff61d6aadff212ab1b833fef517e GIT binary patch literal 63498 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 Uz13-P6y!%qLH)%KIkOM{54!mScmMzZ literal 0 HcmV?d00001 diff --git a/apps/worker/templates/typst/report.typ b/apps/worker/templates/typst/report.typ new file mode 100644 index 0000000..fa8e157 --- /dev/null +++ b/apps/worker/templates/typst/report.typ @@ -0,0 +1,535 @@ +// ============================================================================= +// Security Assessment Report — Typst template +// Invoke: +// typst compile --root --input data=/data.json report.typ out.pdf +// Optional overrides: +// --input tester= --input brand= +// ============================================================================= + +#let data = json(sys.inputs.data) + +// Top-level discriminator. Schema variants in report-output-schema.ts: +// exploits → ExploitsReportData (exploit=true runs, full reproduction) +// findings → FindingsReportData (exploit=false runs, analysis-only) +#let mode = data.at("mode", default: "exploits") + +#let tester-override = sys.inputs.at("tester", default: "Shannon") +#let brand = sys.inputs.at("brand", default: "Shannon | AI Pentester by Keygraph") + +// ---------- Palette --------------------------------------------------------- +// Kept distinct so Critical / High are not confused under monitor gamma. +#let sev-color(level) = { + if level == "Critical" { rgb("#DC2626") } // red-600 + else if level == "High" { rgb("#EA580C") } // orange-600 + else if level == "Medium" { rgb("#D97706") } // amber-600 + else if level == "Low" { rgb("#2563EB") } // blue-600 + else { rgb("#6B7280") } +} + +#let confidence-color(c) = { + if c == "High" { rgb("#15803D") } // green-700 + else if c == "Medium" { rgb("#D97706") } // amber-600 + else if c == "Low" { rgb("#6B7280") } // gray-500 + else { rgb("#6B7280") } +} + +// Warm, editorial, high-contrast document palette. +#let ink = rgb("#141414") // warm near-black text +#let muted = rgb("#5C5850") // warm gray-brown labels +#let tertiary = rgb("#9A958D") // lightest muted +#let rule = rgb("#E6E1D9") // warm hair rules +#let rule-soft = rgb("#D9D3CA") +#let code-bg = rgb("#F6F1EB") // warm eggshell +#let alt-bg = rgb("#EBE6DF") +#let page-bg = white + +// ---------- Page setup ------------------------------------------------------ +#set document(title: "Security Assessment Report", author: brand) + +#set page( + paper: "a4", + margin: (top: 2.2cm, bottom: 2.2cm, left: 2.2cm, right: 2.2cm), + fill: page-bg, + header: context { + if counter(page).get().first() > 1 [ + #set text(size: 8.5pt, fill: muted) + #grid(columns: (1fr, auto), + [Security Assessment Report], + [CONFIDENTIAL], + ) + #v(-4pt) + #line(length: 100%, stroke: 0.3pt + rule) + ] + }, + footer: context { + if counter(page).get().first() > 1 [ + #set text(size: 8.5pt, fill: muted) + #line(length: 100%, stroke: 0.3pt + rule) + #v(2pt) + #grid(columns: (1fr, auto), + [#data.meta.assessmentDate], + [#counter(page).display() / #context counter(page).final().first()], + ) + ] + }, +) + +#set text(size: 10.5pt, fill: ink) +#set par(leading: 0.7em, justify: false) + +#show heading.where(level: 1): it => [ + #pagebreak(weak: true) + #v(4pt) + #set text(size: 24pt, weight: "bold", fill: ink) + #it.body + #v(4pt) + #line(length: 100%, stroke: 0.4pt + rule) + #v(10pt) +] +#show heading.where(level: 2): it => [ + #v(10pt) + #set text(size: 14pt, weight: "semibold", fill: ink) + #it.body + #v(2pt) +] +#show heading.where(level: 3): it => [ + #v(8pt) + #set text(size: 11.5pt, weight: "semibold", fill: ink) + #it.body + #v(-2pt) +] + +#show raw: set text(size: 8.5pt) +#show raw.where(block: false): it => box( + fill: code-bg, + inset: (x: 3pt, y: 0pt), + outset: (y: 2pt), + radius: 2pt, + it, +) +#show raw.where(block: true): it => block( + fill: code-bg, + stroke: (left: 2pt + rule, rest: none), + inset: (x: 10pt, y: 8pt), + width: 100%, + breakable: true, + { + set par(leading: 0.5em, justify: false) + it + }, +) + +// ---------- Helpers --------------------------------------------------------- +#let chip(label, color) = box( + fill: color, + inset: (x: 6pt, y: 2pt), + radius: 2pt, + text(fill: white, weight: "bold", size: 7.5pt, tracking: 0.3pt, upper(label)), +) + +#let categories-in-order = ( + "Authentication", + "Authorization", + "XSS", + "Injection", + "SSRF", + "Other", +) + +#let sev-chip(level) = chip(level, sev-color(level)) +#let confidence-chip(c) = chip(c + " confidence", confidence-color(c)) + +// inline-code renders a string, turning backtick-wrapped spans into +// inline raw. Safe on odd counts — a trailing unclosed backtick is +// emitted as literal text so nothing gets swallowed. +#let inline-code(s) = { + if type(s) != str { return s } + let parts = s.split("`") + if parts.len() == 1 { return parts.at(0) } + let out = [] + for (i, p) in parts.enumerate() { + if calc.even(i) { + out += [#p] + } else if i == parts.len() - 1 { + out += [#("`" + p)] + } else { + out += raw(p) + } + } + out +} + +#let render-items(items) = { + for item in items { + if item.kind == "prose" [ + #par(inline-code(item.text)) + ] else if item.kind == "code" [ + #raw(item.block.content, lang: item.block.language, block: true) + ] + } +} + +// Render step items as a bulleted list; prose items become bullets, +// code items break the list and render as code blocks in between. +#let render-bulleted-items(items) = { + for item in items { + if item.kind == "prose" [ + - #inline-code(item.text) + ] else if item.kind == "code" [ + #raw(item.block.content, lang: item.block.language, block: true) + ] + } +} + +// Render step items as a numbered list; prose items become enumerated, +// code items break the list and render as code blocks in between. +#let render-numbered-items(items) = { + for item in items { + if item.kind == "prose" [ + + #inline-code(item.text) + ] else if item.kind == "code" [ + #raw(item.block.content, lang: item.block.language, block: true) + ] + } +} + +// Render an array of strings as a bulleted list with inline-code support. +#let code-list(items) = list(..items.map(inline-code)) + +#let kv(label, value) = grid( + columns: (auto, 1fr), + column-gutter: 14pt, + row-gutter: 4pt, + text(fill: muted, size: 9.5pt)[#label], + value, +) + +// ---------- COVER PAGE ------------------------------------------------------ +#page(header: none, footer: none)[ + #set align(left) + #v(3.2cm) + + #let brand-parts = brand.split("|").map(p => p.trim()) + #grid( + columns: (auto, 1fr), + column-gutter: 8pt, + align: (horizon, horizon), + image("/assets/keygraph-logo.png", width: 1.6cm), + { + set par(leading: 0.6em) + text(size: 11pt, fill: ink, weight: "semibold", tracking: 1.2pt)[ + #upper(brand-parts.at(0)) + ] + if brand-parts.len() > 1 { + linebreak() + text(size: 9pt, fill: muted, weight: "regular")[ + #brand-parts.slice(1).join(" ") + ] + } + } + ) + #set par(leading: 0.7em) + + #v(1.6cm) + #set par(leading: 0.4em) + #text(size: 46pt, weight: "bold", fill: ink)[ + Security\ + Assessment\ + Report + ] + #set par(leading: 0.7em) + + #v(1fr) + + #line(length: 100%, stroke: 0.3pt + rule) + #v(0.6cm) + + #grid( + columns: (1fr, 1fr), + column-gutter: 28pt, + row-gutter: 14pt, + grid( + columns: (auto, 1fr), + column-gutter: 18pt, + row-gutter: 14pt, + text(fill: muted, size: 9pt)[Target], text(size: 10pt)[#inline-code(data.meta.target)], + text(fill: muted, size: 9pt)[Date], text(size: 10pt)[#data.meta.assessmentDate], + ..(if "application" in data.meta and data.meta.application != none { + (text(fill: muted, size: 9pt)[Application], text(size: 10pt)[#inline-code(data.meta.application)]) + } else { () }), + ), + grid( + columns: (auto, 1fr), + column-gutter: 18pt, + row-gutter: 14pt, + text(fill: muted, size: 9pt)[Tester], text(size: 10pt)[#tester-override], + text(fill: muted, size: 9pt)[Classification], + text(size: 10pt, weight: "semibold")[#data.meta.classification], + ), + ) + + #v(0.8cm) + #text(size: 8pt, fill: muted)[ + This document contains sensitive security findings. + Handle in accordance with your organization's data classification policy. + ] +] + +// ---------- TABLE OF CONTENTS ----------------------------------------------- +#outline(title: [Contents], depth: 3, indent: auto) + +// ---------- EXECUTIVE SUMMARY ----------------------------------------------- += Executive Summary + +#grid( + columns: (auto, 1fr), + column-gutter: 20pt, + row-gutter: 12pt, + text(fill: muted, size: 10pt)[Target], text(size: 10.5pt)[#inline-code(data.meta.target)], + text(fill: muted, size: 10pt)[Date], text(size: 10.5pt)[#data.meta.assessmentDate], + ..(if "application" in data.meta and data.meta.application != none { + (text(fill: muted, size: 10pt)[Application], text(size: 10.5pt)[#inline-code(data.meta.application)]) + } else { () }), + text(fill: muted, size: 10pt)[Tester], text(size: 10.5pt)[#tester-override], +) + +== Scope + +#inline-code(data.scope) + +// ---------- BY TYPE --------------------------------------------------------- +#let by-type-entries = if mode == "exploits" { data.exploitedByType } else { data.identifiedByType } +#if mode == "exploits" [ + = Successfully Exploited Vulnerabilities by Type +] else [ + = Identified Vulnerabilities by Type +] + +#for entry in by-type-entries [ + == #entry.category + #if "narrative" in entry and entry.narrative != none [ + #inline-code(entry.narrative) + ] + #if "bullets" in entry and entry.bullets != none [ + #list( + ..entry.bullets.map(b => [ + #text(weight: "semibold")[#b.id] — #inline-code(b.description) + ]) + ) + ] +] + +// ---------- SUMMARY --------------------------------------------------------- += Summary + +#let s = data.summary +#let sev = data.derivedCounts.bySeverity + +#let severity-card(label, sev-key, n) = box( + fill: sev-color(sev-key), + inset: (x: 8pt, y: 12pt), + radius: 4pt, + width: 100%, + stack( + dir: ttb, + spacing: 6pt, + text(fill: white, weight: "bold", size: 20pt)[#n], + text(fill: white, size: 8pt, tracking: 0.5pt)[#upper(label)], + ), +) + +#grid( + columns: 4, + column-gutter: 8pt, + severity-card("Critical", "Critical", sev.Critical), + severity-card("High", "High", sev.High), + severity-card("Medium", "Medium", sev.Medium), + severity-card("Low", "Low", sev.Low), +) + +#if mode == "findings" [ + #v(18pt) + #let cf = data.derivedCounts.byConfidence + #let confidence-card(label, c-key, n) = box( + stroke: 0.6pt + confidence-color(c-key), + inset: (x: 8pt, y: 12pt), + radius: 4pt, + width: 100%, + stack( + dir: ttb, + spacing: 6pt, + text(fill: ink, weight: "bold", size: 20pt)[#n], + text(fill: confidence-color(c-key), size: 8pt, tracking: 0.5pt)[#upper(label + " confidence")], + ), + ) + + #grid( + columns: 3, + column-gutter: 8pt, + confidence-card("High", "High", cf.High), + confidence-card("Medium", "Medium", cf.Medium), + confidence-card("Low", "Low", cf.Low), + ) +] + +#v(14pt) + +#if mode == "exploits" [ + #grid( + columns: (auto, 1fr), + column-gutter: 14pt, + row-gutter: 4pt, + text(fill: muted, size: 10pt)[Total identified], + text(weight: "semibold")[#s.totalIdentified], + text(fill: muted, size: 10pt)[Successfully exploited], + text(weight: "semibold")[#s.successfullyExploited], + ) +] else [ + #grid( + columns: (auto, 1fr), + column-gutter: 14pt, + row-gutter: 4pt, + text(fill: muted, size: 10pt)[Total identified], + text(weight: "semibold")[#s.totalIdentified], + ) +] + +#v(8pt) + +#let breakdown = if mode == "exploits" { s.exploitedBreakdown } else { s.identifiedBreakdown } +#list( + ..breakdown.map(c => [ + #text(weight: "semibold")[#c.count] #c.category#if "note" in c and c.note != none [ — #inline-code(c.note)] + ]) +) + +#if mode == "exploits" [ + #if "outOfScope" in s and s.outOfScope != none [ + #v(4pt) + #text(weight: "semibold")[Out of Scope#if "note" in s.outOfScope and s.outOfScope.note != none [ (#s.outOfScope.note)]:] #s.outOfScope.total vulnerabilities + #if "breakdown" in s.outOfScope and s.outOfScope.breakdown != none [ + #list( + ..s.outOfScope.breakdown.map(c => [ + #text(weight: "semibold")[#c.count] #c.category#if "note" in c and c.note != none [ — #inline-code(c.note)] + ]) + ) + ] + ] + + #if "blockedByConstraints" in s and s.blockedByConstraints != none [ + #v(4pt) + #text(weight: "semibold")[Blocked by Testing Constraints:] #s.blockedByConstraints.total#if "note" in s.blockedByConstraints and s.blockedByConstraints.note != none [ — #s.blockedByConstraints.note] + ] +] + +== Critical Findings + +#enum(..s.criticalFindings.map(f => [#inline-code(f)])) + +// ---------- FINDINGS OVERVIEW ----------------------------------------------- += Findings Overview + +#let show-confidence-col = mode == "findings" + +#table( + columns: if show-confidence-col { (auto, 1fr, auto, auto, auto) } else { (auto, 1fr, auto, auto) }, + stroke: none, + inset: (x: 8pt, y: 7pt), + align: if show-confidence-col { (left, left, left, center, center) } else { (left, left, left, center) }, + fill: (_, row) => if row == 0 { none } else if calc.even(row) { code-bg } else { none }, + table.header( + text(size: 9.5pt, weight: "semibold")[ID], + text(size: 9.5pt, weight: "semibold")[Title], + text(size: 9.5pt, weight: "semibold")[Category], + text(size: 9.5pt, weight: "semibold")[Severity], + ..(if show-confidence-col { (text(size: 9.5pt, weight: "semibold")[Confidence],) } else { () }), + ), + ..data.findings.map(f => ( + text(weight: "semibold")[#f.id], + inline-code(f.title), + text(size: 9.5pt)[#f.category], + sev-chip(f.severity), + ..(if show-confidence-col { (confidence-chip(f.confidence),) } else { () }), + )).flatten() +) + +// ---------- FINDING RENDER -------------------------------------------------- +#let render-finding-summary(f) = [ + #v(8pt) + #grid( + columns: (auto, 1fr), + column-gutter: 18pt, + row-gutter: 12pt, + text(fill: muted, size: 9.5pt)[Location], text(size: 10pt)[#inline-code(f.summary.vulnerableLocation)], + text(fill: muted, size: 9.5pt)[Overview], text(size: 10pt)[#inline-code(f.summary.overview)], + text(fill: muted, size: 9.5pt)[Impact], text(size: 10pt)[#inline-code(f.summary.impact)], + ) +] + +#let render-finding-extras(f) = [ + #if "notes" in f and f.notes != none and f.notes.len() > 0 [ + #heading(level: 3, outlined: false)[Notes] + #render-bulleted-items(f.notes) + ] + + #if "additionalSections" in f and f.additionalSections != none [ + #for extra in f.additionalSections [ + #heading(level: 3, outlined: false)[#inline-code(extra.heading)] + #render-items(extra.items) + ] + ] +] + +#let render-exploit(f) = [ + == #f.id: #inline-code(f.title) + #sev-chip(f.severity) + + #render-finding-summary(f) + + === Prerequisites + #inline-code(f.prerequisites) + + === Exploitation Steps + #for step in f.exploitationSteps [ + #text(weight: "semibold")[Step #step.number#if "title" in step and step.title != none [ — #inline-code(step.title)]] + + #render-items(step.items) + ] + + === Proof of Impact + #render-numbered-items(f.proofOfImpact) + + #render-finding-extras(f) + + #v(16pt) +] + +#let render-analysis(f) = [ + == #f.id: #inline-code(f.title) + #sev-chip(f.severity) #h(4pt) #confidence-chip(f.confidence) + + #render-finding-summary(f) + + #render-finding-extras(f) + + #v(16pt) +] + +#let render-finding(f) = if mode == "exploits" { render-exploit(f) } else { render-analysis(f) } + +// ---------- PER-CATEGORY ----------------------------------------------------- +#let category-section-label(n) = if mode == "exploits" { + "Exploitation Evidence" +} else { + "Findings" +} + +#for cat in categories-in-order { + let cat-findings = data.findings.filter(f => f.category == cat) + if cat-findings.len() > 0 [ + = #cat #category-section-label(cat-findings.len()) (#cat-findings.len() #if cat-findings.len() == 1 [finding] else [findings]) + #for f in cat-findings { + render-finding(f) + } + ] +} diff --git a/docs/configuration.md b/docs/configuration.md index d237640..b9d4de2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,7 +115,7 @@ A finding carries one rating or the other, never both: an exploited finding is r ### SARIF Output -Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.md` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer. +Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.pdf` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer. ```yaml exploit: "true" diff --git a/docs/development.md b/docs/development.md index a5e17c3..a4d8e38 100644 --- a/docs/development.md +++ b/docs/development.md @@ -136,7 +136,7 @@ Output structure — the run directory's top level holds only the final report; ```text workspaces/{hostname}_{sessionId}/ -|-- Security-Assessment-Report.md # the final report (the deliverable) +|-- Security-Assessment-Report.pdf # the final report (the deliverable) `-- .shannon/ # internals |-- deliverables/ # report source, per-phase analysis, queues |-- agents/ # per-agent logs diff --git a/docs/workspaces.md b/docs/workspaces.md index a350c1a..10625cf 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -11,7 +11,7 @@ Shannon uses workspaces to store scan state, logs, prompts, and deliverables. Wo - Use `-w ` to give a run a custom name. - To resume a run, pass the same workspace name with `-w`. - Each agent's progress is checkpointed so resumed runs can skip completed work. -- The final report is surfaced at the workspace root as `Security-Assessment-Report.md`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory. +- The final report is surfaced at the workspace root as `Security-Assessment-Report.pdf`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory. > [!NOTE] > The URL must match the original workspace URL when resuming. Shannon rejects mismatched URLs to prevent cross-target contamination. diff --git a/llms-full.txt b/llms-full.txt index 7185b68..2f88070 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -401,7 +401,7 @@ Output structure — the run directory's top level holds only the final report; ```text workspaces/{hostname}_{sessionId}/ -|-- Security-Assessment-Report.md # the final report (the deliverable) +|-- Security-Assessment-Report.pdf # the final report (the deliverable) `-- .shannon/ # internals |-- deliverables/ # report source, per-phase analysis, queues |-- agents/ # per-agent logs @@ -532,7 +532,7 @@ A finding carries one rating or the other, never both: an exploited finding is r ### SARIF Output -Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.md` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer. +Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.pdf` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer. ```yaml exploit: "true" @@ -861,7 +861,7 @@ Shannon uses workspaces to store scan state, logs, prompts, and deliverables. Wo - Use `-w ` to give a run a custom name. - To resume a run, pass the same workspace name with `-w`. - Each agent's progress is checkpointed so resumed runs can skip completed work. -- The final report is surfaced at the workspace root as `Security-Assessment-Report.md`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory. +- The final report is surfaced at the workspace root as `Security-Assessment-Report.pdf`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory. > [!NOTE] > The URL must match the original workspace URL when resuming. Shannon rejects mismatched URLs to prevent cross-target contamination.