Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,19 @@ RUN ln -s /app/apps/worker/dist/scripts/save-deliverable.js /usr/local/bin/save-
ln -s /app/apps/worker/dist/scripts/generate-totp.js /usr/local/bin/generate-totp && \
chmod +x /app/apps/worker/dist/scripts/generate-totp.js

# Create directories for session data and ensure proper permissions
# Create directories for session data and ensure proper permissions.
# 0o770 (owner+group rwx, world none) is sufficient: the container only ever
# runs the pentest user (or a remapped UID added to the pentest group via
# entrypoint.sh) and there is no legitimate world-write requirement. 0o777
# previously made every directory writable by every other UID inside the
# container, which is a needlessly broad blast radius.
RUN mkdir -p /app/sessions /app/repos /app/workspaces && \
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \
chmod 777 /app && \
chmod 777 /tmp/.cache && \
chmod 777 /tmp/.config && \
chmod 777 /tmp/.npm && \
chown -R pentest:pentest /app /tmp/.claude
chmod 770 /app && \
chmod 770 /tmp/.cache && \
chmod 770 /tmp/.config && \
chmod 770 /tmp/.npm && \
chown -R pentest:pentest /app /tmp/.claude /tmp/.cache /tmp/.config /tmp/.npm

COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
Expand Down
35 changes: 31 additions & 4 deletions apps/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface StartArgs {
output?: string;
pipelineTesting: boolean;
debug: boolean;
reportFormat: 'md' | 'sarif';
version: string;
}

Expand All @@ -42,6 +43,22 @@ export async function start(args: StartArgs): Promise<void> {
const repo = resolveRepo(args.repo);
const config = args.config ? resolveConfig(args.config) : undefined;

// 3a. Validate target URL up front. Without this, a malformed value or a
// non-http scheme (file://, ftp://, javascript:) crashes the CLI mid-setup
// with a raw TypeError or, worse, slips through to the worker which
// assumes http/https semantics.
let targetUrl: URL;
try {
targetUrl = new URL(args.url);
} catch {
console.error(`ERROR: Invalid URL: ${args.url}`);
process.exit(1);
}
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
console.error(`ERROR: URL scheme must be http or https, got: ${targetUrl.protocol}`);
process.exit(1);
}

// 4. Ensure workspaces dir is writable by container user (UID 1001)
const workspacesDir = getWorkspacesDir();
fs.mkdirSync(workspacesDir, { recursive: true });
Expand All @@ -57,8 +74,7 @@ export async function start(args: StartArgs): Promise<void> {
const containerName = `shannon-worker-${suffix}`;

// 7. Generate workspace name if not provided
const workspace =
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
const workspace = args.workspace ?? `${targetUrl.hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;

// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
// Workspace dir must be 0o777 so the container user (UID 1001) can create audit subdirs
Expand Down Expand Up @@ -105,6 +121,7 @@ export async function start(args: StartArgs): Promise<void> {
taskQueue,
containerName,
envFlags: buildEnvFlags(),
reportFormat: args.reportFormat,
...(config && { config }),
...(hasCredentials && { credentials: credentialsPath }),
...(promptsDir && { promptsDir }),
Expand Down Expand Up @@ -173,8 +190,18 @@ export async function start(args: StartArgs): Promise<void> {
printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir);
return;
}
} catch {
// File doesn't exist yet
} catch (error) {
// ENOENT is the expected steady-state until the worker writes
// session.json — keep polling. SyntaxError means the worker is
// mid-write — also keep polling. Anything else (EACCES, EIO,
// ENOTDIR) is a real problem we should not silently swallow.
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== 'ENOENT' && !(error instanceof SyntaxError)) {
clearInterval(pollInterval);
process.stdout.write('\n');
console.error(`ERROR: Failed to read session file: ${(error as Error).message}`);
process.exit(1);
}
}
process.stdout.write('.');
}, 2000);
Expand Down
9 changes: 9 additions & 0 deletions apps/cli/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export interface WorkerOptions {
workspace: string;
pipelineTesting?: boolean;
debug?: boolean;
reportFormat?: 'md' | 'sarif';
}

/**
Expand Down Expand Up @@ -213,6 +214,14 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
// Environment
args.push(...opts.envFlags);

// Forward report format selection + version to the worker. Done as env
// (rather than CLI args) because the worker reads them once at startup
// to wire DI providers, before any activity-input plumbing exists.
if (opts.reportFormat && opts.reportFormat !== 'md') {
args.push('-e', `SHANNON_REPORT_FORMAT=${opts.reportFormat}`);
}
args.push('-e', `SHANNON_VERSION=${opts.version}`);

// Container settings
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');

Expand Down
19 changes: 19 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ Options for 'start':
-w, --workspace <name> Named workspace (auto-resumes if exists)
--pipeline-testing Use minimal prompts for fast testing
--debug Preserve worker container after exit for log inspection
--report-format <fmt> Report output format: 'md' (default) or 'sarif'
'sarif' emits a SARIF 2.1.0 file alongside the
markdown report for ingestion by GitHub Code
Scanning, GitLab, Defect Dojo, etc.

Examples:
${prefix} start -u https://example.com -r ${mode === 'local' ? 'my-repo' : './my-repo'}
Expand All @@ -87,6 +91,8 @@ Monitor workflows at http://localhost:8233
`);
}

