274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
/**
|
|
* 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<IAnomaly[]>;
|
|
|
|
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<ExplorationConfig>;
|
|
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<ExplorationConfig>;
|
|
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<OrchestratorResult> {
|
|
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 };
|
|
}
|
|
}
|