docs: enterprise refactor plan with ralph specs

This commit is contained in:
debian
2026-03-04 16:17:03 -05:00
parent 4c92712d20
commit f8191133c8
204 changed files with 32722 additions and 422 deletions

137
dist/core/AnomalyDetector.js vendored Normal file
View File

@@ -0,0 +1,137 @@
"use strict";
/**
* AnomalyDetector — heuristic rules to detect anomalies from observations.
* Each rule is independent and testable in isolation.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyDetector = void 0;
let anomalyCounter = 0;
function makeId() {
anomalyCounter += 1;
return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`;
}
class AnomalyDetector {
detect(observation, actionTrace) {
const anomalies = [];
const httpAnomaly = this.checkHttpErrors(observation, actionTrace);
if (httpAnomaly)
anomalies.push(httpAnomaly);
const jsAnomaly = this.checkJsExceptions(observation, actionTrace);
if (jsAnomaly)
anomalies.push(jsAnomaly);
const consoleAnomaly = this.checkConsoleErrors(observation, actionTrace);
if (consoleAnomaly)
anomalies.push(consoleAnomaly);
return anomalies;
}
/** Rule: HTTP 4xx or 5xx responses */
checkHttpErrors(observation, actionTrace) {
const errorResponses = observation.httpResponses.filter((r) => r.status >= 400);
if (errorResponses.length === 0)
return null;
const hasServerError = errorResponses.some((r) => r.status >= 500);
const severity = hasServerError ? 'high' : 'medium';
const statusCodes = errorResponses.map((r) => r.status).join(', ');
return this.buildAnomaly({
type: 'http_error',
severity,
observationId: observation.id,
actionTrace,
description: `HTTP error responses detected: ${statusCodes}`,
evidence: {
httpLog: errorResponses,
rawErrors: errorResponses.map((r) => `${r.method} ${r.url}${r.status} (${r.durationMs}ms)`),
},
});
}
/** Rule: uncaught JS exceptions */
checkJsExceptions(observation, actionTrace) {
if (observation.jsExceptions.length === 0)
return null;
return this.buildAnomaly({
type: 'js_exception',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Uncaught JS exception: ${observation.jsExceptions[0]}`,
evidence: {
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: console.error messages */
checkConsoleErrors(observation, actionTrace) {
if (observation.consoleErrors.length === 0)
return null;
return this.buildAnomaly({
type: 'console_error',
severity: 'low',
observationId: observation.id,
actionTrace,
description: `Console error detected: ${observation.consoleErrors[0]}`,
evidence: {
rawErrors: observation.consoleErrors,
},
});
}
/**
* Rule: server accepted clearly invalid/empty fuzz input (got 2xx).
* fuzzedValue is the value that was submitted; responseStatus is the HTTP response.
*/
checkValidationBypass(observation, actionTrace, fuzzedValue) {
const has2xx = observation.httpResponses.some((r) => r.status >= 200 && r.status < 300);
if (!has2xx)
return null;
return this.buildAnomaly({
type: 'validation_bypass',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Server accepted invalid input without error (value: ${JSON.stringify(fuzzedValue).substring(0, 50)})`,
evidence: { httpLog: observation.httpResponses, rawErrors: [`Fuzzed with: ${fuzzedValue}`] },
});
}
/** Rule: server returned 500 on a fuzzed input */
checkServerErrorOnFuzz(observation, actionTrace) {
const has5xx = observation.httpResponses.some((r) => r.status >= 500);
if (!has5xx)
return null;
return this.buildAnomaly({
type: 'server_error_on_fuzz',
severity: 'high',
observationId: observation.id,
actionTrace,
description: 'Server returned 5xx on fuzzed input',
evidence: {
httpLog: observation.httpResponses.filter((r) => r.status >= 500),
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: fuzzed script tag appears in response body (XSS reflection) */
checkXssReflection(observation, actionTrace, domSnapshot) {
if (!domSnapshot.includes('<script>alert(1)</script>'))
return null;
return this.buildAnomaly({
type: 'xss_reflection',
severity: 'critical',
observationId: observation.id,
actionTrace,
description: 'XSS reflection detected: fuzzed script tag appeared in DOM',
evidence: { rawErrors: ['XSS payload reflected in DOM'] },
});
}
buildAnomaly(params) {
return {
id: makeId(),
type: params.type,
severity: params.severity,
observationId: params.observationId,
actionTrace: params.actionTrace,
description: params.description,
evidence: params.evidence,
timestamp: Date.now(),
};
}
}
exports.AnomalyDetector = AnomalyDetector;

53
dist/core/ExplorationConfig.js vendored Normal file
View File

@@ -0,0 +1,53 @@
"use strict";
/**
* ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y,
* performance, visual regression, and network chaos settings for a session.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_EXPLORATION_CONFIG = exports.NETWORK_PROFILES = void 0;
exports.NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null,
};
exports.DEFAULT_EXPLORATION_CONFIG = {
allowedDomains: [],
maxStates: 50,
maxDepth: 5,
actionDelayMs: 500,
sessionTimeoutMs: 300000,
excludedPaths: [],
excludedSelectors: [],
auth: null,
fuzzingEnabled: true,
fuzzingIntensity: 'medium',
browsers: ['chromium'],
mobileDevice: 'none',
viewport: null,
accessibility: {
enabled: true,
minImpact: 'serious',
wcagLevel: 'AA',
},
performance: {
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
},
visualRegression: {
enabled: false,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
},
networkChaos: {
enabled: false,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [],
},
};

197
dist/core/ExplorationEngine.js vendored Normal file
View File

@@ -0,0 +1,197 @@
"use strict";
/**
* ExplorationEngine — the core loop of ABE.
* Selects states, executes actions, records observations, and detects anomalies.
* Depends only on core interfaces — never imports concrete plugins.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExplorationEngine = void 0;
const AnomalyDetector_1 = require("./AnomalyDetector");
const Logger_1 = require("./Logger");
class ExplorationEngine {
constructor(config) {
/** Accumulated action trace for the current session */
this.actionTrace = [];
/** Set to true to abort the running loop */
this.aborted = false;
this.graph = config.graph;
this.agent = config.agent;
this.detector = config.detector ?? new AnomalyDetector_1.AnomalyDetector();
this.collectors = config.collectors ?? [];
this.exporters = config.exporters ?? [];
this.reproducer = config.reproducer;
this.logger = config.logger ?? new Logger_1.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 ?? [];
}
/** Signals the engine to stop after the current step completes. */
stop() {
this.aborted = true;
}
async run() {
const anomalies = [];
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 = () => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs;
try {
await this.agent.launch(this.url);
// Capture initial state
const initialState = await this.agent.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;
// Mark state as being explored
this.graph.incrementVisit(currentState.id);
// Discover available actions in this state
const actions = await this.agent.discoverActions(currentState);
if (actions.length === 0)
continue;
// Select action deterministically using seed + step count
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());
// Execute action and capture observation
const observation = await this.agent.executeAction(action);
this.actionTrace.push(action);
// Record new state if discovered
if (!this.graph.hasState(observation.newStateId)) {
const newState = await this.agent.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);
// Run per-state hooks (visual regression, accessibility, performance)
for (const hook of this.stateHooks) {
const hookAnomalies = await hook(newState, this.agent, 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,
});
// Detect anomalies
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.agent);
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;
// Run fuzzing if enabled and plugin provided
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.agent.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.agent);
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) {
const msg = err instanceof Error ? err.message : String(err);
this.events.onSessionError?.(this.sessionId, msg);
await this.agent.close().catch(() => undefined);
throw err;
}
await this.agent.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 };
}
}
exports.ExplorationEngine = ExplorationEngine;

