From c81553271f52de7bf1ee973e7c8022516a557af0 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Thu, 1 Oct 2026 23:08:41 +0530 Subject: [PATCH] feat: add --validate-auth to run authentication validation only --- CLAUDE.md | 2 +- apps/cli/src/commands/logs.ts | 12 +++++-- apps/cli/src/commands/start.ts | 41 ++++++++++++++++-------- apps/cli/src/docker.ts | 4 +++ apps/cli/src/help.ts | 2 ++ apps/cli/src/index.ts | 10 ++++++ apps/cli/src/scan/pipeline.ts | 2 ++ apps/worker/src/audit/workflow-logger.ts | 18 ++++++++--- apps/worker/src/temporal/shared.ts | 2 ++ apps/worker/src/temporal/worker.ts | 15 ++++++++- apps/worker/src/temporal/workflows.ts | 22 +++++++++++-- docs/configuration.md | 11 +++++++ docs/development.md | 4 +++ 13 files changed, 121 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 54a5a5f8..a799ae82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ pnpm biome:fix # Auto-fix lint, format, and import sorting **Monorepo tooling:** pnpm workspaces, Turborepo for task orchestration, Biome for linting/formatting. TypeScript compiler options shared via `tsconfig.base.json` at the root. All packages extend it, overriding only `rootDir` and `outDir`. Shared devDependencies (`typescript`, `@types/node`, `turbo`, `@biomejs/biome`) are hoisted to the root workspace. -**Options:** `-c ` (YAML config), `--models-config ` (pi `models.json` defining models pi's catalogue lacks), `-o ` (output directory), `-w ` (named workspace; auto-resumes if exists), `--pipeline-testing` (minimal prompts, 10s retries), `--keep-container` (preserve worker container after exit for log inspection), `--yes`/`-y` (skip the confirmation prompt on `stop`; required for non-interactive use; `reset` requires a typed `confirm` and cannot be skipped) +**Options:** `-c ` (YAML config), `--models-config ` (pi `models.json` defining models pi's catalogue lacks), `-o ` (output directory), `-w ` (named workspace; auto-resumes if exists), `--validate-auth` (run preflight and auth validation only, then stop; no pentest or report; requires a fresh workspace and an `authentication` block in the config), `--pipeline-testing` (minimal prompts, 10s retries), `--keep-container` (preserve worker container after exit for log inspection), `--yes`/`-y` (skip the confirmation prompt on `stop`; required for non-interactive use; `reset` requires a typed `confirm` and cannot be skipped) ## Architecture diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 4b76c7ac..4d48936b 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -21,7 +21,15 @@ import { resolveWorkflowId } from '../session.js'; import { waitForWorkflowClose } from '../temporal-client.js'; import { stdoutIsTerminal } from '../tty.js'; -const TERMINAL_HEADINGS = new Set(['Scan COMPLETED', 'Scan PARTIAL', 'Scan FAILED', 'Scan CANCELLED']); +const TERMINAL_HEADINGS = new Set([ + 'Scan COMPLETED', + 'Scan PARTIAL', + 'Scan FAILED', + 'Scan CANCELLED', + 'Validation COMPLETED', + 'Validation FAILED', + 'Validation CANCELLED', +]); // The combined log resets completion on the bare `RESUMED` heading; a per-agent file carries the // distinct `--- RESUMED () ---` boundary that WorkflowLogger.logResumeBoundary writes @@ -48,7 +56,7 @@ export class LogCompletionState { this.failureIsLastMarker = false; } else if (TERMINAL_HEADINGS.has(line)) { this.terminalIsLastMarker = true; - this.failureIsLastMarker = line === 'Scan FAILED'; + this.failureIsLastMarker = line.endsWith('FAILED'); } } } diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index 1d18ddeb..adc7cf22 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -45,6 +45,7 @@ export interface StartArgs { pipelineTesting: boolean; keepContainer: boolean; follow: boolean; + authOnly: boolean; version: string; } @@ -225,6 +226,9 @@ export function createWorkflowId(workspace: string, isResume: boolean, timestamp } export async function start(args: StartArgs): Promise { + // Auth-only runs are short and have no report to come back for, so they always stream to the end. + if (args.authOnly) args.follow = true; + // 1. Resolve non-mutating inputs and classify the workspace before changing it. initHome(); loadEnv(); @@ -242,6 +246,13 @@ export async function start(args: StartArgs): Promise { const requestedOutputDir = args.output ? path.resolve(expandHome(args.output)) : undefined; const launchDecision = classifyWorkspaceLaunch(workspacePath, args.url, requestedOutputDir); + // Auth-only runs write no resumable state, so they always run fresh; reusing a workspace would resume it. + if (args.authOnly && launchDecision.isResume) { + fail( + 'An auth-validation run needs a fresh workspace. Omit -w to auto-name one, or choose a -w name that is not in use.', + ); + } + // 2. Inputs are valid; identify the run before initializing shared infrastructure. const bannerVersion = isLocal() ? undefined : args.version; if (stdoutIsTerminal()) { @@ -254,7 +265,7 @@ export async function start(args: StartArgs): Promise { ensureDocker(); ensureImage(args.version); const spinner = p.spinner(); - spinner.start('Starting scan'); + spinner.start(args.authOnly ? 'Starting authentication validation' : 'Starting scan'); await ensureInfra(spinner); // 3. Generate the invocation identity. @@ -336,6 +347,7 @@ export async function start(args: StartArgs): Promise { workspace, ...(args.pipelineTesting && { pipelineTesting: true }), ...(args.keepContainer && { keepContainer: true }), + ...(args.authOnly && { authOnly: true }), ...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }), }); @@ -386,7 +398,7 @@ export async function start(args: StartArgs): Promise { }); // Poll for the workflow to register in session.json; the spinner resolves once it does. - spinner.message('Waiting for the scan to start'); + spinner.message(args.authOnly ? 'Waiting for authentication validation to start' : '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. @@ -420,15 +432,15 @@ export async function start(args: StartArgs): Promise { spinner.message('Running preflight checks'); const outcome = await awaitPreflightOutcome(workflowId); if (outcome.kind === 'failed') { - spinner.error('The scan could not start'); + spinner.error(args.authOnly ? 'Authentication validation could not start' : 'The scan could not start'); printScanStartFailure(outcome.message); process.exit(1); } - spinner.stop(`Scan started — ${workspace}`); + spinner.stop(args.authOnly ? `Validating authentication — ${workspace}` : `Scan started — ${workspace}`); printInfo(args, workspace, repo.hostPath, workspacesDir); if (args.follow) { - await followScan(workspace, workspacesDir); + await followScan(workspace, workspacesDir, args.authOnly); } return; } @@ -576,7 +588,7 @@ function printUnconfirmedScanHint(workspace: string, taskQueue: string, containe * That tracks whether the pipeline ran, not whether vulnerabilities were found. On failure the * root-cause message is printed so a red CI build says why. */ -async function followScan(workspace: string, workspacesDir: string): Promise { +async function followScan(workspace: string, workspacesDir: string, authOnly = false): Promise { const logFile = resolveRunFile(path.join(workspacesDir, workspace), 'workflow.log'); const workflowId = resolveWorkflowId(workspace); @@ -587,7 +599,8 @@ async function followScan(workspace: string, workspacesDir: string): Promise', 'Copy deliverables to this directory after the run'], ['-w, --workspace ', 'Named workspace (auto-resumes if it exists)'], ['-f, --follow', 'Stream the scan log until it finishes'], + ['--validate-auth', 'Validate authentication only, then stop (no pentest)'], ['--pipeline-testing', 'Use minimal prompts for fast testing'], ['--keep-container', 'Preserve the worker container after exit for log inspection'], ]; @@ -45,6 +46,7 @@ const COMMAND_HELP: Readonly> = { 'start -u https://example.com -r ./my-repo', 'start -u https://example.com -r /path/to/repo -c config.yaml -w q1-audit', 'start -u https://example.com -r ./my-repo --follow', + 'start -u https://example.com -r ./my-repo -c config.yaml --validate-auth', ], }, stop: { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 66199c61..f74b21db 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -189,6 +189,7 @@ interface ParsedStartArgs { pipelineTesting: boolean; keepContainer: boolean; follow: boolean; + authOnly: boolean; } function parseStartArgs(argv: string[]): ParsedStartArgs { @@ -205,6 +206,7 @@ function parseStartArgs(argv: string[]): ParsedStartArgs { pipelineTesting: ['--pipeline-testing'], keepContainer: ['--keep-container'], follow: ['-f', '--follow'], + authOnly: ['--validate-auth'], }, }); @@ -220,12 +222,20 @@ function parseStartArgs(argv: string[]): ParsedStartArgs { failUsage(`invalid --url: ${url}`); } + if (flags.authOnly && !values.config) { + failUsage( + '--validate-auth needs a config file with an authentication block', + `Usage: ${commandPrefix()} start -u -r -c --validate-auth`, + ); + } + return { url, repo, pipelineTesting: !!flags.pipelineTesting, keepContainer: !!flags.keepContainer, follow: !!flags.follow, + authOnly: !!flags.authOnly, ...(values.config && { config: values.config }), ...(values.modelsConfig && { modelsConfig: values.modelsConfig }), ...(values.workspace && { workspace: values.workspace }), diff --git a/apps/cli/src/scan/pipeline.ts b/apps/cli/src/scan/pipeline.ts index 6a84b4ca..41396e5d 100644 --- a/apps/cli/src/scan/pipeline.ts +++ b/apps/cli/src/scan/pipeline.ts @@ -108,6 +108,7 @@ const MISCELLANEOUS_EXPLOIT_AGENT: AgentSpec = { * available guess. */ export function pipelineForState(state: PipelineState | null): readonly PhaseSpec[] { + if (state?.authOnly === true) return PIPELINE.filter((phase) => phase.key === 'auth-validation'); if (state?.expectedAgents === undefined) return PIPELINE; const expected = new Set(state.expectedAgents); return PIPELINE.map((phase) => { @@ -353,6 +354,7 @@ export type PipelineStatus = 'running' | 'completed' | 'failed' | 'cancelled' | export interface PipelineState { readonly status: PipelineStatus; + readonly authOnly?: boolean; readonly currentPhase: string | null; readonly currentAgent: string | null; readonly completedAgents: string[]; diff --git a/apps/worker/src/audit/workflow-logger.ts b/apps/worker/src/audit/workflow-logger.ts index e512a9e8..d615a40f 100644 --- a/apps/worker/src/audit/workflow-logger.ts +++ b/apps/worker/src/audit/workflow-logger.ts @@ -115,6 +115,11 @@ function safeAgenticSastCode(code: string | undefined): string | undefined { return undefined; } +/** One scan per worker process; the worker sets this flag for an auth-only run (see worker.ts). */ +function isAuthOnlyRun(): boolean { + return process.env.SHANNON_AUTH_ONLY === '1'; +} + function safeAgenticSastStageLabel(label: string | undefined): string | undefined { return label !== undefined && isCapellaTerminalStageLabel(label) ? label : undefined; } @@ -436,9 +441,10 @@ export class WorkflowLogger { try { this.logStream = await LogStream.acquire(this.logPath); const workflowId = safeWorkflowIdentifier(this.workflowId ?? this.sessionMetadata.id); + const title = isAuthOnlyRun() ? 'Shannon - Authentication Validation Log' : 'Shannon Pentest - Scan Log'; const header = [ '================================================================================', - 'Shannon Pentest - Scan Log', + title, '================================================================================', `Workflow ID: ${workflowId}`, `Target URL: ${safeTargetUrl(this.sessionMetadata.webUrl)}`, @@ -447,7 +453,7 @@ export class WorkflowLogger { '', ].join('\n'); await this.logStream.appendIfAbsent(header, { - marker: 'Shannon Pentest - Scan Log', + marker: title, scope: 'whole-file', match: 'exact-line', }); @@ -658,6 +664,8 @@ export class WorkflowLogger { failed: 'FAILED', }; const status = statusHeaders[summary.status]; + const authOnly = isAuthOnlyRun(); + const runLabel = authOnly ? 'Validation' : 'Scan'; const completedAgents = summary.completedAgents.filter(isLoggableAgentName); const skippedAgents = (summary.skippedAgents ?? []).filter(isLoggableAgentName); const operationalGroups = summarizeOperationalMetrics(summary.operationalMetrics, summary.operationalStages); @@ -665,13 +673,13 @@ export class WorkflowLogger { const lines = [ '', '================================================================================', - `Scan ${status}`, + `${runLabel} ${status}`, '────────────────────────────────────────', `Workflow ID: ${safeWorkflowIdentifier(this.workflowId ?? this.sessionMetadata.id)}`, `Status: ${summary.status}`, `Duration: ${formatDuration(Math.max(0, summary.totalDurationMs))}`, `Total Cost: $${Math.max(0, summary.totalCostUsd).toFixed(4)}`, - `Agents: ${completedAgents.length} ran, ${skippedAgents.length} skipped`, + ...(authOnly ? [] : [`Agents: ${completedAgents.length} ran, ${skippedAgents.length} skipped`]), ]; if (summary.usageAccountingComplete === false) { lines.push('Cost Note: Cost is incomplete — some background work is not included in this total.'); @@ -741,7 +749,7 @@ export class WorkflowLogger { } lines.push('================================================================================'); - const marker = `Scan ${status}`; + const marker = `${runLabel} ${status}`; await this.withStream((stream) => stream.appendIfAbsent(`${lines.join('\n')}\n`, { marker, diff --git a/apps/worker/src/temporal/shared.ts b/apps/worker/src/temporal/shared.ts index f5b33df3..178f66fa 100644 --- a/apps/worker/src/temporal/shared.ts +++ b/apps/worker/src/temporal/shared.ts @@ -108,6 +108,7 @@ export interface PipelineInput { customerOutputPath?: string; // Stable mounted path for final customer copies only checkpointsEnabled?: boolean; // Enable checkpoint activities (default: false) exploit?: boolean; // false skips the exploitation phase + authOnly?: boolean; // true stops the run after auth validation (no pentest, no report) } /** What `loadResumeState` reconstructs from a prior workspace: independently verified, never assumed from session.json alone. */ @@ -184,6 +185,7 @@ export interface PipelineSummary { */ export interface PipelineState { status: 'running' | 'completed' | 'failed' | 'cancelled' | 'partial'; + authOnly: boolean; currentPhase: string | null; currentAgent: string | null; /** Agents that actually ran. Mutually exclusive from `skippedAgents`. */ diff --git a/apps/worker/src/temporal/worker.ts b/apps/worker/src/temporal/worker.ts index fed26552..e10fd9e8 100644 --- a/apps/worker/src/temporal/worker.ts +++ b/apps/worker/src/temporal/worker.ts @@ -250,6 +250,7 @@ interface CliArgs { configPath?: string; customerOutputPath?: string; pipelineTestingMode: boolean; + authOnly: boolean; resumeFromWorkspace?: string; } @@ -264,7 +265,8 @@ function showUsage(): void { console.log(' --config Configuration file path'); console.log(' --workspace Resume from existing workspace'); console.log(' --output Stable mounted path for final customer report copies'); - console.log(' --pipeline-testing Use minimal prompts for fast testing\n'); + console.log(' --pipeline-testing Use minimal prompts for fast testing'); + console.log(' --validate-auth Validate authentication only, then stop\n'); } function parseCliArgs(argv: string[]): CliArgs { @@ -280,6 +282,7 @@ function parseCliArgs(argv: string[]): CliArgs { let configPath: string | undefined; let customerOutputPath: string | undefined; let pipelineTestingMode = false; + let authOnly = false; let resumeFromWorkspace: string | undefined; for (let i = 0; i < argv.length; i++) { @@ -316,6 +319,8 @@ function parseCliArgs(argv: string[]): CliArgs { } } else if (arg === '--pipeline-testing') { pipelineTestingMode = true; + } else if (arg === '--validate-auth') { + authOnly = true; } else if (arg && !arg.startsWith('-')) { if (!webUrl) { webUrl = arg; @@ -343,6 +348,7 @@ function parseCliArgs(argv: string[]): CliArgs { taskQueue, ...(workflowId && { workflowId }), pipelineTestingMode, + authOnly, ...(configPath && { configPath }), ...(customerOutputPath && { customerOutputPath }), ...(resumeFromWorkspace && { resumeFromWorkspace }), @@ -591,6 +597,7 @@ function buildPipelineInput( ...(args.customerOutputPath !== undefined && { customerOutputPath: args.customerOutputPath }), ...(orchestration.agenticSast !== undefined && { agenticSast: orchestration.agenticSast }), ...(orchestration.exploit !== undefined && { exploit: orchestration.exploit }), + ...(args.authOnly && { authOnly: true }), }; } @@ -645,6 +652,8 @@ async function waitForWorkflowResult( } } else if (result.status === 'cancelled') { console.log('\nScan cancelled before it finished.'); + } else if (result.authOnly) { + console.log('\nAuthentication validated. No pentest was run (--validate-auth).'); } else { console.log('\nScan completed.'); } @@ -757,6 +766,10 @@ async function run(): Promise { // 1. Parse CLI args const args = parseCliArgs(process.argv.slice(2)); + // One scan per worker process, so an auth-only run is a process-wide fact. The log writers + // read it to frame the log as a validation rather than a pentest. + if (args.authOnly) process.env.SHANNON_AUTH_ONLY = '1'; + // 2. Connect to Temporal server const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; console.log(`Connecting to Temporal at ${address}...`); diff --git a/apps/worker/src/temporal/workflows.ts b/apps/worker/src/temporal/workflows.ts index ffbb98ba..aa8409b0 100644 --- a/apps/worker/src/temporal/workflows.ts +++ b/apps/worker/src/temporal/workflows.ts @@ -381,11 +381,13 @@ export async function pentestPipeline(input: PipelineInput): Promise" - "Click " ``` + +### Validating Authentication Only + +To confirm your login flow works before committing to a full scan, add `--validate-auth` to `start`: + +```bash +npx @keygraph/shannon start -u https://your-app.com -r /path/to/repo -c config.yaml --validate-auth +``` + +The run performs preflight and the single real login, then stops. No pentest, reconciliation, or report +is produced. It requires an `authentication` block in the config. diff --git a/docs/development.md b/docs/development.md index 08ec8b1f..d3cfb584 100644 --- a/docs/development.md +++ b/docs/development.md @@ -122,6 +122,9 @@ npx @keygraph/shannon start -u https://example.com -r /path/to/repo -w q1-audit # Stream the log until the scan finishes, then exit on its outcome (useful in CI). npx @keygraph/shannon start -u https://example.com -r /path/to/repo --follow +# Validate the configured login only, then stop (no pentest or report). +npx @keygraph/shannon start -u https://example.com -r /path/to/repo -c /path/to/my-config.yaml --validate-auth --follow + # List running and completed scans. npx @keygraph/shannon scans ``` @@ -134,6 +137,7 @@ Source-build examples: ./shannon start -u https://example.com -r /path/to/repo -o ./my-reports ./shannon start -u https://example.com -r /path/to/repo -w q1-audit ./shannon start -u https://example.com -r /path/to/repo --follow +./shannon start -u https://example.com -r /path/to/repo -c /path/to/my-config.yaml --validate-auth ./shannon scans # Rebuild the worker image.