mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-15 16:00:29 +02:00
* feat(worker): record severity in analysis mode alongside confidence * fix(worker): align add_finding severity with the exploit collector's four levels * refactor(worker): drop the dead REPORT_VULN_HEADING substitution No prompt in the tree uses the placeholder, so the replacement was a no-op on every render. * fix(worker): strip all whitespace from TOTP secrets, not just the ends * fix(worker): render rule type and value in the agent prompt * refactor(worker): drop the dead vuln-summary subsection substitution
504 lines
20 KiB
TypeScript
504 lines
20 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 { PROMPTS_DIR } from '../paths.js';
|
|
import { PLAYWRIGHT_SESSION_MAPPING } from '../session-manager.js';
|
|
import type { ActivityLogger } from '../types/activity-logger.js';
|
|
import type { Authentication, DistributedConfig, DistributedReportConfig, Rule, VulnClass } from '../types/config.js';
|
|
import { isGlobPattern } from '../utils/glob.js';
|
|
import { handlePromptError, PentestError } from './error-handling.js';
|
|
|
|
function renderRuleLine(tag: string, value: string, description?: string): string {
|
|
const base = `- ${tag} ${value}`;
|
|
return description ? `${base} - ${description}` : base;
|
|
}
|
|
|
|
function renderUrlRules(rules: Rule[]): string {
|
|
if (rules.length === 0) return 'None';
|
|
return rules.map((r) => renderRuleLine(`[${r.type.toUpperCase()}]`, r.value, r.description)).join('\n');
|
|
}
|
|
|
|
function renderCodePathRules(rules: Rule[]): string {
|
|
const filtered = rules.filter((r) => r.type === 'code_path');
|
|
if (filtered.length === 0) return 'None';
|
|
return filtered
|
|
.map((r) => renderRuleLine(isGlobPattern(r.value) ? '[GLOB]' : '[FILE]', r.value, r.description))
|
|
.join('\n');
|
|
}
|
|
|
|
const VULN_CLASS_HEADINGS: Record<VulnClass, string> = {
|
|
auth: 'Authentication Vulnerabilities',
|
|
authz: 'Authorization Vulnerabilities',
|
|
xss: 'Cross-Site Scripting (XSS) Vulnerabilities',
|
|
injection: 'SQL/Command Injection Vulnerabilities',
|
|
ssrf: 'Server-Side Request Forgery (SSRF) Vulnerabilities',
|
|
};
|
|
|
|
/**
|
|
* Renders the <not_assessed_classes> block. Empty when every class completed.
|
|
*
|
|
* A class whose analysis failed was never assessed, so the report must not present its
|
|
* absence of findings as a clean result. The block is authoritative for that caveat.
|
|
*/
|
|
function renderNotAssessedClassesBlock(failed: readonly VulnClass[] = []): string {
|
|
if (failed.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const classes = [...new Set(failed)];
|
|
const lines: string[] = [
|
|
'<not_assessed_classes>',
|
|
'The following vulnerability classes did not complete and were NOT assessed in this run. Treat this list as authoritative for completeness caveats.',
|
|
'',
|
|
];
|
|
|
|
for (const cls of classes) {
|
|
lines.push(
|
|
`- ${VULN_CLASS_HEADINGS[cls]}: analysis did not complete; this class was NOT assessed. Absence of findings here does not indicate the class is clean.`,
|
|
);
|
|
}
|
|
|
|
lines.push(
|
|
'',
|
|
'When writing report_meta.executive_summary, scope any no-findings statement to the classes that were assessed and mention these not-assessed classes. Do not state or imply that the target is clean for these classes.',
|
|
'</not_assessed_classes>',
|
|
);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Which configured filters this run can actually enforce.
|
|
*
|
|
* Every finding carries `severity` (see ../collectors/finding-collector.ts), so a severity
|
|
* threshold always applies. `confidence` exists only on an analysed finding — handing an
|
|
* exploit run a confidence threshold is a directive it cannot honor.
|
|
*/
|
|
function applicableFilters(report: DistributedReportConfig | undefined, exploitEnabled: boolean) {
|
|
return {
|
|
severity: Boolean(report?.min_severity),
|
|
confidence: Boolean(report?.min_confidence) && !exploitEnabled,
|
|
guidance: Boolean(report?.guidance?.trim()),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Renders the top-level <report_filters> block. Empty when no filters are set —
|
|
* each filter is included only when the operator configured it, so the agent
|
|
* never sees `none` placeholders or instructions for filters that don't apply.
|
|
*/
|
|
function renderReportFiltersBlock(report: DistributedReportConfig | undefined, exploitEnabled: boolean): string {
|
|
if (!report) return '';
|
|
const guidance = report.guidance?.trim();
|
|
const applies = applicableFilters(report, exploitEnabled);
|
|
if (!applies.severity && !applies.confidence && !applies.guidance) return '';
|
|
|
|
const lines: string[] = [
|
|
'<report_filters>',
|
|
'The filters below are user-supplied and binding for this assessment. Honor each strictly when assembling the final report.',
|
|
'',
|
|
];
|
|
if (applies.severity) {
|
|
lines.push(
|
|
`- Minimum severity: ${report.min_severity} — keep only findings rated this severity or higher (scale: low < medium < high < critical).`,
|
|
);
|
|
}
|
|
if (applies.confidence) {
|
|
lines.push(
|
|
`- Minimum confidence: ${report.min_confidence} — keep only findings rated this confidence or higher (scale: low < medium < high).`,
|
|
);
|
|
}
|
|
if (guidance) {
|
|
lines.push('');
|
|
lines.push('User guidance — apply throughout the report as binding directives for finding selection:');
|
|
lines.push(guidance);
|
|
}
|
|
lines.push('</report_filters>');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Renders the per-finding DROP rules used inside the cleanup step. Severity and
|
|
* confidence inline as concrete thresholds; guidance is referenced by pointer
|
|
* so the actual text only lives in <report_filters>, avoiding double-statement.
|
|
*/
|
|
function renderReportFilterRules(report: DistributedReportConfig | undefined, exploitEnabled: boolean): string {
|
|
const applies = applicableFilters(report, exploitEnabled);
|
|
const drops: string[] = [];
|
|
if (applies.severity) drops.push(`* severity is below ${report?.min_severity}`);
|
|
if (applies.confidence) drops.push(`* confidence is below ${report?.min_confidence}`);
|
|
if (report?.guidance?.trim()) drops.push('* topic matches an exclusion in the user guidance');
|
|
if (drops.length === 0) return '';
|
|
return [' - DROP any `### [TYPE]-VULN-[NUMBER]` finding whose:', ...drops.map((d) => ` ${d}`)].join('\n');
|
|
}
|
|
|
|
interface PromptVariables {
|
|
webUrl: string;
|
|
repoPath: string;
|
|
/** Classes whose analysis did not complete, so the report can mark them not assessed. */
|
|
failedClasses?: readonly VulnClass[];
|
|
AUTH_STATE_FILE: string;
|
|
PLAYWRIGHT_SESSION?: string;
|
|
}
|
|
|
|
interface IncludeReplacement {
|
|
placeholder: string;
|
|
content: string;
|
|
}
|
|
|
|
// Pure function: Build complete login instructions from config
|
|
async function buildLoginInstructions(
|
|
authentication: Authentication,
|
|
logger: ActivityLogger,
|
|
promptsBaseDir: string = PROMPTS_DIR,
|
|
): Promise<string> {
|
|
try {
|
|
// 1. Load the login instructions template
|
|
const loginInstructionsPath = path.join(promptsBaseDir, 'shared', 'login-instructions.txt');
|
|
|
|
if (!(await fs.pathExists(loginInstructionsPath))) {
|
|
throw new PentestError('Login instructions template not found', 'filesystem', false, { loginInstructionsPath });
|
|
}
|
|
|
|
const fullTemplate = await fs.readFile(loginInstructionsPath, 'utf8');
|
|
|
|
const getSection = (content: string, sectionName: string): string => {
|
|
const regex = new RegExp(`<!-- BEGIN:${sectionName} -->([\\s\\S]*?)<!-- END:${sectionName} -->`, 'g');
|
|
const match = regex.exec(content);
|
|
return match?.[1]?.trim() ?? '';
|
|
};
|
|
|
|
// 2. Extract sections based on login type
|
|
const loginType = authentication.login_type?.toUpperCase();
|
|
let loginInstructions = '';
|
|
|
|
const commonSection = getSection(fullTemplate, 'COMMON');
|
|
const authSection = loginType ? getSection(fullTemplate, loginType) : ''; // FORM or SSO
|
|
const verificationSection = getSection(fullTemplate, 'VERIFICATION');
|
|
|
|
// 3. Assemble instructions from sections (fallback to full template if markers missing)
|
|
if (!commonSection && !authSection && !verificationSection) {
|
|
logger.warn('Section markers not found, using full login instructions template');
|
|
loginInstructions = fullTemplate;
|
|
} else {
|
|
loginInstructions = [commonSection, authSection, verificationSection].filter((section) => section).join('\n\n');
|
|
}
|
|
|
|
// 4. Interpolate login flow and credential placeholders
|
|
let userInstructions = (authentication.login_flow ?? []).join('\n');
|
|
|
|
if (authentication.credentials) {
|
|
if (authentication.credentials.username) {
|
|
userInstructions = replaceLiteral(userInstructions, /\$username/g, authentication.credentials.username);
|
|
}
|
|
if (authentication.credentials.password) {
|
|
userInstructions = replaceLiteral(userInstructions, /\$password/g, authentication.credentials.password);
|
|
}
|
|
if (authentication.credentials.totp_secret) {
|
|
userInstructions = replaceLiteral(
|
|
userInstructions,
|
|
/\$totp/g,
|
|
`generated TOTP code using secret "${authentication.credentials.totp_secret}"`,
|
|
);
|
|
}
|
|
if (authentication.credentials.email_login?.address) {
|
|
userInstructions = replaceLiteral(
|
|
userInstructions,
|
|
/\$email_address/g,
|
|
authentication.credentials.email_login.address,
|
|
);
|
|
}
|
|
if (authentication.credentials.email_login?.password) {
|
|
userInstructions = replaceLiteral(
|
|
userInstructions,
|
|
/\$email_password/g,
|
|
authentication.credentials.email_login.password,
|
|
);
|
|
}
|
|
if (authentication.credentials.email_login?.totp_secret) {
|
|
userInstructions = replaceLiteral(
|
|
userInstructions,
|
|
/\$email_totp/g,
|
|
`generated TOTP code using secret "${authentication.credentials.email_login.totp_secret}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
loginInstructions = replaceLiteral(loginInstructions, /{{user_instructions}}/g, userInstructions);
|
|
|
|
// 5. Replace TOTP secret placeholder if present in template
|
|
if (authentication.credentials?.totp_secret) {
|
|
loginInstructions = replaceLiteral(loginInstructions, /{{totp_secret}}/g, authentication.credentials.totp_secret);
|
|
}
|
|
|
|
return loginInstructions;
|
|
} catch (error) {
|
|
if (error instanceof PentestError) {
|
|
throw error;
|
|
}
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
throw new PentestError(`Failed to build login instructions: ${errMsg}`, 'config', false, {
|
|
authentication,
|
|
originalError: errMsg,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Pure function: Process @include() directives
|
|
async function processIncludes(content: string, baseDir: string): Promise<string> {
|
|
const includeRegex = /@include\(([^)]+)\)/g;
|
|
const resolvedBase = path.resolve(baseDir);
|
|
|
|
const replacements: IncludeReplacement[] = await Promise.all(
|
|
Array.from(content.matchAll(includeRegex)).map(async (match) => {
|
|
const rawPath = match[1] ?? '';
|
|
const includePath = path.resolve(baseDir, rawPath);
|
|
if (!includePath.startsWith(resolvedBase + path.sep) && includePath !== resolvedBase) {
|
|
throw new PentestError(`Path traversal detected in @include(): ${rawPath}`, 'prompt', false, {
|
|
includePath,
|
|
baseDir: resolvedBase,
|
|
});
|
|
}
|
|
const sharedContent = await fs.readFile(includePath, 'utf8');
|
|
return {
|
|
placeholder: match[0],
|
|
content: sharedContent,
|
|
};
|
|
}),
|
|
);
|
|
|
|
for (const replacement of replacements) {
|
|
content = replaceLiteral(content, replacement.placeholder, replacement.content);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Replaces `pattern` with `replacement` treating the replacement as a literal
|
|
* string. Native `String.replace` interprets `$&`, `$1`, `$$` in the replacement
|
|
* as special patterns, which mangles credential and config values that legitimately
|
|
* contain `$`. The function form of `replace` bypasses that interpretation.
|
|
*/
|
|
function replaceLiteral(input: string, pattern: RegExp | string, replacement: string): string {
|
|
return input.replace(pattern, () => replacement);
|
|
}
|
|
|
|
function buildAuthContext(config: DistributedConfig | null): string {
|
|
if (!config?.authentication) {
|
|
return 'No authentication configured - unauthenticated testing only';
|
|
}
|
|
|
|
const auth = config.authentication;
|
|
const lines = [
|
|
`- Login type: ${auth.login_type.toUpperCase()}`,
|
|
`- Username: ${auth.credentials.username}`,
|
|
`- Login URL: ${auth.login_url}`,
|
|
];
|
|
|
|
if (auth.credentials?.totp_secret) {
|
|
lines.push('- MFA: TOTP enabled');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Pure function: Variable interpolation
|
|
async function interpolateVariables(
|
|
template: string,
|
|
variables: PromptVariables,
|
|
config: DistributedConfig | null = null,
|
|
logger: ActivityLogger,
|
|
promptsBaseDir: string = PROMPTS_DIR,
|
|
): Promise<string> {
|
|
try {
|
|
if (!template || typeof template !== 'string') {
|
|
throw new PentestError('Template must be a non-empty string', 'validation', false, {
|
|
templateType: typeof template,
|
|
templateLength: template?.length,
|
|
});
|
|
}
|
|
|
|
if (!variables || !variables.webUrl || !variables.repoPath) {
|
|
throw new PentestError('Variables must include webUrl and repoPath', 'validation', false, {
|
|
variables: Object.keys(variables || {}),
|
|
});
|
|
}
|
|
|
|
// replaceLiteral is used for all value insertions so config values that
|
|
// contain `$&`/`$$`/`$1`/etc. aren't mangled as replacement patterns.
|
|
let result = template;
|
|
result = replaceLiteral(result, /{{WEB_URL}}/g, variables.webUrl);
|
|
result = replaceLiteral(result, /{{REPO_PATH}}/g, variables.repoPath);
|
|
result = replaceLiteral(result, /{{PLAYWRIGHT_SESSION}}/g, variables.PLAYWRIGHT_SESSION || 'agent1');
|
|
result = replaceLiteral(result, /{{AUTH_CONTEXT}}/g, buildAuthContext(config));
|
|
result = replaceLiteral(
|
|
result,
|
|
/{{DESCRIPTION}}/g,
|
|
config?.description ? `Description: ${config.description}` : '',
|
|
);
|
|
|
|
const avoidUrlRules = config?.avoid?.filter((r) => r.type !== 'code_path') ?? [];
|
|
const focusUrlRules = config?.focus?.filter((r) => r.type !== 'code_path') ?? [];
|
|
if (avoidUrlRules.length === 0 && focusUrlRules.length === 0) {
|
|
result = result.replace(/<rules>[\s\S]*?<\/rules>\s*/g, '');
|
|
} else {
|
|
const avoidStr = renderUrlRules(avoidUrlRules);
|
|
const focusStr = renderUrlRules(focusUrlRules);
|
|
result = replaceLiteral(result, /{{RULES_AVOID}}/g, avoidStr);
|
|
result = replaceLiteral(result, /{{RULES_FOCUS}}/g, focusStr);
|
|
}
|
|
|
|
const avoidCodeRules = (config?.avoid ?? []).filter((r) => r.type === 'code_path');
|
|
const focusCodeRules = (config?.focus ?? []).filter((r) => r.type === 'code_path');
|
|
if (avoidCodeRules.length === 0 && focusCodeRules.length === 0) {
|
|
result = result.replace(/<code_path_rules>[\s\S]*?<\/code_path_rules>\s*/g, '');
|
|
} else {
|
|
result = replaceLiteral(result, /{{CODE_RULES_AVOID}}/g, renderCodePathRules(config?.avoid ?? []));
|
|
result = replaceLiteral(result, /{{CODE_RULES_FOCUS}}/g, renderCodePathRules(config?.focus ?? []));
|
|
}
|
|
|
|
const roe = config?.rules_of_engagement?.trim() ?? '';
|
|
if (roe) {
|
|
result = replaceLiteral(result, /{{RULES_OF_ENGAGEMENT}}/g, roe);
|
|
} else {
|
|
result = result.replace(/<rules_of_engagement>[\s\S]*?<\/rules_of_engagement>\s*/g, '');
|
|
}
|
|
|
|
if (!config?.authentication) {
|
|
result = result.replace(/<shared_authenticated_session>[\s\S]*?<\/shared_authenticated_session>\s*/g, '');
|
|
} else {
|
|
result = replaceLiteral(result, /{{AUTH_STATE_FILE}}/g, variables.AUTH_STATE_FILE);
|
|
}
|
|
|
|
if (config?.authentication?.login_flow) {
|
|
const loginInstructions = await buildLoginInstructions(config.authentication, logger, promptsBaseDir);
|
|
result = replaceLiteral(result, /{{LOGIN_INSTRUCTIONS}}/g, loginInstructions);
|
|
} else {
|
|
result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, '');
|
|
}
|
|
|
|
const vulnClasses = config?.vuln_classes ?? [];
|
|
result = replaceLiteral(
|
|
result,
|
|
/{{VULN_CLASSES_TESTED}}/g,
|
|
vulnClasses.length > 0 ? vulnClasses.join(', ') : 'injection, xss, auth, authz, ssrf',
|
|
);
|
|
result = replaceLiteral(
|
|
result,
|
|
/{{NOT_ASSESSED_CLASSES}}/g,
|
|
renderNotAssessedClassesBlock(variables.failedClasses ?? []),
|
|
);
|
|
|
|
const exploitEnabled = config?.exploit ?? true;
|
|
|
|
// Drop every block belonging to the mode this run is not in, so the prompt never documents
|
|
// a field the tool would reject. The backreference pins each match to a closed pair.
|
|
const droppedMode = exploitEnabled ? 'analysis' : 'exploit';
|
|
result = result.replace(new RegExp(`<(${droppedMode}_mode_[a-z_]+)>[\\s\\S]*?</\\1>\\n?`, 'g'), '');
|
|
result = result.replace(/<\/?(?:exploit|analysis)_mode_[a-z_]+>\n?/g, '');
|
|
|
|
result = replaceLiteral(result, /{{EXPLOITATION}}/g, exploitEnabled ? 'enabled' : 'disabled');
|
|
result = replaceLiteral(
|
|
result,
|
|
/{{REPORT_VULN_SUBHEADING}}/g,
|
|
exploitEnabled ? 'Successfully Exploited Vulnerabilities' : 'Identified Vulnerabilities',
|
|
);
|
|
|
|
if (config?.report?.min_confidence && exploitEnabled) {
|
|
logger.warn(
|
|
`report.min_confidence="${config.report.min_confidence}" is ignored when exploit=true: an ` +
|
|
'exploited finding is rated by severity, not confidence. Use report.min_severity.',
|
|
);
|
|
}
|
|
result = replaceLiteral(
|
|
result,
|
|
/{{REPORT_FILTERS_BLOCK}}/g,
|
|
renderReportFiltersBlock(config?.report, exploitEnabled),
|
|
);
|
|
result = replaceLiteral(
|
|
result,
|
|
/{{REPORT_FILTER_RULES}}/g,
|
|
renderReportFilterRules(config?.report, exploitEnabled),
|
|
);
|
|
|
|
// Collapse runs of 3+ newlines (left behind by tag-strip and empty-fragment substitutions).
|
|
result = result.replace(/\n{3,}/g, '\n\n');
|
|
|
|
// Validate that all placeholders have been replaced (excluding instructional text)
|
|
const remainingPlaceholders = result.match(/\{\{[^}]+\}\}/g);
|
|
if (remainingPlaceholders) {
|
|
logger.warn(`Found unresolved placeholders in prompt: ${remainingPlaceholders.join(', ')}`);
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
if (error instanceof PentestError) {
|
|
throw error;
|
|
}
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
throw new PentestError(`Variable interpolation failed: ${errMsg}`, 'prompt', false, { originalError: errMsg });
|
|
}
|
|
}
|
|
|
|
// Resolve promptDir override against SHANNON_WORKER_ROOT so relative paths
|
|
// from callers stay cwd-independent.
|
|
function resolvePromptDir(promptDir: string | undefined): string {
|
|
if (!promptDir) return PROMPTS_DIR;
|
|
if (path.isAbsolute(promptDir)) return promptDir;
|
|
return path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), promptDir);
|
|
}
|
|
|
|
// Pure function: Load and interpolate prompt template
|
|
export async function loadPrompt(
|
|
promptName: string,
|
|
variables: PromptVariables,
|
|
config: DistributedConfig | null = null,
|
|
pipelineTestingMode: boolean = false,
|
|
logger: ActivityLogger,
|
|
promptDir?: string,
|
|
): Promise<string> {
|
|
try {
|
|
const basePromptsDir = resolvePromptDir(promptDir);
|
|
const promptsDir = pipelineTestingMode ? path.join(basePromptsDir, 'pipeline-testing') : basePromptsDir;
|
|
const promptPath = path.join(promptsDir, `${promptName}.txt`);
|
|
|
|
if (pipelineTestingMode) {
|
|
logger.info(`Using pipeline testing prompt: ${promptPath}`);
|
|
}
|
|
|
|
if (!(await fs.pathExists(promptPath))) {
|
|
throw new PentestError(`Prompt file not found: ${promptPath}`, 'prompt', false, { promptName, promptPath });
|
|
}
|
|
|
|
// 2. Assign Playwright session based on agent name
|
|
const enhancedVariables: PromptVariables = { ...variables };
|
|
|
|
const session = PLAYWRIGHT_SESSION_MAPPING[promptName as keyof typeof PLAYWRIGHT_SESSION_MAPPING];
|
|
if (session) {
|
|
enhancedVariables.PLAYWRIGHT_SESSION = session;
|
|
logger.info(`Assigned ${promptName} -> ${enhancedVariables.PLAYWRIGHT_SESSION}`);
|
|
} else {
|
|
enhancedVariables.PLAYWRIGHT_SESSION = 'agent1';
|
|
logger.warn(`Unknown agent ${promptName}, using fallback -> ${enhancedVariables.PLAYWRIGHT_SESSION}`);
|
|
}
|
|
|
|
// 3. Read template file
|
|
let template = await fs.readFile(promptPath, 'utf8');
|
|
|
|
// 4. Process @include directives
|
|
template = await processIncludes(template, promptsDir);
|
|
|
|
// 5. Interpolate variables and return final prompt
|
|
return await interpolateVariables(template, enhancedVariables, config, logger, basePromptsDir);
|
|
} catch (error) {
|
|
if (error instanceof PentestError) {
|
|
throw error;
|
|
}
|
|
const promptError = handlePromptError(promptName, error as Error);
|
|
throw promptError.error;
|
|
}
|
|
}
|