mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-15 16:00:29 +02:00
Compare commits
21
Commits
+22
-10
@@ -3,16 +3,16 @@
|
||||
# Defaults to anthropic:claude-sonnet-4-6.
|
||||
|
||||
# --- Anthropic ---------------------------------------------------------------
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
SHANNON_AI_API_KEY=your-api-key-here
|
||||
SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
|
||||
# --- OpenAI ------------------------------------------------------------------
|
||||
# OPENAI_API_KEY=your-api-key-here
|
||||
# SHANNON_AI_MODEL=openai:gpt-5.6-sol
|
||||
# SHANNON_AI_API_KEY=your-api-key-here
|
||||
# SHANNON_AI_MODEL=openai:gpt-5.5
|
||||
|
||||
# --- xAI ---------------------------------------------------------------------
|
||||
# XAI_API_KEY=your-api-key-here
|
||||
# SHANNON_AI_API_KEY=your-api-key-here
|
||||
# SHANNON_AI_MODEL=xai:grok-4.5
|
||||
|
||||
# --- AWS Bedrock -------------------------------------------------------------
|
||||
@@ -24,20 +24,32 @@ SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
# --- Custom Base URL ---------------------------------------------------------
|
||||
# Route through a proxy or gateway (LiteLLM, an internal endpoint).
|
||||
# Pick the block matching the API dialect your gateway speaks, and uncomment all
|
||||
# three lines. The provider prefix picks the dialect and which key is sent; the
|
||||
# model id is whatever name your gateway serves it under.
|
||||
# three lines. The provider prefix picks the dialect; the model id is whatever
|
||||
# name your gateway serves it under.
|
||||
|
||||
# Anthropic compatible - Anthropic Messages:
|
||||
# ANTHROPIC_API_KEY=your-gateway-key-here
|
||||
# SHANNON_AI_API_KEY=your-gateway-key-here
|
||||
# SHANNON_AI_BASE_URL=https://llm-gateway.example.com
|
||||
# SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
|
||||
# OpenAI compatible - Chat Completions (default) or Responses:
|
||||
# OPENAI_API_KEY=your-gateway-key-here
|
||||
# SHANNON_AI_API_KEY=your-gateway-key-here
|
||||
# SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
|
||||
# SHANNON_AI_MODEL=openai:gpt-5.6-sol
|
||||
# SHANNON_AI_MODEL=openai:gpt-5.5
|
||||
# SHANNON_AI_OPENAI_FORMAT=responses
|
||||
|
||||
# --- Other -------------------------------------------------------------------
|
||||
# --- Other provider ----------------------------------------------------------
|
||||
# Any other provider the Pi harness supports. Name it in SHANNON_AI_MODEL and
|
||||
# supply the key via the generic SHANNON_AI_API_KEY. Pi validates the provider
|
||||
# and model at preflight.
|
||||
# SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3
|
||||
# SHANNON_AI_API_KEY=your-api-key-here
|
||||
|
||||
# --- Misc --------------------------------------------------------------------
|
||||
# Forward /etc/hosts entries into the worker container.
|
||||
# SHANNON_FORWARD_HOSTS=false
|
||||
|
||||
# See the guide below to use an OpenAI subscription
|
||||
# https://github.com/KeygraphHQ/shannon/blob/main/docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription
|
||||
# SHANNON_USE_PI_AUTH=1
|
||||
# SHANNON_AI_MODEL=openai-codex:gpt-5.5
|
||||
|
||||
@@ -117,8 +117,12 @@ body:
|
||||
options:
|
||||
- "Anthropic (API key)"
|
||||
- "Anthropic (OAuth token)"
|
||||
- "Custom base URL (proxy/gateway)"
|
||||
- "OpenAI"
|
||||
- "xAI"
|
||||
- "AWS Bedrock"
|
||||
- "Custom base URL - Anthropic Messages"
|
||||
- "Custom base URL - OpenAI Chat Completions"
|
||||
- "Custom base URL - OpenAI Responses"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
+20
-2
@@ -52,6 +52,8 @@ RUN apk update && apk add --no-cache \
|
||||
curl \
|
||||
ca-certificates \
|
||||
shadow \
|
||||
# Typst tarball decompression
|
||||
xz \
|
||||
# Language runtimes (minimal)
|
||||
nodejs-22 \
|
||||
npm \
|
||||
@@ -73,6 +75,22 @@ RUN apk update && apk add --no-cache \
|
||||
# Font rendering
|
||||
fontconfig
|
||||
|
||||
# Install Typst (report PDF compilation)
|
||||
ARG TYPST_VERSION=0.14.2
|
||||
RUN case "$(uname -m)" in \
|
||||
x86_64) TYPST_ARCH=x86_64-unknown-linux-musl ;; \
|
||||
aarch64) TYPST_ARCH=aarch64-unknown-linux-musl ;; \
|
||||
*) echo "unsupported arch $(uname -m)" && exit 1 ;; \
|
||||
esac && \
|
||||
mkdir -p /tmp/typst-dl /usr/local/bin && cd /tmp/typst-dl && \
|
||||
curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" -o typst.tar.xz && \
|
||||
xz -d typst.tar.xz && \
|
||||
tar -xf typst.tar && \
|
||||
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
|
||||
chmod +x /usr/local/bin/typst && \
|
||||
cd / && rm -rf /tmp/typst-dl && \
|
||||
typst --version
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 pentest && \
|
||||
adduser -u 1001 -G pentest -s /bin/bash -D pentest
|
||||
@@ -107,12 +125,12 @@ RUN ln -s /app/apps/worker/dist/scripts/save-deliverable.js /usr/local/bin/save-
|
||||
|
||||
# Create directories for session data and ensure proper permissions
|
||||
RUN mkdir -p /app/sessions /app/repos /app/workspaces && \
|
||||
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \
|
||||
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm /tmp/.pi/agent && \
|
||||
chmod 777 /app && \
|
||||
chmod 777 /tmp/.cache && \
|
||||
chmod 777 /tmp/.config && \
|
||||
chmod 777 /tmp/.npm && \
|
||||
chown -R pentest:pentest /app /tmp/.claude
|
||||
chown -R pentest:pentest /app /tmp/.claude /tmp/.pi
|
||||
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
> [!NOTE]
|
||||
> **[Shannon 2.0 is officially here](https://github.com/KeygraphHQ/shannon/discussions/405)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="./assets/github-banner.png" alt="Shannon - AI Pentester by Keygraph" width="100%">
|
||||
@@ -6,7 +9,7 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15604" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15604" alt="KeygraphHQ%2Fshannon | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
Shannon is an autonomous, white-box AI pentester for web applications and APIs. <br />
|
||||
Shannon is an autonomous, AI pentester for web applications and APIs. <br />
|
||||
It analyzes your source code, identifies attack paths, and executes real exploits to prove vulnerabilities before they reach production.
|
||||
|
||||
**This repository is Shannon Open Source: the full agent, run locally from your command line.**
|
||||
@@ -31,14 +34,16 @@ It analyzes your source code, identifies attack paths, and executes real exploit
|
||||
- [Editions](#editions)
|
||||
- [Architecture](#architecture)
|
||||
- [Documentation](#documentation)
|
||||
- [Continuous Integration](#continuous-integration)
|
||||
- [Common Questions](#common-questions)
|
||||
- [Safety, Scope, and Limitations](#safety-scope-and-limitations)
|
||||
- [License and Enterprise Licensing](#license-and-enterprise-licensing)
|
||||
- [License](#license)
|
||||
- [About Keygraph](#about-keygraph)
|
||||
- [Community and Support](#community-and-support)
|
||||
|
||||
## What is Shannon?
|
||||
|
||||
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs white-box security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
|
||||
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
|
||||
|
||||
Shannon analyzes your web application's source code to identify potential attack vectors, then uses browser automation and command-line tools to execute real exploits against the running application and its APIs. Only vulnerabilities with a working proof-of-concept are included in the final report.
|
||||
|
||||
@@ -70,7 +75,7 @@ Sample penetration test reports from intentionally vulnerable applications, prod
|
||||
|
||||
- **Docker**: required for the worker container.
|
||||
- **Node.js 18+**: required for the recommended `npx` workflow.
|
||||
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock. Claude models are recommended. Gateway and proxy setups are documented separately.
|
||||
- **AI provider credentials**: Shannon runs on Anthropic, OpenAI, xAI, AWS Bedrock, [any other provider](docs/ai-providers.md#any-other-provider) in the harness catalogue, and any endpoint that speaks the Anthropic Messages API or the OpenAI Chat Completions API through a [custom base URL](docs/ai-providers.md#custom-base-url). You bring your own key, and Keygraph never proxies your model traffic. Shannon is provider-agnostic. See [AI providers](docs/ai-providers.md#suggested-models) for suggested model IDs.
|
||||
- **Cyber safeguards cleared with your provider**: Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before your first run - see [AI providers](docs/ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
### Run Shannon
|
||||
@@ -91,7 +96,10 @@ Shannon pulls the worker image from Docker Hub, starts the required local infras
|
||||
For source builds, authenticated scans, provider-specific setup, and platform notes, see [Documentation](#documentation).
|
||||
|
||||
> [!TIP]
|
||||
> **Prefer to run on your Claude Code subscription instead of API credits?** The [`shannon-v1`](https://github.com/KeygraphHQ/shannon/tree/shannon-v1) branch is the last release built on the Claude Agent SDK, so it accepts a Claude Code OAuth token. Generate one with `claude setup-token`, then run `npx @keygraph/shannon@1.9.0 setup` and pick **OAuth Token**. Pentests then cost nothing beyond your existing subscription.
|
||||
> **Prefer to use a subscription instead of API credits?**
|
||||
>
|
||||
> - **OpenAI Codex:** The latest version of Shannon supports ChatGPT Plus and Pro subscriptions. Follow the [OpenAI Codex subscription setup guide](docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription) to get started.
|
||||
> - **Claude Code:** The latest version of Shannon does not support Claude Code subscriptions. Follow the [Claude Code subscription setup guide](docs/ai-providers.md#claude-code-subscription) to use version `1.9.0`, which is the final release built on the Claude Agent SDK.
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
@@ -101,6 +109,9 @@ For source builds, authenticated scans, provider-specific setup, and platform no
|
||||
- **Authenticated testing**: configuration files can describe login flows, test credentials, TOTP, email-based login flows, focus areas, and rules of engagement.
|
||||
- **OWASP-focused coverage**: Shannon targets exploitable Injection, XSS, SSRF, Broken Authentication, and Broken Authorization issues.
|
||||
- **Resumable workspaces**: Shannon can resume interrupted runs without re-running completed agents.
|
||||
- **Machine-readable output**: Shannon emits findings as structured JSON, and as SARIF 2.1.0 when you enable it in configuration. SARIF is the OASIS standard for static analysis results, so findings flow into any code scanning service, vulnerability management platform, security dashboard, or CI/CD pipeline that reads it.
|
||||
- **Headless CI/CD execution**: Shannon runs fully headless and non-interactively, with environment-variable credentials and configuration-file support, so it fits ephemeral CI environments. This is included in Shannon Open Source and is not gated behind a commercial edition.
|
||||
- **Bring your own key, provider-agnostic**: Shannon runs on Anthropic, OpenAI, xAI, AWS Bedrock, and any endpoint speaking the Anthropic Messages API or the OpenAI Chat Completions API, including self-hosted models served through Ollama, vLLM, or LM Studio and gateways such as OpenRouter and LiteLLM. You supply the credentials, so source code and model traffic stay inside your infrastructure.
|
||||
|
||||
## Editions
|
||||
|
||||
@@ -187,13 +198,93 @@ Use these guides for operational detail:
|
||||
| --- | --- |
|
||||
| [Source build and CLI commands](docs/development.md) | Cloning, building, common commands, output paths, and local development. |
|
||||
| [Configuration](docs/configuration.md) | Authenticated testing, login flows, rules of engagement, and report filters. |
|
||||
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock), and custom gateways. |
|
||||
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock, and any other Pi-supported provider), and custom gateways. |
|
||||
| [Platforms and networking](docs/platforms.md) | Windows/WSL2, Linux, macOS, Docker networking, local apps, and custom hostnames. |
|
||||
| [Workspaces and resuming](docs/workspaces.md) | Naming workspaces, resuming interrupted scans, and workspace storage. |
|
||||
| [Safety and limitations](docs/safety.md) | Authorized-use requirements, non-production guidance, mutative effects, cost, and model caveats. |
|
||||
| [Coverage and roadmap](docs/coverage-roadmap.md) | Current vulnerability coverage and planned work. |
|
||||
| [CI/CD integration](docs/ci-cd.md) | Headless execution, SARIF output, artifact paths, and GitHub Actions examples. |
|
||||
| [Keygraph platform](docs/keygraph-platform.md) | The continuous, agentic pentesting platform: code analysis, black-box and white-box testing, finding management, remediation, verification, and enterprise deployment. |
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Shannon runs fully headless and non-interactively, so it fits ephemeral CI environments. Credentials are read from environment variables, so no interactive `setup` step is required. The example below uses GitHub Actions, but nothing about the run is GitHub-specific.
|
||||
|
||||
```yaml
|
||||
name: Shannon Pentest
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
pentest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Shannon
|
||||
run: |
|
||||
npx @keygraph/shannon start \
|
||||
-u ${{ vars.TARGET_URL }} \
|
||||
-r . \
|
||||
-w ci-${{ github.run_id }} \
|
||||
-o ./shannon-results
|
||||
|
||||
# `start` launches the scan in the background. `logs` streams it and
|
||||
# returns once the scan reports COMPLETED or FAILED.
|
||||
npx @keygraph/shannon logs ci-${{ github.run_id }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shannon-report
|
||||
path: ./shannon-results/
|
||||
```
|
||||
|
||||
`-o` copies the run's deliverables, including the report and the structured findings in `report.json`, to a path the rest of your workflow can read.
|
||||
|
||||
Because Shannon reports only vulnerabilities it has actually exploited, what lands in your pipeline is proven rather than speculative. Set `report.min_severity` in a configuration file passed with `-c` to drop findings below a severity threshold, then gate merges on your own check over `report.json`.
|
||||
|
||||
See [CI/CD integration](docs/ci-cd.md) for artifact paths, SARIF output, authenticated targets, and cost and runtime notes.
|
||||
|
||||
## Common Questions
|
||||
|
||||
### Is Shannon free?
|
||||
|
||||
Yes. Shannon Open Source is free and licensed under AGPL-3.0. You run it yourself from the command line. Your only cost is the AI provider credits you supply.
|
||||
|
||||
### Can I self-host Shannon?
|
||||
|
||||
Yes. Shannon Open Source runs entirely on your own infrastructure in an ephemeral Docker container. Your source code is mounted read-only and never leaves your environment.
|
||||
|
||||
### Does Shannon support bring your own key (BYOK)?
|
||||
|
||||
Yes, always. You supply your own AI provider credentials in every deployment, open source and commercial. Keygraph never proxies your model traffic.
|
||||
|
||||
### Can Shannon run in CI/CD?
|
||||
|
||||
Yes. Shannon runs fully headless and non-interactively, with environment-variable credentials and configuration-file support. See [Continuous Integration](#continuous-integration) for a worked example, and [CI/CD integration](docs/ci-cd.md) for SARIF output and artifact paths. This is part of Shannon Open Source.
|
||||
|
||||
### Does Shannon output SARIF?
|
||||
|
||||
Yes. Shannon emits SARIF 2.1.0, the OASIS standard format for static analysis results, alongside structured JSON. Any SARIF consumer reads it: code scanning services, vulnerability management platforms, security dashboards, and CI/CD pipelines. Set `report.sarif` to `"true"` in your configuration file to enable the SARIF log.
|
||||
|
||||
### Which AI providers does Shannon support?
|
||||
|
||||
Anthropic, OpenAI, xAI, and AWS Bedrock are built in and configured directly by provider ID. Beyond those, Shannon runs on any endpoint that implements the Anthropic Messages API or the OpenAI Chat Completions API, reached through a custom base URL. The rule is the API format, not the vendor. Shannon uses a single unified model setting throughout a pentest.
|
||||
|
||||
### Can I run Shannon on a local or self-hosted model?
|
||||
|
||||
Yes. Shannon works with local models served through Ollama, vLLM, or LM Studio, which expose an OpenAI-compatible endpoint, as well as routers such as OpenRouter and gateways such as LiteLLM. Point Shannon at the endpoint with a custom base URL. See [AI providers](docs/ai-providers.md#custom-base-url).
|
||||
|
||||
### Does Shannon actually exploit vulnerabilities, or just scan?
|
||||
|
||||
Shannon executes real exploits. It reports a finding only when it has produced a working proof-of-concept, and discards hypotheses it cannot prove. It is a pentester, not a scanner.
|
||||
|
||||
### Is Shannon free for startups and nonprofits?
|
||||
|
||||
Shannon Open Source is free for everyone. In addition, the Keygraph Community Program gives eligible nonprofits and early-stage startups free access to the commercial Keygraph platform. See [keygraph.io](https://keygraph.io).
|
||||
|
||||
## Safety, Scope, and Limitations
|
||||
|
||||
Shannon is not a passive scanner. Its exploitation agents can create users, submit forms, mutate application state, trigger outbound requests, and otherwise affect the target system. Use sandboxed, staging, or local development environments with disposable data.
|
||||
@@ -204,13 +295,13 @@ Important limitations:
|
||||
|
||||
- Shannon Open Source focuses on actively exploitable issues such as Injection, XSS, SSRF, Broken Authentication, and Broken Authorization. Broader static-analysis coverage, including vulnerable dependencies and insecure configurations, is delivered through the Keygraph platform.
|
||||
- Findings still require human review. LLM-generated reports can contain weakly supported or incorrect details.
|
||||
- Shannon is officially supported with Claude models. Smaller, alternative, or proxied non-Claude models may be incomplete or unstable.
|
||||
- Anthropic, OpenAI, xAI, and AWS Bedrock are built-in providers, and any Anthropic Messages API or OpenAI Chat Completions API endpoint works through a custom base URL. Model capability varies, and a model that does not follow Shannon's instructions or tool-use constraints reliably will produce weaker results.
|
||||
- A full run can take roughly 1 to 1.5 hours and may incur LLM API costs depending on model pricing and application complexity.
|
||||
- Do not scan untrusted or adversarial codebases. AI-powered tools that read source code can be exposed to prompt injection.
|
||||
|
||||
Read the full [Safety and limitations](docs/safety.md) guide before running Shannon in a new environment.
|
||||
|
||||
## License and Enterprise Licensing
|
||||
## License
|
||||
|
||||
Shannon Open Source is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
import { type OpenAiFormat, type ProviderId, SUPPORTED_PROVIDERS } from '../model-spec.js';
|
||||
import { CURATED_PROVIDERS, type CuratedProviderId, isCuratedProvider, type OpenAiFormat } from '../model-spec.js';
|
||||
import { requireInteractive } from '../tty.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
const CUSTOM_MODEL = '__custom__';
|
||||
const CUSTOM_BASE_URL = '__custom_base_url__';
|
||||
const OTHER_PROVIDER = '__other_provider__';
|
||||
|
||||
/**
|
||||
* Wire formats reachable through the gateway route. The format picks the provider
|
||||
@@ -39,44 +40,53 @@ const GATEWAY_DIALECTS: readonly {
|
||||
{ value: 'openai-responses', label: 'OpenAI Responses', provider: 'openai', format: 'responses' },
|
||||
];
|
||||
|
||||
/** Suggested models per provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
||||
const MODEL_SUGGESTIONS: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
/** Suggested models per curated provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
|
||||
const MODEL_SUGGESTIONS: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-haiku-4-5-20251001'],
|
||||
openai: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4'],
|
||||
xai: ['grok-4.5'],
|
||||
'amazon-bedrock': ['us.anthropic.claude-sonnet-4-6', 'us.anthropic.claude-opus-4-8', 'us.anthropic.claude-opus-4-7'],
|
||||
};
|
||||
|
||||
/** Placeholder shown in the free-text model ID prompt. */
|
||||
const MODEL_ID_PLACEHOLDER: Readonly<Record<ProviderId, string>> = {
|
||||
/** Placeholder shown in the free-text model ID prompt, per curated provider. */
|
||||
const MODEL_ID_PLACEHOLDER: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'claude-sonnet-4-6',
|
||||
openai: 'gpt-5.6-sol',
|
||||
xai: 'grok-4.5',
|
||||
'amazon-bedrock': 'us.anthropic.claude-opus-4-8',
|
||||
};
|
||||
|
||||
/** Model ID placeholder for a provider, absent when the provider is not curated. */
|
||||
function modelIdPlaceholder(provider: string): string | undefined {
|
||||
return isCuratedProvider(provider) ? MODEL_ID_PLACEHOLDER[provider] : undefined;
|
||||
}
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
|
||||
p.intro('Shannon Setup');
|
||||
|
||||
// 1. Select provider. "Custom Base URL" is a route, not a provider — it asks
|
||||
// which API dialect the gateway speaks and configures that provider.
|
||||
// which API dialect the gateway speaks and configures that provider. "Other
|
||||
// provider" reaches any pi-supported provider Shannon does not curate.
|
||||
const selected = await p.select({
|
||||
message: 'Select your AI provider',
|
||||
options: [
|
||||
{ value: 'anthropic' as const, label: 'Anthropic', hint: 'Claude models - recommended' },
|
||||
{ value: 'anthropic' as const, label: 'Anthropic', hint: 'Claude models' },
|
||||
{ value: 'openai' as const, label: 'OpenAI', hint: 'GPT models' },
|
||||
{ value: 'xai' as const, label: 'xAI', hint: 'Grok models' },
|
||||
{ value: 'amazon-bedrock' as const, label: 'AWS Bedrock', hint: 'Claude models via AWS' },
|
||||
{ value: CUSTOM_BASE_URL as typeof CUSTOM_BASE_URL, label: 'Custom Base URL', hint: 'your own proxy or gateway' },
|
||||
{
|
||||
value: OTHER_PROVIDER as typeof OTHER_PROVIDER,
|
||||
label: 'Other provider',
|
||||
hint: 'any other Pi-supported provider',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(selected)) return cancelAndExit();
|
||||
|
||||
// 2. Credentials — and, on the gateway route, the endpoint and its dialect.
|
||||
const gateway = selected === CUSTOM_BASE_URL ? await setupGateway() : undefined;
|
||||
const provider = gateway?.provider ?? (selected as ProviderId);
|
||||
const config = gateway?.config ?? (await setupProvider(provider));
|
||||
const { provider, config, gateway } = await setupSelection(selected);
|
||||
|
||||
// 3. The model that runs every phase.
|
||||
const modelId = await promptModel(provider);
|
||||
@@ -95,7 +105,27 @@ export async function setup(): Promise<void> {
|
||||
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
|
||||
}
|
||||
|
||||
async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
|
||||
interface Selection {
|
||||
provider: string;
|
||||
config: ShannonConfig;
|
||||
gateway?: GatewaySetup;
|
||||
}
|
||||
|
||||
/** Resolve the provider selection into a provider id and its credential config. */
|
||||
async function setupSelection(
|
||||
selected: CuratedProviderId | typeof CUSTOM_BASE_URL | typeof OTHER_PROVIDER,
|
||||
): Promise<Selection> {
|
||||
if (selected === CUSTOM_BASE_URL) {
|
||||
const gateway = await setupGateway();
|
||||
return { provider: gateway.provider, config: gateway.config, gateway };
|
||||
}
|
||||
if (selected === OTHER_PROVIDER) {
|
||||
return setupOtherProvider();
|
||||
}
|
||||
return { provider: selected, config: await setupProvider(selected) };
|
||||
}
|
||||
|
||||
async function setupProvider(provider: CuratedProviderId): Promise<ShannonConfig> {
|
||||
switch (provider) {
|
||||
case 'amazon-bedrock':
|
||||
return setupBedrock();
|
||||
@@ -108,6 +138,27 @@ async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Any pi provider Shannon does not curate. The id is free text — the worker's
|
||||
* preflight validates it — and the key is stored generically as SHANNON_AI_API_KEY.
|
||||
*/
|
||||
async function setupOtherProvider(): Promise<Selection> {
|
||||
p.log.info('Browse supported providers and models at https://pi.dev/models');
|
||||
const provider = await p.text({
|
||||
message: 'Provider ID',
|
||||
validate: (value) => {
|
||||
const id = value?.trim();
|
||||
if (!id) return 'Provider ID is required';
|
||||
if (isCuratedProvider(id)) return `${id} has its own option.`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
|
||||
const apiKey = await promptSecret('Enter the API key');
|
||||
return { provider: provider.trim(), config: { provider: { api_key: apiKey } } };
|
||||
}
|
||||
|
||||
// === Provider Setup Flows ===
|
||||
|
||||
async function setupAnthropic(): Promise<ShannonConfig> {
|
||||
@@ -143,7 +194,7 @@ async function setupBedrock(): Promise<ShannonConfig> {
|
||||
}
|
||||
|
||||
interface GatewaySetup {
|
||||
provider: ProviderId;
|
||||
provider: CuratedProviderId;
|
||||
config: ShannonConfig;
|
||||
baseUrl: string;
|
||||
format?: OpenAiFormat;
|
||||
@@ -195,11 +246,11 @@ async function setupGateway(): Promise<GatewaySetup> {
|
||||
* Ask for the one model that runs every phase. Providers with suggestions offer a
|
||||
* pick list with a free-text escape hatch; the rest go straight to free text.
|
||||
*/
|
||||
async function promptModel(provider: ProviderId): Promise<string> {
|
||||
const suggestions = MODEL_SUGGESTIONS[provider];
|
||||
async function promptModel(provider: string): Promise<string> {
|
||||
const suggestions = isCuratedProvider(provider) ? MODEL_SUGGESTIONS[provider] : [];
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
|
||||
return promptModelId(provider, modelIdPlaceholder(provider));
|
||||
}
|
||||
|
||||
const choice = await p.select({
|
||||
@@ -212,7 +263,7 @@ async function promptModel(provider: ProviderId): Promise<string> {
|
||||
if (p.isCancel(choice)) return cancelAndExit();
|
||||
|
||||
if (choice === CUSTOM_MODEL) {
|
||||
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
|
||||
return promptModelId(provider, modelIdPlaceholder(provider));
|
||||
}
|
||||
return choice as string;
|
||||
}
|
||||
@@ -222,13 +273,13 @@ async function promptModel(provider: ProviderId): Promise<string> {
|
||||
* one. Bedrock model IDs carry their own colons (`…-v1:0`), so only a genuine
|
||||
* provider id counts as a prefix.
|
||||
*/
|
||||
function conflictingProviderPrefix(provider: ProviderId, value: string): string | undefined {
|
||||
function conflictingProviderPrefix(provider: string, value: string): string | undefined {
|
||||
const separator = value.indexOf(':');
|
||||
if (separator === -1) return undefined;
|
||||
|
||||
const head = value.slice(0, separator);
|
||||
if (head === provider) return undefined;
|
||||
return (SUPPORTED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,10 +287,10 @@ function conflictingProviderPrefix(provider: ProviderId, value: string): string
|
||||
* and the caller pairs it with the provider — pasting a full `<provider>:<model>`
|
||||
* spec just has its redundant prefix dropped.
|
||||
*/
|
||||
async function promptModelId(provider: ProviderId, placeholder: string): Promise<string> {
|
||||
async function promptModelId(provider: string, placeholder?: string): Promise<string> {
|
||||
const modelId = await p.text({
|
||||
message: 'Model ID',
|
||||
placeholder,
|
||||
...(placeholder && { placeholder }),
|
||||
validate: (value) => {
|
||||
if (!value) return 'Model ID is required';
|
||||
const conflicting = conflictingProviderPrefix(provider, value);
|
||||
|
||||
@@ -9,11 +9,11 @@ import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
|
||||
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
|
||||
import { getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
|
||||
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
workspace,
|
||||
...(args.pipelineTesting && { pipelineTesting: true }),
|
||||
...(args.debug && { debug: true }),
|
||||
...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }),
|
||||
});
|
||||
|
||||
// 14. Bail if `docker run -d` itself fails (mount error, image missing, etc.)
|
||||
@@ -248,7 +249,7 @@ function printInfo(
|
||||
workspacesDir: string,
|
||||
): void {
|
||||
const logsCmd = isLocal() ? `./shannon logs ${workspace}` : `npx @keygraph/shannon logs ${workspace}`;
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_FILENAME);
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
|
||||
console.log(' Scan started — it runs in the background, so you can close this terminal.');
|
||||
console.log('');
|
||||
|
||||
@@ -9,7 +9,13 @@ import fs from 'node:fs';
|
||||
import { parse as parseTOML } from 'smol-toml';
|
||||
import { getConfigFile } from '../home.js';
|
||||
import { getMode } from '../mode.js';
|
||||
import { DEFAULT_MODEL_SPEC, type ProviderId, parseModelSpec } from '../model-spec.js';
|
||||
import {
|
||||
type CuratedProviderId,
|
||||
DEFAULT_MODEL_SPEC,
|
||||
GENERIC_API_KEY_ENV,
|
||||
isCuratedProvider,
|
||||
parseModelSpec,
|
||||
} from '../model-spec.js';
|
||||
|
||||
// === TOML ↔ Env Mapping ===
|
||||
|
||||
@@ -42,16 +48,22 @@ const CONFIG_MAP: readonly ConfigMapping[] = [
|
||||
// Bedrock
|
||||
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
|
||||
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
|
||||
|
||||
// Generic — credential for any provider Shannon does not curate
|
||||
{ env: GENERIC_API_KEY_ENV, toml: 'provider.api_key', type: 'string' },
|
||||
] as const;
|
||||
|
||||
/** TOML section holding each provider's credentials, keyed by provider id. */
|
||||
const PROVIDER_SECTIONS: Readonly<Record<ProviderId, string>> = {
|
||||
/** TOML section holding each curated provider's credentials, keyed by provider id. */
|
||||
const PROVIDER_SECTIONS: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'anthropic',
|
||||
openai: 'openai',
|
||||
xai: 'xai',
|
||||
'amazon-bedrock': 'bedrock',
|
||||
};
|
||||
|
||||
/** TOML section holding the generic credential for uncurated providers. */
|
||||
const GENERIC_PROVIDER_SECTION = 'provider';
|
||||
|
||||
// === TOML Parsing ===
|
||||
|
||||
type TOMLValue = string | number | boolean;
|
||||
@@ -128,9 +140,18 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
|
||||
/**
|
||||
* Check that the section backing the selected provider carries a usable
|
||||
* credential. `core.model` names the provider, so only that section is required;
|
||||
* other providers' sections are ignored and never forwarded.
|
||||
* other providers' sections are ignored and never forwarded. An uncurated
|
||||
* provider draws its credential from the generic [provider] section.
|
||||
*/
|
||||
function validateProviderFields(config: TOMLConfig, providerId: ProviderId, errors: string[]): void {
|
||||
function validateProviderFields(config: TOMLConfig, providerId: string, errors: string[]): void {
|
||||
if (!isCuratedProvider(providerId)) {
|
||||
const section = config[GENERIC_PROVIDER_SECTION] as Record<string, unknown> | undefined;
|
||||
if (!section || !Object.keys(section).includes('api_key')) {
|
||||
errors.push(`[${GENERIC_PROVIDER_SECTION}] requires api_key for provider "${providerId}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionName = PROVIDER_SECTIONS[providerId];
|
||||
const section = config[sectionName] as Record<string, unknown> | undefined;
|
||||
const keys = section ? Object.keys(section) : [];
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ShannonConfig {
|
||||
openai?: { api_key?: string; format?: string };
|
||||
xai?: { api_key?: string };
|
||||
bedrock?: { region?: string; token?: string };
|
||||
/** Generic credential for any provider Shannon does not curate. Maps to SHANNON_AI_API_KEY. */
|
||||
provider?: { api_key?: string };
|
||||
}
|
||||
|
||||
// === File Operations ===
|
||||
|
||||
@@ -12,6 +12,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { envBool, PI_AUTH_CONTAINER_PATH } from './env.js';
|
||||
import { getMode, isDevMode } from './mode.js';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
|
||||
@@ -203,7 +204,7 @@ function shouldSkipHostsName(name: string, hostname: string): boolean {
|
||||
* `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 (!envBool('SHANNON_FORWARD_HOSTS', true)) return [];
|
||||
if (os.platform() === 'win32') return [];
|
||||
|
||||
let content: string;
|
||||
@@ -255,6 +256,7 @@ export interface WorkerOptions {
|
||||
workspace: string;
|
||||
pipelineTesting?: boolean;
|
||||
debug?: boolean;
|
||||
piAuthHostPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,6 +307,11 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
args.push('-v', `${opts.outputDir}:/app/output`);
|
||||
}
|
||||
|
||||
// Reuse the host's pi credentials: mount only the auth file, allowing token refreshes to persist.
|
||||
if (opts.piAuthHostPath) {
|
||||
args.push('-v', `${opts.piAuthHostPath}:${PI_AUTH_CONTAINER_PATH}`);
|
||||
}
|
||||
|
||||
// Environment
|
||||
args.push(...opts.envFlags);
|
||||
|
||||
|
||||
+76
-19
@@ -5,32 +5,73 @@
|
||||
* NPX mode: fills gaps from ~/.shannon/config.toml (no .env).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import dotenv from 'dotenv';
|
||||
import { resolveConfig } from './config/resolver.js';
|
||||
import { getMode } from './mode.js';
|
||||
import {
|
||||
CURATED_PROVIDERS,
|
||||
type CuratedProviderId,
|
||||
GENERIC_API_KEY_ENV,
|
||||
isCuratedProvider,
|
||||
PROVIDER_API_KEY_ENV,
|
||||
PROVIDER_CREDENTIAL_HINT,
|
||||
PROVIDER_EXTRA_ENV,
|
||||
type ProviderId,
|
||||
resolveModelSpec,
|
||||
SUPPORTED_PROVIDERS,
|
||||
} from './model-spec.js';
|
||||
|
||||
/**
|
||||
* Variables forwarded to every worker container regardless of provider. Each is
|
||||
* forwarded only when set, so an unused one never appears in the container.
|
||||
* SHANNON_AI_API_KEY rides along because it is provider-neutral.
|
||||
*/
|
||||
const COMMON_FORWARD_VARS = ['SHANNON_AI_MODEL', 'SHANNON_AI_BASE_URL', 'SHANNON_AI_OPENAI_FORMAT'] as const;
|
||||
const COMMON_FORWARD_VARS = [
|
||||
'SHANNON_AI_MODEL',
|
||||
'SHANNON_AI_BASE_URL',
|
||||
'SHANNON_AI_OPENAI_FORMAT',
|
||||
GENERIC_API_KEY_ENV,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Credential variables for one provider. Only the selected provider's entries are
|
||||
* forwarded, so a key for an unused provider never enters the scan container.
|
||||
* forwarded, so a key for an unused provider never enters the scan container. An
|
||||
* uncurated provider has none — it relies on the common SHANNON_AI_API_KEY.
|
||||
*/
|
||||
function providerForwardVars(providerId: ProviderId): readonly string[] {
|
||||
function providerForwardVars(providerId: string): readonly string[] {
|
||||
if (!isCuratedProvider(providerId)) return [];
|
||||
return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]];
|
||||
}
|
||||
|
||||
/** Parse a user-facing boolean env var: `1`/`true` (any case) true, `0`/`false`/empty false, else the default. */
|
||||
export function envBool(name: string, defaultValue: boolean): boolean {
|
||||
const raw = process.env[name]?.trim().toLowerCase();
|
||||
if (raw === undefined || raw === '') return defaultValue;
|
||||
if (raw === '1' || raw === 'true') return true;
|
||||
if (raw === '0' || raw === 'false') return false;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const USE_PI_AUTH_ENV = 'SHANNON_USE_PI_AUTH';
|
||||
|
||||
/** Where the host's auth.json is mounted: pi's standard location (worker HOME is /tmp), read natively. */
|
||||
export const PI_AUTH_CONTAINER_PATH = '/tmp/.pi/agent/auth.json';
|
||||
|
||||
/** Host path to pi's credential file. */
|
||||
export function resolveHostPiAuthPath(): string {
|
||||
return path.join(os.homedir(), '.pi', 'agent', 'auth.json');
|
||||
}
|
||||
|
||||
export function piAuthFlagEnabled(): boolean {
|
||||
return envBool(USE_PI_AUTH_ENV, false);
|
||||
}
|
||||
|
||||
/** Opted into pi auth via the flag, and the auth file exists to mount. */
|
||||
export function shouldUsePiAuth(): boolean {
|
||||
return piAuthFlagEnabled() && fs.existsSync(resolveHostPiAuthPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials into process.env.
|
||||
* Local mode: loads ./.env via dotenv.
|
||||
@@ -70,22 +111,23 @@ interface CredentialValidation {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the selected provider has a usable credential in the environment. Any
|
||||
* one API key satisfies a key-based provider; Bedrock instead needs every one of
|
||||
* its AWS_ vars.
|
||||
*/
|
||||
function hasCredential(providerId: ProviderId): boolean {
|
||||
/** Whether a curated provider has its own named credential set (API key plus any extra var). */
|
||||
function hasNamedCredential(providerId: CuratedProviderId): boolean {
|
||||
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
|
||||
if (apiKeys.length > 0 && !apiKeys.some((name) => Boolean(process.env[name]))) {
|
||||
return false;
|
||||
}
|
||||
if (!apiKeys.some((name) => Boolean(process.env[name]))) return false;
|
||||
return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name]));
|
||||
}
|
||||
|
||||
/** Every provider that currently has a complete credential in the environment. */
|
||||
function configuredProviders(): ProviderId[] {
|
||||
return SUPPORTED_PROVIDERS.filter((providerId) => hasCredential(providerId));
|
||||
/** Whether the selected provider has a credential. Bedrock needs its AWS_ vars; the generic key never stands in for it. */
|
||||
function hasCredential(providerId: string): boolean {
|
||||
if (providerId === 'amazon-bedrock') return hasNamedCredential('amazon-bedrock');
|
||||
if (isCuratedProvider(providerId) && hasNamedCredential(providerId)) return true;
|
||||
return Boolean(process.env[GENERIC_API_KEY_ENV]);
|
||||
}
|
||||
|
||||
/** Curated providers with a named credential. The generic key is neutral, so it never counts toward ambiguity. */
|
||||
function configuredProviders(): CuratedProviderId[] {
|
||||
return CURATED_PROVIDERS.filter((providerId) => hasNamedCredential(providerId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,17 +135,32 @@ function configuredProviders(): ProviderId[] {
|
||||
* Runs before any Docker work so mistakes fail immediately.
|
||||
*/
|
||||
export function validateCredentials(): CredentialValidation {
|
||||
// 1. Model selection must parse and name a supported provider
|
||||
// 1. Model selection must parse into a provider and model id
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec === 'string') {
|
||||
return { valid: false, error: spec };
|
||||
}
|
||||
|
||||
// Pi-auth: skip the API-key checks, but the host auth file must exist to mount.
|
||||
if (piAuthFlagEnabled()) {
|
||||
const authPath = resolveHostPiAuthPath();
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${USE_PI_AUTH_ENV} is set but no pi credentials were found at ${authPath}. Authenticate with pi first.`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 2. The selected provider must have a credential
|
||||
if (!hasCredential(spec.providerId)) {
|
||||
const requirement = isCuratedProvider(spec.providerId)
|
||||
? PROVIDER_CREDENTIAL_HINT[spec.providerId]
|
||||
: GENERIC_API_KEY_ENV;
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
? `Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env or export it.`
|
||||
? `Set ${requirement} in .env or export it.`
|
||||
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
|
||||
return {
|
||||
valid: false,
|
||||
|
||||
+23
-18
@@ -6,24 +6,35 @@
|
||||
* rule are duplicated here deliberately and must stay in sync.
|
||||
*/
|
||||
|
||||
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
|
||||
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
/**
|
||||
* Providers Shannon curates with their own credential variables, config sections,
|
||||
* and setup flows. Any other pi provider is reachable via the generic credential
|
||||
* path. Mirrors CURATED_PROVIDERS in apps/worker/src/ai/models.ts.
|
||||
*/
|
||||
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
|
||||
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
|
||||
|
||||
export function isCuratedProvider(value: string): value is CuratedProviderId {
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Generic API key, honored for any provider Shannon does not curate. Mirrors the worker. */
|
||||
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
|
||||
|
||||
/**
|
||||
* Env vars carrying each provider's API key, in precedence order. Any one of them
|
||||
* satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
|
||||
* Env vars carrying each curated provider's API key, in precedence order. Any one of
|
||||
* them satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
xai: ['XAI_API_KEY'],
|
||||
'amazon-bedrock': ['AWS_BEARER_TOKEN_BEDROCK'],
|
||||
};
|
||||
|
||||
/** Additional env vars a provider requires beyond its API key. All must be set. */
|
||||
export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
/** Additional env vars a curated provider requires beyond its API key. All must be set. */
|
||||
export const PROVIDER_EXTRA_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: [],
|
||||
openai: [],
|
||||
xai: [],
|
||||
@@ -31,7 +42,7 @@ export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>>
|
||||
};
|
||||
|
||||
/** Human-readable credential requirement, used in "nothing configured" errors. */
|
||||
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
|
||||
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
xai: 'XAI_API_KEY',
|
||||
@@ -51,18 +62,15 @@ export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
|
||||
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
|
||||
|
||||
export interface ModelSpec {
|
||||
providerId: ProviderId;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
function isSupportedProvider(value: string): value is ProviderId {
|
||||
return (SUPPORTED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
|
||||
* inside a model ID survive (`amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`).
|
||||
* Returns an error string rather than throwing, for the CLI's validation flow.
|
||||
* The provider id is passed through as given — the worker's preflight validates it
|
||||
* against pi. Returns an error string rather than throwing, for the CLI's flow.
|
||||
*/
|
||||
export function parseModelSpec(spec: string): ModelSpec | string {
|
||||
const trimmed = spec.trim();
|
||||
@@ -74,9 +82,6 @@ export function parseModelSpec(spec: string): ModelSpec | string {
|
||||
const modelId = trimmed.slice(separator + 1).trim();
|
||||
if (!providerId || !modelId) return malformed;
|
||||
|
||||
if (!isSupportedProvider(providerId)) {
|
||||
return `Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`;
|
||||
}
|
||||
return { providerId, modelId };
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ export interface MountPair {
|
||||
export const INTERNAL_DIR = '.shannon';
|
||||
|
||||
/**
|
||||
* Filename of the human-facing final report surfaced at the run directory root.
|
||||
* Must match FINAL_REPORT_FILENAME in the worker package.
|
||||
* Filename of the human-facing PDF report surfaced at the run directory root.
|
||||
* Must match FINAL_REPORT_PDF_FILENAME in the worker package.
|
||||
*/
|
||||
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/**
|
||||
* Resolve a run-directory file (e.g. session.json, workflow.log), preferring the
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Human-readable description of the rule"
|
||||
},
|
||||
@@ -222,7 +221,7 @@
|
||||
"description": "Value to match"
|
||||
}
|
||||
},
|
||||
"required": ["description", "type", "value"],
|
||||
"required": ["type", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ Fields:
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity distribution, most critical issues, and overall risk demonstrated by exploitation. If no vulnerabilities were confirmed in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
</exploit_mode_summary>
|
||||
<analysis_mode_summary>
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven. Findings carry no severity rating in this mode, so do not assert one. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
- `executive_summary`: 2-3 sentences summarizing the security posture for technical leadership (CTOs, CISOs, Engineering VPs). Must include the target URL and assessment date. Provide a high-level characterization based on the findings — severity and confidence distribution, the most serious weaknesses identified, and overall risk. State plainly that this was an analysis-only assessment and that no finding was confirmed by exploitation; do not describe risk as demonstrated or proven, and present severity as assessed rather than measured. If no vulnerabilities were identified in the assessed classes, state that scope clearly. A clean report is valid only when no <not_assessed_classes> block is present. If that block is present, explicitly say the listed classes were not assessed and do not assert they are free of vulnerabilities.
|
||||
</analysis_mode_summary>
|
||||
</record_report_meta>
|
||||
|
||||
@@ -112,6 +112,7 @@ Map the finding's content from the per-class deliverable sections to `add_findin
|
||||
</exploit_mode_fields>
|
||||
<analysis_mode_fields>
|
||||
- `confidence`: From the finding's "Confidence" field. Use as-is; do not reassess.
|
||||
- `severity`: The analysis deliverables carry no severity field — no exploit ran to measure impact. Assess it from the vulnerability class and the impact you describe. It is an assessed rating, not a measured one.
|
||||
</analysis_mode_fields>
|
||||
- `owasp_category`: Map to the appropriate OWASP Top 10 (2025) category:
|
||||
- `"A01:2025 — Broken Access Control"`
|
||||
@@ -139,7 +140,7 @@ Map the finding's content from the per-class deliverable sections to `add_findin
|
||||
<analysis_mode_fields>
|
||||
- `impact`: What an attacker could achieve if this vulnerability were exploited. Derive it from the finding's "Impact" and "Overview" fields. Write it as assessed, never as achieved.
|
||||
|
||||
This run had no exploitation phase. Nothing was executed against the target, nothing was demonstrated, and no exploit evidence exists. Accordingly `severity`, `auth_state`, `prerequisites`, `exploitation_steps`, `proof_of_impact` and `status` are **not** part of your tool schema — the deliverables contain no source for any of them. `confidence` is the only rating this run produces; take it straight from the deliverable. Do not compensate for the missing fields by describing attack execution in `overview`, `impact` or `notes`. Report the weakness and how to fix it; that is the whole deliverable for this run.
|
||||
This run had no exploitation phase. Nothing was executed against the target, nothing was demonstrated, and no exploit evidence exists. Accordingly `auth_state`, `prerequisites`, `exploitation_steps`, `proof_of_impact` and `status` are **not** part of your tool schema — the deliverables contain no source for any of them. `confidence` is the deliverable's own rating and carries over verbatim; `severity` is yours to assess, since nothing measured it. Do not compensate for the missing fields by describing attack execution in `overview`, `impact` or `notes`. Report the weakness and how to fix it; that is the whole deliverable for this run.
|
||||
</analysis_mode_fields>
|
||||
|
||||
**Optional fields:**
|
||||
@@ -160,6 +161,7 @@ If no valid findings exist after filtering, do not call `add_finding` at all. Th
|
||||
- **No Fabrications:** Every piece of data must come from the deliverable files. If a finding has incomplete data, include it but note the gap in `overview`.
|
||||
- **Nothing Was Demonstrated:** No exploit ran. Do not write that a vulnerability was confirmed, proven, exploited, or verified against the running target, and do not describe payloads, requests, or responses as having been sent.
|
||||
- **No Confidence Changes:** Use the confidence from the deliverable as-is. Do not raise or lower it.
|
||||
- **Severity Is Assessed:** Rate severity from the vulnerability class and the impact you describe. Never present it as measured or demonstrated.
|
||||
</analysis_mode_constraints>
|
||||
- **No Speculation:** Only record findings that appear in the deliverables with valid vulnerability IDs. Do not add your own assessments.
|
||||
- **OWASP 2025:** Map all findings to OWASP Top 10 (2025) categories.
|
||||
@@ -187,6 +189,7 @@ Before finalizing, verify:
|
||||
</exploit_mode_checks>
|
||||
<analysis_mode_checks>
|
||||
- [ ] Does every finding have `confidence` carried over unchanged from the deliverable?
|
||||
- [ ] Is every `severity` assessed from the impact I described, with no claim that it was measured?
|
||||
- [ ] Is every `impact` phrased as assessed rather than demonstrated, with no claim that anything was executed?
|
||||
</analysis_mode_checks>
|
||||
- [ ] Are remediation recommendations specific and actionable (not generic)?
|
||||
|
||||
@@ -21,21 +21,34 @@
|
||||
* built over an in-memory credential store primed from the environment.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Api, Credential, CredentialInfo, CredentialStore, Model } from '@earendil-works/pi-ai';
|
||||
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
|
||||
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
|
||||
import { getAgentDir, ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
/**
|
||||
* Env vars carrying each provider's API key, in precedence order. Shannon does not
|
||||
* invent credential names — these are the variables each provider's own tooling
|
||||
* uses. Bedrock pairs its bearer token with AWS_REGION, which is provider config
|
||||
* rather than a credential.
|
||||
* Providers Shannon curates with their own credential variables, config sections,
|
||||
* and setup flows. Each is a pi-ai provider id; any other pi provider is still
|
||||
* reachable through the generic credential path below.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
|
||||
export const CURATED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
|
||||
|
||||
export type CuratedProviderId = (typeof CURATED_PROVIDERS)[number];
|
||||
|
||||
function isCuratedProvider(value: string): value is CuratedProviderId {
|
||||
return (CURATED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Generic API key, honored for any provider Shannon does not curate. */
|
||||
export const GENERIC_API_KEY_ENV = 'SHANNON_AI_API_KEY';
|
||||
|
||||
/**
|
||||
* Env vars carrying each curated provider's API key, in precedence order. Shannon
|
||||
* does not invent credential names — these are the variables each provider's own
|
||||
* tooling uses. Bedrock pairs its bearer token with AWS_REGION, which is provider
|
||||
* config rather than a credential.
|
||||
*/
|
||||
export const PROVIDER_API_KEY_ENV: Readonly<Record<CuratedProviderId, readonly string[]>> = {
|
||||
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
||||
openai: ['OPENAI_API_KEY'],
|
||||
xai: ['XAI_API_KEY'],
|
||||
@@ -45,6 +58,9 @@ export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]
|
||||
/** Model used when SHANNON_AI_MODEL is unset. */
|
||||
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
|
||||
|
||||
/** Browsable pi model catalogue — the source of valid `<provider>:<model-id>` ids. */
|
||||
export const PI_CATALOG_URL = 'https://pi.dev/models';
|
||||
|
||||
/**
|
||||
* Wire formats an OpenAI-compatible gateway may serve, named by
|
||||
* SHANNON_AI_OPENAI_FORMAT. Only `openai` offers a choice: every other supported
|
||||
@@ -82,18 +98,14 @@ export function resolveOpenAiFormat(): OpenAiFormat | undefined {
|
||||
}
|
||||
|
||||
export interface ModelSpec {
|
||||
providerId: ProviderId;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
function isSupportedProvider(value: string): value is ProviderId {
|
||||
return (SUPPORTED_PROVIDERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so
|
||||
* colons inside a model ID survive. Throws with the supported provider list on
|
||||
* a malformed or unknown provider.
|
||||
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
|
||||
* inside a model ID survive. The provider id is passed through as given — pi's
|
||||
* registry validates it later — so this throws only on a malformed spec.
|
||||
*/
|
||||
export function parseModelSpec(spec: string): ModelSpec {
|
||||
const trimmed = spec.trim();
|
||||
@@ -112,11 +124,6 @@ export function parseModelSpec(spec: string): ModelSpec {
|
||||
`SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`,
|
||||
);
|
||||
}
|
||||
if (!isSupportedProvider(providerId)) {
|
||||
throw new Error(
|
||||
`Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { providerId, modelId };
|
||||
}
|
||||
@@ -133,17 +140,25 @@ export interface ProviderCredentials {
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
/** Collect the API key and optional endpoint override for a provider. */
|
||||
export function resolveProviderCredentials(providerId: ProviderId): ProviderCredentials {
|
||||
/**
|
||||
* Collect the API key and optional endpoint override for a provider. A curated
|
||||
* provider's own variables win, then the generic SHANNON_AI_API_KEY. Bedrock is
|
||||
* excluded — it authenticates through its AWS_ variables, which pi reads directly.
|
||||
*/
|
||||
export function resolveProviderCredentials(providerId: string): ProviderCredentials {
|
||||
const credentials: ProviderCredentials = {};
|
||||
|
||||
for (const name of PROVIDER_API_KEY_ENV[providerId]) {
|
||||
const namedVars = isCuratedProvider(providerId) ? PROVIDER_API_KEY_ENV[providerId] : [];
|
||||
for (const name of namedVars) {
|
||||
const value = process.env[name];
|
||||
if (value) {
|
||||
credentials.apiKey = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!credentials.apiKey && providerId !== 'amazon-bedrock' && process.env[GENERIC_API_KEY_ENV]) {
|
||||
credentials.apiKey = process.env[GENERIC_API_KEY_ENV];
|
||||
}
|
||||
if (process.env.SHANNON_AI_BASE_URL) credentials.baseUrl = process.env.SHANNON_AI_BASE_URL;
|
||||
|
||||
return credentials;
|
||||
@@ -190,12 +205,29 @@ class RuntimeCredentialStore implements CredentialStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** The file pi reads credentials from: the agent dir's auth.json. */
|
||||
function piAuthPath(): string {
|
||||
return path.join(getAgentDir(), 'auth.json');
|
||||
}
|
||||
|
||||
/** Whether the host's pi credentials are mounted (auth.json present in the agent dir). */
|
||||
export function piAuthPresent(): boolean {
|
||||
return existsSync(piAuthPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ModelRuntime whose only credential is the one supplied. Model catalogs
|
||||
* stay offline (`allowModelNetwork` defaults to false) so a scan never blocks on
|
||||
* a catalog refresh.
|
||||
*
|
||||
* When the host's pi auth.json is present, the runtime reads it instead: pi's
|
||||
* disk-backed store resolves the credential. The mount is writable so OAuth
|
||||
* refreshes persist to the host for subsequent runs.
|
||||
*/
|
||||
export async function createModelRuntime(providerId: string, apiKey: string | undefined): Promise<ModelRuntime> {
|
||||
if (piAuthPresent()) {
|
||||
return ModelRuntime.create({ authPath: piAuthPath() });
|
||||
}
|
||||
return ModelRuntime.create({ credentials: new RuntimeCredentialStore(providerId, apiKey) });
|
||||
}
|
||||
|
||||
@@ -203,7 +235,7 @@ export interface ModelSelection {
|
||||
model: Model<Api>;
|
||||
modelRuntime: ModelRuntime;
|
||||
modelId: string;
|
||||
providerId: ProviderId;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +250,7 @@ export interface ModelSelection {
|
||||
* then describes the format in use. Every other provider has one API and only
|
||||
* changes address.
|
||||
*/
|
||||
function pointAtGateway(model: Model<Api>, providerId: ProviderId, baseUrl: string, format: OpenAiFormat): Model<Api> {
|
||||
function pointAtGateway(model: Model<Api>, providerId: string, baseUrl: string, format: OpenAiFormat): Model<Api> {
|
||||
if (providerId !== 'openai') return { ...model, baseUrl };
|
||||
if (format === 'responses') return { ...model, baseUrl, api: OPENAI_FORMATS.responses };
|
||||
|
||||
@@ -240,7 +272,7 @@ function pointAtGateway(model: Model<Api>, providerId: ProviderId, baseUrl: stri
|
||||
*/
|
||||
export function resolveModel(
|
||||
modelRuntime: ModelRuntime,
|
||||
providerId: ProviderId,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
baseUrl: string | undefined,
|
||||
format: OpenAiFormat = DEFAULT_OPENAI_FORMAT,
|
||||
@@ -265,7 +297,7 @@ export function resolveModel(
|
||||
* are configured, so it is rejected outside that combination rather than
|
||||
* silently ignored.
|
||||
*/
|
||||
export function resolveGatewayFormat(providerId: ProviderId, baseUrl: string | undefined): OpenAiFormat {
|
||||
export function resolveGatewayFormat(providerId: string, baseUrl: string | undefined): OpenAiFormat {
|
||||
const configured = resolveOpenAiFormat();
|
||||
if (!configured) return DEFAULT_OPENAI_FORMAT;
|
||||
|
||||
@@ -296,7 +328,9 @@ export async function resolveModelSelection(): Promise<ModelSelection> {
|
||||
|
||||
const model = resolveModel(modelRuntime, providerId, modelId, credentials.baseUrl, format);
|
||||
if (!model) {
|
||||
throw new Error(`Model not found in pi registry: provider="${providerId}" model="${modelId}"`);
|
||||
throw new Error(
|
||||
`Model not found in pi registry: provider="${providerId}" model="${modelId}". Browse valid providers and models at ${PI_CATALOG_URL}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -37,7 +37,7 @@ const OWASP_CATEGORY_VALUES = [
|
||||
'A10:2025 — Mishandling of Exceptional Conditions',
|
||||
] as const;
|
||||
|
||||
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low', 'informational'] as const;
|
||||
const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const;
|
||||
const STATUS_VALUES = ['exploited', 'out_of_scope', 'blocked_by_constraints', 'false_positive'] as const;
|
||||
const CONFIDENCE_VALUES = ['high', 'medium', 'low'] as const;
|
||||
|
||||
@@ -117,8 +117,18 @@ const AdditionalSectionSchema = Type.Object({
|
||||
}),
|
||||
});
|
||||
|
||||
function identityFields() {
|
||||
/**
|
||||
* `severity` is recorded in both modes, but it does not mean the same thing in each: an exploit
|
||||
* run measures it from what the exploit demonstrated, an analysis run assesses it from the class
|
||||
* of flaw. The description says which, so the agent never presents an assessment as a measurement.
|
||||
*/
|
||||
function identityFields(exploit: boolean) {
|
||||
const severityDescription = exploit
|
||||
? 'Severity of the finding, based on the impact the exploit demonstrated.'
|
||||
: 'Severity of the finding, assessed from the vulnerability class and the impact it would have.';
|
||||
|
||||
return {
|
||||
severity: stringEnum(SEVERITY_VALUES, { description: severityDescription }),
|
||||
finding_id: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Finding identifier (e.g., "AUTH-VULN-07", "INJ-VULN-03"). Must be unique per report.',
|
||||
@@ -178,9 +188,6 @@ function narrativeFields(exploit: boolean) {
|
||||
/** Fields that only mean something once an exploit has run. Absent from the analysis schema. */
|
||||
function exploitOnlyFields() {
|
||||
return {
|
||||
severity: stringEnum(SEVERITY_VALUES, {
|
||||
description: 'Severity of the finding, based on the impact the exploit demonstrated.',
|
||||
}),
|
||||
auth_state: Type.String({
|
||||
minLength: 1,
|
||||
description: 'Authentication state during testing (e.g., "Unauthenticated", "Any authenticated user").',
|
||||
@@ -205,7 +212,7 @@ function exploitOnlyFields() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces `severity` when nothing was exploited. */
|
||||
/** Accompanies `severity` when nothing was exploited — the rating the analysis deliverable itself carries. */
|
||||
function analysisOnlyFields() {
|
||||
return {
|
||||
confidence: stringEnum(CONFIDENCE_VALUES, {
|
||||
@@ -233,7 +240,7 @@ function sharedOptionalFields() {
|
||||
|
||||
export function buildAddFindingSchema(exploit: boolean) {
|
||||
return Type.Object({
|
||||
...identityFields(),
|
||||
...identityFields(exploit),
|
||||
...(exploit ? exploitOnlyFields() : analysisOnlyFields()),
|
||||
...locationFields(),
|
||||
...narrativeFields(exploit),
|
||||
@@ -243,12 +250,12 @@ export function buildAddFindingSchema(exploit: boolean) {
|
||||
|
||||
/**
|
||||
* Superset of both modes, for typing only. Consumers must check presence rather than assume:
|
||||
* `report.json` from an analysis run has no `severity` or `exploitation_steps` key at all.
|
||||
* `report.json` from an analysis run has no `exploitation_steps` key at all. `severity` is the
|
||||
* exception — both modes record it, so it is required here too.
|
||||
*/
|
||||
const AddFindingSupersetSchema = Type.Object({
|
||||
...identityFields(),
|
||||
...identityFields(true),
|
||||
code_locations: Type.Optional(Type.Array(CodeLocationSchema)),
|
||||
severity: Type.Optional(stringEnum(SEVERITY_VALUES)),
|
||||
auth_state: Type.Optional(Type.String()),
|
||||
prerequisites: Type.Optional(Type.String()),
|
||||
exploitation_steps: Type.Optional(Type.Array(StructuredStepSchema)),
|
||||
|
||||
@@ -514,7 +514,7 @@ const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): voi
|
||||
ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
if (pattern.test(rule.description)) {
|
||||
if (rule.description !== undefined && pattern.test(rule.description)) {
|
||||
throw new PentestError(
|
||||
`rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`,
|
||||
'config',
|
||||
@@ -656,11 +656,15 @@ const checkForConflicts = (avoidRules: Rule[] = [], focusRules: Rule[] = []): vo
|
||||
};
|
||||
|
||||
const sanitizeRule = (rule: Rule): Rule => {
|
||||
return {
|
||||
description: rule.description.trim(),
|
||||
const sanitized: Rule = {
|
||||
type: rule.type.toLowerCase().trim() as Rule['type'],
|
||||
value: rule.value.trim(),
|
||||
};
|
||||
const description = rule.description?.trim();
|
||||
if (description) {
|
||||
sanitized.description = description;
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const distributeConfig = (config: Config | null): DistributedConfig => {
|
||||
@@ -702,13 +706,15 @@ const sanitizeAuthentication = (auth: Authentication): Authentication => {
|
||||
credentials: {
|
||||
username: auth.credentials.username.trim(),
|
||||
...(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.replace(/\s/g, ''),
|
||||
}),
|
||||
...(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(),
|
||||
totp_secret: auth.credentials.email_login.totp_secret.replace(/\s/g, ''),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,9 @@ const WORKER_ROOT = path.resolve(import.meta.dirname, '..');
|
||||
export const PROMPTS_DIR = path.join(WORKER_ROOT, 'prompts');
|
||||
export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs');
|
||||
|
||||
/** Bundled Typst template that renders report.json into the PDF report. */
|
||||
export const TYPST_TEMPLATE = path.join(WORKER_ROOT, 'templates', 'typst', 'report.typ');
|
||||
|
||||
/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */
|
||||
export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout');
|
||||
|
||||
@@ -28,8 +31,11 @@ export const INTERNAL_DIR = '.shannon';
|
||||
/** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
|
||||
|
||||
/** Filename of the human-facing final report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
|
||||
/** Filename of the compiled PDF report inside the deliverables dir (internal, source of the surfaced copy) */
|
||||
export const ASSEMBLED_REPORT_PDF_FILENAME = 'comprehensive_security_assessment_report.pdf';
|
||||
|
||||
/** Filename of the human-facing PDF report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/** Structured findings the report agent emits; the markdown report is rendered from it. */
|
||||
export const REPORT_JSON_FILENAME = 'report.json';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Typst PDF renderer.
|
||||
*
|
||||
* Adapts the structured report.json into the Typst-shaped schema and compiles
|
||||
* it to a PDF with the bundled report.typ template. Compilation runs in an
|
||||
* isolated temp dir: the template is copied in and the adapted JSON is written
|
||||
* beside it so `--root` can scope every file read to that dir, matching how the
|
||||
* template resolves `--input data=/data.json`.
|
||||
*
|
||||
* The `typst` binary is installed in the worker image and resolved from PATH.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { adaptReportToTypst } from './report-json-adapter.js';
|
||||
import type { ReportData } from './report-renderer.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TESTER = 'Shannon';
|
||||
const DEFAULT_BRAND = 'Shannon | AI Pentester by Keygraph';
|
||||
|
||||
const DATA_FILENAME = 'data.json';
|
||||
const TEMPLATE_FILENAME = 'report.typ';
|
||||
const OUTPUT_FILENAME = 'report.pdf';
|
||||
|
||||
export interface RenderReportPdfOptions {
|
||||
/** Structured report data (report.json contents), pre-assembly. */
|
||||
readonly reportData: ReportData;
|
||||
/** Absolute path to the bundled report.typ template. */
|
||||
readonly templatePath: string;
|
||||
/** Absolute path where the compiled PDF should be written. */
|
||||
readonly outputPath: string;
|
||||
/** Name shown on the cover/footer. Defaults to "Shannon". */
|
||||
readonly tester?: string;
|
||||
/** Wordmark shown on the cover. Defaults to "Shannon | AI Pentester by Keygraph". */
|
||||
readonly brand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the report to a PDF at `outputPath`.
|
||||
*
|
||||
* Throws if adaptation or `typst compile` fails; callers treat the PDF as a
|
||||
* secondary artifact and should not let a failure here fail the run.
|
||||
*/
|
||||
export async function renderReportPdf(options: RenderReportPdfOptions): Promise<void> {
|
||||
const { reportData, templatePath, outputPath } = options;
|
||||
const tester = options.tester ?? DEFAULT_TESTER;
|
||||
const brand = options.brand ?? DEFAULT_BRAND;
|
||||
|
||||
const typstData = adaptReportToTypst(reportData);
|
||||
|
||||
const workDir = await mkdtemp(path.join(tmpdir(), 'shannon-typst-'));
|
||||
try {
|
||||
const templateInWorkDir = path.join(workDir, TEMPLATE_FILENAME);
|
||||
const dataInWorkDir = path.join(workDir, DATA_FILENAME);
|
||||
const pdfInWorkDir = path.join(workDir, OUTPUT_FILENAME);
|
||||
|
||||
await copyFile(templatePath, templateInWorkDir);
|
||||
|
||||
// Ship the template's assets (e.g. the cover logo) so `--root`-scoped image reads resolve.
|
||||
const assetsDir = path.join(path.dirname(templatePath), 'assets');
|
||||
if (existsSync(assetsDir)) {
|
||||
await cp(assetsDir, path.join(workDir, 'assets'), { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(dataInWorkDir, JSON.stringify(typstData), 'utf-8');
|
||||
|
||||
await execFileAsync('typst', [
|
||||
'compile',
|
||||
'--root',
|
||||
workDir,
|
||||
'--input',
|
||||
`data=/${DATA_FILENAME}`,
|
||||
'--input',
|
||||
`tester=${tester}`,
|
||||
'--input',
|
||||
`brand=${brand}`,
|
||||
templateInWorkDir,
|
||||
pdfInWorkDir,
|
||||
]);
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await copyFile(pdfInWorkDir, outputPath);
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,13 @@ import {
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { glob } from 'zx';
|
||||
import {
|
||||
type CuratedProviderId,
|
||||
createModelRuntime,
|
||||
GENERIC_API_KEY_ENV,
|
||||
type ModelSpec,
|
||||
type OpenAiFormat,
|
||||
type ProviderId,
|
||||
PI_CATALOG_URL,
|
||||
piAuthPresent,
|
||||
resolveGatewayFormat,
|
||||
resolveModel,
|
||||
resolveModelSpec,
|
||||
@@ -179,7 +182,7 @@ type RuleKind = 'avoid' | 'focus';
|
||||
interface MissingCodePath {
|
||||
kind: RuleKind;
|
||||
value: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function validateCodePathsExist(
|
||||
@@ -202,12 +205,16 @@ async function validateCodePathsExist(
|
||||
const missing: MissingCodePath[] = [];
|
||||
for (const { kind, rule } of tagged) {
|
||||
if (!(await patternMatchesAny(repoPath, rule.value))) {
|
||||
missing.push({ kind, value: rule.value, description: rule.description });
|
||||
const entry: MissingCodePath = { kind, value: rule.value };
|
||||
if (rule.description) {
|
||||
entry.description = rule.description;
|
||||
}
|
||||
missing.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
const lines = missing.map((m) => `[${m.kind}] '${m.value}' — ${m.description}`);
|
||||
const lines = missing.map((m) => `[${m.kind}] '${m.value}'${m.description ? ` - ${m.description}` : ''}`);
|
||||
return err(
|
||||
new PentestError(
|
||||
`code_path rules don't match any file or directory in the repo:\n - ${lines.join('\n - ')}\n` +
|
||||
@@ -273,17 +280,24 @@ async function probeCredentialsWithPi(
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Credential env var a provider reads, for "credential missing" messages. */
|
||||
const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
|
||||
/** Credential env var a curated provider reads, for "credential missing" messages. */
|
||||
const PROVIDER_CREDENTIAL_HINT: Readonly<Record<CuratedProviderId, string>> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
xai: 'XAI_API_KEY',
|
||||
'amazon-bedrock': 'AWS_BEARER_TOKEN_BEDROCK and AWS_REGION',
|
||||
};
|
||||
|
||||
/** Which variable to set when a provider's credential is missing. */
|
||||
function credentialHint(providerId: string): string {
|
||||
const curated = (PROVIDER_CREDENTIAL_HINT as Record<string, string | undefined>)[providerId];
|
||||
return curated ?? GENERIC_API_KEY_ENV;
|
||||
}
|
||||
|
||||
/** Human-readable label for which credential path a run is using. */
|
||||
function describeAuth(providerId: ProviderId, baseUrl: string | undefined): string {
|
||||
function describeAuth(providerId: string, baseUrl: string | undefined): string {
|
||||
if (baseUrl) return `custom endpoint (${baseUrl})`;
|
||||
if (piAuthPresent()) return `${providerId} credentials from pi auth.json`;
|
||||
if (providerId === 'amazon-bedrock') return 'Bedrock bearer token';
|
||||
return `${providerId} API key`;
|
||||
}
|
||||
@@ -329,12 +343,14 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
);
|
||||
}
|
||||
|
||||
// With a mounted pi auth.json the env-var checks don't apply — step 5's probe validates it.
|
||||
const isBedrock = spec.providerId === 'amazon-bedrock';
|
||||
const missing = isBedrock ? ['AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK'].filter((n) => !process.env[n]) : [];
|
||||
if (missing.length > 0 || (!isBedrock && !credentials.apiKey)) {
|
||||
const missing =
|
||||
isBedrock && !piAuthPresent() ? ['AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK'].filter((n) => !process.env[n]) : [];
|
||||
if (!piAuthPresent() && (missing.length > 0 || (!isBedrock && !credentials.apiKey))) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`No credentials found for provider "${spec.providerId}". Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env.`,
|
||||
`No credentials found for provider "${spec.providerId}". Set ${credentialHint(spec.providerId)} in .env.`,
|
||||
'config',
|
||||
false,
|
||||
{ providerId: spec.providerId, ...(missing.length > 0 && { missing }) },
|
||||
@@ -352,7 +368,7 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
|
||||
if (!baseModel) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL.`,
|
||||
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL — browse valid providers and models at ${PI_CATALOG_URL}.`,
|
||||
'config',
|
||||
false,
|
||||
{ providerId: spec.providerId, modelId: spec.modelId },
|
||||
|
||||
@@ -12,61 +12,32 @@ import type { Authentication, DistributedConfig, DistributedReportConfig, Rule,
|
||||
import { isGlobPattern } from '../utils/glob.js';
|
||||
import { handlePromptError, PentestError } from './error-handling.js';
|
||||
|
||||
function renderRuleLine(tag: string, value: string, description?: string): string {
|
||||
const base = `- ${tag} ${value}`;
|
||||
return description ? `${base} - ${description}` : base;
|
||||
}
|
||||
|
||||
function renderUrlRules(rules: Rule[]): string {
|
||||
if (rules.length === 0) return 'None';
|
||||
return rules.map((r) => renderRuleLine(`[${r.type.toUpperCase()}]`, r.value, r.description)).join('\n');
|
||||
}
|
||||
|
||||
function renderCodePathRules(rules: Rule[]): string {
|
||||
const filtered = rules.filter((r) => r.type === 'code_path');
|
||||
if (filtered.length === 0) return 'None';
|
||||
return filtered
|
||||
.map((r) => {
|
||||
const kind = isGlobPattern(r.value) ? '[GLOB]' : '[FILE]';
|
||||
return `- ${r.value} ${kind} — ${r.description}`;
|
||||
})
|
||||
.map((r) => renderRuleLine(isGlobPattern(r.value) ? '[GLOB]' : '[FILE]', r.value, r.description))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
interface VulnSummarySpec {
|
||||
readonly heading: string;
|
||||
readonly evidenceSection: string;
|
||||
readonly noneFoundLabel: string;
|
||||
}
|
||||
|
||||
const VULN_SUMMARY_SPECS: Record<VulnClass, VulnSummarySpec> = {
|
||||
auth: {
|
||||
heading: 'Authentication Vulnerabilities',
|
||||
evidenceSection: 'Authentication Exploitation Evidence',
|
||||
noneFoundLabel: 'authentication',
|
||||
},
|
||||
authz: {
|
||||
heading: 'Authorization Vulnerabilities',
|
||||
evidenceSection: 'Authorization Exploitation Evidence',
|
||||
noneFoundLabel: 'authorization',
|
||||
},
|
||||
xss: {
|
||||
heading: 'Cross-Site Scripting (XSS) Vulnerabilities',
|
||||
evidenceSection: 'XSS Exploitation Evidence',
|
||||
noneFoundLabel: 'XSS',
|
||||
},
|
||||
injection: {
|
||||
heading: 'SQL/Command Injection Vulnerabilities',
|
||||
evidenceSection: 'Injection Exploitation Evidence',
|
||||
noneFoundLabel: 'SQL or command injection',
|
||||
},
|
||||
ssrf: {
|
||||
heading: 'Server-Side Request Forgery (SSRF) Vulnerabilities',
|
||||
evidenceSection: 'SSRF Exploitation Evidence',
|
||||
noneFoundLabel: 'SSRF',
|
||||
},
|
||||
const VULN_CLASS_HEADINGS: Record<VulnClass, string> = {
|
||||
auth: 'Authentication Vulnerabilities',
|
||||
authz: 'Authorization Vulnerabilities',
|
||||
xss: 'Cross-Site Scripting (XSS) Vulnerabilities',
|
||||
injection: 'SQL/Command Injection Vulnerabilities',
|
||||
ssrf: 'Server-Side Request Forgery (SSRF) Vulnerabilities',
|
||||
};
|
||||
|
||||
function renderVulnSummarySubsections(selected: readonly VulnClass[]): string {
|
||||
const classes = selected.length > 0 ? selected : (Object.keys(VULN_SUMMARY_SPECS) as VulnClass[]);
|
||||
return classes
|
||||
.map((cls) => {
|
||||
const spec = VULN_SUMMARY_SPECS[cls];
|
||||
return `**${spec.heading}:**\n{Check for "${spec.evidenceSection}" section. Include actually exploited vulnerabilities and those blocked by security controls. Exclude theoretical vulnerabilities requiring internal network access. If vulnerabilities exist, summarize their impact and severity. If section is missing or empty, state: "No ${spec.noneFoundLabel} vulnerabilities were found."}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the <not_assessed_classes> block. Empty when every class completed.
|
||||
*
|
||||
@@ -86,9 +57,8 @@ function renderNotAssessedClassesBlock(failed: readonly VulnClass[] = []): strin
|
||||
];
|
||||
|
||||
for (const cls of classes) {
|
||||
const spec = VULN_SUMMARY_SPECS[cls];
|
||||
lines.push(
|
||||
`- ${spec.heading}: analysis did not complete; this class was NOT assessed. Absence of findings here does not indicate the class is clean.`,
|
||||
`- ${VULN_CLASS_HEADINGS[cls]}: analysis did not complete; this class was NOT assessed. Absence of findings here does not indicate the class is clean.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,13 +73,13 @@ function renderNotAssessedClassesBlock(failed: readonly VulnClass[] = []): strin
|
||||
/**
|
||||
* Which configured filters this run can actually enforce.
|
||||
*
|
||||
* The two ratings are mode-exclusive (see ../collectors/finding-collector.ts): an exploited
|
||||
* finding carries `severity`, an analysed one carries `confidence`. Handing the agent a
|
||||
* threshold for the rating its findings do not have is a directive it cannot honor.
|
||||
* Every finding carries `severity` (see ../collectors/finding-collector.ts), so a severity
|
||||
* threshold always applies. `confidence` exists only on an analysed finding — handing an
|
||||
* exploit run a confidence threshold is a directive it cannot honor.
|
||||
*/
|
||||
function applicableFilters(report: DistributedReportConfig | undefined, exploitEnabled: boolean) {
|
||||
return {
|
||||
severity: Boolean(report?.min_severity) && exploitEnabled,
|
||||
severity: Boolean(report?.min_severity),
|
||||
confidence: Boolean(report?.min_confidence) && !exploitEnabled,
|
||||
guidance: Boolean(report?.guidance?.trim()),
|
||||
};
|
||||
@@ -375,8 +345,8 @@ async function interpolateVariables(
|
||||
if (avoidUrlRules.length === 0 && focusUrlRules.length === 0) {
|
||||
result = result.replace(/<rules>[\s\S]*?<\/rules>\s*/g, '');
|
||||
} else {
|
||||
const avoidStr = avoidUrlRules.length > 0 ? avoidUrlRules.map((r) => `- ${r.description}`).join('\n') : 'None';
|
||||
const focusStr = focusUrlRules.length > 0 ? focusUrlRules.map((r) => `- ${r.description}`).join('\n') : 'None';
|
||||
const avoidStr = renderUrlRules(avoidUrlRules);
|
||||
const focusStr = renderUrlRules(focusUrlRules);
|
||||
result = replaceLiteral(result, /{{RULES_AVOID}}/g, avoidStr);
|
||||
result = replaceLiteral(result, /{{RULES_FOCUS}}/g, focusStr);
|
||||
}
|
||||
@@ -416,7 +386,6 @@ async function interpolateVariables(
|
||||
/{{VULN_CLASSES_TESTED}}/g,
|
||||
vulnClasses.length > 0 ? vulnClasses.join(', ') : 'injection, xss, auth, authz, ssrf',
|
||||
);
|
||||
result = replaceLiteral(result, /{{VULN_SUMMARY_SUBSECTIONS}}/g, renderVulnSummarySubsections(vulnClasses));
|
||||
result = replaceLiteral(
|
||||
result,
|
||||
/{{NOT_ASSESSED_CLASSES}}/g,
|
||||
@@ -432,19 +401,12 @@ async function interpolateVariables(
|
||||
result = result.replace(/<\/?(?:exploit|analysis)_mode_[a-z_]+>\n?/g, '');
|
||||
|
||||
result = replaceLiteral(result, /{{EXPLOITATION}}/g, exploitEnabled ? 'enabled' : 'disabled');
|
||||
result = replaceLiteral(result, /{{REPORT_VULN_HEADING}}/g, exploitEnabled ? 'Exploitation Evidence' : 'Findings');
|
||||
result = replaceLiteral(
|
||||
result,
|
||||
/{{REPORT_VULN_SUBHEADING}}/g,
|
||||
exploitEnabled ? 'Successfully Exploited Vulnerabilities' : 'Identified Vulnerabilities',
|
||||
);
|
||||
|
||||
if (config?.report?.min_severity && !exploitEnabled) {
|
||||
logger.warn(
|
||||
`report.min_severity="${config.report.min_severity}" is ignored when exploit=false: an ` +
|
||||
'analysis-only run rates findings by confidence, not severity. Use report.min_confidence.',
|
||||
);
|
||||
}
|
||||
if (config?.report?.min_confidence && exploitEnabled) {
|
||||
logger.warn(
|
||||
`report.min_confidence="${config.report.min_confidence}" is ignored when exploit=true: an ` +
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Programmatic adapter: report.json → Typst ReportData JSON.
|
||||
*
|
||||
* Converts the renderer-neutral structured report output (produced by the
|
||||
* finding-collector + set-report-meta CLI) into the Typst-specific schema that
|
||||
* report.typ consumes.
|
||||
*
|
||||
* All Typst-specific concepts (PascalCase enums, computed aggregations,
|
||||
* exploitedByType grouping) are confined to this file. The rest of the
|
||||
* pipeline knows nothing about the Typst shape.
|
||||
*/
|
||||
|
||||
import type { AddFindingInput, AdditionalSection, StepItem, StructuredStep } from '../collectors/finding-collector.js';
|
||||
import type {
|
||||
ExploitsReportData,
|
||||
FindingsReportData,
|
||||
TypstCategory,
|
||||
TypstConfidence,
|
||||
ReportData as TypstReportData,
|
||||
TypstSeverity,
|
||||
TypstStatus,
|
||||
} from './report-output-schema.js';
|
||||
import type { ReportData } from './report-renderer.js';
|
||||
|
||||
// ============================================================================
|
||||
// CASING TRANSFORMS
|
||||
// ============================================================================
|
||||
|
||||
const SEVERITY_MAP: Record<string, TypstSeverity> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, TypstStatus> = {
|
||||
exploited: 'Exploited',
|
||||
out_of_scope: 'OutOfScope',
|
||||
blocked_by_constraints: 'BlockedByConstraints',
|
||||
false_positive: 'FalsePositive',
|
||||
};
|
||||
|
||||
const CONFIDENCE_MAP: Record<string, TypstConfidence> = {
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
const VALID_CATEGORIES = new Set<TypstCategory>([
|
||||
'Authentication',
|
||||
'Authorization',
|
||||
'XSS',
|
||||
'Injection',
|
||||
'SSRF',
|
||||
'Other',
|
||||
]);
|
||||
|
||||
function toTypstSeverity(s: string): TypstSeverity {
|
||||
return SEVERITY_MAP[s] ?? 'Low';
|
||||
}
|
||||
|
||||
function toTypstStatus(s: string): TypstStatus {
|
||||
return STATUS_MAP[s] ?? 'Exploited';
|
||||
}
|
||||
|
||||
function toTypstConfidence(s: string): TypstConfidence {
|
||||
return CONFIDENCE_MAP[s] ?? 'Medium';
|
||||
}
|
||||
|
||||
function toTypstCategory(s: string): TypstCategory {
|
||||
if (VALID_CATEGORIES.has(s as TypstCategory)) return s as TypstCategory;
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STEP / ITEM TRANSFORMS
|
||||
// ============================================================================
|
||||
|
||||
function adaptStepItem(item: StepItem): StepItem {
|
||||
return item;
|
||||
}
|
||||
|
||||
function adaptStep(step: StructuredStep, index: number): { number: number; title?: string; items: StepItem[] } {
|
||||
return {
|
||||
number: index + 1,
|
||||
...(step.title && { title: step.title }),
|
||||
items: step.items.map(adaptStepItem),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptAdditionalSection(section: AdditionalSection): { heading: string; items: StepItem[] } {
|
||||
return {
|
||||
heading: section.heading,
|
||||
items: section.items.map(adaptStepItem),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGGREGATION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
interface CategoryGroup {
|
||||
category: TypstCategory;
|
||||
findings: AddFindingInput[];
|
||||
}
|
||||
|
||||
function groupByCategory(findings: readonly AddFindingInput[]): CategoryGroup[] {
|
||||
const map = new Map<TypstCategory, AddFindingInput[]>();
|
||||
for (const f of findings) {
|
||||
const cat = toTypstCategory(f.category);
|
||||
const list = map.get(cat) ?? [];
|
||||
list.push(f);
|
||||
map.set(cat, list);
|
||||
}
|
||||
return Array.from(map.entries()).map(([category, fs]) => ({ category, findings: fs }));
|
||||
}
|
||||
|
||||
function countBySeverity(findings: readonly AddFindingInput[]): Record<TypstSeverity, number> {
|
||||
const counts: Record<string, number> = {
|
||||
Critical: 0,
|
||||
High: 0,
|
||||
Medium: 0,
|
||||
Low: 0,
|
||||
};
|
||||
for (const f of findings) {
|
||||
const sev = toTypstSeverity(f.severity);
|
||||
counts[sev] = (counts[sev] ?? 0) + 1;
|
||||
}
|
||||
return counts as Record<TypstSeverity, number>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPLOIT MODE ADAPTER
|
||||
// ============================================================================
|
||||
|
||||
function adaptExploitsMode(data: ReportData): ExploitsReportData {
|
||||
const { report_meta, findings } = data;
|
||||
const groups = groupByCategory(findings);
|
||||
const sevCounts = countBySeverity(findings);
|
||||
|
||||
const statusCounts = { Exploited: 0, OutOfScope: 0, BlockedByConstraints: 0, FalsePositive: 0 };
|
||||
for (const f of findings) {
|
||||
const s = toTypstStatus(f.status ?? 'exploited');
|
||||
statusCounts[s]++;
|
||||
}
|
||||
|
||||
const exploitedFindings = findings.filter((f) => (f.status ?? 'exploited') === 'exploited');
|
||||
|
||||
return {
|
||||
mode: 'exploits' as const,
|
||||
meta: {
|
||||
target: report_meta.target,
|
||||
assessmentDate: report_meta.assessment_date,
|
||||
classification: 'CONFIDENTIAL',
|
||||
},
|
||||
scope: report_meta.scope,
|
||||
exploitedByType: groups.map((g) => {
|
||||
const exploited = g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited');
|
||||
if (exploited.length === 0) {
|
||||
return {
|
||||
category: g.category,
|
||||
narrative: `No ${g.category.toLowerCase()} vulnerabilities were successfully exploited during this assessment.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
category: g.category,
|
||||
bullets: exploited.map((f) => ({ id: f.finding_id, description: f.title })),
|
||||
};
|
||||
}),
|
||||
summary: {
|
||||
totalIdentified: findings.length,
|
||||
successfullyExploited: exploitedFindings.length,
|
||||
exploitedBreakdown: groups
|
||||
.map((g) => ({
|
||||
category: g.category,
|
||||
count: g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited').length,
|
||||
}))
|
||||
.filter((e) => e.count > 0),
|
||||
criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`),
|
||||
},
|
||||
findings: findings.map((f) => ({
|
||||
id: f.finding_id,
|
||||
title: f.title,
|
||||
category: toTypstCategory(f.category),
|
||||
severity: toTypstSeverity(f.severity),
|
||||
status: toTypstStatus(f.status ?? 'exploited'),
|
||||
summary: {
|
||||
vulnerableLocation: f.vulnerable_location,
|
||||
overview: f.overview,
|
||||
impact: f.impact,
|
||||
},
|
||||
// This branch only runs for an exploitative report, where the schema made these
|
||||
// required. The fallbacks keep the superset type honest rather than assuming.
|
||||
prerequisites: f.prerequisites ?? '',
|
||||
exploitationSteps: (f.exploitation_steps ?? []).map(adaptStep),
|
||||
proofOfImpact: (f.proof_of_impact ?? []).map(adaptStepItem),
|
||||
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
|
||||
...(f.additional_sections &&
|
||||
f.additional_sections.length > 0 && {
|
||||
additionalSections: f.additional_sections.map(adaptAdditionalSection),
|
||||
}),
|
||||
})),
|
||||
derivedCounts: {
|
||||
bySeverity: sevCounts,
|
||||
byStatus: statusCounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FINDINGS MODE ADAPTER
|
||||
// ============================================================================
|
||||
|
||||
function adaptFindingsMode(data: ReportData): FindingsReportData {
|
||||
const { report_meta, findings } = data;
|
||||
const groups = groupByCategory(findings);
|
||||
const sevCounts = countBySeverity(findings);
|
||||
|
||||
const confidenceCounts = { High: 0, Medium: 0, Low: 0 };
|
||||
for (const f of findings) {
|
||||
const c = toTypstConfidence(f.confidence ?? 'medium');
|
||||
confidenceCounts[c]++;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'findings' as const,
|
||||
meta: {
|
||||
target: report_meta.target,
|
||||
assessmentDate: report_meta.assessment_date,
|
||||
classification: 'CONFIDENTIAL',
|
||||
},
|
||||
scope: report_meta.scope,
|
||||
identifiedByType: groups.map((g) => {
|
||||
if (g.findings.length === 0) {
|
||||
return {
|
||||
category: g.category,
|
||||
narrative: `No ${g.category.toLowerCase()} vulnerabilities were identified during this assessment.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
category: g.category,
|
||||
bullets: g.findings.map((f) => ({ id: f.finding_id, description: f.title })),
|
||||
};
|
||||
}),
|
||||
summary: {
|
||||
totalIdentified: findings.length,
|
||||
identifiedBreakdown: groups.map((g) => ({
|
||||
category: g.category,
|
||||
count: g.findings.length,
|
||||
})),
|
||||
criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`),
|
||||
},
|
||||
findings: findings.map((f) => ({
|
||||
id: f.finding_id,
|
||||
title: f.title,
|
||||
category: toTypstCategory(f.category),
|
||||
severity: toTypstSeverity(f.severity),
|
||||
confidence: toTypstConfidence(f.confidence ?? 'medium'),
|
||||
summary: {
|
||||
vulnerableLocation: f.vulnerable_location,
|
||||
overview: f.overview,
|
||||
impact: f.impact,
|
||||
},
|
||||
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
|
||||
...(f.additional_sections &&
|
||||
f.additional_sections.length > 0 && {
|
||||
additionalSections: f.additional_sections.map(adaptAdditionalSection),
|
||||
}),
|
||||
})),
|
||||
derivedCounts: {
|
||||
bySeverity: sevCounts,
|
||||
byConfidence: confidenceCounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC API
|
||||
// ============================================================================
|
||||
|
||||
export function adaptReportToTypst(data: ReportData): TypstReportData {
|
||||
const exploitEnabled = data.report_meta.exploit ?? true;
|
||||
if (exploitEnabled) {
|
||||
return adaptExploitsMode(data);
|
||||
}
|
||||
return adaptFindingsMode(data);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* TypeScript types for the structured report the Typst template consumes, in two
|
||||
* shapes keyed by a `mode` discriminator: `exploits` (exploit=true) and `findings`
|
||||
* (exploit=false, analysis-only). Types only — the object is built programmatically
|
||||
* in report-json-adapter.ts, so these exist to keep the adapter and report.typ in sync.
|
||||
*/
|
||||
|
||||
// === Shared primitives ===
|
||||
|
||||
export type TypstSeverity = 'Critical' | 'High' | 'Medium' | 'Low';
|
||||
export type TypstStatus = 'Exploited' | 'OutOfScope' | 'BlockedByConstraints' | 'FalsePositive';
|
||||
export type TypstConfidence = 'High' | 'Medium' | 'Low';
|
||||
export type TypstCategory = 'Authentication' | 'Authorization' | 'XSS' | 'Injection' | 'SSRF' | 'Other';
|
||||
|
||||
export interface CodeBlock {
|
||||
readonly language: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export type StepItem =
|
||||
| { readonly kind: 'prose'; readonly text: string }
|
||||
| { readonly kind: 'code'; readonly block: CodeBlock };
|
||||
|
||||
export interface Step {
|
||||
readonly number: number;
|
||||
readonly title?: string;
|
||||
readonly items: readonly StepItem[];
|
||||
}
|
||||
|
||||
export interface AdditionalSection {
|
||||
readonly heading: string;
|
||||
readonly items: readonly StepItem[];
|
||||
}
|
||||
|
||||
export interface FindingSummary {
|
||||
readonly vulnerableLocation: string;
|
||||
readonly overview: string;
|
||||
readonly impact: string;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
readonly target: string;
|
||||
readonly assessmentDate: string;
|
||||
readonly tester?: string;
|
||||
readonly application?: string;
|
||||
readonly classification: string;
|
||||
}
|
||||
|
||||
export interface CategoryCount {
|
||||
readonly category: TypstCategory;
|
||||
readonly count: number;
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
export type SeverityCounts = Record<TypstSeverity, number>;
|
||||
export type StatusCounts = Record<TypstStatus, number>;
|
||||
export type ConfidenceCounts = Record<TypstConfidence, number>;
|
||||
|
||||
export interface TypeEntryBullet {
|
||||
readonly id: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
// === Exploits-mode schema ===
|
||||
|
||||
export interface ExploitFinding {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly category: TypstCategory;
|
||||
readonly severity: TypstSeverity;
|
||||
readonly status: TypstStatus;
|
||||
readonly summary: FindingSummary;
|
||||
readonly prerequisites: string;
|
||||
readonly exploitationSteps: readonly Step[];
|
||||
readonly proofOfImpact: readonly StepItem[];
|
||||
readonly notes?: readonly StepItem[];
|
||||
readonly additionalSections?: readonly AdditionalSection[];
|
||||
}
|
||||
|
||||
export interface ExploitedByTypeEntry {
|
||||
readonly category: TypstCategory;
|
||||
readonly bullets?: readonly TypeEntryBullet[];
|
||||
readonly narrative?: string;
|
||||
}
|
||||
|
||||
export interface ExploitsReportData {
|
||||
readonly mode: 'exploits';
|
||||
readonly meta: Meta;
|
||||
readonly scope: string;
|
||||
readonly exploitedByType: readonly ExploitedByTypeEntry[];
|
||||
readonly summary: {
|
||||
readonly totalIdentified: number;
|
||||
readonly successfullyExploited: number;
|
||||
readonly exploitedBreakdown: readonly CategoryCount[];
|
||||
readonly outOfScope?: {
|
||||
readonly total: number;
|
||||
readonly breakdown?: readonly CategoryCount[];
|
||||
readonly note?: string;
|
||||
};
|
||||
readonly blockedByConstraints?: {
|
||||
readonly total: number;
|
||||
readonly note?: string;
|
||||
};
|
||||
readonly criticalFindings: readonly string[];
|
||||
};
|
||||
readonly findings: readonly ExploitFinding[];
|
||||
readonly derivedCounts: {
|
||||
readonly bySeverity: SeverityCounts;
|
||||
readonly byStatus: StatusCounts;
|
||||
};
|
||||
}
|
||||
|
||||
// === Findings-mode schema (analysis-only, exploit=false runs) ===
|
||||
|
||||
export interface AnalysisFinding {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly category: TypstCategory;
|
||||
readonly severity: TypstSeverity;
|
||||
readonly confidence: TypstConfidence;
|
||||
readonly summary: FindingSummary;
|
||||
readonly notes?: readonly StepItem[];
|
||||
readonly additionalSections?: readonly AdditionalSection[];
|
||||
}
|
||||
|
||||
export interface IdentifiedByTypeEntry {
|
||||
readonly category: TypstCategory;
|
||||
readonly bullets?: readonly TypeEntryBullet[];
|
||||
readonly narrative?: string;
|
||||
}
|
||||
|
||||
export interface FindingsReportData {
|
||||
readonly mode: 'findings';
|
||||
readonly meta: Meta;
|
||||
readonly scope: string;
|
||||
readonly identifiedByType: readonly IdentifiedByTypeEntry[];
|
||||
readonly summary: {
|
||||
readonly totalIdentified: number;
|
||||
readonly identifiedBreakdown: readonly CategoryCount[];
|
||||
readonly criticalFindings: readonly string[];
|
||||
};
|
||||
readonly findings: readonly AnalysisFinding[];
|
||||
readonly derivedCounts: {
|
||||
readonly bySeverity: SeverityCounts;
|
||||
readonly byConfidence: ConfidenceCounts;
|
||||
};
|
||||
}
|
||||
|
||||
// === Discriminated union for downstream consumers that handle both ===
|
||||
|
||||
export type ReportData = ExploitsReportData | FindingsReportData;
|
||||
@@ -263,7 +263,16 @@ export function renderReport(data: ReportData): string {
|
||||
sections.push(`### ${cat}`);
|
||||
sections.push('');
|
||||
for (const f of catFindings) {
|
||||
const suffix = f.severity ? ` (${titleCase(f.severity)})` : '';
|
||||
// Both ratings when the mode produced both. Confidence is labelled so it is never
|
||||
// read as a severity in the position where a severity usually sits.
|
||||
const ratings: string[] = [];
|
||||
if (f.severity) {
|
||||
ratings.push(titleCase(f.severity));
|
||||
}
|
||||
if (f.confidence) {
|
||||
ratings.push(`${titleCase(f.confidence)} confidence`);
|
||||
}
|
||||
const suffix = ratings.length > 0 ? ` (${ratings.join(', ')})` : '';
|
||||
sections.push(`- **${f.finding_id}:** ${f.title}${suffix}`);
|
||||
}
|
||||
sections.push('');
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
import { fs, path } from 'zx';
|
||||
import {
|
||||
ASSEMBLED_REPORT_FILENAME,
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
deliverablesDir,
|
||||
FINAL_REPORT_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
SARIF_FILENAME,
|
||||
} from '../paths.js';
|
||||
@@ -174,7 +175,8 @@ export async function injectModelIntoReport(
|
||||
/**
|
||||
* Surface the run's deliverables at the run directory's top level, so a customer opening the run
|
||||
* folder sees the report without digging through internals. Sources stay in the deliverables dir
|
||||
* (git-checkpointed, used by resume).
|
||||
* (git-checkpointed, used by resume). The PDF is the customer-facing report surfaced here; the
|
||||
* markdown remains in the deliverables dir but is not surfaced.
|
||||
*
|
||||
* The SARIF log is surfaced beside it when present, since a CI step consuming it needs a stable
|
||||
* path and cannot be expected to reach into the internals directory. It is absent whenever the
|
||||
@@ -188,13 +190,13 @@ export async function copyReportToRunRoot(
|
||||
): Promise<void> {
|
||||
const dir = deliverablesDir(repoPath, deliverablesSubdir);
|
||||
|
||||
const source = path.join(dir, ASSEMBLED_REPORT_FILENAME);
|
||||
if (await fs.pathExists(source)) {
|
||||
const destination = path.join(runDir, FINAL_REPORT_FILENAME);
|
||||
await fs.copy(source, destination, { overwrite: true });
|
||||
logger.info(`Surfaced report at ${destination}`);
|
||||
const pdfSource = path.join(dir, ASSEMBLED_REPORT_PDF_FILENAME);
|
||||
if (await fs.pathExists(pdfSource)) {
|
||||
const destination = path.join(runDir, FINAL_REPORT_PDF_FILENAME);
|
||||
await fs.copy(pdfSource, destination, { overwrite: true });
|
||||
logger.info(`Surfaced PDF report at ${destination}`);
|
||||
} else {
|
||||
logger.warn(`Final report not found, skipping ${FINAL_REPORT_FILENAME}`);
|
||||
logger.warn(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
}
|
||||
|
||||
const sarifSource = path.join(dir, SARIF_FILENAME);
|
||||
|
||||
@@ -154,7 +154,7 @@ function buildMessageMarkdown(finding: AddFindingInput): string {
|
||||
parts.push('', '**Remediation**', '', finding.remediation);
|
||||
// Exploitation steps and proof of impact are deliberately absent: SARIF has no structural home
|
||||
// for them, and flattening them into prose would imply this file carries the evidence.
|
||||
parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.md`');
|
||||
parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.pdf`');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,13 @@ import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
||||
import {
|
||||
ASSEMBLED_REPORT_FILENAME,
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
DEFAULT_DELIVERABLES_SUBDIR,
|
||||
deliverablesDir,
|
||||
REPORT_JSON_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
SARIF_FILENAME,
|
||||
TYPST_TEMPLATE,
|
||||
} from '../paths.js';
|
||||
import { getAgentGitPaths } from '../services/agent-git-paths.js';
|
||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||
@@ -450,9 +452,11 @@ export async function runAuthzExploitAgent(input: ActivityInput): Promise<AgentM
|
||||
/**
|
||||
* Write report.sarif when the run is exploitative and the operator asked for it.
|
||||
*
|
||||
* Skipped entirely for analysis-only runs: those findings carry no severity, so every
|
||||
* `result.level` would be invented. Failures are logged and swallowed — the SARIF log is a
|
||||
* secondary artifact and must not fail a run whose report is already written.
|
||||
* Skipped entirely for analysis-only runs. The original reason was that those findings carried
|
||||
* no severity, so every `result.level` would have been invented; since severity is recorded in
|
||||
* both modes an analysis run could now populate `level`, but it would report an assessed
|
||||
* severity as a measured one, so the gate stays. Failures are logged and swallowed — the SARIF
|
||||
* log is a secondary artifact and must not fail a run whose report is already written.
|
||||
*/
|
||||
async function writeSarifIfEnabled(
|
||||
input: ActivityInput,
|
||||
@@ -477,6 +481,30 @@ async function writeSarifIfEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the PDF report from the assembled report data.
|
||||
*
|
||||
* Failures are logged and swallowed — the PDF is a secondary artifact and must not fail a run
|
||||
* whose report is already written.
|
||||
*/
|
||||
async function writePdfReport(
|
||||
reportData: ReportData,
|
||||
deliverablesPath: string,
|
||||
logger: ReturnType<typeof createActivityLogger>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { renderReportPdf } = await import('../services/pdf-renderer.js');
|
||||
await renderReportPdf({
|
||||
reportData,
|
||||
templatePath: TYPST_TEMPLATE,
|
||||
outputPath: path.join(deliverablesPath, ASSEMBLED_REPORT_PDF_FILENAME),
|
||||
});
|
||||
logger.info(`Wrote ${ASSEMBLED_REPORT_PDF_FILENAME}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to write ${ASSEMBLED_REPORT_PDF_FILENAME}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runReportAgent(input: ActivityInput, exploit: boolean): Promise<AgentMetrics> {
|
||||
const { createFindingCollector } = await import('../collectors/finding-collector.js');
|
||||
const { renderReport } = await import('../services/report-renderer.js');
|
||||
@@ -532,6 +560,7 @@ export async function runReportAgent(input: ActivityInput, exploit: boolean): Pr
|
||||
await atomicWrite(path.join(deliverablesPath, ASSEMBLED_REPORT_FILENAME), renderReport(reportData));
|
||||
logger.info(`Wrote ${ASSEMBLED_REPORT_FILENAME} from structured data`);
|
||||
|
||||
await writePdfReport(reportData, deliverablesPath, logger);
|
||||
await writeSarifIfEnabled(input, exploit, reportData, deliverablesPath, logger);
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker
|
||||
import dotenv from 'dotenv';
|
||||
import { sanitizeHostname } from '../audit/utils.js';
|
||||
import { parseConfig } from '../config-parser.js';
|
||||
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
|
||||
import {
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
deliverablesDir,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
} from '../paths.js';
|
||||
import type { VulnClass } from '../types/config.js';
|
||||
import { fileExists, readJson } from '../utils/file-io.js';
|
||||
import * as activities from './activities.js';
|
||||
@@ -389,9 +394,9 @@ function copyDeliverables(repoPath: string, outputPath: string): void {
|
||||
}
|
||||
|
||||
// Surface the report under its human-facing name alongside the raw deliverables
|
||||
const assembledReport = path.join(outputDir, ASSEMBLED_REPORT_FILENAME);
|
||||
if (fs.existsSync(assembledReport)) {
|
||||
fs.copyFileSync(assembledReport, path.join(outputPath, FINAL_REPORT_FILENAME));
|
||||
const assembledPdf = path.join(outputDir, ASSEMBLED_REPORT_PDF_FILENAME);
|
||||
if (fs.existsSync(assembledPdf)) {
|
||||
fs.copyFileSync(assembledPdf, path.join(outputPath, FINAL_REPORT_PDF_FILENAME));
|
||||
}
|
||||
|
||||
console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
export type RuleType = 'url_path' | 'subdomain' | 'domain' | 'method' | 'header' | 'parameter' | 'code_path';
|
||||
|
||||
export interface Rule {
|
||||
description: string;
|
||||
description?: string;
|
||||
type: RuleType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,535 @@
|
||||
// =============================================================================
|
||||
// Security Assessment Report — Typst template
|
||||
// Invoke:
|
||||
// typst compile --root <root> --input data=/data.json report.typ out.pdf
|
||||
// Optional overrides:
|
||||
// --input tester=<name> --input brand=<name>
|
||||
// =============================================================================
|
||||
|
||||
#let data = json(sys.inputs.data)
|
||||
|
||||
// Top-level discriminator. Schema variants in report-output-schema.ts:
|
||||
// exploits → ExploitsReportData (exploit=true runs, full reproduction)
|
||||
// findings → FindingsReportData (exploit=false runs, analysis-only)
|
||||
#let mode = data.at("mode", default: "exploits")
|
||||
|
||||
#let tester-override = sys.inputs.at("tester", default: "Shannon")
|
||||
#let brand = sys.inputs.at("brand", default: "Shannon | AI Pentester by Keygraph")
|
||||
|
||||
// ---------- Palette ---------------------------------------------------------
|
||||
// Kept distinct so Critical / High are not confused under monitor gamma.
|
||||
#let sev-color(level) = {
|
||||
if level == "Critical" { rgb("#DC2626") } // red-600
|
||||
else if level == "High" { rgb("#EA580C") } // orange-600
|
||||
else if level == "Medium" { rgb("#D97706") } // amber-600
|
||||
else if level == "Low" { rgb("#2563EB") } // blue-600
|
||||
else { rgb("#6B7280") }
|
||||
}
|
||||
|
||||
#let confidence-color(c) = {
|
||||
if c == "High" { rgb("#15803D") } // green-700
|
||||
else if c == "Medium" { rgb("#D97706") } // amber-600
|
||||
else if c == "Low" { rgb("#6B7280") } // gray-500
|
||||
else { rgb("#6B7280") }
|
||||
}
|
||||
|
||||
// Warm, editorial, high-contrast document palette.
|
||||
#let ink = rgb("#141414") // warm near-black text
|
||||
#let muted = rgb("#5C5850") // warm gray-brown labels
|
||||
#let tertiary = rgb("#9A958D") // lightest muted
|
||||
#let rule = rgb("#E6E1D9") // warm hair rules
|
||||
#let rule-soft = rgb("#D9D3CA")
|
||||
#let code-bg = rgb("#F6F1EB") // warm eggshell
|
||||
#let alt-bg = rgb("#EBE6DF")
|
||||
#let page-bg = white
|
||||
|
||||
// ---------- Page setup ------------------------------------------------------
|
||||
#set document(title: "Security Assessment Report", author: brand)
|
||||
|
||||
#set page(
|
||||
paper: "a4",
|
||||
margin: (top: 2.2cm, bottom: 2.2cm, left: 2.2cm, right: 2.2cm),
|
||||
fill: page-bg,
|
||||
header: context {
|
||||
if counter(page).get().first() > 1 [
|
||||
#set text(size: 8.5pt, fill: muted)
|
||||
#grid(columns: (1fr, auto),
|
||||
[Security Assessment Report],
|
||||
[CONFIDENTIAL],
|
||||
)
|
||||
#v(-4pt)
|
||||
#line(length: 100%, stroke: 0.3pt + rule)
|
||||
]
|
||||
},
|
||||
footer: context {
|
||||
if counter(page).get().first() > 1 [
|
||||
#set text(size: 8.5pt, fill: muted)
|
||||
#line(length: 100%, stroke: 0.3pt + rule)
|
||||
#v(2pt)
|
||||
#grid(columns: (1fr, auto),
|
||||
[#data.meta.assessmentDate],
|
||||
[#counter(page).display() / #context counter(page).final().first()],
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
#set text(size: 10.5pt, fill: ink)
|
||||
#set par(leading: 0.7em, justify: false)
|
||||
|
||||
#show heading.where(level: 1): it => [
|
||||
#pagebreak(weak: true)
|
||||
#v(4pt)
|
||||
#set text(size: 24pt, weight: "bold", fill: ink)
|
||||
#it.body
|
||||
#v(4pt)
|
||||
#line(length: 100%, stroke: 0.4pt + rule)
|
||||
#v(10pt)
|
||||
]
|
||||
#show heading.where(level: 2): it => [
|
||||
#v(10pt)
|
||||
#set text(size: 14pt, weight: "semibold", fill: ink)
|
||||
#it.body
|
||||
#v(2pt)
|
||||
]
|
||||
#show heading.where(level: 3): it => [
|
||||
#v(8pt)
|
||||
#set text(size: 11.5pt, weight: "semibold", fill: ink)
|
||||
#it.body
|
||||
#v(-2pt)
|
||||
]
|
||||
|
||||
#show raw: set text(size: 8.5pt)
|
||||
#show raw.where(block: false): it => box(
|
||||
fill: code-bg,
|
||||
inset: (x: 3pt, y: 0pt),
|
||||
outset: (y: 2pt),
|
||||
radius: 2pt,
|
||||
it,
|
||||
)
|
||||
#show raw.where(block: true): it => block(
|
||||
fill: code-bg,
|
||||
stroke: (left: 2pt + rule, rest: none),
|
||||
inset: (x: 10pt, y: 8pt),
|
||||
width: 100%,
|
||||
breakable: true,
|
||||
{
|
||||
set par(leading: 0.5em, justify: false)
|
||||
it
|
||||
},
|
||||
)
|
||||
|
||||
// ---------- Helpers ---------------------------------------------------------
|
||||
#let chip(label, color) = box(
|
||||
fill: color,
|
||||
inset: (x: 6pt, y: 2pt),
|
||||
radius: 2pt,
|
||||
text(fill: white, weight: "bold", size: 7.5pt, tracking: 0.3pt, upper(label)),
|
||||
)
|
||||
|
||||
#let categories-in-order = (
|
||||
"Authentication",
|
||||
"Authorization",
|
||||
"XSS",
|
||||
"Injection",
|
||||
"SSRF",
|
||||
"Other",
|
||||
)
|
||||
|
||||
#let sev-chip(level) = chip(level, sev-color(level))
|
||||
#let confidence-chip(c) = chip(c + " confidence", confidence-color(c))
|
||||
|
||||
// inline-code renders a string, turning backtick-wrapped spans into
|
||||
// inline raw. Safe on odd counts — a trailing unclosed backtick is
|
||||
// emitted as literal text so nothing gets swallowed.
|
||||
#let inline-code(s) = {
|
||||
if type(s) != str { return s }
|
||||
let parts = s.split("`")
|
||||
if parts.len() == 1 { return parts.at(0) }
|
||||
let out = []
|
||||
for (i, p) in parts.enumerate() {
|
||||
if calc.even(i) {
|
||||
out += [#p]
|
||||
} else if i == parts.len() - 1 {
|
||||
out += [#("`" + p)]
|
||||
} else {
|
||||
out += raw(p)
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#let render-items(items) = {
|
||||
for item in items {
|
||||
if item.kind == "prose" [
|
||||
#par(inline-code(item.text))
|
||||
] else if item.kind == "code" [
|
||||
#raw(item.block.content, lang: item.block.language, block: true)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Render step items as a bulleted list; prose items become bullets,
|
||||
// code items break the list and render as code blocks in between.
|
||||
#let render-bulleted-items(items) = {
|
||||
for item in items {
|
||||
if item.kind == "prose" [
|
||||
- #inline-code(item.text)
|
||||
] else if item.kind == "code" [
|
||||
#raw(item.block.content, lang: item.block.language, block: true)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Render step items as a numbered list; prose items become enumerated,
|
||||
// code items break the list and render as code blocks in between.
|
||||
#let render-numbered-items(items) = {
|
||||
for item in items {
|
||||
if item.kind == "prose" [
|
||||
+ #inline-code(item.text)
|
||||
] else if item.kind == "code" [
|
||||
#raw(item.block.content, lang: item.block.language, block: true)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Render an array of strings as a bulleted list with inline-code support.
|
||||
#let code-list(items) = list(..items.map(inline-code))
|
||||
|
||||
#let kv(label, value) = grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 14pt,
|
||||
row-gutter: 4pt,
|
||||
text(fill: muted, size: 9.5pt)[#label],
|
||||
value,
|
||||
)
|
||||
|
||||
// ---------- COVER PAGE ------------------------------------------------------
|
||||
#page(header: none, footer: none)[
|
||||
#set align(left)
|
||||
#v(3.2cm)
|
||||
|
||||
#let brand-parts = brand.split("|").map(p => p.trim())
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 8pt,
|
||||
align: (horizon, horizon),
|
||||
image("/assets/keygraph-logo.png", width: 1.6cm),
|
||||
{
|
||||
set par(leading: 0.6em)
|
||||
text(size: 11pt, fill: ink, weight: "semibold", tracking: 1.2pt)[
|
||||
#upper(brand-parts.at(0))
|
||||
]
|
||||
if brand-parts.len() > 1 {
|
||||
linebreak()
|
||||
text(size: 9pt, fill: muted, weight: "regular")[
|
||||
#brand-parts.slice(1).join(" ")
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
#set par(leading: 0.7em)
|
||||
|
||||
#v(1.6cm)
|
||||
#set par(leading: 0.4em)
|
||||
#text(size: 46pt, weight: "bold", fill: ink)[
|
||||
Security\
|
||||
Assessment\
|
||||
Report
|
||||
]
|
||||
#set par(leading: 0.7em)
|
||||
|
||||
#v(1fr)
|
||||
|
||||
#line(length: 100%, stroke: 0.3pt + rule)
|
||||
#v(0.6cm)
|
||||
|
||||
#grid(
|
||||
columns: (1fr, 1fr),
|
||||
column-gutter: 28pt,
|
||||
row-gutter: 14pt,
|
||||
grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 18pt,
|
||||
row-gutter: 14pt,
|
||||
text(fill: muted, size: 9pt)[Target], text(size: 10pt)[#inline-code(data.meta.target)],
|
||||
text(fill: muted, size: 9pt)[Date], text(size: 10pt)[#data.meta.assessmentDate],
|
||||
..(if "application" in data.meta and data.meta.application != none {
|
||||
(text(fill: muted, size: 9pt)[Application], text(size: 10pt)[#inline-code(data.meta.application)])
|
||||
} else { () }),
|
||||
),
|
||||
grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 18pt,
|
||||
row-gutter: 14pt,
|
||||
text(fill: muted, size: 9pt)[Tester], text(size: 10pt)[#tester-override],
|
||||
text(fill: muted, size: 9pt)[Classification],
|
||||
text(size: 10pt, weight: "semibold")[#data.meta.classification],
|
||||
),
|
||||
)
|
||||
|
||||
#v(0.8cm)
|
||||
#text(size: 8pt, fill: muted)[
|
||||
This document contains sensitive security findings.
|
||||
Handle in accordance with your organization's data classification policy.
|
||||
]
|
||||
]
|
||||
|
||||
// ---------- TABLE OF CONTENTS -----------------------------------------------
|
||||
#outline(title: [Contents], depth: 3, indent: auto)
|
||||
|
||||
// ---------- EXECUTIVE SUMMARY -----------------------------------------------
|
||||
= Executive Summary
|
||||
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 20pt,
|
||||
row-gutter: 12pt,
|
||||
text(fill: muted, size: 10pt)[Target], text(size: 10.5pt)[#inline-code(data.meta.target)],
|
||||
text(fill: muted, size: 10pt)[Date], text(size: 10.5pt)[#data.meta.assessmentDate],
|
||||
..(if "application" in data.meta and data.meta.application != none {
|
||||
(text(fill: muted, size: 10pt)[Application], text(size: 10.5pt)[#inline-code(data.meta.application)])
|
||||
} else { () }),
|
||||
text(fill: muted, size: 10pt)[Tester], text(size: 10.5pt)[#tester-override],
|
||||
)
|
||||
|
||||
== Scope
|
||||
|
||||
#inline-code(data.scope)
|
||||
|
||||
// ---------- BY TYPE ---------------------------------------------------------
|
||||
#let by-type-entries = if mode == "exploits" { data.exploitedByType } else { data.identifiedByType }
|
||||
#if mode == "exploits" [
|
||||
= Successfully Exploited Vulnerabilities by Type
|
||||
] else [
|
||||
= Identified Vulnerabilities by Type
|
||||
]
|
||||
|
||||
#for entry in by-type-entries [
|
||||
== #entry.category
|
||||
#if "narrative" in entry and entry.narrative != none [
|
||||
#inline-code(entry.narrative)
|
||||
]
|
||||
#if "bullets" in entry and entry.bullets != none [
|
||||
#list(
|
||||
..entry.bullets.map(b => [
|
||||
#text(weight: "semibold")[#b.id] — #inline-code(b.description)
|
||||
])
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
// ---------- SUMMARY ---------------------------------------------------------
|
||||
= Summary
|
||||
|
||||
#let s = data.summary
|
||||
#let sev = data.derivedCounts.bySeverity
|
||||
|
||||
#let severity-card(label, sev-key, n) = box(
|
||||
fill: sev-color(sev-key),
|
||||
inset: (x: 8pt, y: 12pt),
|
||||
radius: 4pt,
|
||||
width: 100%,
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: 6pt,
|
||||
text(fill: white, weight: "bold", size: 20pt)[#n],
|
||||
text(fill: white, size: 8pt, tracking: 0.5pt)[#upper(label)],
|
||||
),
|
||||
)
|
||||
|
||||
#grid(
|
||||
columns: 4,
|
||||
column-gutter: 8pt,
|
||||
severity-card("Critical", "Critical", sev.Critical),
|
||||
severity-card("High", "High", sev.High),
|
||||
severity-card("Medium", "Medium", sev.Medium),
|
||||
severity-card("Low", "Low", sev.Low),
|
||||
)
|
||||
|
||||
#if mode == "findings" [
|
||||
#v(18pt)
|
||||
#let cf = data.derivedCounts.byConfidence
|
||||
#let confidence-card(label, c-key, n) = box(
|
||||
stroke: 0.6pt + confidence-color(c-key),
|
||||
inset: (x: 8pt, y: 12pt),
|
||||
radius: 4pt,
|
||||
width: 100%,
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: 6pt,
|
||||
text(fill: ink, weight: "bold", size: 20pt)[#n],
|
||||
text(fill: confidence-color(c-key), size: 8pt, tracking: 0.5pt)[#upper(label + " confidence")],
|
||||
),
|
||||
)
|
||||
|
||||
#grid(
|
||||
columns: 3,
|
||||
column-gutter: 8pt,
|
||||
confidence-card("High", "High", cf.High),
|
||||
confidence-card("Medium", "Medium", cf.Medium),
|
||||
confidence-card("Low", "Low", cf.Low),
|
||||
)
|
||||
]
|
||||
|
||||
#v(14pt)
|
||||
|
||||
#if mode == "exploits" [
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 14pt,
|
||||
row-gutter: 4pt,
|
||||
text(fill: muted, size: 10pt)[Total identified],
|
||||
text(weight: "semibold")[#s.totalIdentified],
|
||||
text(fill: muted, size: 10pt)[Successfully exploited],
|
||||
text(weight: "semibold")[#s.successfullyExploited],
|
||||
)
|
||||
] else [
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 14pt,
|
||||
row-gutter: 4pt,
|
||||
text(fill: muted, size: 10pt)[Total identified],
|
||||
text(weight: "semibold")[#s.totalIdentified],
|
||||
)
|
||||
]
|
||||
|
||||
#v(8pt)
|
||||
|
||||
#let breakdown = if mode == "exploits" { s.exploitedBreakdown } else { s.identifiedBreakdown }
|
||||
#list(
|
||||
..breakdown.map(c => [
|
||||
#text(weight: "semibold")[#c.count] #c.category#if "note" in c and c.note != none [ — #inline-code(c.note)]
|
||||
])
|
||||
)
|
||||
|
||||
#if mode == "exploits" [
|
||||
#if "outOfScope" in s and s.outOfScope != none [
|
||||
#v(4pt)
|
||||
#text(weight: "semibold")[Out of Scope#if "note" in s.outOfScope and s.outOfScope.note != none [ (#s.outOfScope.note)]:] #s.outOfScope.total vulnerabilities
|
||||
#if "breakdown" in s.outOfScope and s.outOfScope.breakdown != none [
|
||||
#list(
|
||||
..s.outOfScope.breakdown.map(c => [
|
||||
#text(weight: "semibold")[#c.count] #c.category#if "note" in c and c.note != none [ — #inline-code(c.note)]
|
||||
])
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
#if "blockedByConstraints" in s and s.blockedByConstraints != none [
|
||||
#v(4pt)
|
||||
#text(weight: "semibold")[Blocked by Testing Constraints:] #s.blockedByConstraints.total#if "note" in s.blockedByConstraints and s.blockedByConstraints.note != none [ — #s.blockedByConstraints.note]
|
||||
]
|
||||
]
|
||||
|
||||
== Critical Findings
|
||||
|
||||
#enum(..s.criticalFindings.map(f => [#inline-code(f)]))
|
||||
|
||||
// ---------- FINDINGS OVERVIEW -----------------------------------------------
|
||||
= Findings Overview
|
||||
|
||||
#let show-confidence-col = mode == "findings"
|
||||
|
||||
#table(
|
||||
columns: if show-confidence-col { (auto, 1fr, auto, auto, auto) } else { (auto, 1fr, auto, auto) },
|
||||
stroke: none,
|
||||
inset: (x: 8pt, y: 7pt),
|
||||
align: if show-confidence-col { (left, left, left, center, center) } else { (left, left, left, center) },
|
||||
fill: (_, row) => if row == 0 { none } else if calc.even(row) { code-bg } else { none },
|
||||
table.header(
|
||||
text(size: 9.5pt, weight: "semibold")[ID],
|
||||
text(size: 9.5pt, weight: "semibold")[Title],
|
||||
text(size: 9.5pt, weight: "semibold")[Category],
|
||||
text(size: 9.5pt, weight: "semibold")[Severity],
|
||||
..(if show-confidence-col { (text(size: 9.5pt, weight: "semibold")[Confidence],) } else { () }),
|
||||
),
|
||||
..data.findings.map(f => (
|
||||
text(weight: "semibold")[#f.id],
|
||||
inline-code(f.title),
|
||||
text(size: 9.5pt)[#f.category],
|
||||
sev-chip(f.severity),
|
||||
..(if show-confidence-col { (confidence-chip(f.confidence),) } else { () }),
|
||||
)).flatten()
|
||||
)
|
||||
|
||||
// ---------- FINDING RENDER --------------------------------------------------
|
||||
#let render-finding-summary(f) = [
|
||||
#v(8pt)
|
||||
#grid(
|
||||
columns: (auto, 1fr),
|
||||
column-gutter: 18pt,
|
||||
row-gutter: 12pt,
|
||||
text(fill: muted, size: 9.5pt)[Location], text(size: 10pt)[#inline-code(f.summary.vulnerableLocation)],
|
||||
text(fill: muted, size: 9.5pt)[Overview], text(size: 10pt)[#inline-code(f.summary.overview)],
|
||||
text(fill: muted, size: 9.5pt)[Impact], text(size: 10pt)[#inline-code(f.summary.impact)],
|
||||
)
|
||||
]
|
||||
|
||||
#let render-finding-extras(f) = [
|
||||
#if "notes" in f and f.notes != none and f.notes.len() > 0 [
|
||||
#heading(level: 3, outlined: false)[Notes]
|
||||
#render-bulleted-items(f.notes)
|
||||
]
|
||||
|
||||
#if "additionalSections" in f and f.additionalSections != none [
|
||||
#for extra in f.additionalSections [
|
||||
#heading(level: 3, outlined: false)[#inline-code(extra.heading)]
|
||||
#render-items(extra.items)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
#let render-exploit(f) = [
|
||||
== #f.id: #inline-code(f.title)
|
||||
#sev-chip(f.severity)
|
||||
|
||||
#render-finding-summary(f)
|
||||
|
||||
=== Prerequisites
|
||||
#inline-code(f.prerequisites)
|
||||
|
||||
=== Exploitation Steps
|
||||
#for step in f.exploitationSteps [
|
||||
#text(weight: "semibold")[Step #step.number#if "title" in step and step.title != none [ — #inline-code(step.title)]]
|
||||
|
||||
#render-items(step.items)
|
||||
]
|
||||
|
||||
=== Proof of Impact
|
||||
#render-numbered-items(f.proofOfImpact)
|
||||
|
||||
#render-finding-extras(f)
|
||||
|
||||
#v(16pt)
|
||||
]
|
||||
|
||||
#let render-analysis(f) = [
|
||||
== #f.id: #inline-code(f.title)
|
||||
#sev-chip(f.severity) #h(4pt) #confidence-chip(f.confidence)
|
||||
|
||||
#render-finding-summary(f)
|
||||
|
||||
#render-finding-extras(f)
|
||||
|
||||
#v(16pt)
|
||||
]
|
||||
|
||||
#let render-finding(f) = if mode == "exploits" { render-exploit(f) } else { render-analysis(f) }
|
||||
|
||||
// ---------- PER-CATEGORY -----------------------------------------------------
|
||||
#let category-section-label(n) = if mode == "exploits" {
|
||||
"Exploitation Evidence"
|
||||
} else {
|
||||
"Findings"
|
||||
}
|
||||
|
||||
#for cat in categories-in-order {
|
||||
let cat-findings = data.findings.filter(f => f.category == cat)
|
||||
if cat-findings.len() > 0 [
|
||||
= #cat #category-section-label(cat-findings.len()) (#cat-findings.len() #if cat-findings.len() == 1 [finding] else [findings])
|
||||
#for f in cat-findings {
|
||||
render-finding(f)
|
||||
}
|
||||
]
|
||||
}
|
||||
+72
-18
@@ -12,20 +12,32 @@ The provider half decides where the request goes, which credential is used, and
|
||||
|
||||
| Provider | Value | Credential |
|
||||
| --- | --- | --- |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` |
|
||||
| OpenAI | `openai` | `OPENAI_API_KEY` |
|
||||
| xAI | `xai` | `XAI_API_KEY` |
|
||||
| Anthropic | `anthropic` | `SHANNON_AI_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) |
|
||||
| OpenAI | `openai` | `SHANNON_AI_API_KEY` |
|
||||
| xAI | `xai` | `SHANNON_AI_API_KEY` |
|
||||
| AWS Bedrock | `amazon-bedrock` | `AWS_REGION` and `AWS_BEARER_TOKEN_BEDROCK` |
|
||||
|
||||
Shannon does not invent credential names — each is the variable that provider's own tooling already uses. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
|
||||
`SHANNON_AI_API_KEY` holds the key for whichever provider `SHANNON_AI_MODEL` names. Bedrock is the exception — it authenticates through its `AWS_` variables only. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
|
||||
|
||||
Anthropic, OpenAI, and xAI also accept their native variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`); if one of those is set, it is used instead of `SHANNON_AI_API_KEY`.
|
||||
|
||||
Shannon forwards only the selected provider's credential into the scan container. Keys for other providers stay on your machine.
|
||||
|
||||
> [!NOTE]
|
||||
> Only the **first** colon separates the provider from the model ID, so Bedrock IDs that contain colons work unchanged: `amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`.
|
||||
### Any other provider
|
||||
|
||||
Shannon accepts any provider and model present in the Pi harness catalogue. Browse them at [pi.dev/models](https://pi.dev/models).
|
||||
|
||||
```bash
|
||||
export SHANNON_AI_API_KEY=your-api-key # the provider's API key
|
||||
export SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3 # <provider>:<model-id>
|
||||
```
|
||||
|
||||
This path covers providers whose credential is a single API key. Providers that need more than that are not currently supported.
|
||||
|
||||
`npx @keygraph/shannon setup` exposes this as the **Other provider** option.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Claude models are the best-supported option. Shannon's evaluations, internal testing, and agent harness are tuned for Claude. Other models are permitted and validated against the harness catalogue, but may not follow Shannon's instructions or tool-use constraints as reliably. Use them at your own risk.
|
||||
> Models are validated against the harness catalogue, but capability varies. A model that does not follow Shannon's instructions or tool-use constraints reliably will produce weaker pentests. Evaluate the model you choose against your own targets before depending on its results.
|
||||
|
||||
## Cyber safeguards (do this before your first scan)
|
||||
|
||||
@@ -58,21 +70,21 @@ The pattern is learned once: export the provider's key, name the model. Two line
|
||||
Anthropic (default):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
```
|
||||
|
||||
OpenAI:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export SHANNON_AI_API_KEY=sk-...
|
||||
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
|
||||
```
|
||||
|
||||
xAI:
|
||||
|
||||
```bash
|
||||
export XAI_API_KEY=xai-...
|
||||
export SHANNON_AI_API_KEY=xai-...
|
||||
export SHANNON_AI_MODEL=xai:grok-4.5
|
||||
```
|
||||
|
||||
@@ -96,16 +108,16 @@ To route model traffic through your own infrastructure — a corporate proxy, an
|
||||
|
||||
| Gateway serves | Model prefix | API key |
|
||||
| --- | --- | --- |
|
||||
| Anthropic Messages | `anthropic:` | `ANTHROPIC_API_KEY` |
|
||||
| OpenAI Chat Completions | `openai:` | `OPENAI_API_KEY` |
|
||||
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `OPENAI_API_KEY` |
|
||||
| Anthropic Messages | `anthropic:` | `SHANNON_AI_API_KEY` |
|
||||
| OpenAI Chat Completions | `openai:` | `SHANNON_AI_API_KEY` |
|
||||
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `SHANNON_AI_API_KEY` |
|
||||
|
||||
The model ID is whatever name your gateway serves it under; it does not have to exist in Shannon's catalogue.
|
||||
|
||||
Anthropic Messages:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
|
||||
```
|
||||
@@ -113,7 +125,7 @@ export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
|
||||
OpenAI Chat Completions:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export SHANNON_AI_API_KEY=sk-...
|
||||
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
|
||||
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
|
||||
```
|
||||
@@ -132,13 +144,55 @@ The variable is rejected in preflight where it cannot take effect: with a non-`o
|
||||
|
||||
`npx @keygraph/shannon setup` covers this under **Custom Base URL**, which asks which API your gateway serves and configures the matching provider for you.
|
||||
|
||||
## OpenAI Codex (ChatGPT Plus/Pro subscription)
|
||||
|
||||
A ChatGPT Plus or Pro Codex subscription can run Shannon. Shannon reuses a login created by Pi.
|
||||
|
||||
Before running a pentest, review the [cyber safeguards requirements](#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
1. Install Pi by following the instructions at [pi.dev](https://pi.dev).
|
||||
2. Log in with your subscription using Pi's [subscription authentication guide](https://pi.dev/docs/latest/providers#subscriptions). This creates `~/.pi/agent/auth.json` with an `openai-codex` entry.
|
||||
|
||||
3. Select a Codex model and enable Pi authentication:
|
||||
|
||||
```bash
|
||||
export SHANNON_USE_PI_AUTH=1
|
||||
export SHANNON_AI_MODEL=openai-codex:gpt-5.5
|
||||
```
|
||||
|
||||
4. In npx mode, run `npx @keygraph/shannon start ...` from the same shell. In source-build mode, add the two variables to `.env` and run `./shannon start ...`.
|
||||
|
||||
Supported Codex models are `gpt-5.6-sol`, `gpt-5.5`, and `gpt-5.4`.
|
||||
|
||||
## Claude Code subscription
|
||||
|
||||
The latest version of Shannon does not support Claude Code subscriptions. The [`shannon-v1`](https://github.com/KeygraphHQ/shannon/tree/shannon-v1) branch is the final release built on the Claude Agent SDK and supports Claude Code OAuth.
|
||||
|
||||
Before running a pentest, review the [cyber safeguards requirements](#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
1. Generate a Claude Code OAuth token:
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
2. Run the setup flow for the final `shannon-v1` release:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon@1.9.0 setup
|
||||
```
|
||||
|
||||
3. Select **OAuth Token** and enter the token generated by Claude Code.
|
||||
4. Start the pentest with `npx @keygraph/shannon@1.9.0 start ...`.
|
||||
|
||||
These instructions apply only to `shannon-v1`.
|
||||
|
||||
## Validation
|
||||
|
||||
Checks run before a scan starts, so mistakes fail immediately rather than partway through a run:
|
||||
|
||||
- **Provider** — always validated against the providers Shannon's harness knows. An unrecognised provider is rejected with the valid list.
|
||||
- **Model ID** — validated against the harness catalogue for that provider, so a typo is caught instantly.
|
||||
- **Credential presence** — always validated for the selected provider.
|
||||
- **Provider and model ID** — validated against the Pi harness catalogue. An unknown provider or model ID fails preflight with a pointer to [pi.dev/models](https://pi.dev/models). A custom base URL exempts the model ID, since a gateway may serve its own names.
|
||||
- **Credential presence** — validated for the selected provider, or read from Pi when `SHANNON_USE_PI_AUTH=1`.
|
||||
- **Credential validity** — one minimal request against the model the scan will use, so a rejected key, an exhausted quota, or a model the account cannot reach fails before any agent runs. Bedrock included: its bearer token and region go through the same probe.
|
||||
|
||||
## Migrating from the three-tier configuration
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# CI/CD Integration
|
||||
|
||||
Shannon runs headlessly and non-interactively, so it fits ephemeral CI environments. This guide covers credentials, artifact paths, SARIF upload, and the runtime and cost characteristics that shape where a Shannon job belongs in a pipeline.
|
||||
|
||||
Everything here is part of Shannon Open Source. None of it is gated behind a commercial edition.
|
||||
|
||||
> [!WARNING]
|
||||
> Shannon actively executes exploits. Point CI jobs at ephemeral preview environments, staging, or disposable test deployments that you own. Do not run Shannon against production.
|
||||
|
||||
## Headless Requirements
|
||||
|
||||
A CI run needs three things:
|
||||
|
||||
- **Docker**, for the worker container. GitHub-hosted runners already provide it.
|
||||
- **Node.js 18+**, for the `npx` workflow.
|
||||
- **Provider credentials as environment variables**, so no interactive setup step runs.
|
||||
|
||||
`npx @keygraph/shannon setup` is the interactive credential wizard and is not used in CI. Export the variables instead:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=...
|
||||
```
|
||||
|
||||
`SHANNON_AI_MODEL` selects the provider and model as `<provider>:<model-id>`. Left unset, Shannon uses its default Claude model, so a job that exports only `ANTHROPIC_API_KEY` runs without further configuration. See [AI providers](ai-providers.md) for other providers, gateways, and custom base URLs.
|
||||
|
||||
> [!NOTE]
|
||||
> Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before wiring Shannon into a pipeline. See [cyber safeguards](ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
## How a Scan Runs in CI
|
||||
|
||||
`shannon start` launches the scan in a detached worker container and returns as soon as the run registers. It does not block until the pentest finishes.
|
||||
|
||||
`shannon logs <workspace>` streams the run's log and returns when the scan reports `COMPLETED` or `FAILED`. Pair the two commands to make a CI step wait for results:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon start -u "$TARGET_URL" -r . -c shannon.yaml -w ci-run -o ./shannon-results
|
||||
npx @keygraph/shannon logs ci-run
|
||||
```
|
||||
|
||||
Pass an explicit workspace name with `-w` so the `logs` command has a deterministic name to attach to. Without it, Shannon generates a name from the hostname and a timestamp.
|
||||
|
||||
## Output Artifacts
|
||||
|
||||
`-o <path>` copies the run's deliverables out of the workspace and into a directory the rest of your pipeline can read:
|
||||
|
||||
| File | Contents |
|
||||
| --- | --- |
|
||||
| `report.sarif` | SARIF 2.1.0 log. Written only when `report.sarif` is enabled and the run is exploitative. |
|
||||
| `report.json` | Structured findings emitted by the report agent. The Markdown report is rendered from it. |
|
||||
| `Security-Assessment-Report.pdf` | The human-facing report. |
|
||||
| `comprehensive_security_assessment_report.md` | The assembled Markdown report. |
|
||||
|
||||
`report.sarif` and `Security-Assessment-Report.pdf` are also surfaced at the workspace root, so a step that reads from the workspace directly can rely on a stable path.
|
||||
|
||||
## SARIF Output
|
||||
|
||||
SARIF 2.1.0 is the OASIS standard interchange format for static analysis results. Any tool that reads SARIF ingests `report.sarif` unchanged, so the GitHub Actions example below is one consumer among many, not a requirement.
|
||||
|
||||
SARIF is opt-in. Enable it in a configuration file:
|
||||
|
||||
```yaml
|
||||
# shannon.yaml
|
||||
report:
|
||||
sarif: "true"
|
||||
```
|
||||
|
||||
SARIF requires an exploitative run, which is the default. Shannon does not write a SARIF log for analysis-only runs (`exploit: "false"`).
|
||||
|
||||
Each finding becomes one SARIF result, filed under a rule per vulnerability class (`shannon/injection`, `shannon/xss`, `shannon/auth`, `shannon/authz`, `shannon/ssrf`) and tagged with its OWASP Top Ten 2025 category. Severity maps onto SARIF's three levels: `critical` and `high` become `error`, `medium` becomes `warning`, everything else becomes `note`.
|
||||
|
||||
See [Configuration](configuration.md#sarif-output) for the full mapping.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Shannon Pentest
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
pentest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Shannon
|
||||
run: |
|
||||
npx @keygraph/shannon start \
|
||||
-u ${{ vars.TARGET_URL }} \
|
||||
-r . \
|
||||
-c shannon.yaml \
|
||||
-w ci-${{ github.run_id }} \
|
||||
-o ./shannon-results
|
||||
|
||||
npx @keygraph/shannon logs ci-${{ github.run_id }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: ./shannon-results/report.sarif
|
||||
```
|
||||
|
||||
`security-events: write` is required for `upload-sarif` to publish into GitHub code scanning.
|
||||
|
||||
## Gating Merges
|
||||
|
||||
Shannon does not currently fail the job based on what it finds. `logs` returns successfully whether the scan completed or failed, so a passing step means the pipeline ran, not that the target is clean.
|
||||
|
||||
Two options for turning findings into a gate:
|
||||
|
||||
- **GitHub code scanning**: once the SARIF is uploaded, use code scanning's own pull request checks and severity rules to block a merge.
|
||||
- **Your own check**: read `report.json` in a follow-up step and exit non-zero on the findings you care about.
|
||||
|
||||
Filter before you gate. `report.min_severity` drops findings below a severity threshold at report time, so both the SARIF and the JSON carry only what you want to act on:
|
||||
|
||||
```yaml
|
||||
# shannon.yaml
|
||||
report:
|
||||
min_severity: high
|
||||
sarif: "true"
|
||||
```
|
||||
|
||||
Because Shannon reports only vulnerabilities it has produced a working proof-of-concept for, a gate built on these results fires on proven exploitation rather than speculative alerts.
|
||||
|
||||
## Authenticated Targets
|
||||
|
||||
Most useful CI targets sit behind a login. Describe the login flow, test credentials, and rules of engagement in the same configuration file you pass with `-c`, and supply secrets through environment variables rather than committing them. See [Configuration](configuration.md).
|
||||
|
||||
## Runtime and Cost
|
||||
|
||||
A full run can take roughly 1 to 1.5 hours and incurs LLM API costs that scale with model pricing and application complexity. That shapes where the job belongs:
|
||||
|
||||
- Scheduled runs against a staging environment, or a manual `workflow_dispatch`, fit the runtime better than a check on every pull request.
|
||||
- If you do run per pull request, scope it: limit `vuln_classes`, or trigger only on changes to security-sensitive paths.
|
||||
- Give the job a generous `timeout-minutes`. GitHub-hosted runners default to a six-hour job limit, but the step will inherit whatever you set.
|
||||
- Use `-w` with a stable workspace name to resume an interrupted run rather than restarting it from the first agent. See [Workspaces and resuming](workspaces.md).
|
||||
|
||||
## Other CI Systems
|
||||
|
||||
Nothing in the workflow is GitHub-specific. Any runner with Docker and Node.js 18+ can run the same two commands, export the same credentials, and collect the same artifacts from the `-o` directory. SARIF consumers other than GitHub code scanning read `report.sarif` unchanged.
|
||||
@@ -106,16 +106,16 @@ rules:
|
||||
|
||||
| Key | Effect |
|
||||
| --- | --- |
|
||||
| `min_severity` | Drops findings rated below this severity. Applies only when `exploit` is `"true"`. |
|
||||
| `min_severity` | Drops findings rated below this severity. Applies in both exploitative and analysis-only runs. |
|
||||
| `min_confidence` | Drops findings rated below this confidence. Applies only when `exploit` is `"false"`. |
|
||||
| `guidance` | Free-text instruction to the report agent, such as which topics to exclude. |
|
||||
| `sarif` | Emits a SARIF 2.1.0 log alongside the Markdown report. Requires `exploit: "true"`. |
|
||||
|
||||
A finding carries one rating or the other, never both: an exploited finding is rated by severity, an analysis-only finding by confidence. Setting the threshold that does not apply to the run is ignored, and Shannon logs a warning naming the one to use instead.
|
||||
Every finding carries a severity, but it does not mean the same thing in each mode: an exploitative run measures severity from what the exploit demonstrated, while an analysis-only run assesses it from the class of flaw and the impact it would have. An analysis-only finding carries a confidence rating alongside its severity, since nothing was proven. Setting `min_confidence` on an exploitative run is ignored, and Shannon logs a warning naming the threshold to use instead.
|
||||
|
||||
### SARIF Output
|
||||
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.md` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.pdf` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
|
||||
```yaml
|
||||
exploit: "true"
|
||||
@@ -125,7 +125,7 @@ report:
|
||||
|
||||
Each finding becomes one SARIF result, filed under a rule per vulnerability class (`shannon/injection`, `shannon/xss`, `shannon/auth`, `shannon/authz`, `shannon/ssrf`) and tagged with its OWASP Top Ten 2025 category. Results are anchored to the code location the analysis phase recorded, falling back to the HTTP entry point when the finding names no file. Severity maps onto SARIF's three levels: `critical` and `high` become `error`, `medium` becomes `warning`, everything else becomes `note`.
|
||||
|
||||
The log is written only for exploitative runs. An analysis-only run rates findings by confidence and produces no severity, so there is nothing to populate `level` with; `sarif` is ignored when `exploit` is `"false"`.
|
||||
The log is written only for exploitative runs. `sarif` is ignored when `exploit` is `"false"`.
|
||||
|
||||
Supported rule types include `url_path`, `subdomain`, `domain`, `method`, `header`, `parameter`, and `code_path`.
|
||||
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ Output structure — the run directory's top level holds only the final report;
|
||||
|
||||
```text
|
||||
workspaces/{hostname}_{sessionId}/
|
||||
|-- Security-Assessment-Report.md # the final report (the deliverable)
|
||||
|-- Security-Assessment-Report.pdf # the final report (the deliverable)
|
||||
`-- .shannon/ # internals
|
||||
|-- deliverables/ # report source, per-phase analysis, queues
|
||||
|-- agents/ # per-agent logs
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ For maximum isolation, run Shannon inside a disposable virtual machine.
|
||||
## LLM and Automation Caveats
|
||||
|
||||
- **Verification is required**: Shannon uses a proof-by-exploitation methodology, but final reports can still contain weakly supported or incorrect details. Human review is essential.
|
||||
- **Model support**: Shannon is officially supported only with Claude models. Alternative models may be incomplete, inaccurate, or unstable.
|
||||
- **Model support**: results vary by model. A model that does not follow Shannon's instructions or tool-use constraints reliably may produce incomplete, inaccurate, or unstable runs.
|
||||
- **Prompt injection risk**: Do not point Shannon at untrusted or adversarial codebases. AI-powered tools that read source code can be influenced by malicious repository content.
|
||||
|
||||
## Scope of Analysis
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Shannon uses workspaces to store scan state, logs, prompts, and deliverables. Wo
|
||||
- Use `-w <name>` to give a run a custom name.
|
||||
- To resume a run, pass the same workspace name with `-w`.
|
||||
- Each agent's progress is checkpointed so resumed runs can skip completed work.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.md`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.pdf`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
|
||||
> [!NOTE]
|
||||
> The URL must match the original workspace URL when resuming. Shannon rejects mismatched URLs to prevent cross-target contamination.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
|
||||
groupadd -g "$TARGET_GID" pentest
|
||||
useradd -u "$TARGET_UID" -g pentest -s /bin/bash -M pentest
|
||||
|
||||
chown -R pentest:pentest /app/sessions /app/workspaces /tmp/.claude
|
||||
chown -R pentest:pentest /app/sessions /app/workspaces /tmp/.claude /tmp/.pi
|
||||
fi
|
||||
|
||||
exec su -m pentest -c "exec $*"
|
||||
|
||||
+325
-35
@@ -8,7 +8,7 @@
|
||||
# File: README.md
|
||||
|
||||
> [!NOTE]
|
||||
> **[Shannon 2.0 now runs on the Pi harness](https://github.com/KeygraphHQ/shannon/discussions/393)**
|
||||
> **[Shannon 2.0 is officially here](https://github.com/KeygraphHQ/shannon/discussions/405)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15604" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15604" alt="KeygraphHQ%2Fshannon | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
Shannon is an autonomous, white-box AI pentester for web applications and APIs. <br />
|
||||
Shannon is an autonomous, AI pentester for web applications and APIs. <br />
|
||||
It analyzes your source code, identifies attack paths, and executes real exploits to prove vulnerabilities before they reach production.
|
||||
|
||||
**This repository is Shannon Open Source: the full agent, run locally from your command line.**
|
||||
@@ -43,14 +43,16 @@ It analyzes your source code, identifies attack paths, and executes real exploit
|
||||
- [Editions](#editions)
|
||||
- [Architecture](#architecture)
|
||||
- [Documentation](#documentation)
|
||||
- [Continuous Integration](#continuous-integration)
|
||||
- [Common Questions](#common-questions)
|
||||
- [Safety, Scope, and Limitations](#safety-scope-and-limitations)
|
||||
- [License and Enterprise Licensing](#license-and-enterprise-licensing)
|
||||
- [License](#license)
|
||||
- [About Keygraph](#about-keygraph)
|
||||
- [Community and Support](#community-and-support)
|
||||
|
||||
## What is Shannon?
|
||||
|
||||
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs white-box security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
|
||||
Shannon is an autonomous AI pentester developed by [Keygraph](https://keygraph.io). It performs security testing of web applications and their underlying APIs by combining source-code analysis with live exploitation.
|
||||
|
||||
Shannon analyzes your web application's source code to identify potential attack vectors, then uses browser automation and command-line tools to execute real exploits against the running application and its APIs. Only vulnerabilities with a working proof-of-concept are included in the final report.
|
||||
|
||||
@@ -82,7 +84,7 @@ Sample penetration test reports from intentionally vulnerable applications, prod
|
||||
|
||||
- **Docker**: required for the worker container.
|
||||
- **Node.js 18+**: required for the recommended `npx` workflow.
|
||||
- **AI provider credentials**: Anthropic, OpenAI, xAI, or AWS Bedrock. Claude models are recommended. Gateway and proxy setups are documented separately.
|
||||
- **AI provider credentials**: Shannon runs on Anthropic, OpenAI, xAI, AWS Bedrock, [any other provider](docs/ai-providers.md#any-other-provider) in the harness catalogue, and any endpoint that speaks the Anthropic Messages API or the OpenAI Chat Completions API through a [custom base URL](docs/ai-providers.md#custom-base-url). You bring your own key, and Keygraph never proxies your model traffic. Shannon is provider-agnostic. See [AI providers](docs/ai-providers.md#suggested-models) for suggested model IDs.
|
||||
- **Cyber safeguards cleared with your provider**: Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before your first run - see [AI providers](docs/ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
### Run Shannon
|
||||
@@ -103,7 +105,10 @@ Shannon pulls the worker image from Docker Hub, starts the required local infras
|
||||
For source builds, authenticated scans, provider-specific setup, and platform notes, see [Documentation](#documentation).
|
||||
|
||||
> [!TIP]
|
||||
> **Prefer to run on your Claude Code subscription instead of API credits?** The [`shannon-v1`](https://github.com/KeygraphHQ/shannon/tree/shannon-v1) branch is the last release built on the Claude Agent SDK, so it accepts a Claude Code OAuth token. Generate one with `claude setup-token`, then run `npx @keygraph/shannon@1.9.0 setup` and pick **OAuth Token**. Pentests then cost nothing beyond your existing subscription.
|
||||
> **Prefer to use a subscription instead of API credits?**
|
||||
>
|
||||
> - **OpenAI Codex:** The latest version of Shannon supports ChatGPT Plus and Pro subscriptions. Follow the [OpenAI Codex subscription setup guide](docs/ai-providers.md#openai-codex-chatgpt-pluspro-subscription) to get started.
|
||||
> - **Claude Code:** The latest version of Shannon does not support Claude Code subscriptions. Follow the [Claude Code subscription setup guide](docs/ai-providers.md#claude-code-subscription) to use version `1.9.0`, which is the final release built on the Claude Agent SDK.
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
@@ -113,6 +118,9 @@ For source builds, authenticated scans, provider-specific setup, and platform no
|
||||
- **Authenticated testing**: configuration files can describe login flows, test credentials, TOTP, email-based login flows, focus areas, and rules of engagement.
|
||||
- **OWASP-focused coverage**: Shannon targets exploitable Injection, XSS, SSRF, Broken Authentication, and Broken Authorization issues.
|
||||
- **Resumable workspaces**: Shannon can resume interrupted runs without re-running completed agents.
|
||||
- **Machine-readable output**: Shannon emits findings as structured JSON, and as SARIF 2.1.0 when you enable it in configuration. SARIF is the OASIS standard for static analysis results, so findings flow into any code scanning service, vulnerability management platform, security dashboard, or CI/CD pipeline that reads it.
|
||||
- **Headless CI/CD execution**: Shannon runs fully headless and non-interactively, with environment-variable credentials and configuration-file support, so it fits ephemeral CI environments. This is included in Shannon Open Source and is not gated behind a commercial edition.
|
||||
- **Bring your own key, provider-agnostic**: Shannon runs on Anthropic, OpenAI, xAI, AWS Bedrock, and any endpoint speaking the Anthropic Messages API or the OpenAI Chat Completions API, including self-hosted models served through Ollama, vLLM, or LM Studio and gateways such as OpenRouter and LiteLLM. You supply the credentials, so source code and model traffic stay inside your infrastructure.
|
||||
|
||||
## Editions
|
||||
|
||||
@@ -199,13 +207,93 @@ Use these guides for operational detail:
|
||||
| --- | --- |
|
||||
| [Source build and CLI commands](docs/development.md) | Cloning, building, common commands, output paths, and local development. |
|
||||
| [Configuration](docs/configuration.md) | Authenticated testing, login flows, rules of engagement, and report filters. |
|
||||
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock), and custom gateways. |
|
||||
| [AI providers](docs/ai-providers.md) | Selecting the model, the supported providers (Anthropic, OpenAI, xAI, AWS Bedrock, and any other Pi-supported provider), and custom gateways. |
|
||||
| [Platforms and networking](docs/platforms.md) | Windows/WSL2, Linux, macOS, Docker networking, local apps, and custom hostnames. |
|
||||
| [Workspaces and resuming](docs/workspaces.md) | Naming workspaces, resuming interrupted scans, and workspace storage. |
|
||||
| [Safety and limitations](docs/safety.md) | Authorized-use requirements, non-production guidance, mutative effects, cost, and model caveats. |
|
||||
| [Coverage and roadmap](docs/coverage-roadmap.md) | Current vulnerability coverage and planned work. |
|
||||
| [CI/CD integration](docs/ci-cd.md) | Headless execution, SARIF output, artifact paths, and GitHub Actions examples. |
|
||||
| [Keygraph platform](docs/keygraph-platform.md) | The continuous, agentic pentesting platform: code analysis, black-box and white-box testing, finding management, remediation, verification, and enterprise deployment. |
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Shannon runs fully headless and non-interactively, so it fits ephemeral CI environments. Credentials are read from environment variables, so no interactive `setup` step is required. The example below uses GitHub Actions, but nothing about the run is GitHub-specific.
|
||||
|
||||
```yaml
|
||||
name: Shannon Pentest
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
pentest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Shannon
|
||||
run: |
|
||||
npx @keygraph/shannon start \
|
||||
-u ${{ vars.TARGET_URL }} \
|
||||
-r . \
|
||||
-w ci-${{ github.run_id }} \
|
||||
-o ./shannon-results
|
||||
|
||||
# `start` launches the scan in the background. `logs` streams it and
|
||||
# returns once the scan reports COMPLETED or FAILED.
|
||||
npx @keygraph/shannon logs ci-${{ github.run_id }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shannon-report
|
||||
path: ./shannon-results/
|
||||
```
|
||||
|
||||
`-o` copies the run's deliverables, including the report and the structured findings in `report.json`, to a path the rest of your workflow can read.
|
||||
|
||||
Because Shannon reports only vulnerabilities it has actually exploited, what lands in your pipeline is proven rather than speculative. Set `report.min_severity` in a configuration file passed with `-c` to drop findings below a severity threshold, then gate merges on your own check over `report.json`.
|
||||
|
||||
See [CI/CD integration](docs/ci-cd.md) for artifact paths, SARIF output, authenticated targets, and cost and runtime notes.
|
||||
|
||||
## Common Questions
|
||||
|
||||
### Is Shannon free?
|
||||
|
||||
Yes. Shannon Open Source is free and licensed under AGPL-3.0. You run it yourself from the command line. Your only cost is the AI provider credits you supply.
|
||||
|
||||
### Can I self-host Shannon?
|
||||
|
||||
Yes. Shannon Open Source runs entirely on your own infrastructure in an ephemeral Docker container. Your source code is mounted read-only and never leaves your environment.
|
||||
|
||||
### Does Shannon support bring your own key (BYOK)?
|
||||
|
||||
Yes, always. You supply your own AI provider credentials in every deployment, open source and commercial. Keygraph never proxies your model traffic.
|
||||
|
||||
### Can Shannon run in CI/CD?
|
||||
|
||||
Yes. Shannon runs fully headless and non-interactively, with environment-variable credentials and configuration-file support. See [Continuous Integration](#continuous-integration) for a worked example, and [CI/CD integration](docs/ci-cd.md) for SARIF output and artifact paths. This is part of Shannon Open Source.
|
||||
|
||||
### Does Shannon output SARIF?
|
||||
|
||||
Yes. Shannon emits SARIF 2.1.0, the OASIS standard format for static analysis results, alongside structured JSON. Any SARIF consumer reads it: code scanning services, vulnerability management platforms, security dashboards, and CI/CD pipelines. Set `report.sarif` to `"true"` in your configuration file to enable the SARIF log.
|
||||
|
||||
### Which AI providers does Shannon support?
|
||||
|
||||
Anthropic, OpenAI, xAI, and AWS Bedrock are built in and configured directly by provider ID. Beyond those, Shannon runs on any endpoint that implements the Anthropic Messages API or the OpenAI Chat Completions API, reached through a custom base URL. The rule is the API format, not the vendor. Shannon uses a single unified model setting throughout a pentest.
|
||||
|
||||
### Can I run Shannon on a local or self-hosted model?
|
||||
|
||||
Yes. Shannon works with local models served through Ollama, vLLM, or LM Studio, which expose an OpenAI-compatible endpoint, as well as routers such as OpenRouter and gateways such as LiteLLM. Point Shannon at the endpoint with a custom base URL. See [AI providers](docs/ai-providers.md#custom-base-url).
|
||||
|
||||
### Does Shannon actually exploit vulnerabilities, or just scan?
|
||||
|
||||
Shannon executes real exploits. It reports a finding only when it has produced a working proof-of-concept, and discards hypotheses it cannot prove. It is a pentester, not a scanner.
|
||||
|
||||
### Is Shannon free for startups and nonprofits?
|
||||
|
||||
Shannon Open Source is free for everyone. In addition, the Keygraph Community Program gives eligible nonprofits and early-stage startups free access to the commercial Keygraph platform. See [keygraph.io](https://keygraph.io).
|
||||
|
||||
## Safety, Scope, and Limitations
|
||||
|
||||
Shannon is not a passive scanner. Its exploitation agents can create users, submit forms, mutate application state, trigger outbound requests, and otherwise affect the target system. Use sandboxed, staging, or local development environments with disposable data.
|
||||
@@ -216,13 +304,13 @@ Important limitations:
|
||||
|
||||
- Shannon Open Source focuses on actively exploitable issues such as Injection, XSS, SSRF, Broken Authentication, and Broken Authorization. Broader static-analysis coverage, including vulnerable dependencies and insecure configurations, is delivered through the Keygraph platform.
|
||||
- Findings still require human review. LLM-generated reports can contain weakly supported or incorrect details.
|
||||
- Shannon is officially supported with Claude models. Smaller, alternative, or proxied non-Claude models may be incomplete or unstable.
|
||||
- Anthropic, OpenAI, xAI, and AWS Bedrock are built-in providers, and any Anthropic Messages API or OpenAI Chat Completions API endpoint works through a custom base URL. Model capability varies, and a model that does not follow Shannon's instructions or tool-use constraints reliably will produce weaker results.
|
||||
- A full run can take roughly 1 to 1.5 hours and may incur LLM API costs depending on model pricing and application complexity.
|
||||
- Do not scan untrusted or adversarial codebases. AI-powered tools that read source code can be exposed to prompt injection.
|
||||
|
||||
Read the full [Safety and limitations](docs/safety.md) guide before running Shannon in a new environment.
|
||||
|
||||
## License and Enterprise Licensing
|
||||
## License
|
||||
|
||||
Shannon Open Source is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
@@ -401,7 +489,7 @@ Output structure — the run directory's top level holds only the final report;
|
||||
|
||||
```text
|
||||
workspaces/{hostname}_{sessionId}/
|
||||
|-- Security-Assessment-Report.md # the final report (the deliverable)
|
||||
|-- Security-Assessment-Report.pdf # the final report (the deliverable)
|
||||
`-- .shannon/ # internals
|
||||
|-- deliverables/ # report source, per-phase analysis, queues
|
||||
|-- agents/ # per-agent logs
|
||||
@@ -523,16 +611,16 @@ rules:
|
||||
|
||||
| Key | Effect |
|
||||
| --- | --- |
|
||||
| `min_severity` | Drops findings rated below this severity. Applies only when `exploit` is `"true"`. |
|
||||
| `min_severity` | Drops findings rated below this severity. Applies in both exploitative and analysis-only runs. |
|
||||
| `min_confidence` | Drops findings rated below this confidence. Applies only when `exploit` is `"false"`. |
|
||||
| `guidance` | Free-text instruction to the report agent, such as which topics to exclude. |
|
||||
| `sarif` | Emits a SARIF 2.1.0 log alongside the Markdown report. Requires `exploit: "true"`. |
|
||||
|
||||
A finding carries one rating or the other, never both: an exploited finding is rated by severity, an analysis-only finding by confidence. Setting the threshold that does not apply to the run is ignored, and Shannon logs a warning naming the one to use instead.
|
||||
Every finding carries a severity, but it does not mean the same thing in each mode: an exploitative run measures severity from what the exploit demonstrated, while an analysis-only run assesses it from the class of flaw and the impact it would have. An analysis-only finding carries a confidence rating alongside its severity, since nothing was proven. Setting `min_confidence` on an exploitative run is ignored, and Shannon logs a warning naming the threshold to use instead.
|
||||
|
||||
### SARIF Output
|
||||
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.md` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
Set `sarif: "true"` to write `report.sarif` next to `Security-Assessment-Report.pdf` at the workspace root, for upload to GitHub code scanning or any other SARIF consumer.
|
||||
|
||||
```yaml
|
||||
exploit: "true"
|
||||
@@ -542,7 +630,7 @@ report:
|
||||
|
||||
Each finding becomes one SARIF result, filed under a rule per vulnerability class (`shannon/injection`, `shannon/xss`, `shannon/auth`, `shannon/authz`, `shannon/ssrf`) and tagged with its OWASP Top Ten 2025 category. Results are anchored to the code location the analysis phase recorded, falling back to the HTTP entry point when the finding names no file. Severity maps onto SARIF's three levels: `critical` and `high` become `error`, `medium` becomes `warning`, everything else becomes `note`.
|
||||
|
||||
The log is written only for exploitative runs. An analysis-only run rates findings by confidence and produces no severity, so there is nothing to populate `level` with; `sarif` is ignored when `exploit` is `"false"`.
|
||||
The log is written only for exploitative runs. `sarif` is ignored when `exploit` is `"false"`.
|
||||
|
||||
Supported rule types include `url_path`, `subdomain`, `domain`, `method`, `header`, `parameter`, and `code_path`.
|
||||
|
||||
@@ -592,20 +680,32 @@ The provider half decides where the request goes, which credential is used, and
|
||||
|
||||
| Provider | Value | Credential |
|
||||
| --- | --- | --- |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` |
|
||||
| OpenAI | `openai` | `OPENAI_API_KEY` |
|
||||
| xAI | `xai` | `XAI_API_KEY` |
|
||||
| Anthropic | `anthropic` | `SHANNON_AI_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) |
|
||||
| OpenAI | `openai` | `SHANNON_AI_API_KEY` |
|
||||
| xAI | `xai` | `SHANNON_AI_API_KEY` |
|
||||
| AWS Bedrock | `amazon-bedrock` | `AWS_REGION` and `AWS_BEARER_TOKEN_BEDROCK` |
|
||||
|
||||
Shannon does not invent credential names — each is the variable that provider's own tooling already uses. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
|
||||
`SHANNON_AI_API_KEY` holds the key for whichever provider `SHANNON_AI_MODEL` names. Bedrock is the exception — it authenticates through its `AWS_` variables only. If `SHANNON_AI_MODEL` is unset, Shannon uses `anthropic:claude-sonnet-4-6`.
|
||||
|
||||
Anthropic, OpenAI, and xAI also accept their native variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`); if one of those is set, it is used instead of `SHANNON_AI_API_KEY`.
|
||||
|
||||
Shannon forwards only the selected provider's credential into the scan container. Keys for other providers stay on your machine.
|
||||
|
||||
> [!NOTE]
|
||||
> Only the **first** colon separates the provider from the model ID, so Bedrock IDs that contain colons work unchanged: `amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`.
|
||||
### Any other provider
|
||||
|
||||
Shannon accepts any provider and model present in the Pi harness catalogue. Browse them at [pi.dev/models](https://pi.dev/models).
|
||||
|
||||
```bash
|
||||
export SHANNON_AI_API_KEY=your-api-key # the provider's API key
|
||||
export SHANNON_AI_MODEL=openrouter:moonshotai/kimi-k3 # <provider>:<model-id>
|
||||
```
|
||||
|
||||
This path covers providers whose credential is a single API key. Providers that need more than that are not currently supported.
|
||||
|
||||
`npx @keygraph/shannon setup` exposes this as the **Other provider** option.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Claude models are the best-supported option. Shannon's evaluations, internal testing, and agent harness are tuned for Claude. Other models are permitted and validated against the harness catalogue, but may not follow Shannon's instructions or tool-use constraints as reliably. Use them at your own risk.
|
||||
> Models are validated against the harness catalogue, but capability varies. A model that does not follow Shannon's instructions or tool-use constraints reliably will produce weaker pentests. Evaluate the model you choose against your own targets before depending on its results.
|
||||
|
||||
## Cyber safeguards (do this before your first scan)
|
||||
|
||||
@@ -638,21 +738,21 @@ The pattern is learned once: export the provider's key, name the model. Two line
|
||||
Anthropic (default):
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
```
|
||||
|
||||
OpenAI:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export SHANNON_AI_API_KEY=sk-...
|
||||
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
|
||||
```
|
||||
|
||||
xAI:
|
||||
|
||||
```bash
|
||||
export XAI_API_KEY=xai-...
|
||||
export SHANNON_AI_API_KEY=xai-...
|
||||
export SHANNON_AI_MODEL=xai:grok-4.5
|
||||
```
|
||||
|
||||
@@ -676,16 +776,16 @@ To route model traffic through your own infrastructure — a corporate proxy, an
|
||||
|
||||
| Gateway serves | Model prefix | API key |
|
||||
| --- | --- | --- |
|
||||
| Anthropic Messages | `anthropic:` | `ANTHROPIC_API_KEY` |
|
||||
| OpenAI Chat Completions | `openai:` | `OPENAI_API_KEY` |
|
||||
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `OPENAI_API_KEY` |
|
||||
| Anthropic Messages | `anthropic:` | `SHANNON_AI_API_KEY` |
|
||||
| OpenAI Chat Completions | `openai:` | `SHANNON_AI_API_KEY` |
|
||||
| OpenAI Responses | `openai:` + `SHANNON_AI_OPENAI_FORMAT=responses` | `SHANNON_AI_API_KEY` |
|
||||
|
||||
The model ID is whatever name your gateway serves it under; it does not have to exist in Shannon's catalogue.
|
||||
|
||||
Anthropic Messages:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_API_KEY=sk-ant-...
|
||||
export SHANNON_AI_MODEL=anthropic:claude-sonnet-4-6
|
||||
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
|
||||
```
|
||||
@@ -693,7 +793,7 @@ export SHANNON_AI_BASE_URL=https://llm-gateway.example.com
|
||||
OpenAI Chat Completions:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export SHANNON_AI_API_KEY=sk-...
|
||||
export SHANNON_AI_MODEL=openai:gpt-5.6-sol
|
||||
export SHANNON_AI_BASE_URL=https://llm-gateway.example.com/v1
|
||||
```
|
||||
@@ -712,13 +812,55 @@ The variable is rejected in preflight where it cannot take effect: with a non-`o
|
||||
|
||||
`npx @keygraph/shannon setup` covers this under **Custom Base URL**, which asks which API your gateway serves and configures the matching provider for you.
|
||||
|
||||
## OpenAI Codex (ChatGPT Plus/Pro subscription)
|
||||
|
||||
A ChatGPT Plus or Pro Codex subscription can run Shannon. Shannon reuses a login created by Pi.
|
||||
|
||||
Before running a pentest, review the [cyber safeguards requirements](#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
1. Install Pi by following the instructions at [pi.dev](https://pi.dev).
|
||||
2. Log in with your subscription using Pi's [subscription authentication guide](https://pi.dev/docs/latest/providers#subscriptions). This creates `~/.pi/agent/auth.json` with an `openai-codex` entry.
|
||||
|
||||
3. Select a Codex model and enable Pi authentication:
|
||||
|
||||
```bash
|
||||
export SHANNON_USE_PI_AUTH=1
|
||||
export SHANNON_AI_MODEL=openai-codex:gpt-5.5
|
||||
```
|
||||
|
||||
4. In npx mode, run `npx @keygraph/shannon start ...` from the same shell. In source-build mode, add the two variables to `.env` and run `./shannon start ...`.
|
||||
|
||||
Supported Codex models are `gpt-5.6-sol`, `gpt-5.5`, and `gpt-5.4`.
|
||||
|
||||
## Claude Code subscription
|
||||
|
||||
The latest version of Shannon does not support Claude Code subscriptions. The [`shannon-v1`](https://github.com/KeygraphHQ/shannon/tree/shannon-v1) branch is the final release built on the Claude Agent SDK and supports Claude Code OAuth.
|
||||
|
||||
Before running a pentest, review the [cyber safeguards requirements](#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
1. Generate a Claude Code OAuth token:
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
2. Run the setup flow for the final `shannon-v1` release:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon@1.9.0 setup
|
||||
```
|
||||
|
||||
3. Select **OAuth Token** and enter the token generated by Claude Code.
|
||||
4. Start the pentest with `npx @keygraph/shannon@1.9.0 start ...`.
|
||||
|
||||
These instructions apply only to `shannon-v1`.
|
||||
|
||||
## Validation
|
||||
|
||||
Checks run before a scan starts, so mistakes fail immediately rather than partway through a run:
|
||||
|
||||
- **Provider** — always validated against the providers Shannon's harness knows. An unrecognised provider is rejected with the valid list.
|
||||
- **Model ID** — validated against the harness catalogue for that provider, so a typo is caught instantly.
|
||||
- **Credential presence** — always validated for the selected provider.
|
||||
- **Provider and model ID** — validated against the Pi harness catalogue. An unknown provider or model ID fails preflight with a pointer to [pi.dev/models](https://pi.dev/models). A custom base URL exempts the model ID, since a gateway may serve its own names.
|
||||
- **Credential presence** — validated for the selected provider, or read from Pi when `SHANNON_USE_PI_AUTH=1`.
|
||||
- **Credential validity** — one minimal request against the model the scan will use, so a rejected key, an exhausted quota, or a model the account cannot reach fails before any agent runs. Bedrock included: its bearer token and region go through the same probe.
|
||||
|
||||
## Migrating from the three-tier configuration
|
||||
@@ -850,7 +992,7 @@ Shannon uses workspaces to store scan state, logs, prompts, and deliverables. Wo
|
||||
- Use `-w <name>` to give a run a custom name.
|
||||
- To resume a run, pass the same workspace name with `-w`.
|
||||
- Each agent's progress is checkpointed so resumed runs can skip completed work.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.md`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
- The final report is surfaced at the workspace root as `Security-Assessment-Report.pdf`. Run internals — deliverables, logs, prompts, and session state — live under a hidden `.shannon/` directory.
|
||||
|
||||
> [!NOTE]
|
||||
> The URL must match the original workspace URL when resuming. Shannon rejects mismatched URLs to prevent cross-target contamination.
|
||||
@@ -923,7 +1065,7 @@ For maximum isolation, run Shannon inside a disposable virtual machine.
|
||||
## LLM and Automation Caveats
|
||||
|
||||
- **Verification is required**: Shannon uses a proof-by-exploitation methodology, but final reports can still contain weakly supported or incorrect details. Human review is essential.
|
||||
- **Model support**: Shannon is officially supported only with Claude models. Alternative models may be incomplete, inaccurate, or unstable.
|
||||
- **Model support**: results vary by model. A model that does not follow Shannon's instructions or tool-use constraints reliably may produce incomplete, inaccurate, or unstable runs.
|
||||
- **Prompt injection risk**: Do not point Shannon at untrusted or adversarial codebases. AI-powered tools that read source code can be influenced by malicious repository content.
|
||||
|
||||
## Scope of Analysis
|
||||
@@ -944,7 +1086,6 @@ For broader coverage, the Keygraph platform adds black-box and white-box agentic
|
||||
|
||||
A full test run typically takes roughly 1 to 1.5 hours. LLM API costs vary by model pricing, target complexity, selected provider, and concurrency.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# File: docs/coverage-roadmap.md
|
||||
@@ -975,6 +1116,155 @@ For organizations that need broader static and organizational coverage now, see
|
||||
|
||||
---
|
||||
|
||||
# File: docs/ci-cd.md
|
||||
|
||||
# CI/CD Integration
|
||||
|
||||
Shannon runs headlessly and non-interactively, so it fits ephemeral CI environments. This guide covers credentials, artifact paths, SARIF upload, and the runtime and cost characteristics that shape where a Shannon job belongs in a pipeline.
|
||||
|
||||
Everything here is part of Shannon Open Source. None of it is gated behind a commercial edition.
|
||||
|
||||
> [!WARNING]
|
||||
> Shannon actively executes exploits. Point CI jobs at ephemeral preview environments, staging, or disposable test deployments that you own. Do not run Shannon against production.
|
||||
|
||||
## Headless Requirements
|
||||
|
||||
A CI run needs three things:
|
||||
|
||||
- **Docker**, for the worker container. GitHub-hosted runners already provide it.
|
||||
- **Node.js 18+**, for the `npx` workflow.
|
||||
- **Provider credentials as environment variables**, so no interactive setup step runs.
|
||||
|
||||
`npx @keygraph/shannon setup` is the interactive credential wizard and is not used in CI. Export the variables instead:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=...
|
||||
```
|
||||
|
||||
`SHANNON_AI_MODEL` selects the provider and model as `<provider>:<model-id>`. Left unset, Shannon uses its default Claude model, so a job that exports only `ANTHROPIC_API_KEY` runs without further configuration. See [AI providers](ai-providers.md) for other providers, gateways, and custom base URLs.
|
||||
|
||||
> [!NOTE]
|
||||
> Anthropic and OpenAI apply real-time safeguards to cyber-security workloads, which can interrupt a scan mid-run. Complete their guidance for legitimate security testers before wiring Shannon into a pipeline. See [cyber safeguards](ai-providers.md#cyber-safeguards-do-this-before-your-first-scan).
|
||||
|
||||
## How a Scan Runs in CI
|
||||
|
||||
`shannon start` launches the scan in a detached worker container and returns as soon as the run registers. It does not block until the pentest finishes.
|
||||
|
||||
`shannon logs <workspace>` streams the run's log and returns when the scan reports `COMPLETED` or `FAILED`. Pair the two commands to make a CI step wait for results:
|
||||
|
||||
```bash
|
||||
npx @keygraph/shannon start -u "$TARGET_URL" -r . -c shannon.yaml -w ci-run -o ./shannon-results
|
||||
npx @keygraph/shannon logs ci-run
|
||||
```
|
||||
|
||||
Pass an explicit workspace name with `-w` so the `logs` command has a deterministic name to attach to. Without it, Shannon generates a name from the hostname and a timestamp.
|
||||
|
||||
## Output Artifacts
|
||||
|
||||
`-o <path>` copies the run's deliverables out of the workspace and into a directory the rest of your pipeline can read:
|
||||
|
||||
| File | Contents |
|
||||
| --- | --- |
|
||||
| `report.sarif` | SARIF 2.1.0 log. Written only when `report.sarif` is enabled and the run is exploitative. |
|
||||
| `report.json` | Structured findings emitted by the report agent. The Markdown report is rendered from it. |
|
||||
| `Security-Assessment-Report.pdf` | The human-facing report. |
|
||||
| `comprehensive_security_assessment_report.md` | The assembled Markdown report. |
|
||||
|
||||
`report.sarif` and `Security-Assessment-Report.pdf` are also surfaced at the workspace root, so a step that reads from the workspace directly can rely on a stable path.
|
||||
|
||||
## SARIF Output
|
||||
|
||||
SARIF 2.1.0 is the OASIS standard interchange format for static analysis results. Any tool that reads SARIF ingests `report.sarif` unchanged, so the GitHub Actions example below is one consumer among many, not a requirement.
|
||||
|
||||
SARIF is opt-in. Enable it in a configuration file:
|
||||
|
||||
```yaml
|
||||
# shannon.yaml
|
||||
report:
|
||||
sarif: "true"
|
||||
```
|
||||
|
||||
SARIF requires an exploitative run, which is the default. Shannon does not write a SARIF log for analysis-only runs (`exploit: "false"`).
|
||||
|
||||
Each finding becomes one SARIF result, filed under a rule per vulnerability class (`shannon/injection`, `shannon/xss`, `shannon/auth`, `shannon/authz`, `shannon/ssrf`) and tagged with its OWASP Top Ten 2025 category. Severity maps onto SARIF's three levels: `critical` and `high` become `error`, `medium` becomes `warning`, everything else becomes `note`.
|
||||
|
||||
See [Configuration](configuration.md#sarif-output) for the full mapping.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Shannon Pentest
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
pentest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Shannon
|
||||
run: |
|
||||
npx @keygraph/shannon start \
|
||||
-u ${{ vars.TARGET_URL }} \
|
||||
-r . \
|
||||
-c shannon.yaml \
|
||||
-w ci-${{ github.run_id }} \
|
||||
-o ./shannon-results
|
||||
|
||||
npx @keygraph/shannon logs ci-${{ github.run_id }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: ./shannon-results/report.sarif
|
||||
```
|
||||
|
||||
`security-events: write` is required for `upload-sarif` to publish into GitHub code scanning.
|
||||
|
||||
## Gating Merges
|
||||
|
||||
Shannon does not currently fail the job based on what it finds. `logs` returns successfully whether the scan completed or failed, so a passing step means the pipeline ran, not that the target is clean.
|
||||
|
||||
Two options for turning findings into a gate:
|
||||
|
||||
- **GitHub code scanning**: once the SARIF is uploaded, use code scanning's own pull request checks and severity rules to block a merge.
|
||||
- **Your own check**: read `report.json` in a follow-up step and exit non-zero on the findings you care about.
|
||||
|
||||
Filter before you gate. `report.min_severity` drops findings below a severity threshold at report time, so both the SARIF and the JSON carry only what you want to act on:
|
||||
|
||||
```yaml
|
||||
# shannon.yaml
|
||||
report:
|
||||
min_severity: high
|
||||
sarif: "true"
|
||||
```
|
||||
|
||||
Because Shannon reports only vulnerabilities it has produced a working proof-of-concept for, a gate built on these results fires on proven exploitation rather than speculative alerts.
|
||||
|
||||
## Authenticated Targets
|
||||
|
||||
Most useful CI targets sit behind a login. Describe the login flow, test credentials, and rules of engagement in the same configuration file you pass with `-c`, and supply secrets through environment variables rather than committing them. See [Configuration](configuration.md).
|
||||
|
||||
## Runtime and Cost
|
||||
|
||||
A full run can take roughly 1 to 1.5 hours and incurs LLM API costs that scale with model pricing and application complexity. That shapes where the job belongs:
|
||||
|
||||
- Scheduled runs against a staging environment, or a manual `workflow_dispatch`, fit the runtime better than a check on every pull request.
|
||||
- If you do run per pull request, scope it: limit `vuln_classes`, or trigger only on changes to security-sensitive paths.
|
||||
- Give the job a generous `timeout-minutes`. GitHub-hosted runners default to a six-hour job limit, but the step will inherit whatever you set.
|
||||
- Use `-w` with a stable workspace name to resume an interrupted run rather than restarting it from the first agent. See [Workspaces and resuming](workspaces.md).
|
||||
|
||||
## Other CI Systems
|
||||
|
||||
Nothing in the workflow is GitHub-specific. Any runner with Docker and Node.js 18+ can run the same two commands, export the same credentials, and collect the same artifacts from the `-o` directory. SARIF consumers other than GitHub code scanning read `report.sarif` unchanged.
|
||||
|
||||
---
|
||||
|
||||
# File: docs/keygraph-platform.md
|
||||
|
||||
# Keygraph Platform
|
||||
|
||||
@@ -6,18 +6,19 @@ Use this file as the concise entry point for AI agents and LLMs reading this rep
|
||||
|
||||
## Start Here
|
||||
|
||||
- [README](README.md): Main project overview, editions, quick start, Shannon capabilities, Keygraph platform positioning, safety notes, licensing, and support links.
|
||||
- [README](README.md): Main project overview, editions, quick start, continuous integration, Shannon capabilities, Keygraph platform positioning, common questions, safety notes, licensing, and support links.
|
||||
- [Full Combined Context](llms-full.txt): README and documentation combined into one file for agents that need maximum local context.
|
||||
|
||||
## Shannon
|
||||
|
||||
- [Development](docs/development.md): Source-build workflow, common CLI commands, repository paths, and output locations.
|
||||
- [Configuration](docs/configuration.md): Authenticated testing, login flows, rules of engagement, report filters, credential precedence, adaptive thinking, and rate-limit settings.
|
||||
- [AI Providers](docs/ai-providers.md): Anthropic, AWS Bedrock, and custom Anthropic-compatible endpoint setup.
|
||||
- [AI Providers](docs/ai-providers.md): Anthropic, OpenAI, xAI, AWS Bedrock, any other Pi-supported provider, and custom gateway setup.
|
||||
- [Platforms and Networking](docs/platforms.md): Windows/WSL2, Linux, macOS, Docker networking, local applications, and custom hostnames.
|
||||
- [Workspaces and Resuming](docs/workspaces.md): Workspace storage, naming, resuming interrupted scans, and examples.
|
||||
- [Safety and Limitations](docs/safety.md): Authorized-use requirements, non-production guidance, mutative effects, model caveats, scope limits, cost, and performance.
|
||||
- [Coverage and Roadmap](docs/coverage-roadmap.md): Current Shannon coverage and roadmap direction.
|
||||
- [CI/CD Integration](docs/ci-cd.md): Headless and non-interactive execution, environment-variable credentials, SARIF 2.1.0 and JSON artifact paths, GitHub Actions examples, and merge-gating options.
|
||||
|
||||
## Keygraph Platform
|
||||
|
||||
|
||||
Reference in New Issue
Block a user