Skip to content

Commit aa1a399

Browse files
committed
feat(synapse-waf): demo simulator with procedural attacker archetypes (Phase 1)
Adds src/simulator.rs — a tokio background task that procedurally generates HTTP requests from attacker archetypes and feeds them through the REAL WAF detection engine + real shared managers (EntityManager, CampaignManager, BlockLog, WafStats). When the binary is run with --demo, the simulator populates these managers from synthetic traffic, so the admin endpoints horizon already polls (/sensor/status, /sensor/entities, /sensor/campaigns) serve live-looking data without needing a real network sensor in front of the proxy. Two archetypes ship in Phase 1: - CredentialStuffer: 20 source IPs sharing a Go-client JA4 fingerprint, rotating usernames against /api/login. Exercises CampaignManager's JA4-cluster correlation; the dashboard sees a real campaign emerge. - VulnScanner: single IP, sqlmap UA, classic SQLi/XSS/path-traversal/ command-injection payloads against varied URIs. Trips production rules (200200, 280001 etc.) and accumulates entity risk to 100.0. The simulator does NOT touch the production filter chain — it calls DetectionEngine::analyze_with_signals directly and mirrors the small set of post-analyze state updates request_filter normally does. This seam is documented inline; the long-term cleanup is to extract a process_request_state helper both paths call. admin_server: removes 4 hardcoded short-circuits in sites/status/ entities/campaigns handlers so the real handler path serves simulator-populated data when --demo is set. The demo_*() helpers are retained as fallbacks for the brief window between startup and the first simulator tick. Smoke test (10s of runtime): waf.analyzed=140 blocked=20 blockRate=14.3%, entity 203.0.113.99 risk=100.0, real campaign with confidence=100.
1 parent 121f57e commit aa1a399

3 files changed

Lines changed: 551 additions & 18 deletions

File tree

