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 0000000..b0bd978 Binary files /dev/null and b/apps/worker/templates/typst/assets/keygraph-logo.png differ 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.