mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-01 19:15:41 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f59f30f6 | |||
| 7813baf16a | |||
| 8f5d639f0d | |||
| 32c01a39b1 | |||
| 72c424f687 | |||
| 1af42339b9 |
@@ -7,6 +7,9 @@ CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000
|
|||||||
# Adaptive thinking is enabled automatically on Opus 4.6/4.7. Set to false to disable.
|
# Adaptive thinking is enabled automatically on Opus 4.6/4.7. Set to false to disable.
|
||||||
# CLAUDE_ADAPTIVE_THINKING=false
|
# CLAUDE_ADAPTIVE_THINKING=false
|
||||||
|
|
||||||
|
# Shannon forwards your machine's /etc/hosts entries into the worker container. Set to false to disable.
|
||||||
|
# SHANNON_FORWARD_HOSTS=false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# OPTION 1: Direct Anthropic
|
# OPTION 1: Direct Anthropic
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ Infra (Temporal) runs via `docker-compose.yml`. Workers are ephemeral `docker ru
|
|||||||
- `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233). Network: `shannon-net`
|
- `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233). Network: `shannon-net`
|
||||||
- `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Uses pnpm. Entrypoint: `CMD ["node", "apps/worker/dist/temporal/worker.js"]`
|
- `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Uses pnpm. Entrypoint: `CMD ["node", "apps/worker/dist/temporal/worker.js"]`
|
||||||
- No `docker-compose.docker.yml` — host gateway handled via `--add-host` flag in CLI
|
- No `docker-compose.docker.yml` — host gateway handled via `--add-host` flag in CLI
|
||||||
|
- `/etc/hosts` forwarding — at worker spawn, `forwardEtcHostsFlags` in `apps/cli/src/docker.ts` reads the host's `/etc/hosts` and emits one `--add-host` flag per valid user-added entry. Loopback IPs (`127.x`, `::1`) are rewritten to `host-gateway`; IPv6 addresses are bracketed. Disable per-scan via `SHANNON_FORWARD_HOSTS=false`. No-op on Windows native (WSL2 reads its own `/etc/hosts` via the Linux path).
|
||||||
|
|
||||||
### Worker Package (`apps/worker/`)
|
### Worker Package (`apps/worker/`)
|
||||||
- `apps/worker/src/paths.ts` — Centralized path constants (`PROMPTS_DIR`, `CONFIGS_DIR`, `WORKSPACES_DIR`)
|
- `apps/worker/src/paths.ts` — Centralized path constants (`PROMPTS_DIR`, `CONFIGS_DIR`, `WORKSPACES_DIR`)
|
||||||
@@ -146,7 +147,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig
|
|||||||
### Supporting Systems
|
### Supporting Systems
|
||||||
- **Configuration** — YAML configs in `apps/worker/configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings (MFA/TOTP), URL/code rule scoping (`rules.avoid`/`rules.focus`), run-scope steering (`vuln_classes`, `exploit`), free-form `rules_of_engagement`, and post-hoc `report` filters (`min_severity`, `min_confidence`, `guidance`). `code_path` avoid rules are written into `~/.claude/settings.json` `permissions.deny` (`Read`/`Edit`) once per workflow by `apps/worker/src/temporal/activities.ts:syncCodePathDenyRules` so the SDK enforces them at the tool layer even in `bypassPermissions` mode. `vuln_classes`/`exploit` scope is locked into `session.json` on first run; resumes with a different scope fail fast (`persistOrValidateRunScope`). Credential resolution — local mode: env vars → `./.env`; npx mode: env vars → `~/.shannon/config.toml` (via `shn setup`)
|
- **Configuration** — YAML configs in `apps/worker/configs/` with JSON Schema validation (`config-schema.json`). Supports auth settings (MFA/TOTP), URL/code rule scoping (`rules.avoid`/`rules.focus`), run-scope steering (`vuln_classes`, `exploit`), free-form `rules_of_engagement`, and post-hoc `report` filters (`min_severity`, `min_confidence`, `guidance`). `code_path` avoid rules are written into `~/.claude/settings.json` `permissions.deny` (`Read`/`Edit`) once per workflow by `apps/worker/src/temporal/activities.ts:syncCodePathDenyRules` so the SDK enforces them at the tool layer even in `bypassPermissions` mode. `vuln_classes`/`exploit` scope is locked into `session.json` on first run; resumes with a different scope fail fast (`persistOrValidateRunScope`). Credential resolution — local mode: env vars → `./.env`; npx mode: env vars → `~/.shannon/config.toml` (via `shn setup`)
|
||||||
- **Prompts** — Per-phase templates in `apps/worker/prompts/` with variable substitution (`{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`). Shared partials in `apps/worker/prompts/shared/` via `apps/worker/src/services/prompt-manager.ts`, including `_code-path-rules.txt` (focus/avoid `[FILE]`/`[GLOB]` routing) and `_rules-of-engagement.txt` (free-text engagement rules). When `exploit: false`, `apps/worker/src/services/findings-renderer.ts` deterministically converts each `*_exploitation_queue.json` into a `*_findings.md` for report assembly — no LLM in the loop
|
- **Prompts** — Per-phase templates in `apps/worker/prompts/` with variable substitution (`{{TARGET_URL}}`, `{{CONFIG_CONTEXT}}`). Shared partials in `apps/worker/prompts/shared/` via `apps/worker/src/services/prompt-manager.ts`, including `_code-path-rules.txt` (focus/avoid `[FILE]`/`[GLOB]` routing) and `_rules-of-engagement.txt` (free-text engagement rules). When `exploit: false`, `apps/worker/src/services/findings-renderer.ts` deterministically converts each `*_exploitation_queue.json` into a `*_findings.md` for report assembly — no LLM in the loop
|
||||||
- **SDK Integration** — Uses `@anthropic-ai/claude-agent-sdk` with `maxTurns: 10_000` and `bypassPermissions` mode. Adaptive thinking is enabled by default on Opus 4.6/4.7 (`supportsAdaptiveThinking` in `apps/worker/src/ai/models.ts`); disable per-scan via `CLAUDE_ADAPTIVE_THINKING=false` (env) or `core.adaptive_thinking = false` (npx TOML). Browser automation via `playwright-cli` with session isolation (`-s=<session>`). TOTP generation via `generate-totp` CLI tool. Login flow template at `apps/worker/prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth
|
- **SDK Integration** — Uses `@anthropic-ai/claude-agent-sdk` with `maxTurns: 10_000` and `bypassPermissions` mode. Adaptive thinking is enabled by default on Opus 4.6/4.7 (`supportsAdaptiveThinking` in `apps/worker/src/ai/models.ts`); disable per-scan via `CLAUDE_ADAPTIVE_THINKING=false` (env) or `core.adaptive_thinking = false` (npx TOML). Browser automation via `playwright-cli` with session isolation (`-s=<session>`). TOTP generation via `generate-totp` CLI tool. Login flow template at `apps/worker/prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth. On authenticated whitebox scans, the `validate-authentication` preflight performs the single real login and saves the browser session to `auth-state.json` in the per-session audit directory (path from `authStateFile()` in `apps/worker/src/audit/utils.ts`, derived from `generateAuditPath()`). The validation activity (`apps/worker/src/services/validate-authentication.ts`) removes any stale file from a prior run before the agent runs and verifies the file parses and contains cookies or storage before the preflight is marked complete; `logWorkflowComplete` deletes it when the workflow ends so authenticated cookies don't sit on disk between scans. Agent prompts opt in to session reuse by `@include(shared/_shared-session.txt)` before their `<login_instructions>` block — the partial restores the session and falls through to the full login flow if verification fails. `vuln-auth`/`exploit-auth` omit the include and own their own login
|
||||||
- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables. WorkflowLogger (`apps/worker/src/audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`apps/worker/src/audit/log-stream.ts`) shared stream primitive
|
- **Audit System** — Crash-safe append-only logging in `workspaces/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables. WorkflowLogger (`apps/worker/src/audit/workflow-logger.ts`) provides unified human-readable per-workflow logs, backed by LogStream (`apps/worker/src/audit/log-stream.ts`) shared stream primitive
|
||||||
- **Deliverables** — Saved to `deliverables/` in the target repo via the `save-deliverable` CLI script (`apps/worker/src/scripts/save-deliverable.ts`)
|
- **Deliverables** — Saved to `deliverables/` in the target repo via the `save-deliverable` CLI script (`apps/worker/src/scripts/save-deliverable.ts`)
|
||||||
- **Workspaces & Resume** — Named workspaces via `-w <name>` or auto-named from URL+timestamp. Resume detects completed agents via `session.json`. `loadResumeState()` in `apps/worker/src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `apps/worker/src/temporal/workspaces.ts`
|
- **Workspaces & Resume** — Named workspaces via `-w <name>` or auto-named from URL+timestamp. Resume detects completed agents via `session.json`. `loadResumeState()` in `apps/worker/src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `apps/worker/src/temporal/workspaces.ts`
|
||||||
|
|||||||
+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)
|
||||||
|
|
||||||
@@ -655,6 +685,12 @@ npx @keygraph/shannon start -u http://host.docker.internal:3000 -r /path/to/repo
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
**Custom hostnames in `/etc/hosts`:**
|
||||||
|
|
||||||
|
If your local stack uses custom hostnames mapped in `/etc/hosts`, Shannon forwards those entries into the worker container at scan start:
|
||||||
|
|
||||||
|
To disable, add `SHANNON_FORWARD_HOSTS=false` to `.env` (local mode) or export it in your shell: `export SHANNON_FORWARD_HOSTS=false`. In npx mode, the shell export is the only option since there's no `.env`.
|
||||||
|
|
||||||
### Output and Results
|
### Output and Results
|
||||||
|
|
||||||
All results are saved to the workspaces directory: `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode). Use `-o <path>` to copy deliverables to a custom output directory after the run completes.
|
All results are saved to the workspaces directory: `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode). Use `-o <path>` to copy deliverables to a custom output directory after the run completes.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+87
-1
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
@@ -145,6 +146,87 @@ function addHostFlag(): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names whose standard IPs aren't covered by `shouldSkipHostsIp`. Loopback names
|
||||||
|
* stay because their IPs (127.x, ::1) get rewritten — not skipped. Others like
|
||||||
|
* `broadcasthost` and `ip6-mcastprefix` are intentionally omitted: their IPs
|
||||||
|
* (255.255.255.255, ff00::/8) are already dropped at the IP filter.
|
||||||
|
*/
|
||||||
|
const HOSTS_SKIP_NAMES = new Set([
|
||||||
|
'localhost',
|
||||||
|
'ip6-localhost',
|
||||||
|
'ip6-loopback',
|
||||||
|
'ip6-localnet',
|
||||||
|
'host.docker.internal',
|
||||||
|
'gateway.docker.internal',
|
||||||
|
'kubernetes.docker.internal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isLoopbackIp(ip: string): boolean {
|
||||||
|
return ip.startsWith('127.') || ip === '::1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipHostsIp(ip: string): boolean {
|
||||||
|
if (ip === '0.0.0.0' || ip === '255.255.255.255') return true;
|
||||||
|
// Cloud metadata range — consistent with Shannon's SSRF guard
|
||||||
|
if (ip.startsWith('169.254.')) return true;
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
if (lower.startsWith('fe80:') || lower.startsWith('ff')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipHostsName(name: string, hostname: string): boolean {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (HOSTS_SKIP_NAMES.has(lower)) return true;
|
||||||
|
if (lower === hostname.toLowerCase()) return true;
|
||||||
|
if (lower.endsWith('.localhost')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the host's /etc/hosts and emit --add-host flags so the worker resolves
|
||||||
|
* user-added entries the same way. Loopback IPs (127.x, ::1) are rewritten to
|
||||||
|
* `host-gateway` so they target the host's loopback instead of the container's.
|
||||||
|
*/
|
||||||
|
function forwardEtcHostsFlags(): string[] {
|
||||||
|
if (process.env.SHANNON_FORWARD_HOSTS === 'false') return [];
|
||||||
|
if (os.platform() === 'win32') return [];
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync('/etc/hosts', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = os.hostname();
|
||||||
|
const flags: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of content.split('\n')) {
|
||||||
|
const hashIdx = rawLine.indexOf('#');
|
||||||
|
const line = (hashIdx >= 0 ? rawLine.slice(0, hashIdx) : rawLine).trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const tokens = line
|
||||||
|
.split(' ')
|
||||||
|
.flatMap((t) => t.split('\t'))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ip = tokens[0];
|
||||||
|
const names = tokens.slice(1);
|
||||||
|
if (!ip || names.length === 0) continue;
|
||||||
|
if (shouldSkipHostsIp(ip)) continue;
|
||||||
|
|
||||||
|
const targetIp = isLoopbackIp(ip) ? 'host-gateway' : ip;
|
||||||
|
const formattedIp = targetIp.includes(':') ? `[${targetIp}]` : targetIp;
|
||||||
|
for (const name of names) {
|
||||||
|
if (shouldSkipHostsName(name, hostname)) continue;
|
||||||
|
flags.push('--add-host', `${name}:${formattedIp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkerOptions {
|
export interface WorkerOptions {
|
||||||
version: string;
|
version: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -176,6 +258,9 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
|||||||
// Add host flag for Linux
|
// Add host flag for Linux
|
||||||
args.push(...addHostFlag());
|
args.push(...addHostFlag());
|
||||||
|
|
||||||
|
// Forward user-added /etc/hosts entries into the worker
|
||||||
|
args.push(...forwardEtcHostsFlags());
|
||||||
|
|
||||||
// UID remapping for Linux bind mounts
|
// UID remapping for Linux bind mounts
|
||||||
if (os.platform() === 'linux' && process.getuid && process.getgid) {
|
if (os.platform() === 'linux' && process.getuid && process.getgid) {
|
||||||
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
|
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
|
||||||
@@ -185,11 +270,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"
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ After exhaustive bypass attempts, determine:
|
|||||||
@include(shared/_rules.txt)
|
@include(shared/_rules.txt)
|
||||||
</rules>
|
</rules>
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ Remember: An unproven vulnerability is worse than no finding at all - it wastes
|
|||||||
@include(shared/_rules.txt)
|
@include(shared/_rules.txt)
|
||||||
</rules>
|
</rules>
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ After exhaustive bypass attempts, determine:
|
|||||||
@include(shared/_rules.txt)
|
@include(shared/_rules.txt)
|
||||||
</rules>
|
</rules>
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ After exhaustive bypass attempts, determine:
|
|||||||
@include(shared/_rules.txt)
|
@include(shared/_rules.txt)
|
||||||
</rules>
|
</rules>
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Write a stub authenticated session via Bash so the preflight's saved-state check passes:
|
||||||
|
echo '{"cookies":[{"name":"stub","value":"x","domain":"example.com","path":"/"}],"origins":[]}' > {{AUTH_STATE_FILE}}
|
||||||
|
|
||||||
|
Then return the structured verdict `{ "login_success": true }` and stop.
|
||||||
@@ -34,6 +34,8 @@ Areas to Focus On:
|
|||||||
|
|
||||||
@include(shared/_code-path-rules.txt)
|
@include(shared/_code-path-rules.txt)
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<shared_authenticated_session>
|
||||||
|
The preflight already logged in and saved the authenticated browser
|
||||||
|
session to:
|
||||||
|
|
||||||
|
{{AUTH_STATE_FILE}}
|
||||||
|
|
||||||
|
Restore it before doing anything else:
|
||||||
|
|
||||||
|
playwright-cli -s={{PLAYWRIGHT_SESSION}} state-load {{AUTH_STATE_FILE}}
|
||||||
|
|
||||||
|
Then run verification (per the success_condition in your authentication
|
||||||
|
config) to confirm the restored session is still valid:
|
||||||
|
|
||||||
|
- If verification passes → SKIP the login flow below entirely and
|
||||||
|
proceed with your primary task. You are authenticated.
|
||||||
|
- If verification fails → the saved session is stale. Fall through to
|
||||||
|
the full login flow below and perform it on your own browser session.
|
||||||
|
Do NOT overwrite {{AUTH_STATE_FILE}}.
|
||||||
|
</shared_authenticated_session>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<publish_session>
|
||||||
|
After verification confirms login_success, save the authenticated browser session so the rest of the pipeline can reuse it instead of logging in again:
|
||||||
|
|
||||||
|
playwright-cli -s={{PLAYWRIGHT_SESSION}} state-save {{AUTH_STATE_FILE}}
|
||||||
|
|
||||||
|
Run this only when login_success is true. Skip it on failure.
|
||||||
|
</publish_session>
|
||||||
|
|
||||||
|
<critical>
|
||||||
|
- Submit each field (username, password, captcha, TOTP) exactly once.
|
||||||
|
- Any rejection = auth error: return `login_success: false` and stop. Do not retry.
|
||||||
|
</critical>
|
||||||
@@ -21,6 +21,8 @@ Success criterion: A complete, code-backed analysis of every potential authoriza
|
|||||||
|
|
||||||
@include(shared/_code-path-rules.txt)
|
@include(shared/_code-path-rules.txt)
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Success criterion: Complete source-to-sink traces detailing path, sanitizers, si
|
|||||||
|
|
||||||
@include(shared/_code-path-rules.txt)
|
@include(shared/_code-path-rules.txt)
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Success criterion: A complete source-to-sink trace for every identified SSRF vul
|
|||||||
|
|
||||||
@include(shared/_code-path-rules.txt)
|
@include(shared/_code-path-rules.txt)
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Success criterion: Live confirmation of XSS execution for every vulnerability th
|
|||||||
|
|
||||||
@include(shared/_code-path-rules.txt)
|
@include(shared/_code-path-rules.txt)
|
||||||
|
|
||||||
|
@include(shared/_shared-session.txt)
|
||||||
|
|
||||||
<login_instructions>
|
<login_instructions>
|
||||||
{{LOGIN_INSTRUCTIONS}}
|
{{LOGIN_INSTRUCTIONS}}
|
||||||
</login_instructions>
|
</login_instructions>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const sessionMutex = new SessionMutex();
|
|||||||
* AuditSession - Main audit system facade
|
* AuditSession - Main audit system facade
|
||||||
*/
|
*/
|
||||||
export class AuditSession {
|
export class AuditSession {
|
||||||
private sessionMetadata: SessionMetadata;
|
readonly sessionMetadata: SessionMetadata;
|
||||||
private sessionId: string;
|
private sessionId: string;
|
||||||
private metricsTracker: MetricsTracker;
|
private metricsTracker: MetricsTracker;
|
||||||
private workflowLogger: WorkflowLogger;
|
private workflowLogger: WorkflowLogger;
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ export function generateSessionJsonPath(sessionMetadata: SessionMetadata): strin
|
|||||||
return path.join(auditPath, 'session.json');
|
return path.join(auditPath, 'session.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the shared authenticated browser session saved by the preflight
|
||||||
|
* validator and consumed by downstream agents via `_shared-session.txt`.
|
||||||
|
*/
|
||||||
|
export function authStateFile(sessionMetadata: SessionMetadata): string {
|
||||||
|
return path.join(generateAuditPath(sessionMetadata), 'auth-state.json');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate path to workflow.log file
|
* Generate path to workflow.log file
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { fs, path } from 'zx';
|
|||||||
import { type ClaudePromptResult, runClaudePrompt, validateAgentOutput } from '../ai/claude-executor.js';
|
import { type ClaudePromptResult, runClaudePrompt, validateAgentOutput } from '../ai/claude-executor.js';
|
||||||
import { getOutputFormat, getQueueFilename } from '../ai/queue-schemas.js';
|
import { getOutputFormat, getQueueFilename } from '../ai/queue-schemas.js';
|
||||||
import type { AuditSession } from '../audit/index.js';
|
import type { AuditSession } from '../audit/index.js';
|
||||||
|
import { authStateFile } from '../audit/utils.js';
|
||||||
import { AGENTS } from '../session-manager.js';
|
import { AGENTS } from '../session-manager.js';
|
||||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||||
import type { AgentName } from '../types/agents.js';
|
import type { AgentName } from '../types/agents.js';
|
||||||
@@ -95,7 +96,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 +121,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, AUTH_STATE_FILE: authStateFile(auditSession.sessionMetadata) },
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function renderReportFilterRules(report: ReportConfig | undefined): string {
|
|||||||
interface PromptVariables {
|
interface PromptVariables {
|
||||||
webUrl: string;
|
webUrl: string;
|
||||||
repoPath: string;
|
repoPath: string;
|
||||||
|
AUTH_STATE_FILE: string;
|
||||||
PLAYWRIGHT_SESSION?: string;
|
PLAYWRIGHT_SESSION?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +181,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);
|
||||||
@@ -306,6 +322,12 @@ async function interpolateVariables(
|
|||||||
result = result.replace(/<rules_of_engagement>[\s\S]*?<\/rules_of_engagement>\s*/g, '');
|
result = result.replace(/<rules_of_engagement>[\s\S]*?<\/rules_of_engagement>\s*/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config?.authentication) {
|
||||||
|
result = result.replace(/<shared_authenticated_session>[\s\S]*?<\/shared_authenticated_session>\s*/g, '');
|
||||||
|
} else {
|
||||||
|
result = result.replace(/{{AUTH_STATE_FILE}}/g, variables.AUTH_STATE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
if (config?.authentication?.login_flow) {
|
if (config?.authentication?.login_flow) {
|
||||||
const loginInstructions = await buildLoginInstructions(config.authentication, logger, promptsBaseDir);
|
const loginInstructions = await buildLoginInstructions(config.authentication, logger, promptsBaseDir);
|
||||||
result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, loginInstructions);
|
result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, loginInstructions);
|
||||||
@@ -352,6 +374,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 +392,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,262 @@
|
|||||||
|
// 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 { readFile, rm } from 'node:fs/promises';
|
||||||
|
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 { authStateFile } from '../audit/utils.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 stateFile = authStateFile(auditSession.sessionMetadata);
|
||||||
|
await rm(stateFile, { force: true });
|
||||||
|
|
||||||
|
const prompt = await loadPrompt(
|
||||||
|
AGENT_NAME,
|
||||||
|
{ webUrl, repoPath, AUTH_STATE_FILE: stateFile },
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
let classification = classifyResult(result, authentication);
|
||||||
|
|
||||||
|
if (classification.ok) {
|
||||||
|
const sessionCheck = await verifySavedAuthState(stateFile, logger);
|
||||||
|
if (!sessionCheck.ok) {
|
||||||
|
classification = sessionCheck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySavedAuthState(stateFile: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = await readFile(stateFile, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Preflight reported login success but did not save the authenticated session to ${stateFile}.`,
|
||||||
|
'validation',
|
||||||
|
true,
|
||||||
|
{ stateFile },
|
||||||
|
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(contents);
|
||||||
|
} catch (parseErr) {
|
||||||
|
const detail = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Preflight saved an authenticated session to ${stateFile}, but the file is not valid JSON: ${detail}`,
|
||||||
|
'validation',
|
||||||
|
true,
|
||||||
|
{ stateFile, parseError: detail },
|
||||||
|
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieCount = countStorageEntries(parsed, 'cookies');
|
||||||
|
const originCount = countStorageEntries(parsed, 'origins');
|
||||||
|
if (cookieCount === 0 && originCount === 0) {
|
||||||
|
return err(
|
||||||
|
new PentestError(
|
||||||
|
`Preflight saved an authenticated session to ${stateFile}, but it contains no cookies or origins — the browser was not actually logged in.`,
|
||||||
|
'validation',
|
||||||
|
true,
|
||||||
|
{ stateFile, cookieCount, originCount },
|
||||||
|
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Preflight authenticated session saved', { stateFile, cookieCount, originCount });
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countStorageEntries(parsed: unknown, key: 'cookies' | 'origins'): number {
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return 0;
|
||||||
|
const value = (parsed as Record<string, unknown>)[key];
|
||||||
|
return Array.isArray(value) ? value.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,21 +18,23 @@
|
|||||||
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';
|
||||||
import { generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
|
import { authStateFile, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js';
|
||||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
import type { WorkflowSummary } from '../audit/workflow-logger.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';
|
||||||
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.
|
||||||
@@ -821,7 +926,15 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo
|
|||||||
// 5. Write completion entry to workflow.log
|
// 5. Write completion entry to workflow.log
|
||||||
await auditSession.logWorkflowComplete(cumulativeSummary);
|
await auditSession.logWorkflowComplete(cumulativeSummary);
|
||||||
|
|
||||||
// 6. Clean up container
|
// 6. Drop the authenticated browser session
|
||||||
|
try {
|
||||||
|
await fs.rm(authStateFile(sessionMetadata), { force: true });
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`Failed to clean up auth-state.json: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Clean up container
|
||||||
removeContainer(workflowId);
|
removeContainer(workflowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,17 +992,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:
|
||||||
|
|||||||
Generated
+4
-4
@@ -1010,8 +1010,8 @@ packages:
|
|||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
fast-uri@3.1.0:
|
fast-uri@3.1.2:
|
||||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
@@ -2339,7 +2339,7 @@ snapshots:
|
|||||||
ajv@8.18.0:
|
ajv@8.18.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
fast-uri: 3.1.0
|
fast-uri: 3.1.2
|
||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
@@ -2560,7 +2560,7 @@ snapshots:
|
|||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-uri@3.1.0: {}
|
fast-uri@3.1.2: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user