mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-20 08:52:27 +02:00
merge: integrate Shannon 3.0 with public v2.6.0
- preserve the versioned and non-TTY banners from public main - keep workspace launch classification ahead of shared infrastructure setup - carry the eleven-commit Agentic SAST feature history unchanged - normalize Capella prompt endings to the accepted candidate tree
This commit is contained in:
+249
-60
@@ -18,6 +18,7 @@ import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import {
|
||||
expandHome,
|
||||
FINAL_REPORT_MD_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
INTERNAL_DIR,
|
||||
resolveConfig,
|
||||
@@ -43,83 +44,219 @@ export interface StartArgs {
|
||||
version: string;
|
||||
}
|
||||
|
||||
const LAUNCH_STATE_SCHEMA_VERSION = 1 as const;
|
||||
const LAUNCH_STATE_FILENAME = 'launch.json';
|
||||
const FIXED_CLASSES = ['injection', 'xss', 'auth', 'authz', 'ssrf'] as const;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* CLI-owned launch record at INTERNAL_DIR/launch.json, written once when a workspace is
|
||||
* created and never rewritten. It pins the customer output destination so a resume with a
|
||||
* different -o cannot silently redirect the final report. The worker does not read it.
|
||||
*/
|
||||
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;
|
||||
interface LaunchState {
|
||||
readonly schema_version: typeof LAUNCH_STATE_SCHEMA_VERSION;
|
||||
readonly customer_output_path?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceLaunchDecision {
|
||||
readonly isResume: boolean;
|
||||
readonly outputDir?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled twin of the worker's durable-state validator in
|
||||
* apps/worker/src/types/run-state.ts, which owns the session.json.durableScanState shape.
|
||||
* Each array check accepts two variants because the worker appends 'miscellaneous' and
|
||||
* 'miscellaneous-exploit' only after the miscellaneous pipeline admits findings. If the worker's shape
|
||||
* changes and this twin lags, resume fails fast as incompatible instead of launching a
|
||||
* worker against state it would misread.
|
||||
*/
|
||||
function isCurrentDurableState(value: unknown): boolean {
|
||||
if (!isRecord(value) || value.schema_version !== 1 || typeof value.exploit !== 'boolean') return false;
|
||||
if (!Array.isArray(value.participating_classes) || !Array.isArray(value.expected_agents)) return false;
|
||||
|
||||
const participating = value.participating_classes;
|
||||
const validParticipation =
|
||||
arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'miscellaneous']);
|
||||
if (!validParticipation) return false;
|
||||
|
||||
const baselineAgents = ['pre-recon', 'recon', ...FIXED_CLASSES.map((name) => `${name}-vuln`)];
|
||||
if (value.exploit) baselineAgents.push(...FIXED_CLASSES.map((name) => `${name}-exploit`));
|
||||
baselineAgents.push('report');
|
||||
const expected = value.expected_agents;
|
||||
return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']);
|
||||
}
|
||||
|
||||
/** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */
|
||||
const DAMAGED_RECORDS_MESSAGE =
|
||||
"This workspace's internal records are damaged and it cannot be resumed. Its report files are untouched. Start a new scan with a different -w name.";
|
||||
|
||||
const NEWER_RELEASE_MESSAGE =
|
||||
'This workspace was created by a newer version of Shannon. Upgrade Shannon, or start a new scan with a different -w name.';
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
fail(DAMAGED_RECORDS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function readLaunchState(filePath: string): LaunchState {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fail(
|
||||
'This workspace was created by an earlier version of Shannon and cannot be resumed. Its files and report are untouched. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
const value = readJsonFile(filePath);
|
||||
if (!isRecord(value)) fail(NEWER_RELEASE_MESSAGE);
|
||||
// Unknown keys mean a newer release wrote this workspace; refuse rather than half-read it.
|
||||
const keys = Object.keys(value).sort();
|
||||
const keysAreValid = keys.every((key) => key === 'customer_output_path' || key === 'schema_version');
|
||||
const customerPath = value.customer_output_path;
|
||||
const pathIsValid =
|
||||
customerPath === undefined ||
|
||||
(typeof customerPath === 'string' && path.isAbsolute(customerPath) && path.resolve(customerPath) === customerPath);
|
||||
if (value.schema_version !== LAUNCH_STATE_SCHEMA_VERSION || !keysAreValid || !pathIsValid) {
|
||||
fail(NEWER_RELEASE_MESSAGE);
|
||||
}
|
||||
return {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(typeof customerPath === 'string' && { customer_output_path: customerPath }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide fresh-versus-resume from on-disk state alone, before start() mutates anything.
|
||||
* A fresh launch requires the workspace directory to be absent or empty; a resume requires
|
||||
* current-release session state, a matching target URL, and a customer output path that
|
||||
* agrees with the recorded one. Every other combination fails the launch, so a typo in
|
||||
* -w or -o stops here instead of spawning a worker into the wrong workspace.
|
||||
*/
|
||||
export function classifyWorkspaceLaunch(
|
||||
workspacePath: string,
|
||||
expectedUrl: string,
|
||||
requestedOutputDir: string | undefined,
|
||||
): WorkspaceLaunchDecision {
|
||||
const sessionPath = resolveRunFile(workspacePath, 'session.json');
|
||||
const sessionExists = fs.existsSync(sessionPath);
|
||||
if (!sessionExists) {
|
||||
if (fs.existsSync(workspacePath) && fs.readdirSync(workspacePath).length > 0) {
|
||||
fail(
|
||||
'This directory is not a Shannon workspace, or its scan state is missing. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: false, ...(requestedOutputDir !== undefined && { outputDir: requestedOutputDir }) };
|
||||
}
|
||||
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
for (const entry of fs.readdirSync(workspacePath)) {
|
||||
if (entry === INTERNAL_DIR) {
|
||||
continue;
|
||||
const launchPath = path.join(workspacePath, INTERNAL_DIR, LAUNCH_STATE_FILENAME);
|
||||
const launch = readLaunchState(launchPath);
|
||||
const session = readJsonFile(sessionPath);
|
||||
if (!isRecord(session) || !isRecord(session.session) || session.session.webUrl !== expectedUrl) {
|
||||
fail(
|
||||
'This workspace was created for a different target URL, so it cannot be resumed against this one. Check -u, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
if (!isCurrentDurableState(session.durableScanState)) {
|
||||
fail(
|
||||
"This workspace's scan state cannot be read by this version. Its files are untouched. Start a new scan with a different -w name.",
|
||||
);
|
||||
}
|
||||
|
||||
const storedOutputDir = launch.customer_output_path;
|
||||
if (requestedOutputDir !== undefined && requestedOutputDir !== storedOutputDir) {
|
||||
fail(
|
||||
'This workspace already copies its report to a different location than the -o path you passed. Re-run without -o to keep the original location, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: true, ...(storedOutputDir !== undefined && { outputDir: storedOutputDir }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-safe single write: exclusive temp file (pid plus random suffix keeps concurrent
|
||||
* starts apart), fsync, rename into place, then directory fsync so the entry survives a
|
||||
* host crash. Callers invoke this only for a fresh workspace; an existing launch.json is
|
||||
* the resume contract and must never be replaced.
|
||||
*/
|
||||
export function writeLaunchStateAtomically(internalPath: string, outputDir: string | undefined): void {
|
||||
const finalPath = path.join(internalPath, LAUNCH_STATE_FILENAME);
|
||||
const temporaryPath = path.join(internalPath, `${LAUNCH_STATE_FILENAME}.tmp-${process.pid}-${randomSuffix()}`);
|
||||
const launchState: LaunchState = {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(outputDir !== undefined && { customer_output_path: outputDir }),
|
||||
};
|
||||
const descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(launchState, null, 2)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(temporaryPath, finalPath);
|
||||
const directory = fs.openSync(internalPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(directory);
|
||||
} finally {
|
||||
fs.closeSync(directory);
|
||||
}
|
||||
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
||||
} catch (error) {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
// 1. Resolve non-mutating inputs and classify the workspace before changing it.
|
||||
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;
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const requestedOutputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
const launchDecision = classifyWorkspaceLaunch(workspacePath, args.url, requestedOutputDir);
|
||||
|
||||
// Inputs are valid — identify the run before the Docker/Temporal setup work.
|
||||
// 2. Inputs are valid; identify the run before initializing shared infrastructure.
|
||||
const bannerVersion = isLocal() ? undefined : args.version;
|
||||
if (stdoutIsTerminal()) {
|
||||
displaySplash(bannerVersion);
|
||||
} else {
|
||||
displayPlainBanner(bannerVersion);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 3. Generate the invocation identity.
|
||||
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)
|
||||
// 4. Create writable overlay directories after resume validation has succeeded.
|
||||
// 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']) {
|
||||
@@ -127,24 +264,37 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
if (!launchDecision.isResume) {
|
||||
writeLaunchStateAtomically(internalPath, launchDecision.outputDir);
|
||||
}
|
||||
|
||||
// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
|
||||
// 5. Pre-create overlay mount points (:ro mounts cannot 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;
|
||||
// 6. Create the validated customer-copy destination, if configured.
|
||||
const outputDir = launchDecision.outputDir;
|
||||
if (outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 11. Resolve prompts directory (local mode only)
|
||||
// 7. Resolve prompts and capture the pre-launch resume counter.
|
||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||
const sessionJson = resolveRunFile(workspacePath, 'session.json');
|
||||
const isResume = launchDecision.isResume;
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
// Docker and Temporal startup sit between this read and the classification that validated the
|
||||
// same file, so a file that changed in between is a workspace-state failure, not a CLI bug.
|
||||
const session = readJsonFile(sessionJson);
|
||||
const attempts = isRecord(session) && isRecord(session.session) ? session.session.resumeAttempts : undefined;
|
||||
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
|
||||
}
|
||||
|
||||
// 12. Spawn worker container
|
||||
// 8. Spawn the worker container.
|
||||
const proc = spawnWorker({
|
||||
version: args.version,
|
||||
url: args.url,
|
||||
@@ -173,24 +323,16 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
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;
|
||||
|
||||
// Set when the startup poll times out but session.json already holds durable state this
|
||||
// release understands: the workflow is executing, so the exit handler must not stop its
|
||||
// worker. An operator abort is a different intent and still stops it.
|
||||
let scanRunningUnconfirmed = false;
|
||||
|
||||
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
const stopWorker = (): void => {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
spinner.stop('Stopping scan');
|
||||
@@ -204,14 +346,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
process.on('exit', () => {
|
||||
if (scanRunningUnconfirmed) return;
|
||||
stopWorker();
|
||||
});
|
||||
|
||||
// Poll for the workflow to register in session.json; the spinner resolves once it does.
|
||||
spinner.message('Waiting for the scan to start');
|
||||
@@ -238,10 +383,52 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
if (classifyStartupTimeout(sessionJson) === 'scan-running') {
|
||||
scanRunningUnconfirmed = true;
|
||||
spinner.error('The scan started, but this CLI could not confirm it');
|
||||
printUnconfirmedScanHint(workspace, taskQueue, containerName);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.error('Timed out waiting for the scan to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the startup timeout: 'scan-running' when session.json already holds durable state this
|
||||
* release understands, which only the worker writes and only after Temporal began executing the
|
||||
* workflow; 'unregistered' when nothing proves the scan started. The distinction decides whether
|
||||
* timing out may stop the worker container.
|
||||
*/
|
||||
export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered' | 'scan-running' {
|
||||
let session: unknown;
|
||||
try {
|
||||
session = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf-8'));
|
||||
} catch {
|
||||
return 'unregistered';
|
||||
}
|
||||
if (!isRecord(session) || !isCurrentDurableState(session.durableScanState)) {
|
||||
return 'unregistered';
|
||||
}
|
||||
return 'scan-running';
|
||||
}
|
||||
|
||||
/** Point the operator at a scan that is running but whose startup this CLI could not confirm. */
|
||||
function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void {
|
||||
console.log('');
|
||||
console.log(' The scan is running and was left alone; only its startup confirmation is missing.');
|
||||
console.log('');
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(` Task queue: ${taskQueue}`);
|
||||
console.log(` Container: ${containerName}`);
|
||||
console.log('');
|
||||
console.log(' Inspect it:');
|
||||
console.log(` Live logs: ${commandPrefix()} logs ${workspace}`);
|
||||
console.log(` Worker logs: docker logs ${containerName}`);
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log while Temporal drives
|
||||
* completion, then exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed.
|
||||
@@ -331,7 +518,7 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
return;
|
||||
}
|
||||
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
const reportDir = path.join(workspacesDir, workspace);
|
||||
|
||||
// When following, the scan log streams inline next, so the "run these to watch it" hints
|
||||
// would only contradict that.
|
||||
@@ -345,6 +532,8 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
|
||||
console.log('');
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(` ${reportPath}`);
|
||||
console.log(` ${reportDir}${path.sep}`);
|
||||
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user