mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-15 06:25:32 +02:00
181 lines
8.1 KiB
TypeScript
181 lines
8.1 KiB
TypeScript
// Copyright (C) 2026 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.
|
|
|
|
import { ALL_AGENTS } from '../types/agents.js';
|
|
import { ErrorCode, type PentestErrorType } from '../types/errors.js';
|
|
|
|
export const WORKFLOW_PHASES = ['pre-recon', 'recon', 'vulnerability-exploitation', 'reporting'] as const;
|
|
export type WorkflowPhase = (typeof WORKFLOW_PHASES)[number];
|
|
|
|
export const LOGGABLE_AGENT_NAMES = [...ALL_AGENTS, 'validate-authentication'] as const;
|
|
export type LoggableAgentName = (typeof LOGGABLE_AGENT_NAMES)[number];
|
|
|
|
/** A log-safe error rendering: a known code paired with one of the fixed, generic messages below. */
|
|
export interface SafeErrorDetails {
|
|
readonly code: ErrorCode;
|
|
readonly category: PentestErrorType;
|
|
readonly message: string;
|
|
}
|
|
|
|
const SAFE_ERROR_MESSAGES: Readonly<Record<ErrorCode, string>> = {
|
|
[ErrorCode.CONFIG_NOT_FOUND]: 'The requested configuration could not be loaded.',
|
|
[ErrorCode.CONFIG_VALIDATION_FAILED]: 'The scan configuration is invalid.',
|
|
[ErrorCode.CONFIG_PARSE_ERROR]: 'The scan configuration could not be parsed.',
|
|
[ErrorCode.AGENT_EXECUTION_FAILED]: 'The agent could not complete its work.',
|
|
[ErrorCode.OUTPUT_VALIDATION_FAILED]: 'The agent did not produce valid output.',
|
|
[ErrorCode.GIT_CHECKPOINT_FAILED]: 'The scan checkpoint could not be saved.',
|
|
[ErrorCode.GIT_ROLLBACK_FAILED]: 'The scan workspace could not be restored after a failed attempt.',
|
|
[ErrorCode.PROMPT_LOAD_FAILED]: 'The agent instructions could not be loaded.',
|
|
[ErrorCode.DELIVERABLE_NOT_FOUND]: 'The agent did not produce the required result.',
|
|
[ErrorCode.REPO_NOT_FOUND]: 'The repository could not be opened.',
|
|
[ErrorCode.TARGET_UNREACHABLE]: 'The target could not be reached.',
|
|
[ErrorCode.AUTH_FAILED]: 'Authentication validation failed.',
|
|
[ErrorCode.AUTH_LOGIN_FAILED]: 'The configured login could not be completed.',
|
|
[ErrorCode.MODEL_NOT_FOUND]:
|
|
'The selected model was not found in the harness catalogue. Check SHANNON_AI_MODEL, or supply the model with --models-config.',
|
|
[ErrorCode.MODEL_CONFIG_INVALID]: 'The model configuration file could not be used.',
|
|
};
|
|
|
|
const ERROR_CATEGORIES = new Set<PentestErrorType>([
|
|
'config',
|
|
'network',
|
|
'prompt',
|
|
'filesystem',
|
|
'validation',
|
|
'unknown',
|
|
]);
|
|
|
|
const AGENT_NAME_SET = new Set<string>(LOGGABLE_AGENT_NAMES);
|
|
const WORKFLOW_PHASE_SET = new Set<string>(WORKFLOW_PHASES);
|
|
const ERROR_CODE_SET = new Set<string>(Object.values(ErrorCode));
|
|
|
|
export function isWorkflowPhase(value: string): value is WorkflowPhase {
|
|
return WORKFLOW_PHASE_SET.has(value);
|
|
}
|
|
|
|
export function isLoggableAgentName(value: string): value is LoggableAgentName {
|
|
return AGENT_NAME_SET.has(value);
|
|
}
|
|
|
|
/**
|
|
* A workflow id safe to print in a log header or interpolate into a marker line. Falls back to
|
|
* a fixed placeholder rather than throwing, since an unparseable id must not stop the log from
|
|
* being written at all.
|
|
*/
|
|
export function safeWorkflowIdentifier(value: string): string {
|
|
if (/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value)) {
|
|
return value;
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
export function containsControlCharacter(value: string): boolean {
|
|
// Indexed scan, not a spread or regex: allocation-free over large tool arguments, and a
|
|
// control-character regex literal is disallowed by lint.
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
if (code <= 31 || code === 127) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* True when a token looks like a credential, hash, or key rather than an identifier or word:
|
|
* a long unbroken alphanumeric run, a long digit-bearing token, or a long hex string. Used to
|
|
* fail-closed on secret-shaped search patterns and labels the agent may have just discovered.
|
|
*/
|
|
export function looksSecretShaped(value: string): boolean {
|
|
if (/[A-Za-z0-9]{20,}/u.test(value)) return true;
|
|
const alphanumericLength = value.replace(/[^A-Za-z0-9]/gu, '').length;
|
|
if (/[0-9]/u.test(value) && alphanumericLength >= 12) return true;
|
|
if (/^[0-9a-fA-F]{12,}$/u.test(value)) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The origin of a target URL, safe to print in a log header. Only `http`/`https` are accepted so
|
|
* an exotic scheme (or credentials embedded in the URL) never reaches the log; anything else, or
|
|
* anything unparseable, degrades to a placeholder instead of leaking the raw input.
|
|
*/
|
|
export function safeTargetUrl(value: string): string {
|
|
if (containsControlCharacter(value)) return 'unavailable';
|
|
try {
|
|
const parsedUrl = new URL(value);
|
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
return 'unavailable';
|
|
}
|
|
return parsedUrl.origin;
|
|
} catch {
|
|
return 'unavailable';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map an error code to its fixed, pre-approved log message rather than logging the error's own
|
|
* message text. The underlying error can carry a file path, a stack frame, or other repo-specific
|
|
* detail; only the closed `SAFE_ERROR_MESSAGES` table is allowed into a log line. An unrecognized
|
|
* code or category falls back to a generic entry instead of being dropped, so a fault always gets
|
|
* a log line, just not one repeating unvetted text.
|
|
*/
|
|
export function safeErrorFromCode(code: ErrorCode, category: PentestErrorType = 'unknown'): SafeErrorDetails {
|
|
const safeCode = ERROR_CODE_SET.has(code) ? code : ErrorCode.AGENT_EXECUTION_FAILED;
|
|
return {
|
|
code: safeCode,
|
|
category: ERROR_CATEGORIES.has(category) ? category : 'unknown',
|
|
message: SAFE_ERROR_MESSAGES[safeCode],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Recover a code and category from an error of unknown shape, then defer to
|
|
* {@link safeErrorFromCode} for the actual safe rendering. The duck-typed field reads only ever
|
|
* pick out values that are already in the closed code/category sets, so a caught error's message
|
|
* or other properties can never flow through into the log.
|
|
*/
|
|
export function safeErrorFromUnknown(
|
|
error: unknown,
|
|
fallbackCode: ErrorCode = ErrorCode.AGENT_EXECUTION_FAILED,
|
|
): SafeErrorDetails {
|
|
let code = fallbackCode;
|
|
let category: PentestErrorType = 'unknown';
|
|
if (typeof error === 'object' && error !== null) {
|
|
const candidate = error as { readonly code?: unknown; readonly type?: unknown };
|
|
if (typeof candidate.code === 'string' && ERROR_CODE_SET.has(candidate.code)) {
|
|
code = candidate.code as ErrorCode;
|
|
}
|
|
if (typeof candidate.type === 'string' && ERROR_CATEGORIES.has(candidate.type as PentestErrorType)) {
|
|
category = candidate.type as PentestErrorType;
|
|
}
|
|
}
|
|
return safeErrorFromCode(code, category);
|
|
}
|
|
|
|
/**
|
|
* Reduce a free-text human description (child-task description, active todo label) to a
|
|
* short, safe semantic label, or `undefined` when it is structurally unsafe.
|
|
*
|
|
* Ordinary security vocabulary — `authorization`, `password`, `token` — is allowed; the
|
|
* rejection is structural, not a word blocklist. Fail-closed: anything carrying a URL,
|
|
* path, domain, assignment, colon, secret-shaped token, control character, or excessive
|
|
* length is rejected rather than partially sanitized. A forward slash is treated as a word
|
|
* separator (`XSS/Injection` → `xss injection`), not a path marker — real paths and URLs are
|
|
* still rejected below by their `.`, `\`, `:`, or `@`.
|
|
*/
|
|
export function normalizeSemanticLabel(value: unknown): string | undefined {
|
|
if (typeof value !== 'string' || containsControlCharacter(value)) return undefined;
|
|
const collapsed = value.replace(/[\s/]+/gu, ' ').trim();
|
|
if (collapsed.length === 0 || collapsed.length > 48) return undefined;
|
|
// Paths, domains/filenames, assignments, colons, and addresses are structurally unsafe.
|
|
if (/[.\\=:@]/u.test(collapsed)) return undefined;
|
|
// A long unbroken alphanumeric run is secret/hash/base64-shaped, never a real word.
|
|
if (/[A-Za-z0-9_-]{20,}/u.test(collapsed)) return undefined;
|
|
const words = collapsed.toLowerCase().split(' ');
|
|
if (words.length > 6) return undefined;
|
|
if (!words.every((word) => /^[a-z0-9][a-z0-9'-]{0,19}$/u.test(word))) return undefined;
|
|
if (words.some(looksSecretShaped)) return undefined;
|
|
return words.join(' ');
|
|
}
|