mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-27 13:42:33 +02:00
* refactor(cli): list workspaces natively instead of via the worker image * feat(cli): preflight that Docker is installed and running * feat(cli): stop scans by workspace or --all, terminating their Temporal workflows * fix(worker): abort the running agent on cancellation so Temporal cancel takes effect * refactor(cli): split destructive teardown out of stop into a reset command * refactor(cli): centralise flag parsing and confirmation across commands * fix(cli): pass provider credentials to docker by name to keep secrets out of argv * feat(cli): add per-command help via <command> --help/-h and help <command> * feat(cli): replace raw docker output with clack spinners for infra and scan teardown * fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success * fix(cli): resolve running state before prompting on stop and report no-op stops honestly * refactor(cli): show splash first and drive start with one spinner resolving to a clean line * fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash * refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker * feat(cli): add --json/--plain machine-readable output to workspaces and status * refactor(cli): remove the workspaces command * refactor(cli): remove the status command * feat(cli): add 'progress <workspace>' — live scan progress from Temporal * fix(cli): mark metric-less agents as skipped in progress, not done * feat(cli): animate running agents in progress with a clack-style spinner * feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed * fix(cli): mark passed-over phases as skipped live, not pending * style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical * style(cli): drop '(sum of agents)' from status total cost line * style(cli): green filled circle for completed, Shannon gold for running * style(cli): use Shannon gold in place of green in status * feat(cli): suggest closest command or flag on typo * refactor(cli): single-source start help and drop ./repos bare-name shortcut * feat(cli): name providers and fix in multi-provider credential error * feat(cli): support --flag=value syntax and expand leading ~ in paths * refactor(cli): centralize ANSI color codes in colors.ts * feat(cli): add scans command listing completed scans with cost and duration * fix(cli): keep stdout clean off-TTY for logs and start * feat(cli): add repo link to top-level help * feat(worker): record auth-validation metrics and register resume attempts early * refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures * feat(cli): add status --json, auth phase, dashboard link, and stable live redraw * refactor(cli): drop cost from status and scans output * feat(worker): surface both PDF and markdown report at run root * refactor(cli): normalize error/warning prefixing through fail and warn * feat(cli): add version --json for machine-readable output * refactor(cli): rename start --debug to --keep-container * refactor(cli): point start's progress hint at status instead of the Temporal dashboard * refactor(cli): centralize the mode-aware command prefix * refactor(cli): trim start and logs output to durable facts off-TTY * feat(cli): require typed confirmation for reset instead of --yes reset permanently wipes all Temporal data and volumes — a severe, irreversible action. Replace its default y/N confirm (bypassable with --yes) with a typed-word confirmation that has no bypass, so the wipe can only be triggered by a deliberate interactive answer. * feat(cli): surface logs and status hints after start on a TTY * feat(cli): exit 2 on usage errors, distinct from operational failures * feat(cli): add start --follow to stream logs and exit on scan outcome * refactor(cli): redesign splash with sunset-gradient wordmark and truecolor * refactor(cli): remove the uninstall command * docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow * docs: fix reset confirmation — typed confirm, not --yes/-y * style(cli): restructure status footer with divider, aligned Logs/Temporal rows * feat(cli): show splash in the status command * fix(worker): validate auth-state shape, not entry count * docs: correct reset confirmation and add markdown report to run-root docs
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
/**
|
|
* `shannon start` command — launch a pentest scan.
|
|
*
|
|
* Handles both local mode (local build, ./workspaces/, mounted prompts)
|
|
* and npx mode (Docker Hub pull, ~/.shannon/).
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
import * as p from '@clack/prompts';
|
|
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
|
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
|
|
import { fail } from '../errors.js';
|
|
import { getWorkspacesDir, initHome } from '../home.js';
|
|
import { commandPrefix, isLocal } from '../mode.js';
|
|
import { resolveModelSpec } from '../model-spec.js';
|
|
import {
|
|
expandHome,
|
|
FINAL_REPORT_PDF_FILENAME,
|
|
INTERNAL_DIR,
|
|
resolveConfig,
|
|
resolveRepo,
|
|
resolveRunFile,
|
|
} from '../paths.js';
|
|
import { resolveWorkflowId } from '../session.js';
|
|
import { displaySplash } from '../splash.js';
|
|
import { getTerminalOutcome } from '../temporal-client.js';
|
|
import { stdoutIsTerminal } from '../tty.js';
|
|
import { tailUntilComplete } from './logs.js';
|
|
|
|
export interface StartArgs {
|
|
url: string;
|
|
repo: string;
|
|
config?: string;
|
|
workspace?: string;
|
|
output?: string;
|
|
pipelineTesting: boolean;
|
|
keepContainer: boolean;
|
|
follow: boolean;
|
|
version: string;
|
|
}
|
|
|
|
/**
|
|
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
|
|
* so resume finds the old deliverables and their git checkpoints instead of re-running every
|
|
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
|
|
* (a same-filesystem rename carries the deliverables .git along).
|
|
*/
|
|
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
|
|
const legacySessionJson = path.join(workspacePath, 'session.json');
|
|
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
|
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
|
|
return;
|
|
}
|
|
|
|
fs.mkdirSync(internalPath, { recursive: true });
|
|
for (const entry of fs.readdirSync(workspacePath)) {
|
|
if (entry === INTERNAL_DIR) {
|
|
continue;
|
|
}
|
|
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
|
}
|
|
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
|
}
|
|
|
|
export async function start(args: StartArgs): Promise<void> {
|
|
// 1. Initialize state directories and load env
|
|
initHome();
|
|
loadEnv();
|
|
|
|
// 2. Validate credentials
|
|
const creds = validateCredentials();
|
|
if (!creds.valid) {
|
|
fail(creds.error ?? 'Invalid credentials');
|
|
}
|
|
|
|
// 3. Resolve paths
|
|
const repo = resolveRepo(args.repo);
|
|
const config = args.config ? resolveConfig(args.config) : undefined;
|
|
|
|
// Inputs are valid — show the splash before the Docker/Temporal setup work.
|
|
displaySplash(isLocal() ? undefined : args.version);
|
|
|
|
// 4. Ensure workspaces dir is writable by container user (UID 1001)
|
|
const workspacesDir = getWorkspacesDir();
|
|
fs.mkdirSync(workspacesDir, { recursive: true });
|
|
fs.chmodSync(workspacesDir, 0o777);
|
|
|
|
// 5. Ensure Docker and the worker image are available (pull/build prints its own progress).
|
|
ensureDocker();
|
|
ensureImage(args.version);
|
|
|
|
// One spinner spans the whole launch: bringing up Temporal and registering the worker.
|
|
const spinner = p.spinner();
|
|
spinner.start('Starting scan');
|
|
await ensureInfra(spinner);
|
|
|
|
// 6. Generate unique task queue and container name
|
|
const suffix = randomSuffix();
|
|
const taskQueue = `shannon-${suffix}`;
|
|
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()}`;
|
|
|
|
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
|
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
|
|
// subdirs and the overlay backing dirs.
|
|
const workspacePath = path.join(workspacesDir, workspace);
|
|
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
|
fs.mkdirSync(workspacePath, { recursive: true });
|
|
fs.chmodSync(workspacePath, 0o777);
|
|
migrateLegacyWorkspaceLayout(workspacePath);
|
|
fs.mkdirSync(internalPath, { recursive: true });
|
|
fs.chmodSync(internalPath, 0o777);
|
|
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
|
const dirPath = path.join(internalPath, dir);
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
fs.chmodSync(dirPath, 0o777);
|
|
}
|
|
|
|
// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
|
|
const shannonDir = path.join(repo.hostPath, '.shannon');
|
|
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
|
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
|
}
|
|
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
|
|
|
|
// 10. Resolve output directory
|
|
const outputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
|
if (outputDir) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
// 11. Resolve prompts directory (local mode only)
|
|
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
|
|
|
// 12. Spawn worker container
|
|
const proc = spawnWorker({
|
|
version: args.version,
|
|
url: args.url,
|
|
repo,
|
|
workspacesDir,
|
|
taskQueue,
|
|
containerName,
|
|
envFlags: buildEnvFlags(),
|
|
...(config && { config }),
|
|
...(promptsDir && { promptsDir }),
|
|
...(outputDir && { outputDir }),
|
|
workspace,
|
|
...(args.pipelineTesting && { pipelineTesting: true }),
|
|
...(args.keepContainer && { keepContainer: true }),
|
|
...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }),
|
|
});
|
|
|
|
// Bail if `docker run -d` itself fails (mount error, image missing, etc.)
|
|
const dockerExitCode = await new Promise<number>((resolve) => {
|
|
proc.once('exit', (code) => resolve(code ?? 1));
|
|
proc.once('error', () => resolve(1));
|
|
});
|
|
|
|
if (dockerExitCode !== 0) {
|
|
spinner.error('Could not start the scan');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
|
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
|
|
const isResume = fs.existsSync(sessionJson);
|
|
let initialResumeCount = 0;
|
|
if (isResume) {
|
|
try {
|
|
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
|
initialResumeCount = session.session?.resumeAttempts?.length ?? 0;
|
|
} catch {
|
|
// Corrupted file — worker will handle validation
|
|
}
|
|
}
|
|
|
|
let started = false;
|
|
|
|
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
|
|
let cleaned = false;
|
|
const cleanup = (): void => {
|
|
if (cleaned || started) return;
|
|
cleaned = true;
|
|
spinner.stop('Stopping scan');
|
|
try {
|
|
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
|
|
} catch {
|
|
// Container may have already exited
|
|
}
|
|
if (args.keepContainer) {
|
|
printPreservedContainerHint(containerName);
|
|
}
|
|
};
|
|
process.on('SIGINT', () => {
|
|
cleanup();
|
|
process.exit(0);
|
|
});
|
|
process.on('SIGTERM', () => {
|
|
cleanup();
|
|
process.exit(0);
|
|
});
|
|
process.on('exit', cleanup);
|
|
|
|
// Poll for the workflow to register in session.json; the spinner resolves once it does.
|
|
spinner.message('Waiting for the scan to start');
|
|
for (let attempts = 0; attempts < 60; attempts++) {
|
|
try {
|
|
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
|
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
|
|
|
|
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
|
|
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
|
|
|
|
if (ready) {
|
|
started = true;
|
|
spinner.stop(`Scan started — ${workspace}`);
|
|
printInfo(args, workspace, repo.hostPath, workspacesDir);
|
|
if (args.follow) {
|
|
await followScan(workspace, workspacesDir);
|
|
}
|
|
return;
|
|
}
|
|
} catch {
|
|
// File doesn't exist yet
|
|
}
|
|
await sleep(2000);
|
|
}
|
|
|
|
spinner.error('Timed out waiting for the scan to start');
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log to completion, then
|
|
* exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed. That tracks
|
|
* whether the pipeline ran, not whether vulnerabilities were found.
|
|
*/
|
|
async function followScan(workspace: string, workspacesDir: string): Promise<never> {
|
|
const logFile = resolveRunFile(path.join(workspacesDir, workspace), 'workflow.log');
|
|
|
|
// The worker creates workflow.log as it starts; wait briefly so the first read doesn't
|
|
// mistake a not-yet-created file for an already-finished scan.
|
|
for (let attempts = 0; attempts < 30 && !fs.existsSync(logFile); attempts++) {
|
|
await sleep(1000);
|
|
}
|
|
|
|
if (stdoutIsTerminal()) {
|
|
console.error('\n Following scan log (Ctrl-C to stop watching):\n');
|
|
}
|
|
await tailUntilComplete(logFile);
|
|
|
|
const workflowId = resolveWorkflowId(workspace);
|
|
if (!workflowId) {
|
|
fail('Scan finished but its workflow id could not be resolved from session.json.');
|
|
}
|
|
|
|
try {
|
|
const outcome = await getTerminalOutcome(workflowId);
|
|
process.exit(outcome.kind === 'success' ? 0 : 1);
|
|
} catch {
|
|
fail('Could not reach Temporal at 127.0.0.1:7233 to read the scan outcome.');
|
|
}
|
|
}
|
|
|
|
function printPreservedContainerHint(containerName: string): void {
|
|
console.log('');
|
|
console.log(` Worker container preserved: ${containerName}`);
|
|
console.log(` Inspect logs: docker logs ${containerName}`);
|
|
console.log(` Remove: docker rm ${containerName}`);
|
|
console.log('');
|
|
}
|
|
|
|
function printInfo(args: StartArgs, workspace: string, repoPath: string, workspacesDir: string): void {
|
|
const interactive = stdoutIsTerminal();
|
|
|
|
if (interactive && !args.follow) {
|
|
console.log(' It runs in the background — you can close this terminal.');
|
|
console.log('');
|
|
}
|
|
|
|
console.log(` Target: ${args.url}`);
|
|
console.log(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`);
|
|
console.log(` Workspace: ${workspace}`);
|
|
if (args.config) {
|
|
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
|
|
}
|
|
if (args.pipelineTesting) {
|
|
console.log(' Mode: Pipeline Testing');
|
|
}
|
|
|
|
const spec = resolveModelSpec();
|
|
if (typeof spec !== 'string') {
|
|
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
|
|
}
|
|
|
|
if (!interactive) {
|
|
return;
|
|
}
|
|
|
|
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
|
|
|
// When following, the scan log streams inline next, so the "run these to watch it" hints
|
|
// would only contradict that.
|
|
if (!args.follow) {
|
|
const prefix = commandPrefix();
|
|
console.log('');
|
|
console.log(' Watch scan progress:');
|
|
console.log(` Live logs: ${prefix} logs ${workspace}`);
|
|
console.log(` Progress: ${prefix} status ${workspace}`);
|
|
}
|
|
|
|
console.log('');
|
|
console.log(' Report (when the scan finishes):');
|
|
console.log(` ${reportPath}`);
|
|
console.log('');
|
|
}
|