Files
shannon/apps/worker/src/services/prompt-manager.ts
T
ezl-keygraphandGitHub 86effd5240 feat(worker): record severity in analysis mode and fix prompt substitutions (#413)
* 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
2026-08-04 21:02:00 +05:30

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;
}
}