mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-11 22:10:21 +02:00
* feat(worker): record token, cache, and turn usage per agent * feat: replace model tiers with a single SHANNON_AI_MODEL across five providers * feat(cli): rebuild the setup wizard for provider and model selection * docs: document single-model selection and supported providers * feat(worker): use chat completions for OpenAI behind a custom base URL * feat: add SHANNON_AI_OPENAI_FORMAT to pick the wire API for OpenAI gateways * refactor(cli): drop endpoint path hints from the gateway format picker * feat(worker): enable pi in-session provider retry with retry-after backoff * refactor(worker): hand provider error classification to pi and drop the Anthropic ladders * refactor: remove the subscription retry preset and pipeline config section * fix(worker): validate Bedrock credentials with the same live probe as other providers * feat(worker): render the report from structured findings instead of agent-written markdown * fix(worker): dispose the credential probe session on every path * fix(worker): refuse to replace the assembled report with an empty one * refactor(worker): catch post-processing throws across the whole finalization block * revert(worker): drop the report zero-findings guard * docs(worker): correct the retry split and Bedrock credential claims * docs: regenerate llms-full.txt from current sources * feat(cli): build and run the npx flow from a clone * refactor(cli): flatten the setup summary output * feat(cli): reject runs with more than one provider configured * fix(worker): say a rejected bash call never ran * chore(cli): drop grok-4.3 and gpt-5.6-luna from the setup suggestions * feat(worker): capture structured finding locations for SARIF output * fix(worker): enumerate queue confidence so the report inherits it verbatim * feat(worker): give the reporting phase a mode-specific output schema * feat(worker): emit a SARIF 2.1.0 log for exploitative runs * fix(worker): correct SARIF locations and defer fingerprinting to the upload action * fix(worker): drop the confidence suffix from the analysis-mode summary list * feat(worker): give exploit findings a dedicated code location field * feat(worker): carry structured code locations from the vuln queue to the report * fix(worker): join code locations from the vuln queue instead of re-asking agents * fix(worker): spell out the finding_id to category mapping in the tool schema * feat: drop Google/Gemini as a supported AI provider * fix(worker): stop asking the report agent for code locations * docs: correct the provider list and drop the removed rate-limit settings * docs: add provider cyber safeguards and suggested models per provider * docs: document the SARIF output and the report rating thresholds
207 lines
7.3 KiB
TypeScript
207 lines
7.3 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,
|
|
deliverablesDir,
|
|
FINAL_REPORT_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).
|
|
*
|
|
* 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 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}`);
|
|
} else {
|
|
logger.warn(`Final report not found, skipping ${FINAL_REPORT_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}`);
|
|
}
|
|
}
|