feat: add --validate-auth to run authentication validation only

This commit is contained in:
ezl-keygraph committed 2026-10-01 23:08:41 +05:30
1 parent ad2d069563
commit c81553271f
13 files changed
+121 -24

No files matched your search

+1 -1
View File
@@ -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 <file>` (YAML config), `--models-config <file>` (pi `models.json` defining models pi's catalogue lacks), `-o <path>` (output directory), `-w <name>` (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 <file>` (YAML config), `--models-config <file>` (pi `models.json` defining models pi's catalogue lacks), `-o <path>` (output directory), `-w <name>` (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
+10 -2
View File
@@ -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 (<workflow id>) ---` 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');
}
}
}
+28 -13
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
});
// 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<void> {
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<never> {
async function followScan(workspace: string, workspacesDir: string, authOnly = false): Promise<never> {
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<nev
}
if (stdoutIsTerminal()) {
console.error('\n Following scan log (Ctrl-C to stop watching):\n');
const what = authOnly ? 'validation' : 'scan';
console.error(`\n Following ${what} log (Ctrl-C to stop watching):\n`);
}
let temporalUnreachable = false;
@@ -675,10 +688,12 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
console.log(` Progress: ${prefix} status ${workspace}`);
}
console.log('');
console.log(' Report (when the scan finishes):');
console.log(` ${reportDir}${path.sep}`);
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
console.log('');
if (!args.authOnly) {
console.log('');
console.log(' Report (when the scan finishes):');
console.log(` ${reportDir}${path.sep}`);
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
console.log('');
}
}
+4
View File
@@ -412,6 +412,7 @@ export interface WorkerOptions {
workspace: string;
pipelineTesting?: boolean;
keepContainer?: boolean;
authOnly?: boolean;
piAuthHostPath?: string;
}
@@ -511,6 +512,9 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
if (opts.pipelineTesting) {
args.push('--pipeline-testing');
}
if (opts.authOnly) {
args.push('--validate-auth');
}
// Inherit stderr so `docker run` daemon errors surface to the user;
// ignore stdin/stdout (the container ID is noise).
+2
View File
@@ -33,6 +33,7 @@ export const START_OPTIONS: readonly (readonly [string, string])[] = [
['-o, --output <path>', 'Copy deliverables to this directory after the run'],
['-w, --workspace <name>', '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<Record<string, CommandHelp>> = {
'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: {
+10
View File
@@ -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 <url> -r <path> -c <config.yaml> --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 }),
+2
View File
@@ -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[];
+13 -5
View File
@@ -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,
+2
View File
@@ -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`. */
+14 -1
View File
@@ -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 <path> Configuration file path');
console.log(' --workspace <name> Resume from existing workspace');
console.log(' --output <path> 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<void> {
// 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}...`);
+20 -2
View File
@@ -381,11 +381,13 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
const { workflowId } = workflowInfo();
const a = input.pipelineTestingMode ? testActs : acts;
const exploit = input.exploit ?? true;
const authOnly = input.authOnly ?? false;
const sessionId = input.sessionId || input.resumeFromWorkspace || workflowId;
const stateContext: 'fresh' | 'resume' = input.resumeFromWorkspace ? 'resume' : 'fresh';
const state: PipelineState = {
status: 'running',
authOnly,
currentPhase: null,
currentAgent: null,
completedAgents: [],
@@ -1289,7 +1291,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
const durable = await deterministicReportActs.initializeDurableScanState(activityInput, exploit, stateContext);
applyDurableSummary(durable);
if (input.resumeFromWorkspace) {
if (!authOnly && input.resumeFromWorkspace) {
// The new workflow id lands in session.json before anything that can reject the resume, so a
// validation or checkpoint-restore failure still leaves the CLI an attempt to follow.
await deterministicReportActs.registerResumeAttempt(activityInput, input.terminatedWorkflows ?? []);
@@ -1340,7 +1342,8 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
state.currentPhase = 'preflight';
state.currentAgent = null;
await preflightActs.runPreflightValidation(activityInput);
await preflightActs.runExploitReadinessProbe(activityInput);
// The probe gates the exploitation workload, which an auth-only run never reaches.
if (!authOnly) await preflightActs.runExploitReadinessProbe(activityInput);
await preflightActs.syncPlaywrightStealthConfig(activityInput);
state.currentPhase = 'auth-validation';
@@ -1349,6 +1352,21 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
if (authMetrics !== null) state.agentMetrics['validate-authentication'] = authMetrics;
state.currentAgent = null;
// Auth-only runs stop here; a null result means no authentication block, which is a misconfig.
if (authOnly) {
if (authMetrics === null) {
throw ApplicationFailure.nonRetryable(
'An auth-validation run needs an authentication block in the config. Add one, or drop --validate-auth.',
'ConfigurationError',
);
}
state.status = 'completed';
state.currentPhase = null;
state.summary = computeSummary(state, usageAccountingComplete());
await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, 'completed'));
return state;
}
await a.initDeliverableGit(activityInput);
await a.syncCodePathDenyRules(activityInput);
+11
View File
@@ -179,3 +179,14 @@ login_flow:
- "If prompted for 2FA, type $totp in <exact code field label or placeholder>"
- "Click <exact button text>"
```
### 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.
+4
View File
@@ -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.