Files
shannon/apps/worker/src/services/reporting.ts
T
ezl-keygraph d41ae9c20d feat(cli): overhaul commands and add live scan status (#424)
* refactor(cli): list workspaces natively instead of via the worker image

* feat(cli): preflight that Docker is installed and running

* feat(cli): stop scans by workspace or --all, terminating their Temporal workflows

* fix(worker): abort the running agent on cancellation so Temporal cancel takes effect

* refactor(cli): split destructive teardown out of stop into a reset command

* refactor(cli): centralise flag parsing and confirmation across commands

* fix(cli): pass provider credentials to docker by name to keep secrets out of argv

* feat(cli): add per-command help via <command> --help/-h and help <command>

* feat(cli): replace raw docker output with clack spinners for infra and scan teardown

* fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success

* fix(cli): resolve running state before prompting on stop and report no-op stops honestly

* refactor(cli): show splash first and drive start with one spinner resolving to a clean line

* fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash

* refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker

* feat(cli): add --json/--plain machine-readable output to workspaces and status

* refactor(cli): remove the workspaces command

* refactor(cli): remove the status command

* feat(cli): add 'progress <workspace>' — live scan progress from Temporal

* fix(cli): mark metric-less agents as skipped in progress, not done

* feat(cli): animate running agents in progress with a clack-style spinner

* feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed

* fix(cli): mark passed-over phases as skipped live, not pending

* style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical

* style(cli): drop '(sum of agents)' from status total cost line

* style(cli): green filled circle for completed, Shannon gold for running

* style(cli): use Shannon gold in place of green in status

* feat(cli): suggest closest command or flag on typo

* refactor(cli): single-source start help and drop ./repos bare-name shortcut

* feat(cli): name providers and fix in multi-provider credential error

* feat(cli): support --flag=value syntax and expand leading ~ in paths

* refactor(cli): centralize ANSI color codes in colors.ts

* feat(cli): add scans command listing completed scans with cost and duration

* fix(cli): keep stdout clean off-TTY for logs and start

* feat(cli): add repo link to top-level help

* feat(worker): record auth-validation metrics and register resume attempts early

* refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures

* feat(cli): add status --json, auth phase, dashboard link, and stable live redraw

* refactor(cli): drop cost from status and scans output

* feat(worker): surface both PDF and markdown report at run root

* refactor(cli): normalize error/warning prefixing through fail and warn

* feat(cli): add version --json for machine-readable output

* refactor(cli): rename start --debug to --keep-container

* refactor(cli): point start's progress hint at status instead of the Temporal dashboard

* refactor(cli): centralize the mode-aware command prefix

* refactor(cli): trim start and logs output to durable facts off-TTY

* feat(cli): require typed confirmation for reset instead of --yes

reset permanently wipes all Temporal data and volumes — a severe,
irreversible action. Replace its default y/N confirm (bypassable with
--yes) with a typed-word confirmation that has no bypass, so the wipe
can only be triggered by a deliberate interactive answer.

* feat(cli): surface logs and status hints after start on a TTY

* feat(cli): exit 2 on usage errors, distinct from operational failures

* feat(cli): add start --follow to stream logs and exit on scan outcome

* refactor(cli): redesign splash with sunset-gradient wordmark and truecolor

* refactor(cli): remove the uninstall command

* docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow

* docs: fix reset confirmation — typed confirm, not --yes/-y

* style(cli): restructure status footer with divider, aligned Logs/Temporal rows

* feat(cli): show splash in the status command

* fix(worker): validate auth-state shape, not entry count

* docs: correct reset confirmation and add markdown report to run-root docs
2026-08-18 15:46:25 +05:30

219 lines
7.8 KiB
TypeScript

// 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.
import { fs, path } from 'zx';
import {
ASSEMBLED_REPORT_FILENAME,
ASSEMBLED_REPORT_PDF_FILENAME,
deliverablesDir,
FINAL_REPORT_MD_FILENAME,
FINAL_REPORT_PDF_FILENAME,
resolveSessionJsonPath,
SARIF_FILENAME,
} from '../paths.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import { ErrorCode } from '../types/errors.js';
import { PentestError } from './error-handling.js';
interface DeliverableFile {
name: string;
/** Candidate filenames in priority order. First one that exists wins. */
paths: readonly string[];
required: boolean;
}
// Pure function: Assemble final report from specialist deliverables.
// Per class, prefer the exploit-agent's evidence file; fall back to renderer-produced findings.
// Both never coexist for a workspace because scope (exploit flag) is locked.
export async function assembleFinalReport(
sourceDir: string,
deliverablesSubdir: string | undefined,
logger: ActivityLogger,
): Promise<string> {
const deliverableFiles: readonly DeliverableFile[] = [
{ name: 'Injection', paths: ['injection_exploitation_evidence.md', 'injection_findings.md'], required: false },
{ name: 'XSS', paths: ['xss_exploitation_evidence.md', 'xss_findings.md'], required: false },
{ name: 'Authentication', paths: ['auth_exploitation_evidence.md', 'auth_findings.md'], required: false },
{ name: 'SSRF', paths: ['ssrf_exploitation_evidence.md', 'ssrf_findings.md'], required: false },
{ name: 'Authorization', paths: ['authz_exploitation_evidence.md', 'authz_findings.md'], required: false },
];
const dir = deliverablesDir(sourceDir, deliverablesSubdir);
const sections: string[] = [];
for (const file of deliverableFiles) {
let added = false;
for (const candidate of file.paths) {
const filePath = path.join(dir, candidate);
try {
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
sections.push(content);
logger.info(`Added ${file.name} section from ${candidate}`);
added = true;
break;
}
} catch (error) {
const err = error as Error;
logger.warn(`Could not read ${candidate}: ${err.message}`);
}
}
if (!added) {
if (file.required) {
throw new PentestError(
`Required deliverable file not found: ${file.paths.join(' or ')}`,
'filesystem',
false,
{ deliverableFile: file.paths, sourceDir },
ErrorCode.DELIVERABLE_NOT_FOUND,
);
}
logger.info(`No ${file.name} deliverable found`);
}
}
const finalContent = sections.join('\n\n');
const finalReportPath = path.join(dir, ASSEMBLED_REPORT_FILENAME);
try {
await fs.ensureDir(dir);
await fs.writeFile(finalReportPath, finalContent);
logger.info(`Final report assembled at ${finalReportPath}`);
} catch (error) {
const err = error as Error;
throw new PentestError(`Failed to write final report: ${err.message}`, 'filesystem', false, {
finalReportPath,
originalError: err.message,
});
}
return finalContent;
}
/**
* Inject model information into the final security report.
* Reads session.json to get the model(s) used, then injects a "Model:" line
* into the Executive Summary section of the report.
*/
export async function injectModelIntoReport(
repoPath: string,
deliverablesSubdir: string | undefined,
outputPath: string,
logger: ActivityLogger,
): Promise<void> {
// 1. Read session.json to get model information
const sessionJsonPath = resolveSessionJsonPath(outputPath);
if (!(await fs.pathExists(sessionJsonPath))) {
logger.warn('session.json not found, skipping model injection');
return;
}
interface SessionData {
metrics: {
agents: Record<string, { model?: string }>;
};
}
const sessionData: SessionData = await fs.readJson(sessionJsonPath);
// 2. Extract unique models from all agents
const models = new Set<string>();
for (const agent of Object.values(sessionData.metrics.agents)) {
if (agent.model) {
models.add(agent.model);
}
}
if (models.size === 0) {
logger.warn('No model information found in session.json');
return;
}
const modelStr = Array.from(models).join(', ');
logger.info(`Injecting model info into report: ${modelStr}`);
// 3. Read the final report
const reportPath = path.join(deliverablesDir(repoPath, deliverablesSubdir), ASSEMBLED_REPORT_FILENAME);
if (!(await fs.pathExists(reportPath))) {
logger.warn('Final report not found, skipping model injection');
return;
}
let reportContent = await fs.readFile(reportPath, 'utf8');
// 4. Find and inject model line after "Assessment Date" in Executive Summary
// Pattern: "- Assessment Date: <date>" followed by a newline
const assessmentDatePattern = /^(- Assessment Date: .+)$/m;
const match = reportContent.match(assessmentDatePattern);
if (match) {
// Inject model line after Assessment Date
const modelLine = `- Model: ${modelStr}`;
reportContent = reportContent.replace(assessmentDatePattern, `$1\n${modelLine}`);
logger.info('Model info injected into Executive Summary');
} else {
// If no Assessment Date line found, try to add after Executive Summary header
const execSummaryPattern = /^## Executive Summary$/m;
if (reportContent.match(execSummaryPattern)) {
// Add model as first item in Executive Summary
reportContent = reportContent.replace(execSummaryPattern, `## Executive Summary\n- Model: ${modelStr}`);
logger.info('Model info added to Executive Summary header');
} else {
logger.warn('Could not find Executive Summary section');
return;
}
}
// 5. Write modified report back
await fs.writeFile(reportPath, reportContent);
}
/**
* 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). Both the PDF and the markdown report are surfaced here as the
* customer-facing copies.
*
* 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
* run was analysis-only or `report.sarif` was not enabled.
*/
export async function copyReportToRunRoot(
repoPath: string,
deliverablesSubdir: string | undefined,
runDir: string,
logger: ActivityLogger,
): Promise<void> {
const dir = deliverablesDir(repoPath, deliverablesSubdir);
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(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`);
}
const markdownSource = path.join(dir, ASSEMBLED_REPORT_FILENAME);
if (await fs.pathExists(markdownSource)) {
const destination = path.join(runDir, FINAL_REPORT_MD_FILENAME);
await fs.copy(markdownSource, destination, { overwrite: true });
logger.info(`Surfaced markdown report at ${destination}`);
} else {
logger.warn(`Markdown report not found, skipping ${FINAL_REPORT_MD_FILENAME}`);
}
const sarifSource = path.join(dir, SARIF_FILENAME);
if (await fs.pathExists(sarifSource)) {
const sarifDestination = path.join(runDir, SARIF_FILENAME);
await fs.copy(sarifSource, sarifDestination, { overwrite: true });
logger.info(`Surfaced SARIF log at ${sarifDestination}`);
}
}