apps/synapse-pingora/src/admin_server.rs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,12 +1725,30 @@ async fn restart_handler() -> impl IntoResponse {
17251725

17261726
/// GET /sites - List configured sites
17271727
async fn sites_handler(State(state): State<AdminState>) -> impl IntoResponse {
1728-
// Demo mode: return pre-populated sample data
1728+
// NOTE: demo mode used to short-circuit here with hardcoded JSON. The
1729+
// simulator (src/simulator.rs) now populates real managers from
1730+
// procedural traffic, so the regular handler path serves live data
1731+
// even in demo mode. The demo_sites() helper is retained for sites
1732+
// because the simulator does not yet generate site config; if the
1733+
// configured sites list is empty AND demo mode is on, fall back to
1734+
// the canned shape so dashboards aren't blank.
1735+
let response = state.handler.handle_list_sites();
17291736
if is_demo_mode() {
1730-
return (StatusCode::OK, Json(demo_sites())).into_response();
1737+
// Inspect the raw JSON to decide whether to fall back. handle_list_sites
1738+
// returns an ApiResponse wrapping a serializable. Cheap heuristic:
1739+
// serialize, count sites array length, fall back if zero.
1740+
if let Ok(value) = serde_json::to_value(&response) {
1741+
let empty = value
1742+
.get("data")
1743+
.and_then(|d| d.get("sites"))
1744+
.and_then(|s| s.as_array())
1745+
.map(|a| a.is_empty())
1746+
.unwrap_or(true);
1747+
if empty {
1748+
return (StatusCode::OK, Json(demo_sites())).into_response();
1749+
}
1750+
}
17311751
}
1732-
1733-
let response = state.handler.handle_list_sites();
17341752
wrap_response(response)
17351753
}
17361754

@@ -2112,11 +2130,11 @@ async fn sensor_header_profiles_handler() -> impl IntoResponse {
21122130
/// GET /_sensor/status - Dashboard status endpoint
21132131
/// Returns a format compatible with the dashboard's expected response.
21142132
async fn sensor_status_handler(State(state): State<AdminState>) -> impl IntoResponse {
2115-
// Demo mode: return pre-populated sample data
2116-
if is_demo_mode() {
2117-
return (StatusCode::OK, Json(demo_status()));
2118-
}
2119-
2133+
// NOTE: demo mode no longer short-circuits — the simulator populates
2134+
// real WAF stats via DetectionEngine::analyze_with_signals so the
2135+
// regular handler path serves live numbers. demo_status() retained
2136+
// for a hard fallback if state somehow goes empty mid-demo (see
2137+
// sites_handler for the same pattern).
21202138
let health = state.handler.handle_health();
21212139
let stats = state.handler.handle_stats();
21222140
let waf = state.handler.handle_waf_stats();
@@ -2196,13 +2214,18 @@ async fn sensor_entities_handler(
21962214
Query(params): Query<EntitiesQuery>,
21972215
State(state): State<AdminState>,
21982216
) -> impl IntoResponse {
2199-
// Demo mode: return pre-populated sample data
2200-
if is_demo_mode() {
2217+
let limit = params.limit.unwrap_or(100);
2218+
let entities = state.handler.handle_list_entities(limit);
2219+
2220+
// NOTE: demo mode used to short-circuit here. The simulator now
2221+
// populates EntityManager via touch_entity_with_fingerprint and
2222+
// apply_external_risk, so the regular handler returns live data.
2223+
// Fall back to demo_entities() only if the manager is empty AND
2224+
// demo mode is on (e.g. simulator hasn't ticked yet on first poll).
2225+
if is_demo_mode() && entities.is_empty() {
22012226
return (StatusCode::OK, Json(demo_entities()));
22022227
}
22032228

2204-
let limit = params.limit.unwrap_or(100);
2205-
let entities = state.handler.handle_list_entities(limit);
22062229
(
22072230
StatusCode::OK,
22082231
Json(serde_json::json!({ "entities": entities })),
@@ -2889,11 +2912,9 @@ async fn sensor_anomalies_handler(State(state): State<AdminState>) -> impl IntoR
28892912

28902913
/// GET /_sensor/campaigns - Returns active threat campaigns
28912914
async fn sensor_campaigns_handler(State(state): State<AdminState>) -> impl IntoResponse {
2892-
// Demo mode: return pre-populated sample data
2893-
if is_demo_mode() {
2894-
return (StatusCode::OK, Json(demo_campaigns()));
2895-
}
2896-
2915+
// NOTE: demo mode no longer short-circuits — the simulator drives
2916+
// CampaignManager via register_fingerprints. demo_campaigns() retained
2917+
// as a fallback for first-poll-before-tick scenarios.
28972918
let campaigns = match state.handler.campaign_manager() {
28982919
Some(manager) => manager
28992920
.get_campaigns()
@@ -2919,6 +2940,11 @@ async fn sensor_campaigns_handler(State(state): State<AdminState>) -> impl IntoR
29192940
None => vec![],
29202941
};
29212942

2943+
// Fall back to canned demo campaigns if the real manager is empty.
2944+
if is_demo_mode() && campaigns.is_empty() {
2945+
return (StatusCode::OK, Json(demo_campaigns()));
2946+
}
2947+
29222948
(
29232949
StatusCode::OK,
29242950
Json(serde_json::json!({ "data": campaigns })),

apps/synapse-pingora/src/main.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
//! └─────────────────┘
2121
//! ```
2222
23+
// Submodules of the bin crate (see also lib.rs for the library half).
24+
// `simulator` lives here rather than in the lib because it depends on
25+
// `DetectionEngine` which is bin-local.
26+
mod simulator;
27+
2328
use async_trait::async_trait;
2429
use bytes::Bytes;
2530
use http::header::{HeaderName, HeaderValue, AUTHORIZATION, COOKIE, USER_AGENT};
@@ -6305,6 +6310,38 @@ fn main() {
63056310
);
63066311
}
63076312

6313+
// Demo simulator: when --demo is set, kick off the procedural traffic
6314+
// generator that populates the real EntityManager / CampaignManager /
6315+
// BlockLog from synthetic attacker archetypes. The admin endpoints
6316+
// horizon polls then serve live-looking data without needing a real
6317+
// network sensor in front of the proxy. See src/simulator.rs.
6318+
//
6319+
// The simulator runs in a tokio task on the same runtime as the admin
6320+
// server. It does NOT touch the production filter chain — it calls
6321+
// DetectionEngine::analyze_with_signals directly and mirrors the
6322+
// post-analyze state updates request_filter normally does.
6323+
if demo_mode {
6324+
let sim_loop = simulator::SimulatorLoop::new(
6325+
Arc::clone(&shared_entity_manager),
6326+
Arc::clone(&campaign_manager),
6327+
Arc::clone(&shared_block_log),
6328+
health_checker.waf_stats(),
6329+
);
6330+
// Spawn on the existing tokio runtime that admin_server runs on.
6331+
// The handle is intentionally dropped — the loop runs forever or
6332+
// until the process exits. Pingora's shutdown will tear down the
6333+
// tokio runtime which terminates the loop.
6334+
std::thread::spawn(move || {
6335+
let rt = tokio::runtime::Runtime::new().expect("simulator tokio runtime");
6336+
rt.block_on(async move {
6337+
let _handle = sim_loop.start();
6338+
// Keep the runtime alive for the lifetime of the process.
6339+
std::future::pending::<()>().await;
6340+
});
6341+
});
6342+
info!("Demo simulator started (background traffic generator)");
6343+
}
6344+
63086345
// Launch TUI if requested, otherwise run server normally
63096346
if cli.tui {
63106347
let metrics_for_tui = Arc::clone(&metrics_registry);

0 commit comments

Comments
 (0)