Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a96bd1065a |
@@ -30,7 +30,7 @@ export function loadConfig(): Config {
|
|||||||
return {
|
return {
|
||||||
port: Number(process.env.PORT) || 3000,
|
port: Number(process.env.PORT) || 3000,
|
||||||
mcpPort: Number(process.env.MCP_PORT) || 3100,
|
mcpPort: Number(process.env.MCP_PORT) || 3100,
|
||||||
temporalAddress: process.env.TEMPORAL_ADDRESS || 'hightower-temporal:7233',
|
temporalAddress: process.env.TEMPORAL_ADDRESS || 'trebuchet-temporal:7233',
|
||||||
apiKey,
|
apiKey,
|
||||||
k8sNamespace: process.env.K8S_NAMESPACE || 'hightower',
|
k8sNamespace: process.env.K8S_NAMESPACE || 'hightower',
|
||||||
workerImage,
|
workerImage,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function buildJobSpec(params: JobParams): k8s.V1Job {
|
|||||||
|
|
||||||
// 2. Build volumes and mounts
|
// 2. Build volumes and mounts
|
||||||
const volumes: k8s.V1Volume[] = [
|
const volumes: k8s.V1Volume[] = [
|
||||||
{ name: 'workspaces', persistentVolumeClaim: { claimName: 'hightower-workspaces' } },
|
{ name: 'workspaces', persistentVolumeClaim: { claimName: 'trebuchet-workspaces' } },
|
||||||
{ name: 'shm', emptyDir: { medium: 'Memory', sizeLimit: '2Gi' } },
|
{ name: 'shm', emptyDir: { medium: 'Memory', sizeLimit: '2Gi' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export function buildJobSpec(params: JobParams): k8s.V1Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Env vars
|
// 4. Env vars
|
||||||
const env: k8s.V1EnvVar[] = [{ name: 'TEMPORAL_ADDRESS', value: 'hightower-temporal:7233' }];
|
const env: k8s.V1EnvVar[] = [{ name: 'TEMPORAL_ADDRESS', value: 'trebuchet-temporal:7233' }];
|
||||||
|
|
||||||
// 5. Construct the Job
|
// 5. Construct the Job
|
||||||
return {
|
return {
|
||||||
@@ -123,7 +123,7 @@ export function buildJobSpec(params: JobParams): k8s.V1Job {
|
|||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
restartPolicy: 'Never',
|
restartPolicy: 'Never',
|
||||||
serviceAccountName: 'default',
|
serviceAccountName: 'trebuchet-worker',
|
||||||
securityContext: {
|
securityContext: {
|
||||||
seccompProfile: { type: 'Unconfined' },
|
seccompProfile: { type: 'Unconfined' },
|
||||||
// Claude Code refuses --allow-dangerously-skip-permissions as root.
|
// Claude Code refuses --allow-dangerously-skip-permissions as root.
|
||||||
|
|||||||
@@ -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', '.playwright']) {
|
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||||
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,7 +76,6 @@ 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,12 +186,11 @@ 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/ and .playwright/ inside the :ro repo with workspace-backed dirs
|
// Writable overlays: shadow .shannon/ 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,33 +39,9 @@
|
|||||||
"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"],
|
"required": ["username", "password"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"login_flow": {
|
"login_flow": {
|
||||||
|
|||||||
@@ -47,25 +47,6 @@ 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)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Return the structured verdict `{ "login_success": true }` and stop.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// 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,6 +381,15 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,17 +605,8 @@ 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(),
|
||||||
...(auth.credentials.password !== undefined && { password: auth.credentials.password }),
|
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,9 +138,6 @@ 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,17 +76,6 @@ 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);
|
||||||
@@ -232,16 +221,6 @@ 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,
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
// 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,9 +151,6 @@ 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,12 +18,10 @@
|
|||||||
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';
|
||||||
@@ -31,14 +29,12 @@ 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 { Config, ContainerConfig, ProviderConfig } from '../types/config.js';
|
import type { 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';
|
||||||
@@ -186,7 +182,11 @@ 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 && { promptDir: resolvePromptDir(input.promptDir) }),
|
...(input.promptDir !== undefined && {
|
||||||
|
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,131 +373,6 @@ 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).
|
||||||
@@ -924,7 +799,11 @@ 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 && { promptDir: resolvePromptDir(input.promptDir) }),
|
...(input.promptDir !== undefined && {
|
||||||
|
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,7 +56,6 @@ const PRODUCTION_RETRY = {
|
|||||||
maximumAttempts: 50,
|
maximumAttempts: 50,
|
||||||
nonRetryableErrorTypes: [
|
nonRetryableErrorTypes: [
|
||||||
'AuthenticationError',
|
'AuthenticationError',
|
||||||
'AuthLoginFailedError',
|
|
||||||
'PermissionError',
|
'PermissionError',
|
||||||
'InvalidRequestError',
|
'InvalidRequestError',
|
||||||
'RequestTooLargeError',
|
'RequestTooLargeError',
|
||||||
@@ -121,22 +120,6 @@ 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.
|
||||||
@@ -413,16 +396,6 @@ 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,17 +28,10 @@ export interface SuccessCondition {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailLogin {
|
|
||||||
address: string;
|
|
||||||
password: string;
|
|
||||||
totp_secret?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password: string;
|
||||||
totp_secret?: string;
|
totp_secret?: string;
|
||||||
email_login?: EmailLogin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Authentication {
|
export interface Authentication {
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ 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