/** * ExplorationOrchestrator — adapts ExplorationEngine to use ICrawlerEngine port. * All dependencies are injected via constructor; no direct plugin imports. */ import { IState, IAction, IAnomaly, ILogger, ICollector, IExporter, IReproducer, IFuzzingPlugin } from '../../../../core/interfaces'; import { ICrawlerEngine } from '../../domain/ports/ICrawlerEngine'; import { CrawlingStateGraph } from './CrawlingStateGraph'; import { AnomalyDetector } from '../../../../core/AnomalyDetector'; import { NullLogger } from '../../../../core/Logger'; import { ExplorationConfig } from '../../../../core/ExplorationConfig'; export interface OrchestratorEventCallbacks { onSessionStarted?: (sessionId: string, url: string) => void; onStateDiscovered?: (sessionId: string, stateId: string, url: string, title: string) => void; onActionExecuted?: (sessionId: string, actionType: string, selector: string | undefined, timestamp: number) => void; onAnomalyDetected?: (sessionId: string, anomaly: IAnomaly) => void; onSessionCompleted?: (sessionId: string, statesVisited: number, anomaliesFound: number) => void; onSessionError?: (sessionId: string, error: string) => void; } export type StateHook = ( state: IState, engine: ICrawlerEngine, sessionId: string, actionTrace: IAction[] ) => Promise; export interface OrchestratorConfig { graph: CrawlingStateGraph; engine: ICrawlerEngine; detector?: AnomalyDetector; collectors?: ICollector[]; exporters?: IExporter[]; reproducer?: IReproducer; logger?: ILogger; seed: number; url: string; maxSteps?: number; outputDir?: string; events?: OrchestratorEventCallbacks; sessionId?: string; explorationConfig?: Partial; fuzzingPlugin?: IFuzzingPlugin; stateHooks?: StateHook[]; } export interface OrchestratorResult { statesVisited: number; anomaliesFound: number; anomalies: IAnomaly[]; } export class ExplorationOrchestrator { private graph: CrawlingStateGraph; private engine: ICrawlerEngine; private detector: AnomalyDetector; private collectors: ICollector[]; private exporters: IExporter[]; private reproducer?: IReproducer; private logger: ILogger; private seed: number; private url: string; private maxSteps: number; private outputDir: string; private events: OrchestratorEventCallbacks; private sessionId: string; private explorationConfig: Partial; private fuzzingPlugin?: IFuzzingPlugin; private stateHooks: StateHook[]; private actionTrace: IAction[] = []; private aborted = false; constructor(config: OrchestratorConfig) { this.graph = config.graph; this.engine = config.engine; this.detector = config.detector ?? new AnomalyDetector(); this.collectors = config.collectors ?? []; this.exporters = config.exporters ?? []; this.reproducer = config.reproducer; this.logger = config.logger ?? new NullLogger(); this.seed = config.seed; this.url = config.url; this.maxSteps = config.maxSteps ?? 100; this.outputDir = config.outputDir ?? './reports'; this.events = config.events ?? {}; this.sessionId = config.sessionId ?? `${Date.now()}_${config.seed}`; this.explorationConfig = config.explorationConfig ?? {}; this.fuzzingPlugin = config.fuzzingPlugin; this.stateHooks = config.stateHooks ?? []; } stop(): void { this.aborted = true; } async run(): Promise { const anomalies: IAnomaly[] = []; let stepsExecuted = 0; let depth = 0; const sessionTimeoutMs = this.explorationConfig.sessionTimeoutMs ?? 0; const maxDepth = this.explorationConfig.maxDepth ?? Infinity; const sessionStart = Date.now(); this.logger.log({ event: 'session_start', timestamp: sessionStart, seed: this.seed, target: this.url, }); this.events.onSessionStarted?.(this.sessionId, this.url); const isTimedOut = (): boolean => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs; try { await this.engine.launch(this.url); const initialState = await this.engine.captureState(); this.graph.addState(initialState); this.logger.log({ event: 'state_discovered', timestamp: Date.now(), stateId: initialState.id, url: initialState.url, title: initialState.title, }); this.events.onStateDiscovered?.(this.sessionId, initialState.id, initialState.url, initialState.title); while (stepsExecuted < this.maxSteps && !this.aborted && !isTimedOut() && depth <= maxDepth) { const currentState = this.graph.getNextToExplore(); if (!currentState) break; this.graph.incrementVisit(currentState.id); const actions = await this.engine.discoverActions(currentState); if (actions.length === 0) continue; const actionIndex = (this.seed + stepsExecuted) % actions.length; const action = actions[actionIndex]!; this.logger.log({ event: 'action_executed', timestamp: Date.now(), actionId: action.id, type: action.type, selector: action.selector, value: action.value, url: action.url, }); this.events.onActionExecuted?.(this.sessionId, action.type, action.selector, Date.now()); const observation = await this.engine.executeAction(action); this.actionTrace.push(action); if (!this.graph.hasState(observation.newStateId)) { const newState = await this.engine.captureState(); this.graph.addState(newState); depth += 1; this.logger.log({ event: 'state_discovered', timestamp: Date.now(), stateId: newState.id, url: newState.url, title: newState.title, }); this.events.onStateDiscovered?.(this.sessionId, newState.id, newState.url, newState.title); for (const hook of this.stateHooks) { const hookAnomalies = await hook(newState, this.engine, this.sessionId, [...this.actionTrace]).catch(() => []); for (const anomaly of hookAnomalies) { anomalies.push(anomaly); this.logger.log({ event: 'anomaly_detected', timestamp: Date.now(), anomalyId: anomaly.id, type: anomaly.type, severity: anomaly.severity, }); this.events.onAnomalyDetected?.(this.sessionId, anomaly); for (const exporter of this.exporters) { await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`); } } } } this.graph.recordTransition(currentState.id, action, observation.newStateId); this.logger.log({ event: 'exploration_step', timestamp: Date.now(), stateId: currentState.id, actionId: action.id, }); const detected = this.detector.detect(observation, [...this.actionTrace]); for (const anomaly of detected) { for (const collector of this.collectors) { const evidence = await collector.collect(anomaly, this.engine as unknown as import('../../../../core/interfaces').IInteractionAgent); Object.assign(anomaly.evidence, evidence); } anomalies.push(anomaly); this.logger.log({ event: 'anomaly_detected', timestamp: Date.now(), anomalyId: anomaly.id, type: anomaly.type, severity: anomaly.severity, }); this.events.onAnomalyDetected?.(this.sessionId, anomaly); for (const exporter of this.exporters) { const reportDir = `${this.outputDir}/${anomaly.id}`; await exporter.export(anomaly, reportDir); } } stepsExecuted += 1; if ( this.fuzzingPlugin && this.explorationConfig.fuzzingEnabled !== false && currentState.domSnapshot ) { const fuzzActions = this.fuzzingPlugin.generateFuzzActions( currentState.domSnapshot, currentState ); for (const fuzzAction of fuzzActions) { if (this.aborted || isTimedOut()) break; const fuzzObs = await this.engine.executeAction(fuzzAction); this.actionTrace.push(fuzzAction); const fuzzAnomalies = this.detector.detect(fuzzObs, [...this.actionTrace]); for (const anomaly of fuzzAnomalies) { for (const collector of this.collectors) { const evidence = await collector.collect(anomaly, this.engine as unknown as import('../../../../core/interfaces').IInteractionAgent); Object.assign(anomaly.evidence, evidence); } anomalies.push(anomaly); this.events.onAnomalyDetected?.(this.sessionId, anomaly); for (const exporter of this.exporters) { await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`); } } } } } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); this.events.onSessionError?.(this.sessionId, msg); await this.engine.close().catch(() => undefined); throw err; } await this.engine.close(); const statesVisited = this.graph.getAllStates().filter((s) => s.visitCount > 0).length; this.logger.log({ event: 'session_end', timestamp: Date.now(), statesVisited, anomaliesFound: anomalies.length, }); this.events.onSessionCompleted?.(this.sessionId, statesVisited, anomalies.length); return { statesVisited, anomaliesFound: anomalies.length, anomalies }; } }