mirror of
https://github.com/xzcrpw/blackwall.git
synced 2026-04-24 11:56:21 +02:00
452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
|
|
//! Load Test & Licensing Lockdown — Stress simulation for HiveMind Enterprise API.
|
||
|
|
//!
|
||
|
|
//! Spawns a real hyper server, seeds it with IoCs, then hammers it with
|
||
|
|
//! concurrent clients measuring response latency and verifying tier-based
|
||
|
|
//! access control enforcement.
|
||
|
|
|
||
|
|
use common::hivemind::{ApiTier, IoC};
|
||
|
|
use hivemind_api::licensing::LicenseManager;
|
||
|
|
use hivemind_api::server::{self, HivemindCounters};
|
||
|
|
use hivemind_api::store::ThreatFeedStore;
|
||
|
|
use std::net::SocketAddr;
|
||
|
|
use std::time::Instant;
|
||
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
|
|
use tokio::net::TcpStream;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// Make a raw HTTP/1.1 GET request and return (status_code, body).
|
||
|
|
async fn http_get(addr: SocketAddr, path: &str, bearer: Option<&str>) -> (u16, String) {
|
||
|
|
let mut stream = TcpStream::connect(addr)
|
||
|
|
.await
|
||
|
|
.expect("TCP connect failed");
|
||
|
|
|
||
|
|
let mut request = format!(
|
||
|
|
"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n"
|
||
|
|
);
|
||
|
|
if let Some(token) = bearer {
|
||
|
|
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||
|
|
}
|
||
|
|
request.push_str("\r\n");
|
||
|
|
|
||
|
|
stream
|
||
|
|
.write_all(request.as_bytes())
|
||
|
|
.await
|
||
|
|
.expect("write request failed");
|
||
|
|
|
||
|
|
let mut buf = Vec::with_capacity(8192);
|
||
|
|
stream
|
||
|
|
.read_to_end(&mut buf)
|
||
|
|
.await
|
||
|
|
.expect("read response failed");
|
||
|
|
|
||
|
|
let raw = String::from_utf8_lossy(&buf);
|
||
|
|
|
||
|
|
// Parse status code from "HTTP/1.1 NNN ..."
|
||
|
|
let status = raw
|
||
|
|
.get(9..12)
|
||
|
|
.and_then(|s| s.parse::<u16>().ok())
|
||
|
|
.unwrap_or(0);
|
||
|
|
|
||
|
|
// Split at the blank line separating headers from body
|
||
|
|
let body = raw
|
||
|
|
.find("\r\n\r\n")
|
||
|
|
.map(|i| raw[i + 4..].to_string())
|
||
|
|
.unwrap_or_default();
|
||
|
|
|
||
|
|
(status, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Seed the store with `count` synthetic IoCs at 1-second intervals.
|
||
|
|
fn seed_store(store: &mut ThreatFeedStore, count: usize) {
|
||
|
|
let base_time = 1_700_000_000u64;
|
||
|
|
for i in 0..count {
|
||
|
|
let ioc = IoC {
|
||
|
|
ioc_type: (i % 5) as u8,
|
||
|
|
severity: ((i % 5) as u8).min(4),
|
||
|
|
ip: 0xC6120000 + i as u32, // 198.18.x.x range
|
||
|
|
ja4: if i % 3 == 0 {
|
||
|
|
Some("t13d1516h2_8daaf6152771_e5627efa2ab1".into())
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
},
|
||
|
|
entropy_score: Some(5000 + (i as u32 * 100)),
|
||
|
|
description: format!("Synthetic threat indicator #{i}"),
|
||
|
|
first_seen: base_time + i as u64,
|
||
|
|
confirmations: 3,
|
||
|
|
zkp_proof: Vec::new(),
|
||
|
|
};
|
||
|
|
store.insert(ioc, base_time + i as u64 + 60);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Find a free TCP port by binding to :0 and reading the assigned port.
|
||
|
|
async fn free_port() -> u16 {
|
||
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||
|
|
.await
|
||
|
|
.expect("bind :0 failed");
|
||
|
|
listener
|
||
|
|
.local_addr()
|
||
|
|
.expect("local_addr failed")
|
||
|
|
.port()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===========================================================================
|
||
|
|
// Scenario A — API Hammering: 100 concurrent clients
|
||
|
|
// ===========================================================================
|
||
|
|
|
||
|
|
#[tokio::test(flavor = "current_thread")]
|
||
|
|
async fn scenario_a_api_hammering() {
|
||
|
|
// Setup
|
||
|
|
let store = ThreatFeedStore::shared();
|
||
|
|
let licensing = LicenseManager::shared();
|
||
|
|
|
||
|
|
// Seed 50 IoCs
|
||
|
|
{
|
||
|
|
let mut s = store.write().expect("lock");
|
||
|
|
seed_store(&mut s, 50);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register an Enterprise-tier key
|
||
|
|
let api_key = "test-enterprise-key-12345678";
|
||
|
|
{
|
||
|
|
let mut lm = licensing.write().expect("lock");
|
||
|
|
lm.register_key(api_key, ApiTier::Enterprise);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start server on a random port
|
||
|
|
let port = free_port().await;
|
||
|
|
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||
|
|
let server_store = store.clone();
|
||
|
|
let server_lic = licensing.clone();
|
||
|
|
let counters = std::sync::Arc::new(HivemindCounters::default());
|
||
|
|
let server_handle = tokio::task::spawn(async move {
|
||
|
|
let _ = server::run(addr, server_store, server_lic, counters).await;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Give the server a moment to bind — try connecting in a quick retry loop
|
||
|
|
let mut connected = false;
|
||
|
|
for _ in 0..50 {
|
||
|
|
if TcpStream::connect(addr).await.is_ok() {
|
||
|
|
connected = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
tokio::task::yield_now().await;
|
||
|
|
}
|
||
|
|
assert!(connected, "Server did not start within retry window");
|
||
|
|
|
||
|
|
// Define the endpoints to hit
|
||
|
|
let endpoints = [
|
||
|
|
"/api/v1/feed",
|
||
|
|
"/api/v1/feed/stix",
|
||
|
|
"/api/v1/feed/splunk",
|
||
|
|
"/api/v1/stats",
|
||
|
|
];
|
||
|
|
|
||
|
|
// Spawn 100 concurrent client tasks
|
||
|
|
let client_count = 100;
|
||
|
|
let mut handles = Vec::with_capacity(client_count);
|
||
|
|
let start = Instant::now();
|
||
|
|
|
||
|
|
for i in 0..client_count {
|
||
|
|
let endpoint = endpoints[i % endpoints.len()];
|
||
|
|
let key = api_key.to_string();
|
||
|
|
handles.push(tokio::task::spawn(async move {
|
||
|
|
let t = Instant::now();
|
||
|
|
let (status, body) = http_get(addr, endpoint, Some(&key)).await;
|
||
|
|
let latency = t.elapsed();
|
||
|
|
(i, status, body.len(), latency)
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect results
|
||
|
|
let mut total_latency = std::time::Duration::ZERO;
|
||
|
|
let mut max_latency = std::time::Duration::ZERO;
|
||
|
|
let mut error_count = 0;
|
||
|
|
|
||
|
|
for handle in handles {
|
||
|
|
let (idx, status, body_len, latency) = handle.await.expect("task panicked");
|
||
|
|
if status != 200 {
|
||
|
|
error_count += 1;
|
||
|
|
eprintln!(
|
||
|
|
"[HAMMER] Client {idx}: HTTP {status} (body {body_len}B) in {latency:.2?}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
total_latency += latency;
|
||
|
|
if latency > max_latency {
|
||
|
|
max_latency = latency;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let total_elapsed = start.elapsed();
|
||
|
|
let avg_latency = total_latency / client_count as u32;
|
||
|
|
|
||
|
|
eprintln!(
|
||
|
|
"[HAMMER] {client_count} clients completed in {total_elapsed:.2?}"
|
||
|
|
);
|
||
|
|
eprintln!(
|
||
|
|
"[HAMMER] Avg latency: {avg_latency:.2?}, Max: {max_latency:.2?}, Errors: {error_count}"
|
||
|
|
);
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
error_count, 0,
|
||
|
|
"All authenticated requests should succeed (HTTP 200)"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Abort the server
|
||
|
|
server_handle.abort();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===========================================================================
|
||
|
|
// Scenario B — Licensing Lockdown: tier-based access denial
|
||
|
|
// ===========================================================================
|
||
|
|
|
||
|
|
#[tokio::test(flavor = "current_thread")]
|
||
|
|
async fn scenario_b_licensing_lockdown() {
|
||
|
|
let store = ThreatFeedStore::shared();
|
||
|
|
let licensing = LicenseManager::shared();
|
||
|
|
|
||
|
|
// Seed 10 IoCs
|
||
|
|
{
|
||
|
|
let mut s = store.write().expect("lock");
|
||
|
|
seed_store(&mut s, 10);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register keys at each tier
|
||
|
|
let free_key = "free-tier-key-aaaa";
|
||
|
|
let enterprise_key = "enterprise-tier-key-bbbb";
|
||
|
|
let ns_key = "national-security-key-cccc";
|
||
|
|
{
|
||
|
|
let mut lm = licensing.write().expect("lock");
|
||
|
|
lm.register_key(free_key, ApiTier::Free);
|
||
|
|
lm.register_key(enterprise_key, ApiTier::Enterprise);
|
||
|
|
lm.register_key(ns_key, ApiTier::NationalSecurity);
|
||
|
|
}
|
||
|
|
|
||
|
|
let port = free_port().await;
|
||
|
|
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||
|
|
let server_store = store.clone();
|
||
|
|
let server_lic = licensing.clone();
|
||
|
|
let counters = std::sync::Arc::new(HivemindCounters::default());
|
||
|
|
let server_handle = tokio::task::spawn(async move {
|
||
|
|
let _ = server::run(addr, server_store, server_lic, counters).await;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Wait for server
|
||
|
|
for _ in 0..50 {
|
||
|
|
if TcpStream::connect(addr).await.is_ok() {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
tokio::task::yield_now().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Free tier: /api/v1/feed should work ---
|
||
|
|
let (status, _) = http_get(addr, "/api/v1/feed", Some(free_key)).await;
|
||
|
|
assert_eq!(status, 200, "Free tier should access /api/v1/feed");
|
||
|
|
|
||
|
|
// --- Free tier: /api/v1/stats should work ---
|
||
|
|
let (status, _) = http_get(addr, "/api/v1/stats", Some(free_key)).await;
|
||
|
|
assert_eq!(status, 200, "Free tier should access /api/v1/stats");
|
||
|
|
|
||
|
|
// --- Free tier: SIEM endpoints BLOCKED ---
|
||
|
|
let siem_paths = [
|
||
|
|
"/api/v1/feed/splunk",
|
||
|
|
"/api/v1/feed/qradar",
|
||
|
|
"/api/v1/feed/cef",
|
||
|
|
];
|
||
|
|
for path in &siem_paths {
|
||
|
|
let (status, _) = http_get(addr, path, Some(free_key)).await;
|
||
|
|
assert_eq!(
|
||
|
|
status, 403,
|
||
|
|
"Free tier should be FORBIDDEN from {path}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Free tier: STIX/TAXII endpoints BLOCKED ---
|
||
|
|
let taxii_paths = [
|
||
|
|
"/api/v1/feed/stix",
|
||
|
|
"/taxii2/collections/",
|
||
|
|
];
|
||
|
|
for path in &taxii_paths {
|
||
|
|
let (status, _) = http_get(addr, path, Some(free_key)).await;
|
||
|
|
assert_eq!(
|
||
|
|
status, 403,
|
||
|
|
"Free tier should be FORBIDDEN from {path}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- No auth: should get 401 Unauthorized ---
|
||
|
|
let (status, _) = http_get(addr, "/api/v1/feed/splunk", None).await;
|
||
|
|
assert_eq!(
|
||
|
|
status, 401,
|
||
|
|
"No auth header should yield 401 Unauthorized"
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- Invalid key: should get denied ---
|
||
|
|
let (status, _) = http_get(addr, "/api/v1/feed", Some("totally-bogus-key")).await;
|
||
|
|
// /api/v1/feed allows unauthenticated access via effective_tier=Free fallback,
|
||
|
|
// but with an invalid key, the server resolves tier to None and falls through
|
||
|
|
// to Free default for /api/v1/feed
|
||
|
|
assert!(
|
||
|
|
status == 200 || status == 401,
|
||
|
|
"/api/v1/feed with invalid key: got {status}"
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- Enterprise tier: SIEM endpoints ALLOWED ---
|
||
|
|
for path in &siem_paths {
|
||
|
|
let (status, body) = http_get(addr, path, Some(enterprise_key)).await;
|
||
|
|
assert_eq!(
|
||
|
|
status, 200,
|
||
|
|
"Enterprise tier should access {path}, got {status}"
|
||
|
|
);
|
||
|
|
assert!(!body.is_empty(), "{path} response body should not be empty");
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Enterprise tier: TAXII endpoints ALLOWED ---
|
||
|
|
for path in &taxii_paths {
|
||
|
|
let (status, _) = http_get(addr, path, Some(enterprise_key)).await;
|
||
|
|
assert_eq!(
|
||
|
|
status, 200,
|
||
|
|
"Enterprise tier should access {path}, got {status}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- NationalSecurity tier: everything ALLOWED ---
|
||
|
|
let all_paths = [
|
||
|
|
"/api/v1/feed",
|
||
|
|
"/api/v1/feed/stix",
|
||
|
|
"/api/v1/feed/splunk",
|
||
|
|
"/api/v1/feed/qradar",
|
||
|
|
"/api/v1/feed/cef",
|
||
|
|
"/api/v1/stats",
|
||
|
|
"/taxii2/",
|
||
|
|
"/taxii2/collections/",
|
||
|
|
];
|
||
|
|
for path in &all_paths {
|
||
|
|
let (status, _) = http_get(addr, path, Some(ns_key)).await;
|
||
|
|
assert_eq!(
|
||
|
|
status, 200,
|
||
|
|
"NationalSecurity tier should access {path}, got {status}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Unknown endpoint: 404 ---
|
||
|
|
let (status, _) = http_get(addr, "/api/v1/nonexistent", Some(enterprise_key)).await;
|
||
|
|
assert_eq!(status, 404, "Unknown endpoint should yield 404");
|
||
|
|
|
||
|
|
eprintln!("[LOCKDOWN] All tier-based access control assertions passed");
|
||
|
|
server_handle.abort();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===========================================================================
|
||
|
|
// Scenario C — Feed Content Integrity: verify response payloads
|
||
|
|
// ===========================================================================
|
||
|
|
|
||
|
|
#[tokio::test(flavor = "current_thread")]
|
||
|
|
async fn scenario_c_feed_content_integrity() {
|
||
|
|
let store = ThreatFeedStore::shared();
|
||
|
|
let licensing = LicenseManager::shared();
|
||
|
|
|
||
|
|
// Seed 5 IoCs
|
||
|
|
{
|
||
|
|
let mut s = store.write().expect("lock");
|
||
|
|
seed_store(&mut s, 5);
|
||
|
|
}
|
||
|
|
|
||
|
|
let api_key = "integrity-test-key";
|
||
|
|
{
|
||
|
|
let mut lm = licensing.write().expect("lock");
|
||
|
|
lm.register_key(api_key, ApiTier::Enterprise);
|
||
|
|
}
|
||
|
|
|
||
|
|
let port = free_port().await;
|
||
|
|
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||
|
|
let ss = store.clone();
|
||
|
|
let sl = licensing.clone();
|
||
|
|
let counters = std::sync::Arc::new(HivemindCounters::default());
|
||
|
|
let server_handle = tokio::task::spawn(async move {
|
||
|
|
let _ = server::run(addr, ss, sl, counters).await;
|
||
|
|
});
|
||
|
|
|
||
|
|
for _ in 0..50 {
|
||
|
|
if TcpStream::connect(addr).await.is_ok() {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
tokio::task::yield_now().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- JSON feed ---
|
||
|
|
let (status, body) = http_get(addr, "/api/v1/feed", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
let parsed: serde_json::Value = serde_json::from_str(&body)
|
||
|
|
.unwrap_or_else(|e| panic!("Invalid JSON in /api/v1/feed response: {e}"));
|
||
|
|
assert!(
|
||
|
|
parsed.get("items").is_some(),
|
||
|
|
"Feed response should contain 'items' field"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
parsed.get("total").is_some(),
|
||
|
|
"Feed response should contain 'total' field"
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- STIX bundle ---
|
||
|
|
let (status, body) = http_get(addr, "/api/v1/feed/stix", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
let stix: serde_json::Value = serde_json::from_str(&body)
|
||
|
|
.unwrap_or_else(|e| panic!("Invalid STIX JSON: {e}"));
|
||
|
|
assert_eq!(
|
||
|
|
stix.get("type").and_then(|t| t.as_str()),
|
||
|
|
Some("bundle"),
|
||
|
|
"STIX response should be a bundle"
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- Splunk HEC ---
|
||
|
|
let (status, body) = http_get(addr, "/api/v1/feed/splunk", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
let splunk: serde_json::Value = serde_json::from_str(&body)
|
||
|
|
.unwrap_or_else(|e| panic!("Invalid Splunk JSON: {e}"));
|
||
|
|
assert!(splunk.is_array(), "Splunk response should be a JSON array");
|
||
|
|
|
||
|
|
// --- QRadar LEEF (plain text) ---
|
||
|
|
let (status, body) = http_get(addr, "/api/v1/feed/qradar", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
assert!(
|
||
|
|
body.contains("LEEF:"),
|
||
|
|
"QRadar response should contain LEEF headers"
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- CEF (plain text) ---
|
||
|
|
let (status, body) = http_get(addr, "/api/v1/feed/cef", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
assert!(
|
||
|
|
body.contains("CEF:"),
|
||
|
|
"CEF response should contain CEF headers"
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- Stats ---
|
||
|
|
let (status, body) = http_get(addr, "/api/v1/stats", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
let stats: serde_json::Value = serde_json::from_str(&body)
|
||
|
|
.unwrap_or_else(|e| panic!("Invalid stats JSON: {e}"));
|
||
|
|
let total = stats
|
||
|
|
.get("total_iocs")
|
||
|
|
.and_then(|v| v.as_u64())
|
||
|
|
.unwrap_or(0);
|
||
|
|
assert_eq!(total, 5, "Stats should report 5 total IoCs");
|
||
|
|
|
||
|
|
// --- TAXII discovery (no auth needed, but we send one) ---
|
||
|
|
let (status, body) = http_get(addr, "/taxii2/", Some(api_key)).await;
|
||
|
|
assert_eq!(status, 200);
|
||
|
|
let taxii: serde_json::Value = serde_json::from_str(&body)
|
||
|
|
.unwrap_or_else(|e| panic!("Invalid TAXII JSON: {e}"));
|
||
|
|
assert!(
|
||
|
|
taxii.get("title").is_some(),
|
||
|
|
"TAXII discovery should contain title"
|
||
|
|
);
|
||
|
|
|
||
|
|
eprintln!("[INTEGRITY] All 7 endpoints return well-formed responses");
|
||
|
|
server_handle.abort();
|
||
|
|
}
|