mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-04 23:36:54 +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
252 lines
7.9 KiB
TypeScript
252 lines
7.9 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.
|
|
|
|
/**
|
|
* Deterministic queue-JSON to findings-MD renderer.
|
|
*
|
|
* Used when exploit=false: the exploit agents didn't run, so there is no
|
|
* `*_exploitation_evidence.md` to concatenate into the report. This module
|
|
* reads each `*_exploitation_queue.json` (already validated by the submit tool against the
|
|
* schemas in ../ai/queue-schemas.ts) and writes a `*_findings.md` per class
|
|
* in the canonical body shape that report-executive.txt's cleanup expects.
|
|
*
|
|
* No LLM in the loop — every field maps directly from a JSON key.
|
|
*/
|
|
|
|
import { fs, path } from 'zx';
|
|
import type { AuthFinding, AuthzFinding, InjectionFinding, SsrfFinding, XssFinding } from '../ai/queue-schemas.js';
|
|
import { deliverablesDir } from '../paths.js';
|
|
import type { ActivityLogger } from '../types/activity-logger.js';
|
|
import type { VulnClass } from '../types/config.js';
|
|
|
|
const DISCLAIMER = [
|
|
'> Exploitation phase was not run for this assessment. Each entry documents a',
|
|
'> vulnerability identified through static analysis; live exploitation steps and',
|
|
'> proof of impact are not included.',
|
|
].join('\n');
|
|
|
|
interface ClassConfig<T> {
|
|
readonly heading: string;
|
|
readonly noneFoundLabel: string;
|
|
readonly queueFile: string;
|
|
readonly findingsFile: string;
|
|
readonly renderEntry: (entry: T) => string;
|
|
}
|
|
|
|
interface QueueDocument<T> {
|
|
vulnerabilities?: T[];
|
|
}
|
|
|
|
// === Common Render Helpers ===
|
|
|
|
function summaryRow(label: string, value: string | undefined | null | boolean): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value === 'string' && value.trim() === '') return null;
|
|
return `- **${label}:** ${value}`;
|
|
}
|
|
|
|
function formatLocation(endpoint: string | undefined, codeLocation: string | undefined): string {
|
|
if (endpoint && codeLocation) return `${endpoint} (${codeLocation})`;
|
|
return endpoint ?? codeLocation ?? '';
|
|
}
|
|
|
|
/** The analysis queue carries no severity, so confidence is the only rating. */
|
|
interface CommonEntryFields {
|
|
readonly confidence: string;
|
|
}
|
|
|
|
function buildEntry(
|
|
id: string,
|
|
title: string,
|
|
common: CommonEntryFields,
|
|
summaryRows: ReadonlyArray<string | null>,
|
|
notes: string | undefined,
|
|
): string {
|
|
const lines: string[] = [];
|
|
lines.push(`### ${id}: ${title}`);
|
|
lines.push('');
|
|
lines.push('**Summary:**');
|
|
lines.push(`- **Confidence:** ${common.confidence}`);
|
|
for (const row of summaryRows) {
|
|
if (row !== null) lines.push(row);
|
|
}
|
|
lines.push('');
|
|
if (notes && notes.trim() !== '') {
|
|
lines.push(`**Notes:** ${notes.trim()}`);
|
|
}
|
|
return lines.join('\n').trimEnd();
|
|
}
|
|
|
|
// === Per-Class Renderers ===
|
|
|
|
function renderAuthEntry(e: AuthFinding): string {
|
|
return buildEntry(
|
|
e.ID,
|
|
e.vulnerability_type,
|
|
{ confidence: e.confidence },
|
|
[
|
|
summaryRow('Vulnerable location', formatLocation(e.source_endpoint, e.vulnerable_code_location)),
|
|
summaryRow('Overview', e.missing_defense),
|
|
summaryRow('Impact', e.exploitation_hypothesis),
|
|
],
|
|
e.notes,
|
|
);
|
|
}
|
|
|
|
function renderSsrfEntry(e: SsrfFinding): string {
|
|
return buildEntry(
|
|
e.ID,
|
|
e.vulnerability_type,
|
|
{ confidence: e.confidence },
|
|
[
|
|
summaryRow('Vulnerable location', formatLocation(e.source_endpoint, e.vulnerable_code_location)),
|
|
summaryRow('Overview', e.missing_defense),
|
|
summaryRow('Impact', e.exploitation_hypothesis),
|
|
],
|
|
e.notes,
|
|
);
|
|
}
|
|
|
|
function renderAuthzEntry(e: AuthzFinding): string {
|
|
return buildEntry(
|
|
e.ID,
|
|
e.vulnerability_type,
|
|
{ confidence: e.confidence },
|
|
[
|
|
summaryRow('Vulnerable location', formatLocation(e.endpoint, e.vulnerable_code_location)),
|
|
summaryRow('Overview', e.guard_evidence),
|
|
summaryRow('Impact', e.side_effect),
|
|
],
|
|
e.notes,
|
|
);
|
|
}
|
|
|
|
function renderInjectionEntry(e: InjectionFinding): string {
|
|
const location = e.path && e.sink_call ? `${e.sink_call} (path: ${e.path})` : (e.sink_call ?? e.path);
|
|
return buildEntry(
|
|
e.ID,
|
|
e.vulnerability_type,
|
|
{ confidence: e.confidence },
|
|
[summaryRow('Vulnerable location', location), summaryRow('Overview', e.mismatch_reason)],
|
|
e.notes,
|
|
);
|
|
}
|
|
|
|
function renderXssEntry(e: XssFinding): string {
|
|
const location = e.path && e.sink_function ? `${e.sink_function} (path: ${e.path})` : (e.sink_function ?? e.path);
|
|
return buildEntry(
|
|
e.ID,
|
|
e.vulnerability_type,
|
|
{ confidence: e.confidence },
|
|
[summaryRow('Vulnerable location', location), summaryRow('Overview', e.mismatch_reason)],
|
|
e.notes,
|
|
);
|
|
}
|
|
|
|
// === Class Registry ===
|
|
|
|
const CLASSES: Record<VulnClass, ClassConfig<unknown>> = {
|
|
auth: {
|
|
heading: 'Authentication',
|
|
noneFoundLabel: 'authentication',
|
|
queueFile: 'auth_exploitation_queue.json',
|
|
findingsFile: 'auth_findings.md',
|
|
renderEntry: (e) => renderAuthEntry(e as AuthFinding),
|
|
},
|
|
authz: {
|
|
heading: 'Authorization',
|
|
noneFoundLabel: 'authorization',
|
|
queueFile: 'authz_exploitation_queue.json',
|
|
findingsFile: 'authz_findings.md',
|
|
renderEntry: (e) => renderAuthzEntry(e as AuthzFinding),
|
|
},
|
|
injection: {
|
|
heading: 'Injection',
|
|
noneFoundLabel: 'injection',
|
|
queueFile: 'injection_exploitation_queue.json',
|
|
findingsFile: 'injection_findings.md',
|
|
renderEntry: (e) => renderInjectionEntry(e as InjectionFinding),
|
|
},
|
|
xss: {
|
|
heading: 'XSS',
|
|
noneFoundLabel: 'XSS',
|
|
queueFile: 'xss_exploitation_queue.json',
|
|
findingsFile: 'xss_findings.md',
|
|
renderEntry: (e) => renderXssEntry(e as XssFinding),
|
|
},
|
|
ssrf: {
|
|
heading: 'SSRF',
|
|
noneFoundLabel: 'SSRF',
|
|
queueFile: 'ssrf_exploitation_queue.json',
|
|
findingsFile: 'ssrf_findings.md',
|
|
renderEntry: (e) => renderSsrfEntry(e as SsrfFinding),
|
|
},
|
|
};
|
|
|
|
// === Class File Assembly ===
|
|
|
|
function renderClassFile(config: ClassConfig<unknown>, entries: readonly unknown[]): string {
|
|
const sections: string[] = [];
|
|
sections.push(`# ${config.heading} Findings`);
|
|
sections.push('');
|
|
sections.push(DISCLAIMER);
|
|
sections.push('');
|
|
sections.push('## Identified Vulnerabilities');
|
|
sections.push('');
|
|
if (entries.length === 0) {
|
|
sections.push(`No ${config.noneFoundLabel} vulnerabilities were identified.`);
|
|
sections.push('');
|
|
} else {
|
|
for (const entry of entries) {
|
|
sections.push(config.renderEntry(entry));
|
|
sections.push('');
|
|
}
|
|
}
|
|
return `${sections.join('\n').trimEnd()}\n`;
|
|
}
|
|
|
|
// === Public Entry Point ===
|
|
|
|
/**
|
|
* Render `*_findings.md` per class from each `*_exploitation_queue.json`.
|
|
*
|
|
* Idempotent: skips classes whose findings file already exists, or whose queue
|
|
* is missing (class out of scope this run). Per-class failures are logged and
|
|
* other classes still proceed.
|
|
*/
|
|
export async function renderFindingsFromQueues(
|
|
sourceDir: string,
|
|
deliverablesSubdir: string | undefined,
|
|
logger: ActivityLogger,
|
|
): Promise<void> {
|
|
const dir = deliverablesDir(sourceDir, deliverablesSubdir);
|
|
|
|
for (const config of Object.values(CLASSES)) {
|
|
const queuePath = path.join(dir, config.queueFile);
|
|
const findingsPath = path.join(dir, config.findingsFile);
|
|
|
|
if (await fs.pathExists(findingsPath)) {
|
|
logger.info(`${config.heading}: ${config.findingsFile} already exists, skipping`);
|
|
continue;
|
|
}
|
|
if (!(await fs.pathExists(queuePath))) {
|
|
logger.info(`${config.heading}: no queue file (class out of scope), skipping`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const doc = (await fs.readJson(queuePath)) as QueueDocument<unknown>;
|
|
const entries = doc.vulnerabilities ?? [];
|
|
const markdown = renderClassFile(config, entries);
|
|
await fs.writeFile(findingsPath, markdown);
|
|
logger.info(`${config.heading}: rendered ${entries.length} finding(s) to ${config.findingsFile}`);
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
logger.warn(`${config.heading}: failed to render findings from ${config.queueFile}: ${err.message}`);
|
|
}
|
|
}
|
|
}
|