Fizcko/argus_sentinel
Argus Sentinel — WAF ML Classifier (V3)
Production-grade Web Application Firewall classifier. Detects 6 attack types in HTTP requests with sub-millisecond latency on CPU.
Key metrics (test_realistic — production-like distribution, 94% clean):
- Macro F1: 0.866 | FPR: 0.83% | Mean attack recall: 0.889 | Latency: 0.24ms
Model Overview
Architecture
HTTP Request Text
|
v
[BPE Tokenizer (vocab=8192, max_len=128)]
|
+---> [Embedding (128-dim)]
| |
| [Conv1D (128 ch, k=3) + BatchNorm + ReLU] x2
| |
| [AdaptiveMaxPool1d → 128-dim]
|
+---> [6 Numeric Features]
|
[Linear 6→32 + ReLU]
|
[Concatenate (128 + 32 = 160)]
|
[Linear 160→128→64 + ReLU + Dropout(0.1)]
|
+---------+---------+
| |
[Label Head → 7] [Risk Head → 1]
| |
[Sigmoid] [Sigmoid]
| |
label_probs [7] risk_score [1]Tokenizer Specification
Input text construction: "{method} {path}?{query} {body[:200]}" — capped at 500 chars before tokenization.
from tokenizers import Tokenizer
tok = Tokenizer.from_file("tokenizer.json")
result = tok.encode("GET /search?q=test HTTP/1.1")
# result.ids → [2, 546, 287, ...] (starts with [CLS]=2)
# result.attention_mask → [1, 1, 1, ...]Label ID Mapping (CRITICAL)
Output label_probs tensor shape: [batch, 7]. Each index maps to:
Multi-label: Labels are NOT mutually exclusive. Multiple labels can be active simultaneously (e.g., index 2 + 5 = scanner performing SQLi). Exception: clean (index 0) is exclusive — if clean=1, all others must be 0.ONNX Inputs
Numeric Features — Normalization Parameters (CRITICAL)
Features are passed as RAW values — the model was trained on unnormalized features. Pass the same raw scale at inference.
Python:
def extract_numeric_features(request: dict) -> list[float]:
body = request.get("body") or ""
headers = request.get("headers") or {}
return [
float(len(body)), # content_length
float(len(headers)), # num_headers
1.0 if body else 0.0, # has_body
float(request.get("session_request_count") or 0), # session_request_count
float(request.get("session_duration") or 0), # session_duration
float(request.get("session_pattern_score") or 0), # session_pattern_score
]Rust:
fn extract_numeric_features(request: &HttpRequest) -> [f32; 6] {
let body_len = request.body.as_ref().map_or(0, |b| b.len());
[
body_len as f32,
request.headers.len() as f32,
if body_len > 0 { 1.0 } else { 0.0 },
request.session_request_count.unwrap_or(0) as f32,
request.session_duration.unwrap_or(0.0),
request.session_pattern_score.unwrap_or(0.0),
]
}If you don't have session data, pass [content_length, num_headers, has_body, 0.0, 0.0, 0.0] — ~79% of training examples had null session features.ONNX Outputs
Per-Label Thresholds (CRITICAL for deployment)
Do NOT use a default 0.5 threshold for all labels. Use these optimized thresholds from thresholds.json:
Performance
Production-Like (test_realistic — 25,000 examples, 94% clean)
Stratified Stress Test (test — 49,830 examples)
Adversarial Robustness (testmixedadversarial — 22,250 examples)
Latency (ONNX Runtime, CPU, 1 thread, batch=1)
On CPU without VNNI, FP32 is faster than dynamic INT8. Use model.onnx on standard CPUs.Training
Dataset
Usage
Python (ONNX Runtime)
import onnxruntime as ort
import numpy as np
import json
from tokenizers import Tokenizer
# Load model and tokenizer
session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
tokenizer = Tokenizer.from_file("tokenizer.json")
thresholds = json.load(open("thresholds.json"))["thresholds"]
label_names = ["clean", "xss", "sqli", "path_traversal",
"command_injection", "scanner", "spam_bot"]
def classify_request(method, path, query, headers, body):
# 1. Build text
text = f"{method} {path}"
if query: text += f"?{query}"
if body: text += f" {body[:200]}"
text = text[:500]
# 2. Tokenize
enc = tokenizer.encode(text)
input_ids = np.array([enc.ids], dtype=np.int32)
attention_mask = np.array([enc.attention_mask], dtype=np.int32)
# 3. Numeric features (RAW values)
numeric = np.array([[
float(len(body or "")),
float(len(headers)),
1.0 if body else 0.0,
0.0, 0.0, 0.0, # session features (0 if unavailable)
]], dtype=np.float32)
# 4. Inference
probs, risk = session.run(None, {
"input_ids": input_ids,
"attention_mask": attention_mask,
"numeric_features": numeric,
})
# 5. Apply per-label thresholds
detections = {
name: float(probs[0][i])
for i, name in enumerate(label_names)
if name != "clean" and probs[0][i] >= thresholds[name]
}
return {
"risk_score": float(risk[0][0]),
"detections": detections,
"is_clean": len(detections) == 0,
}
# Example
result = classify_request("GET", "/search", "q=' OR 1=1--", {"Host": "example.com"}, None)
print(result)
# {'risk_score': 0.87, 'detections': {'sqli': 0.94}, 'is_clean': False}Rust (ort crate)
use ort::{Session, Value};
use ndarray::Array2;
fn main() -> anyhow::Result<()> {
let session = Session::builder()?
.with_model_from_file("model.onnx")?;
let input_ids = Array2::<i32>::zeros((1, 128)); // from tokenizer
let attention_mask = Array2::<i32>::zeros((1, 128)); // from tokenizer
let numeric_features = Array2::<f32>::zeros((1, 6)); // extract_numeric_features()
let outputs = session.run(ort::inputs![
"input_ids" => &input_ids,
"attention_mask" => &attention_mask,
"numeric_features" => &numeric_features,
]?)?;
let label_probs: Vec<f32> = outputs[0].extract_tensor::<f32>()?.view().iter().copied().collect();
let risk_score: f32 = *outputs[1].extract_tensor::<f32>()?.view().first().unwrap();
// Apply thresholds from thresholds.json
let thresholds = [0.20, 0.50, 0.74, 0.68, 0.66, 0.70, 0.72];
let labels = ["clean", "xss", "sqli", "path_traversal",
"command_injection", "scanner", "spam_bot"];
for (i, (prob, thr)) in label_probs.iter().zip(thresholds.iter()).enumerate() {
if i > 0 && prob >= thr {
println!("DETECTED: {} ({:.3})", labels[i], prob);
}
}
println!("Risk score: {:.4}", risk_score);
Ok(())
}Decision Logic
thresholds = json.load(open("thresholds.json"))["thresholds"]
# Per-label detection
triggered = [name for i, name in enumerate(label_names)
if name != "clean" and probs[0][i] >= thresholds[name]]
# Risk-score action
score = float(risk[0][0])
if score >= 0.8: action = "BLOCK"
elif score >= 0.5: action = "CHALLENGE"
elif score >= 0.2: action = "LOG"
else: action = "ALLOW"Version History
V3 (current) — Production-Hardened
Fixed V2 recall collapse. Multi-checkpoint selection on Macro F1. Per-label threshold optimization replaces Platt scaling.
V2 — Focal Loss + Calibration (superseded)
Introduced Focal Loss and Platt calibration. FPR dropped to 0.18% but XSS recall collapsed to 0.016 and CMDi to 0.222 due to aggressive calibration.
V1 — Baseline
BCE loss, fixed 0.5 thresholds. High recall (~0.98) but lower Macro F1 (0.828) and higher FPR (0.83%).
Deployment Strategy
Phase 1 — Shadow Mode: Deploy alongside existing WAF rules, log predictions, compare decisions, tune thresholds.
Phase 2 — Safe Blocking: Enable blocking for high-confidence classes (scanner 0.98 recall, spam_bot 1.00, xss 0.95). Monitor FPR.
Phase 3 — Full Deployment: Activate all labels with thresholds.json. Use risk-score actions (BLOCK/CHALLENGE/LOG/ALLOW).
Artifacts
Known Limitations
- SQLi recall at 0.73: High threshold (0.74) trades recall for precision. Lower to 0.60 if SQLi detection is critical.
- Adversarial robustness: Fuzzed/encoded payloads have lower recall (test_adversarial macro F1 = 0.50).
- No session-level model: Classifies individual requests. Session features help but don't replace session analysis.
- Sequence truncation: Requests truncated to 128 tokens. Place attack-relevant fields early in the text.
- FP32 > INT8 on CPU: Without VNNI, FP32 is faster. Use
model.onnxon standard CPUs.
Citation
@misc{argus_sentinel_2026,
title = {Argus Sentinel: A Low-Latency CNN-Based WAF Classifier},
author = {Fizcko},
year = {2026},
howpublished = {Hugging Face Model Hub},
note = {V3, 1.17M params, 0.24ms latency, Macro F1 0.866, FPR 0.83\%}
}