- π‘οΈ IronJean WAF v2.1 (ModernBERT Landmark Pooling)
π‘οΈ IronJean WAF v2.1 (ModernBERT Landmark Pooling)
IronJean WAF v2.1 new version is an enterprise-grade, NLP-powered Web Application Firewall classification model engineered on top of ModernBERT-base.
It introduces Vectorized Landmark Pooling, Multi-Pass Recursive Normalization, and Network Landmark Anonymization, leveraging ModernBERT's massive 8,192-token context window to analyze entire HTTP headers and bodies without truncation, providing deterministic, sub-millisecond threat detection across complex HTTP request trees without keyword overfitting.
Unlike traditional regex rules, legacy 512-token BERT classifiers, or naive [CLS] sequence transformers, IronJean WAF understands the semantic grammar of modern web protocols (REST, JSON, XML/SOAP, gRPC, OAuth) while achieving a benchmarked 99.9994% Global Accuracy (100.00% Recall) with an industry-low 0.001% False Positive Rate across 160,500 payloads sourced exclusively from fully cleaned open-source and synthetic datasets as part of my independent research.
π News notes v2.1
- Balanced Protocol & Verb Coverage: Expanded to 160,500 requests with strict 50/50 Safe/Attack parity across all HTTP methods (
GET,POST,PUT,PATCH,DELETE) andContent-Typeformats (JSON objects/arrays, XML/SOAP, Form, Multipart, gRPC). - Markdown & HTML5 False-Positive Immunity: Native support for
text/markdown(technical prose, code blocks) andtext/htmldocuments without false alarms. - Upgraded
HTTPNormalizerv2.1: Added smart cookie/telemetry anonymization (anonymize_cookies), advanced anti-evasion decoding (Log4j, decimal IPv4, sponge-case), and non-target header inspection. - Broader Threat & Benchmark Coverage: Added detection for dotfile recon, open redirects, and HTTP request smuggling, validated across 9 external Hugging Face benchmarks.
β‘ Key Architectural Innovations
1. Vectorized Landmark Pooling (trust_remote_code=True)
Traditional sentence transformers collapse sequence representations onto the initial [CLS] token, discarding spatial boundary relationships between HTTP fields.
IronJean WAF implements Vectorized Landmark Pooling:
- Delimiter tokens (
[SEP]) segment the request into four discrete landmarks:METHOD,URI,HEADERS, andBODY. - The final representation is computed via parallel vectorized pooling over
[SEP]hidden states: - Native Hugging Face Integration (
trust_remote_code=True): Packaged with custom modeling (modeling_ironjean.py) registered underAutoModelForSequenceClassificationandpipeline("text-classification"). External developers can load the full landmark-pooling architecture zero-shot without defining custom PyTorch boilerplate. - Fully Exportable to ONNX: For zero-trust production environments, the pooling graph is baked directly into
model.onnx, requiring no Python runtime.
2. Multi-Pass Recursive Anti-Evasion Decoding
Attackers frequently chain multiple encoding layers (e.g., Base64 embedded inside an URL-encoded parameter) to blind inspect engines. IronJean WAF incorporates an iterative pre-inference unwrapping pipeline (depth limit = 3) that peels recursive layers of URL decoding and Base64 payloads prior to tokenization.
3. Network Landmark Anonymization (<IP> & <DOMAIN>)
To prevent the neural network from memorizing specific corporate domain names, subnets, or proxy IP headers, target headers (Host, X-Forwarded-For, X-Real-IP, True-Client-IP, CF-Connecting-IP, X-Forwarded-Host) are normalized into generalized <DOMAIN> and <IP> landmarks.
4. Focal Loss & Calibration Smoothing
Trained via a two-stage strategy:
- Stages 1β2 (Hard-Example Mining): Focal Loss ($$\alpha=1.0, \gamma=2.0$$) penalizes repetitive trivial attacks (old SQLmap patterns) to force gradient convergence onto subtle edge cases (e.g., French apostrophes vs. SQL quotes).
- Stage 3 (Calibration Smoothing): Fine-tuned with decayed learning rate ($$1\times 10^{-5}$$) under standard Cross-Entropy to smooth logits, collapsing legitimate traffic probabilities down to 0.00%.
π οΈ Dataset Provenance
To ensure a comprehensive and resilient mapping of HTTP threat vectors, the dataset was meticulously aggregated and curated from the following sources:
- Simulated Enterprise Telemetry (WAF Factory datasets): Rigorously generated HTTP logs designed to mirror the natural variance, structural complexity, and edge cases of legitimate enterprise traffic, ensuring realistic baseline behaviors without using proprietary production data.
- CAPECβ’ (Common Attack Pattern Enumeration and Classification): Integration of standardized, industry-recognized attack patterns to guarantee baseline coverage of known exploit topologies.
- miomit/xss_generation: Synthetic, deeply obfuscated cross-site scripting payloads to train the model's attention mechanism against advanced evasion and mutation techniques.
- Custom Synthetic & Noise Data: Custom-engineered synthetic payloads combined with deliberate label noise, specifically designed to force structural learning and provide maximum resilience.
π― Benchmark
Tested against internal and external Hugging Face benchmarks:
Benchmark dataset |
Hugging Face Dataset | Requests | Accuracy | Precision | Recall | F1-Score | TP | TN | FP (FPR) | FN (FNR) | Throughput (Latency) |
|---|---|---|---|---|---|---|---|---|---|---|---|
v18 (Internal) |
dataset_v18_rest_150k |
160 500 | 99.9994% | 99.9988% | 100.00% | 99.9994% | 80 250 | 80 249 | 1 (0.001%) | 0 (0.00%) | 675.42 req/s (1.48 ms) |
waf-detection |
darkknight25/WAF_DETECTION_DATASET |
650 | 100.00% | 100.00% | 100.00% | 100.00% | 650 | 0 | 0 (0.00%) | 0 (0.00%) | 771.16 req/s (1.30 ms) |
polyglot-waf |
darkknight25/polyglot_paylods_datasets |
512 | 100.00% | 100.00% | 100.00% | 100.00% | 512 | 0 | 0 (0.00%) | 0 (0.00%) | ~780 req/s (1.28 ms) |
shengqin-waf |
shengqin/web-attacks |
5 523 | 97.18% | 99.86% | 96.67% | 98.24% | 4 360 | 1 007 | 6 (0.59%) | 150 (3.33%) | ~710 req/s (1.41 ms) |
waf-bench |
netgoat-ai/WAF-Bench |
77 | 94.81% | 96.49% | 96.49% | 96.49% | 55 | 18 | 2 (10.00%) | 2 (3.51%) | 320.69 req/s (3.12 ms) |
secureai-waf |
SecureAI-SE/http-attack-requests |
1 400 | 94.43% | 99.64% | 93.81% | 96.64% | 1 122 | 200 | 4 (1.96%) | 74 (6.19%) | ~730 req/s (1.37 ms) |
puyang |
puyang2025/waf_data_v2 |
100 000 | 93.92% | 80.52% | 91.85% | 85.81% | 18 381 | 75 541 | 4 447 (5.56%) | 1 631 (8.15%) | 619.03 req/s (1.62 ms) |
nosql-waf |
darkknight25/nosql_injection_dataset |
660 | 87.58% | 100.00% | 87.58% | 93.38% | 578 | 0 | 0 (0.00%) | 82 (12.42%) | ~760 req/s (1.31 ms) |
ai-waf |
notesbymuneeb/ai-waf-dataset |
11 949 | 82.80% | 65.17% | 80.67% | 72.10% | 2 655 | 7 239 | 1 419 (16.39%) | 636 (19.33%) | 505.50 req/s (1.98 ms) |
csic |
b1nch3f/csic-dataset |
10 000 | 80.35% | 84.57% | 30.08% | 44.38% | 784 | 7 251 | 143 (1.93%) | 1 822 (69.92%) | 573.55 req/s (1.74 ms) |
π Input Format (Sequence Template)
Input sequences must conform strictly to the four-landmark template:
METHOD: <HTTP_METHOD> [SEP] URI: <REQUEST_URI> [SEP] HEADERS: <FILTERED_HEADERS> [SEP] BODY: <NORMALIZED_BODY>
METHOD:Standard HTTP verb (GET,POST,PUT,DELETE,PATCH).[SEP]: Delimiter token (50282in ModernBERT tokenizer) mapped to Vectorized Landmark Pooling.URI:Recursively decoded path and parameters (e.g./api/v1/resource?id=10).HEADERS:Pipe-separated key-value pairs formatted byHTTPNormalizer.BODY:Recursively decoded payload, orEMPTY_BODY.
π οΈ Mandatory Pre-Inference Normalization Pipeline
To reproduce the benchmarked 99.9994% Global Accuracy and avoid false alarms, incoming HTTP requests must pass through the IronJean Normalization Pipeline before tokenization.
This pipeline strips multi-layer evasion obfuscation, prevents neural overfitting on hostnames/IPs, enforces header length guards, and structures input into canonical HTTP landmarks.
Incoming HTTP Request
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Multi-Pass Recursive Anti-Evasion (Max Depth = 3) β
β URL-decode + Base64 regex unwrap until convergence β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. Network Landmark Anonymization β
β Host, X-Forwarded-For, Referer -> <DOMAIN>, <IP> β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. Header Whitelisting & Flooding Guard (150 chars max)β
β Keep target headers, truncate bloat > 150 chars β
β Cookie: drop telemetry (_ga, __cf_bm), anonymize β
β session tokens (JSESSIONID=NONE), preserve attacks β
β Format: 'key: val | key: val' (or 'NONE' if empty) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 4. Body & Token Stabilization β
β Empty/Null -> 'EMPTY_BODY', safe JWT cookie decode β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 5. Landmark Sequence Serialization β
β METHOD: <M> [SEP] URI: <U> [SEP] HEADERS: <H> ... β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π¦ Python Normalizer (HTTPNormalizer)
To ensure exact parity with production environments and prevent false alarms, raw requests should be normalized before inference.
The complete, battle-tested normalizer is included in core/normalizer.py and demonstrated end-to-end in test_hf.py.
π Click to expand / copy the standalone Python HTTPNormalizer code
import re
import json
import base64
import urllib.parse
from typing import Dict, Any, Union
class HTTPNormalizer:
"""
HTTP normalization and anti-evasion decoder for IronJean ModernBERT WAF.
Features:
- Cascading recursive URL percent-decoding and Base64 parameter decoding.
- Anonymization of IP addresses and domains (<IP>, <DOMAIN>).
- Strict landmark serialization: METHOD, URI, HEADERS, BODY delimited by [SEP].
"""
IP_REGEX = re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b')
DOMAIN_REGEX = re.compile(r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?::\d{1,5})?\b')
B64_REGEX = re.compile(
r'(?<![A-Za-z0-9+/])(?:[A-Za-z0-9+/]{4}){4,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|(?<=[A-Za-z0-9+/]))(?![A-Za-z0-9+/=])'
)
# Common injection keywords (SQLi, XSS, RCE, Code execution)
SUSPICIOUS_KEYWORDS = (
r"select|union|insert|update|delete|drop|exec|"
r"script|alert|onerror|onload|javascript|eval"
)
# Common evasions and injection tokens: traversal (../, ..\, %2e%2e, %u2215, %c0%af), LFI targets, SQL comments (-- , /*), delimiters (< > ' " `), JNDI/SSTI (${, {{), Shellshock
COMMON_SUSPICIOUS_TOKENS = (
r"\.\.[\/\\]|\.\.%u2215|%c0%af|%2e%2e|/etc/passwd|/etc/shadow|win\.ini|boot\.ini|"
r"--(?:[\s]|$)|/\*|<|>|'|\"|`|\$\{|\{\{|\(\)\s*\{\s*:"
)
# Suspicious injection patterns for User-Agent:
# Whitespace (\s) is allowed because real browser User-Agents always contain spaces.
SUSPICIOUS_UA_PATTERN = re.compile(
rf"(?i)({COMMON_SUSPICIOUS_TOKENS}|\b({SUSPICIOUS_KEYWORDS})\b)"
)
NORMALIZED_USER_AGENT = "Xwebstar/1.0"
IS_NORMALIZE_USER_AGENT = False
# Static cookie names (Cloudflare, Analytics/GA4, F5 APM, Imperva, Akamai, AWS WAF/ALB, DataDome, WordPress)
THIRD_PARTY_COOKIE_NAMES = {
# Cloudflare
"__cf_bm", "_cfuvid", "cf_clearance", "__cflb",
# Analytics / Trackers
"_ga", "_gid", "_gat", "_clck", "_clsk", "_fbp",
# F5 BIG-IP APM (Static access cookies)
"MRHSession", "LastMRH_Session", "F5_ST", "TIN",
# Imperva / Incapsula
"reese84",
# Akamai Bot Manager / Kona WAF
"ak_bmsc", "bm_sv", "bm_sz", "_abck",
# AWS WAF / ALB
"aws-waf-token", "AWSALB", "AWSALBCORS",
# DataDome
"datadome",
# WordPress
"wordpress_test_cookie"
}
_THIRD_PARTY_COOKIE_NAMES_LOWER = {c.lower() for c in THIRD_PARTY_COOKIE_NAMES}
# Dynamic cookie name prefixes (F5 ASM/LTM/APM, GA4, Imperva Incapsula, WordPress CMS)
# Matches:
# - TS + 6 to 8 hex chars (Standard F5 ASM main & frame cookies, e.g., TS01a2b3c4, TS01a2b3c4_28)
# - TSPD_101* (F5 Proactive Bot Defense)
# - BIGipServer* (F5 LTM Pool persistence)
# - F5_* (F5 APM dynamic attributes)
# - _ga_* (Google Analytics 4 property cookies)
# - visid_incap_*, incap_ses_*, nlbi_* (Imperva Incapsula WAF / Load Balancer)
# - wordpress_*, wp-settings*, comment_author_* (WordPress CMS session/comment cookies)
DYNAMIC_WAF_COOKIE_REGEX = re.compile(
r"^(TS[0-9a-fA-F]{6,8}(?:_\d+)?|TSPD_\d+.*|BIGipServer.*|F5_.*|_ga_.*|visid_incap_.*|incap_ses_.*|nlbi_.*|wordpress_.*|wp-settings.*|comment_author_.*)$",
re.IGNORECASE
)
# Session and authentication cookies to anonymize to NONE when benign (WAFFilter parity)
SESSION_COOKIE_NAMES = {
"jsessionid", "phpsessid", "aspsessionid", "asp.net_sessionid",
"sails.sid", "connect.sid", "sid", "session_id", "sessionid", "session",
"xsrf-token", "csrf-token", "_csrf", "auth", "token"
}
# Allowed characters for a healthy session/infrastructure token (hex, base64, uuid, timestamps, URL-encoded CMS tokens)
HEALTHY_TOKEN_CHARS = re.compile(r'^[a-zA-Z0-9._\-~%+=:/@|]{1,500}$')
# Suspicious injection patterns for Cookies:
# Whitespace (\s) is forbidden because valid session/tracking cookies are continuous tokens.
SUSPICIOUS_COOKIE_PATTERN = re.compile(
rf"(?i)({COMMON_SUSPICIOUS_TOKENS}|\s|\b({SUSPICIOUS_KEYWORDS})\b)"
)
TARGET_HEADERS = [
'content-type', 'host', 'cookie', 'referer', 'user-agent',
'x-forwarded-for', 'x-real-ip', 'true-client-ip', 'x-forwarded-host', 'x-original-url',
'cf-connecting-ip', 'x-forwarded',
'authorization', 'custom-auth-token', 'x-api-key', 'grpc-trace-bin'
]
ANONYMIZE_HEADERS = [
'host', 'x-forwarded-for', 'x-real-ip', 'true-client-ip',
'x-forwarded-host', 'x-original-url', 'referer',
'cf-connecting-ip', 'x-forwarded'
]
@classmethod
def is_third_party_or_waf_cookie(cls, cookie_name: str) -> bool:
"""Check if a cookie name belongs to known third-party trackers or WAF/LB appliances."""
if not cookie_name or not isinstance(cookie_name, str):
return False
return (
cookie_name in cls.THIRD_PARTY_COOKIE_NAMES
or cookie_name.lower() in cls._THIRD_PARTY_COOKIE_NAMES_LOWER
or bool(cls.DYNAMIC_WAF_COOKIE_REGEX.match(cookie_name))
)
@classmethod
def is_healthy_user_agent(cls, user_agent: str) -> bool:
"""
Checks if a User-Agent is structurally benign and free from injection vectors
(SQLi, XSS, Path Traversal, Command Injection, JNDI/SpEL).
Matches isHealthyUserAgent() from WAFFilter.java.
"""
if not user_agent or not isinstance(user_agent, str):
return True
return not bool(cls.SUSPICIOUS_UA_PATTERN.search(user_agent))
@classmethod
def is_healthy_cookie_value(cls, value: str) -> bool:
"""
Validates if a cookie value is structurally benign (token, UUID, timestamp, Base64)
and free from injection attacks (SQLi, XSS, Path Traversal), both raw and after recursive decoding.
Matches isHealthyCookieValue() from WAFFilter.java.
"""
if not value or not isinstance(value, str):
return True
if not cls.HEALTHY_TOKEN_CHARS.match(value):
return False
if cls.SUSPICIOUS_COOKIE_PATTERN.search(value):
return False
decoded = cls.advanced_decode(value)
if cls.SUSPICIOUS_UA_PATTERN.search(decoded):
return False
return True
@classmethod
def anonymize_cookies(cls, cookie_header: str) -> str:
"""
Cleans and anonymizes cookies for AI inspection (identical to WAFFilter.java):
1. Drops benign third-party/telemetry/WAF cookies (__cf_bm, _ga, F5 BIG-IP, Imperva, Akamai, AWS WAF, DataDome) to eliminate noise.
2. Anonymizes benign session cookies to '=NONE'.
3. Preserves raw values if any suspicious pattern is detected.
Returns the sanitized cookie header or empty string if all cookies were dropped.
"""
if not cookie_header or not isinstance(cookie_header, str):
return ""
pairs = cookie_header.split(";")
result_pairs = []
for pair in pairs:
trimmed = pair.strip()
if not trimmed or trimmed.lower() in ("none", "null", "undefined", '""', "''"):
continue
eq_idx = trimmed.find("=")
if eq_idx == -1:
result_pairs.append(trimmed)
continue
name = trimmed[:eq_idx].strip()
value = trimmed[eq_idx + 1:].strip()
name_lower = name.lower()
if not value or value.lower() in ("none", "null", "undefined", '""', "''"):
continue
healthy = cls.is_healthy_cookie_value(value)
if cls.is_third_party_or_waf_cookie(name):
if not healthy:
result_pairs.append(trimmed)
elif name_lower in cls.SESSION_COOKIE_NAMES or name_lower.startswith("idea-"):
if healthy:
result_pairs.append(f"{name}=NONE")
else:
result_pairs.append(trimmed)
else:
result_pairs.append(trimmed)
return "; ".join(result_pairs)
@classmethod
def anonymize_domain_ip(cls, text: str) -> str:
"""Replaces raw IP addresses and domain names with <IP> and <DOMAIN> tokens."""
if not isinstance(text, str) or not text:
return ""
text = cls.IP_REGEX.sub('<IP>', text)
text = cls.DOMAIN_REGEX.sub('<DOMAIN>', text)
text = re.sub(r'\blocalhost(?::\d{1,5})?\b', '<DOMAIN>', text, flags=re.IGNORECASE)
return text
LOG4J_LOOKUP_REGEX = re.compile(r'\$\{(?:lower|upper):([a-zA-Z0-9])\}|\$\{[^}{]*:-([a-zA-Z0-9])\}', re.IGNORECASE)
INT_IPV4_URL_REGEX = re.compile(r'(https?://)(\d{8,10})(?=[/:?#]|$)', re.IGNORECASE)
SPONGE_CASE_KW_REGEX = re.compile(
r'\b(union|select|insert|update|delete|drop|exec|script|alert|onerror|onload|javascript|eval)\b',
re.IGNORECASE
)
BENIGN_BOT_URL_IN_UA_REGEX = re.compile(r'\s*\(\s*\+?https?://[^)]+\)', re.IGNORECASE)
@classmethod
def advanced_decode(cls, text: str, max_depth: int = 3) -> str:
"""
Recursive anti-evasion decoder:
Applies cascading URL unquoting, Base64 payload decoding, Log4j nested lookup de-obfuscation,
32-bit decimal IPv4 URL decoding, and sponge-case keyword canonicalization.
"""
if not isinstance(text, str) or not text:
return ""
def replace_b64(match):
b64_str = match.group(0)
try:
pad_len = (4 - len(b64_str) % 4) % 4
padded = b64_str + '=' * pad_len
decoded_bytes = base64.b64decode(padded, validate=True)
return decoded_bytes.decode('utf-8')
except Exception:
return b64_str
def replace_int_ip(match):
scheme = match.group(1)
try:
num = int(match.group(2))
if 16777216 <= num <= 4294967295:
ip = f"{(num >> 24) & 0xFF}.{(num >> 16) & 0xFF}.{(num >> 8) & 0xFF}.{num & 0xFF}"
return f"{scheme}{ip}"
except Exception:
pass
return match.group(0)
def replace_sponge_kw(match):
w = match.group(0)
if not w.islower() and not w.isupper() and not w.istitle():
return w.upper()
return w
current_text = text
for _ in range(max_depth):
prev_text = current_text
try:
current_text = urllib.parse.unquote_plus(current_text)
except Exception:
pass
current_text = cls.B64_REGEX.sub(replace_b64, current_text)
current_text = cls.LOG4J_LOOKUP_REGEX.sub(lambda m: m.group(1) or m.group(2), current_text)
if current_text == prev_text:
break
current_text = cls.INT_IPV4_URL_REGEX.sub(replace_int_ip, current_text)
current_text = cls.SPONGE_CASE_KW_REGEX.sub(replace_sponge_kw, current_text)
return current_text
@classmethod
def format_headers(
cls,
headers_input: Union[Dict[str, Any], str],
is_normalize_user_agent: bool = None,
normalized_user_agent: str = None,
add_missing_user_agent: bool = False
) -> str:
"""Filters, anonymizes, decodes, and formats HTTP headers."""
if is_normalize_user_agent is None:
is_normalize_user_agent = cls.IS_NORMALIZE_USER_AGENT
if normalized_user_agent is None:
normalized_user_agent = cls.NORMALIZED_USER_AGENT
headers_dict = {}
if isinstance(headers_input, dict):
headers_dict = headers_input
elif isinstance(headers_input, str) and headers_input.strip():
try:
import json
headers_dict = json.loads(headers_input)
except Exception:
for line in headers_input.split('\n'):
if ':' in line:
k, v = line.split(':', 1)
headers_dict[k.strip()] = v.strip()
filtered_headers = {}
for k, v in headers_dict.items():
k_lower = str(k).lower().strip()
v_str = str(v).strip()
if not v_str or v_str.lower() == "nan":
continue
decoded_val = cls.advanced_decode(v_str)
is_suspicious_non_target = (
k_lower not in cls.TARGET_HEADERS
and k_lower not in ("content-length", "accept", "accept-encoding", "connection", "cache-control")
and (not cls.is_healthy_user_agent(v_str) or not cls.is_healthy_user_agent(decoded_val))
)
if k_lower in cls.TARGET_HEADERS or is_suspicious_non_target:
if k_lower in cls.ANONYMIZE_HEADERS:
v_str = cls.anonymize_domain_ip(v_str)
if k_lower == "cookie":
v_str = cls.anonymize_cookies(v_str)
if not v_str or not v_str.strip():
continue
if k_lower == "user-agent":
decoded_ua = cls.advanced_decode(v_str)
if cls.is_healthy_user_agent(v_str) and cls.is_healthy_user_agent(decoded_ua):
if is_normalize_user_agent:
v_str = normalized_user_agent
else:
v_str = cls.BENIGN_BOT_URL_IN_UA_REGEX.sub("", v_str).strip() or v_str
v_str = cls.advanced_decode(v_str)
if len(v_str) > 150:
v_str = v_str[:100] + "...[TRUNCATED]"
filtered_headers[k_lower] = v_str
# Optional: insert normalized User-Agent if absent and add_missing_user_agent is True
if add_missing_user_agent and "user-agent" not in filtered_headers:
filtered_headers["user-agent"] = normalized_user_agent
headers_str = " | ".join([f"{k}: {v}" for k, v in filtered_headers.items()])
return headers_str if headers_str else "NONE"
@classmethod
def normalize_user_agent_in_sequence(
cls,
seq: str,
is_normalize_user_agent: bool = None,
normalized_user_agent: str = None,
add_missing_user_agent: bool = False
) -> str:
"""
Normalizes the User-Agent header inside an already serialized Landmark sequence
if the User-Agent is structurally healthy.
"""
if is_normalize_user_agent is None:
is_normalize_user_agent = cls.IS_NORMALIZE_USER_AGENT
if normalized_user_agent is None:
normalized_user_agent = cls.NORMALIZED_USER_AGENT
if not is_normalize_user_agent or not isinstance(seq, str):
return seq
def replace_ua(match):
prefix = match.group(1)
raw_ua = match.group(2).strip()
decoded_ua = cls.advanced_decode(raw_ua)
trailing = ' ' if match.group(2).endswith(' ') else ''
if cls.is_healthy_user_agent(raw_ua) and cls.is_healthy_user_agent(decoded_ua):
return f"{prefix}{normalized_user_agent}{trailing}"
return match.group(0)
if re.search(r'\buser-agent:', seq, re.IGNORECASE):
return re.sub(
r'(\buser-agent:\s*)([^|\[]+)',
replace_ua,
seq,
flags=re.IGNORECASE
)
elif add_missing_user_agent:
def add_ua(match):
headers_part = match.group(1).strip()
if headers_part == "NONE" or not headers_part:
return f"HEADERS: user-agent: {normalized_user_agent} [SEP]"
return f"HEADERS: {headers_part} | user-agent: {normalized_user_agent} [SEP]"
return re.sub(
r'HEADERS:\s*(.*?)\s*\[SEP\]',
add_ua,
seq
)
return seq
@classmethod
def normalize_cookies_in_sequence(cls, seq: str) -> str:
"""
Normalizes or drops benign cookies inside an already serialized Landmark sequence.
"""
if not isinstance(seq, str) or not re.search(r'\bcookie:\s*', seq, re.IGNORECASE):
return seq
def replace_cookie(match):
prefix = match.group(1)
raw_cookie = match.group(2).strip()
cleaned = cls.anonymize_cookies(raw_cookie)
if not cleaned:
return ""
trailing = " " if match.group(2).endswith(" ") else ""
return f"{prefix}{cleaned}{trailing}"
res = re.sub(r'(\bcookie:\s*)([^|\[]+)', replace_cookie, seq, flags=re.IGNORECASE)
res = re.sub(r'\s*\|\s*\|\s*', ' | ', res)
res = re.sub(r'HEADERS:\s*\|\s*', 'HEADERS: ', res)
res = re.sub(r'\s*\|\s*\[SEP\]', ' [SEP]', res)
return res
BENIGN_HTML5_DOCTYPE_REGEX = re.compile(r'<!DOCTYPE\s+html\s*>\s*', re.IGNORECASE)
@classmethod
def format_body(cls, raw_body: Any) -> str:
"""Sanitizes and decodes HTTP request body."""
if raw_body is None or str(raw_body).lower() in ["null", "none", "", "nan"]:
return "EMPTY_BODY"
body_str = str(raw_body).strip()
safe_body = cls.advanced_decode(body_str)
safe_body = cls.BENIGN_HTML5_DOCTYPE_REGEX.sub("", safe_body).strip()
return safe_body if len(safe_body) > 0 else "EMPTY_BODY"
@classmethod
def serialize_http(cls, method: str, uri: str, headers: Union[Dict[str, Any], str], body: Any) -> str:
"""Serializes an HTTP request into the landmark sequence format."""
clean_method = str(method if method else "GET").upper().strip()
clean_uri = cls.advanced_decode(str(uri).strip()) if uri else "/"
if not clean_uri.startswith("/"):
clean_uri = "/" + clean_uri
headers_str = cls.format_headers(headers)
safe_body = cls.format_body(body)
return f"METHOD: {clean_method} [SEP] URI: {clean_uri} [SEP] HEADERS: {headers_str} [SEP] BODY: {safe_body}"
@classmethod
def parse_raw_http(cls, raw_text: str) -> Dict[str, Any]:
"""Splits a raw RFC HTTP request (or raw URL) into method, uri, headers, body."""
lines = str(raw_text).strip().splitlines()
if not lines:
return {"method": "GET", "uri": "/", "headers": {}, "body": None}
req_line = lines[0].strip().split()
first_token = req_line[0] if len(req_line) > 0 else "/"
if len(req_line) == 1 and (first_token.lower().startswith(("http://", "https://")) or first_token.startswith("/")):
method = "GET"
raw_url = first_token
else:
method = first_token if len(req_line) > 0 else "GET"
raw_url = req_line[1] if len(req_line) > 1 else "/"
parsed = urllib.parse.urlparse(raw_url)
uri = parsed.path if parsed.path else "/"
if parsed.query:
uri += f"?{parsed.query}"
headers = {}
if parsed.netloc:
headers["Host"] = parsed.netloc
body_lines = []
in_body = False
for line in lines[1:]:
if in_body:
body_lines.append(line)
elif line.strip() == "":
in_body = True
elif ":" in line:
k, v = line.split(":", 1)
headers[k.strip()] = v.strip()
body = "\n".join(body_lines).strip() if body_lines else None
return {"method": method, "uri": uri, "headers": headers, "body": body}
@classmethod
def extract_row_label(cls, row: Union[Dict[str, Any], Any]) -> int:
"""Detects and normalizes the binary label (0=Safe, 1=Attack) across all dataset schemas."""
if hasattr(row, 'to_dict'):
row = row.to_dict()
if "waf_id" in row or ("status_code" in row and "payload" in row):
sc = str(row.get("status_code", "")).strip()
if sc in ("403", "406", "401", "429", "500") or "waf_id" in row:
return 1
if ("attack_type" in row or ("type" in row and "payload" in row)) and "label" not in row and "Label" not in row:
t_val = str(row.get("attack_type") or row.get("type") or "").strip().lower()
if t_val and t_val not in ("0", "normal", "benign", "safe", "legitimate"):
return 1
target_val = row.get("target")
if isinstance(target_val, str) and target_val.lower().startswith(("http://", "https://")):
target_val = None
raw = row.get("label", row.get("Label", row.get("labels", target_val if target_val is not None else row.get("expected_action", 0))))
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return 1 if int(raw) > 0 else 0
raw_str = str(raw).strip().lower()
if raw_str.isdigit():
return 1 if int(raw_str) > 0 else 0
if raw_str in ["1", "malicious", "anomalous", "attack", "true", "block", "sqli", "xss"]:
return 1
return 0
@classmethod
def extract_row_components(cls, row: Union[Dict[str, Any], Any]) -> Dict[str, Any]:
"""Extracts (method, uri, headers, body) regardless of the source dataset schema."""
if hasattr(row, 'to_dict'):
row = row.to_dict()
if ("text" in row and isinstance(row["text"], str) and not row.get("uri") and not row.get("url")) or \
("request" in row and isinstance(row["request"], str) and not row.get("uri") and not row.get("url")):
raw_content = row.get("text") if "text" in row else row.get("request")
parsed = cls.parse_raw_http(raw_content)
method = row.get("method") or parsed["method"]
return {
"method": str(method).upper() if method else "GET",
"uri": parsed["uri"],
"headers": parsed["headers"],
"body": parsed["body"]
}
if ("payload" in row or "Payload" in row) and not row.get("uri") and not row.get("url") and not row.get("path"):
payload = str(row.get("payload") if "payload" in row else row.get("Payload") or "").strip()
target = str(row.get("target") or "").strip()
technique = str(
row.get("technique") or row.get("injection_point") or row.get("target_component") or ""
).strip().lower()
raw_headers = row.get("headers")
headers = dict(raw_headers) if isinstance(raw_headers, dict) else {}
parsed_target = urllib.parse.urlparse(target) if target.lower().startswith(("http://", "https://")) else None
base_path = (parsed_target.path if parsed_target and parsed_target.path else "/")
if parsed_target and parsed_target.netloc and "host" not in {str(k).lower() for k in headers}:
headers["Host"] = parsed_target.netloc
p_lower = payload.lower()
is_json = payload.startswith("{") or payload.startswith("[") or "json" in technique
is_xml = (
p_lower.startswith("<?xml")
or p_lower.startswith("<!doctype")
or p_lower.startswith("<soap")
or "xml" in technique
)
is_post = (
"post" in technique
or "body" in technique
or "command" in technique
or is_json
or is_xml
or "\n" in payload
or "\r" in payload
or "%0a" in p_lower
or "%0d" in p_lower
or "%0c" in p_lower
or len(payload) > 120
)
if is_post:
method = "POST"
uri = base_path
body = payload
if is_json:
headers.setdefault("Content-Type", "application/json")
elif is_xml:
headers.setdefault("Content-Type", "application/xml")
else:
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
else:
method = "GET"
body = None
if payload.startswith("/") or payload.startswith(".."):
uri = "/" + payload.lstrip("/")
elif "=" in payload and not payload.startswith(("<", "'", '"', ";", "|", "$", "`")):
uri = f"{base_path.rstrip('/')}/?{payload}" if base_path == "/" else f"{base_path}?{payload}"
else:
uri = f"{base_path.rstrip('/')}/?q={payload}" if base_path == "/" else f"{base_path}?q={payload}"
return {
"method": method,
"uri": uri,
"headers": headers,
"body": body
}
if "path" in row and row["path"] is not None:
uri = str(row["path"])
query = row.get("query")
if isinstance(query, dict) and query:
clean_query = {k: v for k, v in query.items() if v is not None}
if clean_query:
uri += f"?{urllib.parse.urlencode(clean_query)}"
elif isinstance(query, str) and query.strip():
uri += f"?{query.strip()}"
method = row.get("method", "GET")
headers = row.get("headers") or {}
body = row.get("body")
return {
"method": str(method).upper() if method else "GET",
"uri": uri,
"headers": headers,
"body": body
}
if "uri" in row and row["uri"] is not None:
uri = str(row["uri"])
elif "url" in row and row["url"] is not None:
raw_url = str(row["url"]).strip().split(" HTTP")[0].strip()
parsed = urllib.parse.urlparse(raw_url)
uri = parsed.path if parsed.path else "/"
if parsed.query:
uri += f"?{parsed.query}"
else:
uri = "/"
headers = row.get("headers") or {}
body = row.get("body")
method = row.get("method")
if not method or str(method).lower() == "nan":
raw_body = str(body or "").strip().lower()
content_type = ""
if isinstance(headers, dict):
content_type = str(headers.get("Content-Type", headers.get("content-type", ""))).lower()
elif isinstance(headers, str):
content_type = headers.lower()
if (raw_body and raw_body not in ["none", "empty_body", "nan", "null"]) or "urlencoded" in content_type or "json" in content_type:
method = "POST"
else:
method = "GET"
return {
"method": str(method).upper() if method else "GET",
"uri": uri,
"headers": headers,
"body": body
}
@classmethod
def serialize_row(cls, row: Union[Dict[str, Any], Any]) -> str:
"""Serializes any external or internal dataset row into an HTTP Landmark sequence."""
comp = cls.extract_row_components(row)
return cls.serialize_http(
method=comp["method"],
uri=comp["uri"],
headers=comp["headers"],
body=comp["body"]
)
π§ͺ Live Benchmark Suite & Empirical Validation (OWASP Top 10)
You can run the complete evaluation suite locally using test_hf.py:
python test_hf.py --hub
Real Console Output (CUDA bfloat16, Decision Threshold = 85.0%):
====================================================================
π‘οΈ IRONJEAN-2.1 HUGGING FACE NATIVE BENCHMARK (AutoModel / Pipeline)
π¦ Model Source: Metabaron6/IronJean-v2-ModernBERT-WAF
βοΈ Architecture: Vectorized Landmark Pooling (trust_remote_code=True)
====================================================================
π₯ Loading IronJean-2.1 via AutoModelForSequenceClassification (trust_remote_code=True)...
Target Source: Metabaron6/IronJean-v2-ModernBERT-WAF
β Config loaded | Target Labels: {0: 'SAFE', 1: 'ATTACK'}
β Tokenizer loaded | Vocabulary size: 50280 tokens
β Architecture resolved: IronJeanWAFClassifier
β Successfully initialized on CUDA.
π Starting evaluation suite (max_length=8192, decision_threshold=85.0%)...
π SCENARIO : ATTACK (Command Injection / OS Cmd) -> [ATTACK BLOCKED] π (100.00%) 255.73 ms
π SCENARIO : BENIGN (Standard REST API Request) -> [SAFE ALLOWED] β
(100.00%) 25.94 ms
π SCENARIO : ATTACK (Base64 Evasion / Java Deserialization) -> [ATTACK BLOCKED] π (100.00%) 25.44 ms
π SCENARIO : BENIGN (OAuth2 Token Exchange) -> [SAFE ALLOWED] β
(100.00%) 27.74 ms
π SCENARIO : BENIGN (JSON Form with Natural Apostrophe) -> [SAFE ALLOWED] β
(100.00%) 25.73 ms
π SCENARIO : ATTACK (SQL Injection UNION SELECT) -> [ATTACK BLOCKED] π (100.00%) 26.49 ms
π SCENARIO : BENIGN (WordPress Registration Form) -> [SAFE ALLOWED] β
(100.00%) 44.94 ms
π SCENARIO : ATTACK (User-Agent SQL Injection) -> [ATTACK BLOCKED] π (100.00%) 24.55 ms
π SCENARIO : ATTACK (User-Agent Log4Shell JNDI) -> [ATTACK BLOCKED] π (100.00%) 25.75 ms
π SCENARIO : BENIGN (Cookie Anonymization - Session & Telemetry Dropped) -> [SAFE ALLOWED] β
(100.00%) 27.07 ms
π SCENARIO : ATTACK (Cookie SQL Injection) -> [ATTACK BLOCKED] π (100.00%) 29.00 ms
====================================================================
π Verifying Hugging Face pipeline('text-classification', trust_remote_code=True):
====================================================================
Sample Payload : METHOD: GET [SEP] URI: /products?category=1' UNION SELECT null,password FROM users-- [SEP] HEADERS: host: <DOMAIN> [SEP] BODY: EMPTY_BODY
Pipeline Result: [{'label': 'ATTACK', 'score': 0.9999966621398926}]
====================================================================
β
All Hugging Face AutoModel & Pipeline tests completed successfully!
====================================================================
π» Python Quickstart
Native Hugging Face Loading (trust_remote_code=True)
Thanks to modeling_ironjean.py and trust_remote_code=True, IronJean's Vectorized Landmark Pooling architecture is instantiated dynamically without requiring custom PyTorch classes.
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer, pipeline
# Import the normalizer from core or copy the standalone definition above
from core.normalizer import HTTPNormalizer
# =====================================================================
# Option 1: Native Hugging Face Pipeline (Zero-Boilerplate)
# =====================================================================
classifier = pipeline(
"text-classification",
model="Metabaron6/IronJean-v2-ModernBERT-WAF",
trust_remote_code=True,
device=0 if torch.cuda.is_available() else -1
)
# =====================================================================
# Option 2: Native AutoModel with BFloat16 Acceleration
# =====================================================================
device = "cuda" if torch.cuda.is_available() else "cpu"
model_id = "Metabaron6/IronJean-v2-ModernBERT-WAF"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForSequenceClassification.from_pretrained(
model_id,
trust_remote_code=True,
torch_dtype=torch.bfloat16 if device == "cuda" else torch.float32
).to(device).eval()
def classify_http_request(raw_sequence: str, threshold: float = 85.0):
# Step 1: Tokenize with up to 8192 context window
inputs = tokenizer(raw_sequence, return_tensors="pt", truncation=True, max_length=8192).to(device)
# Step 2: Vectorized Landmark Pooling inference
with torch.no_grad():
outputs = model(**inputs)
probs = torch.softmax(outputs.logits, dim=-1)
attack_prob = probs[0][1].item() * 100.0
is_attack = attack_prob >= threshold
return is_attack, attack_prob
# 2. Format requests using HTTPNormalizer & run inference
# Case A: SQL Injection in URI (Benign User-Agent normalized to Xwebstar/1.0)
sample_attack = HTTPNormalizer.serialize_http(
method="GET",
uri="/api/v1/users?id=1' OR '1'='1",
headers={"Host": "api.corporate.net", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
body=None
)
blocked, prob = classify_http_request(sample_attack)
print(f"Attack URI Probability: {prob:.2f}% | Decision: {'π BLOCKED' if blocked else 'β
ALLOWED'}")
# Case B: Benign Business Form with Natural Text / Apostrophe
sample_safe = HTTPNormalizer.serialize_http(
method="POST",
uri="/api/v1/profile",
headers={"Host": "api.corporate.net", "User-Agent": "curl/8.11.1", "Content-Type": "application/json"},
body='{"name": "O\'Connor", "bio": "Software Engineer. It\'s a genuine profile.", "settings": {"theme": "dark"}}'
)
blocked, prob = classify_http_request(sample_safe)
print(f"Safe Request Attack Probability: {prob:.2f}% | Decision: {'π BLOCKED' if blocked else 'β
ALLOWED'}")
# Case C: Malicious Attack Injected Inside User-Agent Header (Log4Shell / SQLi)
# is_healthy_user_agent flags the suspicious pattern, preserves raw payload, and blocks it!
sample_ua_attack = HTTPNormalizer.serialize_http(
method="GET",
uri="/api/v1/users",
headers={"Host": "api.corporate.net", "User-Agent": "Mozilla/5.0' OR '1'='1--"},
body=None
)
blocked, prob = classify_http_request(sample_ua_attack)
print(f"User-Agent Attack Probability: {prob:.2f}% | Decision: {'π BLOCKED' if blocked else 'β
ALLOWED'}")
# Case D: Cookie Anonymization vs Malicious Cookie Injection (WAFFilter Parity)
# Benign telemetry cookies (_ga, __cf_bm) are dropped, session cookie is neutralized to NONE:
sample_cookie_safe = HTTPNormalizer.serialize_http(
method="POST",
uri="/api/v1/checkout",
headers={
"Host": "api.corporate.net",
"Cookie": "JSESSIONID=5d41402abc4b2a76b9719d911017c592; _ga=GA1.2.123456789.1234567890; __cf_bm=abcdef123456",
"User-Agent": "Mozilla/5.0"
},
body="cart_id=98712&item=44"
)
blocked, prob = classify_http_request(sample_cookie_safe)
print(f"Safe Cookie Request Attack Probability: {prob:.2f}% | Decision: {'π BLOCKED' if blocked else 'β
ALLOWED'}")
# Malicious Injection inside Cookie (SQLi preserved and deterministically blocked):
sample_cookie_attack = HTTPNormalizer.serialize_http(
method="GET",
uri="/dashboard",
headers={"Host": "api.corporate.net", "Cookie": "JSESSIONID=1' OR '1'='1--", "User-Agent": "Mozilla/5.0"},
body=None
)
blocked, prob = classify_http_request(sample_cookie_attack)
print(f"Cookie SQLi Attack Probability: {prob:.2f}% | Decision: {'π BLOCKED' if blocked else 'β
ALLOWED'}")
β Enterprise Java & Tomcat Integration (ONNX Runtime)
For production deployments in Java (Spring Cloud Gateway, Tomcat Filter, Quarkus) use the FP32 ONNX model (model.onnx)
using ONNX Runtime and the Deep Java Library (DJL) for tokenization.
β οΈ Engineering Note on INT8 Quantization: ModernBERT incorporates Rotary Position Embeddings (RoPE) and specialized attention activation dynamics. Naive dynamic INT8 quantization collapses internal attention weights. Always deploy the FP32 ONNX model (
model.onnx) or FP16 for GPUs. At ~500 MB RAM footprint, FP32 runs at sub-5ms CPU latency via ONNX Runtime Java.
package com.security.waf;
import ai.djl.inference.Predictor;
import ai.djl.ndarray.NDArray;
import ai.djl.ndarray.NDList;
import ai.djl.ndarray.NDManager;
import ai.djl.repository.zoo.ZooModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ModernBertWafPredictor {
private static final Logger log = LoggerFactory.getLogger(ModernBertWafPredictor.class);
// The loaded ONNX model, thread-safe and shared across the application
private final ZooModel<NDList, NDList> onnxModel;
// ThreadLocal predictor since Predictor objects are NOT thread-safe in DJL
private final ThreadLocal<Predictor<NDList, NDList>> predictorPool;
public ModernBertWafPredictor(ZooModel<NDList, NDList> onnxModel) {
this.onnxModel = onnxModel;
this.predictorPool = ThreadLocal.withInitial(onnxModel::newPredictor);
}
/**
* Executes the WAF inference using dynamic sequence lengths.
*
* @param tokenIds The tokenized HTTP payload, inherently variable in length.
* @return true if the payload is classified as an Attack, false if Safe.
*/
public boolean isMalicious(long[] tokenIds) {
Predictor<NDList, NDList> predictor = predictorPool.get();
int actualSequenceLength = tokenIds.length;
long startTime = System.currentTimeMillis();
// CRITICAL: Try-with-resources to prevent C++ native memory leaks.
// Bypasses the Java Garbage Collector for immediate tensor destruction.
try (NDManager manager = onnxModel.getNDManager().newSubManager()) {
// 1. Construct input_ids with shape [1, actualSequenceLength]
// This perfectly matches the dynamic_axes 'sequence_length' configured in Python
NDArray inputIds = manager.create(new long[][]{ tokenIds });
inputIds.setName("input_ids");
// 2. Construct attention_mask with shape [1, actualSequenceLength]
// Since we use exact sizing instead of padding, all tokens are real.
// Therefore, the mask is simply an array of 1s of the same length.
long[] maskArray = new long[actualSequenceLength];
java.util.Arrays.fill(maskArray, 1L);
NDArray attentionMask = manager.create(new long[][]{ maskArray });
attentionMask.setName("attention_mask");
// 3. Package tensors into an NDList for the ONNX session
NDList inputs = new NDList(inputIds, attentionMask);
// 4. Run native C++ inference
NDList outputs = predictor.predict(inputs);
// 5. Extract logits.
// Shape is [1, 2] (Batch size 1, 2 probability classes: Safe vs Attack)
NDArray logits = outputs.getFirst();
// Apply softmax to convert raw logits to probabilities [0.0 - 1.0]
NDArray probabilities = logits.softmax(1);
// Extract the probability for class 1 (Attack)
float attackProbability = probabilities.getFloat(0, 1);
// Apply custom threshold (0.85 balances aggressive threat detection with dev/scanner tolerance)
float threshold = 0.85f;
boolean isMalicious = attackProbability > threshold;
if (log.isDebugEnabled()) {
//WafLogitAnalyzer.analyzeOnnxOutput(outputs);
long duration = System.currentTimeMillis() - startTime;
log.debug("WAF Inference - SeqLength: {} | AttackProb: {} | Prediction: {} | Execution Time: {} ms",
actualSequenceLength, String.format("%.4f", attackProbability), isMalicious ? "ATTACK" : "SAFE", duration);
}
return isMalicious;
} catch (Exception e) {
log.error("WAF Inference failed. Failsafe activated: blocking request.", e);
// Default deny in security contexts
return true;
}
}
/**
* Called during Tomcat shutdown or application undeploy to release native ONNX resources.
*/
public void close() {
if (predictorPool != null) {
predictorPool.remove();
}
if (onnxModel != null) {
onnxModel.close();
}
}
}
π License
Licensed under the Apache License, Version 2.0. Free for academic, personal, and commercial enterprise use.
βοΈ Citation
@software{IronJean-v2-ModernBERT-WAF,
author = {Franck Andriano},
title = {IronJean WAF v2.1: Enterprise Web Application Firewall Powered by ModernBERT and Landmark Pooling},
year = {2026},
publisher = {Hugging Face},
url = {https://huggingface.co/Metabaron6/IronJean-v2-ModernBERT-WAF}
}
- Downloads last month
- 143
Model tree for Metabaron6/IronJean-v2-ModernBERT-WAF
Base model
answerdotai/ModernBERT-base