66
dist/core/Logger.js vendored Normal file
View File

@@ -0,0 +1,66 @@
"use strict";
/**
* Logger — writes structured JSON log events to a .jsonl file.
* One JSON object per line.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.NullLogger = exports.FileLogger = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class FileLogger {
constructor(logDir, sessionId) {
const logPath = path.join(logDir, `session_${sessionId}.jsonl`);
fs.mkdirSync(logDir, { recursive: true });
this.stream = fs.createWriteStream(logPath, { flags: 'a' });
}
log(event) {
this.stream.write(JSON.stringify(event) + '\n');
}
close() {
this.stream.end();
}
}
exports.FileLogger = FileLogger;
/** No-op logger for testing */
class NullLogger {
constructor() {
this.events = [];
}
log(event) {
this.events.push(event);
}
}
exports.NullLogger = NullLogger;

83
dist/core/StateGraph.js vendored Normal file
View File

@@ -0,0 +1,83 @@
"use strict";
/**
* StateGraph — manages known states and transitions between them.
* Uses BFS ordering by default for exploration scheduling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.StateGraph = void 0;
class StateGraph {
constructor() {
this.states = new Map();
this.transitions = [];
/** Insertion order for BFS */
this.insertionOrder = [];
}
addState(state) {
if (!this.states.has(state.id)) {
this.states.set(state.id, state);
this.insertionOrder.push(state.id);
}
else {
// Update visit count on revisit
const existing = this.states.get(state.id);
this.states.set(state.id, { ...existing, visitCount: existing.visitCount + 1 });
}
}
hasState(stateId) {
return this.states.has(stateId);
}
getState(stateId) {
return this.states.get(stateId);
}
incrementVisit(stateId) {
const state = this.states.get(stateId);
if (state) {
this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 });
}
}
recordTransition(fromId, action, toId) {
this.transitions.push({
fromId,
action,
toId,
timestamp: Date.now(),
});
}
/** Returns all states that have never been visited (visitCount === 0) */
getUnvisited() {
return this.insertionOrder
.map((id) => this.states.get(id))
.filter((s) => s.visitCount === 0);
}
/** BFS heuristic: returns the oldest unvisited state, or null if none */
getNextToExplore() {
const unvisited = this.getUnvisited();
return unvisited.length > 0 ? unvisited[0] : null;
}
getAllStates() {
return this.insertionOrder.map((id) => this.states.get(id));
}
getTransitions() {
return [...this.transitions];
}
toJSON() {
return {
stateCount: this.states.size,
transitionCount: this.transitions.length,
states: this.getAllStates().map((s) => ({
id: s.id,
url: s.url,
title: s.title,
visitCount: s.visitCount,
})),
transitions: this.transitions.map((t) => ({
fromId: t.fromId,
toId: t.toId,
actionId: t.action.id,
actionType: t.action.type,
timestamp: t.timestamp,
})),
};
}
}
exports.StateGraph = StateGraph;

6
dist/core/interfaces.js vendored Normal file
View File

@@ -0,0 +1,6 @@
"use strict";
/**
* ABE Core Interfaces
* Core data types only. Must NOT import from src/plugins/.
*/
Object.defineProperty(exports, "__esModule", { value: true });