CoolFace
Modelpublic

Fizcko/argus_sentinel

sourceHugging Faceapache-2.0updated 6mo agoView on Hugging Face
1likes19downloads
Model Card

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

PropertyValue
ArchitectureCNN text encoder + numeric features fusion
Parameters1.17M
Vocab size8,192 (BPE ByteLevel)
Max sequence length128 tokens
ONNX model size4.5 MB (FP32) / 1.2 MB (INT8)
Inference latency0.24 ms avg (CPU, single thread)
Training lossFocal BCEWithLogitsLoss (gamma=2.0)
Best epoch3 / 8 (early stopping, selected on Macro F1)

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

PropertyValue
TypeBPE (Byte-Pair Encoding) via HuggingFace tokenizers library
AlgorithmByteLevel BPE — operates on UTF-8 bytes, not characters
Pre-tokenizerByteLevel (addprefixspace=false, trimoffsets=true, useregex=true)
NormalizerNone (raw bytes, no lowercasing or unicode normalization)
Post-processorTemplateProcessing — prepends [CLS] token automatically
Vocab size8,192 tokens (7,933 merges + 3 special tokens + 256 byte tokens)
Special tokens[PAD] (id=0), [UNK] (id=1), [CLS] (id=2)
Max length128 tokens (truncation=Right, padding=Right to fixed 128)
Byte fallbackfalse — unknown bytes map to [UNK]
Filetokenizer.json (HuggingFace tokenizers JSON format)

Input text construction: "{method} {path}?{query} {body[:200]}" — capped at 500 chars before tokenization.

python
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:

IndexLabelDescription
0cleanBenign / legitimate request
1xssCross-Site Scripting
2sqliSQL Injection
3path_traversalDirectory / Path Traversal
4command_injectionOS Command Injection
5scannerVulnerability scanner / probe
6spam_botSpam bot / automated abuse
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

NameShapeDtypeDescription
input_ids[batch, 128]int32BPE token IDs from tokenizer.json
attention_mask[batch, 128]int321 for real tokens, 0 for [PAD]
numeric_features[batch, 6]float32Request-level features (RAW values, see below)

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.

IndexFeatureComputationTraining RangeMeanStd
0content_lengthlen(body) if body else 00 – 66235.263.5
1num_headerslen(headers_dict)3 – 137.71.3
2has_body1.0 if body present, else 0.00 – 10.420.49
3session_request_countTotal requests in session, or 00 – 203.06.0
4session_durationSession time span in seconds, or 00 – 4,965,381619,3221,198,151
5session_pattern_scoreBehavioral pattern score, or 00 – 0.50.090.18

Python:

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:

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

NameShapeDtypeDescription
label_probs[batch, 7]float32Per-label probabilities after sigmoid
risk_score[batch, 1]float32Aggregate risk score [0, 1]

Per-Label Thresholds (CRITICAL for deployment)

Do NOT use a default 0.5 threshold for all labels. Use these optimized thresholds from thresholds.json:

LabelThresholdRecallPrecisionF1
clean0.200.9980.9920.995
xss0.500.9510.5850.724
sqli0.740.7320.9400.823
path_traversal0.680.8960.7940.842
command_injection0.660.8260.6260.712
scanner0.700.9800.9450.962
spam_bot0.721.0001.0001.000

Performance

Production-Like (test_realistic — 25,000 examples, 94% clean)

MetricValue
Macro F10.866
FPR on clean0.83%
Mean attack recall0.889
LabelRecallPrecisionF1
clean0.9980.9920.995
xss0.9510.5850.724
sqli0.7320.9400.823
path_traversal0.8960.7940.842
command_injection0.8260.6260.712
scanner0.9800.9450.962
spam_bot1.0001.0001.000

Stratified Stress Test (test — 49,830 examples)

MetricValue
Macro F10.787
FPR on clean7.9%

Adversarial Robustness (testmixedadversarial — 22,250 examples)

MetricValue
Macro F10.499
FPR on clean11.8%
XSS recall0.833

Latency (ONNX Runtime, CPU, 1 thread, batch=1)

MetricFP32INT8
Average0.24 ms1.20 ms
Throughput~4,100 req/s~830 req/s
On CPU without VNNI, FP32 is faster than dynamic INT8. Use model.onnx on standard CPUs.

Training

HyperparameterV1V3 (current)
LossBCEWithLogitsLossFocal BCE (gamma=2.0)
Learning rate1e-31e-4
Batch size256128
Epochs58 (early stop at 6, best=3)
Patience23
Checkpoint selectionBest val_lossBest Macro F1
CalibrationNonePer-label threshold tuning
Data augmentationNone+20k augmented (encoding, headers, noise, context swap)

Dataset

PropertyValue
Total examples498,345 (+20k augmented)
Training split418,685
Real traffic62.6%
Synthetic37.4%
Multi-label17.0%
Hard negatives15.0%
Unique sources12
SourcesCIC-IDS-2017, CSE-CIC-IDS-2018, HIKARI-2021, WebAttackPayloads, PayloadsAllTheThings, + synthetic

Usage

Python (ONNX Runtime)

python
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)

rust
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

python
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%).

MetricV1V2**V3**
Macro F10.8280.6690.866
FPR0.83%0.18%0.83%
XSS recall0.9800.0160.951
CMDi recall0.9850.2220.826
Latency0.77ms0.99ms0.24ms

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

FileSizeDescription
model.onnx4.5 MBProduction model (FP32, fastest on CPU)
model_int8.onnx1.2 MBINT8 quantized (for VNNI hardware)
model_optimized.onnx4.5 MBGraph-optimized FP32
tokenizer.json510 KBBPE tokenizer
config.json1.5 KBArchitecture + training config
thresholds.json1.3 KBPer-label thresholds (must use at inference)
metrics.json12 KBFull 3-set evaluation results
training_history.json7.3 KBPer-epoch training history

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.onnx on standard CPUs.

Citation

bibtex
@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\%}
}