mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-01 11:05:36 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32c01a39b1 | |||
| 72c424f687 | |||
| 1af42339b9 |
+2
-2
@@ -20,7 +20,7 @@ RUN apk update && apk add --no-cache \
|
|||||||
bash
|
bash
|
||||||
|
|
||||||
# Install pnpm
|
# Install pnpm
|
||||||
RUN npm install -g pnpm@10.33.0
|
RUN npm install -g --ignore-scripts pnpm@10.33.0
|
||||||
|
|
||||||
# Build Node.js application in builder to avoid QEMU emulation failures in CI
|
# Build Node.js application in builder to avoid QEMU emulation failures in CI
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -91,7 +91,7 @@ COPY --from=builder /app/node_modules /app/node_modules
|
|||||||
COPY --from=builder /app/apps/worker /app/apps/worker
|
COPY --from=builder /app/apps/worker /app/apps/worker
|
||||||
COPY --from=builder /app/apps/cli/package.json /app/apps/cli/package.json
|
COPY --from=builder /app/apps/cli/package.json /app/apps/cli/package.json
|
||||||
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code@2.1.84 @playwright/cli@0.1.1
|
RUN npm install -g --ignore-scripts @anthropic-ai/claude-code@2.1.84 @playwright/cli@0.1.1
|
||||||
RUN mkdir -p /tmp/.claude/skills && \
|
RUN mkdir -p /tmp/.claude/skills && \
|
||||||
playwright-cli install --skills && \
|
playwright-cli install --skills && \
|
||||||
cp -r .claude/skills/playwright-cli /tmp/.claude/skills/ && \
|
cp -r .claude/skills/playwright-cli /tmp/.claude/skills/ && \
|
||||||
|
|||||||
@@ -396,6 +396,13 @@ authentication:
|
|||||||
password: "yourpassword"
|
password: "yourpassword"
|
||||||
totp_secret: "LB2E2RX7XFHSTGCK" # Optional for 2FA
|
totp_secret: "LB2E2RX7XFHSTGCK" # Optional for 2FA
|
||||||
|
|
||||||
|
# Optional mailbox credentials for magic-link / email-OTP flows.
|
||||||
|
# email_login:
|
||||||
|
# address: "inbox@example.com"
|
||||||
|
# password: "mailbox-password"
|
||||||
|
# totp_secret: "JBSWY3DPEHPK3PXP"
|
||||||
|
|
||||||
|
# Natural language instructions for login flow
|
||||||
login_flow:
|
login_flow:
|
||||||
- "Type $username into the email field"
|
- "Type $username into the email field"
|
||||||
- "Type $password into the password field"
|
- "Type $password into the password field"
|
||||||
@@ -445,9 +452,32 @@ npx @keygraph/shannon start -u https://example.com -r /path/to/repo -c ./my-app-
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
#### TOTP Setup for 2FA
|
#### Writing Login Flow
|
||||||
|
|
||||||
If your application uses two-factor authentication, simply add the TOTP secret to your config file. The AI will automatically generate the required codes during testing.
|
Log in once in a fresh incognito/private window. Write the steps in the same order you perform them:
|
||||||
|
- When you type into a field, reference the field by its exact label or placeholder.
|
||||||
|
- When you click a button, reference the exact button text.
|
||||||
|
|
||||||
|
Supported placeholders:
|
||||||
|
|
||||||
|
- `$username`
|
||||||
|
- `$password`
|
||||||
|
- `$totp`
|
||||||
|
- `$email_address`
|
||||||
|
- `$email_password`
|
||||||
|
- `$email_totp`
|
||||||
|
|
||||||
|
At runtime, Shannon replaces these placeholders with the credentials passed in the config.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
login_flow:
|
||||||
|
- "Type $username in <exact email field label or placeholder>"
|
||||||
|
- "Click <exact button text>"
|
||||||
|
- "Type $password in <exact password field label or placeholder>"
|
||||||
|
- "Click <exact button text>"
|
||||||
|
- "If prompted for 2FA, type $totp in <exact code field label or placeholder>"
|
||||||
|
- "Click <exact button text>"
|
||||||
|
```
|
||||||
|
|
||||||
#### Adaptive Thinking (Opus 4.6/4.7)
|
#### Adaptive Thinking (Opus 4.6/4.7)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ networks:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
temporal:
|
temporal:
|
||||||
image: temporalio/temporal:latest
|
image: temporalio/temporal:1.7.0
|
||||||
container_name: shannon-temporal
|
container_name: shannon-temporal
|
||||||
command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"]
|
command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"]
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -185,11 +185,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 account credentials for magic-link or OTP follow-through flows",
|
||||||
|
"properties": {
|
||||||
|
"address": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"description": "Email address used to receive magic links or OTPs"
|
||||||
|
},
|
||||||
|
"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 the email account's two-factor authentication (Base32 encoded)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["address", "password"],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["username", "password"],
|
"required": ["username"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"login_flow": {
|
"login_flow": {
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ authentication:
|
|||||||
password: "testpassword"
|
password: "testpassword"
|
||||||
totp_secret: "JBSWY3DPEHPK3PXP" # Optional TOTP secret for 2FA
|
totp_secret: "JBSWY3DPEHPK3PXP" # Optional TOTP secret for 2FA
|
||||||
|
|
||||||
|
# Optional mailbox credentials for magic-link / email-OTP flows.
|
||||||
|
# email_login:
|
||||||
|
# address: "inbox@example.com"
|
||||||
|
# password: "mailbox-password"
|
||||||
|
# totp_secret: "JBSWY3DPEHPK3PXP"
|
||||||
|
|
||||||
# Natural language instructions for login flow
|
# Natural language instructions for login flow
|
||||||
login_flow:
|
login_flow:
|
||||||
- "Type $username into the email field"
|
- "Type $username into the email field"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Return the structured verdict `{ "login_success": true }` and stop.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<role>
|
||||||
|
You are a credential validator agent. Your job is to confirm that the user-supplied credentials successfully log into the target application.
|
||||||
|
</role>
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
This runs as a preflight check for our AI pentester. The user supplies credentials for the target application, and the pentester relies on them downstream to authenticate. Drive the live browser, attempt the login exactly as configured, and report whether authentication succeeded or where it broke.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<target_authentication>
|
||||||
|
{{AUTH_CONTEXT}}
|
||||||
|
</target_authentication>
|
||||||
|
|
||||||
|
<cli_tools>
|
||||||
|
- **Browser Automation (playwright-cli skill):** Invoke the `playwright-cli` skill to learn available commands. Always pass `-s={{PLAYWRIGHT_SESSION}}` to every command for session isolation.
|
||||||
|
- **generate-totp (CLI Tool):** Run `generate-totp --secret <secret>` via the Bash tool to produce a current TOTP code when the login flow requires one.
|
||||||
|
</cli_tools>
|
||||||
|
|
||||||
|
<login_instructions>
|
||||||
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
|
</login_instructions>
|
||||||
|
|
||||||
|
<critical>
|
||||||
|
- Submit each field (username, password, captcha, TOTP) exactly once.
|
||||||
|
- Any rejection = auth error: return `login_success: false` and stop. Do not retry.
|
||||||
|
</critical>
|
||||||
@@ -177,7 +177,8 @@ export async function runClaudePrompt(
|
|||||||
sdkEnv.CLAUDE_CODE_USE_VERTEX = '1';
|
sdkEnv.CLAUDE_CODE_USE_VERTEX = '1';
|
||||||
if (providerConfig.gcpRegion) sdkEnv.CLOUD_ML_REGION = providerConfig.gcpRegion;
|
if (providerConfig.gcpRegion) sdkEnv.CLOUD_ML_REGION = providerConfig.gcpRegion;
|
||||||
if (providerConfig.gcpProjectId) sdkEnv.ANTHROPIC_VERTEX_PROJECT_ID = providerConfig.gcpProjectId;
|
if (providerConfig.gcpProjectId) sdkEnv.ANTHROPIC_VERTEX_PROJECT_ID = providerConfig.gcpProjectId;
|
||||||
if (providerConfig.gcpCredentialsPath) sdkEnv.GOOGLE_APPLICATION_CREDENTIALS = providerConfig.gcpCredentialsPath;
|
if (providerConfig.gcpCredentialsPath)
|
||||||
|
sdkEnv.GOOGLE_APPLICATION_CREDENTIALS = providerConfig.gcpCredentialsPath;
|
||||||
break;
|
break;
|
||||||
case 'litellm_router':
|
case 'litellm_router':
|
||||||
if (providerConfig.baseUrl) sdkEnv.ANTHROPIC_BASE_URL = providerConfig.baseUrl;
|
if (providerConfig.baseUrl) sdkEnv.ANTHROPIC_BASE_URL = providerConfig.baseUrl;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes <sourceDir>/.playwright/cli.config.json with stealth defaults so
|
||||||
|
* `playwright-cli open` auto-loads them from the agent's cwd. Skipped when a
|
||||||
|
* config already exists so user-provided files are never clobbered.
|
||||||
|
*
|
||||||
|
* NOTE: Playwright's MCP browser config treats `initScript` entries as file
|
||||||
|
* paths, not inline source. The stealth script is written alongside the config
|
||||||
|
* and referenced by absolute path. Inline strings silently fail the daemon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
async function pathExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEALTH_INIT_SCRIPT = `delete Object.getPrototypeOf(navigator).webdriver;
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => {
|
||||||
|
const arr = [
|
||||||
|
{ 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: '' },
|
||||||
|
];
|
||||||
|
arr.__proto__ = PluginArray.prototype;
|
||||||
|
return arr;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
window.chrome = window.chrome || {};
|
||||||
|
window.chrome.runtime = window.chrome.runtime || {
|
||||||
|
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
|
||||||
|
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||||
|
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||||
|
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
|
||||||
|
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
|
||||||
|
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
function buildStealthConfig(initScriptPath: string) {
|
||||||
|
return {
|
||||||
|
browser: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
launchOptions: {
|
||||||
|
headless: true,
|
||||||
|
args: ['--disable-blink-features=AutomationControlled'],
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
|
},
|
||||||
|
contextOptions: {
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
locale: 'en-US',
|
||||||
|
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
initScript: [initScriptPath],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StealthConfigWriteResult = 'wrote' | 'skipped-existing';
|
||||||
|
|
||||||
|
export async function writePlaywrightStealthConfig(
|
||||||
|
sourceDir: string,
|
||||||
|
): Promise<{ result: StealthConfigWriteResult; configPath: string }> {
|
||||||
|
const playwrightDir = path.join(sourceDir, '.playwright');
|
||||||
|
const configPath = path.join(playwrightDir, 'cli.config.json');
|
||||||
|
if (await pathExists(configPath)) {
|
||||||
|
return { result: 'skipped-existing', configPath };
|
||||||
|
}
|
||||||
|
const initScriptPath = path.join(playwrightDir, 'scripts', 'stealth.js');
|
||||||
|
await fs.mkdir(path.dirname(initScriptPath), { recursive: true });
|
||||||
|
await fs.writeFile(initScriptPath, STEALTH_INIT_SCRIPT);
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(buildStealthConfig(initScriptPath), null, 2));
|
||||||
|
return { result: 'wrote', configPath };
|
||||||
|
}
|
||||||
@@ -17,8 +17,7 @@ import type { AgentName } from '../types/agents.js';
|
|||||||
|
|
||||||
// === Common Fields ===
|
// === Common Fields ===
|
||||||
|
|
||||||
const ANALYSIS_NOTES_DESCRIPTION =
|
const ANALYSIS_NOTES_DESCRIPTION = 'Plain context for defenders (caveats, scope, what is at risk). Not attack steps.';
|
||||||
'Plain context for defenders (caveats, scope, what is at risk). Not attack steps.';
|
|
||||||
|
|
||||||
function notesField(exploit: boolean) {
|
function notesField(exploit: boolean) {
|
||||||
const f = z.string().optional();
|
const f = z.string().optional();
|
||||||
@@ -114,53 +113,83 @@ function toOutputFormat(zodSchema: z.ZodType): JsonSchemaOutputFormat {
|
|||||||
function buildOutputFormats(exploit: boolean): Partial<Record<AgentName, JsonSchemaOutputFormat>> {
|
function buildOutputFormats(exploit: boolean): Partial<Record<AgentName, JsonSchemaOutputFormat>> {
|
||||||
const base = makeBase(exploit);
|
const base = makeBase(exploit);
|
||||||
return {
|
return {
|
||||||
'injection-vuln': toOutputFormat(z.object({ vulnerabilities: z.array(base.extend({
|
'injection-vuln': toOutputFormat(
|
||||||
source: z.string().optional(),
|
z.object({
|
||||||
combined_sources: z.string().optional(),
|
vulnerabilities: z.array(
|
||||||
path: z.string().optional(),
|
base.extend({
|
||||||
sink_call: z.string().optional(),
|
source: z.string().optional(),
|
||||||
slot_type: z.string().optional(),
|
combined_sources: z.string().optional(),
|
||||||
sanitization_observed: z.string().optional(),
|
path: z.string().optional(),
|
||||||
concat_occurrences: z.string().optional(),
|
sink_call: z.string().optional(),
|
||||||
verdict: z.string().optional(),
|
slot_type: z.string().optional(),
|
||||||
mismatch_reason: z.string().optional(),
|
sanitization_observed: z.string().optional(),
|
||||||
witness_payload: z.string().optional(),
|
concat_occurrences: z.string().optional(),
|
||||||
})) })),
|
verdict: z.string().optional(),
|
||||||
'xss-vuln': toOutputFormat(z.object({ vulnerabilities: z.array(base.extend({
|
mismatch_reason: z.string().optional(),
|
||||||
source: z.string().optional(),
|
witness_payload: z.string().optional(),
|
||||||
source_detail: z.string().optional(),
|
}),
|
||||||
path: z.string().optional(),
|
),
|
||||||
sink_function: z.string().optional(),
|
}),
|
||||||
render_context: z.string().optional(),
|
),
|
||||||
encoding_observed: z.string().optional(),
|
'xss-vuln': toOutputFormat(
|
||||||
verdict: z.string().optional(),
|
z.object({
|
||||||
mismatch_reason: z.string().optional(),
|
vulnerabilities: z.array(
|
||||||
witness_payload: z.string().optional(),
|
base.extend({
|
||||||
})) })),
|
source: z.string().optional(),
|
||||||
'auth-vuln': toOutputFormat(z.object({ vulnerabilities: z.array(base.extend({
|
source_detail: z.string().optional(),
|
||||||
source_endpoint: z.string().optional(),
|
path: z.string().optional(),
|
||||||
vulnerable_code_location: z.string().optional(),
|
sink_function: z.string().optional(),
|
||||||
missing_defense: z.string().optional(),
|
render_context: z.string().optional(),
|
||||||
exploitation_hypothesis: z.string().optional(),
|
encoding_observed: z.string().optional(),
|
||||||
suggested_exploit_technique: z.string().optional(),
|
verdict: z.string().optional(),
|
||||||
})) })),
|
mismatch_reason: z.string().optional(),
|
||||||
'ssrf-vuln': toOutputFormat(z.object({ vulnerabilities: z.array(base.extend({
|
witness_payload: z.string().optional(),
|
||||||
source_endpoint: z.string().optional(),
|
}),
|
||||||
vulnerable_parameter: z.string().optional(),
|
),
|
||||||
vulnerable_code_location: z.string().optional(),
|
}),
|
||||||
missing_defense: z.string().optional(),
|
),
|
||||||
exploitation_hypothesis: z.string().optional(),
|
'auth-vuln': toOutputFormat(
|
||||||
suggested_exploit_technique: z.string().optional(),
|
z.object({
|
||||||
})) })),
|
vulnerabilities: z.array(
|
||||||
'authz-vuln': toOutputFormat(z.object({ vulnerabilities: z.array(base.extend({
|
base.extend({
|
||||||
endpoint: z.string().optional(),
|
source_endpoint: z.string().optional(),
|
||||||
vulnerable_code_location: z.string().optional(),
|
vulnerable_code_location: z.string().optional(),
|
||||||
role_context: z.string().optional(),
|
missing_defense: z.string().optional(),
|
||||||
guard_evidence: z.string().optional(),
|
exploitation_hypothesis: z.string().optional(),
|
||||||
side_effect: z.string().optional(),
|
suggested_exploit_technique: z.string().optional(),
|
||||||
reason: z.string().optional(),
|
}),
|
||||||
minimal_witness: z.string().optional(),
|
),
|
||||||
})) })),
|
}),
|
||||||
|
),
|
||||||
|
'ssrf-vuln': toOutputFormat(
|
||||||
|
z.object({
|
||||||
|
vulnerabilities: z.array(
|
||||||
|
base.extend({
|
||||||
|
source_endpoint: z.string().optional(),
|
||||||
|
vulnerable_parameter: z.string().optional(),
|
||||||
|
vulnerable_code_location: z.string().optional(),
|
||||||
|
missing_defense: z.string().optional(),
|
||||||
|
exploitation_hypothesis: z.string().optional(),
|
||||||
|
suggested_exploit_technique: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'authz-vuln': toOutputFormat(
|
||||||
|
z.object({
|
||||||
|
vulnerabilities: z.array(
|
||||||
|
base.extend({
|
||||||
|
endpoint: z.string().optional(),
|
||||||
|
vulnerable_code_location: z.string().optional(),
|
||||||
|
role_context: z.string().optional(),
|
||||||
|
guard_evidence: z.string().optional(),
|
||||||
|
side_effect: z.string().optional(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
minimal_witness: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -428,15 +428,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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,8 +700,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 && { 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: {
|
||||||
|
|||||||
@@ -30,22 +30,13 @@ export interface CheckpointProvider {
|
|||||||
* Return { skip: true, metrics } to skip the agent (e.g., output files already exist).
|
* Return { skip: true, metrics } to skip the agent (e.g., output files already exist).
|
||||||
* Return { skip: false } to run normally.
|
* Return { skip: false } to run normally.
|
||||||
*/
|
*/
|
||||||
shouldSkipAgent(
|
shouldSkipAgent(agentName: string, repoPath: string, deliverablesSubdir: string): Promise<SkipDecision>;
|
||||||
agentName: string,
|
|
||||||
repoPath: string,
|
|
||||||
deliverablesSubdir: string,
|
|
||||||
): Promise<SkipDecision>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after an agent activity succeeds.
|
* Called after an agent activity succeeds.
|
||||||
* Receives pipeline state and optional file context for artifact persistence.
|
* Receives pipeline state and optional file context for artifact persistence.
|
||||||
*/
|
*/
|
||||||
onAgentComplete(
|
onAgentComplete(agentName: string, phase: string, state: PipelineState, context?: CheckpointContext): Promise<void>;
|
||||||
agentName: string,
|
|
||||||
phase: string,
|
|
||||||
state: PipelineState,
|
|
||||||
context?: CheckpointContext,
|
|
||||||
): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default no-op implementation — no external checkpointing. */
|
/** Default no-op implementation — no external checkpointing. */
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import type { ActivityInput } from '../temporal/activities.js';
|
|||||||
import type { VulnType } from '../types/agents.js';
|
import type { VulnType } from '../types/agents.js';
|
||||||
|
|
||||||
export interface FindingsProvider {
|
export interface FindingsProvider {
|
||||||
mergeFindingsIntoQueue(
|
mergeFindingsIntoQueue(repoPath: string, vulnType: VulnType, input: ActivityInput): Promise<{ mergedCount: number }>;
|
||||||
repoPath: string,
|
|
||||||
vulnType: VulnType,
|
|
||||||
input: ActivityInput,
|
|
||||||
): Promise<{ mergedCount: number }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default no-op implementation — no external findings to merge. */
|
/** Default no-op implementation — no external findings to merge. */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Consumers can provide alternate implementations via the DI container.
|
* Consumers can provide alternate implementations via the DI container.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { CheckpointProvider, CheckpointContext, SkipDecision } from './checkpoint-provider.js';
|
export type { CheckpointContext, CheckpointProvider, SkipDecision } from './checkpoint-provider.js';
|
||||||
export { NoOpCheckpointProvider } from './checkpoint-provider.js';
|
export { NoOpCheckpointProvider } from './checkpoint-provider.js';
|
||||||
export type { FindingsProvider } from './findings-provider.js';
|
export type { FindingsProvider } from './findings-provider.js';
|
||||||
export { NoOpFindingsProvider } from './findings-provider.js';
|
export { NoOpFindingsProvider } from './findings-provider.js';
|
||||||
|
|||||||
@@ -95,7 +95,19 @@ export class AgentExecutionService {
|
|||||||
auditSession: AuditSession,
|
auditSession: AuditSession,
|
||||||
logger: ActivityLogger,
|
logger: ActivityLogger,
|
||||||
): Promise<Result<AgentEndResult, PentestError>> {
|
): Promise<Result<AgentEndResult, PentestError>> {
|
||||||
const { webUrl, repoPath, deliverablesPath, configPath, configData, configYAML, pipelineTestingMode = false, attemptNumber, apiKey, promptDir, providerConfig } = input;
|
const {
|
||||||
|
webUrl,
|
||||||
|
repoPath,
|
||||||
|
deliverablesPath,
|
||||||
|
configPath,
|
||||||
|
configData,
|
||||||
|
configYAML,
|
||||||
|
pipelineTestingMode = false,
|
||||||
|
attemptNumber,
|
||||||
|
apiKey,
|
||||||
|
promptDir,
|
||||||
|
providerConfig,
|
||||||
|
} = input;
|
||||||
|
|
||||||
// 1. Load config (pre-parsed configData → raw YAML → file path)
|
// 1. Load config (pre-parsed configData → raw YAML → file path)
|
||||||
const configResult = await this.configLoader.loadOptional(configPath, configData, configYAML);
|
const configResult = await this.configLoader.loadOptional(configPath, configData, configYAML);
|
||||||
@@ -108,7 +120,14 @@ export class AgentExecutionService {
|
|||||||
const promptTemplate = AGENTS[agentName].promptTemplate;
|
const promptTemplate = AGENTS[agentName].promptTemplate;
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
try {
|
try {
|
||||||
prompt = await loadPrompt(promptTemplate, { webUrl, repoPath }, distributedConfig, pipelineTestingMode, logger, promptDir);
|
prompt = await loadPrompt(
|
||||||
|
promptTemplate,
|
||||||
|
{ webUrl, repoPath },
|
||||||
|
distributedConfig,
|
||||||
|
pipelineTestingMode,
|
||||||
|
logger,
|
||||||
|
promptDir,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return err(
|
return err(
|
||||||
|
|||||||
@@ -81,7 +81,13 @@ export class ConfigLoaderService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return err(
|
return err(
|
||||||
new PentestError(`Failed to parse config YAML: ${errorMessage}`, 'config', false, { originalError: errorMessage }, ErrorCode.CONFIG_PARSE_ERROR),
|
new PentestError(
|
||||||
|
`Failed to parse config YAML: ${errorMessage}`,
|
||||||
|
'config',
|
||||||
|
false,
|
||||||
|
{ originalError: errorMessage },
|
||||||
|
ErrorCode.CONFIG_PARSE_ERROR,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,11 +99,7 @@ const DEFAULT_CONFIG: ContainerConfig = {
|
|||||||
* setContainerFactory() at worker startup to inject custom provider
|
* setContainerFactory() at worker startup to inject custom provider
|
||||||
* implementations into every container.
|
* implementations into every container.
|
||||||
*/
|
*/
|
||||||
type ContainerFactory = (
|
type ContainerFactory = (workflowId: string, sessionMetadata: SessionMetadata, config: ContainerConfig) => Container;
|
||||||
workflowId: string,
|
|
||||||
sessionMetadata: SessionMetadata,
|
|
||||||
config: ContainerConfig,
|
|
||||||
) => Container;
|
|
||||||
|
|
||||||
let containerFactory: ContainerFactory = (_workflowId, sessionMetadata, config) =>
|
let containerFactory: ContainerFactory = (_workflowId, sessionMetadata, config) =>
|
||||||
new Container({ sessionMetadata, config });
|
new Container({ sessionMetadata, config });
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fs, path } from 'zx';
|
import { fs, path } from 'zx';
|
||||||
import type {
|
import type { AuthFinding, AuthzFinding, InjectionFinding, SsrfFinding, XssFinding } from '../ai/queue-schemas.js';
|
||||||
AuthFinding,
|
|
||||||
AuthzFinding,
|
|
||||||
InjectionFinding,
|
|
||||||
SsrfFinding,
|
|
||||||
XssFinding,
|
|
||||||
} from '../ai/queue-schemas.js';
|
|
||||||
import { deliverablesDir } from '../paths.js';
|
import { deliverablesDir } from '../paths.js';
|
||||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||||
import type { VulnClass } from '../types/config.js';
|
import type { VulnClass } from '../types/config.js';
|
||||||
@@ -125,10 +119,7 @@ function renderInjectionEntry(e: InjectionFinding): string {
|
|||||||
return buildEntry(
|
return buildEntry(
|
||||||
e.ID,
|
e.ID,
|
||||||
e.vulnerability_type,
|
e.vulnerability_type,
|
||||||
[
|
[summaryRow('Vulnerable location', location), summaryRow('Overview', e.mismatch_reason)],
|
||||||
summaryRow('Vulnerable location', location),
|
|
||||||
summaryRow('Overview', e.mismatch_reason),
|
|
||||||
],
|
|
||||||
e.notes,
|
e.notes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,10 +129,7 @@ function renderXssEntry(e: XssFinding): string {
|
|||||||
return buildEntry(
|
return buildEntry(
|
||||||
e.ID,
|
e.ID,
|
||||||
e.vulnerability_type,
|
e.vulnerability_type,
|
||||||
[
|
[summaryRow('Vulnerable location', location), summaryRow('Overview', e.mismatch_reason)],
|
||||||
summaryRow('Vulnerable location', location),
|
|
||||||
summaryRow('Overview', e.mismatch_reason),
|
|
||||||
],
|
|
||||||
e.notes,
|
e.notes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,13 @@
|
|||||||
* Services are pure domain logic with no Temporal dependencies.
|
* Services are pure domain logic with no Temporal dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type { ClaudePromptResult } from '../ai/claude-executor.js';
|
||||||
|
export { runClaudePrompt } from '../ai/claude-executor.js';
|
||||||
export type { AgentExecutionInput } from './agent-execution.js';
|
export type { AgentExecutionInput } from './agent-execution.js';
|
||||||
export { AgentExecutionService } from './agent-execution.js';
|
export { AgentExecutionService } from './agent-execution.js';
|
||||||
|
|
||||||
export { ConfigLoaderService } from './config-loader.js';
|
export { ConfigLoaderService } from './config-loader.js';
|
||||||
export type { ContainerDependencies } from './container.js';
|
export type { ContainerDependencies } from './container.js';
|
||||||
export { Container, getContainer, getOrCreateContainer, removeContainer, setContainerFactory } from './container.js';
|
export { Container, getContainer, getOrCreateContainer, removeContainer, setContainerFactory } from './container.js';
|
||||||
export { ExploitationCheckerService } from './exploitation-checker.js';
|
export { ExploitationCheckerService } from './exploitation-checker.js';
|
||||||
export { loadPrompt } from './prompt-manager.js';
|
export { loadPrompt } from './prompt-manager.js';
|
||||||
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
|
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
|
||||||
export type { ClaudePromptResult } from '../ai/claude-executor.js';
|
|
||||||
export { runClaudePrompt } from '../ai/claude-executor.js';
|
|
||||||
|
|||||||
@@ -16,13 +16,15 @@
|
|||||||
* 2. Config file parses and validates (if provided)
|
* 2. Config file parses and validates (if provided)
|
||||||
* 3. code_path rules match real entries in the repo (filesystem only)
|
* 3. code_path rules match real entries in the repo (filesystem only)
|
||||||
* 4. Credentials validate via Claude Agent SDK query (API key, OAuth, Bedrock, or Vertex AI)
|
* 4. Credentials validate via Claude Agent SDK query (API key, OAuth, Bedrock, or Vertex AI)
|
||||||
* 5. Target URL is reachable from the container (DNS + HTTP)
|
* 5. Target URL resolves, is not link-local (cloud metadata), and is reachable (DNS + HTTP)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { LookupAddress } from 'node:dns';
|
||||||
import { lookup } from 'node:dns/promises';
|
import { lookup } from 'node:dns/promises';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
|
import net, { type LookupFunction } from 'node:net';
|
||||||
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { glob } from 'zx';
|
import { glob } from 'zx';
|
||||||
@@ -40,9 +42,47 @@ function isLoopbackAddress(address: string): boolean {
|
|||||||
return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0';
|
return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 169.254.0.0/16 hosts the cloud metadata service. RFC1918 and loopback are
|
||||||
|
// intentionally allowed — scanning local targets is a supported Shannon use case.
|
||||||
|
const metadataBlockList = new net.BlockList();
|
||||||
|
metadataBlockList.addSubnet('169.254.0.0', 16, 'ipv4');
|
||||||
|
|
||||||
|
function isBlockedAddress(address: string): boolean {
|
||||||
|
switch (net.isIP(address)) {
|
||||||
|
case 4:
|
||||||
|
return metadataBlockList.check(address, 'ipv4');
|
||||||
|
case 6:
|
||||||
|
return metadataBlockList.check(address, 'ipv6');
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DNS lookup pinned to already-validated `addresses`, so the socket cannot be re-pointed after validation (DNS rebinding). */
|
||||||
|
function pinnedLookup(addresses: LookupAddress[]): LookupFunction {
|
||||||
|
return (hostname, options, callback) => {
|
||||||
|
const matching = options.family ? addresses.filter((a) => a.family === options.family) : addresses;
|
||||||
|
const pool = matching.length > 0 ? matching : addresses;
|
||||||
|
if (options.all) {
|
||||||
|
callback(null, pool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = pool[0];
|
||||||
|
if (!first) {
|
||||||
|
callback(new Error(`no resolved address for ${hostname}`), '', 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, first.address, first.family);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// === Repository Validation ===
|
// === Repository Validation ===
|
||||||
|
|
||||||
async function validateRepo(repoPath: string, logger: ActivityLogger, skipGitCheck?: boolean): Promise<Result<void, PentestError>> {
|
async function validateRepo(
|
||||||
|
repoPath: string,
|
||||||
|
logger: ActivityLogger,
|
||||||
|
skipGitCheck?: boolean,
|
||||||
|
): Promise<Result<void, PentestError>> {
|
||||||
logger.info('Checking repository path...', { repoPath });
|
logger.info('Checking repository path...', { repoPath });
|
||||||
|
|
||||||
// 1. Check repo directory exists
|
// 1. Check repo directory exists
|
||||||
@@ -254,11 +294,17 @@ function classifySdkError(sdkError: SDKAssistantMessageError, authType: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Validate credentials via a minimal Claude Agent SDK query. */
|
/** Validate credentials via a minimal Claude Agent SDK query. */
|
||||||
async function validateCredentials(logger: ActivityLogger, apiKey?: string, providerConfig?: import('../types/config.js').ProviderConfig): Promise<Result<void, PentestError>> {
|
async function validateCredentials(
|
||||||
|
logger: ActivityLogger,
|
||||||
|
apiKey?: string,
|
||||||
|
providerConfig?: import('../types/config.js').ProviderConfig,
|
||||||
|
): Promise<Result<void, PentestError>> {
|
||||||
// 0. If providerConfig is present, credentials are managed by the caller.
|
// 0. If providerConfig is present, credentials are managed by the caller.
|
||||||
// The executor will map providerConfig directly to sdkEnv — no process.env needed.
|
// The executor will map providerConfig directly to sdkEnv — no process.env needed.
|
||||||
if (providerConfig) {
|
if (providerConfig) {
|
||||||
logger.info(`Provider config present (type: ${providerConfig.providerType || 'anthropic_api'}) — skipping env-based credential validation`);
|
logger.info(
|
||||||
|
`Provider config present (type: ${providerConfig.providerType || 'anthropic_api'}) — skipping env-based credential validation`,
|
||||||
|
);
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +470,7 @@ async function validateCredentials(logger: ActivityLogger, apiKey?: string, prov
|
|||||||
// === Target URL Validation ===
|
// === Target URL Validation ===
|
||||||
|
|
||||||
/** HTTP HEAD with TLS verification disabled — we check reachability, not certificate validity. */
|
/** HTTP HEAD with TLS verification disabled — we check reachability, not certificate validity. */
|
||||||
function httpHead(url: string, timeoutMs: number): Promise<number> {
|
function httpHead(url: string, timeoutMs: number, addresses: LookupAddress[]): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const isHttps = parsed.protocol === 'https:';
|
const isHttps = parsed.protocol === 'https:';
|
||||||
@@ -435,6 +481,7 @@ function httpHead(url: string, timeoutMs: number): Promise<number> {
|
|||||||
{
|
{
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
|
lookup: pinnedLookup(addresses),
|
||||||
...(isHttps && { rejectUnauthorized: false }),
|
...(isHttps && { rejectUnauthorized: false }),
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
@@ -472,12 +519,11 @@ async function validateTargetUrl(targetUrl: string, logger: ActivityLogger): Pro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. DNS lookup — detect loopback addresses early for a better hint
|
// 2. Resolve all records once — reused (pinned) for the connection below.
|
||||||
const hostname = parsed.hostname;
|
const hostname = parsed.hostname;
|
||||||
let resolvedAddress: string | undefined;
|
let addresses: LookupAddress[];
|
||||||
try {
|
try {
|
||||||
const result = await lookup(hostname);
|
addresses = await lookup(hostname, { all: true });
|
||||||
resolvedAddress = result.address;
|
|
||||||
} catch {
|
} catch {
|
||||||
return err(
|
return err(
|
||||||
new PentestError(
|
new PentestError(
|
||||||
@@ -490,25 +536,40 @@ async function validateTargetUrl(targetUrl: string, logger: ActivityLogger): Pro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. HTTP reachability check
|
// 3. Reject the link-local metadata range (169.254.0.0/16).
|
||||||
|
const blocked = addresses.find((entry) => isBlockedAddress(entry.address));
|
||||||
|
if (blocked) {
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Target URL ${targetUrl} resolves to ${blocked.address}, a link-local address ` +
|
||||||
|
`(169.254.0.0/16). This range hosts the cloud instance metadata service and cannot be scanned.`,
|
||||||
|
'config',
|
||||||
|
false,
|
||||||
|
{ targetUrl, hostname, address: blocked.address },
|
||||||
|
ErrorCode.TARGET_UNREACHABLE,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. HTTP reachability check (socket pinned to the resolved addresses).
|
||||||
try {
|
try {
|
||||||
await httpHead(targetUrl, TARGET_URL_TIMEOUT_MS);
|
await httpHead(targetUrl, TARGET_URL_TIMEOUT_MS, addresses);
|
||||||
|
|
||||||
logger.info('Target URL OK');
|
logger.info('Target URL OK');
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isLoopback = isLoopbackAddress(resolvedAddress);
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
const isLoopback = addresses.some((entry) => isLoopbackAddress(entry.address));
|
||||||
|
|
||||||
if (isLoopback) {
|
if (isLoopback) {
|
||||||
const suggestion = targetUrl.replace(hostname, 'host.docker.internal');
|
const suggestion = targetUrl.replace(hostname, 'host.docker.internal');
|
||||||
return err(
|
return err(
|
||||||
new PentestError(
|
new PentestError(
|
||||||
`Target URL ${targetUrl} resolves to ${resolvedAddress} (loopback) and is not reachable. ` +
|
`Target URL ${targetUrl} resolves to a loopback address and is not reachable. ` +
|
||||||
`For local services, use host.docker.internal instead of ${hostname} (e.g., ${suggestion})`,
|
`For local services, use host.docker.internal instead of ${hostname} (e.g., ${suggestion})`,
|
||||||
'network',
|
'network',
|
||||||
false,
|
false,
|
||||||
{ targetUrl, resolvedAddress, hostname },
|
{ targetUrl, hostname },
|
||||||
ErrorCode.TARGET_UNREACHABLE,
|
ErrorCode.TARGET_UNREACHABLE,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -519,7 +580,7 @@ async function validateTargetUrl(targetUrl: string, logger: ActivityLogger): Pro
|
|||||||
`Target URL ${targetUrl} is not reachable: ${detail}`,
|
`Target URL ${targetUrl} is not reachable: ${detail}`,
|
||||||
'network',
|
'network',
|
||||||
false,
|
false,
|
||||||
{ targetUrl, resolvedAddress },
|
{ targetUrl },
|
||||||
ErrorCode.TARGET_UNREACHABLE,
|
ErrorCode.TARGET_UNREACHABLE,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -180,6 +180,21 @@ 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?.address) {
|
||||||
|
userInstructions = userInstructions.replace(/\$email_address/g, authentication.credentials.email_login.address);
|
||||||
|
}
|
||||||
|
if (authentication.credentials.email_login?.password) {
|
||||||
|
userInstructions = userInstructions.replace(
|
||||||
|
/\$email_password/g,
|
||||||
|
authentication.credentials.email_login.password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (authentication.credentials.email_login?.totp_secret) {
|
||||||
|
userInstructions = userInstructions.replace(
|
||||||
|
/\$email_totp/g,
|
||||||
|
`generated TOTP code using secret "${authentication.credentials.email_login.totp_secret}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions);
|
loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions);
|
||||||
@@ -352,6 +367,14 @@ async function interpolateVariables(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve promptDir override against SHANNON_WORKER_ROOT so relative paths
|
||||||
|
// from callers stay cwd-independent.
|
||||||
|
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,
|
||||||
@@ -362,8 +385,7 @@ export async function loadPrompt(
|
|||||||
promptDir?: string,
|
promptDir?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// 1. Resolve prompt file path (promptDir override → default PROMPTS_DIR)
|
const basePromptsDir = resolvePromptDir(promptDir);
|
||||||
const basePromptsDir = promptDir ?? PROMPTS_DIR;
|
|
||||||
const promptsDir = pipelineTestingMode ? path.join(basePromptsDir, 'pipeline-testing') : basePromptsDir;
|
const promptsDir = pipelineTestingMode ? path.join(basePromptsDir, 'pipeline-testing') : basePromptsDir;
|
||||||
const promptPath = path.join(promptsDir, `${promptName}.txt`);
|
const promptPath = path.join(promptsDir, `${promptName}.txt`);
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ export async function injectModelIntoReport(
|
|||||||
logger.info(`Injecting model info into report: ${modelStr}`);
|
logger.info(`Injecting model info into report: ${modelStr}`);
|
||||||
|
|
||||||
// 3. Read the final report
|
// 3. Read the final report
|
||||||
const reportPath = path.join(deliverablesDir(repoPath, deliverablesSubdir), 'comprehensive_security_assessment_report.md');
|
const reportPath = path.join(
|
||||||
|
deliverablesDir(repoPath, deliverablesSubdir),
|
||||||
|
'comprehensive_security_assessment_report.md',
|
||||||
|
);
|
||||||
|
|
||||||
if (!(await fs.pathExists(reportPath))) {
|
if (!(await fs.pathExists(reportPath))) {
|
||||||
logger.warn('Final report not found, skipping model injection');
|
logger.warn('Final report not found, skipping model injection');
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication validation service.
|
||||||
|
*
|
||||||
|
* Drives a real browser via the playwright-cli skill to confirm
|
||||||
|
* user-supplied credentials log in successfully, before the pentest
|
||||||
|
* pipeline burns hours on broken auth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { AgentEndResult } from '../types/audit.js';
|
||||||
|
import type { DistributedConfig, ProviderConfig } from '../types/config.js';
|
||||||
|
import { ErrorCode } from '../types/errors.js';
|
||||||
|
import { err, ok, type Result } from '../types/result.js';
|
||||||
|
import { PentestError } from './error-handling.js';
|
||||||
|
import { loadPrompt } from './prompt-manager.js';
|
||||||
|
|
||||||
|
const FAILURE_POINTS = ['username_or_password', 'totp_secret', 'out_of_band'] as const;
|
||||||
|
type AuthFailurePoint = (typeof FAILURE_POINTS)[number];
|
||||||
|
|
||||||
|
function isAuthFailurePoint(v: unknown): v is AuthFailurePoint {
|
||||||
|
return typeof v === 'string' && (FAILURE_POINTS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: SDK's AJV validator expects draft-07; Zod defaults to draft-2020-12,
|
||||||
|
// which causes the SDK to silently skip structured output.
|
||||||
|
const AuthValidationSchema = z.object({
|
||||||
|
login_success: z.boolean(),
|
||||||
|
failure_point: z.enum(FAILURE_POINTS).optional(),
|
||||||
|
failure_detail: z
|
||||||
|
.string()
|
||||||
|
.max(250)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Free-form 1-2 sentence diagnostic of what the page showed (error messages, page state) when login failed. Required when login_success is false. Mask any sensitive values.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AuthValidationVerdict = z.infer<typeof AuthValidationSchema>;
|
||||||
|
|
||||||
|
const VALIDATION_SCHEMA: JsonSchemaOutputFormat = {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: z.toJSONSchema(AuthValidationSchema, { target: 'draft-07' }) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_NAME = 'validate-authentication';
|
||||||
|
|
||||||
|
export interface ValidateAuthInput {
|
||||||
|
readonly distributedConfig: DistributedConfig;
|
||||||
|
readonly repoPath: string;
|
||||||
|
readonly webUrl: string;
|
||||||
|
readonly logger: ActivityLogger;
|
||||||
|
readonly auditSession: AuditSession;
|
||||||
|
readonly attemptNumber: number;
|
||||||
|
readonly apiKey?: string;
|
||||||
|
readonly providerConfig?: ProviderConfig;
|
||||||
|
readonly deliverablesSubdir?: string;
|
||||||
|
readonly promptDir?: string;
|
||||||
|
readonly pipelineTestingMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateAuthentication(input: ValidateAuthInput): Promise<Result<void, PentestError>> {
|
||||||
|
const {
|
||||||
|
distributedConfig,
|
||||||
|
repoPath,
|
||||||
|
webUrl,
|
||||||
|
logger,
|
||||||
|
auditSession,
|
||||||
|
attemptNumber,
|
||||||
|
apiKey,
|
||||||
|
providerConfig,
|
||||||
|
deliverablesSubdir,
|
||||||
|
promptDir,
|
||||||
|
pipelineTestingMode,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const authentication = distributedConfig.authentication;
|
||||||
|
if (!authentication) {
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Validating authentication credentials with live browser...', {
|
||||||
|
loginUrl: authentication.login_url,
|
||||||
|
loginType: authentication.login_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = await loadPrompt(
|
||||||
|
AGENT_NAME,
|
||||||
|
{ webUrl, repoPath },
|
||||||
|
distributedConfig,
|
||||||
|
pipelineTestingMode ?? false,
|
||||||
|
logger,
|
||||||
|
promptDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditSession.startAgent(AGENT_NAME, prompt, attemptNumber);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const result = await runClaudePrompt(
|
||||||
|
prompt,
|
||||||
|
repoPath,
|
||||||
|
'',
|
||||||
|
'Authentication validation',
|
||||||
|
AGENT_NAME,
|
||||||
|
auditSession,
|
||||||
|
logger,
|
||||||
|
'medium',
|
||||||
|
VALIDATION_SCHEMA,
|
||||||
|
apiKey,
|
||||||
|
deliverablesSubdir,
|
||||||
|
providerConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
const classification = classifyResult(result, authentication);
|
||||||
|
|
||||||
|
const endResult: AgentEndResult = {
|
||||||
|
attemptNumber,
|
||||||
|
duration_ms: Date.now() - startTime,
|
||||||
|
cost_usd: result.cost || 0,
|
||||||
|
success: classification.ok,
|
||||||
|
...(result.model !== undefined && { model: result.model }),
|
||||||
|
...(!classification.ok && { error: classification.error.message }),
|
||||||
|
};
|
||||||
|
await auditSession.endAgent(AGENT_NAME, endResult);
|
||||||
|
|
||||||
|
return classification;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyResult(
|
||||||
|
result: import('../ai/claude-executor.js').ClaudePromptResult,
|
||||||
|
authentication: NonNullable<DistributedConfig['authentication']>,
|
||||||
|
): Result<void, PentestError> {
|
||||||
|
if (!result.success) {
|
||||||
|
const detail = result.error ?? 'Validator agent terminated unexpectedly.';
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Authentication validator failed to run: ${detail}`,
|
||||||
|
'validation',
|
||||||
|
result.retryable ?? true,
|
||||||
|
{ originalError: detail, errorType: result.errorType, cost: result.cost },
|
||||||
|
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.structuredOutput || typeof result.structuredOutput !== 'object') {
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
'Authentication validator did not return a structured verdict.',
|
||||||
|
'validation',
|
||||||
|
true,
|
||||||
|
{ cost: result.cost },
|
||||||
|
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = result.structuredOutput as Partial<AuthValidationVerdict>;
|
||||||
|
|
||||||
|
if (verdict.login_success === true) {
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failurePoint: AuthFailurePoint = isAuthFailurePoint(verdict.failure_point)
|
||||||
|
? verdict.failure_point
|
||||||
|
: 'out_of_band';
|
||||||
|
const failureDetail =
|
||||||
|
verdict.failure_detail?.trim() || 'Login failed without a specific diagnostic from the validator agent.';
|
||||||
|
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Authentication failed at "${failurePoint}": ${failureDetail}`,
|
||||||
|
'config',
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
failurePoint,
|
||||||
|
failureDetail,
|
||||||
|
loginUrl: authentication.login_url,
|
||||||
|
loginType: authentication.login_type,
|
||||||
|
cost: result.cost,
|
||||||
|
},
|
||||||
|
ErrorCode.AUTH_LOGIN_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +18,7 @@
|
|||||||
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 { writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js';
|
||||||
import { writeUserSettingsForCodePathAvoids } from '../ai/settings-writer.js';
|
import { writeUserSettingsForCodePathAvoids } from '../ai/settings-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';
|
||||||
@@ -28,11 +29,12 @@ import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js';
|
|||||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||||
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
|
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
|
||||||
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
||||||
|
import { renderFindingsFromQueues } from '../services/findings-renderer.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 type { ExploitationDecision, VulnType } from '../services/queue-validation.js';
|
import type { ExploitationDecision, VulnType } from '../services/queue-validation.js';
|
||||||
import { renderFindingsFromQueues } from '../services/findings-renderer.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';
|
||||||
@@ -184,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: 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,
|
||||||
@@ -375,6 +373,95 @@ export async function runPreflightValidation(input: ActivityInput): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication validation activity. No-ops without an authentication
|
||||||
|
* block; otherwise surfaces a classified failure (failurePoint +
|
||||||
|
* failureDetail in ApplicationFailure.details) on credential rejection.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
const sessionMetadata = buildSessionMetadata(input);
|
||||||
|
const container = getOrCreateContainer(input.workflowId, sessionMetadata, buildContainerConfig(input));
|
||||||
|
const configResult = await container.configLoader.loadOptional(input.configPath, undefined, input.configYAML);
|
||||||
|
if (isErr(configResult)) {
|
||||||
|
// runPreflightValidation already validated parsing, so this is unexpected.
|
||||||
|
logger.warn(`runAuthenticationValidation: config load failed unexpectedly: ${configResult.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributedConfig = configResult.value;
|
||||||
|
if (!distributedConfig?.authentication) {
|
||||||
|
logger.info('No authentication configured — skipping credential validation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditSession = new AuditSession(sessionMetadata);
|
||||||
|
await auditSession.initialize(input.workflowId);
|
||||||
|
|
||||||
|
const result = await validateAuthentication({
|
||||||
|
distributedConfig,
|
||||||
|
repoPath: input.repoPath,
|
||||||
|
webUrl: input.webUrl,
|
||||||
|
logger,
|
||||||
|
auditSession,
|
||||||
|
attemptNumber,
|
||||||
|
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||||||
|
...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }),
|
||||||
|
...(input.deliverablesSubdir !== undefined && { deliverablesSubdir: input.deliverablesSubdir }),
|
||||||
|
...(input.promptDir !== undefined && { promptDir: input.promptDir }),
|
||||||
|
...(input.pipelineTestingMode !== undefined && { pipelineTestingMode: input.pipelineTestingMode }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isErr(result)) {
|
||||||
|
const classified = classifyErrorForTemporal(result.error);
|
||||||
|
const message = truncateErrorMessage(result.error.message);
|
||||||
|
const ctx = result.error.context;
|
||||||
|
const details = [
|
||||||
|
{
|
||||||
|
phase: 'auth-validation',
|
||||||
|
attemptNumber,
|
||||||
|
elapsed: Date.now() - startTime,
|
||||||
|
...(ctx.failurePoint !== undefined && { failurePoint: ctx.failurePoint }),
|
||||||
|
...(ctx.failureDetail !== undefined && { failureDetail: ctx.failureDetail }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const failure = classified.retryable
|
||||||
|
? ApplicationFailure.create({ message, type: classified.type, details })
|
||||||
|
: ApplicationFailure.nonRetryable(message, classified.type, details);
|
||||||
|
truncateStackTrace(failure);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
} 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 details = [{ phase: 'auth-validation', attemptNumber, elapsed: Date.now() - startTime }];
|
||||||
|
|
||||||
|
const failure = classified.retryable
|
||||||
|
? ApplicationFailure.create({ message, type: classified.type, details })
|
||||||
|
: ApplicationFailure.nonRetryable(message, classified.type, details);
|
||||||
|
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).
|
||||||
@@ -400,6 +487,24 @@ export async function initDeliverableGit(input: ActivityInput): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a stealth cli.config.json into the repo's .playwright/ directory so
|
||||||
|
* `playwright-cli open` auto-loads anti-detection defaults from the agent's
|
||||||
|
* cwd (disables the Blink AutomationControlled flag, drops the
|
||||||
|
* --enable-automation default, and overrides the HeadlessChrome user agent).
|
||||||
|
*
|
||||||
|
* No-op when the repo already has its own .playwright/cli.config.json.
|
||||||
|
*/
|
||||||
|
export async function syncPlaywrightStealthConfig(input: ActivityInput): Promise<void> {
|
||||||
|
const logger = createActivityLogger();
|
||||||
|
const { result, configPath } = await writePlaywrightStealthConfig(input.repoPath);
|
||||||
|
if (result === 'skipped-existing') {
|
||||||
|
logger.info(`Playwright stealth config: leaving existing ${configPath} in place`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Playwright stealth config: wrote ${configPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync code_path avoid rules into Claude's user-scope settings.json so the
|
* Sync code_path avoid rules into Claude's user-scope settings.json so the
|
||||||
* SDK enforces them at the tool layer for every agent in this run.
|
* SDK enforces them at the tool layer for every agent in this run.
|
||||||
@@ -879,17 +984,7 @@ export async function generateReportOutputActivity(input: ActivityInput): Promis
|
|||||||
|
|
||||||
const logger = createActivityLogger();
|
const logger = createActivityLogger();
|
||||||
|
|
||||||
// Resolve promptDir against the worker root so providers are cwd-independent.
|
const result = await container.reportOutputProvider.generate(input, logger);
|
||||||
const resolvedInput: ActivityInput = {
|
|
||||||
...input,
|
|
||||||
...(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);
|
|
||||||
if (result.outputPath) {
|
if (result.outputPath) {
|
||||||
logger.info(`Report output written to ${result.outputPath}`);
|
logger.info(`Report output written to ${result.outputPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* within their own workflow context.
|
* within their own workflow context.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { pentestPipeline } from './workflows.js';
|
export type { ActivityInput } from './activities.js';
|
||||||
export type {
|
export type {
|
||||||
AgentMetrics,
|
AgentMetrics,
|
||||||
PipelineInput,
|
PipelineInput,
|
||||||
@@ -14,4 +14,4 @@ export type {
|
|||||||
ResumeState,
|
ResumeState,
|
||||||
VulnExploitPipelineResult,
|
VulnExploitPipelineResult,
|
||||||
} from './shared.js';
|
} from './shared.js';
|
||||||
export type { ActivityInput } from './activities.js';
|
export { pentestPipeline } from './workflows.js';
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const PRODUCTION_RETRY = {
|
|||||||
'ConfigurationError',
|
'ConfigurationError',
|
||||||
'InvalidTargetError',
|
'InvalidTargetError',
|
||||||
'ExecutionLimitError',
|
'ExecutionLimitError',
|
||||||
|
'AuthLoginFailedError',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,6 +135,22 @@ const preflightActs = proxyActivities<typeof activities>({
|
|||||||
retry: PREFLIGHT_RETRY,
|
retry: PREFLIGHT_RETRY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Credential rejection is not retryable; transient SDK errors get 3 attempts.
|
||||||
|
const AUTH_VALIDATION_RETRY = {
|
||||||
|
initialInterval: '10 seconds',
|
||||||
|
maximumInterval: '1 minute',
|
||||||
|
backoffCoefficient: 2,
|
||||||
|
maximumAttempts: 3,
|
||||||
|
nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Browser-driving validation measured at 60–180s; 10 min start-to-close leaves headroom for slow SSO/MFA flows.
|
||||||
|
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.
|
||||||
@@ -420,6 +437,18 @@ 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 ===
|
||||||
|
// Write the playwright-cli config before any browser session opens so the
|
||||||
|
// validator and downstream agents inherit anti-detection defaults.
|
||||||
|
await preflightActs.syncPlaywrightStealthConfig(activityInput);
|
||||||
|
|
||||||
|
// === Authentication Validation ===
|
||||||
|
state.currentPhase = 'auth-validation';
|
||||||
|
state.currentAgent = 'validate-authentication';
|
||||||
|
await authValidationActs.runAuthenticationValidation(activityInput);
|
||||||
|
state.currentAgent = null;
|
||||||
|
log.info('Authentication validation passed');
|
||||||
|
|
||||||
// === Initialize Deliverables Git ===
|
// === Initialize Deliverables Git ===
|
||||||
await a.initDeliverableGit(activityInput);
|
await a.initDeliverableGit(activityInput);
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,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;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export enum ErrorCode {
|
|||||||
REPO_NOT_FOUND = 'REPO_NOT_FOUND',
|
REPO_NOT_FOUND = 'REPO_NOT_FOUND',
|
||||||
TARGET_UNREACHABLE = 'TARGET_UNREACHABLE',
|
TARGET_UNREACHABLE = 'TARGET_UNREACHABLE',
|
||||||
AUTH_FAILED = 'AUTH_FAILED',
|
AUTH_FAILED = 'AUTH_FAILED',
|
||||||
|
AUTH_LOGIN_FAILED = 'AUTH_LOGIN_FAILED',
|
||||||
BILLING_ERROR = 'BILLING_ERROR',
|
BILLING_ERROR = 'BILLING_ERROR',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ networks:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
temporal:
|
temporal:
|
||||||
image: temporalio/temporal:latest
|
image: temporalio/temporal:1.7.0
|
||||||
container_name: shannon-temporal
|
container_name: shannon-temporal
|
||||||
command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"]
|
command: ["server", "start-dev", "--db-filename", "/home/temporal/temporal.db", "--ip", "0.0.0.0"]
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user