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

+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[];