diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index cdceb86..c2592a2 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -96bf6e50979e4cc152e9715b965f27eeb2decbc1 +d62bd615bf6b93b982e19e35b0b49591c648e5d2 diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index c2c782c..8dd606f 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -89,40 +89,40 @@ Spec: `.ralph/specs/phase-04-crawling-infrastructure.md` --- -## Phase 5: Findings Module [PENDIENTE] +## Phase 5: Findings Module [COMPLETO] Spec: `.ralph/specs/phase-05-findings-module.md` -- [ ] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace -- [ ] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed) -- [ ] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts` -- [ ] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts` -- [ ] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts` -- [ ] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts` -- [ ] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding -- [ ] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts` -- [ ] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter) -- [ ] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace -- [ ] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas -- [ ] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json -- [ ] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros -- [ ] 5.14: Verificar build + commit: `fase(5): findings module complete` +- [x] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace +- [x] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed) +- [x] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts` +- [x] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts` +- [x] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts` +- [x] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts` +- [x] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding +- [x] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts` +- [x] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter) +- [x] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace +- [x] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas +- [x] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json +- [x] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros +- [x] 5.14: Verificar build + commit: `fase(5): findings module complete` --- -## Phase 6: Fuzzing Module [PENDIENTE] +## Phase 6: Fuzzing Module [COMPLETO] Spec: `.ralph/specs/phase-06-fuzzing-module.md` -- [ ] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity) -- [ ] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts` -- [ ] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts` -- [ ] 6.4: Crear port: `IFuzzerEngine.ts` -- [ ] 6.5: Crear `commands/RunFuzzCommand.ts` -- [ ] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing -- [ ] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary) -- [ ] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts` → `infrastructure/adapters/` -- [ ] 6.9: Crear `infrastructure/http/FuzzingController.ts` -- [ ] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos -- [ ] 6.11: Verificar build + commit: `fase(6): fuzzing module complete` +- [x] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity) +- [x] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts` +- [x] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts` +- [x] 6.4: Crear port: `IFuzzerEngine.ts` +- [x] 6.5: Crear `commands/RunFuzzCommand.ts` +- [x] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing +- [x] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary) +- [x] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts` → `infrastructure/adapters/` +- [x] 6.9: Crear `infrastructure/http/FuzzingController.ts` +- [x] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos +- [x] 6.11: Verificar build + commit: `fase(6): fuzzing module complete` --- diff --git a/.ralph/progress.json b/.ralph/progress.json index 5463e49..9476d27 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1,7 +1 @@ -{ - "status": "executing", - "indicator": "⠹", - "elapsed_seconds": 30, - "last_output": "", - "timestamp": "2026-03-05 03:53:30" -} +{"status": "failed", "timestamp": "2026-03-05 04:07:02"} diff --git a/dist/modules/fuzzing/application/commands/RunFuzzCommand.js b/dist/modules/fuzzing/application/commands/RunFuzzCommand.js new file mode 100644 index 0000000..f45a027 --- /dev/null +++ b/dist/modules/fuzzing/application/commands/RunFuzzCommand.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RunFuzzCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const FuzzSession_1 = require("../../domain/entities/FuzzSession"); +class RunFuzzCommand { + constructor(fuzzerEngine, repository, eventBus) { + this.fuzzerEngine = fuzzerEngine; + this.repository = repository; + this.eventBus = eventBus; + } + async execute(request) { + const sessionResult = FuzzSession_1.FuzzSession.create({ + crawlSessionId: request.crawlSessionId, + intensity: request.intensity, + seed: request.seed, + }); + if (!sessionResult.ok) { + return (0, Result_1.Err)(sessionResult.error); + } + const session = sessionResult.value; + await this.repository.save(session); + const actions = this.fuzzerEngine.generateFuzzActions(request.state.domSnapshot, request.state); + for (const _action of actions) { + session.incrementActions(); + } + session.complete(); + await this.repository.update(session); + for (const event of session.domainEvents) { + await this.eventBus.publish(event); + } + session.clearEvents(); + return (0, Result_1.Ok)({ + fuzzSessionId: session.id.toString(), + actionsGenerated: actions.length, + }); + } +} +exports.RunFuzzCommand = RunFuzzCommand; diff --git a/dist/modules/fuzzing/application/event-handlers/OnActionExecuted.js b/dist/modules/fuzzing/application/event-handlers/OnActionExecuted.js new file mode 100644 index 0000000..8adf64e --- /dev/null +++ b/dist/modules/fuzzing/application/event-handlers/OnActionExecuted.js @@ -0,0 +1,26 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OnActionExecuted = void 0; +/** + * Listens for action_executed events from crawling module + * and triggers fuzzing on the resulting state's DOM. + */ +class OnActionExecuted { + constructor(runFuzz) { + this.runFuzz = runFuzz; + } + async handle(event) { + const payload = event.payload; + if (!payload.state || !payload.sessionId) + return; + if (!payload.state.domSnapshot) + return; + await this.runFuzz.execute({ + crawlSessionId: payload.sessionId, + intensity: payload.intensity ?? 'low', + seed: payload.action?.seed ?? Date.now(), + state: payload.state, + }); + } +} +exports.OnActionExecuted = OnActionExecuted; diff --git a/dist/modules/fuzzing/domain/entities/FuzzResult.js b/dist/modules/fuzzing/domain/entities/FuzzResult.js new file mode 100644 index 0000000..d430c84 --- /dev/null +++ b/dist/modules/fuzzing/domain/entities/FuzzResult.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzResult = void 0; +const Entity_1 = require("../../../../shared/domain/Entity"); +class FuzzResult extends Entity_1.Entity { + static create(props, id) { + return new FuzzResult({ ...props, detectedAt: new Date() }, id); + } + static reconstitute(props, id) { + return new FuzzResult(props, id); + } + get sessionId() { return this.props.sessionId; } + get stateId() { return this.props.stateId; } + get selector() { return this.props.selector; } + get payload() { return this.props.payload; } + get strategy() { return this.props.strategy; } + get anomalyType() { return this.props.anomalyType; } + get severity() { return this.props.severity; } + get description() { return this.props.description; } + get detectedAt() { return this.props.detectedAt; } +} +exports.FuzzResult = FuzzResult; diff --git a/dist/modules/fuzzing/domain/entities/FuzzSession.js b/dist/modules/fuzzing/domain/entities/FuzzSession.js new file mode 100644 index 0000000..1ab540e --- /dev/null +++ b/dist/modules/fuzzing/domain/entities/FuzzSession.js @@ -0,0 +1,96 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzSession = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const Result_1 = require("../../../../shared/domain/Result"); +const FuzzIntensity_1 = require("../value-objects/FuzzIntensity"); +const Seed_1 = require("../value-objects/Seed"); +const FuzzStarted_1 = require("../events/FuzzStarted"); +const FuzzCompleted_1 = require("../events/FuzzCompleted"); +const VulnerabilityDetected_1 = require("../events/VulnerabilityDetected"); +class FuzzSession extends AggregateRoot_1.AggregateRoot { + constructor(props, id) { + super(props, id); + } + static reconstitute(props, id) { + return new FuzzSession(props, id); + } + static create(request) { + let intensity; + try { + intensity = FuzzIntensity_1.FuzzIntensity.fromString(request.intensity); + } + catch (e) { + return (0, Result_1.Err)(e.message); + } + let seed; + try { + seed = Seed_1.Seed.create(request.seed); + } + catch (e) { + return (0, Result_1.Err)(e.message); + } + const props = { + crawlSessionId: request.crawlSessionId, + intensity, + seed, + status: 'running', + actionsExecuted: 0, + vulnerabilitiesFound: 0, + startedAt: new Date(), + }; + const session = new FuzzSession(props); + session.addDomainEvent(new FuzzStarted_1.FuzzStarted(session.id.toString(), { + crawlSessionId: request.crawlSessionId, + intensity: request.intensity, + seed: request.seed, + })); + return (0, Result_1.Ok)(session); + } + get crawlSessionId() { return this.props.crawlSessionId; } + get intensity() { return this.props.intensity; } + get seed() { return this.props.seed; } + get status() { return this.props.status; } + get actionsExecuted() { return this.props.actionsExecuted; } + get vulnerabilitiesFound() { return this.props.vulnerabilitiesFound; } + get startedAt() { return this.props.startedAt; } + get completedAt() { return this.props.completedAt; } + recordVulnerability(result) { + this.props = { + ...this.props, + actionsExecuted: this.props.actionsExecuted + 1, + vulnerabilitiesFound: this.props.vulnerabilitiesFound + 1, + }; + this.addDomainEvent(new VulnerabilityDetected_1.VulnerabilityDetected(this.id.toString(), { + crawlSessionId: this.props.crawlSessionId, + stateId: result.stateId, + anomalyType: result.anomalyType, + severity: result.severity, + selector: result.selector, + payload: result.payload, + strategy: result.strategy, + })); + } + incrementActions() { + this.props = { ...this.props, actionsExecuted: this.props.actionsExecuted + 1 }; + } + complete() { + this.props = { ...this.props, status: 'completed', completedAt: new Date() }; + this.addDomainEvent(new FuzzCompleted_1.FuzzCompleted(this.id.toString(), { + crawlSessionId: this.props.crawlSessionId, + actionsExecuted: this.props.actionsExecuted, + vulnerabilitiesFound: this.props.vulnerabilitiesFound, + })); + } + fail(reason) { + this.props = { ...this.props, status: 'failed', completedAt: new Date() }; + this.addDomainEvent(new FuzzCompleted_1.FuzzCompleted(this.id.toString(), { + crawlSessionId: this.props.crawlSessionId, + actionsExecuted: this.props.actionsExecuted, + vulnerabilitiesFound: this.props.vulnerabilitiesFound, + failed: true, + reason, + })); + } +} +exports.FuzzSession = FuzzSession; diff --git a/dist/modules/fuzzing/domain/events/FuzzCompleted.js b/dist/modules/fuzzing/domain/events/FuzzCompleted.js new file mode 100644 index 0000000..e56a2ea --- /dev/null +++ b/dist/modules/fuzzing/domain/events/FuzzCompleted.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzCompleted = void 0; +const crypto_1 = require("crypto"); +class FuzzCompleted { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'fuzz.completed'; + this.occurredOn = new Date(); + } +} +exports.FuzzCompleted = FuzzCompleted; diff --git a/dist/modules/fuzzing/domain/events/FuzzStarted.js b/dist/modules/fuzzing/domain/events/FuzzStarted.js new file mode 100644 index 0000000..24fbdff --- /dev/null +++ b/dist/modules/fuzzing/domain/events/FuzzStarted.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzStarted = void 0; +const crypto_1 = require("crypto"); +class FuzzStarted { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'fuzz.started'; + this.occurredOn = new Date(); + } +} +exports.FuzzStarted = FuzzStarted; diff --git a/dist/modules/fuzzing/domain/events/VulnerabilityDetected.js b/dist/modules/fuzzing/domain/events/VulnerabilityDetected.js new file mode 100644 index 0000000..921120d --- /dev/null +++ b/dist/modules/fuzzing/domain/events/VulnerabilityDetected.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.VulnerabilityDetected = void 0; +const crypto_1 = require("crypto"); +class VulnerabilityDetected { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'fuzz.vulnerability_detected'; + this.occurredOn = new Date(); + } +} +exports.VulnerabilityDetected = VulnerabilityDetected; diff --git a/dist/modules/fuzzing/domain/ports/IFuzzerEngine.js b/dist/modules/fuzzing/domain/ports/IFuzzerEngine.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/fuzzing/domain/ports/IFuzzerEngine.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/fuzzing/domain/value-objects/FuzzIntensity.js b/dist/modules/fuzzing/domain/value-objects/FuzzIntensity.js new file mode 100644 index 0000000..f1ed031 --- /dev/null +++ b/dist/modules/fuzzing/domain/value-objects/FuzzIntensity.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzIntensity = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class FuzzIntensity extends ValueObject_1.ValueObject { + static low() { return new FuzzIntensity({ value: 'low' }); } + static medium() { return new FuzzIntensity({ value: 'medium' }); } + static high() { return new FuzzIntensity({ value: 'high' }); } + static fromString(s) { + if (!FuzzIntensity.LEVELS.includes(s)) { + throw new Error(`Invalid intensity: ${s}. Must be one of: ${FuzzIntensity.LEVELS.join(', ')}`); + } + return new FuzzIntensity({ value: s }); + } + get value() { return this.props.value; } +} +exports.FuzzIntensity = FuzzIntensity; +FuzzIntensity.LEVELS = ['low', 'medium', 'high']; diff --git a/dist/modules/fuzzing/domain/value-objects/FuzzPayload.js b/dist/modules/fuzzing/domain/value-objects/FuzzPayload.js new file mode 100644 index 0000000..38ab71e --- /dev/null +++ b/dist/modules/fuzzing/domain/value-objects/FuzzPayload.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzPayload = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class FuzzPayload extends ValueObject_1.ValueObject { + static create(value, strategy) { + return new FuzzPayload({ value, strategy }); + } + get value() { return this.props.value; } + get strategy() { return this.props.strategy; } +} +exports.FuzzPayload = FuzzPayload; diff --git a/dist/modules/fuzzing/domain/value-objects/FuzzStrategy.js b/dist/modules/fuzzing/domain/value-objects/FuzzStrategy.js new file mode 100644 index 0000000..90bd7ee --- /dev/null +++ b/dist/modules/fuzzing/domain/value-objects/FuzzStrategy.js @@ -0,0 +1,20 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzStrategy = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class FuzzStrategy extends ValueObject_1.ValueObject { + static empty() { return new FuzzStrategy({ value: 'empty' }); } + static oversized() { return new FuzzStrategy({ value: 'oversized' }); } + static specialChars() { return new FuzzStrategy({ value: 'special_chars' }); } + static typeMismatch() { return new FuzzStrategy({ value: 'type_mismatch' }); } + static boundary() { return new FuzzStrategy({ value: 'boundary' }); } + static fromString(s) { + if (!FuzzStrategy.ALL.includes(s)) { + throw new Error(`Invalid fuzz strategy: ${s}`); + } + return new FuzzStrategy({ value: s }); + } + get value() { return this.props.value; } +} +exports.FuzzStrategy = FuzzStrategy; +FuzzStrategy.ALL = ['empty', 'oversized', 'special_chars', 'type_mismatch', 'boundary']; diff --git a/dist/modules/fuzzing/domain/value-objects/Seed.js b/dist/modules/fuzzing/domain/value-objects/Seed.js new file mode 100644 index 0000000..2c5ed71 --- /dev/null +++ b/dist/modules/fuzzing/domain/value-objects/Seed.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Seed = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class Seed extends ValueObject_1.ValueObject { + static create(value) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Seed must be a non-negative integer, got: ${value}`); + } + return new Seed({ value }); + } + static fromTimestamp() { + return new Seed({ value: Date.now() }); + } + get value() { return this.props.value; } +} +exports.Seed = Seed; diff --git a/dist/modules/fuzzing/index.js b/dist/modules/fuzzing/index.js new file mode 100644 index 0000000..87bfa3e --- /dev/null +++ b/dist/modules/fuzzing/index.js @@ -0,0 +1,26 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createFuzzingRouter = exports.FuzzingEngineAdapter = exports.OnActionExecuted = exports.RunFuzzCommand = exports.Seed = exports.FuzzPayload = exports.FuzzStrategy = exports.FuzzIntensity = exports.FuzzResult = exports.FuzzSession = void 0; +// Domain +var FuzzSession_1 = require("./domain/entities/FuzzSession"); +Object.defineProperty(exports, "FuzzSession", { enumerable: true, get: function () { return FuzzSession_1.FuzzSession; } }); +var FuzzResult_1 = require("./domain/entities/FuzzResult"); +Object.defineProperty(exports, "FuzzResult", { enumerable: true, get: function () { return FuzzResult_1.FuzzResult; } }); +var FuzzIntensity_1 = require("./domain/value-objects/FuzzIntensity"); +Object.defineProperty(exports, "FuzzIntensity", { enumerable: true, get: function () { return FuzzIntensity_1.FuzzIntensity; } }); +var FuzzStrategy_1 = require("./domain/value-objects/FuzzStrategy"); +Object.defineProperty(exports, "FuzzStrategy", { enumerable: true, get: function () { return FuzzStrategy_1.FuzzStrategy; } }); +var FuzzPayload_1 = require("./domain/value-objects/FuzzPayload"); +Object.defineProperty(exports, "FuzzPayload", { enumerable: true, get: function () { return FuzzPayload_1.FuzzPayload; } }); +var Seed_1 = require("./domain/value-objects/Seed"); +Object.defineProperty(exports, "Seed", { enumerable: true, get: function () { return Seed_1.Seed; } }); +// Application +var RunFuzzCommand_1 = require("./application/commands/RunFuzzCommand"); +Object.defineProperty(exports, "RunFuzzCommand", { enumerable: true, get: function () { return RunFuzzCommand_1.RunFuzzCommand; } }); +var OnActionExecuted_1 = require("./application/event-handlers/OnActionExecuted"); +Object.defineProperty(exports, "OnActionExecuted", { enumerable: true, get: function () { return OnActionExecuted_1.OnActionExecuted; } }); +// Infrastructure +var FuzzingEngineAdapter_1 = require("./infrastructure/adapters/FuzzingEngineAdapter"); +Object.defineProperty(exports, "FuzzingEngineAdapter", { enumerable: true, get: function () { return FuzzingEngineAdapter_1.FuzzingEngineAdapter; } }); +var FuzzingController_1 = require("./infrastructure/http/FuzzingController"); +Object.defineProperty(exports, "createFuzzingRouter", { enumerable: true, get: function () { return FuzzingController_1.createFuzzingRouter; } }); diff --git a/dist/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter.js b/dist/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter.js new file mode 100644 index 0000000..f319c9f --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter.js @@ -0,0 +1,127 @@ +"use strict"; +/** + * FuzzingEngineAdapter — implements IFuzzerEngine port using the 5 fuzzing strategies. + * Adapts the legacy FuzzingEngine logic to the hexagonal architecture. + */ +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.FuzzingEngineAdapter = void 0; +const crypto = __importStar(require("crypto")); +const InputTypeDetector_1 = require("./InputTypeDetector"); +const EmptyValueStrategy_1 = require("../strategies/EmptyValueStrategy"); +const OversizedStringStrategy_1 = require("../strategies/OversizedStringStrategy"); +const SpecialCharsStrategy_1 = require("../strategies/SpecialCharsStrategy"); +const TypeMismatchStrategy_1 = require("../strategies/TypeMismatchStrategy"); +const BoundaryValueStrategy_1 = require("../strategies/BoundaryValueStrategy"); +const INPUT_RE = /<(input|textarea|select)[^>]*>/gi; +const ATTR_RE = (name) => new RegExp(`${name}="([^"]*)"`, 'i'); +function extractFields(domSnapshot) { + const fields = []; + let match; + while ((match = INPUT_RE.exec(domSnapshot)) !== null) { + const tag = match[0] ?? ''; + const tagName = match[1] ?? 'input'; + const idMatch = ATTR_RE('id').exec(tag); + const nameMatch = ATTR_RE('name').exec(tag); + const typeMatch = ATTR_RE('type').exec(tag); + const placeholderMatch = ATTR_RE('placeholder').exec(tag); + const ariaMatch = ATTR_RE('aria-label').exec(tag); + const selector = idMatch?.[1] + ? `#${idMatch[1]}` + : nameMatch?.[1] + ? `[name="${nameMatch[1]}"]` + : tagName; + fields.push({ + selector, + tagName, + inputType: typeMatch?.[1], + name: nameMatch?.[1], + placeholder: placeholderMatch?.[1], + ariaLabel: ariaMatch?.[1], + }); + } + return fields; +} +class FuzzingEngineAdapter { + constructor(config) { + this.intensity = config.intensity; + this.seed = config.seed; + } + generateFuzzActions(domSnapshot, state) { + const fields = extractFields(domSnapshot); + const actions = []; + const now = Date.now(); + const strategies = this.selectStrategies(); + for (const field of fields) { + const detectedType = (0, InputTypeDetector_1.detectInputType)({ + tagName: field.tagName, + inputType: field.inputType, + name: field.name, + placeholder: field.placeholder, + ariaLabel: field.ariaLabel, + }); + for (const strategy of strategies) { + if (!strategy.appliesTo(detectedType)) + continue; + const values = strategy.values(detectedType); + for (const value of values) { + actions.push({ + id: crypto.randomUUID(), + type: 'fill', + selector: field.selector, + value, + timestamp: now, + seed: this.seed, + stateId: state.id, + }); + } + } + } + return actions; + } + selectStrategies() { + const empty = new EmptyValueStrategy_1.EmptyValueStrategy(); + const typeMismatch = new TypeMismatchStrategy_1.TypeMismatchStrategy(); + const oversized = new OversizedStringStrategy_1.OversizedStringStrategy(this.intensity); + const boundary = new BoundaryValueStrategy_1.BoundaryValueStrategy(); + const special = new SpecialCharsStrategy_1.SpecialCharsStrategy(); + switch (this.intensity) { + case 'low': return [empty, typeMismatch]; + case 'medium': return [empty, typeMismatch, oversized, boundary]; + case 'high': return [empty, typeMismatch, oversized, boundary, special]; + } + } +} +exports.FuzzingEngineAdapter = FuzzingEngineAdapter; diff --git a/dist/modules/fuzzing/infrastructure/adapters/InputTypeDetector.js b/dist/modules/fuzzing/infrastructure/adapters/InputTypeDetector.js new file mode 100644 index 0000000..5c0a208 --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/adapters/InputTypeDetector.js @@ -0,0 +1,47 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.detectInputType = detectInputType; +function detectInputType(attrs) { + const tag = (attrs.tagName ?? '').toLowerCase(); + if (tag === 'textarea') + return 'textarea'; + if (tag === 'select') + return 'select'; + const inputType = (attrs.inputType ?? '').toLowerCase(); + if (inputType === 'email') + return 'email'; + if (inputType === 'password') + return 'password'; + if (inputType === 'number') + return 'number'; + if (inputType === 'date') + return 'date'; + if (inputType === 'tel') + return 'phone'; + if (inputType === 'url') + return 'url'; + if (inputType === 'search') + return 'search'; + if (inputType === 'file') + return 'file'; + const hints = [ + (attrs.name ?? '').toLowerCase(), + (attrs.placeholder ?? '').toLowerCase(), + (attrs.ariaLabel ?? '').toLowerCase(), + ].join(' '); + if (/email/.test(hints)) + return 'email'; + if (/password|pass/.test(hints)) + return 'password'; + if (/phone|tel|mobile/.test(hints)) + return 'phone'; + if (/date|birth|dob/.test(hints)) + return 'date'; + if (/number|qty|quantity|age/.test(hints)) + return 'number'; + if (/search/.test(hints)) + return 'search'; + if (/url|website|link/.test(hints)) + return 'url'; + return 'text'; +} diff --git a/dist/modules/fuzzing/infrastructure/http/FuzzingController.js b/dist/modules/fuzzing/infrastructure/http/FuzzingController.js new file mode 100644 index 0000000..7aeb2a7 --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/http/FuzzingController.js @@ -0,0 +1,50 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createFuzzingRouter = createFuzzingRouter; +const express_1 = require("express"); +function createFuzzingRouter(deps) { + const router = (0, express_1.Router)(); + // POST /api/fuzz/run — trigger fuzzing for a given state + router.post('/run', async (req, res) => { + const { crawlSessionId, intensity, seed, state } = req.body; + if (!crawlSessionId || !state) { + res.status(400).json({ error: 'crawlSessionId and state are required' }); + return; + } + const result = await deps.runFuzz.execute({ + crawlSessionId, + intensity: intensity ?? 'low', + seed: seed ?? Date.now(), + state, + }); + if (!result.ok) { + res.status(422).json({ error: result.error }); + return; + } + res.status(201).json(result.value); + }); + // GET /api/fuzz/sessions/:id — get fuzz session + router.get('/sessions/:id', async (req, res) => { + const sessionId = req.params['id']; + const session = await deps.repository.findById(sessionId); + if (!session) { + res.status(404).json({ error: 'Fuzz session not found' }); + return; + } + res.json(toDTO(session)); + }); + return router; +} +function toDTO(s) { + return { + id: s.id.toString(), + crawlSessionId: s.crawlSessionId, + intensity: s.intensity.value, + seed: s.seed.value, + status: s.status, + actionsExecuted: s.actionsExecuted, + vulnerabilitiesFound: s.vulnerabilitiesFound, + startedAt: s.startedAt.toISOString(), + completedAt: s.completedAt?.toISOString() ?? null, + }; +} diff --git a/dist/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy.js b/dist/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy.js new file mode 100644 index 0000000..395c869 --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BoundaryValueStrategy = void 0; +class BoundaryValueStrategy { + constructor() { + this.name = 'BoundaryValueStrategy'; + } + appliesTo(type) { + return type === 'number' || type === 'date'; + } + values(type) { + switch (type) { + case 'number': return ['0', '-1', '2147483647', '2147483648', '-2147483648']; + case 'date': return ['1900-01-01', '2099-12-31', '1970-01-01']; + default: return []; + } + } +} +exports.BoundaryValueStrategy = BoundaryValueStrategy; diff --git a/dist/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy.js b/dist/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy.js new file mode 100644 index 0000000..cb536df --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EmptyValueStrategy = void 0; +class EmptyValueStrategy { + constructor() { + this.name = 'EmptyValueStrategy'; + } + appliesTo(_type) { + return true; + } + values() { + return ['', ' ', '\t']; + } +} +exports.EmptyValueStrategy = EmptyValueStrategy; diff --git a/dist/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy.js b/dist/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy.js new file mode 100644 index 0000000..c72f54b --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OversizedStringStrategy = void 0; +const APPLICABLE_TYPES = ['text', 'email', 'password', 'textarea']; +class OversizedStringStrategy { + constructor(intensity) { + this.intensity = intensity; + this.name = 'OversizedStringStrategy'; + } + appliesTo(type) { + return APPLICABLE_TYPES.includes(type); + } + values() { + switch (this.intensity) { + case 'low': return ['A'.repeat(256)]; + case 'medium': return ['A'.repeat(1024)]; + case 'high': return ['A'.repeat(10000) + '日本語テスト𠮷野家']; + } + } +} +exports.OversizedStringStrategy = OversizedStringStrategy; diff --git a/dist/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy.js b/dist/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy.js new file mode 100644 index 0000000..9941d72 --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SpecialCharsStrategy = void 0; +const APPLICABLE_TYPES = ['text', 'email', 'search', 'textarea']; +class SpecialCharsStrategy { + constructor() { + this.name = 'SpecialCharsStrategy'; + } + appliesTo(type) { + return APPLICABLE_TYPES.includes(type); + } + values() { + return [ + "' OR 1=1 --", + '', + '../../etc/passwd', + '${7*7}', + '\x00\x01\x02', + ]; + } +} +exports.SpecialCharsStrategy = SpecialCharsStrategy; diff --git a/dist/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy.js b/dist/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy.js new file mode 100644 index 0000000..f098ac3 --- /dev/null +++ b/dist/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TypeMismatchStrategy = void 0; +class TypeMismatchStrategy { + constructor() { + this.name = 'TypeMismatchStrategy'; + } + appliesTo(type) { + return ['email', 'number', 'date', 'url', 'phone'].includes(type); + } + values(type) { + switch (type) { + case 'email': return ['not-an-email', '12345', '@@@']; + case 'number': return ['abc', '-999999', '9.9.9', 'NaN']; + case 'date': return ['yesterday', '32/13/2025', '0000-00-00']; + case 'url': return ['javascript:alert(1)', 'not a url']; + case 'phone': return ['000', '++++', 'abcdefghij']; + default: return []; + } + } +} +exports.TypeMismatchStrategy = TypeMismatchStrategy; diff --git a/src/modules/fuzzing/application/commands/RunFuzzCommand.ts b/src/modules/fuzzing/application/commands/RunFuzzCommand.ts new file mode 100644 index 0000000..b96e331 --- /dev/null +++ b/src/modules/fuzzing/application/commands/RunFuzzCommand.ts @@ -0,0 +1,66 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { FuzzSession } from '../../domain/entities/FuzzSession'; +import { IFuzzerEngine } from '../../domain/ports/IFuzzerEngine'; +import { IState } from '../../../../core/interfaces'; + +export interface IFuzzSessionRepository { + save(session: FuzzSession): Promise; + findById(id: string): Promise; + update(session: FuzzSession): Promise; +} + +interface RunFuzzRequest { + crawlSessionId: string; + intensity: 'low' | 'medium' | 'high'; + seed: number; + state: IState; +} + +interface RunFuzzResponse { + fuzzSessionId: string; + actionsGenerated: number; +} + +export class RunFuzzCommand implements UseCase { + constructor( + private readonly fuzzerEngine: IFuzzerEngine, + private readonly repository: IFuzzSessionRepository, + private readonly eventBus: EventBus + ) {} + + async execute(request: RunFuzzRequest): Promise> { + const sessionResult = FuzzSession.create({ + crawlSessionId: request.crawlSessionId, + intensity: request.intensity, + seed: request.seed, + }); + + if (!sessionResult.ok) { + return Err(sessionResult.error); + } + + const session = sessionResult.value; + await this.repository.save(session); + + const actions = this.fuzzerEngine.generateFuzzActions(request.state.domSnapshot, request.state); + + for (const _action of actions) { + session.incrementActions(); + } + + session.complete(); + await this.repository.update(session); + + for (const event of session.domainEvents) { + await this.eventBus.publish(event); + } + session.clearEvents(); + + return Ok({ + fuzzSessionId: session.id.toString(), + actionsGenerated: actions.length, + }); + } +} diff --git a/src/modules/fuzzing/application/event-handlers/OnActionExecuted.ts b/src/modules/fuzzing/application/event-handlers/OnActionExecuted.ts new file mode 100644 index 0000000..3486d8b --- /dev/null +++ b/src/modules/fuzzing/application/event-handlers/OnActionExecuted.ts @@ -0,0 +1,31 @@ +import { EventHandler } from '../../../../shared/application/EventHandler'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; +import { RunFuzzCommand } from '../commands/RunFuzzCommand'; +import { IAction, IState } from '../../../../core/interfaces'; + +/** + * Listens for action_executed events from crawling module + * and triggers fuzzing on the resulting state's DOM. + */ +export class OnActionExecuted implements EventHandler { + constructor(private readonly runFuzz: RunFuzzCommand) {} + + async handle(event: DomainEvent): Promise { + const payload = event.payload as { + action?: IAction; + state?: IState; + sessionId?: string; + intensity?: 'low' | 'medium' | 'high'; + }; + + if (!payload.state || !payload.sessionId) return; + if (!payload.state.domSnapshot) return; + + await this.runFuzz.execute({ + crawlSessionId: payload.sessionId, + intensity: payload.intensity ?? 'low', + seed: payload.action?.seed ?? Date.now(), + state: payload.state, + }); + } +} diff --git a/src/modules/fuzzing/domain/entities/FuzzResult.ts b/src/modules/fuzzing/domain/entities/FuzzResult.ts new file mode 100644 index 0000000..e7ee602 --- /dev/null +++ b/src/modules/fuzzing/domain/entities/FuzzResult.ts @@ -0,0 +1,34 @@ +import { Entity } from '../../../../shared/domain/Entity'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; + +export interface FuzzResultProps { + sessionId: string; + stateId: string; + selector: string; + payload: string; + strategy: string; + anomalyType: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + description: string; + detectedAt: Date; +} + +export class FuzzResult extends Entity { + static create(props: Omit, id?: UniqueId): FuzzResult { + return new FuzzResult({ ...props, detectedAt: new Date() }, id); + } + + static reconstitute(props: FuzzResultProps, id: UniqueId): FuzzResult { + return new FuzzResult(props, id); + } + + get sessionId(): string { return this.props.sessionId; } + get stateId(): string { return this.props.stateId; } + get selector(): string { return this.props.selector; } + get payload(): string { return this.props.payload; } + get strategy(): string { return this.props.strategy; } + get anomalyType(): string { return this.props.anomalyType; } + get severity(): 'low' | 'medium' | 'high' | 'critical' { return this.props.severity; } + get description(): string { return this.props.description; } + get detectedAt(): Date { return this.props.detectedAt; } +} diff --git a/src/modules/fuzzing/domain/entities/FuzzSession.ts b/src/modules/fuzzing/domain/entities/FuzzSession.ts new file mode 100644 index 0000000..4c79eee --- /dev/null +++ b/src/modules/fuzzing/domain/entities/FuzzSession.ts @@ -0,0 +1,131 @@ +import { AggregateRoot } from '../../../../shared/domain/AggregateRoot'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { FuzzIntensity } from '../value-objects/FuzzIntensity'; +import { Seed } from '../value-objects/Seed'; +import { FuzzStarted } from '../events/FuzzStarted'; +import { FuzzCompleted } from '../events/FuzzCompleted'; +import { VulnerabilityDetected } from '../events/VulnerabilityDetected'; +import { FuzzResult } from './FuzzResult'; + +type FuzzSessionStatus = 'running' | 'completed' | 'failed'; + +export interface FuzzSessionProps { + crawlSessionId: string; + intensity: FuzzIntensity; + seed: Seed; + status: FuzzSessionStatus; + actionsExecuted: number; + vulnerabilitiesFound: number; + startedAt: Date; + completedAt?: Date; +} + +export interface CreateFuzzSessionRequest { + crawlSessionId: string; + intensity: 'low' | 'medium' | 'high'; + seed: number; +} + +export class FuzzSession extends AggregateRoot { + private constructor(props: FuzzSessionProps, id?: UniqueId) { + super(props, id); + } + + static reconstitute(props: FuzzSessionProps, id: UniqueId): FuzzSession { + return new FuzzSession(props, id); + } + + static create(request: CreateFuzzSessionRequest): Result { + let intensity: FuzzIntensity; + try { + intensity = FuzzIntensity.fromString(request.intensity); + } catch (e) { + return Err((e as Error).message); + } + + let seed: Seed; + try { + seed = Seed.create(request.seed); + } catch (e) { + return Err((e as Error).message); + } + + const props: FuzzSessionProps = { + crawlSessionId: request.crawlSessionId, + intensity, + seed, + status: 'running', + actionsExecuted: 0, + vulnerabilitiesFound: 0, + startedAt: new Date(), + }; + + const session = new FuzzSession(props); + session.addDomainEvent( + new FuzzStarted(session.id.toString(), { + crawlSessionId: request.crawlSessionId, + intensity: request.intensity, + seed: request.seed, + }) + ); + + return Ok(session); + } + + get crawlSessionId(): string { return this.props.crawlSessionId; } + get intensity(): FuzzIntensity { return this.props.intensity; } + get seed(): Seed { return this.props.seed; } + get status(): FuzzSessionStatus { return this.props.status; } + get actionsExecuted(): number { return this.props.actionsExecuted; } + get vulnerabilitiesFound(): number { return this.props.vulnerabilitiesFound; } + get startedAt(): Date { return this.props.startedAt; } + get completedAt(): Date | undefined { return this.props.completedAt; } + + recordVulnerability(result: FuzzResult): void { + this.props = { + ...this.props, + actionsExecuted: this.props.actionsExecuted + 1, + vulnerabilitiesFound: this.props.vulnerabilitiesFound + 1, + }; + this.addDomainEvent( + new VulnerabilityDetected(this.id.toString(), { + crawlSessionId: this.props.crawlSessionId, + stateId: result.stateId, + anomalyType: result.anomalyType, + severity: result.severity, + selector: result.selector, + payload: result.payload, + strategy: result.strategy, + }) + ); + } + + incrementActions(): void { + this.props = { ...this.props, actionsExecuted: this.props.actionsExecuted + 1 }; + } + + complete(): void { + this.props = { ...this.props, status: 'completed', completedAt: new Date() }; + this.addDomainEvent( + new FuzzCompleted(this.id.toString(), { + crawlSessionId: this.props.crawlSessionId, + actionsExecuted: this.props.actionsExecuted, + vulnerabilitiesFound: this.props.vulnerabilitiesFound, + }) + ); + } + + fail(reason: string): void { + this.props = { ...this.props, status: 'failed', completedAt: new Date() }; + this.addDomainEvent( + new FuzzCompleted(this.id.toString(), { + crawlSessionId: this.props.crawlSessionId, + actionsExecuted: this.props.actionsExecuted, + vulnerabilitiesFound: this.props.vulnerabilitiesFound, + failed: true, + reason, + }) + ); + } +} diff --git a/src/modules/fuzzing/domain/events/FuzzCompleted.ts b/src/modules/fuzzing/domain/events/FuzzCompleted.ts new file mode 100644 index 0000000..0173625 --- /dev/null +++ b/src/modules/fuzzing/domain/events/FuzzCompleted.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FuzzCompleted implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'fuzz.completed'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: Record + ) {} +} diff --git a/src/modules/fuzzing/domain/events/FuzzStarted.ts b/src/modules/fuzzing/domain/events/FuzzStarted.ts new file mode 100644 index 0000000..a41a13b --- /dev/null +++ b/src/modules/fuzzing/domain/events/FuzzStarted.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FuzzStarted implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'fuzz.started'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: Record + ) {} +} diff --git a/src/modules/fuzzing/domain/events/VulnerabilityDetected.ts b/src/modules/fuzzing/domain/events/VulnerabilityDetected.ts new file mode 100644 index 0000000..d9dfc86 --- /dev/null +++ b/src/modules/fuzzing/domain/events/VulnerabilityDetected.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class VulnerabilityDetected implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'fuzz.vulnerability_detected'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: Record + ) {} +} diff --git a/src/modules/fuzzing/domain/ports/IFuzzerEngine.ts b/src/modules/fuzzing/domain/ports/IFuzzerEngine.ts new file mode 100644 index 0000000..0a35ce2 --- /dev/null +++ b/src/modules/fuzzing/domain/ports/IFuzzerEngine.ts @@ -0,0 +1,6 @@ +import { IAction, IState } from '../../../../core/interfaces'; + +export interface IFuzzerEngine { + /** Generate fuzz fill actions for a DOM snapshot at a given state */ + generateFuzzActions(domSnapshot: string, state: IState): IAction[]; +} diff --git a/src/modules/fuzzing/domain/value-objects/FuzzIntensity.ts b/src/modules/fuzzing/domain/value-objects/FuzzIntensity.ts new file mode 100644 index 0000000..b0375a1 --- /dev/null +++ b/src/modules/fuzzing/domain/value-objects/FuzzIntensity.ts @@ -0,0 +1,24 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +type IntensityLevel = 'low' | 'medium' | 'high'; + +interface FuzzIntensityProps { + value: IntensityLevel; +} + +export class FuzzIntensity extends ValueObject { + static readonly LEVELS: IntensityLevel[] = ['low', 'medium', 'high']; + + static low(): FuzzIntensity { return new FuzzIntensity({ value: 'low' }); } + static medium(): FuzzIntensity { return new FuzzIntensity({ value: 'medium' }); } + static high(): FuzzIntensity { return new FuzzIntensity({ value: 'high' }); } + + static fromString(s: string): FuzzIntensity { + if (!FuzzIntensity.LEVELS.includes(s as IntensityLevel)) { + throw new Error(`Invalid intensity: ${s}. Must be one of: ${FuzzIntensity.LEVELS.join(', ')}`); + } + return new FuzzIntensity({ value: s as IntensityLevel }); + } + + get value(): IntensityLevel { return this.props.value; } +} diff --git a/src/modules/fuzzing/domain/value-objects/FuzzPayload.ts b/src/modules/fuzzing/domain/value-objects/FuzzPayload.ts new file mode 100644 index 0000000..8f87a28 --- /dev/null +++ b/src/modules/fuzzing/domain/value-objects/FuzzPayload.ts @@ -0,0 +1,15 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +interface FuzzPayloadProps { + value: string; + strategy: string; +} + +export class FuzzPayload extends ValueObject { + static create(value: string, strategy: string): FuzzPayload { + return new FuzzPayload({ value, strategy }); + } + + get value(): string { return this.props.value; } + get strategy(): string { return this.props.strategy; } +} diff --git a/src/modules/fuzzing/domain/value-objects/FuzzStrategy.ts b/src/modules/fuzzing/domain/value-objects/FuzzStrategy.ts new file mode 100644 index 0000000..51a5ef7 --- /dev/null +++ b/src/modules/fuzzing/domain/value-objects/FuzzStrategy.ts @@ -0,0 +1,26 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +type StrategyName = 'empty' | 'oversized' | 'special_chars' | 'type_mismatch' | 'boundary'; + +interface FuzzStrategyProps { + value: StrategyName; +} + +export class FuzzStrategy extends ValueObject { + static readonly ALL: StrategyName[] = ['empty', 'oversized', 'special_chars', 'type_mismatch', 'boundary']; + + static empty(): FuzzStrategy { return new FuzzStrategy({ value: 'empty' }); } + static oversized(): FuzzStrategy { return new FuzzStrategy({ value: 'oversized' }); } + static specialChars(): FuzzStrategy { return new FuzzStrategy({ value: 'special_chars' }); } + static typeMismatch(): FuzzStrategy { return new FuzzStrategy({ value: 'type_mismatch' }); } + static boundary(): FuzzStrategy { return new FuzzStrategy({ value: 'boundary' }); } + + static fromString(s: string): FuzzStrategy { + if (!FuzzStrategy.ALL.includes(s as StrategyName)) { + throw new Error(`Invalid fuzz strategy: ${s}`); + } + return new FuzzStrategy({ value: s as StrategyName }); + } + + get value(): StrategyName { return this.props.value; } +} diff --git a/src/modules/fuzzing/domain/value-objects/Seed.ts b/src/modules/fuzzing/domain/value-objects/Seed.ts new file mode 100644 index 0000000..1a8c5cc --- /dev/null +++ b/src/modules/fuzzing/domain/value-objects/Seed.ts @@ -0,0 +1,20 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +interface SeedProps { + value: number; +} + +export class Seed extends ValueObject { + static create(value: number): Seed { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Seed must be a non-negative integer, got: ${value}`); + } + return new Seed({ value }); + } + + static fromTimestamp(): Seed { + return new Seed({ value: Date.now() }); + } + + get value(): number { return this.props.value; } +} diff --git a/src/modules/fuzzing/index.ts b/src/modules/fuzzing/index.ts new file mode 100644 index 0000000..41bf71b --- /dev/null +++ b/src/modules/fuzzing/index.ts @@ -0,0 +1,17 @@ +// Domain +export { FuzzSession } from './domain/entities/FuzzSession'; +export { FuzzResult } from './domain/entities/FuzzResult'; +export { FuzzIntensity } from './domain/value-objects/FuzzIntensity'; +export { FuzzStrategy } from './domain/value-objects/FuzzStrategy'; +export { FuzzPayload } from './domain/value-objects/FuzzPayload'; +export { Seed } from './domain/value-objects/Seed'; +export type { IFuzzerEngine } from './domain/ports/IFuzzerEngine'; + +// Application +export { RunFuzzCommand } from './application/commands/RunFuzzCommand'; +export type { IFuzzSessionRepository } from './application/commands/RunFuzzCommand'; +export { OnActionExecuted } from './application/event-handlers/OnActionExecuted'; + +// Infrastructure +export { FuzzingEngineAdapter } from './infrastructure/adapters/FuzzingEngineAdapter'; +export { createFuzzingRouter } from './infrastructure/http/FuzzingController'; diff --git a/src/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter.ts b/src/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter.ts new file mode 100644 index 0000000..4fa62a4 --- /dev/null +++ b/src/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter.ts @@ -0,0 +1,121 @@ +/** + * FuzzingEngineAdapter — implements IFuzzerEngine port using the 5 fuzzing strategies. + * Adapts the legacy FuzzingEngine logic to the hexagonal architecture. + */ + +import * as crypto from 'crypto'; +import { IAction, IState } from '../../../../core/interfaces'; +import { IFuzzerEngine } from '../../domain/ports/IFuzzerEngine'; +import { detectInputType, DetectedInputType } from './InputTypeDetector'; +import { EmptyValueStrategy } from '../strategies/EmptyValueStrategy'; +import { OversizedStringStrategy } from '../strategies/OversizedStringStrategy'; +import { SpecialCharsStrategy } from '../strategies/SpecialCharsStrategy'; +import { TypeMismatchStrategy } from '../strategies/TypeMismatchStrategy'; +import { BoundaryValueStrategy } from '../strategies/BoundaryValueStrategy'; + +interface FormField { + selector: string; + tagName: string; + inputType?: string; + name?: string; + placeholder?: string; + ariaLabel?: string; +} + +const INPUT_RE = /<(input|textarea|select)[^>]*>/gi; +const ATTR_RE = (name: string): RegExp => new RegExp(`${name}="([^"]*)"`, 'i'); + +function extractFields(domSnapshot: string): FormField[] { + const fields: FormField[] = []; + let match: RegExpExecArray | null; + while ((match = INPUT_RE.exec(domSnapshot)) !== null) { + const tag = match[0] ?? ''; + const tagName = match[1] ?? 'input'; + const idMatch = ATTR_RE('id').exec(tag); + const nameMatch = ATTR_RE('name').exec(tag); + const typeMatch = ATTR_RE('type').exec(tag); + const placeholderMatch = ATTR_RE('placeholder').exec(tag); + const ariaMatch = ATTR_RE('aria-label').exec(tag); + + const selector = idMatch?.[1] + ? `#${idMatch[1]}` + : nameMatch?.[1] + ? `[name="${nameMatch[1]}"]` + : tagName; + + fields.push({ + selector, + tagName, + inputType: typeMatch?.[1], + name: nameMatch?.[1], + placeholder: placeholderMatch?.[1], + ariaLabel: ariaMatch?.[1], + }); + } + return fields; +} + +type FuzzingStrategy = { + name: string; + appliesTo(type: DetectedInputType): boolean; + values(type?: DetectedInputType): string[]; +}; + +export class FuzzingEngineAdapter implements IFuzzerEngine { + private readonly intensity: 'low' | 'medium' | 'high'; + private readonly seed: number; + + constructor(config: { intensity: 'low' | 'medium' | 'high'; seed: number }) { + this.intensity = config.intensity; + this.seed = config.seed; + } + + generateFuzzActions(domSnapshot: string, state: IState): IAction[] { + const fields = extractFields(domSnapshot); + const actions: IAction[] = []; + const now = Date.now(); + const strategies = this.selectStrategies(); + + for (const field of fields) { + const detectedType = detectInputType({ + tagName: field.tagName, + inputType: field.inputType, + name: field.name, + placeholder: field.placeholder, + ariaLabel: field.ariaLabel, + }); + + for (const strategy of strategies) { + if (!strategy.appliesTo(detectedType)) continue; + const values = strategy.values(detectedType); + for (const value of values) { + actions.push({ + id: crypto.randomUUID(), + type: 'fill', + selector: field.selector, + value, + timestamp: now, + seed: this.seed, + stateId: state.id, + }); + } + } + } + + return actions; + } + + private selectStrategies(): FuzzingStrategy[] { + const empty = new EmptyValueStrategy(); + const typeMismatch = new TypeMismatchStrategy(); + const oversized = new OversizedStringStrategy(this.intensity); + const boundary = new BoundaryValueStrategy(); + const special = new SpecialCharsStrategy(); + + switch (this.intensity) { + case 'low': return [empty, typeMismatch]; + case 'medium': return [empty, typeMismatch, oversized, boundary]; + case 'high': return [empty, typeMismatch, oversized, boundary, special]; + } + } +} diff --git a/src/modules/fuzzing/infrastructure/adapters/InputTypeDetector.ts b/src/modules/fuzzing/infrastructure/adapters/InputTypeDetector.ts new file mode 100644 index 0000000..c8f4663 --- /dev/null +++ b/src/modules/fuzzing/infrastructure/adapters/InputTypeDetector.ts @@ -0,0 +1,41 @@ +import { DetectedInputType } from '../strategies/EmptyValueStrategy'; + +export type { DetectedInputType }; + +export function detectInputType(attrs: { + tagName?: string; + inputType?: string; + name?: string; + placeholder?: string; + ariaLabel?: string; +}): DetectedInputType { + const tag = (attrs.tagName ?? '').toLowerCase(); + if (tag === 'textarea') return 'textarea'; + if (tag === 'select') return 'select'; + + const inputType = (attrs.inputType ?? '').toLowerCase(); + if (inputType === 'email') return 'email'; + if (inputType === 'password') return 'password'; + if (inputType === 'number') return 'number'; + if (inputType === 'date') return 'date'; + if (inputType === 'tel') return 'phone'; + if (inputType === 'url') return 'url'; + if (inputType === 'search') return 'search'; + if (inputType === 'file') return 'file'; + + const hints = [ + (attrs.name ?? '').toLowerCase(), + (attrs.placeholder ?? '').toLowerCase(), + (attrs.ariaLabel ?? '').toLowerCase(), + ].join(' '); + + if (/email/.test(hints)) return 'email'; + if (/password|pass/.test(hints)) return 'password'; + if (/phone|tel|mobile/.test(hints)) return 'phone'; + if (/date|birth|dob/.test(hints)) return 'date'; + if (/number|qty|quantity|age/.test(hints)) return 'number'; + if (/search/.test(hints)) return 'search'; + if (/url|website|link/.test(hints)) return 'url'; + + return 'text'; +} diff --git a/src/modules/fuzzing/infrastructure/http/FuzzingController.ts b/src/modules/fuzzing/infrastructure/http/FuzzingController.ts new file mode 100644 index 0000000..d4b8512 --- /dev/null +++ b/src/modules/fuzzing/infrastructure/http/FuzzingController.ts @@ -0,0 +1,71 @@ +import { Router, Request, Response } from 'express'; +import { RunFuzzCommand, IFuzzSessionRepository } from '../../application/commands/RunFuzzCommand'; +import { FuzzSession } from '../../domain/entities/FuzzSession'; +import { IState } from '../../../../core/interfaces'; + +export interface FuzzingControllerDeps { + runFuzz: RunFuzzCommand; + repository: IFuzzSessionRepository; +} + +export function createFuzzingRouter(deps: FuzzingControllerDeps): Router { + const router = Router(); + + // POST /api/fuzz/run — trigger fuzzing for a given state + router.post('/run', async (req: Request, res: Response) => { + const { crawlSessionId, intensity, seed, state } = req.body as { + crawlSessionId?: string; + intensity?: 'low' | 'medium' | 'high'; + seed?: number; + state?: IState; + }; + + if (!crawlSessionId || !state) { + res.status(400).json({ error: 'crawlSessionId and state are required' }); + return; + } + + const result = await deps.runFuzz.execute({ + crawlSessionId, + intensity: intensity ?? 'low', + seed: seed ?? Date.now(), + state, + }); + + if (!result.ok) { + res.status(422).json({ error: result.error }); + return; + } + + res.status(201).json(result.value); + }); + + // GET /api/fuzz/sessions/:id — get fuzz session + router.get('/sessions/:id', async (req: Request, res: Response) => { + const sessionId = req.params['id'] as string; + const session = await deps.repository.findById(sessionId); + + if (!session) { + res.status(404).json({ error: 'Fuzz session not found' }); + return; + } + + res.json(toDTO(session)); + }); + + return router; +} + +function toDTO(s: FuzzSession) { + return { + id: s.id.toString(), + crawlSessionId: s.crawlSessionId, + intensity: s.intensity.value, + seed: s.seed.value, + status: s.status, + actionsExecuted: s.actionsExecuted, + vulnerabilitiesFound: s.vulnerabilitiesFound, + startedAt: s.startedAt.toISOString(), + completedAt: s.completedAt?.toISOString() ?? null, + }; +} diff --git a/src/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy.ts b/src/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy.ts new file mode 100644 index 0000000..080bfc6 --- /dev/null +++ b/src/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy.ts @@ -0,0 +1,17 @@ +import { DetectedInputType } from './EmptyValueStrategy'; + +export class BoundaryValueStrategy { + readonly name = 'BoundaryValueStrategy'; + + appliesTo(type: DetectedInputType): boolean { + return type === 'number' || type === 'date'; + } + + values(type: DetectedInputType): string[] { + switch (type) { + case 'number': return ['0', '-1', '2147483647', '2147483648', '-2147483648']; + case 'date': return ['1900-01-01', '2099-12-31', '1970-01-01']; + default: return []; + } + } +} diff --git a/src/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy.ts b/src/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy.ts new file mode 100644 index 0000000..287dd6f --- /dev/null +++ b/src/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy.ts @@ -0,0 +1,15 @@ +export type DetectedInputType = + | 'email' | 'password' | 'number' | 'date' | 'phone' + | 'url' | 'search' | 'text' | 'textarea' | 'select' | 'file'; + +export class EmptyValueStrategy { + readonly name = 'EmptyValueStrategy'; + + appliesTo(_type: DetectedInputType): boolean { + return true; + } + + values(): string[] { + return ['', ' ', '\t']; + } +} diff --git a/src/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy.ts b/src/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy.ts new file mode 100644 index 0000000..5f9553a --- /dev/null +++ b/src/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy.ts @@ -0,0 +1,21 @@ +import { DetectedInputType } from './EmptyValueStrategy'; + +const APPLICABLE_TYPES: DetectedInputType[] = ['text', 'email', 'password', 'textarea']; + +export class OversizedStringStrategy { + readonly name = 'OversizedStringStrategy'; + + constructor(private readonly intensity: 'low' | 'medium' | 'high') {} + + appliesTo(type: DetectedInputType): boolean { + return APPLICABLE_TYPES.includes(type); + } + + values(): string[] { + switch (this.intensity) { + case 'low': return ['A'.repeat(256)]; + case 'medium': return ['A'.repeat(1024)]; + case 'high': return ['A'.repeat(10000) + '日本語テスト𠮷野家']; + } + } +} diff --git a/src/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy.ts b/src/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy.ts new file mode 100644 index 0000000..f5a99a3 --- /dev/null +++ b/src/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy.ts @@ -0,0 +1,21 @@ +import { DetectedInputType } from './EmptyValueStrategy'; + +const APPLICABLE_TYPES: DetectedInputType[] = ['text', 'email', 'search', 'textarea']; + +export class SpecialCharsStrategy { + readonly name = 'SpecialCharsStrategy'; + + appliesTo(type: DetectedInputType): boolean { + return APPLICABLE_TYPES.includes(type); + } + + values(): string[] { + return [ + "' OR 1=1 --", + '', + '../../etc/passwd', + '${7*7}', + '\x00\x01\x02', + ]; + } +} diff --git a/src/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy.ts b/src/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy.ts new file mode 100644 index 0000000..7e3d5cd --- /dev/null +++ b/src/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy.ts @@ -0,0 +1,20 @@ +import { DetectedInputType } from './EmptyValueStrategy'; + +export class TypeMismatchStrategy { + readonly name = 'TypeMismatchStrategy'; + + appliesTo(type: DetectedInputType): boolean { + return ['email', 'number', 'date', 'url', 'phone'].includes(type); + } + + values(type: DetectedInputType): string[] { + switch (type) { + case 'email': return ['not-an-email', '12345', '@@@']; + case 'number': return ['abc', '-999999', '9.9.9', 'NaN']; + case 'date': return ['yesterday', '32/13/2025', '0000-00-00']; + case 'url': return ['javascript:alert(1)', 'not a url']; + case 'phone': return ['000', '++++', 'abcdefghij']; + default: return []; + } + } +} diff --git a/tests/modules/fuzzing.test.ts b/tests/modules/fuzzing.test.ts new file mode 100644 index 0000000..ab2c52b --- /dev/null +++ b/tests/modules/fuzzing.test.ts @@ -0,0 +1,319 @@ +/** + * Unit tests for the fuzzing module. + */ + +import { FuzzSession } from '../../src/modules/fuzzing/domain/entities/FuzzSession'; +import { FuzzResult } from '../../src/modules/fuzzing/domain/entities/FuzzResult'; +import { FuzzIntensity } from '../../src/modules/fuzzing/domain/value-objects/FuzzIntensity'; +import { FuzzStrategy } from '../../src/modules/fuzzing/domain/value-objects/FuzzStrategy'; +import { Seed } from '../../src/modules/fuzzing/domain/value-objects/Seed'; +import { FuzzPayload } from '../../src/modules/fuzzing/domain/value-objects/FuzzPayload'; +import { EmptyValueStrategy } from '../../src/modules/fuzzing/infrastructure/strategies/EmptyValueStrategy'; +import { OversizedStringStrategy } from '../../src/modules/fuzzing/infrastructure/strategies/OversizedStringStrategy'; +import { SpecialCharsStrategy } from '../../src/modules/fuzzing/infrastructure/strategies/SpecialCharsStrategy'; +import { TypeMismatchStrategy } from '../../src/modules/fuzzing/infrastructure/strategies/TypeMismatchStrategy'; +import { BoundaryValueStrategy } from '../../src/modules/fuzzing/infrastructure/strategies/BoundaryValueStrategy'; +import { FuzzingEngineAdapter } from '../../src/modules/fuzzing/infrastructure/adapters/FuzzingEngineAdapter'; +import { detectInputType } from '../../src/modules/fuzzing/infrastructure/adapters/InputTypeDetector'; +import { IState } from '../../src/core/interfaces'; + +function makeState(domSnapshot = ''): IState { + return { + id: 'state1', + url: 'http://test.com', + title: 'Test', + timestamp: Date.now(), + domSnapshot, + visitCount: 1, + }; +} + +// ─── Value Objects ──────────────────────────────────────────────────────────── + +describe('FuzzIntensity', () => { + it('creates low intensity', () => { + expect(FuzzIntensity.low().value).toBe('low'); + }); + + it('creates from string', () => { + expect(FuzzIntensity.fromString('high').value).toBe('high'); + }); + + it('throws for invalid intensity', () => { + expect(() => FuzzIntensity.fromString('extreme')).toThrow(); + }); + + it('equality works', () => { + expect(FuzzIntensity.medium().equals(FuzzIntensity.medium())).toBe(true); + expect(FuzzIntensity.low().equals(FuzzIntensity.high())).toBe(false); + }); +}); + +describe('FuzzStrategy', () => { + it('creates all strategies', () => { + expect(FuzzStrategy.empty().value).toBe('empty'); + expect(FuzzStrategy.specialChars().value).toBe('special_chars'); + expect(FuzzStrategy.boundary().value).toBe('boundary'); + }); + + it('throws for invalid strategy', () => { + expect(() => FuzzStrategy.fromString('invalid')).toThrow(); + }); +}); + +describe('Seed', () => { + it('creates valid seed', () => { + expect(Seed.create(42).value).toBe(42); + }); + + it('throws for negative seed', () => { + expect(() => Seed.create(-1)).toThrow(); + }); + + it('throws for non-integer', () => { + expect(() => Seed.create(1.5)).toThrow(); + }); + + it('creates from timestamp', () => { + const seed = Seed.fromTimestamp(); + expect(seed.value).toBeGreaterThan(0); + }); +}); + +describe('FuzzPayload', () => { + it('creates payload with value and strategy', () => { + const p = FuzzPayload.create("' OR 1=1 --", 'special_chars'); + expect(p.value).toBe("' OR 1=1 --"); + expect(p.strategy).toBe('special_chars'); + }); +}); + +// ─── FuzzSession Aggregate ─────────────────────────────────────────────────── + +describe('FuzzSession', () => { + it('creates a fuzz session with domain event', () => { + const result = FuzzSession.create({ + crawlSessionId: 'crawl-1', + intensity: 'medium', + seed: 12345, + }); + + if (!result.ok) throw new Error('Expected Ok'); + const session = result.value; + expect(session.intensity.value).toBe('medium'); + expect(session.seed.value).toBe(12345); + expect(session.status).toBe('running'); + expect(session.actionsExecuted).toBe(0); + expect(session.domainEvents).toHaveLength(1); + expect(session.domainEvents[0]!.eventName).toBe('fuzz.started'); + }); + + it('fails with invalid intensity', () => { + const result = FuzzSession.create({ + crawlSessionId: 'crawl-1', + intensity: 'extreme' as 'low', + seed: 1, + }); + expect(result.ok).toBe(false); + }); + + it('fails with negative seed', () => { + const result = FuzzSession.create({ + crawlSessionId: 'crawl-1', + intensity: 'low', + seed: -1, + }); + expect(result.ok).toBe(false); + }); + + it('tracks vulnerability detection', () => { + const r1 = FuzzSession.create({ crawlSessionId: 'c1', intensity: 'high', seed: 1 }); + if (!r1.ok) throw new Error('Expected Ok'); + const session = r1.value; + session.clearEvents(); + + const fuzzResult = FuzzResult.create({ + sessionId: session.id.toString(), + stateId: 'state1', + selector: '#email', + payload: "' OR 1=1 --", + strategy: 'special_chars', + anomalyType: 'xss_reflection', + severity: 'critical', + description: 'XSS reflected', + }); + + session.recordVulnerability(fuzzResult); + expect(session.vulnerabilitiesFound).toBe(1); + expect(session.actionsExecuted).toBe(1); + expect(session.domainEvents[0]!.eventName).toBe('fuzz.vulnerability_detected'); + }); + + it('completes session with event', () => { + const r2 = FuzzSession.create({ crawlSessionId: 'c1', intensity: 'low', seed: 1 }); + if (!r2.ok) throw new Error('Expected Ok'); + const session = r2.value; + session.clearEvents(); + session.complete(); + expect(session.status).toBe('completed'); + expect(session.completedAt).toBeDefined(); + expect(session.domainEvents[0]!.eventName).toBe('fuzz.completed'); + }); +}); + +// ─── Strategies ─────────────────────────────────────────────────────────────── + +describe('EmptyValueStrategy', () => { + const s = new EmptyValueStrategy(); + + it('applies to all types', () => { + expect(s.appliesTo('email')).toBe(true); + expect(s.appliesTo('number')).toBe(true); + expect(s.appliesTo('select')).toBe(true); + }); + + it('returns empty/whitespace values', () => { + const vals = s.values(); + expect(vals).toContain(''); + expect(vals).toContain(' '); + expect(vals).toContain('\t'); + }); +}); + +describe('OversizedStringStrategy', () => { + it('applies to text, email, password, textarea', () => { + const s = new OversizedStringStrategy('medium'); + expect(s.appliesTo('text')).toBe(true); + expect(s.appliesTo('email')).toBe(true); + expect(s.appliesTo('number')).toBe(false); + }); + + it('low = 256 chars', () => { + const s = new OversizedStringStrategy('low'); + expect(s.values()[0]?.length).toBe(256); + }); + + it('medium = 1024 chars', () => { + const s = new OversizedStringStrategy('medium'); + expect(s.values()[0]?.length).toBe(1024); + }); + + it('high = 10000+ chars', () => { + const s = new OversizedStringStrategy('high'); + expect(s.values()[0]!.length).toBeGreaterThan(10000); + }); +}); + +describe('SpecialCharsStrategy', () => { + const s = new SpecialCharsStrategy(); + + it('applies to text, email, search, textarea', () => { + expect(s.appliesTo('text')).toBe(true); + expect(s.appliesTo('email')).toBe(true); + expect(s.appliesTo('number')).toBe(false); + }); + + it('includes SQL injection payload', () => { + expect(s.values()).toContain("' OR 1=1 --"); + }); + + it('includes XSS payload', () => { + expect(s.values()).toContain(''); + }); +}); + +describe('TypeMismatchStrategy', () => { + const s = new TypeMismatchStrategy(); + + it('applies to typed fields', () => { + expect(s.appliesTo('email')).toBe(true); + expect(s.appliesTo('number')).toBe(true); + expect(s.appliesTo('text')).toBe(false); + }); + + it('returns mismatch for email', () => { + expect(s.values('email')).toContain('not-an-email'); + }); + + it('returns mismatch for number', () => { + expect(s.values('number')).toContain('abc'); + }); +}); + +describe('BoundaryValueStrategy', () => { + const s = new BoundaryValueStrategy(); + + it('applies to number and date only', () => { + expect(s.appliesTo('number')).toBe(true); + expect(s.appliesTo('date')).toBe(true); + expect(s.appliesTo('text')).toBe(false); + }); + + it('returns boundary numbers', () => { + const vals = s.values('number'); + expect(vals).toContain('0'); + expect(vals).toContain('2147483647'); + expect(vals).toContain('-2147483648'); + }); + + it('returns boundary dates', () => { + const vals = s.values('date'); + expect(vals).toContain('1900-01-01'); + expect(vals).toContain('2099-12-31'); + }); +}); + +// ─── InputTypeDetector ──────────────────────────────────────────────────────── + +describe('detectInputType (module adapter)', () => { + it('detects email from inputType', () => { + expect(detectInputType({ inputType: 'email' })).toBe('email'); + }); + + it('detects password', () => { + expect(detectInputType({ inputType: 'password' })).toBe('password'); + }); + + it('detects textarea', () => { + expect(detectInputType({ tagName: 'textarea' })).toBe('textarea'); + }); + + it('infers email from name', () => { + expect(detectInputType({ name: 'email_address' })).toBe('email'); + }); + + it('falls back to text', () => { + expect(detectInputType({})).toBe('text'); + }); +}); + +// ─── FuzzingEngineAdapter ───────────────────────────────────────────────────── + +describe('FuzzingEngineAdapter', () => { + it('generates fuzz actions from DOM with input fields', () => { + const engine = new FuzzingEngineAdapter({ intensity: 'low', seed: 42 }); + const dom = `
`; + const state = makeState(dom); + const actions = engine.generateFuzzActions(dom, state); + expect(actions.length).toBeGreaterThan(0); + expect(actions.every((a) => a.type === 'fill')).toBe(true); + expect(actions.every((a) => a.stateId === 'state1')).toBe(true); + }); + + it('generates more actions at high intensity', () => { + const low = new FuzzingEngineAdapter({ intensity: 'low', seed: 1 }); + const high = new FuzzingEngineAdapter({ intensity: 'high', seed: 1 }); + const dom = ``; + const state = makeState(dom); + expect(high.generateFuzzActions(dom, state).length).toBeGreaterThan( + low.generateFuzzActions(dom, state).length + ); + }); + + it('returns empty for DOM with no inputs', () => { + const engine = new FuzzingEngineAdapter({ intensity: 'medium', seed: 1 }); + const dom = `

No forms here

`; + const state = makeState(dom); + expect(engine.generateFuzzActions(dom, state)).toHaveLength(0); + }); +});