Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a6e4933a |
@@ -65,7 +65,7 @@ export async function start(args: StartArgs): Promise<void> {
|
|||||||
const workspacePath = path.join(workspacesDir, workspace);
|
const workspacePath = path.join(workspacesDir, workspace);
|
||||||
fs.mkdirSync(workspacePath, { recursive: true });
|
fs.mkdirSync(workspacePath, { recursive: true });
|
||||||
fs.chmodSync(workspacePath, 0o777);
|
fs.chmodSync(workspacePath, 0o777);
|
||||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
||||||
const dirPath = path.join(workspacePath, dir);
|
const dirPath = path.join(workspacePath, dir);
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
fs.chmodSync(dirPath, 0o777);
|
fs.chmodSync(dirPath, 0o777);
|
||||||
@@ -76,6 +76,7 @@ export async function start(args: StartArgs): Promise<void> {
|
|||||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||||
}
|
}
|
||||||
|
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
|
||||||
|
|
||||||
const credentialsPath = getCredentialsPath();
|
const credentialsPath = getCredentialsPath();
|
||||||
const hasCredentials = fs.existsSync(credentialsPath);
|
const hasCredentials = fs.existsSync(credentialsPath);
|
||||||
|
|||||||
@@ -186,11 +186,12 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
|||||||
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
||||||
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
|
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
|
||||||
|
|
||||||
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
|
// Writable overlays: shadow .shannon/ and .playwright/ inside the :ro repo with workspace-backed dirs
|
||||||
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
|
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
|
||||||
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
||||||
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
||||||
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
||||||
|
args.push('-v', `${path.join(workspacePath, '.playwright')}:${opts.repo.containerPath}/.playwright`);
|
||||||
|
|
||||||
// Local mode: mount prompts for live editing
|
// Local mode: mount prompts for live editing
|
||||||
if (opts.promptsDir) {
|
if (opts.promptsDir) {
|
||||||
|
|||||||
@@ -39,9 +39,33 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z2-7]+=*$",
|
"pattern": "^[A-Za-z2-7]+=*$",
|
||||||
"description": "TOTP secret for two-factor authentication (Base32 encoded, case insensitive)"
|
"description": "TOTP secret for two-factor authentication (Base32 encoded, case insensitive)"
|
||||||
|
},
|
||||||
|
"email_login": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Email-based login credentials for magic-link and email-OTP flows",
|
||||||
|
"properties": {
|
||||||
|
"address": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"description": "Email address for authentication"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 255,
|
||||||
|
"description": "Password for the email account"
|
||||||
|
},
|
||||||
|
"totp_secret": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z2-7]+=*$",
|
||||||
|
"description": "TOTP secret for email 2FA (Base32 encoded)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["address", "password"],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["username", "password"],
|
"required": ["username"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"login_flow": {
|
"login_flow": {
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ rules:
|
|||||||
type: path
|
type: path
|
||||||
url_path: "/api/v2/user-profile"
|
url_path: "/api/v2/user-profile"
|
||||||
|
|
||||||
|
# Email-based login (for magic-link / email-OTP flows)
|
||||||
|
# authentication:
|
||||||
|
# login_type: form
|
||||||
|
# login_url: "https://example.com/login"
|
||||||
|
# credentials:
|
||||||
|
# username: "testuser"
|
||||||
|
# email_login:
|
||||||
|
# address: "testuser@example.com"
|
||||||
|
# password: "email-password"
|
||||||
|
# totp_secret: "JBSWY3DPEHPK3PXP" # Optional TOTP for email 2FA
|
||||||
|
# login_flow:
|
||||||
|
# - "Type $username into the username field"
|
||||||
|
# - "Type $email_address into the email field"
|
||||||
|
# - "Type $email_password into the email password field"
|
||||||
|
# - "Enter $email_totp in the verification code field"
|
||||||
|
# success_condition:
|
||||||
|
# type: url_contains
|
||||||
|
# value: "/dashboard"
|
||||||
|
|
||||||
# Pipeline execution settings (optional)
|
# Pipeline execution settings (optional)
|
||||||
# pipeline:
|
# pipeline:
|
||||||
# retry_preset: subscription # 'default' or 'subscription' (6h max retry for rate limit recovery)
|
# retry_preset: subscription # 'default' or 'subscription' (6h max retry for rate limit recovery)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Return the structured verdict `{ "login_success": true }` and stop.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<role>
|
||||||
|
You are a credential validation agent. Your sole job is to verify that the provided login credentials work against the target application.
|
||||||
|
</role>
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Attempt to log in to the target application using the provided credentials. Report whether the login succeeded or failed.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<target_authentication>
|
||||||
|
{{AUTH_CONTEXT}}
|
||||||
|
</target_authentication>
|
||||||
|
|
||||||
|
<cli_tools>
|
||||||
|
Use playwright-cli with session flag: `-s={{PLAYWRIGHT_SESSION}}`
|
||||||
|
</cli_tools>
|
||||||
|
|
||||||
|
<login_instructions>
|
||||||
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
|
</login_instructions>
|
||||||
|
|
||||||
|
<critical>
|
||||||
|
- Do NOT explore the application beyond verifying the login.
|
||||||
|
- Do NOT modify any data or settings.
|
||||||
|
- After verifying, return your structured verdict immediately and stop.
|
||||||
|
- If login fails, include which step failed and a brief detail (mask sensitive values like passwords).
|
||||||
|
</critical>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright (C) 2025 Keygraph, Inc.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License version 3
|
||||||
|
// as published by the Free Software Foundation.
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export type StealthConfigWriteResult = 'wrote' | 'skipped-existing';
|
||||||
|
|
||||||
|
const STEALTH_INIT_SCRIPT = `
|
||||||
|
// Remove webdriver flag
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
|
|
||||||
|
// Fake plugins array (Chrome PDF Plugin, PDF Viewer, Native Client)
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => {
|
||||||
|
const plugins = [
|
||||||
|
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||||
|
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
|
||||||
|
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
|
||||||
|
];
|
||||||
|
plugins.refresh = () => {};
|
||||||
|
return plugins;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stub window.chrome.runtime
|
||||||
|
if (!window.chrome) window.chrome = {};
|
||||||
|
if (!window.chrome.runtime) window.chrome.runtime = {};
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
function buildStealthConfig(): object {
|
||||||
|
return {
|
||||||
|
browser: 'chromium',
|
||||||
|
launchOptions: {
|
||||||
|
headless: true,
|
||||||
|
args: ['--disable-blink-features=AutomationControlled', '--no-first-run', '--no-default-browser-check'],
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
|
},
|
||||||
|
contextOptions: {
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
locale: 'en-US',
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write Playwright stealth configuration to the source directory.
|
||||||
|
* No-ops if the config file already exists.
|
||||||
|
*/
|
||||||
|
export async function writePlaywrightStealthConfig(sourceDir: string): Promise<StealthConfigWriteResult> {
|
||||||
|
const playwrightDir = path.join(sourceDir, '.playwright');
|
||||||
|
const configPath = path.join(playwrightDir, 'cli.config.json');
|
||||||
|
|
||||||
|
// Skip if config already exists
|
||||||
|
try {
|
||||||
|
await fs.stat(configPath);
|
||||||
|
return 'skipped-existing';
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, proceed with writing
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(playwrightDir, { recursive: true });
|
||||||
|
|
||||||
|
const config = buildStealthConfig();
|
||||||
|
const initScriptPath = path.join(playwrightDir, 'stealth-init.js');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'),
|
||||||
|
fs.writeFile(initScriptPath, STEALTH_INIT_SCRIPT, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return 'wrote';
|
||||||
|
}
|
||||||
@@ -381,15 +381,6 @@ const performSecurityValidation = (config: Config): void => {
|
|||||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pattern.test(auth.credentials.password)) {
|
|
||||||
throw new PentestError(
|
|
||||||
`authentication.credentials.password contains potentially dangerous pattern: ${pattern.source}`,
|
|
||||||
'config',
|
|
||||||
false,
|
|
||||||
{ field: 'credentials.password', pattern: pattern.source },
|
|
||||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,8 +596,17 @@ const sanitizeAuthentication = (auth: Authentication): Authentication => {
|
|||||||
login_url: auth.login_url.trim(),
|
login_url: auth.login_url.trim(),
|
||||||
credentials: {
|
credentials: {
|
||||||
username: auth.credentials.username.trim(),
|
username: auth.credentials.username.trim(),
|
||||||
password: auth.credentials.password,
|
...(auth.credentials.password !== undefined && { password: auth.credentials.password }),
|
||||||
...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }),
|
...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }),
|
||||||
|
...(auth.credentials.email_login && {
|
||||||
|
email_login: {
|
||||||
|
address: auth.credentials.email_login.address.trim(),
|
||||||
|
password: auth.credentials.email_login.password,
|
||||||
|
...(auth.credentials.email_login.totp_secret && {
|
||||||
|
totp_secret: auth.credentials.email_login.totp_secret.trim(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
...(auth.login_flow && { login_flow: auth.login_flow.map((step) => step.trim()) }),
|
...(auth.login_flow && { login_flow: auth.login_flow.map((step) => step.trim()) }),
|
||||||
success_condition: {
|
success_condition: {
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ function classifyByErrorCode(code: ErrorCode, retryableFromError: boolean): { ty
|
|||||||
case ErrorCode.AUTH_FAILED:
|
case ErrorCode.AUTH_FAILED:
|
||||||
return { type: 'AuthenticationError', retryable: false };
|
return { type: 'AuthenticationError', retryable: false };
|
||||||
|
|
||||||
|
case ErrorCode.AUTH_LOGIN_FAILED:
|
||||||
|
return { type: 'AuthLoginFailedError', retryable: false };
|
||||||
|
|
||||||
case ErrorCode.BILLING_ERROR:
|
case ErrorCode.BILLING_ERROR:
|
||||||
return { type: 'BillingError', retryable: true };
|
return { type: 'BillingError', retryable: true };
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ async function buildLoginInstructions(
|
|||||||
`generated TOTP code using secret "${authentication.credentials.totp_secret}"`,
|
`generated TOTP code using secret "${authentication.credentials.totp_secret}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (authentication.credentials.email_login) {
|
||||||
|
const emailLogin = authentication.credentials.email_login;
|
||||||
|
userInstructions = userInstructions.replace(/\$email_address/g, emailLogin.address);
|
||||||
|
userInstructions = userInstructions.replace(/\$email_password/g, emailLogin.password);
|
||||||
|
if (emailLogin.totp_secret) {
|
||||||
|
userInstructions = userInstructions.replace(
|
||||||
|
/\$email_totp/g,
|
||||||
|
`generated TOTP code using secret "${emailLogin.totp_secret}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions);
|
loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions);
|
||||||
@@ -221,6 +232,16 @@ async function interpolateVariables(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a prompt directory override to an absolute path.
|
||||||
|
* Falls back to the compiled-in PROMPTS_DIR when no override is given.
|
||||||
|
*/
|
||||||
|
export function resolvePromptDir(promptDir: string | undefined): string {
|
||||||
|
if (!promptDir) return PROMPTS_DIR;
|
||||||
|
if (path.isAbsolute(promptDir)) return promptDir;
|
||||||
|
return path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), promptDir);
|
||||||
|
}
|
||||||
|
|
||||||
// Pure function: Load and interpolate prompt template
|
// Pure function: Load and interpolate prompt template
|
||||||
export async function loadPrompt(
|
export async function loadPrompt(
|
||||||
promptName: string,
|
promptName: string,
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright (C) 2025 Keygraph, Inc.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License version 3
|
||||||
|
// as published by the Free Software Foundation.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-validation preflight service.
|
||||||
|
*
|
||||||
|
* Drives a real browser login before the full pipeline runs,
|
||||||
|
* catching bad credentials early and saving API budget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonSchemaOutputFormat } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runClaudePrompt } from '../ai/claude-executor.js';
|
||||||
|
import type { AuditSession } from '../audit/index.js';
|
||||||
|
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||||
|
import type { DistributedConfig, ProviderConfig } from '../types/config.js';
|
||||||
|
import { ErrorCode } from '../types/errors.js';
|
||||||
|
import type { Result } from '../types/result.js';
|
||||||
|
import { err, ok } from '../types/result.js';
|
||||||
|
import { PentestError } from './error-handling.js';
|
||||||
|
import { loadPrompt } from './prompt-manager.js';
|
||||||
|
|
||||||
|
type FailurePoint = 'username_or_password' | 'totp_secret' | 'out_of_band';
|
||||||
|
|
||||||
|
const AuthValidationSchema = z.object({
|
||||||
|
login_success: z.boolean(),
|
||||||
|
failure_point: z.enum(['username_or_password', 'totp_secret', 'out_of_band']).optional(),
|
||||||
|
failure_detail: z.string().max(250).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AUTH_VALIDATION_OUTPUT_FORMAT: JsonSchemaOutputFormat = {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: z.toJSONSchema(AuthValidationSchema, { target: 'draft-07' }) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AuthValidationInput {
|
||||||
|
webUrl: string;
|
||||||
|
repoPath: string;
|
||||||
|
config: DistributedConfig;
|
||||||
|
pipelineTestingMode: boolean;
|
||||||
|
auditSession: AuditSession;
|
||||||
|
logger: ActivityLogger;
|
||||||
|
promptDir?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
providerConfig?: ProviderConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyResult(parsed: z.infer<typeof AuthValidationSchema>): Result<void, PentestError> {
|
||||||
|
if (parsed.login_success) {
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failurePoint: FailurePoint = parsed.failure_point ?? 'username_or_password';
|
||||||
|
const detail = parsed.failure_detail ?? 'Login failed';
|
||||||
|
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Authentication validation failed at "${failurePoint}": ${detail}`,
|
||||||
|
'config',
|
||||||
|
false,
|
||||||
|
{ failurePoint, failureDetail: detail },
|
||||||
|
ErrorCode.AUTH_LOGIN_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateAuthentication(input: AuthValidationInput): Promise<Result<void, PentestError>> {
|
||||||
|
const { webUrl, repoPath, config, pipelineTestingMode, auditSession, logger, promptDir, apiKey, providerConfig } =
|
||||||
|
input;
|
||||||
|
|
||||||
|
// 1. Load the validation prompt
|
||||||
|
const prompt = await loadPrompt(
|
||||||
|
'validate-authentication',
|
||||||
|
{ webUrl, repoPath },
|
||||||
|
config,
|
||||||
|
pipelineTestingMode,
|
||||||
|
logger,
|
||||||
|
promptDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Run the agent with structured output
|
||||||
|
const result = await runClaudePrompt(
|
||||||
|
prompt,
|
||||||
|
repoPath,
|
||||||
|
'',
|
||||||
|
'Auth validation',
|
||||||
|
'validate-authentication',
|
||||||
|
auditSession,
|
||||||
|
logger,
|
||||||
|
'medium',
|
||||||
|
AUTH_VALIDATION_OUTPUT_FORMAT,
|
||||||
|
apiKey,
|
||||||
|
undefined,
|
||||||
|
providerConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Parse structured output
|
||||||
|
if (!result.success || !result.structuredOutput) {
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Auth validation agent did not return a structured verdict: ${result.error ?? 'unknown error'}`,
|
||||||
|
'validation',
|
||||||
|
true,
|
||||||
|
{ agentError: result.error },
|
||||||
|
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = AuthValidationSchema.safeParse(result.structuredOutput);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Auth validation output failed schema validation: ${parseResult.error.message}`,
|
||||||
|
'validation',
|
||||||
|
true,
|
||||||
|
{ zodErrors: parseResult.error.issues },
|
||||||
|
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Classify the verdict
|
||||||
|
return classifyResult(parseResult.data);
|
||||||
|
}
|
||||||
@@ -151,6 +151,9 @@ function createExploitValidator(vulnType: VulnType): AgentValidator {
|
|||||||
// Playwright session mapping - assigns each agent to a specific session for browser isolation
|
// Playwright session mapping - assigns each agent to a specific session for browser isolation
|
||||||
// Keys are promptTemplate values from AGENTS registry
|
// Keys are promptTemplate values from AGENTS registry
|
||||||
export const PLAYWRIGHT_SESSION_MAPPING: Record<string, PlaywrightSession> = Object.freeze({
|
export const PLAYWRIGHT_SESSION_MAPPING: Record<string, PlaywrightSession> = Object.freeze({
|
||||||
|
// Runs before any agent — non-concurrent, so agent1 is safe to share
|
||||||
|
'validate-authentication': 'agent1',
|
||||||
|
|
||||||
// Phase 1: Pre-reconnaissance
|
// Phase 1: Pre-reconnaissance
|
||||||
'pre-recon-code': 'agent1',
|
'pre-recon-code': 'agent1',
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ApplicationFailure, Context, heartbeat } from '@temporalio/activity';
|
import { ApplicationFailure, Context, heartbeat } from '@temporalio/activity';
|
||||||
|
import { type StealthConfigWriteResult, writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js';
|
||||||
import { AuditSession } from '../audit/index.js';
|
import { AuditSession } from '../audit/index.js';
|
||||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||||
import type { SessionMetadata } from '../audit/utils.js';
|
import type { SessionMetadata } from '../audit/utils.js';
|
||||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||||
|
import { distributeConfig, parseConfig, parseConfigYAML } from '../config-parser.js';
|
||||||
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
||||||
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js';
|
import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js';
|
||||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||||
@@ -29,12 +31,14 @@ import { classifyErrorForTemporal, PentestError } from '../services/error-handli
|
|||||||
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
||||||
import { executeGitCommandWithRetry } from '../services/git-manager.js';
|
import { executeGitCommandWithRetry } from '../services/git-manager.js';
|
||||||
import { runPreflightChecks } from '../services/preflight.js';
|
import { runPreflightChecks } from '../services/preflight.js';
|
||||||
|
import { resolvePromptDir } from '../services/prompt-manager.js';
|
||||||
import type { ExploitationDecision, VulnType } from '../services/queue-validation.js';
|
import type { ExploitationDecision, VulnType } from '../services/queue-validation.js';
|
||||||
import { assembleFinalReport, injectModelIntoReport } from '../services/reporting.js';
|
import { assembleFinalReport, injectModelIntoReport } from '../services/reporting.js';
|
||||||
|
import { validateAuthentication } from '../services/validate-authentication.js';
|
||||||
import { AGENTS } from '../session-manager.js';
|
import { AGENTS } from '../session-manager.js';
|
||||||
import type { AgentName } from '../types/agents.js';
|
import type { AgentName } from '../types/agents.js';
|
||||||
import { ALL_AGENTS } from '../types/agents.js';
|
import { ALL_AGENTS } from '../types/agents.js';
|
||||||
import type { ContainerConfig, ProviderConfig } from '../types/config.js';
|
import type { Config, ContainerConfig, ProviderConfig } from '../types/config.js';
|
||||||
import { ErrorCode } from '../types/errors.js';
|
import { ErrorCode } from '../types/errors.js';
|
||||||
import { isErr } from '../types/result.js';
|
import { isErr } from '../types/result.js';
|
||||||
import { fileExists, readJson } from '../utils/file-io.js';
|
import { fileExists, readJson } from '../utils/file-io.js';
|
||||||
@@ -182,11 +186,7 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro
|
|||||||
attemptNumber,
|
attemptNumber,
|
||||||
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||||||
...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }),
|
...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }),
|
||||||
...(input.promptDir !== undefined && {
|
...(input.promptDir !== undefined && { promptDir: resolvePromptDir(input.promptDir) }),
|
||||||
promptDir: path.isAbsolute(input.promptDir)
|
|
||||||
? input.promptDir
|
|
||||||
: path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), input.promptDir),
|
|
||||||
}),
|
|
||||||
...(input.configYAML !== undefined && { configYAML: input.configYAML }),
|
...(input.configYAML !== undefined && { configYAML: input.configYAML }),
|
||||||
},
|
},
|
||||||
auditSession,
|
auditSession,
|
||||||
@@ -373,6 +373,131 @@ export async function runPreflightValidation(input: ActivityInput): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write Playwright stealth configuration to the source directory.
|
||||||
|
* Thin activity wrapper — delegates to writePlaywrightStealthConfig.
|
||||||
|
*/
|
||||||
|
export async function syncPlaywrightStealthConfig(input: ActivityInput): Promise<StealthConfigWriteResult> {
|
||||||
|
const logger = createActivityLogger();
|
||||||
|
const result = await writePlaywrightStealthConfig(input.repoPath);
|
||||||
|
logger.info(`Playwright stealth config: ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-validation preflight activity.
|
||||||
|
*
|
||||||
|
* Runs a real browser login attempt to confirm credentials work
|
||||||
|
* before committing to the full pipeline.
|
||||||
|
*
|
||||||
|
* NOT using runAgentActivity — auth validation has its own structured output
|
||||||
|
* flow and retry semantics.
|
||||||
|
*/
|
||||||
|
export async function runAuthenticationValidation(input: ActivityInput): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const attemptNumber = Context.current().info.attempt;
|
||||||
|
|
||||||
|
const heartbeatInterval = setInterval(() => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
heartbeat({ phase: 'auth-validation', elapsedSeconds: elapsed, attempt: attemptNumber });
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logger = createActivityLogger();
|
||||||
|
logger.info('Running authentication validation...', { attempt: attemptNumber });
|
||||||
|
|
||||||
|
// 1. Load config to get authentication details
|
||||||
|
let config: Config;
|
||||||
|
if (input.configYAML) {
|
||||||
|
config = parseConfigYAML(input.configYAML);
|
||||||
|
} else if (input.configPath) {
|
||||||
|
config = await parseConfig(input.configPath);
|
||||||
|
} else {
|
||||||
|
logger.info('No config provided, skipping auth validation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributed = distributeConfig(config);
|
||||||
|
if (!distributed.authentication) {
|
||||||
|
logger.info('No authentication configured, skipping auth validation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create audit session
|
||||||
|
const sessionMetadata = buildSessionMetadata(input);
|
||||||
|
const auditSession = new AuditSession(sessionMetadata);
|
||||||
|
await auditSession.initialize(input.workflowId);
|
||||||
|
|
||||||
|
// 3. Run validation
|
||||||
|
const result = await validateAuthentication({
|
||||||
|
webUrl: input.webUrl,
|
||||||
|
repoPath: input.repoPath,
|
||||||
|
config: distributed,
|
||||||
|
pipelineTestingMode: input.pipelineTestingMode ?? false,
|
||||||
|
auditSession,
|
||||||
|
logger,
|
||||||
|
promptDir: resolvePromptDir(input.promptDir),
|
||||||
|
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||||||
|
...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isErr(result)) {
|
||||||
|
const classified = classifyErrorForTemporal(result.error);
|
||||||
|
const message = truncateErrorMessage(result.error.message);
|
||||||
|
|
||||||
|
const details: Record<string, unknown>[] = [
|
||||||
|
{ phase: 'auth-validation', attemptNumber, elapsed: Date.now() - startTime },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include failure point and detail for consumer error handling
|
||||||
|
if (result.error.context.failurePoint) {
|
||||||
|
details.push({
|
||||||
|
failurePoint: result.error.context.failurePoint,
|
||||||
|
failureDetail: result.error.context.failureDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.retryable) {
|
||||||
|
const failure = ApplicationFailure.create({
|
||||||
|
message,
|
||||||
|
type: classified.type,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
truncateStackTrace(failure);
|
||||||
|
throw failure;
|
||||||
|
} else {
|
||||||
|
const failure = ApplicationFailure.nonRetryable(message, classified.type, details);
|
||||||
|
truncateStackTrace(failure);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Authentication validation passed');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApplicationFailure) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classified = classifyErrorForTemporal(error);
|
||||||
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const message = truncateErrorMessage(rawMessage);
|
||||||
|
|
||||||
|
const failure = classified.retryable
|
||||||
|
? ApplicationFailure.create({
|
||||||
|
message,
|
||||||
|
type: classified.type,
|
||||||
|
details: [{ phase: 'auth-validation', attemptNumber, elapsed: Date.now() - startTime }],
|
||||||
|
})
|
||||||
|
: ApplicationFailure.nonRetryable(message, classified.type, [
|
||||||
|
{ phase: 'auth-validation', attemptNumber, elapsed: Date.now() - startTime },
|
||||||
|
]);
|
||||||
|
truncateStackTrace(failure);
|
||||||
|
throw failure;
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize a private git repository inside the workspace deliverables directory.
|
* Initialize a private git repository inside the workspace deliverables directory.
|
||||||
* Idempotent — skips if .git already exists (resume case).
|
* Idempotent — skips if .git already exists (resume case).
|
||||||
@@ -799,11 +924,7 @@ export async function generateReportOutputActivity(input: ActivityInput): Promis
|
|||||||
// Resolve promptDir against the worker root so providers are cwd-independent.
|
// Resolve promptDir against the worker root so providers are cwd-independent.
|
||||||
const resolvedInput: ActivityInput = {
|
const resolvedInput: ActivityInput = {
|
||||||
...input,
|
...input,
|
||||||
...(input.promptDir !== undefined && {
|
...(input.promptDir !== undefined && { promptDir: resolvePromptDir(input.promptDir) }),
|
||||||
promptDir: path.isAbsolute(input.promptDir)
|
|
||||||
? input.promptDir
|
|
||||||
: path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), input.promptDir),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await container.reportOutputProvider.generate(resolvedInput, logger);
|
const result = await container.reportOutputProvider.generate(resolvedInput, logger);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const PRODUCTION_RETRY = {
|
|||||||
maximumAttempts: 50,
|
maximumAttempts: 50,
|
||||||
nonRetryableErrorTypes: [
|
nonRetryableErrorTypes: [
|
||||||
'AuthenticationError',
|
'AuthenticationError',
|
||||||
|
'AuthLoginFailedError',
|
||||||
'PermissionError',
|
'PermissionError',
|
||||||
'InvalidRequestError',
|
'InvalidRequestError',
|
||||||
'RequestTooLargeError',
|
'RequestTooLargeError',
|
||||||
@@ -120,6 +121,22 @@ const preflightActs = proxyActivities<typeof activities>({
|
|||||||
retry: PREFLIGHT_RETRY,
|
retry: PREFLIGHT_RETRY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retry configuration for auth validation (browser-based, longer timeout)
|
||||||
|
const AUTH_VALIDATION_RETRY = {
|
||||||
|
initialInterval: '10 seconds',
|
||||||
|
maximumInterval: '1 minute',
|
||||||
|
backoffCoefficient: 2,
|
||||||
|
maximumAttempts: 3,
|
||||||
|
nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activity proxy for auth validation (10-minute timeout for browser login)
|
||||||
|
const authValidationActs = proxyActivities<typeof activities>({
|
||||||
|
startToCloseTimeout: '10 minutes',
|
||||||
|
heartbeatTimeout: '10 minutes',
|
||||||
|
retry: AUTH_VALIDATION_RETRY,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute aggregated metrics from the current pipeline state.
|
* Compute aggregated metrics from the current pipeline state.
|
||||||
* Called on both success and failure to provide partial metrics.
|
* Called on both success and failure to provide partial metrics.
|
||||||
@@ -396,6 +413,16 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
|||||||
await preflightActs.runPreflightValidation(activityInput);
|
await preflightActs.runPreflightValidation(activityInput);
|
||||||
log.info('Preflight validation passed');
|
log.info('Preflight validation passed');
|
||||||
|
|
||||||
|
// === Playwright Stealth Config ===
|
||||||
|
await a.syncPlaywrightStealthConfig(activityInput);
|
||||||
|
|
||||||
|
// === Auth Validation ===
|
||||||
|
// Browser-based credential check before committing to the full pipeline.
|
||||||
|
state.currentPhase = 'auth-validation';
|
||||||
|
state.currentAgent = 'validate-authentication';
|
||||||
|
await authValidationActs.runAuthenticationValidation(activityInput);
|
||||||
|
log.info('Auth validation passed');
|
||||||
|
|
||||||
// === Initialize Deliverables Git ===
|
// === Initialize Deliverables Git ===
|
||||||
await a.initDeliverableGit(activityInput);
|
await a.initDeliverableGit(activityInput);
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,19 @@ export interface SuccessCondition {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Credentials {
|
export interface EmailLogin {
|
||||||
username: string;
|
address: string;
|
||||||
password: string;
|
password: string;
|
||||||
totp_secret?: string;
|
totp_secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Credentials {
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
totp_secret?: string;
|
||||||
|
email_login?: EmailLogin;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Authentication {
|
export interface Authentication {
|
||||||
login_type: LoginType;
|
login_type: LoginType;
|
||||||
login_url: string;
|
login_url: string;
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export enum ErrorCode {
|
|||||||
TARGET_UNREACHABLE = 'TARGET_UNREACHABLE',
|
TARGET_UNREACHABLE = 'TARGET_UNREACHABLE',
|
||||||
AUTH_FAILED = 'AUTH_FAILED',
|
AUTH_FAILED = 'AUTH_FAILED',
|
||||||
BILLING_ERROR = 'BILLING_ERROR',
|
BILLING_ERROR = 'BILLING_ERROR',
|
||||||
|
|
||||||
|
// Auth validation errors
|
||||||
|
AUTH_LOGIN_FAILED = 'AUTH_LOGIN_FAILED',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PentestErrorType = 'config' | 'network' | 'prompt' | 'filesystem' | 'validation' | 'billing' | 'unknown';
|
export type PentestErrorType = 'config' | 'network' | 'prompt' | 'filesystem' | 'validation' | 'billing' | 'unknown';
|
||||||
|
|||||||
Reference in New Issue
Block a user