diff --git a/.gitignore b/.gitignore index c24f52e0..3e20a17b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules/ .env +.env.* workspaces/ credentials/ +configs/ dist/ repos/ .turbo/ +audit-logs/ diff --git a/apps/cli/src/auth/pre-auth.ts b/apps/cli/src/auth/pre-auth.ts new file mode 100644 index 00000000..70569242 --- /dev/null +++ b/apps/cli/src/auth/pre-auth.ts @@ -0,0 +1,177 @@ +/** + * Interactive pre-authentication via Playwright. + * + * Opens a headed (visible) Chromium browser, navigates to the login URL, + * and waits for the user to complete authentication (e.g., Google OAuth + 2FA). + * Once the success condition is met, captures the browser's storage state + * (cookies + localStorage) and writes it to auth-state.json. + * + * Playwright is NOT a bundled dependency — it must be installed on the host: + * npm install -g playwright && npx playwright install chromium + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +export interface PreAuthOptions { + loginUrl: string; + successType: string; + successValue: string; + outputPath: string; +} + +interface PlaywrightBrowser { + newContext(): Promise; + close(): Promise; +} + +interface PlaywrightContext { + newPage(): Promise; + storageState(): Promise; + close(): Promise; +} + +interface PlaywrightPage { + goto(url: string, opts?: { waitUntil?: string }): Promise; + url(): string; + textContent(selector: string): Promise; + waitForSelector(selector: string, opts?: { timeout?: number }): Promise; + waitForTimeout(ms: number): Promise; +} + +interface PlaywrightChromium { + launch(opts: { headless: boolean }): Promise; +} + +function resolvePlaywrightPath(): string { + // Try local node_modules first, then global + const localPath = path.resolve('node_modules', 'playwright'); + if (fs.existsSync(localPath)) return localPath; + + try { + const globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf-8' }).trim(); + const globalPath = path.join(globalRoot, 'playwright'); + if (fs.existsSync(globalPath)) return globalPath; + } catch { + // npm not available or failed + } + + return 'playwright'; // Fallback to bare specifier +} + +async function loadPlaywright(): Promise { + try { + // Use createRequire to resolve playwright from local or global node_modules. + // ESM dynamic import can't resolve bare directory paths, but require.resolve can. + const resolved = resolvePlaywrightPath(); + const require = createRequire(import.meta.url); + const pw = require(resolved) as { chromium: PlaywrightChromium }; + if (!pw.chromium) throw new Error('chromium not found in playwright module'); + return pw.chromium; + } catch { + console.error('\nERROR: Playwright is required for interactive authentication.'); + console.error('Install it with:\n'); + console.error(' npm install -g playwright'); + console.error(' npx playwright install chromium\n'); + process.exit(1); + } +} + +function checkSuccessCondition(page: PlaywrightPage, successType: string, successValue: string): boolean { + switch (successType) { + case 'url_contains': + return page.url().includes(successValue); + case 'url_equals_exactly': + return page.url() === successValue; + default: + // element_present and text_contains are checked asynchronously below + return false; + } +} + +async function checkAsyncSuccessCondition( + page: PlaywrightPage, + successType: string, + successValue: string, +): Promise { + try { + switch (successType) { + case 'element_present': + await page.waitForSelector(successValue, { timeout: 500 }); + return true; + case 'text_contains': { + const text = await page.textContent('body'); + return text ? text.includes(successValue) : false; + } + default: + return false; + } + } catch { + return false; + } +} + +const POLL_INTERVAL_MS = 2000; +const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export async function runPreAuth(opts: PreAuthOptions): Promise { + const chromium = await loadPlaywright(); + + console.log('\nOpening browser for interactive login...'); + console.log(` Login URL: ${opts.loginUrl}`); + console.log(` Success: ${opts.successType} = "${opts.successValue}"`); + console.log('\nComplete the login in the browser window.'); + console.log('Shannon will detect when you are done and continue automatically.\n'); + + const browser = await chromium.launch({ headless: false }); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.goto(opts.loginUrl, { waitUntil: 'domcontentloaded' }); + + // Poll for success condition + const deadline = Date.now() + TIMEOUT_MS; + let authenticated = false; + + while (Date.now() < deadline) { + // Check URL-based conditions synchronously + if (checkSuccessCondition(page, opts.successType, opts.successValue)) { + authenticated = true; + break; + } + + // Check DOM-based conditions asynchronously + if (opts.successType === 'element_present' || opts.successType === 'text_contains') { + if (await checkAsyncSuccessCondition(page, opts.successType, opts.successValue)) { + authenticated = true; + break; + } + } + + await page.waitForTimeout(POLL_INTERVAL_MS); + } + + if (!authenticated) { + console.error('\nERROR: Login timed out after 5 minutes.'); + console.error('The success condition was not met. Please try again.'); + process.exit(1); + } + + // Capture storage state (cookies including HttpOnly + localStorage) + const storageState = await context.storageState(); + + // Write to output path + const outputDir = path.dirname(opts.outputPath); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(opts.outputPath, JSON.stringify(storageState, null, 2), 'utf-8'); + + console.log('\nAuthentication successful!'); + console.log(`Session state saved to: ${opts.outputPath}`); + } finally { + await context.close(); + await browser.close(); + } +} diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts new file mode 100644 index 00000000..d5863686 --- /dev/null +++ b/apps/cli/src/commands/auth.ts @@ -0,0 +1,160 @@ +/** + * `shannon auth` command — interactive pre-authentication. + * + * Opens a visible browser for the user to complete OAuth/SSO login (e.g., + * Google Sign-In with 2FA). Captures the authenticated session state and + * saves it so `shannon start` can distribute it to agents. + * + * Requires: login_type "interactive" in the YAML config. + * Requires: Playwright installed on the host (npm install -g playwright). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { runPreAuth } from '../auth/pre-auth.js'; +import { getWorkspacesDir, initHome } from '../home.js'; + +export interface AuthArgs { + config: string; + workspace?: string; +} + +interface AuthenticationBlock { + login_type?: string; + login_url?: string; + success_condition?: { + type?: string; + value?: string; + }; +} + +/** + * Minimal YAML parser for extracting the authentication block. + * Avoids adding js-yaml as a dependency — only needs login_url, + * success_condition.type, and success_condition.value. + */ +function parseAuthFromYaml(content: string): AuthenticationBlock | null { + const lines = content.split('\n'); + const auth: AuthenticationBlock = {}; + let inAuth = false; + let inSuccessCondition = false; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + const stripped = line.replace(/#.*$/, '').trimEnd(); + if (!stripped) continue; + + const indent = line.search(/\S/); + + // Top-level key + if (indent === 0) { + inAuth = stripped.startsWith('authentication:'); + inSuccessCondition = false; + continue; + } + + if (!inAuth) continue; + + // Authentication-level keys (indent 2) + if (indent === 2 && stripped.includes('login_type:')) { + auth.login_type = extractYamlValue(stripped); + } else if (indent === 2 && stripped.includes('login_url:')) { + auth.login_url = extractYamlValue(stripped); + } else if (indent === 2 && stripped.includes('success_condition:')) { + inSuccessCondition = true; + auth.success_condition = {}; + } else if (indent === 2) { + inSuccessCondition = false; + } + + // Success condition keys (indent 4) + if (inSuccessCondition && indent === 4) { + if (stripped.includes('type:')) { + auth.success_condition!.type = extractYamlValue(stripped); + } else if (stripped.includes('value:')) { + auth.success_condition!.value = extractYamlValue(stripped); + } + } + } + + return auth.login_type ? auth : null; +} + +function extractYamlValue(line: string): string { + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) return ''; + const raw = line.slice(colonIdx + 1).trim(); + // Strip surrounding quotes + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) { + return raw.slice(1, -1); + } + return raw; +} + +export async function auth(args: AuthArgs): Promise { + initHome(); + + // 1. Read and parse the config file + const configPath = path.resolve(args.config); + if (!fs.existsSync(configPath)) { + console.error(`ERROR: Config file not found: ${configPath}`); + process.exit(1); + } + + const configContent = fs.readFileSync(configPath, 'utf-8'); + const authBlock = parseAuthFromYaml(configContent); + + if (!authBlock) { + console.error('ERROR: No authentication section found in config file.'); + process.exit(1); + } + + if (authBlock.login_type !== 'interactive') { + console.error(`ERROR: login_type must be "interactive" for the auth command (got: "${authBlock.login_type}").`); + console.error('The auth command is only for interactive pre-authentication (OAuth, SSO, etc.).'); + process.exit(1); + } + + if (!authBlock.login_url) { + console.error('ERROR: authentication.login_url is required.'); + process.exit(1); + } + + if (!authBlock.success_condition?.type || !authBlock.success_condition?.value) { + console.error('ERROR: authentication.success_condition (type + value) is required.'); + process.exit(1); + } + + // 2. Resolve workspace name + let workspaceName: string; + if (args.workspace) { + workspaceName = args.workspace; + } else { + try { + const hostname = new URL(authBlock.login_url).hostname.replace(/[^a-zA-Z0-9-]/g, '-'); + workspaceName = `${hostname}_shannon-${Date.now()}`; + } catch { + console.error(`ERROR: Invalid login_url: ${authBlock.login_url}`); + process.exit(1); + } + } + + // 3. Run pre-auth + const workspacesDir = getWorkspacesDir(); + const workspaceDir = path.join(workspacesDir, workspaceName); + fs.mkdirSync(workspaceDir, { recursive: true }); + + const authStatePath = path.join(workspaceDir, 'auth-state.json'); + + await runPreAuth({ + loginUrl: authBlock.login_url, + successType: authBlock.success_condition.type, + successValue: authBlock.success_condition.value, + outputPath: authStatePath, + }); + + // 4. Show next steps + const prefix = process.env.SHANNON_LOCAL === '1' ? './shannon' : 'npx @keygraph/shannon'; + console.log(`\nNext step — start the scan with the same workspace:\n`); + console.log(` ${prefix} start -u -r -c ${args.config} -w ${workspaceName}\n`); +} diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index 35e7518b..bfa5aab2 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -85,10 +85,16 @@ export async function start(args: StartArgs): Promise { // 11. Resolve prompts directory (local mode only) const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined; - // 12. Display splash screen + // 12. Check for pre-authenticated session + const authStatePath = path.join(workspacesDir, workspace, 'auth-state.json'); + if (fs.existsSync(authStatePath)) { + console.log('Using pre-authenticated session from auth-state.json'); + } + + // 13. Display splash screen displaySplash(isLocal() ? undefined : args.version); - // 13. Spawn worker container + // 14. Spawn worker container const proc = spawnWorker({ version: args.version, url: args.url, @@ -105,7 +111,7 @@ export async function start(args: StartArgs): Promise { ...(args.pipelineTesting && { pipelineTesting: true }), }); - // 14. Wait for workflow to register, then display info + // 15. Wait for workflow to register, then display info proc.on('error', (err) => { console.error(`Failed to start worker: ${err.message}`); process.exit(1); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6d1cf84e..d741d7d1 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -12,6 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { auth } from './commands/auth.js'; import { build } from './commands/build.js'; import { logs } from './commands/logs.js'; import { setup } from './commands/setup.js'; @@ -48,6 +49,7 @@ Usage:${ : ` ${prefix} setup Configure credentials` } + ${prefix} auth --config [--workspace ] Pre-authenticate (OAuth/SSO) ${prefix} start --url --repo [options] Start a pentest scan ${prefix} stop [--clean] Stop all containers ${prefix} workspaces List all workspaces @@ -176,12 +178,62 @@ function parseStartArgs(argv: string[]): ParsedStartArgs { }; } +interface ParsedAuthArgs { + config: string; + workspace?: string; +} + +function parseAuthArgs(argv: string[]): ParsedAuthArgs { + let config = ''; + let workspace: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = argv[i + 1]; + + switch (arg) { + case '-c': + case '--config': + if (next && !next.startsWith('-')) { + config = next; + i++; + } + break; + case '-w': + case '--workspace': + if (next && !next.startsWith('-')) { + workspace = next; + i++; + } + break; + default: + console.error(`Unknown option for auth: ${arg}`); + process.exit(1); + } + } + + if (!config) { + console.error('ERROR: --config is required for auth'); + console.error( + `Usage: ${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} auth -c [-w ]`, + ); + process.exit(1); + } + + return { config, ...(workspace && { workspace }) }; +} + // === Main Dispatch === const args = process.argv.slice(2); const command = args[0]; switch (command) { + case 'auth': { + const parsed = parseAuthArgs(args.slice(1)); + await auth(parsed); + break; + } case 'start': { const parsed = parseStartArgs(args.slice(1)); await start({ ...parsed, version: getVersion() }); diff --git a/apps/worker/configs/config-schema.json b/apps/worker/configs/config-schema.json index 757f0838..26e7dbef 100644 --- a/apps/worker/configs/config-schema.json +++ b/apps/worker/configs/config-schema.json @@ -11,7 +11,7 @@ "properties": { "login_type": { "type": "string", - "enum": ["form", "sso", "api", "basic"], + "enum": ["form", "sso", "api", "basic", "interactive"], "description": "Type of authentication mechanism" }, "login_url": { @@ -75,7 +75,13 @@ "additionalProperties": false } }, - "required": ["login_type", "login_url", "credentials", "success_condition"], + "required": ["login_type", "login_url", "success_condition"], + "if": { + "properties": { "login_type": { "not": { "const": "interactive" } } } + }, + "then": { + "required": ["credentials"] + }, "additionalProperties": false }, "pipeline": { diff --git a/apps/worker/configs/example-config.yaml b/apps/worker/configs/example-config.yaml index e46e8e67..7375246f 100644 --- a/apps/worker/configs/example-config.yaml +++ b/apps/worker/configs/example-config.yaml @@ -5,7 +5,7 @@ description: "Next.js e-commerce app on PostgreSQL. Local dev environment — .env files contain local-only credentials, not deployed to production." authentication: - login_type: form # Options: 'form' or 'sso' + login_type: form # Options: 'form', 'sso', or 'interactive' login_url: "https://example.com/login" credentials: username: "testuser" @@ -47,6 +47,17 @@ rules: type: path url_path: "/api/v2/user-profile" +# --- Interactive authentication example (OAuth / Google Sign-In) --- +# Requires running `shannon auth -c config.yaml` before `shannon start`. +# Shannon opens a browser, you log in manually, and it captures the session. +# +# authentication: +# login_type: interactive +# login_url: "https://example.com/login" +# success_condition: +# type: url_contains +# value: "/dashboard" + # Pipeline execution settings (optional) # pipeline: # retry_preset: subscription # 'default' or 'subscription' (6h max retry for rate limit recovery) diff --git a/apps/worker/prompts/shared/login-instructions.txt b/apps/worker/prompts/shared/login-instructions.txt index 01155fea..dba0ffea 100644 --- a/apps/worker/prompts/shared/login-instructions.txt +++ b/apps/worker/prompts/shared/login-instructions.txt @@ -37,6 +37,37 @@ Execute the login flow based on the login_type specified in the configuration: 4. Ensure all consent and authorization steps are explicitly handled + +**Pre-authenticated session (interactive login):** +Your browser session has been pre-authenticated via an interactive login. Before starting your analysis, restore the session state. + +1. Read the auth state file: `cat /app/workspaces/{{SESSION_ID}}/auth-state.json` +2. Parse the JSON to extract `origins[].localStorage` entries and `cookies` entries. +3. Navigate to {{WEB_URL}} using Playwright. +4. For each entry in `origins[].localStorage`, execute via Playwright evaluate: + `localStorage.setItem('', '')` +5. For each cookie where `httpOnly` is `false`, execute via Playwright evaluate: + `document.cookie = '=; path=; domain='` +6. Reload the page. +7. Verify authentication using the success condition below. + +**Success verification for interactive login:** +- Success condition type: {{INTERACTIVE_SUCCESS_TYPE}} +- Success condition value: {{INTERACTIVE_SUCCESS_VALUE}} + +**If authentication verification fails after injection:** +- Re-read auth-state.json and retry the injection once +- If still failing, the pre-authenticated session may have expired +- Report the authentication failure and proceed with unauthenticated testing where possible + +**For API requests (curl, scripts) — use the FULL cookie header including httpOnly cookies:** +Build a Cookie header from ALL cookies in auth-state.json: +`curl -H "Cookie: =; =" ` + +If a localStorage entry value starts with "eyJ" (base64-encoded JSON, likely a JWT), also try: +`curl -H "Authorization: Bearer " ` + + diff --git a/apps/worker/src/config-parser.ts b/apps/worker/src/config-parser.ts index 437eefca..a9736d6b 100644 --- a/apps/worker/src/config-parser.ts +++ b/apps/worker/src/config-parser.ts @@ -322,7 +322,7 @@ const performSecurityValidation = (config: Config): void => { } } - if (auth.credentials) { + if (auth.credentials && auth.login_type !== 'interactive') { for (const pattern of DANGEROUS_PATTERNS) { if (pattern.test(auth.credentials.username)) { throw new PentestError( @@ -555,11 +555,13 @@ const sanitizeAuthentication = (auth: Authentication): Authentication => { return { login_type: auth.login_type.toLowerCase().trim() as Authentication['login_type'], login_url: auth.login_url.trim(), - credentials: { - username: auth.credentials.username.trim(), - password: auth.credentials.password, - ...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }), - }, + ...(auth.credentials && { + credentials: { + username: auth.credentials.username.trim(), + password: auth.credentials.password, + ...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }), + }, + }), ...(auth.login_flow && { login_flow: auth.login_flow.map((step) => step.trim()) }), success_condition: { type: auth.success_condition.type.toLowerCase().trim() as Authentication['success_condition']['type'], diff --git a/apps/worker/src/services/agent-execution.ts b/apps/worker/src/services/agent-execution.ts index 48aba61b..11cbd97b 100644 --- a/apps/worker/src/services/agent-execution.ts +++ b/apps/worker/src/services/agent-execution.ts @@ -44,6 +44,7 @@ import { loadPrompt } from './prompt-manager.js'; export interface AgentExecutionInput { webUrl: string; repoPath: string; + sessionId?: string | undefined; configPath?: string | undefined; pipelineTestingMode?: boolean | undefined; attemptNumber: number; @@ -89,7 +90,7 @@ export class AgentExecutionService { auditSession: AuditSession, logger: ActivityLogger, ): Promise> { - const { webUrl, repoPath, configPath, pipelineTestingMode = false, attemptNumber } = input; + const { webUrl, repoPath, sessionId, configPath, pipelineTestingMode = false, attemptNumber } = input; // 1. Load config (if provided) const configResult = await this.configLoader.loadOptional(configPath); @@ -102,7 +103,13 @@ export class AgentExecutionService { const promptTemplate = AGENTS[agentName].promptTemplate; let prompt: string; try { - prompt = await loadPrompt(promptTemplate, { webUrl, repoPath }, distributedConfig, pipelineTestingMode, logger); + prompt = await loadPrompt( + promptTemplate, + { webUrl, repoPath, ...(sessionId !== undefined && { sessionId }) }, + distributedConfig, + pipelineTestingMode, + logger, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return err( diff --git a/apps/worker/src/services/prompt-manager.ts b/apps/worker/src/services/prompt-manager.ts index 3e629c3d..8d8984b6 100644 --- a/apps/worker/src/services/prompt-manager.ts +++ b/apps/worker/src/services/prompt-manager.ts @@ -14,6 +14,7 @@ import { handlePromptError, PentestError } from './error-handling.js'; interface PromptVariables { webUrl: string; repoPath: string; + sessionId?: string; PLAYWRIGHT_SESSION?: string; } @@ -49,36 +50,46 @@ async function buildLoginInstructions(authentication: Authentication, logger: Ac const verificationSection = getSection(fullTemplate, 'VERIFICATION'); // 3. Assemble instructions from sections (fallback to full template if markers missing) - if (!commonSection && !authSection && !verificationSection) { + if (loginType === 'INTERACTIVE') { + // Interactive login: use only the INTERACTIVE section (no common/verification — handled inline) + loginInstructions = authSection; + + // Replace interactive-specific placeholders + loginInstructions = loginInstructions + .replace(/{{INTERACTIVE_SUCCESS_TYPE}}/g, authentication.success_condition.type) + .replace(/{{INTERACTIVE_SUCCESS_VALUE}}/g, authentication.success_condition.value); + } else 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 = userInstructions.replace(/\$username/g, authentication.credentials.username); - } - if (authentication.credentials.password) { - userInstructions = userInstructions.replace(/\$password/g, authentication.credentials.password); - } - if (authentication.credentials.totp_secret) { - userInstructions = userInstructions.replace( - /\$totp/g, - `generated TOTP code using secret "${authentication.credentials.totp_secret}"`, - ); + // 4. Interpolate login flow and credential placeholders (skip for interactive — no credentials) + if (loginType !== 'INTERACTIVE') { + let userInstructions = (authentication.login_flow ?? []).join('\n'); + + if (authentication.credentials) { + if (authentication.credentials.username) { + userInstructions = userInstructions.replace(/\$username/g, authentication.credentials.username); + } + if (authentication.credentials.password) { + userInstructions = userInstructions.replace(/\$password/g, authentication.credentials.password); + } + if (authentication.credentials.totp_secret) { + userInstructions = userInstructions.replace( + /\$totp/g, + `generated TOTP code using secret "${authentication.credentials.totp_secret}"`, + ); + } } - } - loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions); + loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions); - // 5. Replace TOTP secret placeholder if present in template - if (authentication.credentials?.totp_secret) { - loginInstructions = loginInstructions.replace(/{{totp_secret}}/g, authentication.credentials.totp_secret); + // 5. Replace TOTP secret placeholder if present in template + if (authentication.credentials?.totp_secret) { + loginInstructions = loginInstructions.replace(/{{totp_secret}}/g, authentication.credentials.totp_secret); + } } return loginInstructions; @@ -129,14 +140,15 @@ function buildAuthContext(config: DistributedConfig | null): string { } 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'); + const lines = [`- Login type: ${auth.login_type.toUpperCase()}`, `- Login URL: ${auth.login_url}`]; + + if (auth.login_type === 'interactive') { + lines.push('- Session: Pre-authenticated (interactive login)'); + } else if (auth.credentials) { + lines.push(`- Username: ${auth.credentials.username}`); + if (auth.credentials.totp_secret) { + lines.push('- MFA: TOTP enabled'); + } } return lines.join('\n'); @@ -166,6 +178,7 @@ async function interpolateVariables( let result = template .replace(/{{WEB_URL}}/g, variables.webUrl) .replace(/{{REPO_PATH}}/g, variables.repoPath) + .replace(/{{SESSION_ID}}/g, variables.sessionId || '') .replace(/{{PLAYWRIGHT_SESSION}}/g, variables.PLAYWRIGHT_SESSION || 'agent1') .replace(/{{AUTH_CONTEXT}}/g, buildAuthContext(config)) .replace(/{{DESCRIPTION}}/g, config?.description ? `Description: ${config.description}` : ''); @@ -187,8 +200,10 @@ async function interpolateVariables( } // Extract and inject login instructions from config - if (config.authentication?.login_flow) { - const loginInstructions = await buildLoginInstructions(config.authentication, logger); + const needsLoginInstructions = + config.authentication?.login_type === 'interactive' || config.authentication?.login_flow; + if (needsLoginInstructions) { + const loginInstructions = await buildLoginInstructions(config.authentication!, logger); result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, loginInstructions); } else { result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, ''); diff --git a/apps/worker/src/temporal/activities.ts b/apps/worker/src/temporal/activities.ts index 5e2c5dbc..c1794aba 100644 --- a/apps/worker/src/temporal/activities.ts +++ b/apps/worker/src/temporal/activities.ts @@ -102,7 +102,7 @@ function buildSessionMetadata(input: ActivityInput): SessionMetadata { * 4. Error classification for Temporal retry */ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Promise { - const { repoPath, configPath, pipelineTestingMode = false, workflowId, webUrl } = input; + const { repoPath, configPath, pipelineTestingMode = false, workflowId, sessionId, webUrl } = input; const startTime = Date.now(); const attemptNumber = Context.current().info.attempt; @@ -131,6 +131,7 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro { webUrl, repoPath, + sessionId, configPath, pipelineTestingMode, attemptNumber, diff --git a/apps/worker/src/types/config.ts b/apps/worker/src/types/config.ts index b1173871..ac94507f 100644 --- a/apps/worker/src/types/config.ts +++ b/apps/worker/src/types/config.ts @@ -21,7 +21,7 @@ export interface Rules { focus?: Rule[]; } -export type LoginType = 'form' | 'sso' | 'api' | 'basic'; +export type LoginType = 'form' | 'sso' | 'api' | 'basic' | 'interactive'; export interface SuccessCondition { type: 'url_contains' | 'element_present' | 'url_equals_exactly' | 'text_contains'; @@ -37,7 +37,7 @@ export interface Credentials { export interface Authentication { login_type: LoginType; login_url: string; - credentials: Credentials; + credentials?: Credentials; login_flow?: string[]; success_condition: SuccessCondition; } diff --git a/docs/interactive-auth.md b/docs/interactive-auth.md new file mode 100644 index 00000000..7dbc9de9 --- /dev/null +++ b/docs/interactive-auth.md @@ -0,0 +1,148 @@ +# Interactive Authentication + +Test applications that use OAuth, Google Sign-In, or any SSO provider that requires manual login (e.g., with 2FA). + +Shannon opens a real browser window on your machine. You complete the login once, and Shannon captures the session for all agents to use during the scan. + +## Prerequisites + +Install Playwright on your host machine (one-time setup): + +```bash +npm install -g playwright +npx playwright install chromium +``` + +## Quick Start + +### 1. Create a config file + +```yaml +# my-config.yaml +authentication: + login_type: interactive + login_url: "https://your-app.com/login" + success_condition: + type: url_contains + value: "/dashboard" +``` + +The `success_condition` tells Shannon how to detect that login is complete: + +| type | value | Detects | +|------|-------|---------| +| `url_contains` | `/dashboard` | URL changes to include `/dashboard` | +| `url_equals_exactly` | `https://app.com/home` | URL matches exactly | +| `element_present` | `#user-menu` | A CSS selector appears on page | +| `text_contains` | `Welcome` | Page body contains the text | + +### 2. Authenticate + +```bash +# Local mode +./shannon auth -c my-config.yaml -w my-audit + +# NPX mode +npx @keygraph/shannon auth -c my-config.yaml -w my-audit +``` + +A Chromium browser opens. Complete the login (Google Sign-In, 2FA, etc.). Shannon detects the success condition and captures the session automatically. The browser closes. + +### 3. Run the scan + +Use the **same workspace name** from step 2: + +```bash +# Local mode +./shannon start -u https://your-app.com -r /path/to/repo -c my-config.yaml -w my-audit + +# NPX mode +npx @keygraph/shannon start -u https://your-app.com -r /path/to/repo -c my-config.yaml -w my-audit +``` + +Shannon detects `auth-state.json` in the workspace and distributes the authenticated session to all agents. + +## How It Works + +1. **`shannon auth`** opens a headed Chromium browser and navigates to `login_url` +2. You complete the login manually (handles any auth flow — Google, Okta, SAML, etc.) +3. Shannon polls for the `success_condition` (every 2 seconds, 5 minute timeout) +4. Once met, Shannon captures `context.storageState()` — all cookies (including HttpOnly) and localStorage +5. Saves to `workspaces//auth-state.json` +6. **`shannon start`** mounts the workspace into the Docker container +7. Each agent reads `auth-state.json` and injects the session into Playwright: + - `localStorage.setItem()` for each stored entry + - `document.cookie` for non-HttpOnly cookies + - `Cookie` header in `curl` commands for API testing (includes HttpOnly cookies) + +## Multi-Repo Applications + +Shannon takes a single repository path (`-r`). For applications with separate frontend and backend repos, combine them into one directory: + +```bash +mkdir repos/my-app +cp -r /path/to/frontend repos/my-app/frontend +cp -r /path/to/backend repos/my-app/backend + +# Remove nested .git directories (Shannon needs a single git root) +rm -rf repos/my-app/frontend/.git repos/my-app/backend/.git + +# Initialize as a single repo +cd repos/my-app +git init && git add -A && git commit -m "combined for scan" +cd ../.. + +# Scan +./shannon start -u https://your-app.com -r my-app -c my-config.yaml -w my-audit +``` + +Shannon's code analysis agents will examine both `frontend/` and `backend/` directories. + +## Full Example: Google Sign-In App + +```yaml +# configs/my-app.yaml +description: "TypeScript frontend + Python backend. Google Sign-In OAuth 2.0." + +authentication: + login_type: interactive + login_url: "https://my-app.example.com/" + success_condition: + type: url_contains + value: "/dashboard" + +rules: + avoid: + - description: "Do not test Google OAuth endpoints" + type: domain + url_path: "accounts.google.com" + - description: "Do not test Google APIs" + type: domain + url_path: "googleapis.com" +``` + +```bash +# 1. Authenticate +./shannon auth -c configs/my-app.yaml -w app-audit + +# 2. Scan +./shannon start -u https://my-app.example.com -r my-app -c configs/my-app.yaml -w app-audit + +# 3. Monitor +./shannon logs app-audit +``` + +## Limitations + +- **Session lifetime**: Sessions are captured once and not automatically refreshed. Most app sessions last 1–24 hours; scans typically complete in 1–3 hours. If a session expires mid-scan, the agent reports a 401 error. +- **HttpOnly cookies**: Cannot be set in the browser via JavaScript. Agents use the cookie values in `curl` headers for API testing. Browser-based testing relies on localStorage tokens and non-HttpOnly cookies. +- **Display required**: The `auth` command opens a visible browser, so it requires a display (not available on headless servers or CI). For CI environments, use `login_type: form` or `login_type: sso` with credentials instead. + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| `Playwright is required for interactive authentication` | Run `npm install -g playwright && npx playwright install chromium` | +| Login times out after 5 minutes | Check that `success_condition` matches the post-login URL/page. Try `text_contains` if URL doesn't change. | +| Agents report 401 during scan | Session may have expired. Re-run `shannon auth` with the same workspace, then resume the scan. | +| Browser doesn't open | Ensure you're running on a machine with a display (not SSH without X11 forwarding). |