// 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 = { 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 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[] = [ '', '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.', '', ); 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 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[] = [ '', '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(''); 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 , 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 { 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(`([\\s\\S]*?)`, '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 { 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 { 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(/[\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(/[\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(/[\s\S]*?<\/rules_of_engagement>\s*/g, ''); } if (!config?.authentication) { result = result.replace(/[\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]*?\\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 { 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; } }