@atlascrew/apparatus 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/apparatus.mjs +2 -0
- package/certs/server.crt +17 -0
- package/certs/server.key +28 -0
- package/dist/ai/client.js +104 -0
- package/dist/ai/client.js.map +1 -0
- package/dist/ai/personas.js +104 -0
- package/dist/ai/personas.js.map +1 -0
- package/dist/ai/redteam.js +1404 -0
- package/dist/ai/redteam.js.map +1 -0
- package/dist/ai/report-store.js +309 -0
- package/dist/ai/report-store.js.map +1 -0
- package/dist/app.js +525 -0
- package/dist/app.js.map +1 -0
- package/dist/attack-sim.js +69 -0
- package/dist/attack-sim.js.map +1 -0
- package/dist/attacker-tracker.js +276 -0
- package/dist/attacker-tracker.js.map +1 -0
- package/dist/blackhole.js +95 -0
- package/dist/blackhole.js.map +1 -0
- package/dist/chaos.js +88 -0
- package/dist/chaos.js.map +1 -0
- package/dist/cluster.js +462 -0
- package/dist/cluster.js.map +1 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/deception.js +205 -0
- package/dist/deception.js.map +1 -0
- package/dist/demo-mode.js +109 -0
- package/dist/demo-mode.js.map +1 -0
- package/dist/dist-dashboard/assets/index-BsMhEnGu.js +648 -0
- package/dist/dist-dashboard/assets/index-CNOkYC_Q.css +10 -0
- package/dist/dist-dashboard/assets/index-CW2grvPC.js +648 -0
- package/dist/dist-dashboard/assets/logo/apparatus-favicon.svg +15 -0
- package/dist/dist-dashboard/assets/logo/apparatus-icon-dark.svg +24 -0
- package/dist/dist-dashboard/assets/logo/apparatus-icon-light.svg +24 -0
- package/dist/dist-dashboard/assets/logo/apparatus-logo-512.png +0 -0
- package/dist/dist-dashboard/assets/logo/apparatus-logo-dark.svg +18 -0
- package/dist/dist-dashboard/assets/logo/apparatus-logo.svg +17 -0
- package/dist/dist-dashboard/assets/logo/apple-touch-icon.png +0 -0
- package/dist/dist-dashboard/assets/logo/favicon-192.png +0 -0
- package/dist/dist-dashboard/assets/logo/favicon-32.png +0 -0
- package/dist/dist-dashboard/assets/logo/favicon.ico +0 -0
- package/dist/dist-dashboard/assets/logo/icon-192.png +0 -0
- package/dist/dist-dashboard/assets/logo/icon-512.png +0 -0
- package/dist/dist-dashboard/assets/logo/icon-light-512.png +0 -0
- package/dist/dist-dashboard/assets/react-vendor-DpRMSntD.js +1 -0
- package/dist/dist-dashboard/assets/router-DSc5pRwN.js +59 -0
- package/dist/dist-dashboard/docs-index.json +1577 -0
- package/dist/dist-dashboard/index.html +21 -0
- package/dist/dlp.js +40 -0
- package/dist/dlp.js.map +1 -0
- package/dist/drills.js +770 -0
- package/dist/drills.js.map +1 -0
- package/dist/echoHandler.js +113 -0
- package/dist/echoHandler.js.map +1 -0
- package/dist/escape/index.js +225 -0
- package/dist/escape/index.js.map +1 -0
- package/dist/escape/methods/dns.js +74 -0
- package/dist/escape/methods/dns.js.map +1 -0
- package/dist/escape/methods/http.js +81 -0
- package/dist/escape/methods/http.js.map +1 -0
- package/dist/escape/methods/icmp.js +36 -0
- package/dist/escape/methods/icmp.js.map +1 -0
- package/dist/escape/methods/tcp.js +38 -0
- package/dist/escape/methods/tcp.js.map +1 -0
- package/dist/escape/methods/udp.js +27 -0
- package/dist/escape/methods/udp.js.map +1 -0
- package/dist/escape/methods/websocket.js +37 -0
- package/dist/escape/methods/websocket.js.map +1 -0
- package/dist/forensics.js +111 -0
- package/dist/forensics.js.map +1 -0
- package/dist/generator.js +67 -0
- package/dist/generator.js.map +1 -0
- package/dist/ghosting.js +414 -0
- package/dist/ghosting.js.map +1 -0
- package/dist/graphql.js +44 -0
- package/dist/graphql.js.map +1 -0
- package/dist/history.js +40 -0
- package/dist/history.js.map +1 -0
- package/dist/imposter/creds.js +16 -0
- package/dist/imposter/creds.js.map +1 -0
- package/dist/imposter/index.js +44 -0
- package/dist/imposter/index.js.map +1 -0
- package/dist/imposter/providers/aws.js +103 -0
- package/dist/imposter/providers/aws.js.map +1 -0
- package/dist/imposter/providers/gcp.js +26 -0
- package/dist/imposter/providers/gcp.js.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/infra-debug.js +68 -0
- package/dist/infra-debug.js.map +1 -0
- package/dist/jwt-debug.js +272 -0
- package/dist/jwt-debug.js.map +1 -0
- package/dist/kv.js +22 -0
- package/dist/kv.js.map +1 -0
- package/dist/lib/generators.js +43 -0
- package/dist/lib/generators.js.map +1 -0
- package/dist/lib/json.js +26 -0
- package/dist/lib/json.js.map +1 -0
- package/dist/logger.js +9 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.js +20 -0
- package/dist/metrics.js.map +1 -0
- package/dist/mtd.js +30 -0
- package/dist/mtd.js.map +1 -0
- package/dist/oidc.js +69 -0
- package/dist/oidc.js.map +1 -0
- package/dist/persistence/cluster-state.js +47 -0
- package/dist/persistence/cluster-state.js.map +1 -0
- package/dist/persistence/deception-history.js +65 -0
- package/dist/persistence/deception-history.js.map +1 -0
- package/dist/persistence/drill-runs.js +138 -0
- package/dist/persistence/drill-runs.js.map +1 -0
- package/dist/persistence/request-history.js +41 -0
- package/dist/persistence/request-history.js.map +1 -0
- package/dist/persistence/scenario-catalog.js +73 -0
- package/dist/persistence/scenario-catalog.js.map +1 -0
- package/dist/persistence/status.js +51 -0
- package/dist/persistence/status.js.map +1 -0
- package/dist/persistence/tarpit-state.js +47 -0
- package/dist/persistence/tarpit-state.js.map +1 -0
- package/dist/persistence/webhook-store.js +69 -0
- package/dist/persistence/webhook-store.js.map +1 -0
- package/dist/proxy.js +28 -0
- package/dist/proxy.js.map +1 -0
- package/dist/ratelimit.js +32 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/redteam.js +442 -0
- package/dist/redteam.js.map +1 -0
- package/dist/scenarios.js +229 -0
- package/dist/scenarios.js.map +1 -0
- package/dist/scripting.js +30 -0
- package/dist/scripting.js.map +1 -0
- package/dist/self-healing.js +42 -0
- package/dist/self-healing.js.map +1 -0
- package/dist/sentinel.js +50 -0
- package/dist/sentinel.js.map +1 -0
- package/dist/server-bad-ssl.js +47 -0
- package/dist/server-bad-ssl.js.map +1 -0
- package/dist/server-grpc.js +66 -0
- package/dist/server-grpc.js.map +1 -0
- package/dist/server-http1.js +5 -0
- package/dist/server-http1.js.map +1 -0
- package/dist/server-http2.js +27 -0
- package/dist/server-http2.js.map +1 -0
- package/dist/server-icap.js +46 -0
- package/dist/server-icap.js.map +1 -0
- package/dist/server-l4.js +30 -0
- package/dist/server-l4.js.map +1 -0
- package/dist/server-mqtt.js +29 -0
- package/dist/server-mqtt.js.map +1 -0
- package/dist/server-protocols.js +18 -0
- package/dist/server-protocols.js.map +1 -0
- package/dist/server-redis.js +112 -0
- package/dist/server-redis.js.map +1 -0
- package/dist/server-smtp.js +66 -0
- package/dist/server-smtp.js.map +1 -0
- package/dist/server-syslog.js +23 -0
- package/dist/server-syslog.js.map +1 -0
- package/dist/server-ws.js +18 -0
- package/dist/server-ws.js.map +1 -0
- package/dist/sidecar/chaos/engine.js +41 -0
- package/dist/sidecar/chaos/engine.js.map +1 -0
- package/dist/sidecar/index.js +98 -0
- package/dist/sidecar/index.js.map +1 -0
- package/dist/simulator/dependency-graph.js +102 -0
- package/dist/simulator/dependency-graph.js.map +1 -0
- package/dist/simulator/supply-chain.js +67 -0
- package/dist/simulator/supply-chain.js.map +1 -0
- package/dist/sink.js +24 -0
- package/dist/sink.js.map +1 -0
- package/dist/sse-broadcast.js +105 -0
- package/dist/sse-broadcast.js.map +1 -0
- package/dist/swagger.js +309 -0
- package/dist/swagger.js.map +1 -0
- package/dist/sysinfo.js +36 -0
- package/dist/sysinfo.js.map +1 -0
- package/dist/tarpit.js +126 -0
- package/dist/tarpit.js.map +1 -0
- package/dist/tool-executor.js +315 -0
- package/dist/tool-executor.js.map +1 -0
- package/dist/tui/api-client.js +341 -0
- package/dist/tui/api-client.js.map +1 -0
- package/dist/tui/core/action-handler.js +302 -0
- package/dist/tui/core/action-handler.js.map +1 -0
- package/dist/tui/core/index.js +18 -0
- package/dist/tui/core/index.js.map +1 -0
- package/dist/tui/core/keyboard.js +329 -0
- package/dist/tui/core/keyboard.js.map +1 -0
- package/dist/tui/core/modal.js +397 -0
- package/dist/tui/core/modal.js.map +1 -0
- package/dist/tui/core/screen-manager.js +262 -0
- package/dist/tui/core/screen-manager.js.map +1 -0
- package/dist/tui/core/store.js +254 -0
- package/dist/tui/core/store.js.map +1 -0
- package/dist/tui/core/widget.js +167 -0
- package/dist/tui/core/widget.js.map +1 -0
- package/dist/tui/dashboard.js +649 -0
- package/dist/tui/dashboard.js.map +1 -0
- package/dist/tui/index.js +118 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/modals/add-rule-modal.js +190 -0
- package/dist/tui/modals/add-rule-modal.js.map +1 -0
- package/dist/tui/modals/dlp-output-modal.js +102 -0
- package/dist/tui/modals/dlp-output-modal.js.map +1 -0
- package/dist/tui/modals/dns-form-modal.js +26 -0
- package/dist/tui/modals/dns-form-modal.js.map +1 -0
- package/dist/tui/modals/ghost-config-modal.js +35 -0
- package/dist/tui/modals/ghost-config-modal.js.map +1 -0
- package/dist/tui/modals/har-results-modal.js +41 -0
- package/dist/tui/modals/har-results-modal.js.map +1 -0
- package/dist/tui/modals/index.js +15 -0
- package/dist/tui/modals/index.js.map +1 -0
- package/dist/tui/modals/jwt-decode-modal.js +45 -0
- package/dist/tui/modals/jwt-decode-modal.js.map +1 -0
- package/dist/tui/modals/jwt-mint-modal.js +70 -0
- package/dist/tui/modals/jwt-mint-modal.js.map +1 -0
- package/dist/tui/modals/ping-form-modal.js +19 -0
- package/dist/tui/modals/ping-form-modal.js.map +1 -0
- package/dist/tui/modals/redteam-results-modal.js +43 -0
- package/dist/tui/modals/redteam-results-modal.js.map +1 -0
- package/dist/tui/modals/scan-form-modal.js +26 -0
- package/dist/tui/modals/scan-form-modal.js.map +1 -0
- package/dist/tui/screens/defense-screen.js +281 -0
- package/dist/tui/screens/defense-screen.js.map +1 -0
- package/dist/tui/screens/forensics-screen.js +81 -0
- package/dist/tui/screens/forensics-screen.js.map +1 -0
- package/dist/tui/screens/index.js +140 -0
- package/dist/tui/screens/index.js.map +1 -0
- package/dist/tui/screens/system-screen.js +81 -0
- package/dist/tui/screens/system-screen.js.map +1 -0
- package/dist/tui/screens/testing-screen.js +429 -0
- package/dist/tui/screens/testing-screen.js.map +1 -0
- package/dist/tui/screens/traffic-screen.js +76 -0
- package/dist/tui/screens/traffic-screen.js.map +1 -0
- package/dist/tui/sse-client.js +130 -0
- package/dist/tui/sse-client.js.map +1 -0
- package/dist/tui/state/metrics-buffer.js +195 -0
- package/dist/tui/state/metrics-buffer.js.map +1 -0
- package/dist/tui/state/metrics-buffer.test.js +102 -0
- package/dist/tui/state/metrics-buffer.test.js.map +1 -0
- package/dist/tui/theme.js +136 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/types.js +6 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/tui/widgets/chaos-widget.js +152 -0
- package/dist/tui/widgets/chaos-widget.js.map +1 -0
- package/dist/tui/widgets/cluster-widget.js +156 -0
- package/dist/tui/widgets/cluster-widget.js.map +1 -0
- package/dist/tui/widgets/dlp-widget.js +161 -0
- package/dist/tui/widgets/dlp-widget.js.map +1 -0
- package/dist/tui/widgets/ghost-widget.js +169 -0
- package/dist/tui/widgets/ghost-widget.js.map +1 -0
- package/dist/tui/widgets/har-widget.js +173 -0
- package/dist/tui/widgets/har-widget.js.map +1 -0
- package/dist/tui/widgets/index.js +122 -0
- package/dist/tui/widgets/index.js.map +1 -0
- package/dist/tui/widgets/jwt-widget.js +177 -0
- package/dist/tui/widgets/jwt-widget.js.map +1 -0
- package/dist/tui/widgets/kv-widget.js +261 -0
- package/dist/tui/widgets/kv-widget.js.map +1 -0
- package/dist/tui/widgets/mtd-widget.js +181 -0
- package/dist/tui/widgets/mtd-widget.js.map +1 -0
- package/dist/tui/widgets/netdiag-widget.js +155 -0
- package/dist/tui/widgets/netdiag-widget.js.map +1 -0
- package/dist/tui/widgets/oidc-widget.js +162 -0
- package/dist/tui/widgets/oidc-widget.js.map +1 -0
- package/dist/tui/widgets/pcap-widget.js +239 -0
- package/dist/tui/widgets/pcap-widget.js.map +1 -0
- package/dist/tui/widgets/redteam-widget.js +155 -0
- package/dist/tui/widgets/redteam-widget.js.map +1 -0
- package/dist/tui/widgets/rps-gauge-widget.js +124 -0
- package/dist/tui/widgets/rps-gauge-widget.js.map +1 -0
- package/dist/tui/widgets/sentinel-widget.js +171 -0
- package/dist/tui/widgets/sentinel-widget.js.map +1 -0
- package/dist/tui/widgets/sparklines-widget.js +127 -0
- package/dist/tui/widgets/sparklines-widget.js.map +1 -0
- package/dist/tui/widgets/sysinfo-widget.js +197 -0
- package/dist/tui/widgets/sysinfo-widget.js.map +1 -0
- package/dist/tui/widgets/traffic-chart-widget.js +170 -0
- package/dist/tui/widgets/traffic-chart-widget.js.map +1 -0
- package/dist/tui/widgets/webhook-widget.js +259 -0
- package/dist/tui/widgets/webhook-widget.js.map +1 -0
- package/dist/utils/ip.js +18 -0
- package/dist/utils/ip.js.map +1 -0
- package/dist/victim/index.js +71 -0
- package/dist/victim/index.js.map +1 -0
- package/dist/webhook.js +88 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +90 -0
- package/proto/echo.proto +19 -0
package/dist/drills.js
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { request } from "undici";
|
|
3
|
+
import { executeToolStep, stopAllActiveExperiments } from "./tool-executor.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
import { sseBroadcaster } from "./sse-broadcast.js";
|
|
6
|
+
import { isClusterAttackActive } from "./cluster.js";
|
|
7
|
+
import { getGhostStatus, startGhostTraffic, stopGhostTraffic } from "./ghosting.js";
|
|
8
|
+
import { cfg } from "./config.js";
|
|
9
|
+
import { loadDrillRunsState, writeDrillRunsState } from "./persistence/drill-runs.js";
|
|
10
|
+
import { markPersistenceHydrated, markPersistenceWrite, registerPersistenceStore } from "./persistence/status.js";
|
|
11
|
+
const MAX_RUNS = 500;
|
|
12
|
+
const MAX_TIMELINE_EVENTS = 300;
|
|
13
|
+
const REQUEST_WINDOW_MS = 30000;
|
|
14
|
+
const MAX_REQUEST_SAMPLES = 5000;
|
|
15
|
+
const METRIC_TIMELINE_INTERVAL_MS = 5000;
|
|
16
|
+
const TERMINAL_STATUSES = new Set(["won", "failed", "cancelled"]);
|
|
17
|
+
const LIVE_STATUSES = new Set(["pending", "arming", "active", "stabilizing"]);
|
|
18
|
+
const DEFAULT_SQLI_PAYLOADS = [
|
|
19
|
+
"admin' OR '1'='1",
|
|
20
|
+
"' OR 1=1",
|
|
21
|
+
"admin') OR ('1'='1",
|
|
22
|
+
"1' OR '1'='1",
|
|
23
|
+
];
|
|
24
|
+
const drillDefinitions = new Map();
|
|
25
|
+
const drillRuns = new Map();
|
|
26
|
+
const latestRunByDrill = new Map();
|
|
27
|
+
const drillRunContexts = new Map();
|
|
28
|
+
const requestSamples = [];
|
|
29
|
+
let drillRunsHydrationPromise = null;
|
|
30
|
+
let drillRunsPersistQueue = Promise.resolve(true);
|
|
31
|
+
const DRILL_STORE_KEY = "drillRuns";
|
|
32
|
+
registerPersistenceStore(DRILL_STORE_KEY, cfg.drillRunsPath);
|
|
33
|
+
let requestListenerAttached = false;
|
|
34
|
+
const BUILTIN_DRILLS = [
|
|
35
|
+
{
|
|
36
|
+
id: "drill-cpu-leak-jr",
|
|
37
|
+
name: "CPU Leak Containment",
|
|
38
|
+
description: "Stabilize an overloaded node and confirm service health recovers.",
|
|
39
|
+
difficulty: "junior",
|
|
40
|
+
tags: ["reliability"],
|
|
41
|
+
briefing: "Operators report latency spikes and unstable host performance. Contain and stabilize the environment.",
|
|
42
|
+
stressors: [
|
|
43
|
+
{
|
|
44
|
+
id: "stressor-memory",
|
|
45
|
+
kind: "tool",
|
|
46
|
+
step: {
|
|
47
|
+
id: "memory-allocate",
|
|
48
|
+
action: "chaos.memory",
|
|
49
|
+
params: { action: "allocate", amount: 256 },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "stressor-cpu",
|
|
54
|
+
kind: "tool",
|
|
55
|
+
step: {
|
|
56
|
+
id: "cpu-spike",
|
|
57
|
+
action: "chaos.cpu",
|
|
58
|
+
params: { duration: 3000 },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
winConditions: [
|
|
63
|
+
{ kind: "cpu_percent", op: "<=", value: 100, sustainSec: 2 },
|
|
64
|
+
{ kind: "error_rate", op: "<=", value: 0.25, sustainSec: 2 },
|
|
65
|
+
{ kind: "detected_marked", op: "==", value: true, sustainSec: 2 },
|
|
66
|
+
],
|
|
67
|
+
hintLadder: [
|
|
68
|
+
{ atSec: 20, title: "Hint 1", body: "Check system pressure and recent traffic behavior before applying mitigation." },
|
|
69
|
+
{ atSec: 45, title: "Hint 2", body: "Mark detection once you identify likely root cause to proceed with validation." },
|
|
70
|
+
],
|
|
71
|
+
maxDurationSec: 180,
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "drill-ddos-sr",
|
|
76
|
+
name: "Volumetric Traffic Spike",
|
|
77
|
+
description: "Investigate sustained request surge and recover service stability.",
|
|
78
|
+
difficulty: "senior",
|
|
79
|
+
tags: ["traffic"],
|
|
80
|
+
briefing: "Edge traffic volume has surged unexpectedly. Identify whether this is malicious and restore stable response behavior.",
|
|
81
|
+
stressors: [
|
|
82
|
+
{
|
|
83
|
+
id: "stressor-ghost-start",
|
|
84
|
+
kind: "ghost.start",
|
|
85
|
+
delayMs: 80,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
winConditions: [
|
|
89
|
+
{ kind: "ghost_traffic_active", op: "==", value: false, sustainSec: 5 },
|
|
90
|
+
{ kind: "error_rate", op: "<=", value: 0.35, sustainSec: 5 },
|
|
91
|
+
{ kind: "detected_marked", op: "==", value: true, sustainSec: 5 },
|
|
92
|
+
],
|
|
93
|
+
failConditions: [
|
|
94
|
+
{ kind: "error_rate", op: ">=", value: 0.95, sustainSec: 0 },
|
|
95
|
+
],
|
|
96
|
+
hintLadder: [
|
|
97
|
+
{ atSec: 30, title: "Hint 1", body: "Traffic-focused drills often need both triage and containment actions." },
|
|
98
|
+
{ atSec: 70, title: "Hint 2", body: "Use defense and traffic modules together to verify pressure actually drops." },
|
|
99
|
+
{ atSec: 110, title: "Hint 3", body: "A stable win requires sustained recovery, not one-time improvement." },
|
|
100
|
+
],
|
|
101
|
+
maxDurationSec: 300,
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "drill-sqli-principal",
|
|
106
|
+
name: "SQLi Exfil Attempt",
|
|
107
|
+
description: "Detect probing and improve block effectiveness under active injection traffic.",
|
|
108
|
+
difficulty: "principal",
|
|
109
|
+
tags: ["appsec"],
|
|
110
|
+
briefing: "Suspicious query payloads suggest SQLi reconnaissance. Correlate evidence and raise block efficacy.",
|
|
111
|
+
stressors: [
|
|
112
|
+
{
|
|
113
|
+
id: "stressor-seed-sqli",
|
|
114
|
+
kind: "seed.sqli",
|
|
115
|
+
delayMs: 220,
|
|
116
|
+
durationSec: 180,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "stressor-noise",
|
|
120
|
+
kind: "ghost.start",
|
|
121
|
+
delayMs: 180,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
winConditions: [
|
|
125
|
+
{ kind: "blocked_sqli_ratio", op: ">=", value: 0.75, sustainSec: 8 },
|
|
126
|
+
{ kind: "detected_marked", op: "==", value: true, sustainSec: 8 },
|
|
127
|
+
],
|
|
128
|
+
failConditions: [
|
|
129
|
+
{ kind: "error_rate", op: ">=", value: 0.98, sustainSec: 0 },
|
|
130
|
+
],
|
|
131
|
+
hintLadder: [
|
|
132
|
+
{ atSec: 35, title: "Hint 1", body: "Start by confirming signals across both deception and traffic views." },
|
|
133
|
+
{ atSec: 75, title: "Hint 2", body: "Improve block precision; over-broad filters can disrupt normal operations." },
|
|
134
|
+
{ atSec: 130, title: "Hint 3", body: "Track effective block ratio over time before declaring incident stable." },
|
|
135
|
+
],
|
|
136
|
+
maxDurationSec: 420,
|
|
137
|
+
createdAt: new Date().toISOString(),
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
function initDrills() {
|
|
141
|
+
if (drillDefinitions.size > 0)
|
|
142
|
+
return;
|
|
143
|
+
for (const drill of BUILTIN_DRILLS) {
|
|
144
|
+
drillDefinitions.set(drill.id, drill);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function normalizePersistedDrillRun(run) {
|
|
148
|
+
if (!drillDefinitions.has(run.drillId))
|
|
149
|
+
return null;
|
|
150
|
+
const normalized = {
|
|
151
|
+
runId: run.runId,
|
|
152
|
+
drillId: run.drillId,
|
|
153
|
+
drillName: run.drillName,
|
|
154
|
+
status: run.status,
|
|
155
|
+
startedAt: run.startedAt,
|
|
156
|
+
finishedAt: run.finishedAt,
|
|
157
|
+
detectedAt: run.detectedAt,
|
|
158
|
+
mitigatedAt: run.mitigatedAt,
|
|
159
|
+
failureReason: run.failureReason,
|
|
160
|
+
timeline: run.timeline.map((event) => ({
|
|
161
|
+
at: event.at,
|
|
162
|
+
type: event.type,
|
|
163
|
+
message: event.message,
|
|
164
|
+
data: event.data,
|
|
165
|
+
})),
|
|
166
|
+
lastSnapshot: run.lastSnapshot,
|
|
167
|
+
score: run.score,
|
|
168
|
+
};
|
|
169
|
+
if (LIVE_STATUSES.has(normalized.status)) {
|
|
170
|
+
normalized.status = "failed";
|
|
171
|
+
normalized.finishedAt = normalized.finishedAt || new Date().toISOString();
|
|
172
|
+
normalized.failureReason = normalized.failureReason || "Drill run interrupted by restart";
|
|
173
|
+
appendTimeline(normalized, "system", "Run marked failed after restart recovery");
|
|
174
|
+
normalized.score = calculateScore(normalized, normalized.status);
|
|
175
|
+
}
|
|
176
|
+
return normalized;
|
|
177
|
+
}
|
|
178
|
+
function snapshotDrillRunsState() {
|
|
179
|
+
return {
|
|
180
|
+
runs: Array.from(drillRuns.values()),
|
|
181
|
+
latestRunByDrill: Object.fromEntries(latestRunByDrill.entries()),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async function persistDrillRunsStateQueued() {
|
|
185
|
+
drillRunsPersistQueue = drillRunsPersistQueue.then(() => writeDrillRunsState(cfg.drillRunsPath, snapshotDrillRunsState()), () => writeDrillRunsState(cfg.drillRunsPath, snapshotDrillRunsState()));
|
|
186
|
+
const persisted = await drillRunsPersistQueue;
|
|
187
|
+
markPersistenceWrite(DRILL_STORE_KEY, persisted);
|
|
188
|
+
return persisted;
|
|
189
|
+
}
|
|
190
|
+
async function hydrateDrillRunsState() {
|
|
191
|
+
const persistedState = await loadDrillRunsState(cfg.drillRunsPath);
|
|
192
|
+
for (const run of persistedState.runs) {
|
|
193
|
+
const normalizedRun = normalizePersistedDrillRun(run);
|
|
194
|
+
if (!normalizedRun)
|
|
195
|
+
continue;
|
|
196
|
+
drillRuns.set(normalizedRun.runId, normalizedRun);
|
|
197
|
+
if (drillRuns.size > MAX_RUNS) {
|
|
198
|
+
const oldestRunId = drillRuns.keys().next().value;
|
|
199
|
+
if (oldestRunId) {
|
|
200
|
+
drillRuns.delete(oldestRunId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
latestRunByDrill.clear();
|
|
205
|
+
for (const [drillId, runId] of Object.entries(persistedState.latestRunByDrill)) {
|
|
206
|
+
const run = drillRuns.get(runId);
|
|
207
|
+
if (run && run.drillId === drillId) {
|
|
208
|
+
latestRunByDrill.set(drillId, runId);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
markPersistenceHydrated(DRILL_STORE_KEY);
|
|
212
|
+
}
|
|
213
|
+
async function ensureDrillStateHydrated() {
|
|
214
|
+
initDrills();
|
|
215
|
+
if (!drillRunsHydrationPromise) {
|
|
216
|
+
drillRunsHydrationPromise = hydrateDrillRunsState();
|
|
217
|
+
}
|
|
218
|
+
await drillRunsHydrationPromise;
|
|
219
|
+
}
|
|
220
|
+
function attachRequestListener() {
|
|
221
|
+
if (requestListenerAttached)
|
|
222
|
+
return;
|
|
223
|
+
requestListenerAttached = true;
|
|
224
|
+
sseBroadcaster.on("request", (event) => {
|
|
225
|
+
const status = extractStatusCode(event);
|
|
226
|
+
if (status === null)
|
|
227
|
+
return;
|
|
228
|
+
requestSamples.push({ ts: Date.now(), status });
|
|
229
|
+
if (requestSamples.length > MAX_REQUEST_SAMPLES) {
|
|
230
|
+
requestSamples.splice(0, requestSamples.length - MAX_REQUEST_SAMPLES);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function extractStatusCode(event) {
|
|
235
|
+
if (!event || typeof event !== "object")
|
|
236
|
+
return null;
|
|
237
|
+
const maybeEvent = event;
|
|
238
|
+
if (typeof maybeEvent.status === "number") {
|
|
239
|
+
return maybeEvent.status;
|
|
240
|
+
}
|
|
241
|
+
if (!maybeEvent.data || typeof maybeEvent.data !== "object")
|
|
242
|
+
return null;
|
|
243
|
+
const nested = maybeEvent.data;
|
|
244
|
+
return typeof nested.status === "number" ? nested.status : null;
|
|
245
|
+
}
|
|
246
|
+
function appendTimeline(run, type, message, data) {
|
|
247
|
+
run.timeline.push({
|
|
248
|
+
at: new Date().toISOString(),
|
|
249
|
+
type,
|
|
250
|
+
message,
|
|
251
|
+
data,
|
|
252
|
+
});
|
|
253
|
+
if (run.timeline.length > MAX_TIMELINE_EVENTS) {
|
|
254
|
+
run.timeline.splice(0, run.timeline.length - MAX_TIMELINE_EVENTS);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function elapsedSec(run, nowMs = Date.now()) {
|
|
258
|
+
const startedAtMs = Date.parse(run.startedAt);
|
|
259
|
+
return Math.max(0, Math.round((nowMs - startedAtMs) / 1000));
|
|
260
|
+
}
|
|
261
|
+
function compareNumber(actual, op, expected) {
|
|
262
|
+
switch (op) {
|
|
263
|
+
case "<":
|
|
264
|
+
return actual < expected;
|
|
265
|
+
case ">":
|
|
266
|
+
return actual > expected;
|
|
267
|
+
case "<=":
|
|
268
|
+
return actual <= expected;
|
|
269
|
+
case ">=":
|
|
270
|
+
return actual >= expected;
|
|
271
|
+
default:
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function compareBoolean(actual, op, expected) {
|
|
276
|
+
if (op === "==") {
|
|
277
|
+
return actual === expected;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
function pruneRequestSamples(nowMs) {
|
|
282
|
+
const floor = nowMs - REQUEST_WINDOW_MS;
|
|
283
|
+
while (requestSamples.length > 0 && requestSamples[0].ts < floor) {
|
|
284
|
+
requestSamples.shift();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function collectSnapshot(run, context) {
|
|
288
|
+
const cpus = Math.max(1, os.cpus().length);
|
|
289
|
+
const loadAverage = os.loadavg()[0] || 0;
|
|
290
|
+
const cpuPercent = Math.max(0, Math.min(100, (loadAverage / cpus) * 100));
|
|
291
|
+
const nowMs = Date.now();
|
|
292
|
+
pruneRequestSamples(nowMs);
|
|
293
|
+
const total = requestSamples.length;
|
|
294
|
+
const errors = requestSamples.filter((sample) => sample.status >= 500).length;
|
|
295
|
+
const errorRate = total === 0 ? 0 : errors / total;
|
|
296
|
+
const blockedSqliRatio = context.sqliAttempted === 0
|
|
297
|
+
? 0
|
|
298
|
+
: context.sqliBlocked / context.sqliAttempted;
|
|
299
|
+
return {
|
|
300
|
+
cpuPercent,
|
|
301
|
+
errorRate,
|
|
302
|
+
blockedSqliRatio,
|
|
303
|
+
detectedMarked: Boolean(run.detectedAt),
|
|
304
|
+
clusterAttackActive: isClusterAttackActive(),
|
|
305
|
+
ghostTrafficActive: getGhostStatus() === "running",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function evaluateCondition(condition, snapshot) {
|
|
309
|
+
switch (condition.kind) {
|
|
310
|
+
case "cpu_percent":
|
|
311
|
+
return compareNumber(snapshot.cpuPercent, condition.op, condition.value);
|
|
312
|
+
case "error_rate":
|
|
313
|
+
return compareNumber(snapshot.errorRate, condition.op, condition.value);
|
|
314
|
+
case "blocked_sqli_ratio":
|
|
315
|
+
return compareNumber(snapshot.blockedSqliRatio, condition.op, condition.value);
|
|
316
|
+
case "detected_marked":
|
|
317
|
+
return compareBoolean(snapshot.detectedMarked, condition.op, condition.value);
|
|
318
|
+
case "cluster_attack_active":
|
|
319
|
+
return compareBoolean(snapshot.clusterAttackActive, condition.op, condition.value);
|
|
320
|
+
case "ghost_traffic_active":
|
|
321
|
+
return compareBoolean(snapshot.ghostTrafficActive, condition.op, condition.value);
|
|
322
|
+
default:
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function requiredSustainMs(conditions) {
|
|
327
|
+
const maxSustainSec = conditions.reduce((max, condition) => Math.max(max, condition.sustainSec), 0);
|
|
328
|
+
return maxSustainSec * 1000;
|
|
329
|
+
}
|
|
330
|
+
function getRunIdFromRequest(req, drillId) {
|
|
331
|
+
if (typeof req.query.runId === "string") {
|
|
332
|
+
return req.query.runId;
|
|
333
|
+
}
|
|
334
|
+
const body = req.body;
|
|
335
|
+
if (body && typeof body.runId === "string") {
|
|
336
|
+
return body.runId;
|
|
337
|
+
}
|
|
338
|
+
return latestRunByDrill.get(drillId);
|
|
339
|
+
}
|
|
340
|
+
function getRunForDrill(drillId, runId) {
|
|
341
|
+
if (!runId) {
|
|
342
|
+
return { error: "No run found for this drill" };
|
|
343
|
+
}
|
|
344
|
+
const run = drillRuns.get(runId);
|
|
345
|
+
if (!run || run.drillId !== drillId) {
|
|
346
|
+
return { error: "Run not found" };
|
|
347
|
+
}
|
|
348
|
+
return { run };
|
|
349
|
+
}
|
|
350
|
+
function updateRunStatus(run, status, message) {
|
|
351
|
+
if (run.status === status)
|
|
352
|
+
return;
|
|
353
|
+
run.status = status;
|
|
354
|
+
appendTimeline(run, "status_change", message, { status });
|
|
355
|
+
}
|
|
356
|
+
function stopSqliSeeder(context) {
|
|
357
|
+
if (context.sqliSeedInterval) {
|
|
358
|
+
clearInterval(context.sqliSeedInterval);
|
|
359
|
+
context.sqliSeedInterval = undefined;
|
|
360
|
+
}
|
|
361
|
+
if (context.sqliSeedStopTimeout) {
|
|
362
|
+
clearTimeout(context.sqliSeedStopTimeout);
|
|
363
|
+
context.sqliSeedStopTimeout = undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function startSqliSeeder(context, options) {
|
|
367
|
+
stopSqliSeeder(context);
|
|
368
|
+
const targetBase = options.targetBase || `http://127.0.0.1:${process.env.PORT || 8080}`;
|
|
369
|
+
const delayMs = Math.max(50, Math.min(5000, Math.trunc(options.delayMs ?? 250)));
|
|
370
|
+
const durationSec = Math.max(5, Math.min(1800, Math.trunc(options.durationSec ?? 120)));
|
|
371
|
+
const payloads = Array.isArray(options.payloads) && options.payloads.length > 0
|
|
372
|
+
? options.payloads
|
|
373
|
+
: DEFAULT_SQLI_PAYLOADS;
|
|
374
|
+
let payloadIndex = 0;
|
|
375
|
+
context.sqliSeedInterval = setInterval(() => {
|
|
376
|
+
const payload = payloads[payloadIndex % payloads.length] || "' OR 1=1";
|
|
377
|
+
payloadIndex += 1;
|
|
378
|
+
void (async () => {
|
|
379
|
+
context.sqliAttempted += 1;
|
|
380
|
+
try {
|
|
381
|
+
const url = `${targetBase}/echo?q=${encodeURIComponent(payload)}`;
|
|
382
|
+
const response = await request(url, {
|
|
383
|
+
method: "GET",
|
|
384
|
+
headers: {
|
|
385
|
+
"User-Agent": "BreachProtocolSeeder/1.0",
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
if (response.statusCode === 403 || response.statusCode === 406) {
|
|
389
|
+
context.sqliBlocked += 1;
|
|
390
|
+
}
|
|
391
|
+
void response.body.dump();
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Treat request failure as attempted but not blocked.
|
|
395
|
+
}
|
|
396
|
+
})();
|
|
397
|
+
}, delayMs);
|
|
398
|
+
context.sqliSeedStopTimeout = setTimeout(() => {
|
|
399
|
+
stopSqliSeeder(context);
|
|
400
|
+
}, durationSec * 1000);
|
|
401
|
+
}
|
|
402
|
+
async function executeStressor(stressor, run, context) {
|
|
403
|
+
switch (stressor.kind) {
|
|
404
|
+
case "tool": {
|
|
405
|
+
const result = await executeToolStep(stressor.step);
|
|
406
|
+
if (!result.ok) {
|
|
407
|
+
throw new Error(result.error || result.message || "Failed to execute stressor");
|
|
408
|
+
}
|
|
409
|
+
appendTimeline(run, "system", `Stressor executed: ${stressor.step.action}`, {
|
|
410
|
+
stressorId: stressor.id,
|
|
411
|
+
message: result.message,
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
case "ghost.start": {
|
|
416
|
+
const result = startGhostTraffic({
|
|
417
|
+
target: stressor.target,
|
|
418
|
+
delay: stressor.delayMs,
|
|
419
|
+
});
|
|
420
|
+
appendTimeline(run, "system", "Ghost traffic started", {
|
|
421
|
+
stressorId: stressor.id,
|
|
422
|
+
status: result.status,
|
|
423
|
+
target: "target" in result ? result.target : undefined,
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
case "ghost.stop": {
|
|
428
|
+
const result = stopGhostTraffic();
|
|
429
|
+
appendTimeline(run, "system", "Ghost traffic stopped", {
|
|
430
|
+
stressorId: stressor.id,
|
|
431
|
+
status: result.status,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
case "seed.sqli": {
|
|
436
|
+
startSqliSeeder(context, stressor);
|
|
437
|
+
appendTimeline(run, "system", "SQLi seeder started", {
|
|
438
|
+
stressorId: stressor.id,
|
|
439
|
+
targetBase: stressor.targetBase,
|
|
440
|
+
delayMs: stressor.delayMs,
|
|
441
|
+
durationSec: stressor.durationSec,
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
default:
|
|
446
|
+
throw new Error(`Unsupported stressor kind: ${String(stressor.kind)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function calculateScore(run, status) {
|
|
450
|
+
const startedAtMs = Date.parse(run.startedAt);
|
|
451
|
+
const finishedAtMs = run.finishedAt ? Date.parse(run.finishedAt) : Date.now();
|
|
452
|
+
const ttdSec = run.detectedAt
|
|
453
|
+
? Math.max(0, Math.round((Date.parse(run.detectedAt) - startedAtMs) / 1000))
|
|
454
|
+
: Math.max(0, Math.round((finishedAtMs - startedAtMs) / 1000));
|
|
455
|
+
const mitigationAnchorMs = run.detectedAt ? Date.parse(run.detectedAt) : startedAtMs;
|
|
456
|
+
const mitigatedAtMs = run.mitigatedAt ? Date.parse(run.mitigatedAt) : finishedAtMs;
|
|
457
|
+
const ttmSec = Math.max(0, Math.round((mitigatedAtMs - mitigationAnchorMs) / 1000));
|
|
458
|
+
const ttrSec = Math.max(0, Math.round((finishedAtMs - startedAtMs) / 1000));
|
|
459
|
+
const penalties = [];
|
|
460
|
+
const bonuses = [];
|
|
461
|
+
penalties.push({ code: "ttd", points: ttdSec, reason: "Time to detection penalty" });
|
|
462
|
+
penalties.push({ code: "ttm", points: Math.round(ttmSec * 0.5), reason: "Time to mitigation penalty" });
|
|
463
|
+
penalties.push({ code: "ttr", points: Math.round(ttrSec * 0.25), reason: "Time to resolution penalty" });
|
|
464
|
+
if (status === "failed") {
|
|
465
|
+
penalties.push({ code: "failed", points: 100, reason: "Drill failed" });
|
|
466
|
+
}
|
|
467
|
+
if (status === "won") {
|
|
468
|
+
bonuses.push({ code: "won", points: 50, reason: "Successful incident resolution" });
|
|
469
|
+
}
|
|
470
|
+
const totalPenalty = penalties.reduce((acc, penalty) => acc + penalty.points, 0);
|
|
471
|
+
const totalBonus = bonuses.reduce((acc, bonus) => acc + bonus.points, 0);
|
|
472
|
+
const rawTotal = 1000 - totalPenalty + totalBonus;
|
|
473
|
+
return {
|
|
474
|
+
total: Math.max(0, Math.min(1200, Math.round(rawTotal))),
|
|
475
|
+
ttdSec,
|
|
476
|
+
ttmSec,
|
|
477
|
+
ttrSec,
|
|
478
|
+
penalties,
|
|
479
|
+
bonuses,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
async function finalizeRun(runId, status, reason) {
|
|
483
|
+
const run = drillRuns.get(runId);
|
|
484
|
+
const context = drillRunContexts.get(runId);
|
|
485
|
+
if (!run || !context || context.terminal)
|
|
486
|
+
return;
|
|
487
|
+
context.terminal = true;
|
|
488
|
+
if (context.interval) {
|
|
489
|
+
clearInterval(context.interval);
|
|
490
|
+
context.interval = undefined;
|
|
491
|
+
}
|
|
492
|
+
if (context.timeout) {
|
|
493
|
+
clearTimeout(context.timeout);
|
|
494
|
+
context.timeout = undefined;
|
|
495
|
+
}
|
|
496
|
+
stopSqliSeeder(context);
|
|
497
|
+
updateRunStatus(run, status, `Run entered terminal state: ${status}`);
|
|
498
|
+
if (reason) {
|
|
499
|
+
run.failureReason = reason;
|
|
500
|
+
appendTimeline(run, "system", reason);
|
|
501
|
+
}
|
|
502
|
+
run.finishedAt = new Date().toISOString();
|
|
503
|
+
const cleanupSummary = {};
|
|
504
|
+
try {
|
|
505
|
+
const stopGhostResult = stopGhostTraffic();
|
|
506
|
+
cleanupSummary.ghostTraffic = stopGhostResult.status;
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
cleanupSummary.ghostTraffic = `error:${error?.message || String(error)}`;
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
const cleanup = await stopAllActiveExperiments();
|
|
513
|
+
cleanupSummary.cpuStopped = cleanup.cpuStopped;
|
|
514
|
+
cleanupSummary.memoryCleared = cleanup.memoryCleared;
|
|
515
|
+
cleanupSummary.cluster = cleanup.cluster;
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
logger.error({ runId, error: error?.message || String(error) }, "Drill cleanup failed");
|
|
519
|
+
cleanupSummary.experimentsError = error?.message || String(error);
|
|
520
|
+
}
|
|
521
|
+
appendTimeline(run, "system", "Executed terminal cleanup", cleanupSummary);
|
|
522
|
+
run.score = calculateScore(run, status);
|
|
523
|
+
logger.info({ runId, drillId: run.drillId, status }, "Drill run finalized");
|
|
524
|
+
const persisted = await persistDrillRunsStateQueued();
|
|
525
|
+
if (!persisted) {
|
|
526
|
+
logger.warn({ runId }, "Drill run finalized in memory but persistence write failed");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function evaluateRun(drill, runId) {
|
|
530
|
+
const run = drillRuns.get(runId);
|
|
531
|
+
const context = drillRunContexts.get(runId);
|
|
532
|
+
if (!run || !context || context.terminal)
|
|
533
|
+
return;
|
|
534
|
+
const nowMs = Date.now();
|
|
535
|
+
const snapshot = collectSnapshot(run, context);
|
|
536
|
+
run.lastSnapshot = snapshot;
|
|
537
|
+
const elapsed = elapsedSec(run, nowMs);
|
|
538
|
+
for (let idx = 0; idx < drill.hintLadder.length; idx++) {
|
|
539
|
+
const hint = drill.hintLadder[idx];
|
|
540
|
+
if (hint && elapsed >= hint.atSec && !context.emittedHints.has(idx)) {
|
|
541
|
+
context.emittedHints.add(idx);
|
|
542
|
+
appendTimeline(run, "hint", `${hint.title}: ${hint.body}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (nowMs - context.lastMetricSampleAtMs >= METRIC_TIMELINE_INTERVAL_MS) {
|
|
546
|
+
context.lastMetricSampleAtMs = nowMs;
|
|
547
|
+
appendTimeline(run, "metric", "Metric snapshot", {
|
|
548
|
+
cpuPercent: Number(snapshot.cpuPercent.toFixed(2)),
|
|
549
|
+
errorRate: Number(snapshot.errorRate.toFixed(4)),
|
|
550
|
+
blockedSqliRatio: Number(snapshot.blockedSqliRatio.toFixed(4)),
|
|
551
|
+
detectedMarked: snapshot.detectedMarked,
|
|
552
|
+
clusterAttackActive: snapshot.clusterAttackActive,
|
|
553
|
+
ghostTrafficActive: snapshot.ghostTrafficActive,
|
|
554
|
+
sqliAttempted: context.sqliAttempted,
|
|
555
|
+
sqliBlocked: context.sqliBlocked,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (drill.failConditions && drill.failConditions.some((condition) => evaluateCondition(condition, snapshot))) {
|
|
559
|
+
await finalizeRun(runId, "failed", "Fail condition triggered");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const winSatisfied = drill.winConditions.every((condition) => evaluateCondition(condition, snapshot));
|
|
563
|
+
if (winSatisfied) {
|
|
564
|
+
if (!context.winSinceMs) {
|
|
565
|
+
context.winSinceMs = nowMs;
|
|
566
|
+
if (!run.mitigatedAt) {
|
|
567
|
+
run.mitigatedAt = new Date().toISOString();
|
|
568
|
+
}
|
|
569
|
+
updateRunStatus(run, "stabilizing", "Win conditions reached, validating stability window");
|
|
570
|
+
}
|
|
571
|
+
const sustainMs = requiredSustainMs(drill.winConditions);
|
|
572
|
+
if (nowMs - context.winSinceMs >= sustainMs) {
|
|
573
|
+
await finalizeRun(runId, "won");
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (context.winSinceMs) {
|
|
578
|
+
context.winSinceMs = undefined;
|
|
579
|
+
updateRunStatus(run, "active", "Conditions regressed; returning to active incident state");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function runDrill(drill, runId) {
|
|
583
|
+
const run = drillRuns.get(runId);
|
|
584
|
+
const context = drillRunContexts.get(runId);
|
|
585
|
+
if (!run || !context || context.terminal)
|
|
586
|
+
return;
|
|
587
|
+
try {
|
|
588
|
+
updateRunStatus(run, "arming", "Applying initial stressors");
|
|
589
|
+
for (const stressor of drill.stressors) {
|
|
590
|
+
if (context.terminal)
|
|
591
|
+
return;
|
|
592
|
+
await executeStressor(stressor, run, context);
|
|
593
|
+
}
|
|
594
|
+
updateRunStatus(run, "active", "Drill is active");
|
|
595
|
+
context.timeout = setTimeout(() => {
|
|
596
|
+
void finalizeRun(runId, "failed", "Drill timed out before resolution");
|
|
597
|
+
}, drill.maxDurationSec * 1000);
|
|
598
|
+
context.interval = setInterval(() => {
|
|
599
|
+
void evaluateRun(drill, runId);
|
|
600
|
+
}, 1000);
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
logger.error({ runId, drillId: drill.id, error: error?.message || String(error) }, "Failed to start drill run");
|
|
604
|
+
await finalizeRun(runId, "failed", error?.message || "Failed to execute stressors");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function createRun(drill) {
|
|
608
|
+
const runId = `drill-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
609
|
+
const run = {
|
|
610
|
+
runId,
|
|
611
|
+
drillId: drill.id,
|
|
612
|
+
drillName: drill.name,
|
|
613
|
+
status: "pending",
|
|
614
|
+
startedAt: new Date().toISOString(),
|
|
615
|
+
timeline: [],
|
|
616
|
+
};
|
|
617
|
+
appendTimeline(run, "system", "Drill run created");
|
|
618
|
+
drillRuns.set(runId, run);
|
|
619
|
+
latestRunByDrill.set(drill.id, runId);
|
|
620
|
+
if (drillRuns.size > MAX_RUNS) {
|
|
621
|
+
const firstRunId = drillRuns.keys().next().value;
|
|
622
|
+
if (firstRunId) {
|
|
623
|
+
const staleContext = drillRunContexts.get(firstRunId);
|
|
624
|
+
if (staleContext) {
|
|
625
|
+
stopSqliSeeder(staleContext);
|
|
626
|
+
}
|
|
627
|
+
drillRuns.delete(firstRunId);
|
|
628
|
+
drillRunContexts.delete(firstRunId);
|
|
629
|
+
for (const [drillId, runId] of latestRunByDrill.entries()) {
|
|
630
|
+
if (runId === firstRunId) {
|
|
631
|
+
latestRunByDrill.delete(drillId);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
drillRunContexts.set(runId, {
|
|
637
|
+
emittedHints: new Set(),
|
|
638
|
+
terminal: false,
|
|
639
|
+
lastMetricSampleAtMs: Date.now(),
|
|
640
|
+
sqliAttempted: 0,
|
|
641
|
+
sqliBlocked: 0,
|
|
642
|
+
});
|
|
643
|
+
return run;
|
|
644
|
+
}
|
|
645
|
+
export async function drillListHandler(_req, res) {
|
|
646
|
+
await ensureDrillStateHydrated();
|
|
647
|
+
attachRequestListener();
|
|
648
|
+
res.json(Array.from(drillDefinitions.values()));
|
|
649
|
+
}
|
|
650
|
+
export async function drillRunHandler(req, res) {
|
|
651
|
+
await ensureDrillStateHydrated();
|
|
652
|
+
attachRequestListener();
|
|
653
|
+
const drillId = req.params.id;
|
|
654
|
+
const drill = drillDefinitions.get(drillId);
|
|
655
|
+
if (!drill) {
|
|
656
|
+
return res.status(404).json({ error: "Drill not found" });
|
|
657
|
+
}
|
|
658
|
+
const run = createRun(drill);
|
|
659
|
+
const persisted = await persistDrillRunsStateQueued();
|
|
660
|
+
if (!persisted) {
|
|
661
|
+
logger.warn({ runId: run.runId }, "Drill run created in memory but persistence write failed");
|
|
662
|
+
}
|
|
663
|
+
res.status(202).json({
|
|
664
|
+
status: "started",
|
|
665
|
+
runId: run.runId,
|
|
666
|
+
drillId: drill.id,
|
|
667
|
+
message: `Drill started: ${drill.name}`,
|
|
668
|
+
});
|
|
669
|
+
setImmediate(() => {
|
|
670
|
+
void runDrill(drill, run.runId);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
export async function drillStatusHandler(req, res) {
|
|
674
|
+
await ensureDrillStateHydrated();
|
|
675
|
+
const drillId = req.params.id;
|
|
676
|
+
if (!drillDefinitions.has(drillId)) {
|
|
677
|
+
return res.status(404).json({ error: "Drill not found" });
|
|
678
|
+
}
|
|
679
|
+
const runId = getRunIdFromRequest(req, drillId);
|
|
680
|
+
const lookup = getRunForDrill(drillId, runId);
|
|
681
|
+
if ("error" in lookup) {
|
|
682
|
+
return res.status(404).json({ error: lookup.error });
|
|
683
|
+
}
|
|
684
|
+
const run = lookup.run;
|
|
685
|
+
return res.json({
|
|
686
|
+
...run,
|
|
687
|
+
elapsedSec: elapsedSec(run),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
export async function drillMarkDetectedHandler(req, res) {
|
|
691
|
+
await ensureDrillStateHydrated();
|
|
692
|
+
const drillId = req.params.id;
|
|
693
|
+
if (!drillDefinitions.has(drillId)) {
|
|
694
|
+
return res.status(404).json({ error: "Drill not found" });
|
|
695
|
+
}
|
|
696
|
+
const runId = getRunIdFromRequest(req, drillId);
|
|
697
|
+
const lookup = getRunForDrill(drillId, runId);
|
|
698
|
+
if ("error" in lookup) {
|
|
699
|
+
return res.status(404).json({ error: lookup.error });
|
|
700
|
+
}
|
|
701
|
+
const run = lookup.run;
|
|
702
|
+
if (TERMINAL_STATUSES.has(run.status)) {
|
|
703
|
+
return res.status(409).json({ error: "Run is already in terminal state", status: run.status });
|
|
704
|
+
}
|
|
705
|
+
if (!run.detectedAt) {
|
|
706
|
+
run.detectedAt = new Date().toISOString();
|
|
707
|
+
appendTimeline(run, "user_action", "Operator marked incident as detected");
|
|
708
|
+
const persisted = await persistDrillRunsStateQueued();
|
|
709
|
+
if (!persisted) {
|
|
710
|
+
logger.warn({ runId: run.runId }, "Drill detection mark stored in memory but persistence write failed");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return res.json({
|
|
714
|
+
status: "ok",
|
|
715
|
+
run,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
export async function drillCancelHandler(req, res) {
|
|
719
|
+
await ensureDrillStateHydrated();
|
|
720
|
+
const drillId = req.params.id;
|
|
721
|
+
if (!drillDefinitions.has(drillId)) {
|
|
722
|
+
return res.status(404).json({ error: "Drill not found" });
|
|
723
|
+
}
|
|
724
|
+
const runId = getRunIdFromRequest(req, drillId);
|
|
725
|
+
const lookup = getRunForDrill(drillId, runId);
|
|
726
|
+
if ("error" in lookup) {
|
|
727
|
+
return res.status(404).json({ error: lookup.error });
|
|
728
|
+
}
|
|
729
|
+
const run = lookup.run;
|
|
730
|
+
if (TERMINAL_STATUSES.has(run.status)) {
|
|
731
|
+
return res.json({ status: "ok", run });
|
|
732
|
+
}
|
|
733
|
+
await finalizeRun(run.runId, "cancelled", "Cancelled by operator");
|
|
734
|
+
return res.json({ status: "ok", run });
|
|
735
|
+
}
|
|
736
|
+
export async function drillDebriefHandler(req, res) {
|
|
737
|
+
await ensureDrillStateHydrated();
|
|
738
|
+
const drillId = req.params.id;
|
|
739
|
+
if (!drillDefinitions.has(drillId)) {
|
|
740
|
+
return res.status(404).json({ error: "Drill not found" });
|
|
741
|
+
}
|
|
742
|
+
const runId = getRunIdFromRequest(req, drillId);
|
|
743
|
+
const lookup = getRunForDrill(drillId, runId);
|
|
744
|
+
if ("error" in lookup) {
|
|
745
|
+
return res.status(404).json({ error: lookup.error });
|
|
746
|
+
}
|
|
747
|
+
const run = lookup.run;
|
|
748
|
+
if (!TERMINAL_STATUSES.has(run.status)) {
|
|
749
|
+
return res.status(409).json({ error: "Run has not reached a terminal state yet", status: run.status });
|
|
750
|
+
}
|
|
751
|
+
if (!run.score) {
|
|
752
|
+
run.score = calculateScore(run, run.status);
|
|
753
|
+
const persisted = await persistDrillRunsStateQueued();
|
|
754
|
+
if (!persisted) {
|
|
755
|
+
logger.warn({ runId: run.runId }, "Drill debrief score stored in memory but persistence write failed");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return res.json({
|
|
759
|
+
runId: run.runId,
|
|
760
|
+
drillId: run.drillId,
|
|
761
|
+
status: run.status,
|
|
762
|
+
score: run.score,
|
|
763
|
+
detectedAt: run.detectedAt,
|
|
764
|
+
mitigatedAt: run.mitigatedAt,
|
|
765
|
+
startedAt: run.startedAt,
|
|
766
|
+
finishedAt: run.finishedAt,
|
|
767
|
+
timeline: run.timeline,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
//# sourceMappingURL=drills.js.map
|