type ReportFormat = 'md' | 'sarif';

interface ParsedStartArgs {
url: string;
repo: string;
Expand All @@ -95,6 +101,7 @@ interface ParsedStartArgs {
output?: string;
pipelineTesting: boolean;
debug: boolean;
reportFormat: ReportFormat;
}

function parseStartArgs(argv: string[]): ParsedStartArgs {
Expand All @@ -105,6 +112,7 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
let output: string | undefined;
let pipelineTesting = false;
let debug = false;
let reportFormat: ReportFormat = 'md';

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
Expand Down Expand Up @@ -152,6 +160,16 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
case '--debug':
debug = true;
break;
case '--report-format':
if (next && !next.startsWith('-')) {
if (next !== 'md' && next !== 'sarif') {
console.error(`ERROR: --report-format must be 'md' or 'sarif', got '${next}'`);
process.exit(1);
}
reportFormat = next;
i++;
}
break;
default:
console.error(`Unknown option: ${arg}`);
console.error(`Run "${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} help" for usage`);
Expand All @@ -170,6 +188,7 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
repo,
pipelineTesting,
debug,
reportFormat,
...(config && { config }),
...(workspace && { workspace }),
...(output && { output }),
Expand Down
93 changes: 79 additions & 14 deletions apps/cli/src/splash.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,56 @@
/**
* Splash screen display — pure terminal output, no npm dependencies.
*
* Renders Unicode box-drawing + block art when the terminal advertises
* UTF-8 support, and falls back to a plain-ASCII variant otherwise. The
* fallback exists because raw cmd.exe, some CI log streams, and locale-
* less SSH sessions render the Unicode glyphs as `?` or mojibake.
*/

function supportsUtf8(): boolean {
const lang = process.env.LANG ?? process.env.LC_ALL ?? process.env.LC_CTYPE ?? '';
if (/utf-?8/i.test(lang)) return true;
// Windows Terminal and VS Code's integrated terminal report UTF-8
// capability via env even without a POSIX locale.
if (process.env.WT_SESSION) return true;
if (process.env.TERM_PROGRAM === 'vscode') return true;
return false;
}

export function displaySplash(version?: string): void {
if (supportsUtf8()) {
renderUnicodeSplash(version);
} else {
renderAsciiSplash(version);
}
}

function renderUnicodeSplash(version?: string): void {
const GOLD = '\x1b[38;2;244;197;66m';
const CYAN = '\x1b[36;1m';
const WHITE = '\x1b[1;37m';
const GRAY = '\x1b[0;37m';
const YELLOW = '\x1b[1;33m';
const RESET = '\x1b[0m';

const B = `${CYAN}\u2551${RESET}`;
const B = `${CYAN}${RESET}`;
const S67 = ' '.repeat(67);
const HR = '\u2550'.repeat(67);
const HR = ''.repeat(67);

const lines = [
'',
` ${CYAN}\u2554${HR}\u2557${RESET}`,
` ${CYAN}${HR}${RESET}`,
` ${B}${S67}${B}`,
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557${RESET} ${B}`,
` ${B} ${GOLD}\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551${RESET} ${B}`,
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551${RESET} ${B}`,
` ${B} ${GOLD}\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551${RESET} ${B}`,
` ${B} ${GOLD}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D${RESET} ${B}`,
` ${B} ${GOLD}███████╗██╗ ██╗ █████╗ ███╗ ██╗███╗ ██╗ ██████╗ ███╗ ██╗${RESET} ${B}`,
` ${B} ${GOLD}██╔════╝██║ ██║██╔══██╗████╗ ██║████╗ ██║██╔═══██╗████╗ ██║${RESET} ${B}`,
` ${B} ${GOLD}███████╗███████║███████║██╔██╗ ██║██╔██╗ ██║██║ ██║██╔██╗ ██║${RESET} ${B}`,
` ${B} ${GOLD}╚════██║██╔══██║██╔══██║██║╚██╗██║██║╚██╗██║██║ ██║██║╚██╗██║${RESET} ${B}`,
` ${B} ${GOLD}███████║██║ ██║██║ ██║██║ ╚████║██║ ╚████║╚██████╔╝██║ ╚████║${RESET} ${B}`,
` ${B} ${GOLD}╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═══╝${RESET} ${B}`,
` ${B}${S67}${B}`,
` ${B} ${CYAN}\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557${RESET} ${B}`,
` ${B} ${CYAN}\u2551${RESET} ${WHITE}AI Penetration Testing Framework${RESET} ${CYAN}\u2551${RESET} ${B}`,
` ${B} ${CYAN}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D${RESET} ${B}`,
` ${B} ${CYAN}╔═══════════════════════════════════╗${RESET} ${B}`,
` ${B} ${CYAN}${RESET} ${WHITE}AI Penetration Testing Framework${RESET} ${CYAN}${RESET} ${B}`,
` ${B} ${CYAN}╚════════════════════════════════════╝${RESET} ${B}`,
` ${B}${S67}${B}`,
];

Expand All @@ -40,9 +63,51 @@ export function displaySplash(version?: string): void {

lines.push(
` ${B}${S67}${B}`,
` ${B} ${YELLOW}\uD83D\uDD10 DEFENSIVE SECURITY ONLY \uD83D\uDD10${RESET} ${B}`,
` ${B} ${YELLOW}🔐 DEFENSIVE SECURITY ONLY 🔐${RESET} ${B}`,
` ${B}${S67}${B}`,
` ${CYAN}\u255A${HR}\u255D${RESET}`,
` ${CYAN}╚${HR}╝${RESET}`,
'',
);

console.log(lines.join('\n'));
}

function renderAsciiSplash(version?: string): void {
const CYAN = '\x1b[36;1m';
const GOLD = '\x1b[33;1m';
const WHITE = '\x1b[1;37m';
const GRAY = '\x1b[0;37m';
const YELLOW = '\x1b[1;33m';
const RESET = '\x1b[0m';

const W = 67;
const HR = '-'.repeat(W);
const PAD = ' '.repeat(W);

const center = (text: string): string => {
const padLeft = Math.floor((W - text.length) / 2);
const padRight = W - text.length - padLeft;
return `${' '.repeat(padLeft)}${text}${' '.repeat(padRight)}`;
};

const lines = [
'',
` ${CYAN}+${HR}+${RESET}`,
` ${CYAN}|${RESET}${PAD}${CYAN}|${RESET}`,
` ${CYAN}|${RESET}${GOLD}${center('SHANNON')}${RESET}${CYAN}|${RESET}`,
` ${CYAN}|${RESET}${WHITE}${center('AI Penetration Testing Framework')}${RESET}${CYAN}|${RESET}`,
` ${CYAN}|${RESET}${PAD}${CYAN}|${RESET}`,
];

if (version) {
lines.push(` ${CYAN}|${RESET}${GRAY}${center(`v${version}`)}${RESET}${CYAN}|${RESET}`);
lines.push(` ${CYAN}|${RESET}${PAD}${CYAN}|${RESET}`);
}

lines.push(
` ${CYAN}|${RESET}${YELLOW}${center('[ DEFENSIVE SECURITY ONLY ]')}${RESET}${CYAN}|${RESET}`,
` ${CYAN}|${RESET}${PAD}${CYAN}|${RESET}`,
` ${CYAN}+${HR}+${RESET}`,
'',
);

Expand Down
6 changes: 4 additions & 2 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "catalog:",
Expand All @@ -32,6 +33,7 @@
"zx": "^8.0.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9"
"@types/js-yaml": "^4.0.9",
"vitest": "^3.2.4"
}
}
Loading