feat: surface startup and preflight failures (#454)

* fix: surface pre-workflow worker failures instead of dying silently

* feat: clearer, aggregated config rule validation errors

* fix: preserve blank lines when printing startup errors

* feat: hold start until preflight passes and surface its failure

* fix: cleaner formatting for scan-start failure messages
This commit is contained in:
ezl-keygraph
2026-09-09 02:15:44 +05:30
committed by GitHub
parent d41d52f17d
commit 2786f9aa2d
6 changed files with 311 additions and 138 deletions
+114 -2
View File
@@ -25,12 +25,13 @@ import {
resolveModelsConfig,
resolveRepo,
resolveRunFile,
STARTUP_ERROR_FILENAME,
} from '../paths.js';
import { clearPendingWorkflowIdentity, writePendingWorkflowIdentity } from '../pending-workflow.js';
import { indentFailureSegments } from '../scan/failure.js';
import { indentFailureSegments, parseFailureSegments } from '../scan/failure.js';
import { resolveWorkflowId } from '../session.js';
import { displayPlainBanner, displaySplash } from '../splash.js';
import { getTerminalOutcome } from '../temporal-client.js';
import { describeWorkflowLifecycle, getTerminalOutcome, queryProgress } from '../temporal-client.js';
import { stdoutIsTerminal } from '../tty.js';
import { tailUntilComplete } from './logs.js';
@@ -314,6 +315,10 @@ export async function start(args: StartArgs): Promise<void> {
process.exit(1);
}
// Clear a stale startup-error from a previous launch so the poll reacts only to this worker's.
const startupErrorPath = path.join(internalPath, STARTUP_ERROR_FILENAME);
fs.rmSync(startupErrorPath, { force: true });
// 9. Spawn the worker container.
const proc = spawnWorker({
version: args.version,
@@ -383,6 +388,16 @@ export async function start(args: StartArgs): Promise<void> {
// Poll for the workflow to register in session.json; the spinner resolves once it does.
spinner.message('Waiting for the scan to start');
for (let attempts = 0; attempts < 60; attempts++) {
// A pre-workflow failure leaves its reason here (nothing reached Temporal); surface it
// rather than polling out to a generic timeout.
const startupError = readStartupError(startupErrorPath);
if (startupError) {
cleaned = true; // The worker already exited; nothing to stop.
spinner.error('The scan could not start');
printStartupError(startupError);
process.exit(1);
}
try {
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
@@ -399,6 +414,17 @@ export async function start(args: StartArgs): Promise<void> {
} catch {
warn(`Scan ${workspace} started, but its launch record could not be removed.`);
}
// Hold until preflight clears, so an unreachable target or bad credential is reported here
// rather than after "Scan started".
spinner.message('Running preflight checks');
const outcome = await awaitPreflightOutcome(workflowId);
if (outcome.kind === 'failed') {
spinner.error('The scan could not start');
printScanStartFailure(outcome.message);
process.exit(1);
}
spinner.stop(`Scan started — ${workspace}`);
printInfo(args, workspace, repo.hostPath, workspacesDir);
if (args.follow) {
@@ -442,6 +468,92 @@ export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered'
return 'scan-running';
}
/** A pre-workflow failure the worker persisted; mirrors StartupErrorRecord in the worker. */
interface StartupError {
phase?: string;
code?: string;
message?: string;
}
/**
* Read the worker's pre-workflow failure record, if it wrote one. Undefined until the file exists
* and parses, so a partial write is simply re-read on the next poll rather than treated as failure.
*/
function readStartupError(startupErrorPath: string): StartupError | undefined {
let raw: string;
try {
raw = fs.readFileSync(startupErrorPath, 'utf-8');
} catch {
return undefined;
}
try {
const parsed = JSON.parse(raw);
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
/** Outcome of waiting for the in-workflow preflight to clear. */
type PreflightOutcome = { kind: 'passed' } | { kind: 'failed'; message: string } | { kind: 'unconfirmed' };
/**
* Wait for the registered workflow's preflight to pass or fail: passed once `currentPhase` moves
* beyond 'preflight' (or the scan already closed ok), failed when the workflow terminates with an
* error. Bounded, so a Temporal query outage falls through as 'unconfirmed' rather than hanging.
*/
async function awaitPreflightOutcome(workflowId: string): Promise<PreflightOutcome> {
for (let attempts = 0; attempts < 80; attempts++) {
try {
const lifecycle = await describeWorkflowLifecycle(workflowId);
if (lifecycle.kind === 'terminal') {
const outcome = await getTerminalOutcome(workflowId);
return outcome.kind === 'failed' ? { kind: 'failed', message: outcome.message } : { kind: 'passed' };
}
const progress = await queryProgress(workflowId);
if (progress && progress.currentPhase !== null && progress.currentPhase !== 'preflight') {
return { kind: 'passed' };
}
} catch {
// Transient query failure; keep waiting within the bound.
}
await sleep(1500);
}
return { kind: 'unconfirmed' };
}
/** Print a preflight failure: context line, then the indented reason and hint, then the reference code. */
function printScanStartFailure(message: string): void {
const segments = parseFailureSegments(message);
const phaseContext = segments.shift() ?? 'The scan failed';
const last = segments[segments.length - 1];
const reference = last?.startsWith('Reference code:') ? segments.pop() : undefined;
const lines = [` ${phaseContext}`, '', ...segments.map((segment) => ` ${segment}`)];
if (reference) {
lines.push('', ` ${reference}`);
}
console.error(`\n${lines.join('\n')}\n`);
}
/** Print the worker's persisted startup-failure reason, with its reference code when present. */
function printStartupError(startupError: StartupError): void {
const message =
typeof startupError.message === 'string' && startupError.message.trim()
? startupError.message.trim()
: 'The worker rejected the scan before it could start. Check the configuration file passed with -c.';
console.error('');
for (const line of message.split('\n')) {
console.error(line.length > 0 ? ` ${line}` : '');
}
if (typeof startupError.code === 'string' && startupError.code.trim()) {
console.error('');
console.error(` Reference code: ${startupError.code.trim()}`);
}
console.error('');
}
/** Point the operator at a scan that is running but whose startup this CLI could not confirm. */
function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void {
console.log('');
+7
View File
@@ -48,6 +48,13 @@ export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
*/
export const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
/**
* Reason for a pre-workflow failure, written by the worker under INTERNAL_DIR. The CLI reads it
* during the startup poll to report the real cause instead of a generic timeout. Must match
* STARTUP_ERROR_FILENAME in the worker package.
*/
export const STARTUP_ERROR_FILENAME = 'startup-error.json';
/**
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
* current INTERNAL_DIR location and falling back to the legacy run-root location
+81 -88
View File
@@ -462,8 +462,19 @@ const performSecurityValidation = (config: Config): void => {
}
if (config.rules) {
validateRulesSecurity(config.rules.avoid, 'avoid');
validateRulesSecurity(config.rules.focus, 'focus');
// Report every bad rule at once, so a config is fixed in one pass rather than one per re-run.
const ruleErrors: string[] = [];
collectRuleErrors(config.rules.avoid, 'avoid', ruleErrors);
collectRuleErrors(config.rules.focus, 'focus', ruleErrors);
if (ruleErrors.length > 0) {
throw new PentestError(
`Configuration validation failed:\n\n${ruleErrors.join('\n\n')}`,
'config',
false,
{ validationErrors: ruleErrors },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
}
checkForDuplicates(config.rules.avoid || [], 'avoid');
checkForDuplicates(config.rules.focus || [], 'focus');
@@ -513,126 +524,108 @@ const performSecurityValidation = (config: Config): void => {
}
};
const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): void => {
if (!rules) return;
/** Human-readable rule label, e.g. "Focus rule 1" — 1-based to match how an operator counts them. */
function ruleLabel(ruleType: string, index: number): string {
const capitalized = `${ruleType.charAt(0).toUpperCase()}${ruleType.slice(1)}`;
return `${capitalized} rule ${index + 1}`;
}
rules.forEach((rule, index) => {
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(rule.value)) {
throw new PentestError(
`rules.${ruleType}[${index}].value contains potentially dangerous pattern: ${pattern.source}`,
'config',
false,
{ field: `rules.${ruleType}[${index}].value`, pattern: pattern.source },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
}
if (rule.description !== undefined && pattern.test(rule.description)) {
throw new PentestError(
`rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`,
'config',
false,
{ field: `rules.${ruleType}[${index}].description`, pattern: pattern.source },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
}
}
validateRuleTypeSpecific(rule, ruleType, index);
});
};
const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number): void => {
const field = `rules.${ruleType}[${index}].value`;
/** A rule error as an aligned label/Value/Problem block, so the offending value is easy to spot. */
function ruleValueMessage(label: string, value: string, problem: string): string {
return [`${label}:`, ` Value: ${value}`, ` Problem: ${problem}`].join('\n');
}
/**
* The type-specific constraint a rule value breaks, or undefined when valid. Returns rather than
* throws so every bad rule can be collected and reported together.
*/
function ruleTypeProblem(rule: Rule): string | undefined {
switch (rule.type) {
case 'url_path':
if (!rule.value.startsWith('/')) {
throw new PentestError(
`${field} for type 'url_path' must start with '/'`,
'config',
false,
{ field, ruleType: rule.type },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
return "a 'url_path' rule matches the request path only, so it must begin with '/' (e.g. '/api/users')";
}
break;
return undefined;
case 'code_path':
if (rule.value.includes('://')) {
throw new PentestError(
`${field} for type 'code_path' must not contain a URL protocol (got '${rule.value}')`,
'config',
false,
{ field, ruleType: rule.type },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
return "a 'code_path' rule points at source files, so it must not contain a URL protocol like 'http://' (e.g. 'src/api/users.ts' or 'src/**/*.ts')";
}
break;
return undefined;
case 'subdomain':
case 'domain':
// Basic domain validation - no slashes allowed
if (rule.value.includes('/')) {
throw new PentestError(
`${field} for type '${rule.type}' cannot contain '/' characters`,
'config',
false,
{ field, ruleType: rule.type },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
return `a '${rule.type}' rule is a host name, so it cannot contain '/' (e.g. 'api.example.com')`;
}
// Must contain at least one dot for domains
if (rule.type === 'domain' && !rule.value.includes('.')) {
throw new PentestError(
`${field} for type 'domain' must be a valid domain name`,
'config',
false,
{ field, ruleType: rule.type },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
return "a 'domain' rule must be a full domain name, including the top-level domain (e.g. 'example.com')";
}
break;
return undefined;
case 'method': {
const allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
if (!allowedMethods.includes(rule.value.toUpperCase())) {
throw new PentestError(
`${field} for type 'method' must be one of: ${allowedMethods.join(', ')}`,
'config',
false,
{ field, ruleType: rule.type, allowedMethods },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
return `'${rule.value}' is not a recognized HTTP method — use one of: ${allowedMethods.join(', ')}`;
}
break;
return undefined;
}
case 'header':
if (!rule.value.match(/^[a-zA-Z0-9\-_]+$/)) {
throw new PentestError(
`${field} for type 'header' must be a valid header name (alphanumeric, hyphens, underscores only)`,
'config',
false,
{ field, ruleType: rule.type },
ErrorCode.CONFIG_VALIDATION_FAILED,
);
return "a header name may contain only letters, digits, hyphens, and underscores (e.g. 'Authorization' or 'X-Api-Key')";
}
break;
return undefined;
case 'parameter':
if (!rule.value.match(/^[a-zA-Z0-9\-_]+$/)) {
throw new PentestError(
`${field} for type 'parameter' must be a valid parameter name (alphanumeric, hyphens, underscores only)`,
'config',
false,
{ field, ruleType: rule.type },
ErrorCode.CONFIG_VALIDATION_FAILED,
return "a parameter name may contain only letters, digits, hyphens, and underscores (e.g. 'user_id' or 'redirect-url')";
}
return undefined;
default:
return undefined;
}
}
/**
* Append a block to `blocks` for every invalid rule — a dangerous pattern in the value or
* description, or a broken type-specific constraint — so all bad rules can be reported together.
*/
function collectRuleErrors(rules: Rule[] | undefined, ruleType: string, blocks: string[]): void {
if (!rules) return;
rules.forEach((rule, index) => {
const label = ruleLabel(ruleType, index);
const dangerousInValue = DANGEROUS_PATTERNS.find((pattern) => pattern.test(rule.value));
if (dangerousInValue) {
blocks.push(
ruleValueMessage(label, rule.value, `contains a potentially dangerous pattern (${dangerousInValue.source})`),
);
} else {
const problem = ruleTypeProblem(rule);
if (problem) {
blocks.push(ruleValueMessage(label, rule.value, problem));
}
}
const description = rule.description;
if (description !== undefined) {
const dangerousInDescription = DANGEROUS_PATTERNS.find((pattern) => pattern.test(description));
if (dangerousInDescription) {
blocks.push(
ruleValueMessage(
`${label} (description)`,
description,
`contains a potentially dangerous pattern (${dangerousInDescription.source})`,
),
);
}
break;
}
};
}
});
}
const checkForDuplicates = (rules: Rule[], ruleType: string): void => {
const seen = new Set<string>();
+7
View File
@@ -55,6 +55,13 @@ export const SARIF_FILENAME = 'report.sarif';
/** Deterministic receipt for the canonical report finalization commit. */
export const REPORT_FINALIZATION_MANIFEST_FILENAME = 'report_finalization_manifest.json';
/**
* Reason for a pre-workflow failure (bad config, resume mismatch, worker setup), written under
* INTERNAL_DIR for the CLI to surface — at that point Temporal has no record of the run. Must
* match STARTUP_ERROR_FILENAME in the CLI package.
*/
export const STARTUP_ERROR_FILENAME = 'startup-error.json';
/**
* Resolve the session.json path for a run directory, preferring the current
* `.shannon/` location and falling back to the legacy run-root location so
+101 -44
View File
@@ -28,6 +28,7 @@
* TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233)
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client, Connection, type WorkflowHandle, WorkflowNotFoundError } from '@temporalio/client';
@@ -40,7 +41,8 @@ import { CAPELLA_FORMAT_VERSION, CAPELLA_PROMPT_SET_VERSION } from '../ai/sast/c
import { summarizeOperationalMetrics } from '../audit/operational-summary.js';
import { sanitizeHostname } from '../audit/utils.js';
import { distributeConfig, parseConfig } from '../config-parser.js';
import { deliverablesDir, resolveSessionJsonPath } from '../paths.js';
import { deliverablesDir, INTERNAL_DIR, resolveSessionJsonPath, STARTUP_ERROR_FILENAME } from '../paths.js';
import { PentestError } from '../services/error-handling.js';
import { isProviderFailureCategory } from '../types/errors.js';
import {
ACCEPTED_CAPELLA_FAILURE_STAGES,
@@ -511,31 +513,61 @@ interface OrchestrationConfig {
exploit?: boolean;
}
/**
* Parse the scan config into orchestration values, or throw on a broken config. Failing (rather
* than falling back to defaults that quietly change scope) lets the caller persist parseConfig's
* error for the CLI instead of running a misconfigured scan.
*/
async function loadOrchestrationConfig(configPath: string | undefined): Promise<OrchestrationConfig> {
if (!configPath) return {};
try {
const config = await parseConfig(configPath);
const distributed = distributeConfig(config);
const codePathAvoids = distributed.avoid.filter((rule) => rule.type === 'code_path').map((rule) => rule.value);
const codePathFocus = distributed.focus.filter((rule) => rule.type === 'code_path').map((rule) => rule.value);
const config = await parseConfig(configPath);
const distributed = distributeConfig(config);
const codePathAvoids = distributed.avoid.filter((rule) => rule.type === 'code_path').map((rule) => rule.value);
const codePathFocus = distributed.focus.filter((rule) => rule.type === 'code_path').map((rule) => rule.value);
return {
...(distributed.agenticSast && {
agenticSast: {
codePathAvoids,
codePathFocus,
modelSpec: process.env.SHANNON_AI_MODEL?.trim() || DEFAULT_MODEL_SPEC,
capellaFormatVersion: CAPELLA_FORMAT_VERSION,
promptSetVersion: CAPELLA_PROMPT_SET_VERSION,
},
}),
exploit: distributed.exploit,
};
} catch (error) {
// A broken config must fail the run, not silently fall back to empty
// defaults that quietly change scope (vuln classes, exploit, retries).
console.error('Worker configuration could not be loaded. Reference code: CONFIG_VALIDATION_FAILED');
process.exit(1);
return {
...(distributed.agenticSast && {
agenticSast: {
codePathAvoids,
codePathFocus,
modelSpec: process.env.SHANNON_AI_MODEL?.trim() || DEFAULT_MODEL_SPEC,
capellaFormatVersion: CAPELLA_FORMAT_VERSION,
promptSetVersion: CAPELLA_PROMPT_SET_VERSION,
},
}),
exploit: distributed.exploit,
};
}
// === Startup Failure Persistence ===
/** Reason for a failure that happens before the workflow is created. */
interface StartupErrorRecord {
phase: string;
code?: string;
message: string;
}
/**
* Persist a pre-workflow failure to the bind-mounted workspace so the CLI can surface it. The
* worker exits before the workflow exists, so Temporal has no record and `--rm` removes the
* container; the file under INTERNAL_DIR outlives it on the host mount. `workspace` is the CLI's
* `--workspace` name (the run directory); absent only when the worker is run off the CLI path.
* Best-effort — a persist failure must not mask the original error.
*/
function persistStartupError(workspace: string | undefined, error: unknown, phase: string): void {
if (!workspace) return;
const record: StartupErrorRecord = {
phase,
...(error instanceof PentestError && error.code !== undefined && { code: error.code }),
message: error instanceof Error ? error.message : String(error),
};
try {
const dir = path.join('./workspaces', workspace, INTERNAL_DIR);
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, STARTUP_ERROR_FILENAME), JSON.stringify(record, null, 2), 'utf8');
} catch {
// A broken bind mount must not compound the failure; the caller's console.error still fires.
}
}
@@ -655,24 +687,26 @@ async function waitForWorkflowResult(
// === Main Entry Point ===
async function run(): Promise<void> {
// 1. Parse CLI args
const args = parseCliArgs(process.argv.slice(2));
// 2. Connect to Temporal server
const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233';
console.log(`Connecting to Temporal at ${address}...`);
const connection = await NativeConnection.connect({ address });
const clientConnection = await Connection.connect({ address });
const client = new Client({ connection: clientConnection });
/** A scan whose workflow is durably submitted, with the handles run() needs to await it. */
interface StartedScan {
handle: WorkflowHandle<(input: PipelineInput) => Promise<PipelineState>>;
workspace: WorkspaceResolution;
worker: Worker;
workerDone: Promise<void>;
}
/**
* Run every step that precedes the durable creation of the workflow: config parsing, workspace
* resolution, worker setup, and workflow submission. A failure anywhere here is a startup failure
* — Temporal holds no record yet — so the reason is persisted for the CLI before it propagates.
*/
async function startScan(client: Client, connection: NativeConnection, args: CliArgs): Promise<StartedScan> {
try {
// 3. Validate orchestration and resume state before terminating any workflow.
// 1. Validate orchestration and resume state before terminating any workflow.
const orchestration = await loadOrchestrationConfig(args.configPath);
const workspace = await resolveWorkspace(client, args, orchestration.exploit ?? true);
// 4. Bundle workflows and create the worker with the collision-checked activity registry.
// 2. Bundle workflows and create the worker with the collision-checked activity registry.
console.log('Preparing scan...');
const workflowBundle = await bundleWorkflowCode({
workflowsPath: path.join(__dirname, 'workflows.js'),
@@ -695,13 +729,11 @@ async function run(): Promise<void> {
maxConcurrentActivityTaskExecutions: 25,
});
// 5. Build the fixed-scope pipeline input.
// 3. Build the fixed-scope pipeline input and start worker polling in the background.
const input = buildPipelineInput(args, workspace, orchestration);
// 6. Start worker polling in the background.
const workerDone = worker.run();
// 7. Submit workflow to the same task queue.
// 4. Submit workflow to the same task queue. Past this point the run exists in Temporal.
const handle = await client.workflow.start<(input: PipelineInput) => Promise<PipelineState>>(
'pentestPipelineWorkflow',
{
@@ -711,10 +743,33 @@ async function run(): Promise<void> {
},
);
// 8. Wait for workflow result.
return { handle, workspace, worker, workerDone };
} catch (startupError) {
persistStartupError(args.resumeFromWorkspace, startupError, 'startup');
throw startupError;
}
}
async function run(): Promise<void> {
// 1. Parse CLI args
const args = parseCliArgs(process.argv.slice(2));
// 2. Connect to Temporal server
const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233';
console.log(`Connecting to Temporal at ${address}...`);
const connection = await NativeConnection.connect({ address });
const clientConnection = await Connection.connect({ address });
const client = new Client({ connection: clientConnection });
try {
// 3. Start the scan: parse config, resolve the workspace, and submit the workflow.
const { handle, workspace, worker, workerDone } = await startScan(client, connection, args);
// 4. Wait for workflow result.
await waitForWorkflowResult(handle, workspace);
// 9. Shut down worker gracefully. Final customer copies are workflow-owned.
// 5. Shut down worker gracefully. Final customer copies are workflow-owned.
worker.shutdown();
await workerDone;
} finally {
@@ -725,8 +780,10 @@ async function run(): Promise<void> {
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined;
if (invokedPath === fileURLToPath(import.meta.url)) {
run().catch(() => {
console.error('Worker failed. Reference code: WORKER_FAILED');
run().catch((error) => {
// startScan persists pre-workflow failures for the CLI; this also logs them in the container.
const message = error instanceof Error ? error.message : String(error);
console.error(`Worker failed: ${message}`);
process.exit(1);
});
}
+1 -4
View File
@@ -131,10 +131,6 @@ export function formatWorkflowError(error: unknown, currentPhase: string | null,
const segments: string[] = [phaseContext];
if (unwrapped.type) {
segments.push(unwrapped.type);
}
segments.push(
unwrapped.type === null
? 'The scan could not be completed.'
@@ -146,6 +142,7 @@ export function formatWorkflowError(error: unknown, currentPhase: string | null,
if (hint) {
segments.push(`Hint: ${hint}`);
}
segments.push(`Reference code: ${unwrapped.type}`);
}
return segments.join('|');