138 lines
5.3 KiB
JavaScript
138 lines
5.3 KiB
JavaScript
"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;
|