mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-06-30 18:45:34 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2f78c5d9 | |||
| 7abcc1d3e1 |
@@ -4,7 +4,7 @@ AI-powered penetration testing agent for defensive security analysis. Automates
|
||||
|
||||
## Commands
|
||||
|
||||
**Prerequisites:** Docker, AI provider credentials (`.env` for local, `shn setup` or env vars for npx)
|
||||
**Prerequisites:** Docker, AI provider credentials (`.env` for local, `npx @keygraph/shannon setup` or env vars for npx)
|
||||
|
||||
### Dual CLI
|
||||
|
||||
@@ -15,8 +15,8 @@ Shannon supports two CLI modes, auto-detected based on the current working direc
|
||||
| **Install** | Zero-install via npm | Clone the repo |
|
||||
| **Image** | Pulled from Docker Hub (`keygraph/shannon:latest`) | Built locally (`shannon-worker`) |
|
||||
| **State** | `~/.shannon/` | Project directory |
|
||||
| **Credentials** | `~/.shannon/config.toml` (via `shn setup`) or env vars | `./.env` |
|
||||
| **Config** | `~/.shannon/config.toml` (via `shn setup`) | N/A |
|
||||
| **Credentials** | `~/.shannon/config.toml` (via `npx @keygraph/shannon setup`) or env vars | `./.env` |
|
||||
| **Config** | `~/.shannon/config.toml` (via `npx @keygraph/shannon setup`) | N/A |
|
||||
| **Prompts** | Bundled in Docker image | Mounted from `./apps/worker/prompts/` (live-editable) |
|
||||
|
||||
Mode auto-detection: local mode activates when env var `SHANNON_LOCAL=1` is set by the `./shannon` entry point (`apps/cli/src/mode.ts`). Otherwise npx mode.
|
||||
@@ -145,7 +145,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig
|
||||
5. **Reporting** (`report`) — Executive-level security report
|
||||
|
||||
### 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 `npx @keygraph/shannon 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
|
||||
- **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/4.8 (`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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* `shn setup` — interactive TUI wizard for one-time credential configuration.
|
||||
* `npx @keygraph/shannon setup` — interactive TUI wizard for one-time credential configuration.
|
||||
*
|
||||
* Walks the user through selecting a provider and entering credentials,
|
||||
* then persists everything to ~/.shannon/config.toml with 0o600 permissions.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* `shn uninstall` command — remove ~/.shannon/ after confirmation (npx only).
|
||||
* `npx @keygraph/shannon uninstall` command — remove ~/.shannon/ after confirmation (npx only).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
@@ -270,7 +270,7 @@ export function resolveConfig(): void {
|
||||
for (const err of errors) {
|
||||
console.error(` - ${err}`);
|
||||
}
|
||||
console.error(`\nRun 'shn setup' to reconfigure.\n`);
|
||||
console.error(`\nRun 'npx @keygraph/shannon setup' to reconfigure.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* - Spending cap check using isSpendingCapBehavior
|
||||
* - Handle failure (rollback, audit)
|
||||
* - Validate output using AGENTS[agentName].deliverableFilename
|
||||
* - Render the deliverable to disk via the writeDeliverable hook (if provided)
|
||||
* - Commit on success, log metrics
|
||||
*
|
||||
* No Temporal dependencies - pure domain logic.
|
||||
@@ -55,6 +56,8 @@ export interface AgentExecutionInput {
|
||||
promptDir?: string | undefined;
|
||||
providerConfig?: import('../types/config.js').ProviderConfig | undefined;
|
||||
mcpServers?: Record<string, import('@anthropic-ai/claude-agent-sdk').McpServerConfig>;
|
||||
// Renders the deliverable to disk; invoked after validation, before the success commit.
|
||||
writeDeliverable?: (deliverablesPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface FailAgentOpts {
|
||||
@@ -110,6 +113,7 @@ export class AgentExecutionService {
|
||||
promptDir,
|
||||
providerConfig,
|
||||
mcpServers,
|
||||
writeDeliverable,
|
||||
} = input;
|
||||
|
||||
// 1. Load config (pre-parsed configData → raw YAML → file path)
|
||||
@@ -236,7 +240,12 @@ export class AgentExecutionService {
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Success - commit deliverables, then capture checkpoint hash
|
||||
// 10. Render the deliverable to disk so the success commit below stages it
|
||||
if (writeDeliverable) {
|
||||
await writeDeliverable(deliverablesPath);
|
||||
}
|
||||
|
||||
// 11. Success - commit deliverables, then capture checkpoint hash
|
||||
await commitGitSuccess(deliverablesPath, agentName, logger);
|
||||
const commitHash = await getGitCommitHash(deliverablesPath);
|
||||
|
||||
|
||||
@@ -127,12 +127,11 @@ export const AGENT_PHASE_MAP: Readonly<Record<AgentName, PhaseName>> = Object.fr
|
||||
|
||||
// Factory function for vulnerability queue validators.
|
||||
//
|
||||
// Post-MCP-migration, the analysis_deliverable.md is rendered by the activity
|
||||
// wrapper after validateAgentOutput runs, so the previous "both files exist"
|
||||
// check would race the renderer. The validator only checks the queue.json —
|
||||
// that file is written by the SDK structured-output path in agent-execution.ts
|
||||
// before this validator runs. The downstream checkExploitationQueue still
|
||||
// renders the .md.
|
||||
// The analysis_deliverable.md is rendered via the writeDeliverable hook, which
|
||||
// AgentExecutionService runs after validateAgentOutput but before the success
|
||||
// commit — so a "both files exist" check here would race the renderer. The
|
||||
// validator only checks queue.json, written by the SDK structured-output path
|
||||
// in agent-execution.ts before this validator runs.
|
||||
function createVulnValidator(vulnType: VulnType): AgentValidator {
|
||||
return async (sourceDir: string, logger: ActivityLogger): Promise<boolean> => {
|
||||
const queueFile = path.join(sourceDir, `${vulnType}_exploitation_queue.json`);
|
||||
@@ -145,9 +144,9 @@ function createVulnValidator(vulnType: VulnType): AgentValidator {
|
||||
};
|
||||
}
|
||||
|
||||
// Exploitation agents — validation lives in runExploitAgentWithCollector post-processing
|
||||
// (collector harvest + renderer write). The deliverable file is written by the renderer
|
||||
// after the agent succeeds, so a file-existence check here would race the renderer.
|
||||
// Exploitation agents — the evidence deliverable is rendered via the writeDeliverable
|
||||
// hook after the agent succeeds (before the success commit), so a file-existence check
|
||||
// here would race the renderer.
|
||||
//
|
||||
// VulnType is kept in the import surface for createVulnValidator above; this factory
|
||||
// returns a no-op validator parameterized only for symmetry with the vuln-side factory.
|
||||
|
||||
@@ -137,6 +137,7 @@ async function runAgentActivity(
|
||||
agentName: AgentName,
|
||||
input: ActivityInput,
|
||||
mcpServers?: Record<string, import('@anthropic-ai/claude-agent-sdk').McpServerConfig>,
|
||||
writeDeliverable?: (deliverablesPath: string) => Promise<void>,
|
||||
): Promise<AgentMetrics> {
|
||||
const { repoPath, configPath, pipelineTestingMode = false, workflowId, webUrl } = input;
|
||||
|
||||
@@ -192,6 +193,7 @@ async function runAgentActivity(
|
||||
...(input.promptDir !== undefined && { promptDir: input.promptDir }),
|
||||
...(input.configYAML !== undefined && { configYAML: input.configYAML }),
|
||||
...(mcpServers && { mcpServers }),
|
||||
...(writeDeliverable && { writeDeliverable }),
|
||||
},
|
||||
auditSession,
|
||||
logger,
|
||||
@@ -255,28 +257,21 @@ export async function runPreReconAgent(input: ActivityInput): Promise<AgentMetri
|
||||
const { renderPreRecon } = await import('../services/pre-recon-renderer.js');
|
||||
|
||||
const collector = createPreReconCollectorServer();
|
||||
const metrics = await runAgentActivity('pre-recon', input, { 'pre-recon-collector': collector.server });
|
||||
|
||||
// On resume, the agent is skipped and the collector is never populated.
|
||||
// The cached deliverable from the prior run is the source of truth.
|
||||
if (metrics.skipped) {
|
||||
return metrics;
|
||||
}
|
||||
const writeDeliverable = async (deliverablesPath: string): Promise<void> => {
|
||||
const logger = createActivityLogger();
|
||||
// Skipped tools surface as renderer placeholders, not as activity failures.
|
||||
const callStatus = collector.getCallStatus();
|
||||
logger.info('Pre-recon tool call status', { callStatus });
|
||||
|
||||
const logger = createActivityLogger();
|
||||
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
|
||||
const collected = collector.getAll();
|
||||
const markdown = renderPreRecon(collected);
|
||||
const mdPath = path.join(deliverablesPath, 'pre_recon_deliverable.md');
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote pre_recon_deliverable.md from structured data (${markdown.length} bytes)`);
|
||||
};
|
||||
|
||||
// Skipped tools surface as renderer placeholders, not as activity failures.
|
||||
const callStatus = collector.getCallStatus();
|
||||
logger.info('Pre-recon tool call status', { callStatus });
|
||||
|
||||
const collected = collector.getAll();
|
||||
const markdown = renderPreRecon(collected);
|
||||
const mdPath = path.join(dir, 'pre_recon_deliverable.md');
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote pre_recon_deliverable.md from structured data (${markdown.length} bytes)`);
|
||||
|
||||
return metrics;
|
||||
return runAgentActivity('pre-recon', input, { 'pre-recon-collector': collector.server }, writeDeliverable);
|
||||
}
|
||||
|
||||
export async function runReconAgent(input: ActivityInput): Promise<AgentMetrics> {
|
||||
@@ -284,28 +279,21 @@ export async function runReconAgent(input: ActivityInput): Promise<AgentMetrics>
|
||||
const { renderRecon } = await import('../services/recon-renderer.js');
|
||||
|
||||
const collector = createReconCollectorServer();
|
||||
const metrics = await runAgentActivity('recon', input, { 'recon-collector': collector.server });
|
||||
|
||||
// On resume, the agent is skipped and the collector is never populated.
|
||||
// The cached deliverable from the prior run is the source of truth.
|
||||
if (metrics.skipped) {
|
||||
return metrics;
|
||||
}
|
||||
const writeDeliverable = async (deliverablesPath: string): Promise<void> => {
|
||||
const logger = createActivityLogger();
|
||||
// Skipped tools surface as renderer placeholders, not as activity failures.
|
||||
const callStatus = collector.getCallStatus();
|
||||
logger.info('Recon tool call status', { callStatus });
|
||||
|
||||
const logger = createActivityLogger();
|
||||
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
|
||||
const collected = collector.getAll();
|
||||
const markdown = renderRecon(collected);
|
||||
const mdPath = path.join(deliverablesPath, 'recon_deliverable.md');
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote recon_deliverable.md from structured data (${markdown.length} bytes)`);
|
||||
};
|
||||
|
||||
// Skipped tools surface as renderer placeholders, not as activity failures.
|
||||
const callStatus = collector.getCallStatus();
|
||||
logger.info('Recon tool call status', { callStatus });
|
||||
|
||||
const collected = collector.getAll();
|
||||
const markdown = renderRecon(collected);
|
||||
const mdPath = path.join(dir, 'recon_deliverable.md');
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote recon_deliverable.md from structured data (${markdown.length} bytes)`);
|
||||
|
||||
return metrics;
|
||||
return runAgentActivity('recon', input, { 'recon-collector': collector.server }, writeDeliverable);
|
||||
}
|
||||
|
||||
async function runVulnAgentWithCollector(
|
||||
@@ -317,28 +305,21 @@ async function runVulnAgentWithCollector(
|
||||
const { renderVulnDeliverable } = await import('../services/vuln-renderer.js');
|
||||
|
||||
const collector = createVulnCollector(vulnClass);
|
||||
const metrics = await runAgentActivity(agentName, input, { 'vuln-collector': collector.server });
|
||||
|
||||
// On resume, the agent is skipped and the collector is never populated.
|
||||
// The cached deliverable from the prior run is the source of truth.
|
||||
if (metrics.skipped) {
|
||||
return metrics;
|
||||
}
|
||||
const writeDeliverable = async (deliverablesPath: string): Promise<void> => {
|
||||
const logger = createActivityLogger();
|
||||
// Skipped tools surface as renderer placeholders, not as activity failures.
|
||||
const callStatus = collector.getCallStatus();
|
||||
logger.info(`${vulnClass} vuln tool call status`, { callStatus });
|
||||
|
||||
const logger = createActivityLogger();
|
||||
const dir = deliverablesDir(input.repoPath, input.deliverablesSubdir);
|
||||
const collected = collector.getAll();
|
||||
const markdown = renderVulnDeliverable(vulnClass, collected);
|
||||
const mdPath = path.join(deliverablesPath, `${vulnClass}_analysis_deliverable.md`);
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote ${vulnClass}_analysis_deliverable.md from structured data (${markdown.length} bytes)`);
|
||||
};
|
||||
|
||||
// Skipped tools surface as renderer placeholders, not as activity failures.
|
||||
const callStatus = collector.getCallStatus();
|
||||
logger.info(`${vulnClass} vuln tool call status`, { callStatus });
|
||||
|
||||
const collected = collector.getAll();
|
||||
const markdown = renderVulnDeliverable(vulnClass, collected);
|
||||
const mdPath = path.join(dir, `${vulnClass}_analysis_deliverable.md`);
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote ${vulnClass}_analysis_deliverable.md from structured data (${markdown.length} bytes)`);
|
||||
|
||||
return metrics;
|
||||
return runAgentActivity(agentName, input, { 'vuln-collector': collector.server }, writeDeliverable);
|
||||
}
|
||||
|
||||
export async function runInjectionVulnAgent(input: ActivityInput): Promise<AgentMetrics> {
|
||||
@@ -398,34 +379,29 @@ async function runExploitAgentWithCollector(
|
||||
const { validIds, idToType } = await readExploitQueue(queuePath);
|
||||
|
||||
const collector = createExploitCollector({ vulnClass, validIds });
|
||||
const metrics = await runAgentActivity(agentName, input, { 'exploit-collector': collector.server });
|
||||
|
||||
// On resume, the agent is skipped and the collector is never populated.
|
||||
// The cached deliverable from the prior run is the source of truth.
|
||||
if (metrics.skipped) {
|
||||
return metrics;
|
||||
}
|
||||
const writeDeliverable = async (deliverablesPath: string): Promise<void> => {
|
||||
const logger = createActivityLogger();
|
||||
const collected = collector.getAll();
|
||||
const emittedIds = new Set(collected.map((e) => e.vulnerability_id));
|
||||
const missingIds = [...validIds].filter((id) => !emittedIds.has(id));
|
||||
const exploitedCount = collected.filter((e) => e.status === 'exploited').length;
|
||||
const blockedCount = collected.filter((e) => e.status === 'blocked').length;
|
||||
|
||||
const logger = createActivityLogger();
|
||||
const collected = collector.getAll();
|
||||
const emittedIds = new Set(collected.map((e) => e.vulnerability_id));
|
||||
const missingIds = [...validIds].filter((id) => !emittedIds.has(id));
|
||||
const exploitedCount = collected.filter((e) => e.status === 'exploited').length;
|
||||
const blockedCount = collected.filter((e) => e.status === 'blocked').length;
|
||||
logger.info(`${vulnClass} exploit tool call metrics`, {
|
||||
queueSize: validIds.size,
|
||||
exploited: exploitedCount,
|
||||
blocked: blockedCount,
|
||||
missing: missingIds.length,
|
||||
});
|
||||
|
||||
logger.info(`${vulnClass} exploit tool call metrics`, {
|
||||
queueSize: validIds.size,
|
||||
exploited: exploitedCount,
|
||||
blocked: blockedCount,
|
||||
missing: missingIds.length,
|
||||
});
|
||||
const markdown = renderExploitDeliverable(vulnClass, collected, idToType);
|
||||
const mdPath = path.join(deliverablesPath, `${vulnClass}_exploitation_evidence.md`);
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote ${vulnClass}_exploitation_evidence.md from structured data (${markdown.length} bytes)`);
|
||||
};
|
||||
|
||||
const markdown = renderExploitDeliverable(vulnClass, collected, idToType);
|
||||
const mdPath = path.join(dir, `${vulnClass}_exploitation_evidence.md`);
|
||||
await atomicWrite(mdPath, markdown);
|
||||
logger.info(`Wrote ${vulnClass}_exploitation_evidence.md from structured data (${markdown.length} bytes)`);
|
||||
|
||||
return metrics;
|
||||
return runAgentActivity(agentName, input, { 'exploit-collector': collector.server }, writeDeliverable);
|
||||
}
|
||||
|
||||
export async function runInjectionExploitAgent(input: ActivityInput): Promise<AgentMetrics> {
|
||||
|
||||
Reference in New Issue
Block a user