mirror of
https://github.com/ParrotXray/Mantis.git
synced 2026-08-24 19:00:27 +09:00
Fix/rule issus (#11)
* wip * refactor: replace xsk_cpu_base with xsk_cpu_set for round-robin core distribution * refactor: replace native vectorscan rule engine with Suricata daemon mode * docs: overhaul TODO, research state, and CLAUDE.md post-Suricata refactor
This commit is contained in:
parent
d9c02047c5
commit
40d420a0d7
@ -1,13 +1,13 @@
|
||||
# NetGuardia Research State
|
||||
# Updated: 2026-05-19
|
||||
# Updated: 2026-05-21
|
||||
|
||||
[[epics]]
|
||||
id = "nids-v1"
|
||||
title = "NetGuardia NIDS v1 — Research Prototype"
|
||||
status = "active"
|
||||
description = """
|
||||
End-to-end NIDS combining eBPF packet capture, LSTM autoencoder ML inference,
|
||||
Suricata-compatible rule engine, and a Next.js monitoring frontend.
|
||||
End-to-end NIDS combining eBPF/AF_XDP packet capture, LSTM autoencoder ML inference,
|
||||
Suricata daemon integration (49 000+ ET Open rules), and a Next.js monitoring frontend.
|
||||
Target: demonstrate joint ML+Rule detection superiority over single-method baselines.
|
||||
"""
|
||||
|
||||
@ -15,22 +15,60 @@ Target: demonstrate joint ML+Rule detection superiority over single-method basel
|
||||
id = "backend-detection"
|
||||
epic = "nids-v1"
|
||||
status = "active"
|
||||
description = "ML pipeline, rule engine, fusion layer, and alert broadcast."
|
||||
description = """
|
||||
ML pipeline, Suricata daemon mode, fusion layer, and alert broadcast.
|
||||
Rule engine (vectorscan) fully replaced by Suricata 8 daemon + veth mirror.
|
||||
Fusion engine correlates ML anomaly scores with Suricata EVE JSON alerts.
|
||||
Next: TLS/JA3 analysis, XDP auto-block active response.
|
||||
"""
|
||||
|
||||
[[themes]]
|
||||
id = "backend-infra"
|
||||
epic = "nids-v1"
|
||||
status = "active"
|
||||
description = "GeoIP, logging, eBPF statistics, HTTP API, WebSocket delivery."
|
||||
description = """
|
||||
GeoIP, logging, eBPF statistics, HTTP API, WebSocket delivery.
|
||||
Fixed: GeoIP thread explosion (semaphore), maxminddb debug flood.
|
||||
Pending: account system (SQLite app.db), full REST API with auth.
|
||||
"""
|
||||
|
||||
[[themes]]
|
||||
id = "frontend"
|
||||
epic = "nids-v1"
|
||||
status = "active"
|
||||
description = "Next.js dashboard, detection page, WebSocket provider, config alignment."
|
||||
description = """
|
||||
Next.js dashboard, detection page, WebSocket provider, config alignment.
|
||||
Aligned with UnifiedAlert API (source/severity as lowercase strings).
|
||||
Open: dashboard counters never reset (accumulate forever).
|
||||
"""
|
||||
|
||||
[[themes]]
|
||||
id = "tls-analysis"
|
||||
epic = "nids-v1"
|
||||
status = "planned"
|
||||
description = """
|
||||
Encrypted traffic analysis without decryption.
|
||||
Suricata EVE tls events -> JA3 hash whitelist + cert anomaly rules + beacon detection.
|
||||
No ML model required initially; extend with classifier if FP rate is too high.
|
||||
Addresses the blind spot where Suricata rules cannot inspect TLS payload.
|
||||
"""
|
||||
|
||||
[[themes]]
|
||||
id = "active-response"
|
||||
epic = "nids-v1"
|
||||
status = "planned"
|
||||
description = """
|
||||
XDP-layer automatic IP blocking triggered by high-confidence FusionEngine alerts.
|
||||
BPF_MAP_TYPE_LRU_HASH in ingress XDP program; userspace inserts src_ip on alert.
|
||||
TTL-based expiry via background tokio task.
|
||||
Demonstrates sub-microsecond response latency (key academic differentiator).
|
||||
"""
|
||||
|
||||
[[themes]]
|
||||
id = "account-system"
|
||||
epic = "nids-v1"
|
||||
status = "parked"
|
||||
description = "SQLite-backed accounts, sessions, persistent whitelist/blacklist."
|
||||
description = """
|
||||
SQLite-backed accounts, sessions, persistent whitelist/blacklist.
|
||||
Deprioritised; not required for research evaluation.
|
||||
"""
|
||||
|
||||
440
Cargo.lock
generated
440
Cargo.lock
generated
@ -259,41 +259,6 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@ -363,45 +328,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.98",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_matches"
|
||||
version = "1.5.0"
|
||||
@ -723,16 +649,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
@ -744,15 +660,6 @@ dependencies = [
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
@ -876,39 +783,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.11"
|
||||
@ -1042,18 +925,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
@ -1099,33 +970,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-macros"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@ -1257,16 +1101,6 @@ dependencies = [
|
||||
"wasi 0.14.3+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@ -1335,15 +1169,6 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
@ -1540,15 +1365,6 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imapprotolens"
|
||||
version = "0.16.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4436252f26379481b02b023a59530310d17e1d68cee692416b162a44aa68db9"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "impl-more"
|
||||
version = "0.1.9"
|
||||
@ -1576,21 +1392,6 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.21.1"
|
||||
@ -1734,17 +1535,6 @@ dependencies = [
|
||||
"redox_syscall 0.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libxdp-sys"
|
||||
version = "0.2.3+1.5.6"
|
||||
@ -1910,12 +1700,6 @@ dependencies = [
|
||||
"thiserror 2.0.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
@ -2021,40 +1805,29 @@ dependencies = [
|
||||
"actix-cors",
|
||||
"actix-web",
|
||||
"actix-ws",
|
||||
"aes-gcm",
|
||||
"aya",
|
||||
"aya-log",
|
||||
"cargo_metadata",
|
||||
"cc",
|
||||
"chrono",
|
||||
"common",
|
||||
"crossbeam",
|
||||
"digest",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"lru",
|
||||
"macros",
|
||||
"maxminddb",
|
||||
"md5",
|
||||
"mime_guess",
|
||||
"ndarray 0.17.2",
|
||||
"network-types",
|
||||
"nom 7.1.3",
|
||||
"nom 8.0.0",
|
||||
"ort",
|
||||
"ort-tract",
|
||||
"parking_lot",
|
||||
"pcre2",
|
||||
"pcre2-sys",
|
||||
"protolens",
|
||||
"rusqlite",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
@ -2065,8 +1838,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tract-onnx",
|
||||
"url",
|
||||
"vectorscan-rs",
|
||||
"x509-parser",
|
||||
"xsk-rs",
|
||||
]
|
||||
|
||||
@ -2137,16 +1908,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@ -2232,27 +1993,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "ort"
|
||||
version = "2.0.0-rc.12"
|
||||
@ -2318,28 +2064,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pcre2"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e970b0fcce0c7ee6ef662744ff711f21ccd6f11b7cf03cd187a80e89797fc67"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"pcre2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pcre2-sys"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18b9073c1a2549bd409bf4a32c94d903bb1a09bf845bc306ae148897fa0760a4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@ -2389,48 +2113,6 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
@ -2449,18 +2131,6 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.0"
|
||||
@ -2565,22 +2235,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protolens"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c040ea146711a09a3e22158263859f189232b235626a922037a5dccab6926f6a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"imapprotolens",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nom 7.1.3",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.43"
|
||||
@ -2724,20 +2378,6 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.7.2"
|
||||
@ -2792,15 +2432,6 @@ dependencies = [
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.0.8"
|
||||
@ -2978,12 +2609,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
@ -3048,12 +2673,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@ -3601,16 +3220,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.7"
|
||||
@ -3647,36 +3256,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vectorscan-rs"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "657b43a26c41ea3e5b0de95240a518a011561d204b83f8ea213fddf2cc224fb0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"vectorscan-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorscan-rs-sys"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "195d07e20b78923c2e8542f95a9bfd31d0cacf19f9134181c292b84055c5c7c1"
|
||||
dependencies = [
|
||||
"cmake",
|
||||
"flate2",
|
||||
"tar",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@ -4026,23 +3605,6 @@ version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
|
||||
287
TODO
287
TODO
@ -1,238 +1,83 @@
|
||||
1. .csv 一開始文件格式為 traffic-yyyy-oo-zz.csv, 換天後直接變日期創新檔,例如今天啟動今天為 traffic-2026-05-11.csv 隔天變成 traffic-2026-05-12.csv [x]
|
||||
2. will change tract-onnx engine to ort-tract engine [x]
|
||||
3. 改善推論效能及速度,在大量資料時 [x]
|
||||
4. 實現 ML + RULE 的共用 HashMap 實現共同決策結果 [x]
|
||||
5. 代碼最佳化,檢查是否除了 build.rs 以外有無 .unwarp() and eprintln() [x]
|
||||
6. 完成前端 detection 頁面 [x]
|
||||
7. 使用 sqllite 實現帳號系統、白黑名單永久記錄
|
||||
|
||||
|
||||
[x]
|
||||
Suricata
|
||||
提升偵測準確率:
|
||||
pcre 很多規則依賴正則,沒有會漏掉很多威脅
|
||||
flow:established 減少掃握手封包的無意義處理
|
||||
Flowbits 複雜攻擊鏈的偵測(例如先偵測掃描再偵測滲透)
|
||||
|
||||
減少假陽性:
|
||||
$HOME_NET / $EXTERNAL_NET 區分內外網,很多規則依賴這個
|
||||
flow:established 順便過濾掉不完整連線
|
||||
|
||||
============================================================
|
||||
PRIORITY BACKLOG (updated 2026-05-19)
|
||||
NetGuardia TODO (updated 2026-05-21)
|
||||
============================================================
|
||||
|
||||
-- MUST DO (system is not usable without these) -----------
|
||||
-- DONE (archived) ----------------------------------------
|
||||
|
||||
[x] byte_test / byte_jump / byte_extract
|
||||
Completed 2026-05-15.
|
||||
build.rs: parse_byte_test/jump/extract -> SQLite byte_ops table.
|
||||
rule_engine.rs: eval_byte_ops() -> eval_byte_test/jump, read_bytes().
|
||||
Supports: relative, endian, string mode (dec/hex/oct), bitmask, vars.
|
||||
[x] CSV rolling log with date-based filenames
|
||||
[x] Migrate inference engine to ort-tract (pure Rust)
|
||||
[x] Improve ML inference throughput under high load
|
||||
[x] ML + Rule fusion decision layer (FusionEngine)
|
||||
[x] Code audit: remove .unwrap() / eprintln() outside build.rs
|
||||
[x] Frontend detection page
|
||||
[x] GeoIP unbounded thread explosion (semaphore cap = 8)
|
||||
[x] maxminddb DEBUG log flood (EnvFilter "maxminddb=warn")
|
||||
[x] Frontend UnifiedAlert API alignment (detection.tsx, WebSocketProvider, config.ts, dashboard.tsx)
|
||||
[x] Rule engine -> replaced entirely by Suricata daemon mode
|
||||
Removed: vectorscan, rusqlite, protolens, pcre2, byte_test/jump/extract,
|
||||
app-layer-protocol, threshold, QUIC parser, suppress list (SQLite).
|
||||
Replaced by: suricata daemon + veth mirror + EVE JSON unix socket.
|
||||
Suricata handles all signature matching; NetGuardia reads alerts via output.rs.
|
||||
[x] Suricata EVE socket race condition (bind before spawn)
|
||||
[x] af-packet block-size too small (32768 -> 131072)
|
||||
[x] Suricata noisy rule categories (exclude emerging-info/policy/user_agents)
|
||||
[x] suppress.conf: add known-benign SIDs (2013504 APT User-Agent etc.)
|
||||
|
||||
[x] app-layer-protocol: keyword
|
||||
Completed 2026-05-15.
|
||||
build.rs: extract_alproto() parses keyword -> (proto_id, negated).
|
||||
rule_engine.rs: pkt.proto as u8 == sig.alproto filter before chain.
|
||||
Protocols mapped: http(1) http2(2) tls(3) dns(4) ssh(5) smtp(6)
|
||||
ftp(7) mqtt(8) quic(9).
|
||||
-- SHOULD DO (detection capability) ----------------------
|
||||
|
||||
[x] ML + Rule fusion decision layer
|
||||
Core differentiator of the project. Currently ML and rule
|
||||
engine produce independent alerts with no cross-awareness.
|
||||
Design: shared per-flow HashMap; two fusion modes:
|
||||
AND - alert only when both agree
|
||||
OR - alert when either fires (with source tag)
|
||||
Files: detection/ml/engine.rs, detection/rule/rule_engine.rs,
|
||||
new detection/fusion.rs, model/ml_detection.rs
|
||||
[ ] TLS / JA3 fingerprint analysis
|
||||
Goal: detect encrypted malware C2 / beaconing without decryption.
|
||||
Step 1: add tls event type to suricata.yaml EVE outputs (extended: yes).
|
||||
Step 2: parse event_type == "tls" in output.rs -> extract ja3.hash, sni, cert info.
|
||||
Step 3: build JA3 whitelist (HashSet<String>) from observed normal traffic baseline.
|
||||
Step 4: alert on unknown JA3 hash + check cert anomalies (self-signed, expired, CN mismatch).
|
||||
Step 5: beacon detection -- low stddev of inter-connection interval to same dst_ip on 443.
|
||||
No model needed for steps 1-5; add dedicated classifier only if false-positive rate
|
||||
is unacceptable after baseline calibration.
|
||||
Files: detection/suricata/output.rs, detection/suricata/tls.rs (new),
|
||||
model/log/suricata.rs, model/error/suricata.rs
|
||||
|
||||
[x] threshold: complete three modes
|
||||
Completed 2026-05-17.
|
||||
build.rs: extract_threshold() parses type/track/count/seconds -> threshold_entries table.
|
||||
rule_engine.rs: ThresholdKey (BySrc/ByDst/ByBoth/ByRule); ThresholdState;
|
||||
parking_lot::Mutex<HashMap<(sig_idx, key), state>>; should_alert() mirrors
|
||||
Suricata ThresholdCheckUpdate() exactly:
|
||||
threshold (3) - fire on every Nth hit; reset after fire; no alert on window expiry
|
||||
limit (1) - fire on first N hits per window; alert after window reset
|
||||
both (2) - fire exactly on Nth hit (==, not >=); silence for N+1...; fire on expiry if count==1
|
||||
Track: TRACK_DST=1 TRACK_SRC=2 TRACK_RULE=3 TRACK_BOTH=5 TRACK_FLOW=6 (matches Suricata header)
|
||||
[ ] XDP active response (auto-block)
|
||||
Goal: block confirmed attacker IPs at kernel level (< 1 us per packet).
|
||||
Design: BPF_MAP_TYPE_LRU_HASH keyed by src IPv4/IPv6; XDP program checks map
|
||||
and returns XDP_DROP before any userspace processing.
|
||||
Trigger: FusionEngine fires a fusion or high-confidence ML alert -> insert src_ip
|
||||
into block map via userspace BPF map update API.
|
||||
Expiry: separate tokio task sweeps entries older than block_ttl_secs (config).
|
||||
API: POST /api/blacklist still works for manual entries; auto-block is additive.
|
||||
Files: ingress-ebpf/src/main.rs (map lookup + XDP_DROP),
|
||||
core/ebpf/xsk_manager.rs (map fd plumbing),
|
||||
detection/fusion.rs (trigger auto-block on high-confidence events)
|
||||
|
||||
-- SHOULD DO (runtime management) ------------------------
|
||||
|
||||
[ ] Account system + persistent list DB (updated 2026-05-18)
|
||||
Two SQLite files, separated by concern:
|
||||
|
||||
static/db/rules.db (rule-related, already exists)
|
||||
suppress (id, sid INTEGER, track INTEGER, ip_net TEXT, comment TEXT, created_at)
|
||||
- track: 1=by_src 2=by_dst 4=by_either (Suricata values)
|
||||
- already populated by build.rs; API writes go here; RuleEngine reloads on change
|
||||
|
||||
static/db/app.db (account + network list, new)
|
||||
accounts (id, username, password_hash, role, created_at)
|
||||
- role: "admin" | "viewer"
|
||||
- password_hash: argon2 or bcrypt
|
||||
sessions (token TEXT PK, account_id, expires_at)
|
||||
- token: 32-byte random hex, expires in 24 h
|
||||
whitelist (id, ip_net TEXT, comment TEXT, created_at)
|
||||
- packets from whitelisted CIDRs skip rule + ML evaluation entirely
|
||||
blacklist (id, ip_net TEXT, comment TEXT, action TEXT, created_at)
|
||||
- action: "alert" | "drop"
|
||||
- packets from blacklisted CIDRs auto-alert without rule evaluation
|
||||
[ ] Account system + persistent lists
|
||||
Two SQLite files:
|
||||
static/db/app.db
|
||||
accounts (id, username, password_hash, role, created_at)
|
||||
sessions (token TEXT PK, account_id, expires_at)
|
||||
whitelist (id, ip_net TEXT, comment TEXT, created_at)
|
||||
blacklist (id, ip_net TEXT, action TEXT, comment TEXT, created_at)
|
||||
Migration: CREATE TABLE IF NOT EXISTS at startup in AppServices::init().
|
||||
File: core/infrastructure/app_db.rs (new)
|
||||
|
||||
[ ] HTTP API - accounts / suppress / whitelist / blacklist / rules
|
||||
All routes require session token in Authorization: Bearer <token> header
|
||||
except POST /api/auth/login.
|
||||
|
||||
Auth:
|
||||
POST /api/auth/login - { username, password } -> { token, expires_at }
|
||||
POST /api/auth/logout - invalidate current token
|
||||
GET /api/auth/me - current account info
|
||||
|
||||
Accounts (admin only):
|
||||
GET /api/accounts - list accounts
|
||||
POST /api/accounts - create account { username, password, role }
|
||||
PUT /api/accounts/:id/password - change password
|
||||
DELETE /api/accounts/:id - delete account
|
||||
|
||||
Suppress:
|
||||
GET /api/suppress - list all entries
|
||||
POST /api/suppress - add { sid, track, ip_net, comment }
|
||||
DELETE /api/suppress/:id - remove entry
|
||||
(runtime effect: RuleEngine reloads suppress table after write)
|
||||
|
||||
Whitelist:
|
||||
GET /api/whitelist - list entries
|
||||
POST /api/whitelist - add { ip_net, comment }
|
||||
DELETE /api/whitelist/:id - remove
|
||||
|
||||
Blacklist:
|
||||
GET /api/blacklist - list entries
|
||||
POST /api/blacklist - add { ip_net, action, comment }
|
||||
DELETE /api/blacklist/:id - remove
|
||||
|
||||
Rules:
|
||||
GET /api/rules - list loaded rules (sid, msg, enabled)
|
||||
POST /api/rules/reload - trigger hot-reload
|
||||
|
||||
System:
|
||||
POST /api/system/restart - graceful restart
|
||||
|
||||
Files: web/routes/auth.rs, web/routes/accounts.rs, web/routes/lists.rs,
|
||||
web/routes/rules.rs, web/routes/system.rs
|
||||
Middleware: web/middleware/auth.rs (token extractor + role check)
|
||||
|
||||
[ ] Hot-reload: rules + suppress without process restart
|
||||
Trigger: POST /api/rules/reload OR SIGHUP signal.
|
||||
Steps:
|
||||
1. build.rs logic extracted into a runtime fn rebuild_rules_db(rules_dir) -> Result<()>
|
||||
that re-parses *.rules files and rewrites rules.db in a temp path.
|
||||
2. RuleEngine::reload(new_db_path) acquires a write lock, swaps BlockDatabase +
|
||||
sigs vec + SuppressList atomically; old engine dropped after swap.
|
||||
3. ML engine is NOT restarted (model/config unchanged).
|
||||
4. In-flight threshold states are preserved across reload to avoid counter reset.
|
||||
Files: detection/rule/rule_engine.rs (add reload()), build.rs (extract parse logic),
|
||||
core/infrastructure/app_services.rs (wire SIGHUP handler via tokio::signal).
|
||||
[ ] HTTP API -- auth / accounts / lists / system
|
||||
All routes require Bearer token except POST /api/auth/login.
|
||||
Auth: POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me
|
||||
Accounts: GET/POST /api/accounts, PUT /api/accounts/:id/password, DELETE /api/accounts/:id
|
||||
Suppress: GET/POST/DELETE /api/suppress (writes suppress.conf + signals Suricata reload)
|
||||
Lists: GET/POST/DELETE /api/whitelist, /api/blacklist
|
||||
System: POST /api/system/restart
|
||||
Files: web/routes/{auth,accounts,lists,system}.rs, web/middleware/auth.rs
|
||||
|
||||
[ ] Graceful restart
|
||||
Trigger: POST /api/system/restart OR SIGTERM + re-exec.
|
||||
Steps:
|
||||
1. Drain in-flight packets: wait up to 2 s for flow tracker to flush.
|
||||
2. Detach eBPF programs (XDP unload) cleanly.
|
||||
3. Flush CSV writer and close traffic log.
|
||||
4. tokio runtime shutdown, then re-exec self via std::process::Command.
|
||||
Why re-exec instead of just reload: eBPF object files must be re-loaded from disk
|
||||
when kernel map layouts change; a hot-reload cannot cover this case.
|
||||
Files: core/ebpf/ (detach helpers), main.rs (signal handler + re-exec path).
|
||||
Trigger: POST /api/system/restart or SIGTERM.
|
||||
Steps: drain flow tracker (2 s), XDP unload, flush CSV, tokio shutdown, re-exec.
|
||||
Files: core/ebpf/ (detach helpers), main.rs (signal handler + re-exec)
|
||||
|
||||
-- SHOULD DO (usable but blind spots remain) --------------
|
||||
-- OPEN QUESTIONS -----------------------------------------
|
||||
|
||||
[x] QUIC parser
|
||||
Completed 2026-05-16. Full port of Suricata rust/src/quic/parser.rs
|
||||
and rust/src/quic/frames.rs.
|
||||
RFC QUIC v1/v2: HKDF key derivation + AES-128-GCM Initial decryption
|
||||
+ TLS ClientHello SNI extraction from CRYPTO frames.
|
||||
gQUIC Q043-Q046: plaintext Initial frames; SNI from CHLO tag-value
|
||||
structure (StreamTag::Sni 0x534e4900) in STREAM frames (0x80+).
|
||||
File: app_layer/quic.rs; integrated in mod.rs + rule_engine.rs.
|
||||
|
||||
[x] suppress list
|
||||
Completed 2026-05-15.
|
||||
Suricata suppress.conf format: gen_id N, sig_id N, track by_src|by_dst, ip addr.
|
||||
File: detection/rule/suppress.rs; loaded at startup; filters results
|
||||
in rule_engine.rs after matching (results.retain(|m| !suppress.is_suppressed(...))).
|
||||
Placeholder: static/db/suppress.conf (empty, add entries as needed).
|
||||
|
||||
[x] isdataat
|
||||
Completed 2026-05-15 (included with byte_test/jump/extract).
|
||||
ByteOp kind=3; supports negated form (!isdataat) and relative flag.
|
||||
build.rs: parse_isdataat(); rule_engine.rs: eval_byte_ops() case 3.
|
||||
|
||||
-- INFRASTRUCTURE FIXES (2026-05-19) ----------------------
|
||||
|
||||
[x] GeoIP unbounded thread explosion
|
||||
Completed 2026-05-19.
|
||||
Root cause: statistics.rs calls join_all() across all flows per WebSocket push;
|
||||
each cache-miss triggers spawn_blocking with no concurrency cap. Observed
|
||||
60+ simultaneous OS threads (ThreadId 83-148) under normal traffic.
|
||||
Fix: added Arc<Semaphore>(MAX_CONCURRENT_DB_LOOKUPS=8) to GeoIpService.
|
||||
acquire_owned() before spawn_blocking; OwnedSemaphorePermit moved into
|
||||
closure and held until blocking call returns. Cache hits and private IP
|
||||
paths bypass semaphore. After fix: max 8 concurrent threads at any time.
|
||||
File: core/infrastructure/geoip.rs
|
||||
|
||||
[x] maxminddb DEBUG log flood in development builds
|
||||
Completed 2026-05-19.
|
||||
Root cause: logging.rs sets global tracing level to Level::DEBUG when
|
||||
cfg!(debug_assertions) (non-release builds). maxminddb emits ~50 debug
|
||||
lines per IP lookup via tracing::debug!() in its deserializer.
|
||||
Fix: added EnvFilter directive "maxminddb=warn" which takes precedence
|
||||
over the global level. Release builds are unaffected (already INFO).
|
||||
File: utils/logging.rs
|
||||
|
||||
-- FRONTEND ALIGNMENT (2026-05-19) ------------------------
|
||||
|
||||
[x] Frontend detection page — UnifiedAlert API alignment
|
||||
Completed 2026-05-19.
|
||||
Old detection.tsx used AlertLog type with rf_score/ensemble_score and
|
||||
confidence-based severity (4 levels). Backend sends UnifiedAlert with
|
||||
source (ml|rule|fusion), severity (high|critical), rule_sid, rule_msg,
|
||||
and nullable attack_type. All serde enum variants are snake_case lowercase.
|
||||
Files updated: .research/frontend-refactor/detection.tsx
|
||||
Changes: new UnifiedAlert type inline, SOURCE_CONFIG/SEVERITY_CONFIG with
|
||||
lowercase keys, dual filter (severity x source), 7 stats cards,
|
||||
ML scores panel only for ml/fusion, rule details panel only for rule/fusion,
|
||||
isPausedRef pattern to prevent WS re-subscription on pause toggle.
|
||||
|
||||
[x] Frontend config URL mismatches
|
||||
Completed 2026-05-19.
|
||||
Six URL mismatches corrected; port updated to 2048:
|
||||
/control/access_control/... -> /ebpf/access_control/... (x4)
|
||||
/statistics/websocket/... -> /ebpf/statistics/websocket/... (x2)
|
||||
/health/websocket/system_health -> /health/websocket/metrics
|
||||
websocketUrl.mlAlert (/ml/websocket/alert) -> detectionAlert (/detection/websocket/alert)
|
||||
File: .research/frontend-refactor/config.ts
|
||||
|
||||
[x] WebSocketProvider — naming and Subject upgrade
|
||||
Completed 2026-05-19.
|
||||
getAiAlertStream -> getDetectionAlertStream throughout interface, context
|
||||
default, useCallback, and initializeWebSockets. Subject -> BehaviorSubject
|
||||
so late subscribers receive the last cached value immediately. Added
|
||||
getLatestFlowData() and getLatestSystemHealth() sync cache accessors.
|
||||
File: .research/frontend-refactor/WebSocketProvider.tsx
|
||||
|
||||
[x] Dashboard page — detection stream alignment
|
||||
Completed 2026-05-19.
|
||||
Removed unused AlertLog import. getAiAlertStream -> getDetectionAlertStream.
|
||||
attack_type null guard added before updating attackTypeCounts.
|
||||
Locale zh-TW -> en-GB for chart axis time labels.
|
||||
File: .research/frontend-refactor/dashboard.tsx
|
||||
|
||||
-- OPEN QUESTIONS (not blocking, worth revisiting) --------
|
||||
[ ] attackTypeCounts / protocolCounts in dashboard never reset
|
||||
Accumulate for the entire session lifetime. After hours of runtime, the
|
||||
donut charts show historical totals rather than recent activity.
|
||||
Consider: sliding window reset (e.g., clear counts every 1 hour) or
|
||||
limit to last N alerts.
|
||||
File: .research/frontend-refactor/dashboard.tsx
|
||||
[ ] Dashboard attack/protocol counters never reset
|
||||
Accumulate for entire session lifetime; charts show historical totals.
|
||||
Consider: sliding window reset every N minutes, or cap at last 500 alerts.
|
||||
File: net-guardia-frontend/...dashboard.tsx
|
||||
|
||||
25
config.toml
25
config.toml
@ -19,27 +19,26 @@ frame_count = 4096 # Umem Used (Should not modify)
|
||||
http_server_bind_port = 8080 # Http Server Listen Port
|
||||
refresh_interval = 5 # Statistics Refresh Time
|
||||
|
||||
max_concurrent_flows = 10000 # max_flows: track up to 10000 concurrent flows
|
||||
inference_interval_secs = 5 # interval_secs: run inference every 5 seconds
|
||||
min_signature_matches = 3
|
||||
max_concurrent_flows = 10000 # track up to N concurrent flows
|
||||
inference_interval_secs = 5 # run ML inference every N seconds
|
||||
aggregator_window_secs = 30
|
||||
inference_batch_size = 200
|
||||
flow_timeout_us = 60_000_000
|
||||
|
||||
traffic_logging_mode = false # When true, disables ML inference and records all ingress/egress packets to CSV
|
||||
traffic_log_csv_path = "traffic_log.csv" # Output CSV file path for traffic logging mode
|
||||
|
||||
home_net = ["140.130.34.0/24"]
|
||||
traffic_logging_mode = false # when true, disables ML inference and records packets to CSV
|
||||
traffic_log_csv_path = "traffic_log.csv"
|
||||
|
||||
# tls_keylog_path = "/tmp/tls_keys.log" # NSS key log file for TLS decryption (SSLKEYLOGFILE)
|
||||
|
||||
# CPU affinity (Linux only). Uncomment and tune for your hardware.
|
||||
# Pin XSK packet threads starting from this core (one core per queue pair).
|
||||
# Example: xsk_cpu_base=0 with combined_queue_count=4 uses cores 0-3 for packets.
|
||||
xsk_cpu_base = 0
|
||||
#
|
||||
# Distribute XSK packet threads across a core range [start, end] (inclusive).
|
||||
# Threads are assigned round-robin: core = start + (queue_id % (end - start + 1)).
|
||||
xsk_cpu_set = [0, 3]
|
||||
|
||||
# Pin ML inference (ONNX spawn_blocking) to this core.
|
||||
# Example: on an 8-core machine, reserve core 7 for inference.
|
||||
ml_cpu = 7
|
||||
|
||||
ae_threshold_method = "94"
|
||||
ae_threshold_method = "94"
|
||||
|
||||
# Suricata daemon mode. Remove or comment out to disable the rule engine.
|
||||
suricata_name = "suricata.yaml"
|
||||
@ -37,20 +37,6 @@ lru = "0.16.2"
|
||||
futures = "0.3.31"
|
||||
tract-onnx = "0.22.1"
|
||||
chrono = "0.4"
|
||||
vectorscan-rs = "0.0.6"
|
||||
protolens = "0.2.3"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
pcre2 = "0.2"
|
||||
pcre2-sys = "0.2"
|
||||
md5 = "0.7"
|
||||
ipnet = "2"
|
||||
x509-parser = "0.16"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
digest = "0.10"
|
||||
nom7 = { version = "7.1", package = "nom" }
|
||||
nom8 = { version = "8.0", package = "nom" }
|
||||
aes-gcm = "0.10"
|
||||
|
||||
ort-tract = { version = "0.3.0+0.22", optional = true }
|
||||
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "ndarray"] }
|
||||
@ -60,14 +46,12 @@ ndarray = "0.17"
|
||||
default = ["native-ort-backend"]
|
||||
tract-backend = ["dep:ort-tract", "ort/alternative-backend"]
|
||||
native-ort-backend = ["ort/load-dynamic", "ort/api-18"]
|
||||
#csv = "1.4.0"
|
||||
#anyhow = "1.0.100"
|
||||
|
||||
[build-dependencies]
|
||||
cargo_metadata = { workspace = true }
|
||||
cc = "1"
|
||||
dotenvy = "0.15.7"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
[[bin]]
|
||||
name = "net-guardia"
|
||||
path = "src/main.rs"
|
||||
path = "src/main.rs"
|
||||
|
||||
1603
net-guardia/build.rs
1603
net-guardia/build.rs
File diff suppressed because it is too large
Load Diff
@ -15,8 +15,8 @@ use crate::core::ebpf::service::Service;
|
||||
use crate::core::ebpf::statistics::Statistics;
|
||||
use crate::core::ebpf::xsk_manager::XskManager;
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::detection::fusion::FusionEngine;
|
||||
use crate::detection::ml::engine::Engine;
|
||||
use crate::detection::suricata::SuricataEngine;
|
||||
use crate::model::error::system::SystemError;
|
||||
use crate::model::error::Error;
|
||||
|
||||
@ -51,12 +51,12 @@ impl EbpfServices {
|
||||
pub async fn run(
|
||||
self: Arc<Self>,
|
||||
ml_engine: Arc<Engine>,
|
||||
fusion_engine: Arc<FusionEngine>,
|
||||
suricata_engine: Arc<SuricataEngine>,
|
||||
) -> Result<(), Error> {
|
||||
let xsk_manager = self.xsk_manager.clone();
|
||||
let statistics = self.statistics.clone();
|
||||
|
||||
xsk_manager.run(Some(ml_engine), Some(fusion_engine), &self.shutdowns)?;
|
||||
xsk_manager.run(Some(ml_engine), Some(suricata_engine), &self.shutdowns)?;
|
||||
|
||||
let statistics_shutdown = statistics.run().await;
|
||||
self.shutdowns.push(statistics_shutdown);
|
||||
|
||||
@ -17,18 +17,14 @@ use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, So
|
||||
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
|
||||
|
||||
use crate::core::infrastructure::app_config::AppConfig;
|
||||
use crate::detection::fusion::FusionEngine;
|
||||
use crate::detection::ml::engine::Engine;
|
||||
use crate::detection::rule::rule_engine::RuleEngine;
|
||||
use crate::detection::rule::stream_reassembler::StreamReassembler;
|
||||
use crate::detection::suricata::SuricataEngine;
|
||||
use crate::model::config::Config;
|
||||
use crate::model::direction::Direction;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::system::SystemError;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::log::ebpf::EbpfLog;
|
||||
use crate::model::log::ml::MLLog;
|
||||
use crate::model::log::rule::RuleLog;
|
||||
use crate::utils::cpu_affinity::set_cpu_affinity;
|
||||
|
||||
pub struct XskManager {
|
||||
@ -57,25 +53,12 @@ impl XskManager {
|
||||
pub fn run(
|
||||
&self,
|
||||
engine: Option<Arc<Engine>>,
|
||||
fusion_engine: Option<Arc<FusionEngine>>,
|
||||
suricata_engine: Option<Arc<SuricataEngine>>,
|
||||
shutdowns: &SegQueue<oneshot::Sender<()>>,
|
||||
) -> Result<(), Error> {
|
||||
let config = self.app_config.config.clone();
|
||||
let combined_queue_count = config.combined_queue_count;
|
||||
|
||||
let min_sig = config.min_signature_matches;
|
||||
|
||||
let rule_engine = match RuleEngine::new(&config.home_net) {
|
||||
Some(re) => {
|
||||
log!(RuleLog::RulesLoaded(re.pattern_count()));
|
||||
Some(Arc::new(re))
|
||||
}
|
||||
None => {
|
||||
log!(RuleLog::RulesDisabled);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
for queue_id in 0..combined_queue_count {
|
||||
let (ingress_to_egress_tx, ingress_to_egress_rx) = bounded(config.channel_size);
|
||||
let (egress_to_ingress_tx, egress_to_ingress_rx) = bounded(config.channel_size);
|
||||
@ -84,24 +67,18 @@ impl XskManager {
|
||||
config.clone(),
|
||||
queue_id,
|
||||
&config.ingress_ifname,
|
||||
&config.egress_ifname,
|
||||
Direction::Ingress,
|
||||
engine.clone(),
|
||||
rule_engine.clone(),
|
||||
fusion_engine.clone(),
|
||||
min_sig,
|
||||
suricata_engine.clone(),
|
||||
)?;
|
||||
|
||||
let egress_xsk = XskPair::new(
|
||||
config.clone(),
|
||||
queue_id,
|
||||
&config.egress_ifname,
|
||||
&config.ingress_ifname,
|
||||
Direction::Egress,
|
||||
engine.clone(),
|
||||
rule_engine.clone(),
|
||||
fusion_engine.clone(),
|
||||
min_sig,
|
||||
suricata_engine.clone(),
|
||||
)?;
|
||||
|
||||
let mut xsk_map = self.xsk_map.lock();
|
||||
@ -134,7 +111,7 @@ impl XskManager {
|
||||
pub struct XskPair {
|
||||
direction: Direction,
|
||||
queue_id: u32,
|
||||
xsk_cpu_base: Option<u32>,
|
||||
xsk_cpu_set: Option<[u32; 2]>,
|
||||
umem: Arc<Umem>,
|
||||
fill_queue: FillQueue,
|
||||
comp_queue: CompQueue,
|
||||
@ -142,9 +119,7 @@ pub struct XskPair {
|
||||
rx: RxQueue,
|
||||
frame_pool: Arc<Mutex<Vec<FrameDesc>>>,
|
||||
engine: Option<Arc<Engine>>,
|
||||
rule_engine: Option<Arc<RuleEngine>>,
|
||||
fusion_engine: Option<Arc<FusionEngine>>,
|
||||
min_signature_matches: u32,
|
||||
suricata_engine: Option<Arc<SuricataEngine>>,
|
||||
}
|
||||
|
||||
impl XskPair {
|
||||
@ -152,12 +127,9 @@ impl XskPair {
|
||||
config: Config,
|
||||
queue_id: u32,
|
||||
rx_ifname: &str,
|
||||
tx_ifname: &str,
|
||||
direction: Direction,
|
||||
engine: Option<Arc<Engine>>,
|
||||
rule_engine: Option<Arc<RuleEngine>>,
|
||||
fusion_engine: Option<Arc<FusionEngine>>,
|
||||
min_signature_matches: u32,
|
||||
suricata_engine: Option<Arc<SuricataEngine>>,
|
||||
) -> Result<Self, Error> {
|
||||
let rx_ifname_c = CString::new(rx_ifname).map_err(|_| SystemError::UnknownError)?;
|
||||
|
||||
@ -207,7 +179,7 @@ impl XskPair {
|
||||
let xsk_pair = Self {
|
||||
direction,
|
||||
queue_id,
|
||||
xsk_cpu_base: config.xsk_cpu_base,
|
||||
xsk_cpu_set: config.xsk_cpu_set,
|
||||
umem: Arc::new(umem),
|
||||
fill_queue,
|
||||
comp_queue,
|
||||
@ -215,9 +187,7 @@ impl XskPair {
|
||||
rx,
|
||||
frame_pool: Arc::new(Mutex::new(pool_frames)),
|
||||
engine,
|
||||
rule_engine,
|
||||
fusion_engine,
|
||||
min_signature_matches,
|
||||
suricata_engine,
|
||||
};
|
||||
|
||||
Ok(xsk_pair)
|
||||
@ -235,17 +205,11 @@ impl XskPair {
|
||||
thread::Builder::new()
|
||||
.name(thread_name.clone())
|
||||
.spawn(move || {
|
||||
if let Some(base) = self.xsk_cpu_base {
|
||||
set_cpu_affinity((base + self.queue_id) as usize);
|
||||
if let Some([start, end]) = self.xsk_cpu_set {
|
||||
let num_cores = (end - start + 1).max(1);
|
||||
set_cpu_affinity((start + self.queue_id % num_cores) as usize);
|
||||
}
|
||||
|
||||
// StreamReassembler is !Send (Rc inside protolens), so create it here.
|
||||
let min_sig_matches = self.min_signature_matches;
|
||||
let is_ingress = self.direction == Direction::Ingress;
|
||||
let mut reassembler: Option<StreamReassembler> = self
|
||||
.rule_engine
|
||||
.take()
|
||||
.map(|re| StreamReassembler::new(re, min_sig_matches, is_ingress));
|
||||
|
||||
let mut shutdown_rx = Some(shutdown_rx);
|
||||
let mut idle_count: u32 = 0;
|
||||
@ -268,7 +232,7 @@ impl XskPair {
|
||||
Err(e) => log!(EbpfLog::CompQueueError(format!("{:?}", e))),
|
||||
}
|
||||
|
||||
match self.process_rx_queue(&forward_tx, &mut reassembler) {
|
||||
match self.process_rx_queue(&forward_tx) {
|
||||
Ok(count) => total_activity += count,
|
||||
Err(e) => log!(EbpfLog::RXQueueError(format!("{:?}", e))),
|
||||
}
|
||||
@ -284,17 +248,7 @@ impl XskPair {
|
||||
idle_count = 0;
|
||||
}
|
||||
|
||||
// Purge idle TCP flows every 60 s of wall time.
|
||||
if last_cleanup.elapsed() >= Duration::from_secs(60) {
|
||||
if let Some(r) = &mut reassembler {
|
||||
r.cleanup_stale(Duration::from_secs(60));
|
||||
log!(RuleLog::SignatureStats(
|
||||
format!("{:?}", self.direction),
|
||||
r.flow_count(),
|
||||
r.min_matches(),
|
||||
format!("{:?}", r.match_counts()),
|
||||
));
|
||||
}
|
||||
last_cleanup = std::time::Instant::now();
|
||||
}
|
||||
|
||||
@ -335,7 +289,6 @@ impl XskPair {
|
||||
fn process_rx_queue(
|
||||
&mut self,
|
||||
forward_tx: &Sender<Vec<u8>>,
|
||||
reassembler: &mut Option<StreamReassembler>,
|
||||
) -> Result<usize, EbpfError> {
|
||||
let mut rx_descs = vec![FrameDesc::default(); 64];
|
||||
let rx_count = unsafe { self.rx.consume(&mut rx_descs) };
|
||||
@ -348,24 +301,8 @@ impl XskPair {
|
||||
let data = unsafe { self.umem.data(rx_desc) };
|
||||
let packet_data = data.contents()[..packet_len].to_vec();
|
||||
|
||||
if let Some(r) = &mut *reassembler {
|
||||
match r.process(&packet_data) {
|
||||
Ok(matches) => {
|
||||
for m in &matches {
|
||||
log!(RuleLog::SignatureMatch(
|
||||
format!("{:?}", self.direction),
|
||||
m.src.clone(),
|
||||
m.dst.clone(),
|
||||
m.sid,
|
||||
m.msg.clone(),
|
||||
));
|
||||
if let Some(ref fe) = self.fusion_engine {
|
||||
fe.record_rule(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log!(e),
|
||||
}
|
||||
if let Some(ref se) = self.suricata_engine {
|
||||
se.inject(packet_data.clone());
|
||||
}
|
||||
|
||||
if let Some(ref engine) = self.engine {
|
||||
|
||||
@ -21,6 +21,7 @@ use crate::detection::ml::engine::Engine;
|
||||
use crate::detection::ml::feature_extractor::FlowFeatures;
|
||||
use crate::detection::ml::model_loader::MLModels;
|
||||
use crate::detection::ml::traffic_logger::TrafficLogger;
|
||||
use crate::detection::suricata::SuricataEngine;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::system::SystemError;
|
||||
@ -32,6 +33,7 @@ pub struct AppServices {
|
||||
pub fusion_engine: Arc<FusionEngine>,
|
||||
pub ml_models: Arc<MLModels>,
|
||||
pub ml_engine: Arc<Engine>,
|
||||
pub suricata_engine: Arc<SuricataEngine>,
|
||||
shutdowns: SegQueue<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
@ -75,12 +77,22 @@ impl AppServices {
|
||||
app_config.ml_cpu,
|
||||
));
|
||||
|
||||
let rule_path = PathBuf::from(env!("RULE_PATH")).join(&app_config.suricata_name);
|
||||
let eve_socket = PathBuf::from(env!("RULE_EVE_PATH"));
|
||||
|
||||
let suricata_engine = SuricataEngine::start(
|
||||
rule_path,
|
||||
eve_socket,
|
||||
fusion_engine.clone(),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
health: Arc::new(health),
|
||||
detection_alert,
|
||||
fusion_engine,
|
||||
ml_models,
|
||||
ml_engine,
|
||||
suricata_engine,
|
||||
shutdowns: SegQueue::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -82,7 +82,7 @@ impl System {
|
||||
log!(SystemLog::InitializeComplete);
|
||||
self.attach_ebpf()?;
|
||||
|
||||
ebpf_services.run(app_services.ml_engine.clone(), app_services.fusion_engine.clone()).await?;
|
||||
ebpf_services.run(app_services.ml_engine.clone(), app_services.suricata_engine.clone()).await?;
|
||||
app_services.run().await?;
|
||||
self.run_http_server().await?;
|
||||
Ok(())
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use chrono::{DateTime, Utc, TimeZone};
|
||||
use chrono::{Utc, TimeZone};
|
||||
|
||||
use super::flow_tracker::FlowData;
|
||||
use crate::model::ml_detection::{ClipParams, PacketData};
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod fusion;
|
||||
pub mod ml;
|
||||
pub mod rule;
|
||||
pub mod suricata;
|
||||
@ -1,357 +0,0 @@
|
||||
/* Ported from Suricata rust/src/dns/parser.rs
|
||||
* Copyright (C) 2017-2022 Open Information Security Foundation
|
||||
* Licensed under GPLv2.
|
||||
*
|
||||
* Rewritten to use nom8 (nom = "8.0" aliased as nom8).
|
||||
* Safety limits match Suricata:
|
||||
* MAX_NAME_LEN = 1025 (same as DNSMasq)
|
||||
* MAX_LABEL_COUNT = 255
|
||||
*/
|
||||
|
||||
use nom8::error::Error as NomError;
|
||||
use nom8::multi::length_data;
|
||||
use nom8::number::streaming::{be_u16, be_u8};
|
||||
use nom8::Parser;
|
||||
|
||||
const MAX_NAME_LEN: usize = 1025;
|
||||
const MAX_LABEL_COUNT: usize = 255;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DnsInfo {
|
||||
pub query_name: Vec<u8>,
|
||||
pub qtype: Vec<u8>,
|
||||
pub rcode: Vec<u8>,
|
||||
pub answer: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn parse_dns(data: &[u8]) -> Option<DnsInfo> {
|
||||
if data.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = if has_tcp_length_prefix(data) { 2 } else { 0 };
|
||||
let msg = data.get(base..)?;
|
||||
if msg.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse DNS header with nom8
|
||||
let (rest, _id) = be_u16::<_, NomError<_>>(msg).ok()?;
|
||||
let (rest, flags) = be_u16::<_, NomError<_>>(rest).ok()?;
|
||||
let (rest, qdcount) = be_u16::<_, NomError<_>>(rest).ok()?;
|
||||
let (rest, ancount) = be_u16::<_, NomError<_>>(rest).ok()?;
|
||||
let (rest, _nscount) = be_u16::<_, NomError<_>>(rest).ok()?;
|
||||
let (_rest, _arcount) = be_u16::<_, NomError<_>>(rest).ok()?;
|
||||
|
||||
let rcode_val = (flags & 0x000F) as u8;
|
||||
|
||||
if qdcount == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Work with absolute offsets into msg for pointer resolution
|
||||
let mut pos = 12usize;
|
||||
|
||||
// Question section: QNAME + QTYPE(2) + QCLASS(2)
|
||||
let (query_name, next_pos) = dns_parse_name(msg, pos)?;
|
||||
pos = next_pos;
|
||||
|
||||
if pos + 4 > msg.len() {
|
||||
return None;
|
||||
}
|
||||
let qtype_val = u16::from_be_bytes([msg[pos], msg[pos + 1]]);
|
||||
pos += 4; // skip QTYPE + QCLASS
|
||||
|
||||
let mut info = DnsInfo {
|
||||
query_name,
|
||||
qtype: qtype_str(qtype_val).as_bytes().to_vec(),
|
||||
rcode: rcode_val.to_string().into_bytes(),
|
||||
answer: Vec::new(),
|
||||
};
|
||||
|
||||
// Answer section
|
||||
for _ in 0..ancount {
|
||||
let (_, next_pos) = match dns_parse_name(msg, pos) {
|
||||
Some(r) => r,
|
||||
None => break,
|
||||
};
|
||||
pos = next_pos;
|
||||
|
||||
// rrtype(2) + rrclass(2) + ttl(4) + rdlength(2) = 10 bytes
|
||||
if pos + 10 > msg.len() {
|
||||
break;
|
||||
}
|
||||
let rtype = u16::from_be_bytes([msg[pos], msg[pos + 1]]);
|
||||
let rdlength = u16::from_be_bytes([msg[pos + 8], msg[pos + 9]]) as usize;
|
||||
pos += 10;
|
||||
|
||||
if pos + rdlength > msg.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
if info.answer.is_empty() {
|
||||
if let Some(s) = parse_rdata(rtype, &msg[pos..pos + rdlength], msg, pos) {
|
||||
info.answer = s;
|
||||
}
|
||||
}
|
||||
|
||||
pos += rdlength;
|
||||
}
|
||||
|
||||
Some(info)
|
||||
}
|
||||
|
||||
// ─── Name parser (Suricata dns_parse_name algorithm, ported to nom8) ──────────
|
||||
|
||||
/// Parse a DNS name starting at `start` within `msg`.
|
||||
///
|
||||
/// Uses nom8 for label parsing (length_data + be_u8, be_u16 for pointers).
|
||||
/// Returns (name_bytes, end_pos_in_msg).
|
||||
/// end_pos is positioned after the first pointer encountered, or after the
|
||||
/// null terminator if no pointer was used. Matches Suricata's pivot logic.
|
||||
///
|
||||
/// Safety limits:
|
||||
/// - MAX_LABEL_COUNT = 255 segments
|
||||
/// - MAX_NAME_LEN = 1025 characters; name is truncated if exceeded
|
||||
fn dns_parse_name(msg: &[u8], start: usize) -> Option<(Vec<u8>, usize)> {
|
||||
let mut pos = start;
|
||||
let mut pivot: Option<usize> = None;
|
||||
let mut name: Vec<u8> = Vec::with_capacity(32);
|
||||
let mut count = 0usize;
|
||||
let mut truncated = false;
|
||||
|
||||
loop {
|
||||
if pos >= msg.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let len = msg[pos];
|
||||
|
||||
if len == 0x00 {
|
||||
// Null terminator: end of name
|
||||
pos += 1;
|
||||
break;
|
||||
} else if len & 0b1100_0000 == 0 {
|
||||
// Plain label: use nom8 length_data(be_u8) to consume length + bytes
|
||||
let input = &msg[pos..];
|
||||
let (_, label) = length_data(be_u8::<_, NomError<_>>).parse(input).ok()?;
|
||||
if !truncated {
|
||||
if !name.is_empty() {
|
||||
name.push(b'.');
|
||||
}
|
||||
name.extend_from_slice(label);
|
||||
}
|
||||
// Advance: 1 byte (length) + label bytes
|
||||
pos += 1 + label.len();
|
||||
} else if len & 0b1100_0000 == 0b1100_0000 {
|
||||
// Pointer: use nom8 be_u16 to read the two-byte leader
|
||||
let input = &msg[pos..];
|
||||
let (_, leader) = be_u16::<_, NomError<_>>(input).ok()?;
|
||||
let offset = (leader & 0x3fff) as usize;
|
||||
if offset >= msg.len() {
|
||||
return None;
|
||||
}
|
||||
// Self-reference detection (Suricata: compare slice pointers)
|
||||
if offset == pos {
|
||||
if pivot.is_some() {
|
||||
break;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if pivot.is_none() {
|
||||
pivot = Some(pos + 2);
|
||||
}
|
||||
pos = offset;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
if count > MAX_LABEL_COUNT {
|
||||
if pivot.is_some() {
|
||||
// We know where the name ends; truncate and stop
|
||||
break;
|
||||
}
|
||||
// Without a pointer we cannot locate the next unit; error out
|
||||
return None;
|
||||
}
|
||||
|
||||
if name.len() > MAX_NAME_LEN {
|
||||
name.truncate(MAX_NAME_LEN);
|
||||
truncated = true;
|
||||
if pivot.is_some() {
|
||||
break;
|
||||
}
|
||||
// Continue scanning to find null terminator, but stop appending
|
||||
}
|
||||
}
|
||||
|
||||
// Return position after first pointer, or after null terminator
|
||||
let end = pivot.unwrap_or(pos);
|
||||
Some((name, end))
|
||||
}
|
||||
|
||||
// ─── RDATA decoders ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode RDATA into a printable form for rule matching.
|
||||
/// `rdata_pos` is the absolute position of `rdata` within `msg`.
|
||||
fn parse_rdata(rtype: u16, rdata: &[u8], msg: &[u8], rdata_pos: usize) -> Option<Vec<u8>> {
|
||||
match rtype {
|
||||
// A: dotted-decimal IPv4
|
||||
1 => {
|
||||
if rdata.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{}.{}.{}.{}", rdata[0], rdata[1], rdata[2], rdata[3]).into_bytes())
|
||||
}
|
||||
// AAAA: colon-hex IPv6
|
||||
28 => {
|
||||
if rdata.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let groups: Vec<String> = rdata
|
||||
.chunks(2)
|
||||
.map(|g| format!("{:02x}{:02x}", g[0], g[1]))
|
||||
.collect();
|
||||
Some(groups.join(":").into_bytes())
|
||||
}
|
||||
// CNAME (5), NS (2), PTR (12): name starting at rdata
|
||||
5 | 2 | 12 => dns_parse_name(msg, rdata_pos).map(|(n, _)| n),
|
||||
// MX (15): skip 2-byte preference, then name
|
||||
15 => {
|
||||
if rdata.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
dns_parse_name(msg, rdata_pos + 2).map(|(n, _)| n)
|
||||
}
|
||||
// SOA (6): mname + rname
|
||||
6 => {
|
||||
let (mname, pos1) = dns_parse_name(msg, rdata_pos)?;
|
||||
let (rname, _) = dns_parse_name(msg, pos1)?;
|
||||
Some(
|
||||
format!(
|
||||
"{} {}",
|
||||
String::from_utf8_lossy(&mname),
|
||||
String::from_utf8_lossy(&rname)
|
||||
)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
// SRV (33): priority + weight + port + target name
|
||||
33 => {
|
||||
if rdata.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let priority = u16::from_be_bytes([rdata[0], rdata[1]]);
|
||||
let weight = u16::from_be_bytes([rdata[2], rdata[3]]);
|
||||
let port = u16::from_be_bytes([rdata[4], rdata[5]]);
|
||||
let (target, _) = dns_parse_name(msg, rdata_pos + 6)?;
|
||||
Some(
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
priority,
|
||||
weight,
|
||||
port,
|
||||
String::from_utf8_lossy(&target)
|
||||
)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
// TXT (16): one or more length-prefixed character strings (nom8 length_data loop)
|
||||
16 => {
|
||||
let mut out = Vec::new();
|
||||
let mut input = rdata;
|
||||
while !input.is_empty() {
|
||||
match length_data(be_u8::<_, NomError<_>>).parse(input) {
|
||||
Ok((rem, s)) => {
|
||||
out.extend_from_slice(s);
|
||||
input = rem;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
// SSHFP (44): algo + fp_type + fingerprint hex
|
||||
44 => {
|
||||
if rdata.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let algo = rdata[0];
|
||||
let fp_type = rdata[1];
|
||||
let hex: String = rdata[2..].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
Some(format!("{} {} {}", algo, fp_type, hex).into_bytes())
|
||||
}
|
||||
// NULL (10): raw bytes verbatim
|
||||
10 => Some(rdata.to_vec()),
|
||||
// OPT (41): extended DNS metadata, not suitable for rule matching
|
||||
41 => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full QTYPE string table matching Suricata's DNSRecordType enum.
|
||||
fn qtype_str(t: u16) -> &'static str {
|
||||
match t {
|
||||
1 => "A",
|
||||
2 => "NS",
|
||||
3 => "MD",
|
||||
4 => "MF",
|
||||
5 => "CNAME",
|
||||
6 => "SOA",
|
||||
7 => "MB",
|
||||
8 => "MG",
|
||||
9 => "MR",
|
||||
10 => "NULL",
|
||||
11 => "WKS",
|
||||
12 => "PTR",
|
||||
13 => "HINFO",
|
||||
14 => "MINFO",
|
||||
15 => "MX",
|
||||
16 => "TXT",
|
||||
17 => "RP",
|
||||
18 => "AFSDB",
|
||||
28 => "AAAA",
|
||||
33 => "SRV",
|
||||
35 => "NAPTR",
|
||||
36 => "KX",
|
||||
37 => "CERT",
|
||||
38 => "A6",
|
||||
39 => "DNAME",
|
||||
41 => "OPT",
|
||||
42 => "APL",
|
||||
43 => "DS",
|
||||
44 => "SSHFP",
|
||||
45 => "IPSECKEY",
|
||||
46 => "RRSIG",
|
||||
47 => "NSEC",
|
||||
48 => "DNSKEY",
|
||||
50 => "NSEC3",
|
||||
51 => "NSEC3PARAM",
|
||||
52 => "TLSA",
|
||||
53 => "SMIMEA",
|
||||
55 => "HIP",
|
||||
59 => "CDS",
|
||||
60 => "CDNSKEY",
|
||||
61 => "OPENPGPKEY",
|
||||
62 => "CSYNC",
|
||||
65 => "HTTPS",
|
||||
99 => "SPF",
|
||||
255 => "ANY",
|
||||
256 => "URI",
|
||||
257 => "CAA",
|
||||
32768 => "TA",
|
||||
32769 => "DLV",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn has_tcp_length_prefix(data: &[u8]) -> bool {
|
||||
if data.len() < 14 {
|
||||
return false;
|
||||
}
|
||||
let len = u16::from_be_bytes([data[0], data[1]]) as usize;
|
||||
len >= 12 && len + 2 <= data.len()
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
/// FTP protocol parser.
|
||||
///
|
||||
/// Extracts the first command verb, filename argument (for data-transfer commands),
|
||||
/// and the first 3-digit reply code from a single TCP payload chunk.
|
||||
///
|
||||
/// FTP commands and their arguments are described in RFC 959.
|
||||
|
||||
pub struct FtpInfo {
|
||||
/// FTP command in uppercase, e.g. "RETR".
|
||||
pub command: Vec<u8>,
|
||||
/// Filename argument for data-transfer commands (RETR, STOR, APPE, etc.).
|
||||
pub filename: Vec<u8>,
|
||||
/// First 3-digit reply code, e.g. "220".
|
||||
pub reply: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Commands that carry a filename or path argument.
|
||||
static FILE_CMDS: &[&[u8]] = &[
|
||||
b"RETR", b"STOR", b"APPE", b"RNFR", b"RNTO", b"DELE",
|
||||
b"MKD", b"RMD", b"NLST", b"LIST", b"MLST", b"MLSD",
|
||||
b"SIZE", b"MDTM",
|
||||
];
|
||||
|
||||
pub fn parse_ftp(data: &[u8]) -> Option<FtpInfo> {
|
||||
let mut info = FtpInfo {
|
||||
command: Vec::new(),
|
||||
filename: Vec::new(),
|
||||
reply: Vec::new(),
|
||||
};
|
||||
let mut found = false;
|
||||
|
||||
for raw_line in data.split(|&b| b == b'\n') {
|
||||
let line = strip_cr(raw_line);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reply: line starts with three ASCII digits.
|
||||
if line.len() >= 3 && line[..3].iter().all(|b| b.is_ascii_digit()) {
|
||||
if info.reply.is_empty() {
|
||||
info.reply = line[..3].to_vec();
|
||||
found = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Command: first token of all-alpha bytes.
|
||||
let sp = line.iter().position(|&b| b == b' ');
|
||||
let cmd = sp.map_or(line, |i| &line[..i]);
|
||||
if cmd.is_empty() || !cmd.iter().all(|b| b.is_ascii_alphabetic()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if info.command.is_empty() {
|
||||
info.command = cmd.to_ascii_uppercase();
|
||||
found = true;
|
||||
|
||||
if let Some(arg_start) = sp {
|
||||
let arg = &line[arg_start + 1..];
|
||||
if !arg.is_empty()
|
||||
&& FILE_CMDS.iter().any(|&fc| cmd.eq_ignore_ascii_case(fc))
|
||||
{
|
||||
info.filename = arg.to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found { Some(info) } else { None }
|
||||
}
|
||||
|
||||
fn strip_cr(line: &[u8]) -> &[u8] {
|
||||
line.strip_suffix(b"\r").unwrap_or(line)
|
||||
}
|
||||
@ -1,175 +0,0 @@
|
||||
/// Lightweight HTTP/1.x request and response parser.
|
||||
///
|
||||
/// Extracts the fields used by Suricata/Snort HTTP sticky buffers so that
|
||||
/// content rules targeted at `http.uri`, `http.user_agent`, etc. can be
|
||||
/// verified against the correct byte slice instead of the raw TCP payload.
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HttpRequest {
|
||||
pub method: Vec<u8>,
|
||||
pub uri: Vec<u8>,
|
||||
pub raw_uri: Vec<u8>,
|
||||
pub host: Vec<u8>,
|
||||
pub user_agent: Vec<u8>,
|
||||
pub cookie: Vec<u8>,
|
||||
/// Full request header section (everything before the blank line).
|
||||
pub headers: Vec<u8>,
|
||||
/// Request body (everything after the blank line).
|
||||
pub client_body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HttpResponse {
|
||||
pub stat_code: Vec<u8>,
|
||||
/// Full response header section.
|
||||
pub headers: Vec<u8>,
|
||||
/// Response body.
|
||||
pub server_body: Vec<u8>,
|
||||
}
|
||||
|
||||
static HTTP_METHODS: &[&[u8]] = &[
|
||||
b"GET", b"POST", b"PUT", b"DELETE", b"HEAD",
|
||||
b"OPTIONS", b"PATCH", b"CONNECT", b"TRACE",
|
||||
];
|
||||
|
||||
/// Try to parse `data` as an HTTP/1.x request.
|
||||
/// Returns `None` when the data does not start with a recognised method.
|
||||
pub fn parse_http_request(data: &[u8]) -> Option<HttpRequest> {
|
||||
// Find first space — end of method token.
|
||||
let sp1 = data.iter().position(|&b| b == b' ')?;
|
||||
let method = &data[..sp1];
|
||||
|
||||
// Validate method
|
||||
if !HTTP_METHODS.contains(&method) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find second space — end of URI.
|
||||
let after_sp1 = sp1 + 1;
|
||||
let sp2 = data[after_sp1..].iter().position(|&b| b == b' ')?;
|
||||
let uri = data[after_sp1..after_sp1 + sp2].to_vec();
|
||||
|
||||
// Find end of first line.
|
||||
let crlf1 = find_crlf(data, after_sp1 + sp2)?;
|
||||
let headers_start = crlf1 + 2;
|
||||
|
||||
// Find end of headers (blank line).
|
||||
let (headers_end, body_start) = find_header_end(data, headers_start);
|
||||
let headers_bytes = &data[..headers_end]; // includes request line
|
||||
|
||||
let mut req = HttpRequest {
|
||||
method: method.to_vec(),
|
||||
raw_uri: uri.clone(),
|
||||
uri: decode_uri(&uri),
|
||||
headers: headers_bytes.to_vec(),
|
||||
client_body: data.get(body_start..).unwrap_or(&[]).to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Walk individual header lines to extract named fields.
|
||||
let mut pos = headers_start;
|
||||
while pos < headers_end {
|
||||
let line_end = find_crlf(data, pos).unwrap_or(headers_end);
|
||||
let line = &data[pos..line_end];
|
||||
extract_header_field(line, &mut req);
|
||||
pos = line_end + 2;
|
||||
if pos >= headers_end { break; }
|
||||
}
|
||||
|
||||
Some(req)
|
||||
}
|
||||
|
||||
/// Try to parse `data` as an HTTP/1.x response.
|
||||
/// Returns `None` when the data does not start with `HTTP/`.
|
||||
pub fn parse_http_response(data: &[u8]) -> Option<HttpResponse> {
|
||||
if !data.starts_with(b"HTTP/") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// "HTTP/1.x NNN " — status code starts after version + space.
|
||||
let sp1 = data.iter().position(|&b| b == b' ')?;
|
||||
let code_start = sp1 + 1;
|
||||
let sp2 = data[code_start..].iter().position(|&b| b == b' ' || b == b'\r' || b == b'\n')
|
||||
.unwrap_or(3); // 3-digit code
|
||||
let stat_code = data[code_start..code_start + sp2].to_vec();
|
||||
|
||||
let crlf1 = find_crlf(data, code_start + sp2)?;
|
||||
let headers_start = crlf1 + 2;
|
||||
let (headers_end, body_start) = find_header_end(data, headers_start);
|
||||
|
||||
Some(HttpResponse {
|
||||
stat_code,
|
||||
headers: data[..headers_end].to_vec(),
|
||||
server_body: data.get(body_start..).unwrap_or(&[]).to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Populate named fields from a single HTTP header line (`Name: value`).
|
||||
fn extract_header_field(line: &[u8], req: &mut HttpRequest) {
|
||||
let colon = match line.iter().position(|&b| b == b':') {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
let name = &line[..colon];
|
||||
let value = line.get(colon + 1..).map(|v| v.trim_ascii_start()).unwrap_or(&[]);
|
||||
|
||||
if name.eq_ignore_ascii_case(b"Host") {
|
||||
req.host = value.to_vec();
|
||||
} else if name.eq_ignore_ascii_case(b"User-Agent") {
|
||||
req.user_agent = value.to_vec();
|
||||
} else if name.eq_ignore_ascii_case(b"Cookie") {
|
||||
req.cookie = value.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first CRLF (`\r\n`) in `data` starting at `from`.
|
||||
/// Returns the offset of `\r`.
|
||||
fn find_crlf(data: &[u8], from: usize) -> Option<usize> {
|
||||
let slice = data.get(from..)?;
|
||||
let pos = slice.windows(2).position(|w| w == b"\r\n")?;
|
||||
Some(from + pos)
|
||||
}
|
||||
|
||||
/// Find end-of-headers (blank line `\r\n\r\n`) starting at `from`.
|
||||
/// Returns `(headers_end, body_start)` where `headers_end` is the offset of
|
||||
/// the first `\r` of the blank line, and `body_start` is after the blank line.
|
||||
fn find_header_end(data: &[u8], from: usize) -> (usize, usize) {
|
||||
let slice = match data.get(from..) {
|
||||
Some(s) => s,
|
||||
None => return (data.len(), data.len()),
|
||||
};
|
||||
if let Some(pos) = slice.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let abs = from + pos;
|
||||
(abs + 2, abs + 4) // end at blank line start; body after blank line
|
||||
} else {
|
||||
(data.len(), data.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a URI byte slice.
|
||||
fn decode_uri(uri: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(uri.len());
|
||||
let mut i = 0;
|
||||
while i < uri.len() {
|
||||
if uri[i] == b'%' && i + 2 < uri.len() {
|
||||
if let (Some(h), Some(l)) = (hex_val(uri[i + 1]), hex_val(uri[i + 2])) {
|
||||
out.push((h << 4) | l);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(uri[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -1,672 +0,0 @@
|
||||
/* HTTP/2 frame parser with HPACK header decompression.
|
||||
*
|
||||
* Huffman decoder ported from Suricata rust/src/http2/huffman.rs
|
||||
* Copyright (C) 2020 Open Information Security Foundation
|
||||
* Licensed under GPLv2.
|
||||
*
|
||||
* Uses nom7 bits::streaming::take and branch::alt to replicate Suricata's
|
||||
* http2_decode_huffman exactly. The public entry point is http2_decode_huffman
|
||||
* (nom7 IResult interface); huffman_decode wraps it for use by hpack_str.
|
||||
*
|
||||
* Decoding order (ascending code length):
|
||||
* 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30
|
||||
*
|
||||
* EOS (0x3fffffff, 30 bits) is excluded from the table; remaining padding bits
|
||||
* are validated as all-ones after the main decode loop, matching Suricata's
|
||||
* many0 termination behaviour.
|
||||
*/
|
||||
|
||||
use nom7::bits::streaming::take as take_bits;
|
||||
use nom7::branch::alt;
|
||||
use nom7::combinator::{complete, map_opt};
|
||||
use nom7::error::{make_error, ErrorKind};
|
||||
use nom7::{Err as NomErr, IResult};
|
||||
|
||||
// HTTP/2 client connection preface (RFC 7540 §3.5)
|
||||
const H2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
|
||||
|
||||
const FRAME_HEADERS: u8 = 0x1;
|
||||
const FLAG_PADDED: u8 = 0x08;
|
||||
const FLAG_PRIORITY: u8 = 0x20;
|
||||
|
||||
pub struct Http2Info {
|
||||
pub method: Vec<u8>,
|
||||
pub path: Vec<u8>,
|
||||
pub authority: Vec<u8>,
|
||||
pub status: Vec<u8>,
|
||||
pub user_agent: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn parse_http2(data: &[u8]) -> Option<Http2Info> {
|
||||
let mut pos = 0usize;
|
||||
|
||||
if data.starts_with(H2_PREFACE) {
|
||||
pos += H2_PREFACE.len();
|
||||
}
|
||||
|
||||
while pos + 9 <= data.len() {
|
||||
let frame_len = (data[pos] as usize) << 16
|
||||
| (data[pos + 1] as usize) << 8
|
||||
| data[pos + 2] as usize;
|
||||
let frame_type = data[pos + 3];
|
||||
let flags = data[pos + 4];
|
||||
pos += 9;
|
||||
|
||||
if pos + frame_len > data.len() {
|
||||
break;
|
||||
}
|
||||
let payload = &data[pos..pos + frame_len];
|
||||
pos += frame_len;
|
||||
|
||||
if frame_type == FRAME_HEADERS {
|
||||
if let Some(info) = parse_headers_frame(payload, flags) {
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_headers_frame(data: &[u8], flags: u8) -> Option<Http2Info> {
|
||||
let mut pos = 0usize;
|
||||
|
||||
let pad_len = if flags & FLAG_PADDED != 0 {
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let p = data[pos] as usize;
|
||||
pos += 1;
|
||||
p
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if flags & FLAG_PRIORITY != 0 {
|
||||
pos += 5;
|
||||
}
|
||||
|
||||
let frag_end = data.len().saturating_sub(pad_len);
|
||||
if pos > frag_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
decode_hpack(&data[pos..frag_end])
|
||||
}
|
||||
|
||||
// ─── HPACK decoder ────────────────────────────────────────────────────────────
|
||||
|
||||
fn decode_hpack(data: &[u8]) -> Option<Http2Info> {
|
||||
let mut info = Http2Info {
|
||||
method: Vec::new(),
|
||||
path: Vec::new(),
|
||||
authority: Vec::new(),
|
||||
status: Vec::new(),
|
||||
user_agent: Vec::new(),
|
||||
};
|
||||
let mut pos = 0usize;
|
||||
let mut dynamic: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
let mut found = false;
|
||||
|
||||
while pos < data.len() {
|
||||
let b = data[pos];
|
||||
|
||||
if b & 0x80 != 0 {
|
||||
// 6.1 Indexed Header Field
|
||||
let (idx, adv) = hpack_int(data, pos, 7)?;
|
||||
pos += adv;
|
||||
if idx > 0 {
|
||||
if let Some((name, value)) = table_lookup(&dynamic, idx) {
|
||||
apply(&mut info, &name, &value);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
} else if b & 0xC0 == 0x40 {
|
||||
// 6.2.1 Literal Header Field with Incremental Indexing
|
||||
let (idx, adv) = hpack_int(data, pos, 6)?;
|
||||
pos += adv;
|
||||
let name = if idx == 0 {
|
||||
let (n, nadv) = hpack_str(data, pos)?;
|
||||
pos += nadv;
|
||||
n
|
||||
} else {
|
||||
let (n, _) = table_lookup(&dynamic, idx)?;
|
||||
n
|
||||
};
|
||||
let (value, vadv) = hpack_str(data, pos)?;
|
||||
pos += vadv;
|
||||
apply(&mut info, &name, &value);
|
||||
found = true;
|
||||
dynamic.insert(0, (name, value));
|
||||
} else if b & 0xE0 == 0x20 {
|
||||
// 6.3 Dynamic Table Size Update
|
||||
let (_, adv) = hpack_int(data, pos, 5)?;
|
||||
pos += adv;
|
||||
} else {
|
||||
// 6.2.2 / 6.2.3 Literal without / never Indexing
|
||||
let (idx, adv) = hpack_int(data, pos, 4)?;
|
||||
pos += adv;
|
||||
let name = if idx == 0 {
|
||||
let (n, nadv) = hpack_str(data, pos)?;
|
||||
pos += nadv;
|
||||
n
|
||||
} else {
|
||||
let (n, _) = table_lookup(&dynamic, idx)?;
|
||||
n
|
||||
};
|
||||
let (value, vadv) = hpack_str(data, pos)?;
|
||||
pos += vadv;
|
||||
apply(&mut info, &name, &value);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found { Some(info) } else { None }
|
||||
}
|
||||
|
||||
fn apply(info: &mut Http2Info, name: &[u8], value: &[u8]) {
|
||||
match name {
|
||||
b":method" => { if info.method.is_empty() { info.method = value.to_vec(); } }
|
||||
b":path" => { if info.path.is_empty() { info.path = value.to_vec(); } }
|
||||
b":authority" => { if info.authority.is_empty() { info.authority = value.to_vec(); } }
|
||||
b":status" => { if info.status.is_empty() { info.status = value.to_vec(); } }
|
||||
b"user-agent" => { if info.user_agent.is_empty() { info.user_agent = value.to_vec(); } }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HPACK static table (RFC 7541 Appendix A, 1-based) ───────────────────────
|
||||
|
||||
static STATIC_TABLE: &[(&[u8], &[u8])] = &[
|
||||
(b":authority", b""), // 1
|
||||
(b":method", b"GET"), // 2
|
||||
(b":method", b"POST"), // 3
|
||||
(b":path", b"/"), // 4
|
||||
(b":path", b"/index.html"), // 5
|
||||
(b":scheme", b"http"), // 6
|
||||
(b":scheme", b"https"), // 7
|
||||
(b":status", b"200"), // 8
|
||||
(b":status", b"204"), // 9
|
||||
(b":status", b"206"), // 10
|
||||
(b":status", b"304"), // 11
|
||||
(b":status", b"400"), // 12
|
||||
(b":status", b"404"), // 13
|
||||
(b":status", b"500"), // 14
|
||||
(b"accept-charset", b""), // 15
|
||||
(b"accept-encoding", b"gzip, deflate"), // 16
|
||||
(b"accept-language", b""), // 17
|
||||
(b"accept-ranges", b""), // 18
|
||||
(b"accept", b""), // 19
|
||||
(b"access-control-allow-origin", b""), // 20
|
||||
(b"age", b""), // 21
|
||||
(b"allow", b""), // 22
|
||||
(b"authorization", b""), // 23
|
||||
(b"cache-control", b""), // 24
|
||||
(b"content-disposition", b""), // 25
|
||||
(b"content-encoding", b""), // 26
|
||||
(b"content-language", b""), // 27
|
||||
(b"content-length", b""), // 28
|
||||
(b"content-location", b""), // 29
|
||||
(b"content-range", b""), // 30
|
||||
(b"content-type", b""), // 31
|
||||
(b"cookie", b""), // 32
|
||||
(b"date", b""), // 33
|
||||
(b"etag", b""), // 34
|
||||
(b"expect", b""), // 35
|
||||
(b"expires", b""), // 36
|
||||
(b"from", b""), // 37
|
||||
(b"host", b""), // 38
|
||||
(b"if-match", b""), // 39
|
||||
(b"if-modified-since", b""), // 40
|
||||
(b"if-none-match", b""), // 41
|
||||
(b"if-range", b""), // 42
|
||||
(b"if-unmodified-since", b""), // 43
|
||||
(b"last-modified", b""), // 44
|
||||
(b"link", b""), // 45
|
||||
(b"location", b""), // 46
|
||||
(b"max-forwards", b""), // 47
|
||||
(b"proxy-authenticate", b""), // 48
|
||||
(b"proxy-authorization", b""), // 49
|
||||
(b"range", b""), // 50
|
||||
(b"referer", b""), // 51
|
||||
(b"refresh", b""), // 52
|
||||
(b"retry-after", b""), // 53
|
||||
(b"server", b""), // 54
|
||||
(b"set-cookie", b""), // 55
|
||||
(b"strict-transport-security", b""), // 56
|
||||
(b"transfer-encoding", b""), // 57
|
||||
(b"user-agent", b""), // 58
|
||||
(b"vary", b""), // 59
|
||||
(b"via", b""), // 60
|
||||
(b"www-authenticate", b""), // 61
|
||||
];
|
||||
|
||||
fn table_lookup(dynamic: &[(Vec<u8>, Vec<u8>)], idx: usize) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
if idx == 0 {
|
||||
return None;
|
||||
}
|
||||
let static_len = STATIC_TABLE.len();
|
||||
if idx <= static_len {
|
||||
let (n, v) = STATIC_TABLE[idx - 1];
|
||||
Some((n.to_vec(), v.to_vec()))
|
||||
} else {
|
||||
let dyn_idx = idx - static_len - 1;
|
||||
dynamic.get(dyn_idx).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HPACK integer encoding (RFC 7541 §5.1) ──────────────────────────────────
|
||||
|
||||
fn hpack_int(data: &[u8], pos: usize, n: u8) -> Option<(usize, usize)> {
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let mask = (1u8 << n).wrapping_sub(1);
|
||||
let prefix = (data[pos] & mask) as usize;
|
||||
let max = mask as usize;
|
||||
|
||||
if prefix < max {
|
||||
return Some((prefix, 1));
|
||||
}
|
||||
|
||||
let mut value = max;
|
||||
let mut shift = 0u32;
|
||||
let mut i = 1usize;
|
||||
loop {
|
||||
if pos + i >= data.len() || i > 4 {
|
||||
return None;
|
||||
}
|
||||
let b = data[pos + i];
|
||||
value += ((b & 0x7F) as usize) << shift;
|
||||
shift += 7;
|
||||
i += 1;
|
||||
if b & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some((value, i))
|
||||
}
|
||||
|
||||
// ─── HPACK string literal (RFC 7541 §5.2) ────────────────────────────────────
|
||||
|
||||
fn hpack_str(data: &[u8], pos: usize) -> Option<(Vec<u8>, usize)> {
|
||||
if pos >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let huffman = data[pos] & 0x80 != 0;
|
||||
let (str_len, hdr) = hpack_int(data, pos, 7)?;
|
||||
let body_start = pos + hdr;
|
||||
if body_start + str_len > data.len() {
|
||||
return None;
|
||||
}
|
||||
let raw = &data[body_start..body_start + str_len];
|
||||
let result = if huffman { huffman_decode(raw)? } else { raw.to_vec() };
|
||||
Some((result, hdr + str_len))
|
||||
}
|
||||
|
||||
// ─── HPACK Huffman decoder ────────────────────────────────────────────────────
|
||||
//
|
||||
// Ported from Suricata rust/src/http2/huffman.rs using nom7 bits.
|
||||
//
|
||||
// Each http2_decode_huffman_lenN function is:
|
||||
// complete(map_opt(take_bits(N u32), http2_huffman_table_lenN))
|
||||
// matching Suricata exactly.
|
||||
//
|
||||
// http2_decode_huffman is the top-level nom7 combinator (Suricata's alt(...)).
|
||||
// huffman_decode wraps it via nom7::multi::many0 for use by hpack_str.
|
||||
//
|
||||
// EOS (0x3fffffff, 30 bits) excluded; remaining padding verified as all-ones.
|
||||
|
||||
/// Entry point: decode a full Huffman-coded byte string.
|
||||
///
|
||||
/// Wraps http2_decode_huffman via many0, then validates EOS padding.
|
||||
/// Matches Suricata's http2_decode_huffman_block behaviour.
|
||||
fn huffman_decode(data: &[u8]) -> Option<Vec<u8>> {
|
||||
use nom7::multi::many0;
|
||||
let bit_input: (&[u8], usize) = (data, 0);
|
||||
match many0(http2_decode_huffman)(bit_input) {
|
||||
Ok(((remaining_bytes, remaining_bit_offset), out)) => {
|
||||
// Validate EOS padding: remaining bits must all be 1s, at most 7.
|
||||
let remaining_bits = remaining_bytes.len() * 8 - remaining_bit_offset;
|
||||
if remaining_bits > 7 {
|
||||
return None;
|
||||
}
|
||||
if !remaining_bytes.is_empty() {
|
||||
// Check that the last partial byte's remaining bits are all 1s.
|
||||
let shift = remaining_bit_offset;
|
||||
if shift < 8 {
|
||||
let mask = (1u8 << (8 - shift)) - 1;
|
||||
if remaining_bytes[0] & mask != mask {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nom7 Huffman symbol decoder — matches Suricata http2_decode_huffman.
|
||||
///
|
||||
/// Tries each code length in ascending order using nested alt() because
|
||||
/// nom7::branch::Alt is implemented for tuples up to size 20.
|
||||
pub fn http2_decode_huffman(
|
||||
input: (&[u8], usize),
|
||||
) -> IResult<(&[u8], usize), u8> {
|
||||
alt((
|
||||
http2_decode_huffman_len5,
|
||||
http2_decode_huffman_len6,
|
||||
http2_decode_huffman_len7,
|
||||
http2_decode_huffman_len8,
|
||||
http2_decode_huffman_len10,
|
||||
http2_decode_huffman_len11,
|
||||
http2_decode_huffman_len12,
|
||||
http2_decode_huffman_len13,
|
||||
http2_decode_huffman_len14,
|
||||
http2_decode_huffman_len15,
|
||||
http2_decode_huffman_len19,
|
||||
http2_decode_huffman_len20,
|
||||
http2_decode_huffman_len21,
|
||||
http2_decode_huffman_len21,
|
||||
http2_decode_huffman_len22,
|
||||
http2_decode_huffman_len23,
|
||||
http2_decode_huffman_len24,
|
||||
http2_decode_huffman_len25,
|
||||
http2_decode_huffman_len26,
|
||||
http2_decode_huffman_len27,
|
||||
alt((
|
||||
http2_decode_huffman_len28,
|
||||
http2_decode_huffman_len30,
|
||||
http2_decode_huffman_end,
|
||||
)),
|
||||
))(input)
|
||||
}
|
||||
|
||||
// Hack to end many0 even if some bits remain (Suricata: http2_decode_huffman_end).
|
||||
fn http2_decode_huffman_end(
|
||||
input: (&[u8], usize),
|
||||
) -> IResult<(&[u8], usize), u8> {
|
||||
Err(NomErr::Error(make_error(input, ErrorKind::Eof)))
|
||||
}
|
||||
|
||||
// Per-length nom7 decoders — each wraps its table with complete(map_opt(take_bits(N), table)).
|
||||
|
||||
fn http2_decode_huffman_len5(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(5u32), http2_huffman_table_len5))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len6(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(6u32), http2_huffman_table_len6))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len7(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(7u32), http2_huffman_table_len7))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len8(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(8u32), http2_huffman_table_len8))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len10(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(10u32), http2_huffman_table_len10))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len11(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(11u32), http2_huffman_table_len11))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len12(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(12u32), http2_huffman_table_len12))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len13(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(13u32), http2_huffman_table_len13))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len14(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(14u32), http2_huffman_table_len14))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len15(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(15u32), http2_huffman_table_len15))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len19(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(19u32), http2_huffman_table_len19))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len20(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(20u32), http2_huffman_table_len20))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len21(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(21u32), http2_huffman_table_len21))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len22(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(22u32), http2_huffman_table_len22))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len23(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(23u32), http2_huffman_table_len23))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len24(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(24u32), http2_huffman_table_len24))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len25(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(25u32), http2_huffman_table_len25))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len26(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(26u32), http2_huffman_table_len26))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len27(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(27u32), http2_huffman_table_len27))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len28(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(28u32), http2_huffman_table_len28))(input)
|
||||
}
|
||||
fn http2_decode_huffman_len30(input: (&[u8], usize)) -> IResult<(&[u8], usize), u8> {
|
||||
complete(map_opt(take_bits(30u32), http2_huffman_table_len30))(input)
|
||||
}
|
||||
|
||||
// Per-length decode tables — verbatim from Suricata http2_huffman_table_lenN.
|
||||
|
||||
fn http2_huffman_table_len5(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0 => Some(48), 1 => Some(49), 2 => Some(50), 3 => Some(97),
|
||||
4 => Some(99), 5 => Some(101), 6 => Some(105), 7 => Some(111),
|
||||
8 => Some(115), 9 => Some(116),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len6(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x14 => Some(32), 0x15 => Some(37), 0x16 => Some(45), 0x17 => Some(46),
|
||||
0x18 => Some(47), 0x19 => Some(51), 0x1a => Some(52), 0x1b => Some(53),
|
||||
0x1c => Some(54), 0x1d => Some(55), 0x1e => Some(56), 0x1f => Some(57),
|
||||
0x20 => Some(61), 0x21 => Some(65), 0x22 => Some(95), 0x23 => Some(98),
|
||||
0x24 => Some(100), 0x25 => Some(102), 0x26 => Some(103), 0x27 => Some(104),
|
||||
0x28 => Some(108), 0x29 => Some(109), 0x2a => Some(110), 0x2b => Some(112),
|
||||
0x2c => Some(114), 0x2d => Some(117),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len7(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x5c => Some(58), 0x5d => Some(66), 0x5e => Some(67), 0x5f => Some(68),
|
||||
0x60 => Some(69), 0x61 => Some(70), 0x62 => Some(71), 0x63 => Some(72),
|
||||
0x64 => Some(73), 0x65 => Some(74), 0x66 => Some(75), 0x67 => Some(76),
|
||||
0x68 => Some(77), 0x69 => Some(78), 0x6a => Some(79), 0x6b => Some(80),
|
||||
0x6c => Some(81), 0x6d => Some(82), 0x6e => Some(83), 0x6f => Some(84),
|
||||
0x70 => Some(85), 0x71 => Some(86), 0x72 => Some(87), 0x73 => Some(89),
|
||||
0x74 => Some(106), 0x75 => Some(107), 0x76 => Some(113), 0x77 => Some(118),
|
||||
0x78 => Some(119), 0x79 => Some(120), 0x7a => Some(121), 0x7b => Some(122),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len8(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0xf8 => Some(38), 0xf9 => Some(42), 0xfa => Some(44),
|
||||
0xfb => Some(59), 0xfc => Some(88), 0xfd => Some(90),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len10(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x3f8 => Some(33), 0x3f9 => Some(34), 0x3fa => Some(40),
|
||||
0x3fb => Some(41), 0x3fc => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len11(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x7fa => Some(39), 0x7fb => Some(43), 0x7fc => Some(124),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len12(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0xffa => Some(35), 0xffb => Some(62),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len13(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x1ff8 => Some(0), 0x1ff9 => Some(36), 0x1ffa => Some(64),
|
||||
0x1ffb => Some(91), 0x1ffc => Some(93), 0x1ffd => Some(126),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len14(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x3ffc => Some(94), 0x3ffd => Some(125),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len15(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x7ffc => Some(60), 0x7ffd => Some(96), 0x7ffe => Some(123),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len19(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x7fff0 => Some(92), 0x7fff1 => Some(195), 0x7fff2 => Some(208),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len20(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0xfffe6 => Some(128), 0xfffe7 => Some(130), 0xfffe8 => Some(131),
|
||||
0xfffe9 => Some(162), 0xfffea => Some(184), 0xfffeb => Some(194),
|
||||
0xfffec => Some(224), 0xfffed => Some(226),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len21(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x1fffdc => Some(153), 0x1fffdd => Some(161), 0x1fffde => Some(167),
|
||||
0x1fffdf => Some(172), 0x1fffe0 => Some(176), 0x1fffe1 => Some(177),
|
||||
0x1fffe2 => Some(179), 0x1fffe3 => Some(209), 0x1fffe4 => Some(216),
|
||||
0x1fffe5 => Some(217), 0x1fffe6 => Some(227), 0x1fffe7 => Some(229),
|
||||
0x1fffe8 => Some(230),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len22(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x3fffd2 => Some(129), 0x3fffd3 => Some(132), 0x3fffd4 => Some(133),
|
||||
0x3fffd5 => Some(134), 0x3fffd6 => Some(136), 0x3fffd7 => Some(146),
|
||||
0x3fffd8 => Some(154), 0x3fffd9 => Some(156), 0x3fffda => Some(160),
|
||||
0x3fffdb => Some(163), 0x3fffdc => Some(164), 0x3fffdd => Some(169),
|
||||
0x3fffde => Some(170), 0x3fffdf => Some(173), 0x3fffe0 => Some(178),
|
||||
0x3fffe1 => Some(181), 0x3fffe2 => Some(185), 0x3fffe3 => Some(186),
|
||||
0x3fffe4 => Some(187), 0x3fffe5 => Some(189), 0x3fffe6 => Some(190),
|
||||
0x3fffe7 => Some(196), 0x3fffe8 => Some(198), 0x3fffe9 => Some(228),
|
||||
0x3fffea => Some(232), 0x3fffeb => Some(233),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len23(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x7fffd8 => Some(1), 0x7fffd9 => Some(135), 0x7fffda => Some(137),
|
||||
0x7fffdb => Some(138), 0x7fffdc => Some(139), 0x7fffdd => Some(140),
|
||||
0x7fffde => Some(141), 0x7fffdf => Some(143), 0x7fffe0 => Some(147),
|
||||
0x7fffe1 => Some(149), 0x7fffe2 => Some(150), 0x7fffe3 => Some(151),
|
||||
0x7fffe4 => Some(152), 0x7fffe5 => Some(155), 0x7fffe6 => Some(157),
|
||||
0x7fffe7 => Some(158), 0x7fffe8 => Some(165), 0x7fffe9 => Some(166),
|
||||
0x7fffea => Some(168), 0x7fffeb => Some(174), 0x7fffec => Some(175),
|
||||
0x7fffed => Some(180), 0x7fffee => Some(182), 0x7fffef => Some(183),
|
||||
0x7ffff0 => Some(188), 0x7ffff1 => Some(191), 0x7ffff2 => Some(197),
|
||||
0x7ffff3 => Some(231), 0x7ffff4 => Some(239),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len24(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0xffffea => Some(9), 0xffffeb => Some(142), 0xffffec => Some(144),
|
||||
0xffffed => Some(145), 0xffffee => Some(148), 0xffffef => Some(159),
|
||||
0xfffff0 => Some(171), 0xfffff1 => Some(206), 0xfffff2 => Some(215),
|
||||
0xfffff3 => Some(225), 0xfffff4 => Some(236), 0xfffff5 => Some(237),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len25(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x1ffffec => Some(199), 0x1ffffed => Some(207),
|
||||
0x1ffffee => Some(234), 0x1ffffef => Some(235),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len26(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x3ffffe0 => Some(192), 0x3ffffe1 => Some(193), 0x3ffffe2 => Some(200),
|
||||
0x3ffffe3 => Some(201), 0x3ffffe4 => Some(202), 0x3ffffe5 => Some(205),
|
||||
0x3ffffe6 => Some(210), 0x3ffffe7 => Some(213), 0x3ffffe8 => Some(218),
|
||||
0x3ffffe9 => Some(219), 0x3ffffea => Some(238), 0x3ffffeb => Some(240),
|
||||
0x3ffffec => Some(242), 0x3ffffed => Some(243), 0x3ffffee => Some(255),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len27(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x7ffffde => Some(203), 0x7ffffdf => Some(204), 0x7ffffe0 => Some(211),
|
||||
0x7ffffe1 => Some(212), 0x7ffffe2 => Some(214), 0x7ffffe3 => Some(221),
|
||||
0x7ffffe4 => Some(222), 0x7ffffe5 => Some(223), 0x7ffffe6 => Some(241),
|
||||
0x7ffffe7 => Some(244), 0x7ffffe8 => Some(245), 0x7ffffe9 => Some(246),
|
||||
0x7ffffea => Some(247), 0x7ffffeb => Some(248), 0x7ffffec => Some(250),
|
||||
0x7ffffed => Some(251), 0x7ffffee => Some(252), 0x7ffffef => Some(253),
|
||||
0x7fffff0 => Some(254),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http2_huffman_table_len28(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0xfffffe2 => Some(2), 0xfffffe3 => Some(3), 0xfffffe4 => Some(4),
|
||||
0xfffffe5 => Some(5), 0xfffffe6 => Some(6), 0xfffffe7 => Some(7),
|
||||
0xfffffe8 => Some(8), 0xfffffe9 => Some(11), 0xfffffea => Some(12),
|
||||
0xfffffeb => Some(14), 0xfffffec => Some(15), 0xfffffed => Some(16),
|
||||
0xfffffee => Some(17), 0xfffffef => Some(18), 0xffffff0 => Some(19),
|
||||
0xffffff1 => Some(20), 0xffffff2 => Some(21), 0xffffff3 => Some(23),
|
||||
0xffffff4 => Some(24), 0xffffff5 => Some(25), 0xffffff6 => Some(26),
|
||||
0xffffff7 => Some(27), 0xffffff8 => Some(28), 0xffffff9 => Some(29),
|
||||
0xffffffa => Some(30), 0xffffffb => Some(31), 0xffffffc => Some(127),
|
||||
0xffffffd => Some(220), 0xffffffe => Some(249),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// EOS (0x3fffffff) excluded; only non-EOS 30-bit codes listed.
|
||||
fn http2_huffman_table_len30(n: u32) -> Option<u8> {
|
||||
match n {
|
||||
0x3ffffffc => Some(10),
|
||||
0x3ffffffd => Some(13),
|
||||
0x3ffffffe => Some(22),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -1,228 +0,0 @@
|
||||
pub mod dns;
|
||||
pub mod ftp;
|
||||
pub mod http;
|
||||
pub mod http2;
|
||||
pub mod mqtt;
|
||||
pub mod quic;
|
||||
pub mod smtp;
|
||||
pub mod ssh;
|
||||
pub mod tls;
|
||||
|
||||
use http::{HttpRequest, HttpResponse};
|
||||
|
||||
pub use crate::model::rule_detection::{AppProto, ParsedPacket, buf};
|
||||
|
||||
/// Well-known TLS server ports. When either sport or dport matches, the flow
|
||||
/// is treated as TLS unconditionally — even for mid-stream captures where the
|
||||
/// TCP payload no longer starts at a TLS record boundary.
|
||||
fn is_tls_port(port: u16) -> bool {
|
||||
matches!(port,
|
||||
443 // HTTPS
|
||||
| 8443 // HTTPS alt
|
||||
| 465 // SMTPS
|
||||
| 993 // IMAPS
|
||||
| 995 // POP3S
|
||||
| 636 // LDAPS
|
||||
| 853 // DNS-over-TLS
|
||||
| 4433 // HTTPS alt
|
||||
| 8883 // MQTT over TLS
|
||||
)
|
||||
}
|
||||
|
||||
fn is_ssh_port(port: u16) -> bool {
|
||||
matches!(port, 22 | 2222)
|
||||
}
|
||||
|
||||
fn is_smtp_port(port: u16) -> bool {
|
||||
matches!(port, 25 | 587 | 2525)
|
||||
}
|
||||
|
||||
fn is_ftp_port(port: u16) -> bool {
|
||||
matches!(port, 21 | 2121)
|
||||
}
|
||||
|
||||
fn is_mqtt_port(port: u16) -> bool {
|
||||
matches!(port, 1883)
|
||||
}
|
||||
|
||||
fn is_http2_port(port: u16) -> bool {
|
||||
matches!(port, 80 | 8080 | 8000)
|
||||
}
|
||||
|
||||
fn is_quic_port(port: u16) -> bool {
|
||||
matches!(port, 443 | 8443 | 4433)
|
||||
}
|
||||
|
||||
/// Parse `data` into a `ParsedPacket`.
|
||||
///
|
||||
/// Both `sport` and `dport` are needed so that we can identify TLS flows by
|
||||
/// server port even when the callback only has the ephemeral client port stored
|
||||
/// in `current_dport` (e.g. the flow key was created from an inbound Google
|
||||
/// packet: sip=142.251.x.x:443 → dip=local:52873, so sport=443).
|
||||
///
|
||||
/// Detection order:
|
||||
/// 1. TLS — content-based header check OR port-based hint (catches mid-stream)
|
||||
/// 2. HTTP/2 — connection preface detection (cleartext h2c)
|
||||
/// 3. HTTP/1.x request/response
|
||||
/// 4. DNS (port 53 / 5353)
|
||||
/// 5. SSH — banner or Kexinit
|
||||
/// 6. SMTP
|
||||
/// 7. FTP
|
||||
/// 8. MQTT
|
||||
/// 9. Port-based TLS fallback
|
||||
pub fn detect_and_parse(data: &[u8], sport: u16, dport: u16) -> ParsedPacket {
|
||||
let mut pkt = ParsedPacket::new(data.to_vec());
|
||||
|
||||
if data.is_empty() {
|
||||
return pkt;
|
||||
}
|
||||
|
||||
// 1. TLS — content-based detection first.
|
||||
if let Some(tls) = tls::detect_tls(data) {
|
||||
pkt.raw.clear();
|
||||
pkt.is_tls = true;
|
||||
pkt.proto = AppProto::Tls;
|
||||
if tls.is_app_data {
|
||||
pkt.tls_app_data = true;
|
||||
return pkt;
|
||||
}
|
||||
pkt.set(buf::TLS_SNI, tls.sni);
|
||||
pkt.set(buf::TLS_VERSION, tls.version);
|
||||
pkt.set(buf::TLS_JA3, tls.ja3);
|
||||
pkt.set(buf::TLS_JA3S, tls.ja3s);
|
||||
pkt.set(buf::TLS_JA4, tls.ja4);
|
||||
pkt.set(buf::TLS_ALPN, tls.alpn);
|
||||
pkt.set(buf::TLS_CERT_SUBJECT, tls.cert_subject);
|
||||
pkt.set(buf::TLS_CERT_ISSUER, tls.cert_issuer);
|
||||
pkt.set(buf::TLS_CERT_FINGERPRINT, tls.cert_fingerprint);
|
||||
pkt.set(buf::TLS_CERT_NOT_BEFORE, tls.cert_not_before);
|
||||
pkt.set(buf::TLS_CERT_NOT_AFTER, tls.cert_not_after);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
// 2. HTTP/2 cleartext (h2c) — detected by connection preface.
|
||||
if data.starts_with(b"PRI * HTTP/2.0\r\n") || (is_http2_port(sport) || is_http2_port(dport)) {
|
||||
if let Some(h2) = http2::parse_http2(data) {
|
||||
pkt.proto = AppProto::Http2;
|
||||
pkt.set(buf::HTTP2_METHOD, h2.method);
|
||||
pkt.set(buf::HTTP2_PATH, h2.path);
|
||||
pkt.set(buf::HTTP2_AUTHORITY, h2.authority);
|
||||
pkt.set(buf::HTTP2_STATUS, h2.status);
|
||||
pkt.set(buf::HTTP2_UA, h2.user_agent);
|
||||
return pkt;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. HTTP/1.x request / response — must come BEFORE port-based TLS fallback
|
||||
// so that plaintext HTTP on port 443 is identified correctly.
|
||||
if let Some(req) = http::parse_http_request(data) {
|
||||
pkt.proto = AppProto::Http;
|
||||
apply_request(&mut pkt, req);
|
||||
return pkt;
|
||||
}
|
||||
if let Some(resp) = http::parse_http_response(data) {
|
||||
pkt.proto = AppProto::Http;
|
||||
apply_response(&mut pkt, resp);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
// 4. DNS
|
||||
if dport == 53 || dport == 5353 || sport == 53 || sport == 5353 {
|
||||
pkt.proto = AppProto::Dns;
|
||||
if let Some(dns) = dns::parse_dns(data) {
|
||||
pkt.set(buf::DNS_QUERY, dns.query_name);
|
||||
pkt.set(buf::DNS_RCODE, dns.rcode);
|
||||
pkt.set(buf::DNS_QTYPE, dns.qtype);
|
||||
pkt.set(buf::DNS_ANSWER, dns.answer);
|
||||
}
|
||||
return pkt;
|
||||
}
|
||||
|
||||
// 5. SSH — banner (starts with "SSH-") or binary Kexinit.
|
||||
if data.starts_with(b"SSH-") || is_ssh_port(sport) || is_ssh_port(dport) {
|
||||
if let Some(ssh) = ssh::parse_ssh(data) {
|
||||
pkt.proto = AppProto::Ssh;
|
||||
pkt.set(buf::SSH_PROTO, ssh.proto);
|
||||
pkt.set(buf::SSH_SOFTWARE, ssh.software);
|
||||
pkt.set(buf::SSH_HASSH, ssh.hassh);
|
||||
return pkt;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. SMTP
|
||||
if is_smtp_port(sport) || is_smtp_port(dport) {
|
||||
if let Some(smtp) = smtp::parse_smtp(data) {
|
||||
pkt.proto = AppProto::Smtp;
|
||||
pkt.set(buf::SMTP_HELO, smtp.helo);
|
||||
pkt.set(buf::SMTP_FROM, smtp.from);
|
||||
pkt.set(buf::SMTP_RCPT, smtp.rcpt);
|
||||
pkt.set(buf::SMTP_REPLY, smtp.reply);
|
||||
return pkt;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. FTP
|
||||
if is_ftp_port(sport) || is_ftp_port(dport) {
|
||||
if let Some(ftp) = ftp::parse_ftp(data) {
|
||||
pkt.proto = AppProto::Ftp;
|
||||
pkt.set(buf::FTP_COMMAND, ftp.command);
|
||||
pkt.set(buf::FTP_FILENAME, ftp.filename);
|
||||
pkt.set(buf::FTP_REPLY, ftp.reply);
|
||||
return pkt;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. MQTT
|
||||
if is_mqtt_port(sport) || is_mqtt_port(dport) {
|
||||
if let Some(mqtt) = mqtt::parse_mqtt(data) {
|
||||
pkt.proto = AppProto::Mqtt;
|
||||
pkt.set(buf::MQTT_CLIENTID, mqtt.client_id);
|
||||
pkt.set(buf::MQTT_TOPIC, mqtt.topic);
|
||||
pkt.set(buf::MQTT_USERNAME, mqtt.username);
|
||||
return pkt;
|
||||
}
|
||||
}
|
||||
|
||||
// 9. QUIC (UDP port 443/8443 — QUIC is always UDP, but we see payload here
|
||||
// from the eBPF capture which forwards UDP payloads the same way).
|
||||
if is_quic_port(sport) || is_quic_port(dport) {
|
||||
if let Some(q) = quic::detect_quic(data) {
|
||||
pkt.proto = AppProto::Quic;
|
||||
pkt.set(buf::QUIC_SNI, q.sni);
|
||||
pkt.set(buf::QUIC_VERSION, q.version);
|
||||
pkt.set(buf::QUIC_UA, q.ua);
|
||||
return pkt;
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Port-based TLS fallback — reached only when content detection found
|
||||
// nothing. Handles mid-stream captures of TLS flows where the first
|
||||
// byte is not a TLS record header (connection established before we
|
||||
// started capturing). Suricata equivalent: AppLayerProtoDetectGetProto
|
||||
// with port hints as last resort.
|
||||
if is_tls_port(sport) || is_tls_port(dport) {
|
||||
pkt.raw.clear();
|
||||
pkt.is_tls = true;
|
||||
pkt.proto = AppProto::Tls;
|
||||
return pkt;
|
||||
}
|
||||
|
||||
pkt
|
||||
}
|
||||
|
||||
fn apply_request(pkt: &mut ParsedPacket, req: HttpRequest) {
|
||||
pkt.set(buf::HTTP_URI, req.uri);
|
||||
pkt.set(buf::HTTP_RAW_URI, req.raw_uri);
|
||||
pkt.set(buf::HTTP_METHOD, req.method);
|
||||
pkt.set(buf::HTTP_HOST, req.host);
|
||||
pkt.set(buf::HTTP_USER_AGENT, req.user_agent);
|
||||
pkt.set(buf::HTTP_COOKIE, req.cookie);
|
||||
pkt.set(buf::HTTP_HEADER, req.headers);
|
||||
pkt.set(buf::HTTP_CLIENT_BODY, req.client_body);
|
||||
}
|
||||
|
||||
fn apply_response(pkt: &mut ParsedPacket, resp: HttpResponse) {
|
||||
pkt.set(buf::HTTP_STAT_CODE, resp.stat_code);
|
||||
pkt.set(buf::HTTP_HEADER, resp.headers);
|
||||
pkt.set(buf::HTTP_SERVER_BODY, resp.server_body);
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
/* Ported from Suricata rust/src/mqtt/parser.rs
|
||||
* Copyright (C) 2020-2022 Open Information Security Foundation
|
||||
* Licensed under GPLv2.
|
||||
*
|
||||
* Rewritten to use nom8 (nom = "8.0" aliased as nom8).
|
||||
*
|
||||
* Fixed header byte layout (Suricata parse_fixed_header_flags via bits/take_bits):
|
||||
* bits 7-4: message_type (4 bits)
|
||||
* bit 3: dup_flag (1 bit)
|
||||
* bits 2-1: qos_level (2 bits)
|
||||
* bit 0: retain (1 bit)
|
||||
*
|
||||
* Variable-length integer (Suricata parse_mqtt_variable_integer):
|
||||
* take_while_m_n(0, 3, is_continuation_bit_set) + verify last byte has no MSB
|
||||
*
|
||||
* CONNECT payload (Suricata parse_connect):
|
||||
* protocol_string + protocol_version + rawflags + keepalive +
|
||||
* [MQTT5: properties] + client_id + [will: [MQTT5: props] + topic + message] +
|
||||
* [username] + [password]
|
||||
*
|
||||
* PUBLISH variable header (Suricata parse_publish):
|
||||
* topic + [has_id: message_id] + [MQTT5: properties]
|
||||
* has_id = qos_level > 0
|
||||
*/
|
||||
|
||||
use nom8::bytes::streaming::take_while_m_n;
|
||||
use nom8::combinator::verify;
|
||||
use nom8::error::{Error as NomError};
|
||||
use nom8::multi::length_data;
|
||||
use nom8::number::streaming::{be_u16, be_u8};
|
||||
use nom8::{IResult, Parser};
|
||||
|
||||
const MQTT_CONNECT: u8 = 1;
|
||||
const MQTT_PUBLISH: u8 = 3;
|
||||
|
||||
pub struct MqttInfo {
|
||||
pub client_id: Vec<u8>,
|
||||
pub topic: Vec<u8>,
|
||||
pub username: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn parse_mqtt(data: &[u8]) -> Option<MqttInfo> {
|
||||
if data.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Fixed header first byte: type(4) | dup(1) | qos(2) | retain(1)
|
||||
let first_byte = data[0];
|
||||
let packet_type = first_byte >> 4;
|
||||
let qos_level = (first_byte >> 1) & 0x03;
|
||||
|
||||
// Parse remaining length using nom8 variable integer parser
|
||||
let (payload, rem_len) = parse_mqtt_variable_integer(&data[1..]).ok()?;
|
||||
let rem_len = rem_len as usize;
|
||||
|
||||
if rem_len > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let payload = &payload[..rem_len];
|
||||
|
||||
match packet_type {
|
||||
MQTT_CONNECT => parse_connect(payload),
|
||||
MQTT_PUBLISH => parse_publish(payload, qos_level),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Variable-length integer (Suricata parse_mqtt_variable_integer) ───────────
|
||||
|
||||
/// Decode MQTT variable-length integer using nom8.
|
||||
///
|
||||
/// Suricata: take_while_m_n(0, 3, is_continuation_bit_set) collects continuation
|
||||
/// bytes (MSB set), then verify(be_u8, |v| !is_continuation_bit_set(v)) reads
|
||||
/// the final byte. convert_varint computes the value.
|
||||
fn parse_mqtt_variable_integer(input: &[u8]) -> IResult<&[u8], u32, NomError<&[u8]>> {
|
||||
#[inline]
|
||||
fn is_continuation_bit_set(b: u8) -> bool {
|
||||
(b & 0x80) != 0
|
||||
}
|
||||
|
||||
let (input, continued) = take_while_m_n(0, 3, is_continuation_bit_set)(input)?;
|
||||
let (input, last) =
|
||||
verify(be_u8::<_, NomError<_>>, |&val| !is_continuation_bit_set(val)).parse(input)?;
|
||||
|
||||
// convert_varint: sum up continued bytes + last byte with 128-multiplier
|
||||
let mut multiplier = 1u32;
|
||||
let mut value = 0u32;
|
||||
for &b in continued {
|
||||
value += (b as u32 & 0x7F) * multiplier;
|
||||
multiplier *= 128;
|
||||
}
|
||||
value += (last as u32 & 0x7F) * multiplier;
|
||||
|
||||
Ok((input, value))
|
||||
}
|
||||
|
||||
// ─── Wire helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read a length-prefixed UTF-8 string (Suricata parse_mqtt_string: length_data(be_u16)).
|
||||
#[inline]
|
||||
fn parse_mqtt_string(input: &[u8]) -> IResult<&[u8], Vec<u8>, NomError<&[u8]>> {
|
||||
let (input, content) = length_data(be_u16::<_, NomError<_>>).parse(input)?;
|
||||
Ok((input, content.to_vec()))
|
||||
}
|
||||
|
||||
/// Read a length-prefixed binary field (Suricata parse_mqtt_binary_data: length_data(be_u16)).
|
||||
#[inline]
|
||||
fn parse_mqtt_binary_data(input: &[u8]) -> IResult<&[u8], Vec<u8>, NomError<&[u8]>> {
|
||||
let (input, content) = length_data(be_u16::<_, NomError<_>>).parse(input)?;
|
||||
Ok((input, content.to_vec()))
|
||||
}
|
||||
|
||||
/// Parse MQTT 5.0 properties block: variable-length size + that many bytes.
|
||||
///
|
||||
/// Suricata: parse_properties with precond; if precond is false, skip.
|
||||
/// Returns the input unchanged when skip=true.
|
||||
fn parse_properties_skip<'a>(
|
||||
input: &'a [u8],
|
||||
precond: bool,
|
||||
) -> IResult<&'a [u8], (), NomError<&'a [u8]>> {
|
||||
if !precond {
|
||||
return Ok((input, ()));
|
||||
}
|
||||
let (input, prop_len) = parse_mqtt_variable_integer(input)?;
|
||||
// Skip prop_len bytes
|
||||
if prop_len as usize > input.len() {
|
||||
return Err(nom8::Err::Error(NomError::new(input, nom8::error::ErrorKind::Eof)));
|
||||
}
|
||||
Ok((&input[prop_len as usize..], ()))
|
||||
}
|
||||
|
||||
// ─── CONNECT (Suricata parse_connect) ────────────────────────────────────────
|
||||
|
||||
fn parse_connect(input: &[u8]) -> Option<MqttInfo> {
|
||||
// Protocol name: be_u16-length-prefixed string ("MQTT" or "MQIsdp")
|
||||
let (input, _protocol_string) = parse_mqtt_string(input).ok()?;
|
||||
|
||||
// Protocol version byte
|
||||
let (input, protocol_version) = be_u8::<_, NomError<_>>(input).ok()?;
|
||||
|
||||
// Connect flags
|
||||
let (input, rawflags) = be_u8::<_, NomError<_>>(input).ok()?;
|
||||
let username_flag = rawflags & 0x80 != 0;
|
||||
let password_flag = rawflags & 0x40 != 0;
|
||||
let will_flag = rawflags & 0x04 != 0;
|
||||
|
||||
// Keep Alive: be_u16 (unused but must be consumed)
|
||||
let (input, _keepalive) = be_u16::<_, NomError<_>>(input).ok()?;
|
||||
|
||||
// MQTT 5.0: connect properties (Suricata: parse_properties with precond=version==5)
|
||||
let (input, _) = parse_properties_skip(input, protocol_version == 5).ok()?;
|
||||
|
||||
// Client ID (Suricata: parse_mqtt_string)
|
||||
let (input, client_id) = parse_mqtt_string(input).ok()?;
|
||||
|
||||
// Will fields (Suricata: cond(will_flag, ...))
|
||||
let input = if will_flag {
|
||||
// MQTT 5.0: will properties
|
||||
let (input, _) = parse_properties_skip(input, protocol_version == 5).ok()?;
|
||||
let (input, _will_topic) = parse_mqtt_string(input).ok()?;
|
||||
let (input, _will_message) = parse_mqtt_binary_data(input).ok()?;
|
||||
input
|
||||
} else {
|
||||
input
|
||||
};
|
||||
|
||||
// Username (Suricata: cond(username_flag, parse_mqtt_string))
|
||||
let (input, username) = if username_flag {
|
||||
let (i, u) = parse_mqtt_string(input).ok()?;
|
||||
(i, u)
|
||||
} else {
|
||||
(input, Vec::new())
|
||||
};
|
||||
|
||||
// Password (Suricata: cond(password_flag, parse_mqtt_binary_data)) - consumed but not stored
|
||||
let _input = if password_flag {
|
||||
match parse_mqtt_binary_data(input) {
|
||||
Ok((i, _)) => i,
|
||||
Err(_) => input,
|
||||
}
|
||||
} else {
|
||||
input
|
||||
};
|
||||
|
||||
Some(MqttInfo { client_id, topic: Vec::new(), username })
|
||||
}
|
||||
|
||||
// ─── PUBLISH (Suricata parse_publish) ────────────────────────────────────────
|
||||
|
||||
/// Parse PUBLISH variable header.
|
||||
///
|
||||
/// Ports Suricata's parse_publish: parse_mqtt_string(topic) + cond(has_id, be_u16).
|
||||
/// has_id (Packet Identifier present) = qos_level > 0.
|
||||
fn parse_publish(input: &[u8], qos_level: u8) -> Option<MqttInfo> {
|
||||
let (input, topic) = parse_mqtt_string(input).ok()?;
|
||||
|
||||
// Packet Identifier present when QoS > 0 (Suricata: cond(has_id, be_u16))
|
||||
let _input = if qos_level > 0 {
|
||||
match be_u16::<_, NomError<_>>(input) {
|
||||
Ok((i, _)) => i,
|
||||
Err(_) => input,
|
||||
}
|
||||
} else {
|
||||
input
|
||||
};
|
||||
|
||||
Some(MqttInfo { client_id: Vec::new(), topic, username: Vec::new() })
|
||||
}
|
||||
@ -1,773 +0,0 @@
|
||||
/* Ported from Suricata rust/src/quic/parser.rs and rust/src/quic/frames.rs
|
||||
* Copyright (C) 2021-2022 Open Information Security Foundation
|
||||
* Licensed under GPLv2.
|
||||
*
|
||||
* QUIC long-header detection and Initial-packet parsing per RFC 9000.
|
||||
* SNI is extracted from the TLS ClientHello embedded in CRYPTO frames
|
||||
* of Initial packets. Initial packets use well-known keys (RFC 9001 §5.2)
|
||||
* derived from the Destination Connection ID, so no session state is needed.
|
||||
*
|
||||
* gQUIC (Google QUIC) support: versions Q043-Q046. Q043-Q045 Initial packets
|
||||
* are plaintext after skipping the 4-byte packet number and 12-byte FNV hash.
|
||||
* The SNI is carried in a STREAM frame (type 0x80+) as a gQUIC CHLO tag-value
|
||||
* structure (StreamTag::Sni = 0x534e4900), not in a TLS ClientHello.
|
||||
* Q046 uses IETF long-header format and is treated like RFC QUIC for Initial.
|
||||
*
|
||||
* Varint decoding (RFC 9000 §16): ported from Suricata quic_var_uint using nom8.
|
||||
* CRYPTO frame parsing: ported from Suricata parse_crypto_frame using nom8.
|
||||
* gQUIC frame parsing: ported from Suricata frames.rs decode_frame / parse_stream_frame.
|
||||
*
|
||||
* Key derivation (QUIC v1, RFC 9001 §A.1):
|
||||
* initial_secret = HKDF-Extract(initial_salt, dcid)
|
||||
* client_in = HKDF-Expand-Label(initial_secret, "client in", "", 32)
|
||||
* quic_key = HKDF-Expand-Label(client_in, "quic key", "", 16)
|
||||
* quic_iv = HKDF-Expand-Label(client_in, "quic iv", "", 12)
|
||||
* quic_hp = HKDF-Expand-Label(client_in, "quic hp", "", 16)
|
||||
*
|
||||
* Header-protection mask (RFC 9001 §5.4.1):
|
||||
* sample = ciphertext[pn_offset + 4 .. pn_offset + 20]
|
||||
* mask = AES-128-ECB(quic_hp, sample)
|
||||
*
|
||||
* Payload decryption: AEAD-AES-128-GCM with nonce = quic_iv XOR pn_bytes.
|
||||
*
|
||||
* CYU fingerprint (Suricata cyu.rs):
|
||||
* hash of "version:ciphers:extensions:..." analogous to JA3 for QUIC.
|
||||
* Not yet implemented; placeholder field kept for future use.
|
||||
*/
|
||||
|
||||
use aes_gcm::{Aes128Gcm, Key, Nonce, aead::{Aead, KeyInit, Payload}};
|
||||
use nom8::number::complete::{be_u8, be_u24, be_u32};
|
||||
use nom8::IResult;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
// ─── gQUIC version constants (Suricata quic.rs) ──────────────────────────────
|
||||
|
||||
/// gQUIC version "Q043" = b'Q','0','4','3' packed as big-endian u32.
|
||||
const GQUIC_VERSION_Q043: u32 = 0x5130_3433;
|
||||
/// gQUIC version "Q044".
|
||||
const GQUIC_VERSION_Q044: u32 = 0x5130_3434;
|
||||
/// gQUIC version "Q045".
|
||||
const GQUIC_VERSION_Q045: u32 = 0x5130_3435;
|
||||
/// gQUIC version "Q046" — uses IETF long-header format.
|
||||
const GQUIC_VERSION_Q046: u32 = 0x5130_3436;
|
||||
|
||||
/// QUIC v1 initial salt (RFC 9001 §A.1).
|
||||
const INITIAL_SALT_V1: &[u8] = &[
|
||||
0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
|
||||
0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
|
||||
0xcc, 0xbb, 0x7f, 0x0a,
|
||||
];
|
||||
|
||||
/// QUIC v2 initial salt (RFC 9369 §A.1).
|
||||
const INITIAL_SALT_V2: &[u8] = &[
|
||||
0x0d, 0xed, 0xe3, 0xde, 0xf7, 0x00, 0xa6, 0xdb,
|
||||
0x81, 0x93, 0x81, 0xbe, 0x6e, 0x26, 0x9d, 0xcb,
|
||||
0xf9, 0xbd, 0x2e, 0xd9,
|
||||
];
|
||||
|
||||
pub struct QuicInfo {
|
||||
/// SNI extracted from embedded TLS ClientHello or gQUIC CHLO (empty if unavailable).
|
||||
pub sni: Vec<u8>,
|
||||
/// Human-readable QUIC version: "1", "2", "Q043", etc.
|
||||
pub version: Vec<u8>,
|
||||
/// User-agent from gQUIC CHLO UAID tag (StreamTag::Uaid = 0x55414944).
|
||||
/// Only populated for gQUIC Q043-Q045 plaintext Initial packets.
|
||||
/// RFC QUIC v1/v2 UA is in encrypted 1-RTT HEADERS and is not available passively.
|
||||
pub ua: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Attempt to parse `data` as a QUIC long-header packet.
|
||||
///
|
||||
/// Returns `None` when the data does not look like QUIC. Returns
|
||||
/// `Some(QuicInfo)` with at least `version` populated when QUIC is detected.
|
||||
///
|
||||
/// Handles both RFC 9000 QUIC (v1/v2/drafts) and gQUIC (Q043-Q046).
|
||||
/// Ported from Suricata rust/src/quic/parser.rs.
|
||||
pub fn detect_quic(data: &[u8]) -> Option<QuicInfo> {
|
||||
if data.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let first = data[0];
|
||||
|
||||
// All QUIC long-header packets have bit7 = 1 (public long-header marker).
|
||||
if first & 0x80 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = u32::from_be_bytes([data[1], data[2], data[3], data[4]]);
|
||||
if !is_valid_quic_version(version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// gQUIC Q043-Q045 do not require the IETF fixed bit (bit6 = 0x40).
|
||||
// RFC QUIC and Q046 require bit7 + bit6 = 0xC0 (Suricata quic.rs:is_long_header).
|
||||
let is_gquic_pre46 = is_gquic_version(version) && version != GQUIC_VERSION_Q046;
|
||||
if !is_gquic_pre46 && (first & 0x40 == 0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_str: Vec<u8> = match version {
|
||||
0x0000_0001 => b"1".to_vec(),
|
||||
0x6b33_43cf => b"2".to_vec(),
|
||||
0x0000_0000 => b"negotiation".to_vec(),
|
||||
GQUIC_VERSION_Q043 => b"Q043".to_vec(),
|
||||
GQUIC_VERSION_Q044 => b"Q044".to_vec(),
|
||||
GQUIC_VERSION_Q045 => b"Q045".to_vec(),
|
||||
GQUIC_VERSION_Q046 => b"Q046".to_vec(),
|
||||
v => format!("{:08x}", v).into_bytes(),
|
||||
};
|
||||
|
||||
let mut sni = Vec::new();
|
||||
let mut ua = Vec::new();
|
||||
|
||||
if is_gquic_pre46 {
|
||||
// gQUIC Q043-Q045: Initial packet type is (first & 0x7f) == 0x7f.
|
||||
// Suricata quic.rs: match first & 0x7f { 0x7f => QuicType::Initial, ... }
|
||||
if (first & 0x7f) == 0x7f {
|
||||
let (s, u) = try_extract_gquic_info(data);
|
||||
sni = s;
|
||||
ua = u;
|
||||
}
|
||||
} else {
|
||||
// RFC QUIC v1/v2 and Q046: DCID length at byte 5, max 20 per RFC 9000.
|
||||
let pos = 5usize;
|
||||
if pos >= data.len() {
|
||||
return Some(QuicInfo { sni, version: version_str, ua });
|
||||
}
|
||||
let dcid_len = data[pos] as usize;
|
||||
if dcid_len > 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Packet type: bits 4-5 of first byte.
|
||||
// RFC 9000 (v1) and Q046: Initial = 0b00.
|
||||
// RFC 9369 (v2): Initial = 0b01 (Suricata parser.rs QuicVersion::V2 branch).
|
||||
let pkt_bits = (first >> 4) & 0x03;
|
||||
let is_initial = match version {
|
||||
0x0000_0001 => pkt_bits == 0x00,
|
||||
0x6b33_43cf => pkt_bits == 0x01,
|
||||
_ => pkt_bits == 0x00,
|
||||
};
|
||||
if is_initial {
|
||||
if let Some(parsed_sni) = try_extract_initial_sni(data, version, dcid_len) {
|
||||
sni = parsed_sni;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(QuicInfo { sni, version: version_str, ua })
|
||||
}
|
||||
|
||||
// ─── gQUIC SNI extraction ─────────────────────────────────────────────────────
|
||||
|
||||
/// Extract SNI and UA from a gQUIC Q043/Q044/Q045 Initial packet.
|
||||
///
|
||||
/// gQUIC Initial plaintext layout (after first_byte[0] + version[1-4]):
|
||||
/// CID_lengths(1) | DCID(dcid_len) | SCID(scid_len) | pkt_num(4) | auth_hash(12) | frames
|
||||
///
|
||||
/// CID_lengths nibble encoding (Suricata quic.rs):
|
||||
/// dcid_nibble = (byte >> 4) & 0x0F → actual_len = if nonzero { nibble + 3 } else { 0 }
|
||||
/// scid_nibble = byte & 0x0F → actual_len = if nonzero { nibble + 3 } else { 0 }
|
||||
///
|
||||
/// Returns (sni, ua) — either may be empty if the corresponding CHLO tag is absent.
|
||||
/// Mirrors Suricata quic.rs handle_frames() which reads both StreamTag::Sni and StreamTag::Uaid.
|
||||
fn try_extract_gquic_info(data: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let none = (Vec::new(), Vec::new());
|
||||
let rest = match data.get(5..) { Some(r) if !r.is_empty() => r, _ => return none };
|
||||
|
||||
let cid_lengths = rest[0];
|
||||
let rest = &rest[1..];
|
||||
|
||||
let mut dcid_len = ((cid_lengths >> 4) & 0x0F) as usize;
|
||||
let mut scid_len = (cid_lengths & 0x0F) as usize;
|
||||
if dcid_len != 0 { dcid_len += 3; }
|
||||
if scid_len != 0 { scid_len += 3; }
|
||||
|
||||
let rest = match rest.get(dcid_len + scid_len..) { Some(r) => r, None => return none };
|
||||
// Skip pkt_num (4 bytes) + msg_auth_hash (12 bytes).
|
||||
let rest = match rest.get(16..) { Some(r) => r, None => return none };
|
||||
|
||||
parse_gquic_frames_for_info(rest)
|
||||
}
|
||||
|
||||
/// Walk gQUIC frames and return (sni, ua) from the first CHLO found.
|
||||
///
|
||||
/// Uses be_u8 for frame type (Suricata frames.rs:decode_frame), not quic_var_uint.
|
||||
/// This is critical because gQUIC STREAM type has bit7 set (0x80+), which a varint
|
||||
/// decoder would misinterpret as a 2-byte value.
|
||||
/// Mirrors Suricata handle_frames() which extracts both StreamTag::Sni and StreamTag::Uaid.
|
||||
fn parse_gquic_frames_for_info(payload: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut input = payload;
|
||||
while !input.is_empty() {
|
||||
let frame_ty = input[0];
|
||||
let rest = &input[1..];
|
||||
|
||||
if frame_ty & 0x80 == 0x80 {
|
||||
// STREAM frame — carries gQUIC CHLO tag-value structure.
|
||||
// Suricata: parse_stream_frame(rest, frame_ty)
|
||||
return parse_gquic_stream_frame_for_info(rest, frame_ty);
|
||||
}
|
||||
|
||||
match frame_ty {
|
||||
0x00 => {
|
||||
// PADDING: consume all consecutive zero bytes.
|
||||
// Suricata: parse_padding_frame
|
||||
let mut i = 0;
|
||||
while i < rest.len() && rest[i] == 0 { i += 1; }
|
||||
input = &rest[i..];
|
||||
}
|
||||
0x08 => {
|
||||
// gQUIC crypto stream frame (handshake data).
|
||||
// Suricata: parse_crypto_stream_frame (frames.rs:504)
|
||||
return parse_gquic_crypto_stream_frame_for_info(rest);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
(Vec::new(), Vec::new())
|
||||
}
|
||||
|
||||
/// Parse a gQUIC STREAM frame and return (sni, ua) from the CHLO tag-value payload.
|
||||
///
|
||||
/// Ported from Suricata frames.rs parse_stream_frame (line 459).
|
||||
///
|
||||
/// Frame type byte layout: 0b1_f_d_ooo_ss
|
||||
/// f (0x40): FIN flag
|
||||
/// d (0x20): has data_length field
|
||||
/// ooo (0x1c >> 2): offset nibble (actual offset bytes = nibble+1 if nonzero)
|
||||
/// ss (0x03): stream_id length - 1 (so +1 gives actual bytes)
|
||||
fn parse_gquic_stream_frame_for_info(input: &[u8], frame_ty: u8) -> (Vec<u8>, Vec<u8>) {
|
||||
let none = (Vec::new(), Vec::new());
|
||||
let has_data_length = frame_ty & 0x20 == 0x20;
|
||||
|
||||
let offset_nibble = (frame_ty & 0x1c) >> 2;
|
||||
let offset_hdr_length = if offset_nibble != 0 { (offset_nibble + 1) as usize } else { 0 };
|
||||
let stream_id_hdr_length = ((frame_ty & 0x03) + 1) as usize;
|
||||
|
||||
let rest = match input.get(stream_id_hdr_length..) { Some(r) => r, None => return none };
|
||||
let rest = match rest.get(offset_hdr_length..) { Some(r) => r, None => return none };
|
||||
|
||||
let stream_data = if has_data_length {
|
||||
if rest.len() < 2 { return none; }
|
||||
let data_length = u16::from_be_bytes([rest[0], rest[1]]) as usize;
|
||||
match rest.get(2..2 + data_length) { Some(s) => s, None => return none }
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
|
||||
// Suricata: all_consuming(parse_crypto_stream).parse(stream_data)
|
||||
parse_crypto_stream_for_info(stream_data)
|
||||
}
|
||||
|
||||
/// Parse a gQUIC crypto stream frame (type 0x08) and return (sni, ua).
|
||||
///
|
||||
/// Ported from Suricata frames.rs parse_crypto_stream_frame (line 504).
|
||||
/// Layout: offset(quic_var_uint) + data_length(quic_var_uint) + stream_data
|
||||
fn parse_gquic_crypto_stream_frame_for_info(input: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let none = (Vec::new(), Vec::new());
|
||||
let (rest, _offset) = match quic_var_uint(input) { Ok(v) => v, Err(_) => return none };
|
||||
let (rest, data_length) = match quic_var_uint(rest) { Ok(v) => v, Err(_) => return none };
|
||||
if data_length > u32::MAX as u64 { return none; }
|
||||
let stream_data = match rest.get(..data_length as usize) { Some(s) => s, None => return none };
|
||||
parse_crypto_stream_for_info(stream_data)
|
||||
}
|
||||
|
||||
/// Parse a gQUIC CHLO tag-value structure and return (sni, ua).
|
||||
///
|
||||
/// Ported from Suricata frames.rs parse_crypto_stream (line 430).
|
||||
/// Mirrors Suricata handle_frames() which reads StreamTag::Sni (0x534e4900)
|
||||
/// and StreamTag::Uaid (0x55414944) from the same CHLO.
|
||||
///
|
||||
/// Wire format:
|
||||
/// message_tag(4 BE) | num_entries(2 LE) | padding(2) |
|
||||
/// [[tag(4 BE) | end_offset(4 LE)] × num_entries] |
|
||||
/// [value_data cumulative bytes]
|
||||
///
|
||||
/// Tags are sorted; each entry's value occupies bytes [prev_end..end_offset]
|
||||
/// in the trailing value_data region.
|
||||
fn parse_crypto_stream_for_info(input: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
// Minimum: message_tag(4) + num_entries(2) + padding(2) = 8 bytes.
|
||||
if input.len() < 8 { return (Vec::new(), Vec::new()); }
|
||||
|
||||
// message_tag(4 BE): should be CHLO (0x43484c4f), but we accept anything.
|
||||
let num_entries = u16::from_le_bytes([input[4], input[5]]) as usize;
|
||||
// input[6..8] is padding, skipped.
|
||||
|
||||
let tag_table_size = num_entries * 8;
|
||||
let value_data_start = 8 + tag_table_size;
|
||||
if input.len() < value_data_start { return (Vec::new(), Vec::new()); }
|
||||
|
||||
let mut previous_end: u32 = 0;
|
||||
let mut value_cursor: usize = 0;
|
||||
let mut sni: Vec<u8> = Vec::new();
|
||||
let mut ua: Vec<u8> = Vec::new();
|
||||
|
||||
for i in 0..num_entries {
|
||||
let entry = 8 + i * 8;
|
||||
let tag = u32::from_be_bytes([
|
||||
input[entry], input[entry + 1], input[entry + 2], input[entry + 3],
|
||||
]);
|
||||
let end_offset = u32::from_le_bytes([
|
||||
input[entry + 4], input[entry + 5], input[entry + 6], input[entry + 7],
|
||||
]);
|
||||
|
||||
let value_len = match end_offset.checked_sub(previous_end) {
|
||||
Some(v) => v as usize,
|
||||
None => break,
|
||||
};
|
||||
let value_start = value_data_start + value_cursor;
|
||||
let value_end = value_start + value_len;
|
||||
|
||||
if let Some(slice) = input.get(value_start..value_end) {
|
||||
match tag {
|
||||
0x534e_4900 => sni = slice.to_vec(), // StreamTag::Sni
|
||||
0x5541_4944 => ua = slice.to_vec(), // StreamTag::Uaid
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
value_cursor += value_len;
|
||||
previous_end = end_offset;
|
||||
|
||||
// Early exit once both are found.
|
||||
if !sni.is_empty() && !ua.is_empty() { break; }
|
||||
}
|
||||
|
||||
(sni, ua)
|
||||
}
|
||||
|
||||
// ─── RFC QUIC Initial-packet SNI extraction ───────────────────────────────────
|
||||
|
||||
/// Attempt to strip header protection and extract SNI from a QUIC Initial packet.
|
||||
///
|
||||
/// Suricata reference: quic_parse_initial → quic_parse_crypto_frame →
|
||||
/// quic_extract_sni (rust/src/quic/parser.rs).
|
||||
///
|
||||
/// Header protection removal (RFC 9001 §5.4.1):
|
||||
/// sample = payload_ciphertext[4..20] (relative to protected pn position)
|
||||
/// mask = AES-128-ECB(hp_key, sample)
|
||||
/// first_byte ^= mask[0] & 0x0F
|
||||
/// pn_bytes[i] ^= mask[1 + i]
|
||||
fn try_extract_initial_sni(data: &[u8], version: u32, dcid_len: usize) -> Option<Vec<u8>> {
|
||||
// RFC 9000 §17.2: byte[5] = DCID_Length, bytes[6..6+dcid_len] = DCID data.
|
||||
let mut pos = 6usize + dcid_len; // skip first_byte(1)+version(4)+DCID_Length(1)+DCID
|
||||
|
||||
// SCID
|
||||
if pos >= data.len() { return None; }
|
||||
let scid_len = data[pos] as usize;
|
||||
pos += 1 + scid_len;
|
||||
|
||||
// Token (Initial only): varint length + bytes.
|
||||
let token_len = read_varint(data, &mut pos)? as usize;
|
||||
pos += token_len;
|
||||
|
||||
// Remaining payload length (varint).
|
||||
let _payload_len = read_varint(data, &mut pos)?;
|
||||
|
||||
let pn_offset = pos;
|
||||
if pn_offset + 20 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Derive keys from DCID.
|
||||
let dcid = &data[6..6 + dcid_len];
|
||||
let salt = if version == 0x0000_0001 { INITIAL_SALT_V1 } else { INITIAL_SALT_V2 };
|
||||
let initial_secret = hkdf_extract(salt, dcid);
|
||||
let client_in = hkdf_expand_label(&initial_secret, "client in", &[], 32);
|
||||
let hp_key = hkdf_expand_label(&client_in, "quic hp", &[], 16);
|
||||
let quic_key = hkdf_expand_label(&client_in, "quic key", &[], 16);
|
||||
let quic_iv = hkdf_expand_label(&client_in, "quic iv", &[], 12);
|
||||
|
||||
// Header protection mask: AES-128-ECB(hp_key, ciphertext[4..20]).
|
||||
let sample: [u8; 16] = data[pn_offset + 4..pn_offset + 20].try_into().ok()?;
|
||||
let mask = aes128_ecb_block(&hp_key.try_into().ok()?, &sample);
|
||||
|
||||
// Unmask first byte and determine packet number length.
|
||||
let first_unmasked = data[0] ^ (mask[0] & 0x0F);
|
||||
let pn_len = (first_unmasked & 0x03) as usize + 1;
|
||||
|
||||
// Unmask packet number.
|
||||
let mut pn_bytes = [0u8; 4];
|
||||
for i in 0..pn_len {
|
||||
if pn_offset + i >= data.len() { return None; }
|
||||
pn_bytes[i] = data[pn_offset + i] ^ mask[1 + i];
|
||||
}
|
||||
let packet_number = {
|
||||
let mut pn = 0u64;
|
||||
for i in 0..pn_len { pn = (pn << 8) | pn_bytes[i] as u64; }
|
||||
pn
|
||||
};
|
||||
|
||||
// Build AAD (decrypted header bytes).
|
||||
let ciphertext_start = pn_offset + pn_len;
|
||||
if ciphertext_start >= data.len() { return None; }
|
||||
let mut header_aad = data[..ciphertext_start].to_vec();
|
||||
header_aad[0] = first_unmasked;
|
||||
for i in 0..pn_len {
|
||||
header_aad[pn_offset + i] = pn_bytes[i];
|
||||
}
|
||||
|
||||
// Nonce: quic_iv XOR packet_number (right-aligned to 12 bytes).
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce.copy_from_slice(&quic_iv);
|
||||
let pn_be = packet_number.to_be_bytes();
|
||||
for i in 0..8 {
|
||||
nonce[4 + i] ^= pn_be[i];
|
||||
}
|
||||
|
||||
// Decrypt payload and extract SNI from CRYPTO frames.
|
||||
let ciphertext = &data[ciphertext_start..];
|
||||
let plaintext = decrypt_aes128_gcm(&quic_key.try_into().ok()?, &nonce, &header_aad, ciphertext)?;
|
||||
parse_crypto_frames_for_sni(&plaintext)
|
||||
}
|
||||
|
||||
// ─── RFC QUIC CRYPTO frame parsing ───────────────────────────────────────────
|
||||
|
||||
/// Walk RFC QUIC frames and collect CRYPTO (0x06) data, then extract SNI.
|
||||
///
|
||||
/// Ported from Suricata rust/src/quic/frames.rs `parse_crypto_frame` using nom8.
|
||||
/// Frame dispatch mirrors Suricata's parse_quic_frames loop (frames.rs ~line 530).
|
||||
fn parse_crypto_frames_for_sni(payload: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut tls_data: Vec<u8> = Vec::new();
|
||||
let mut input = payload;
|
||||
|
||||
while !input.is_empty() {
|
||||
// Decode frame type varint (Suricata: quic_var_uint).
|
||||
let (rest, frame_type) = quic_var_uint(input).ok()?;
|
||||
match frame_type {
|
||||
0x00 => {
|
||||
// PADDING: consume all consecutive zero bytes (Suricata: parse_padding_frame).
|
||||
let mut i = 0;
|
||||
while i < rest.len() && rest[i] == 0 { i += 1; }
|
||||
input = &rest[i..];
|
||||
}
|
||||
0x01 => {
|
||||
// PING: no payload.
|
||||
input = rest;
|
||||
}
|
||||
0x02 | 0x03 => {
|
||||
// ACK / ACK with ECN: skip fields.
|
||||
// Suricata: parse_ack_frame / parse_ack3_frame
|
||||
let (r, _la) = quic_var_uint(rest).ok()?;
|
||||
let (r, _ad) = quic_var_uint(r).ok()?;
|
||||
let (r, arc) = quic_var_uint(r).ok()?;
|
||||
let (mut r, _far) = quic_var_uint(r).ok()?;
|
||||
for _ in 0..arc {
|
||||
let (r2, _gap) = quic_var_uint(r).ok()?;
|
||||
let (r2, _arl) = quic_var_uint(r2).ok()?;
|
||||
r = r2;
|
||||
}
|
||||
if frame_type == 0x03 {
|
||||
let (r2, _) = quic_var_uint(r).ok()?;
|
||||
let (r2, _) = quic_var_uint(r2).ok()?;
|
||||
let (r2, _) = quic_var_uint(r2).ok()?;
|
||||
r = r2;
|
||||
}
|
||||
input = r;
|
||||
}
|
||||
0x06 => {
|
||||
// CRYPTO frame: offset(varint) + length(varint) + data.
|
||||
// Suricata: parse_crypto_frame
|
||||
let (r, _offset) = quic_var_uint(rest).ok()?;
|
||||
let (r, length) = quic_var_uint(r).ok()?;
|
||||
let len = length as usize;
|
||||
if len > r.len() { break; }
|
||||
tls_data.extend_from_slice(&r[..len]);
|
||||
input = &r[len..];
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
if tls_data.is_empty() { return None; }
|
||||
extract_sni_from_tls_client_hello(&tls_data)
|
||||
}
|
||||
|
||||
/// Extract SNI from a raw TLS ClientHello handshake message.
|
||||
///
|
||||
/// Layout (no TLS record header — raw handshake payload from CRYPTO frames):
|
||||
/// handshake_type(1) + length(3) + version(2) + random(32) + ...
|
||||
fn extract_sni_from_tls_client_hello(data: &[u8]) -> Option<Vec<u8>> {
|
||||
if data.len() < 38 { return None; }
|
||||
if data[0] != 0x01 { return None; } // ClientHello
|
||||
let hs_len = u24_be(&data[1..]) as usize;
|
||||
if 4 + hs_len > data.len() { return None; }
|
||||
let ch = &data[4..4 + hs_len];
|
||||
|
||||
// version(2) + random(32) = 34 bytes.
|
||||
if ch.len() < 34 { return None; }
|
||||
let mut pos = 34usize;
|
||||
|
||||
// Session ID.
|
||||
let sid_len = *ch.get(pos)? as usize;
|
||||
pos = pos.checked_add(1 + sid_len)?;
|
||||
|
||||
// Cipher suites.
|
||||
if pos + 2 > ch.len() { return None; }
|
||||
let cs_len = u16_be(&ch[pos..]) as usize;
|
||||
pos = pos.checked_add(2 + cs_len)?;
|
||||
|
||||
// Compression methods.
|
||||
let cm_len = *ch.get(pos)? as usize;
|
||||
pos = pos.checked_add(1 + cm_len)?;
|
||||
|
||||
// Extensions.
|
||||
if pos + 2 > ch.len() { return None; }
|
||||
let ext_total = u16_be(&ch[pos..]) as usize;
|
||||
pos += 2;
|
||||
let ext_end = pos + ext_total;
|
||||
if ext_end > ch.len() { return None; }
|
||||
|
||||
while pos + 4 <= ext_end {
|
||||
let ext_type = u16_be(&ch[pos..]);
|
||||
let ext_len = u16_be(&ch[pos + 2..]) as usize;
|
||||
pos += 4;
|
||||
if pos + ext_len > ext_end { break; }
|
||||
let ext_data = &ch[pos..pos + ext_len];
|
||||
|
||||
if ext_type == 0x0000 && ext_len >= 5 {
|
||||
// SNI extension: list_len(2) + name_type(1) + name_len(2) + name.
|
||||
let name_type = ext_data[2];
|
||||
let name_len = u16_be(&ext_data[3..]) as usize;
|
||||
if name_type == 0 && 5 + name_len <= ext_len {
|
||||
return Some(ext_data[5..5 + name_len].to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
pos += ext_len;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Crypto primitives ────────────────────────────────────────────────────────
|
||||
|
||||
/// HMAC-SHA256 using the existing sha2 crate (no hmac crate needed).
|
||||
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
|
||||
let mut k = [0u8; 64];
|
||||
if key.len() <= 64 {
|
||||
k[..key.len()].copy_from_slice(key);
|
||||
} else {
|
||||
let h: [u8; 32] = Sha256::digest(key).into();
|
||||
k[..32].copy_from_slice(&h);
|
||||
}
|
||||
let mut ipad = k;
|
||||
let mut opad = k;
|
||||
for b in &mut ipad { *b ^= 0x36; }
|
||||
for b in &mut opad { *b ^= 0x5C; }
|
||||
let inner: [u8; 32] = {
|
||||
let mut h = Sha256::new();
|
||||
h.update(ipad);
|
||||
h.update(msg);
|
||||
h.finalize().into()
|
||||
};
|
||||
let outer: [u8; 32] = {
|
||||
let mut h = Sha256::new();
|
||||
h.update(opad);
|
||||
h.update(inner);
|
||||
h.finalize().into()
|
||||
};
|
||||
outer
|
||||
}
|
||||
|
||||
/// HKDF-Extract = HMAC-SHA256(salt, ikm).
|
||||
fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] {
|
||||
hmac_sha256(salt, ikm)
|
||||
}
|
||||
|
||||
/// HKDF-Expand-Label as specified in RFC 8446 §7.1 (used by QUIC per RFC 9001).
|
||||
fn hkdf_expand_label(prk: &[u8], label: &str, context: &[u8], length: usize) -> Vec<u8> {
|
||||
let full_label = format!("tls13 {}", label);
|
||||
let mut info = Vec::new();
|
||||
info.push((length >> 8) as u8);
|
||||
info.push(length as u8);
|
||||
info.push(full_label.len() as u8);
|
||||
info.extend_from_slice(full_label.as_bytes());
|
||||
info.push(context.len() as u8);
|
||||
info.extend_from_slice(context);
|
||||
hkdf_expand(prk, &info, length)
|
||||
}
|
||||
|
||||
/// HKDF-Expand (RFC 5869 §2.3).
|
||||
fn hkdf_expand(prk: &[u8], info: &[u8], length: usize) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(length);
|
||||
let mut t: Vec<u8> = Vec::new();
|
||||
let mut n = 1u8;
|
||||
while out.len() < length {
|
||||
let mut msg = t.clone();
|
||||
msg.extend_from_slice(info);
|
||||
msg.push(n);
|
||||
t = hmac_sha256(prk, &msg).to_vec();
|
||||
out.extend_from_slice(&t);
|
||||
n += 1;
|
||||
}
|
||||
out.truncate(length);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB single-block encrypt for QUIC header protection mask.
|
||||
fn aes128_ecb_block(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
aes128_encrypt(key, block)
|
||||
}
|
||||
|
||||
/// AES-128-GCM decryption using the aes-gcm crate.
|
||||
fn decrypt_aes128_gcm(key: &[u8; 16], nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Option<Vec<u8>> {
|
||||
let cipher = Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(key));
|
||||
cipher.decrypt(Nonce::from_slice(nonce), Payload { msg: ct, aad }).ok()
|
||||
}
|
||||
|
||||
// ─── AES-128 software implementation (FIPS 197) ──────────────────────────────
|
||||
// Used for header protection mask (ECB mode, single block).
|
||||
// Reference: Suricata rust/src/quic/aes128.rs (hand-rolled for no-dep builds).
|
||||
|
||||
const SBOX: [u8; 256] = [
|
||||
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
|
||||
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
|
||||
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
|
||||
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
|
||||
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
|
||||
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
|
||||
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
|
||||
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
|
||||
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
|
||||
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
|
||||
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
|
||||
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
|
||||
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
|
||||
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
|
||||
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
|
||||
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
|
||||
];
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn xtime(a: u8) -> u8 {
|
||||
if a & 0x80 != 0 { (a << 1) ^ 0x1b } else { a << 1 }
|
||||
}
|
||||
|
||||
fn gmul(mut a: u8, mut b: u8) -> u8 {
|
||||
let mut p = 0u8;
|
||||
for _ in 0..8 {
|
||||
if b & 1 != 0 { p ^= a; }
|
||||
let hi = a & 0x80 != 0;
|
||||
a <<= 1;
|
||||
if hi { a ^= 0x1b; }
|
||||
b >>= 1;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn sub_bytes(state: &mut [u8; 16]) {
|
||||
for b in state.iter_mut() { *b = SBOX[*b as usize]; }
|
||||
}
|
||||
|
||||
fn shift_rows(s: &mut [u8; 16]) {
|
||||
let t = s[1]; s[1] = s[5]; s[5] = s[9]; s[9] = s[13]; s[13] = t;
|
||||
s.swap(2, 10); s.swap(6, 14);
|
||||
let t = s[15]; s[15] = s[11]; s[11] = s[7]; s[7] = s[3]; s[3] = t;
|
||||
}
|
||||
|
||||
fn mix_columns(s: &mut [u8; 16]) {
|
||||
for c in 0..4 {
|
||||
let i = c * 4;
|
||||
let (s0, s1, s2, s3) = (s[i], s[i+1], s[i+2], s[i+3]);
|
||||
s[i] = gmul(0x02,s0)^gmul(0x03,s1)^s2^s3;
|
||||
s[i+1] = s0^gmul(0x02,s1)^gmul(0x03,s2)^s3;
|
||||
s[i+2] = s0^s1^gmul(0x02,s2)^gmul(0x03,s3);
|
||||
s[i+3] = gmul(0x03,s0)^s1^s2^gmul(0x02,s3);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_round_key(state: &mut [u8; 16], round_key: &[u8]) {
|
||||
for (s, k) in state.iter_mut().zip(round_key) { *s ^= k; }
|
||||
}
|
||||
|
||||
/// AES-128 key schedule + encryption of a single 16-byte block.
|
||||
fn aes128_encrypt(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
let mut w = [0u8; 176];
|
||||
w[..16].copy_from_slice(key);
|
||||
let rcon: [u8; 10] = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36];
|
||||
for i in 4..44usize {
|
||||
let mut temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]];
|
||||
if i % 4 == 0 {
|
||||
let t = temp[0];
|
||||
temp[0] = SBOX[temp[1] as usize] ^ rcon[i/4 - 1];
|
||||
temp[1] = SBOX[temp[2] as usize];
|
||||
temp[2] = SBOX[temp[3] as usize];
|
||||
temp[3] = SBOX[t as usize];
|
||||
}
|
||||
for j in 0..4 { w[i*4+j] = w[(i-4)*4+j] ^ temp[j]; }
|
||||
}
|
||||
|
||||
let mut state: [u8; 16] = *block;
|
||||
add_round_key(&mut state, &w[0..16]);
|
||||
for round in 1..=9 {
|
||||
sub_bytes(&mut state);
|
||||
shift_rows(&mut state);
|
||||
mix_columns(&mut state);
|
||||
add_round_key(&mut state, &w[round*16..(round+1)*16]);
|
||||
}
|
||||
sub_bytes(&mut state);
|
||||
shift_rows(&mut state);
|
||||
add_round_key(&mut state, &w[160..176]);
|
||||
state
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// QUIC variable-length integer (RFC 9000 §16) using nom8.
|
||||
///
|
||||
/// Ported verbatim from Suricata rust/src/quic/parser.rs `quic_var_uint`.
|
||||
pub(crate) fn quic_var_uint(input: &[u8]) -> IResult<&[u8], u64> {
|
||||
let (rest, first) = be_u8(input)?;
|
||||
let msb = first >> 6;
|
||||
let lsb = (first & 0x3F) as u64;
|
||||
match msb {
|
||||
3 => {
|
||||
let (rest, second) = be_u24(rest)?;
|
||||
let (rest, third) = be_u32(rest)?;
|
||||
Ok((rest, (lsb << 56) | ((second as u64) << 32) | (third as u64)))
|
||||
}
|
||||
2 => {
|
||||
let (rest, second) = be_u24(rest)?;
|
||||
Ok((rest, (lsb << 24) | (second as u64)))
|
||||
}
|
||||
1 => {
|
||||
let (rest, second) = be_u8(rest)?;
|
||||
Ok((rest, (lsb << 8) | (second as u64)))
|
||||
}
|
||||
_ => Ok((rest, lsb)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter: call quic_var_uint from position-based code and advance pos.
|
||||
fn read_varint(data: &[u8], pos: &mut usize) -> Option<u64> {
|
||||
let (rest, value) = quic_var_uint(&data[*pos..]).ok()?;
|
||||
*pos = data.len() - rest.len();
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// True for RFC QUIC, draft, and gQUIC version values.
|
||||
fn is_valid_quic_version(v: u32) -> bool {
|
||||
is_gquic_version(v) || matches!(v,
|
||||
0x0000_0001 // QUIC v1 (RFC 9000)
|
||||
| 0x6b33_43cf // QUIC v2 (RFC 9369)
|
||||
| 0x0000_0000 // Version Negotiation
|
||||
| 0xff00_001d // draft-29
|
||||
| 0xff00_0020 // draft-32
|
||||
| 0xff00_0021 // draft-33
|
||||
| 0xff00_0022 // draft-34
|
||||
)
|
||||
}
|
||||
|
||||
/// True for Google QUIC versions Q043-Q046.
|
||||
fn is_gquic_version(v: u32) -> bool {
|
||||
matches!(v,
|
||||
GQUIC_VERSION_Q043 | GQUIC_VERSION_Q044 | GQUIC_VERSION_Q045 | GQUIC_VERSION_Q046
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn u16_be(b: &[u8]) -> u16 { u16::from_be_bytes([b[0], b[1]]) }
|
||||
|
||||
#[inline]
|
||||
fn u24_be(b: &[u8]) -> u32 { (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32 }
|
||||
@ -1,86 +0,0 @@
|
||||
/// SMTP protocol parser.
|
||||
///
|
||||
/// Extracts EHLO/HELO hostname, MAIL FROM address, RCPT TO address, and
|
||||
/// the first reply code from a single TCP payload chunk.
|
||||
///
|
||||
/// Commands are matched case-insensitively per RFC 5321 §2.4.
|
||||
|
||||
pub struct SmtpInfo {
|
||||
/// Argument from EHLO or HELO command.
|
||||
pub helo: Vec<u8>,
|
||||
/// Sender address from MAIL FROM (angle brackets stripped).
|
||||
pub from: Vec<u8>,
|
||||
/// Recipient address from RCPT TO (angle brackets stripped).
|
||||
pub rcpt: Vec<u8>,
|
||||
/// First 3-digit reply code seen in this chunk.
|
||||
pub reply: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn parse_smtp(data: &[u8]) -> Option<SmtpInfo> {
|
||||
let mut info = SmtpInfo {
|
||||
helo: Vec::new(),
|
||||
from: Vec::new(),
|
||||
rcpt: Vec::new(),
|
||||
reply: Vec::new(),
|
||||
};
|
||||
let mut found = false;
|
||||
|
||||
for raw_line in data.split(|&b| b == b'\n') {
|
||||
let line = strip_cr(raw_line);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if info.helo.is_empty() && ci_starts_with(line, b"EHLO ") {
|
||||
info.helo = line[5..].to_vec();
|
||||
found = true;
|
||||
} else if info.helo.is_empty() && ci_starts_with(line, b"HELO ") {
|
||||
info.helo = line[5..].to_vec();
|
||||
found = true;
|
||||
} else if info.from.is_empty() && ci_starts_with(line, b"MAIL FROM:") {
|
||||
info.from = angle_addr(&line[10..]).to_vec();
|
||||
found = true;
|
||||
} else if info.rcpt.is_empty() && ci_starts_with(line, b"RCPT TO:") {
|
||||
info.rcpt = angle_addr(&line[8..]).to_vec();
|
||||
found = true;
|
||||
} else if info.reply.is_empty()
|
||||
&& line.len() >= 3
|
||||
&& line[..3].iter().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
info.reply = line[..3].to_vec();
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found { Some(info) } else { None }
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn strip_cr(line: &[u8]) -> &[u8] {
|
||||
line.strip_suffix(b"\r").unwrap_or(line)
|
||||
}
|
||||
|
||||
fn ci_starts_with(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.len() >= needle.len()
|
||||
&& haystack[..needle.len()]
|
||||
.iter()
|
||||
.zip(needle)
|
||||
.all(|(a, b)| a.to_ascii_uppercase() == b.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Strip surrounding `<` `>` and leading whitespace from an address field.
|
||||
fn angle_addr(s: &[u8]) -> &[u8] {
|
||||
let s = ltrim(s);
|
||||
if s.first() == Some(&b'<') {
|
||||
let end = s.iter().position(|&b| b == b'>').unwrap_or(s.len());
|
||||
&s[1..end]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn ltrim(s: &[u8]) -> &[u8] {
|
||||
let start = s.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(s.len());
|
||||
&s[start..]
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
/* Ported from Suricata rust/src/ssh/parser.rs
|
||||
* Copyright (C) 2020 Open Information Security Foundation
|
||||
* Licensed under GPLv2.
|
||||
*
|
||||
* Rewritten to use nom8 (nom = "8.0" aliased as nom8).
|
||||
*
|
||||
* HASSH (FoxIO) fingerprints:
|
||||
* client: MD5(kex_algs ; enc_c2s ; mac_c2s ; comp_c2s)
|
||||
* server: MD5(kex_algs ; enc_s2c ; mac_s2c ; comp_s2c)
|
||||
*
|
||||
* Reference: https://github.com/salesforce/hassh
|
||||
*/
|
||||
|
||||
use nom8::branch::alt;
|
||||
use nom8::bytes::streaming::{is_not, tag, take};
|
||||
use nom8::combinator::{complete, rest};
|
||||
use nom8::error::{Error as NomError};
|
||||
use nom8::multi::length_data;
|
||||
use nom8::number::streaming::{be_u32, be_u8};
|
||||
use nom8::combinator::verify;
|
||||
use nom8::Parser;
|
||||
|
||||
const SSH_MSG_KEXINIT: u8 = 20;
|
||||
|
||||
pub struct SshInfo {
|
||||
/// Protocol version string, e.g. "2.0".
|
||||
pub proto: Vec<u8>,
|
||||
/// Software version string, e.g. "OpenSSH_8.9".
|
||||
pub software: Vec<u8>,
|
||||
/// HASSH MD5 hex fingerprint (32 chars), empty for banner packets.
|
||||
pub hassh: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Try to parse `data` as an SSH banner or Kexinit packet.
|
||||
pub fn parse_ssh(data: &[u8]) -> Option<SshInfo> {
|
||||
if data.starts_with(b"SSH-") {
|
||||
parse_banner(data)
|
||||
} else {
|
||||
parse_kexinit(data)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Banner (Suricata ssh_parse_banner) ──────────────────────────────────────
|
||||
|
||||
/// Parse SSH version banner: SSH-<protover>-<swver>[ <comment>][CR][LF]
|
||||
///
|
||||
/// Ports Suricata's ssh_parse_banner using nom8:
|
||||
/// tag("SSH-") + is_not("-") + char('-') + alt((complete(is_not(" \r\n")), rest))
|
||||
fn parse_banner(data: &[u8]) -> Option<SshInfo> {
|
||||
let (i, _) = tag::<_, _, NomError<_>>("SSH-")(data).ok()?;
|
||||
let (i, protover) = is_not::<_, _, NomError<_>>("-")(i).ok()?;
|
||||
// consume the '-' separator between protover and swver
|
||||
let (i, _) = tag::<_, _, NomError<_>>("-")(i).ok()?;
|
||||
// swver: up to first space/CR/LF, or the remainder of data
|
||||
let (_i, swver) = alt((
|
||||
complete(is_not::<_, _, NomError<_>>(" \r\n")),
|
||||
rest::<_, NomError<_>>,
|
||||
))
|
||||
.parse(i)
|
||||
.ok()?;
|
||||
|
||||
Some(SshInfo {
|
||||
proto: protover.to_vec(),
|
||||
software: swver.to_vec(),
|
||||
hassh: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Kexinit (Suricata ssh_parse_record_header + ssh_parse_key_exchange + generate_hassh) ──
|
||||
|
||||
/// Parse SSH_MSG_KEXINIT binary packet and compute client HASSH.
|
||||
///
|
||||
/// Ports Suricata's ssh_parse_record_header (verify(be_u32, val>1) + be_u8 + be_u8)
|
||||
/// and ssh_parse_key_exchange (take(16) cookie + 10x length_data(be_u32) name-lists).
|
||||
/// generate_hassh(to_server=false): MD5(kex ; enc_c2s ; mac_c2s ; comp_c2s)
|
||||
fn parse_kexinit(data: &[u8]) -> Option<SshInfo> {
|
||||
// ssh_parse_record_header: verify pkt_len > 1
|
||||
let (i, pkt_len) = verify(be_u32::<_, NomError<_>>, |&val| val > 1)
|
||||
.parse(data)
|
||||
.ok()?;
|
||||
let (i, padding_len) = be_u8::<_, NomError<_>>(i).ok()?;
|
||||
let (i, msg_code) = be_u8::<_, NomError<_>>(i).ok()?;
|
||||
|
||||
if msg_code != SSH_MSG_KEXINIT {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Payload length = pkt_len - 1 (padding_len field) - padding_len - 1 (msg_code)
|
||||
let padding_len = padding_len as usize;
|
||||
let pkt_len = pkt_len as usize;
|
||||
if padding_len + 2 > pkt_len {
|
||||
return None;
|
||||
}
|
||||
let payload_len = pkt_len - 2 - padding_len;
|
||||
if payload_len == 0 || payload_len > i.len() {
|
||||
return None;
|
||||
}
|
||||
let (_, payload) = take::<_, _, NomError<_>>(payload_len)(i).ok()?;
|
||||
|
||||
// ssh_parse_key_exchange: cookie(16) + 10 name-lists
|
||||
let (payload, _cookie) = take::<_, _, NomError<_>>(16usize)(payload).ok()?;
|
||||
|
||||
// Each name-list is a be_u32-length-prefixed byte string.
|
||||
// Parse all 10 in SSH order; only kex_algs, enc_c2s, mac_c2s, comp_c2s are kept.
|
||||
let (p, kex_algs) = length_data(be_u32::<_, NomError<_>>).parse(payload).ok()?; // 0
|
||||
let (p, _) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 1 server_host_key_algs
|
||||
let (p, enc_c2s) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 2 enc_c2s
|
||||
let (p, _) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 3 enc_s2c
|
||||
let (p, mac_c2s) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 4 mac_c2s
|
||||
let (p, _) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 5 mac_s2c
|
||||
let (_, comp_c2s) = length_data(be_u32::<_, NomError<_>>).parse(p).ok()?; // 6 comp_c2s
|
||||
|
||||
// generate_hassh(to_server=false): MD5(kex ; enc_c2s ; mac_c2s ; comp_c2s)
|
||||
let mut hassh_string: Vec<u8> = Vec::new();
|
||||
hassh_string.extend_from_slice(kex_algs);
|
||||
hassh_string.push(b';');
|
||||
hassh_string.extend_from_slice(enc_c2s);
|
||||
hassh_string.push(b';');
|
||||
hassh_string.extend_from_slice(mac_c2s);
|
||||
hassh_string.push(b';');
|
||||
hassh_string.extend_from_slice(comp_c2s);
|
||||
|
||||
let digest = md5::compute(&hassh_string);
|
||||
let hassh = format!("{:x}", digest).into_bytes();
|
||||
|
||||
Some(SshInfo { proto: Vec::new(), software: Vec::new(), hassh })
|
||||
}
|
||||
@ -1,613 +0,0 @@
|
||||
/* Ported from Suricata rust/src/tls/
|
||||
* Copyright (C) 2017-2022 Open Information Security Foundation
|
||||
* Licensed under GPLv2.
|
||||
*
|
||||
* Algorithm ported verbatim; nom8 replaced with hand-written byte-slice parsing.
|
||||
*
|
||||
* JA3 (Salesforce): MD5(version,ciphers,extensions,curves,point_fmts)
|
||||
* Reference: https://github.com/salesforce/ja3
|
||||
*
|
||||
* JA3S (Salesforce): MD5(version,cipher,extensions)
|
||||
* Reference: https://github.com/salesforce/ja3#ja3s
|
||||
*
|
||||
* JA4 (FoxIO): t{ver}{sni}{cc:02}{ec:02}{alpn}_{cipher_sha256[:12]}_{ext_sha256[:12]}
|
||||
* Reference: https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
|
||||
*
|
||||
* Certificate parsing via x509-parser; SHA-1 fingerprint over raw DER bytes.
|
||||
* Suricata reference: rust/src/x509/mod.rs
|
||||
*/
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
use sha2::Sha256;
|
||||
use x509_parser::prelude::*;
|
||||
|
||||
const RT_CHANGE_CIPHER: u8 = 0x14;
|
||||
const RT_ALERT: u8 = 0x15;
|
||||
const RT_HANDSHAKE: u8 = 0x16;
|
||||
const RT_APP_DATA: u8 = 0x17;
|
||||
|
||||
const HS_CLIENT_HELLO: u8 = 0x01;
|
||||
const HS_SERVER_HELLO: u8 = 0x02;
|
||||
const HS_CERTIFICATE: u8 = 0x0B;
|
||||
|
||||
const EXT_SNI: u16 = 0x0000;
|
||||
const EXT_SIG_ALGS: u16 = 0x000d;
|
||||
const EXT_SUPPORTED_GROUPS: u16 = 0x000a;
|
||||
const EXT_EC_POINT_FORMATS: u16 = 0x000b;
|
||||
const EXT_ALPN: u16 = 0x0010;
|
||||
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TlsInfo {
|
||||
pub is_app_data: bool,
|
||||
pub sni: Vec<u8>,
|
||||
/// Human-readable version: "TLS 1.3", "TLS 1.2", etc.
|
||||
pub version: Vec<u8>,
|
||||
/// JA3 MD5 hex fingerprint (32 chars), from ClientHello.
|
||||
pub ja3: Vec<u8>,
|
||||
/// JA3S MD5 hex fingerprint (32 chars), from ServerHello.
|
||||
pub ja3s: Vec<u8>,
|
||||
/// JA4 fingerprint string, from ClientHello.
|
||||
pub ja4: Vec<u8>,
|
||||
/// First ALPN protocol, from ClientHello or ServerHello.
|
||||
pub alpn: Vec<u8>,
|
||||
/// Certificate subject distinguished name.
|
||||
pub cert_subject: Vec<u8>,
|
||||
/// Certificate issuer distinguished name.
|
||||
pub cert_issuer: Vec<u8>,
|
||||
/// Certificate SHA-1 fingerprint, lowercase hex (40 chars).
|
||||
pub cert_fingerprint: Vec<u8>,
|
||||
/// Certificate notBefore in ISO 8601 format: "YYYYMMDDTHHMMSSZ".
|
||||
pub cert_not_before: Vec<u8>,
|
||||
/// Certificate notAfter in ISO 8601 format: "YYYYMMDDTHHMMSSZ".
|
||||
pub cert_not_after: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parse a TLS record layer buffer.
|
||||
///
|
||||
/// Scans all TLS records in `data`. A single TCP segment may contain multiple
|
||||
/// TLS records (e.g. ServerHello + Certificate in one segment). ClientHello
|
||||
/// populates ja3/ja4/sni/version; ServerHello populates ja3s/alpn/version;
|
||||
/// Certificate populates cert_* fields.
|
||||
pub fn detect_tls(data: &[u8]) -> Option<TlsInfo> {
|
||||
if data.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Quick sanity check: first byte must be a known TLS record type and
|
||||
// version field must be 3.x (SSLv3 / TLS).
|
||||
let record_type = data[0];
|
||||
if !matches!(record_type, RT_CHANGE_CIPHER | RT_ALERT | RT_HANDSHAKE | RT_APP_DATA) {
|
||||
return None;
|
||||
}
|
||||
if data[1] != 3 || data[2] > 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if record_type == RT_APP_DATA {
|
||||
return Some(TlsInfo { is_app_data: true, ..Default::default() });
|
||||
}
|
||||
|
||||
let mut info = TlsInfo::default();
|
||||
|
||||
// Walk all TLS records in the segment.
|
||||
let mut pos = 0usize;
|
||||
while pos + 5 <= data.len() {
|
||||
let rtype = data[pos];
|
||||
let rlen = u16_be(&data[pos + 3..]) as usize;
|
||||
pos += 5;
|
||||
if pos + rlen > data.len() {
|
||||
break;
|
||||
}
|
||||
let record = &data[pos..pos + rlen];
|
||||
pos += rlen;
|
||||
|
||||
if rtype != RT_HANDSHAKE {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Walk all handshake messages within the record.
|
||||
let mut hp = 0usize;
|
||||
while hp + 4 <= record.len() {
|
||||
let hs_type = record[hp];
|
||||
let hs_len = u24_be(&record[hp + 1..]) as usize;
|
||||
hp += 4;
|
||||
if hp + hs_len > record.len() {
|
||||
break;
|
||||
}
|
||||
let hs_body = &record[hp..hp + hs_len];
|
||||
hp += hs_len;
|
||||
|
||||
match hs_type {
|
||||
HS_CLIENT_HELLO => parse_client_hello_into(&mut info, hs_body),
|
||||
HS_SERVER_HELLO => parse_server_hello_into(&mut info, hs_body),
|
||||
HS_CERTIFICATE => parse_certificate_into(&mut info, hs_body),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(info)
|
||||
}
|
||||
|
||||
// ─── ClientHello ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_client_hello_into(info: &mut TlsInfo, ch: &[u8]) {
|
||||
// version(2) + random(32) + ...
|
||||
if ch.len() < 34 {
|
||||
return;
|
||||
}
|
||||
|
||||
let client_version = u16_be(ch);
|
||||
let mut pos = 34usize;
|
||||
|
||||
// Session ID
|
||||
let sid_len = match ch.get(pos) { Some(&v) => v as usize, None => return };
|
||||
pos = match pos.checked_add(1 + sid_len) { Some(v) => v, None => return };
|
||||
|
||||
// Cipher suites
|
||||
if pos + 2 > ch.len() { return; }
|
||||
let cs_len = u16_be(&ch[pos..]) as usize;
|
||||
pos += 2;
|
||||
if pos + cs_len > ch.len() { return; }
|
||||
let mut ciphers: Vec<u16> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < cs_len {
|
||||
let c = u16_be(&ch[pos + i..]);
|
||||
if !is_grease(c) { ciphers.push(c); }
|
||||
i += 2;
|
||||
}
|
||||
pos += cs_len;
|
||||
|
||||
// Compression methods
|
||||
let cm_len = match ch.get(pos) { Some(&v) => v as usize, None => return };
|
||||
pos = match pos.checked_add(1 + cm_len) { Some(v) => v, None => return };
|
||||
|
||||
// Extensions
|
||||
let mut sni = Vec::new();
|
||||
let mut ext_types = Vec::new();
|
||||
let mut curves = Vec::new();
|
||||
let mut point_fmts = Vec::new();
|
||||
let mut supported_ver: Option<u16> = None;
|
||||
let mut alpn = Vec::new();
|
||||
let mut sig_algs = Vec::new();
|
||||
|
||||
if pos + 2 <= ch.len() {
|
||||
let ext_total = u16_be(&ch[pos..]) as usize;
|
||||
pos += 2;
|
||||
let ext_end = pos + ext_total;
|
||||
if ext_end <= ch.len() {
|
||||
while pos + 4 <= ext_end {
|
||||
let ext_type = u16_be(&ch[pos..]);
|
||||
let ext_len = u16_be(&ch[pos + 2..]) as usize;
|
||||
pos += 4;
|
||||
if pos + ext_len > ext_end { break; }
|
||||
let ext_data = &ch[pos..pos + ext_len];
|
||||
|
||||
if !is_grease(ext_type) {
|
||||
ext_types.push(ext_type);
|
||||
}
|
||||
|
||||
match ext_type {
|
||||
EXT_SNI => {
|
||||
if ext_len >= 5 {
|
||||
let name_type = ext_data[2];
|
||||
let name_len = u16_be(&ext_data[3..]) as usize;
|
||||
if name_type == 0 && 5 + name_len <= ext_len {
|
||||
sni = ext_data[5..5 + name_len].to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
EXT_SUPPORTED_GROUPS => {
|
||||
if ext_len >= 2 {
|
||||
let list_len = u16_be(ext_data) as usize;
|
||||
let mut ci = 2;
|
||||
while ci + 1 < list_len.saturating_add(2).min(ext_len) {
|
||||
let v = u16_be(&ext_data[ci..]);
|
||||
if !is_grease(v) { curves.push(v); }
|
||||
ci += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXT_EC_POINT_FORMATS => {
|
||||
if ext_len >= 1 {
|
||||
let list_len = ext_data[0] as usize;
|
||||
for j in 0..list_len.min(ext_len.saturating_sub(1)) {
|
||||
point_fmts.push(ext_data[1 + j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
EXT_SUPPORTED_VERSIONS => {
|
||||
// ClientHello: list_len(1) + 2-byte versions
|
||||
if ext_len >= 1 {
|
||||
let list_len = ext_data[0] as usize;
|
||||
let mut vi = 1;
|
||||
while vi + 1 <= list_len.saturating_add(1).min(ext_len) {
|
||||
let v = u16_be(&ext_data[vi..]);
|
||||
if !is_grease(v) && supported_ver.is_none() {
|
||||
supported_ver = Some(v);
|
||||
}
|
||||
vi += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXT_ALPN => {
|
||||
// protocol_list_len(2) + [ proto_len(1) + proto_bytes... ]
|
||||
if ext_len >= 4 {
|
||||
let list_len = u16_be(ext_data) as usize;
|
||||
if list_len >= 1 && 2 + list_len <= ext_len {
|
||||
let proto_len = ext_data[2] as usize;
|
||||
if proto_len > 0 && 3 + proto_len <= ext_len {
|
||||
alpn = ext_data[3..3 + proto_len].to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXT_SIG_ALGS => {
|
||||
// list_len(2) + 2-byte scheme codes
|
||||
if ext_len >= 2 {
|
||||
let list_len = u16_be(ext_data) as usize;
|
||||
let mut si = 2;
|
||||
while si + 1 < list_len.saturating_add(2).min(ext_len) {
|
||||
let v = u16_be(&ext_data[si..]);
|
||||
sig_algs.push(v);
|
||||
si += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
pos += ext_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JA3 uses client_version (legacy field), per the JA3 specification.
|
||||
info.ja3 = ja3_compute(client_version, &ciphers, &ext_types, &curves, &point_fmts);
|
||||
|
||||
// JA4
|
||||
let display_v = supported_ver.unwrap_or(client_version);
|
||||
info.ja4 = ja4_compute(
|
||||
display_v,
|
||||
&sni,
|
||||
&ciphers,
|
||||
&ext_types,
|
||||
&alpn,
|
||||
&sig_algs,
|
||||
);
|
||||
|
||||
// Version string from supported_versions extension (TLS 1.3) or client_version.
|
||||
if info.version.is_empty() {
|
||||
info.version = tls_version_str(display_v).as_bytes().to_vec();
|
||||
}
|
||||
|
||||
if info.sni.is_empty() {
|
||||
info.sni = sni;
|
||||
}
|
||||
if info.alpn.is_empty() {
|
||||
info.alpn = alpn;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ServerHello ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_server_hello_into(info: &mut TlsInfo, sh: &[u8]) {
|
||||
// version(2) + random(32) + ...
|
||||
if sh.len() < 34 {
|
||||
return;
|
||||
}
|
||||
|
||||
let server_version = u16_be(sh);
|
||||
let mut pos = 34usize;
|
||||
|
||||
// Session ID
|
||||
let sid_len = match sh.get(pos) { Some(&v) => v as usize, None => return };
|
||||
pos = match pos.checked_add(1 + sid_len) { Some(v) => v, None => return };
|
||||
|
||||
// Selected cipher suite (2 bytes)
|
||||
if pos + 2 > sh.len() { return; }
|
||||
let cipher = u16_be(&sh[pos..]);
|
||||
pos += 2;
|
||||
|
||||
// Compression method (1 byte)
|
||||
pos += 1;
|
||||
|
||||
// Extensions
|
||||
let mut ext_types = Vec::new();
|
||||
let mut supported_ver: Option<u16> = None;
|
||||
let mut alpn = Vec::new();
|
||||
|
||||
if pos + 2 <= sh.len() {
|
||||
let ext_total = u16_be(&sh[pos..]) as usize;
|
||||
pos += 2;
|
||||
let ext_end = pos + ext_total;
|
||||
if ext_end <= sh.len() {
|
||||
while pos + 4 <= ext_end {
|
||||
let ext_type = u16_be(&sh[pos..]);
|
||||
let ext_len = u16_be(&sh[pos + 2..]) as usize;
|
||||
pos += 4;
|
||||
if pos + ext_len > ext_end { break; }
|
||||
let ext_data = &sh[pos..pos + ext_len];
|
||||
|
||||
if !is_grease(ext_type) {
|
||||
ext_types.push(ext_type);
|
||||
}
|
||||
|
||||
match ext_type {
|
||||
EXT_SUPPORTED_VERSIONS => {
|
||||
// ServerHello: single 2-byte version (no length prefix)
|
||||
if ext_len >= 2 {
|
||||
let v = u16_be(ext_data);
|
||||
if !is_grease(v) {
|
||||
supported_ver = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
EXT_ALPN => {
|
||||
// Same layout as ClientHello ALPN
|
||||
if ext_len >= 4 {
|
||||
let list_len = u16_be(ext_data) as usize;
|
||||
if list_len >= 1 && 2 + list_len <= ext_len {
|
||||
let proto_len = ext_data[2] as usize;
|
||||
if proto_len > 0 && 3 + proto_len <= ext_len {
|
||||
alpn = ext_data[3..3 + proto_len].to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
pos += ext_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JA3S: MD5(version,cipher,extensions)
|
||||
let display_v = supported_ver.unwrap_or(server_version);
|
||||
info.ja3s = ja3s_compute(display_v, cipher, &ext_types);
|
||||
|
||||
if info.version.is_empty() {
|
||||
info.version = tls_version_str(display_v).as_bytes().to_vec();
|
||||
}
|
||||
if info.alpn.is_empty() {
|
||||
info.alpn = alpn;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Certificate ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse TLS Certificate handshake body.
|
||||
///
|
||||
/// Layout: cert_list_len(3) + [ cert_len(3) + DER_bytes ... ]
|
||||
/// Only the first (leaf) certificate is examined.
|
||||
/// Suricata reference: TlsCertificateNew in rust/src/tls/tls.rs, which calls
|
||||
/// x509_parser::parse_x509_certificate on the first cert DER.
|
||||
fn parse_certificate_into(info: &mut TlsInfo, body: &[u8]) {
|
||||
if body.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let list_len = u24_be(body) as usize;
|
||||
if list_len + 3 > body.len() || list_len < 3 {
|
||||
return;
|
||||
}
|
||||
let first_cert_len = u24_be(&body[3..]) as usize;
|
||||
if 6 + first_cert_len > body.len() {
|
||||
return;
|
||||
}
|
||||
let der = &body[6..6 + first_cert_len];
|
||||
|
||||
// SHA-1 fingerprint of raw DER bytes (Suricata: sha1::compute).
|
||||
let sha1_hash = Sha1::digest(der);
|
||||
info.cert_fingerprint = sha1_hash
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>()
|
||||
.into_bytes();
|
||||
|
||||
// Parse DER with x509-parser (Suricata: parse_x509_certificate).
|
||||
if let Ok((_, cert)) = X509Certificate::from_der(der) {
|
||||
info.cert_subject = cert.subject().to_string().into_bytes();
|
||||
info.cert_issuer = cert.issuer().to_string().into_bytes();
|
||||
|
||||
let validity = cert.validity();
|
||||
info.cert_not_before = format_asn1_time(validity.not_before.timestamp());
|
||||
info.cert_not_after = format_asn1_time(validity.not_after.timestamp());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JA3 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn ja3_compute(
|
||||
version: u16,
|
||||
ciphers: &[u16],
|
||||
ext_types: &[u16],
|
||||
curves: &[u16],
|
||||
point_fmts: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let join_u16 = |v: &[u16]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join("-");
|
||||
let join_u8 = |v: &[u8]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>().join("-");
|
||||
let s = format!(
|
||||
"{},{},{},{},{}",
|
||||
version,
|
||||
join_u16(ciphers),
|
||||
join_u16(ext_types),
|
||||
join_u16(curves),
|
||||
join_u8(point_fmts),
|
||||
);
|
||||
let digest = md5::compute(s.as_bytes());
|
||||
format!("{:x}", digest).into_bytes()
|
||||
}
|
||||
|
||||
// ─── JA3S ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// JA3S: MD5(version,cipher,extensions).
|
||||
/// Extensions are dash-joined decimals; no GREASE (already filtered).
|
||||
fn ja3s_compute(version: u16, cipher: u16, ext_types: &[u16]) -> Vec<u8> {
|
||||
let exts_str = ext_types.iter().map(|x| x.to_string()).collect::<Vec<_>>().join("-");
|
||||
let s = format!("{},{},{}", version, cipher, exts_str);
|
||||
let digest = md5::compute(s.as_bytes());
|
||||
format!("{:x}", digest).into_bytes()
|
||||
}
|
||||
|
||||
// ─── JA4 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// JA4 fingerprint.
|
||||
///
|
||||
/// Format: t{ver}{sni_type}{cc:02}{ec:02}{alpn}_{cipher_hash}_{ext_hash}
|
||||
///
|
||||
/// ver: 13=TLS1.3 12=TLS1.2 11=TLS1.1 10=TLS1.0 s3=SSL3.0 00=unknown
|
||||
/// sni_type: 'd' if domain SNI present, 'i' if IP literal, 'n' if no SNI
|
||||
/// cc: cipher count (excluding GREASE + 0x00FF, capped at 99)
|
||||
/// ec: extension count (excluding GREASE, capped at 99)
|
||||
/// alpn: first and last byte of first ALPN (or "00")
|
||||
/// cipher_hash: first 12 chars of SHA-256 hex of sorted cipher decimals
|
||||
/// ext_hash: first 12 chars of SHA-256 hex of sorted ext types
|
||||
/// (excl. GREASE, SNI=0x0000, ALPN=0x0010)
|
||||
/// + "_" + sig_algs as dash-joined decimals (in order, not sorted)
|
||||
fn ja4_compute(
|
||||
version: u16,
|
||||
sni: &[u8],
|
||||
ciphers: &[u16],
|
||||
ext_types: &[u16],
|
||||
alpn: &[u8],
|
||||
sig_algs: &[u16],
|
||||
) -> Vec<u8> {
|
||||
// Protocol prefix: always 't' (TCP) for standard TLS captured by NetGuardia.
|
||||
let proto = 't';
|
||||
|
||||
// Version code
|
||||
let ver_code = match version {
|
||||
0x0304 => "13",
|
||||
0x0303 => "12",
|
||||
0x0302 => "11",
|
||||
0x0301 => "10",
|
||||
0x0300 => "s3",
|
||||
_ => "00",
|
||||
};
|
||||
|
||||
// SNI type
|
||||
let sni_type = if sni.is_empty() {
|
||||
'n'
|
||||
} else if sni.iter().all(|&b| b.is_ascii_digit() || b == b'.' || b == b':') {
|
||||
'i'
|
||||
} else {
|
||||
'd'
|
||||
};
|
||||
|
||||
// Cipher count: exclude GREASE and 0x00FF (EMPTY_RENEGOTIATION_INFO_SCSV).
|
||||
let filtered_ciphers: Vec<u16> = ciphers.iter()
|
||||
.copied()
|
||||
.filter(|&c| !is_grease(c) && c != 0x00FF)
|
||||
.collect();
|
||||
let cc = filtered_ciphers.len().min(99);
|
||||
|
||||
// Extension count: exclude GREASE.
|
||||
let filtered_exts: Vec<u16> = ext_types.iter()
|
||||
.copied()
|
||||
.filter(|&e| !is_grease(e))
|
||||
.collect();
|
||||
let ec = filtered_exts.len().min(99);
|
||||
|
||||
// ALPN: first and last byte of the first protocol string.
|
||||
let alpn_tag = if alpn.len() >= 2 {
|
||||
format!("{}{}", alpn[0] as char, alpn[alpn.len() - 1] as char)
|
||||
} else if alpn.len() == 1 {
|
||||
format!("{}{}", alpn[0] as char, alpn[0] as char)
|
||||
} else {
|
||||
"00".to_string()
|
||||
};
|
||||
|
||||
// Cipher hash: sorted decimal, comma-separated.
|
||||
let mut sorted_ciphers = filtered_ciphers.clone();
|
||||
sorted_ciphers.sort_unstable();
|
||||
let cipher_str = sorted_ciphers.iter().map(|c| c.to_string()).collect::<Vec<_>>().join(",");
|
||||
let cipher_hash_full = hex_sha256(cipher_str.as_bytes());
|
||||
let cipher_hash = &cipher_hash_full[..12.min(cipher_hash_full.len())];
|
||||
|
||||
// Extension hash: sorted extensions (excl. SNI=0, ALPN=0x0010), then sig_algs.
|
||||
let mut sorted_exts: Vec<u16> = filtered_exts.iter()
|
||||
.copied()
|
||||
.filter(|&e| e != EXT_SNI && e != EXT_ALPN)
|
||||
.collect();
|
||||
sorted_exts.sort_unstable();
|
||||
let ext_str = sorted_exts.iter().map(|e| e.to_string()).collect::<Vec<_>>().join(",");
|
||||
let sig_str = sig_algs.iter().map(|s| s.to_string()).collect::<Vec<_>>().join("-");
|
||||
let ext_input = if sig_str.is_empty() {
|
||||
ext_str
|
||||
} else {
|
||||
format!("{}_{}", ext_str, sig_str)
|
||||
};
|
||||
let ext_hash_full = hex_sha256(ext_input.as_bytes());
|
||||
let ext_hash = &ext_hash_full[..12.min(ext_hash_full.len())];
|
||||
|
||||
format!(
|
||||
"{}{}{}{:02}{:02}{}_{}_{}",
|
||||
proto, ver_code, sni_type, cc, ec, alpn_tag, cipher_hash, ext_hash
|
||||
).into_bytes()
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GREASE values per RFC 8701: both bytes equal, lower nibble == 0xA.
|
||||
fn is_grease(v: u16) -> bool {
|
||||
let lo = v as u8;
|
||||
let hi = (v >> 8) as u8;
|
||||
hi == lo && lo & 0x0F == 0x0A
|
||||
}
|
||||
|
||||
fn hex_sha256(data: &[u8]) -> String {
|
||||
let hash = Sha256::digest(data);
|
||||
hash.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp as "YYYYMMDDTHHMMSSZ".
|
||||
fn format_asn1_time(ts: i64) -> Vec<u8> {
|
||||
// Manual UTC decomposition — avoids a chrono dependency in this file.
|
||||
// Uses the proleptic Gregorian calendar algorithm.
|
||||
let secs = if ts >= 0 { ts as u64 } else { return b"19700101T000000Z".to_vec() };
|
||||
let s = secs % 60;
|
||||
let m = (secs / 60) % 60;
|
||||
let h = (secs / 3600) % 24;
|
||||
let days = secs / 86400;
|
||||
let (y, mo, d) = days_to_ymd(days);
|
||||
format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", y, mo, d, h, m, s).into_bytes()
|
||||
}
|
||||
|
||||
/// Convert days since Unix epoch (1970-01-01) to (year, month, day).
|
||||
fn days_to_ymd(z: u64) -> (u64, u64, u64) {
|
||||
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
||||
let z = z + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
(y, mo, d)
|
||||
}
|
||||
|
||||
fn tls_version_str(v: u16) -> &'static str {
|
||||
match v {
|
||||
0x0304 => "TLS 1.3",
|
||||
0x0303 => "TLS 1.2",
|
||||
0x0302 => "TLS 1.1",
|
||||
0x0301 => "TLS 1.0",
|
||||
0x0300 => "SSL 3.0",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn u16_be(b: &[u8]) -> u16 {
|
||||
u16::from_be_bytes([b[0], b[1]])
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn u24_be(b: &[u8]) -> u32 {
|
||||
(b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
use std::ptr;
|
||||
|
||||
use pcre2_sys::{
|
||||
PCRE2_DOTALL, PCRE2_ERROR_NOMATCH, PCRE2_MULTILINE,
|
||||
pcre2_code_8, pcre2_code_free_8, pcre2_compile_8,
|
||||
pcre2_match_8, pcre2_match_context_8, pcre2_match_context_create_8,
|
||||
pcre2_match_context_free_8, pcre2_match_data_create_from_pattern_8,
|
||||
pcre2_match_data_free_8, pcre2_set_depth_limit_8, pcre2_set_match_limit_8,
|
||||
};
|
||||
|
||||
// Suricata production defaults (detect-pcre.h: SC_MATCH_LIMIT_DEFAULT / SC_MATCH_LIMIT_RECURSION_DEFAULT).
|
||||
const MATCH_LIMIT: u32 = 3500;
|
||||
const DEPTH_LIMIT: u32 = 1500;
|
||||
|
||||
/// A compiled PCRE2 pattern with a pre-configured match context that enforces
|
||||
/// hard backtracking limits (match_limit + depth_limit) on every call to is_match.
|
||||
///
|
||||
/// This works around the pcre2 v0.2 high-level crate not exposing match context
|
||||
/// configuration. Code and context are both read-only after construction, so the
|
||||
/// type is Send + Sync.
|
||||
pub struct BoundedPcre {
|
||||
code: *mut pcre2_code_8,
|
||||
match_ctx: *mut pcre2_match_context_8,
|
||||
pub negated: bool,
|
||||
pub buffer: u8,
|
||||
}
|
||||
|
||||
// Safety: pcre2_code_8 is read-only after compile; pcre2_match_context_8 is
|
||||
// read-only after the limits are set. pcre2_match_8 only reads both. The
|
||||
// per-call pcre2_match_data_8 is allocated and freed within each is_match call.
|
||||
unsafe impl Send for BoundedPcre {}
|
||||
unsafe impl Sync for BoundedPcre {}
|
||||
|
||||
impl BoundedPcre {
|
||||
/// Compile `pattern` with DOTALL + MULTILINE and attach a match context
|
||||
/// capped at MATCH_LIMIT backtrack steps and DEPTH_LIMIT nesting depth.
|
||||
/// Returns None if compilation fails (the caller should drop the signature).
|
||||
pub fn new(pattern: &str, negated: bool, buffer: u8) -> Option<Self> {
|
||||
let mut error_code: i32 = 0;
|
||||
let mut error_offset: usize = 0;
|
||||
|
||||
let code = unsafe {
|
||||
pcre2_compile_8(
|
||||
pattern.as_ptr(),
|
||||
pattern.len(),
|
||||
PCRE2_DOTALL | PCRE2_MULTILINE,
|
||||
&mut error_code,
|
||||
&mut error_offset,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if code.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let match_ctx = unsafe { pcre2_match_context_create_8(ptr::null_mut()) };
|
||||
if match_ctx.is_null() {
|
||||
unsafe { pcre2_code_free_8(code) };
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
pcre2_set_match_limit_8(match_ctx, MATCH_LIMIT);
|
||||
pcre2_set_depth_limit_8(match_ctx, DEPTH_LIMIT);
|
||||
}
|
||||
|
||||
Some(Self { code, match_ctx, negated, buffer })
|
||||
}
|
||||
|
||||
/// Run the pattern against `haystack`.
|
||||
///
|
||||
/// - `Ok(true)` — pattern matched
|
||||
/// - `Ok(false)` — no match (PCRE2_ERROR_NOMATCH)
|
||||
/// - `Err(())` — PCRE2 error: limit exceeded, internal failure, etc.
|
||||
///
|
||||
/// The caller is responsible for translating Err into the correct
|
||||
/// fail-open / fail-closed behaviour for positive vs. negated patterns.
|
||||
pub fn is_match(&self, haystack: &[u8]) -> Result<bool, ()> {
|
||||
let match_data = unsafe {
|
||||
pcre2_match_data_create_from_pattern_8(self.code, ptr::null_mut())
|
||||
};
|
||||
if match_data.is_null() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let rc = unsafe {
|
||||
pcre2_match_8(
|
||||
self.code,
|
||||
haystack.as_ptr(),
|
||||
haystack.len(),
|
||||
0,
|
||||
0,
|
||||
match_data,
|
||||
self.match_ctx,
|
||||
)
|
||||
};
|
||||
|
||||
unsafe { pcre2_match_data_free_8(match_data) };
|
||||
|
||||
match rc {
|
||||
r if r >= 0 => Ok(true),
|
||||
PCRE2_ERROR_NOMATCH => Ok(false),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BoundedPcre {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
pcre2_match_context_free_8(self.match_ctx);
|
||||
pcre2_code_free_8(self.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
pub mod app_layer;
|
||||
pub mod bounded_pcre;
|
||||
pub mod rule_engine;
|
||||
pub mod stream_reassembler;
|
||||
pub mod suppress;
|
||||
@ -1,816 +0,0 @@
|
||||
use std::collections::{HashSet, HashMap};
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use rusqlite::{Connection, params};
|
||||
use ipnet::IpNet;
|
||||
use parking_lot::Mutex;
|
||||
use vectorscan_rs::{BlockDatabase, Flag, Pattern, Scan};
|
||||
|
||||
use macros::log;
|
||||
use crate::model::error::rule::RuleError;
|
||||
use crate::model::rule_detection::{RuleMatch, RuleSig, ContentEntry, ParsedPacket, ByteOp, ThresholdInfo};
|
||||
use super::bounded_pcre::BoundedPcre;
|
||||
use super::suppress::SuppressList;
|
||||
|
||||
// ─── Threshold state ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Hash, Eq, PartialEq)]
|
||||
enum ThresholdKey {
|
||||
BySrc(IpAddr),
|
||||
ByDst(IpAddr),
|
||||
ByBoth(IpAddr, IpAddr),
|
||||
ByRule,
|
||||
}
|
||||
|
||||
struct ThresholdState {
|
||||
hit_count: u32,
|
||||
window_start: Instant,
|
||||
}
|
||||
|
||||
// ─── Extended RuleSig ────────────────────────────────────────────────────────
|
||||
|
||||
struct Sig {
|
||||
base: RuleSig,
|
||||
/// True when rule requires flow:established.
|
||||
flow_established: bool,
|
||||
/// 0=any 1=$HOME_NET 2=$EXTERNAL_NET
|
||||
src_ip_flag: u8,
|
||||
dst_ip_flag: u8,
|
||||
/// Compiled PCRE patterns — verified after content chain passes.
|
||||
pcres: Vec<BoundedPcre>,
|
||||
/// Flowbit operations: (op, name)
|
||||
/// Mirrors Suricata DETECT_FLOWBITS_CMD_*:
|
||||
/// op: 0=set 1=toggle 2=unset 3=isnotset 4=isset 5=noalert(internal)
|
||||
flowbits: Vec<(u8, String)>,
|
||||
/// Application-layer protocol filter (0 = any).
|
||||
alproto: u8,
|
||||
alproto_neg: bool,
|
||||
/// Byte-level inspection ops evaluated after the content chain succeeds.
|
||||
byte_ops: Vec<ByteOp>,
|
||||
/// Optional inline threshold (threshold: keyword).
|
||||
threshold: Option<ThresholdInfo>,
|
||||
}
|
||||
|
||||
pub struct RuleEngine {
|
||||
db: BlockDatabase,
|
||||
sigs: Vec<Sig>,
|
||||
home_nets: Vec<IpNet>,
|
||||
suppress: SuppressList,
|
||||
threshold_states: Mutex<HashMap<(u32, ThresholdKey), ThresholdState>>,
|
||||
}
|
||||
|
||||
impl RuleEngine {
|
||||
pub fn new(home_net_strs: &[String]) -> Option<Self> {
|
||||
let home_nets: Vec<IpNet> = home_net_strs
|
||||
.iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
|
||||
let rules_db_path = PathBuf::from(env!("RULES_DB_PATH"));
|
||||
let conn = Connection::open(rules_db_path.join("rules.db")).ok()?;
|
||||
|
||||
let patterns = load_patterns(&conn)?;
|
||||
let sigs = load_sigs(&conn)?;
|
||||
|
||||
if patterns.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let db = match BlockDatabase::new(patterns) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
log!(RuleError::BlockDatabaseFailed(e.to_string()));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let suppress = SuppressList::load_from_db(&conn);
|
||||
|
||||
Some(Self { db, sigs, home_nets, suppress, threshold_states: Mutex::new(HashMap::new()) })
|
||||
}
|
||||
|
||||
pub fn pattern_count(&self) -> usize {
|
||||
self.sigs.len()
|
||||
}
|
||||
|
||||
fn is_home(&self, ip: &IpAddr) -> bool {
|
||||
self.home_nets.iter().any(|n| n.contains(ip))
|
||||
}
|
||||
|
||||
/// Scan a parsed application-layer packet.
|
||||
///
|
||||
/// Returns an empty list immediately for TLS Application Data (encrypted).
|
||||
/// Filters apply in order: established, direction, port, IP zone, flowbit
|
||||
/// preconditions, content chain, PCRE.
|
||||
pub fn scan(
|
||||
&self,
|
||||
pkt: &ParsedPacket,
|
||||
is_ingress: bool,
|
||||
dst_port: u16,
|
||||
is_established: bool,
|
||||
flowbits: &HashSet<String>,
|
||||
src_ip: IpAddr,
|
||||
dst_ip: IpAddr,
|
||||
) -> Vec<RuleMatch> {
|
||||
if pkt.tls_app_data {
|
||||
return Vec::new();
|
||||
}
|
||||
if pkt.raw.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut scanner = match self.db.create_scanner() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut candidates: Vec<u32> = Vec::new();
|
||||
let _ = scanner.scan(&pkt.raw, |id, _from, _to, _flags| {
|
||||
candidates.push(id);
|
||||
Scan::Continue
|
||||
});
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
|
||||
let src_home = self.is_home(&src_ip);
|
||||
let dst_home = self.is_home(&dst_ip);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for sig_idx in candidates {
|
||||
let Some(sig) = self.sigs.get(sig_idx as usize) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// flow:established filter
|
||||
if sig.flow_established && !is_established {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direction filter
|
||||
let dir_ok = match sig.base.flow_dir {
|
||||
1 => is_ingress,
|
||||
2 => !is_ingress,
|
||||
_ => true,
|
||||
};
|
||||
if !dir_ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Port filter
|
||||
if !sig.base.dports.is_empty() && !sig.base.dports.contains(&dst_port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// IP zone filter
|
||||
if !check_ip_flag(sig.src_ip_flag, src_home) {
|
||||
continue;
|
||||
}
|
||||
if !check_ip_flag(sig.dst_ip_flag, dst_home) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// app-layer-protocol: filter
|
||||
if sig.alproto != 0 {
|
||||
let matches = pkt.proto as u8 == sig.alproto;
|
||||
if sig.alproto_neg == matches { continue; }
|
||||
}
|
||||
|
||||
// Flowbit preconditions (isset / isnotset)
|
||||
if !check_flowbit_conditions(&sig.flowbits, flowbits) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content chain verification
|
||||
let last_match_end = match verify_chain(&sig.base.chain, pkt) {
|
||||
Some(end) => end,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Byte-level ops (byte_test / byte_jump / byte_extract / isdataat)
|
||||
if !eval_byte_ops(&sig.byte_ops, pkt, last_match_end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// PCRE verification
|
||||
if !verify_pcres(&sig.pcres, pkt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect mutation ops (set / toggle / unset)
|
||||
let flowbit_ops: Vec<(u8, String)> = sig.flowbits.iter()
|
||||
.filter(|(op, _)| matches!(op, 0 | 1 | 2))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let is_noalert = sig.flowbits.iter().any(|(op, _)| *op == 5);
|
||||
|
||||
if !is_noalert && !self.should_alert(sig_idx, &sig.threshold, src_ip, dst_ip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(RuleMatch {
|
||||
sid: sig.base.sid,
|
||||
msg: sig.base.msg.clone(),
|
||||
src: String::new(),
|
||||
dst: String::new(),
|
||||
flowbit_ops,
|
||||
is_noalert,
|
||||
});
|
||||
}
|
||||
|
||||
results.retain(|m| !self.suppress.is_suppressed(m.sid, src_ip, dst_ip));
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Apply Suricata-compatible threshold logic (detect-threshold.c).
|
||||
///
|
||||
/// Returns true when the alert should fire:
|
||||
/// threshold — fire on every Nth match within the window
|
||||
/// limit — fire at most N times per window
|
||||
/// both — fire on every Nth match, capped at N per window
|
||||
///
|
||||
/// noalert rules bypass this check — their match is always kept for
|
||||
/// flowbit mutation regardless of threshold state.
|
||||
fn should_alert(
|
||||
&self,
|
||||
sig_idx: u32,
|
||||
threshold: &Option<ThresholdInfo>,
|
||||
src: IpAddr,
|
||||
dst: IpAddr,
|
||||
) -> bool {
|
||||
let thresh = match threshold {
|
||||
Some(t) => t,
|
||||
None => return true,
|
||||
};
|
||||
|
||||
let key = match thresh.track {
|
||||
1 => ThresholdKey::ByDst(dst),
|
||||
2 => ThresholdKey::BySrc(src),
|
||||
5 => ThresholdKey::ByBoth(src, dst),
|
||||
6 => ThresholdKey::ByBoth(src, dst), // by_flow: approximate as (src, dst) pair
|
||||
_ => ThresholdKey::ByRule,
|
||||
};
|
||||
|
||||
let mut states = self.threshold_states.lock();
|
||||
let state = states.entry((sig_idx, key)).or_insert_with(|| ThresholdState {
|
||||
hit_count: 0,
|
||||
window_start: Instant::now(),
|
||||
});
|
||||
|
||||
let now = Instant::now();
|
||||
// Mirrors Suricata SCTIME_CMP_LTE(p->ts, te->tv1 + td->seconds): inclusive upper bound.
|
||||
let within_window =
|
||||
now.duration_since(state.window_start) <= Duration::from_secs(thresh.seconds as u64);
|
||||
|
||||
if !within_window {
|
||||
// Window expired — mirrors Suricata ThresholdCheckUpdate expiry path.
|
||||
// Reset tv1 and current_count, then return type-specific value.
|
||||
state.window_start = now;
|
||||
state.hit_count = 1;
|
||||
return match thresh.kind {
|
||||
3 => false, // threshold: no alert on first hit of new window
|
||||
1 => true, // limit: always alert after reset
|
||||
2 => thresh.count == 1, // both: alert only if count == 1
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
// Within window — mirrors Suricata's within-window branch.
|
||||
state.hit_count += 1;
|
||||
match thresh.kind {
|
||||
3 => {
|
||||
// TYPE_THRESHOLD: alert on every Nth hit; reset counter after firing.
|
||||
if state.hit_count >= thresh.count {
|
||||
state.hit_count = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// TYPE_LIMIT: alert on the first N hits; silence thereafter.
|
||||
// Mirrors: current_count <= td->count -> ret=1.
|
||||
state.hit_count <= thresh.count
|
||||
}
|
||||
2 => {
|
||||
// TYPE_BOTH: alert exactly on the Nth hit; silence for hits N+1, N+2, ...
|
||||
// Mirrors: current_count == td->count -> ret=1, > td->count -> ret=2 (silent).
|
||||
state.hit_count == thresh.count
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 0=any 1=must be home 2=must be external
|
||||
fn check_ip_flag(flag: u8, is_home: bool) -> bool {
|
||||
match flag {
|
||||
1 => is_home,
|
||||
2 => !is_home,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return false if any isset/isnotset condition is not satisfied.
|
||||
fn check_flowbit_conditions(sig_flowbits: &[(u8, String)], flow: &HashSet<String>) -> bool {
|
||||
for (op, name) in sig_flowbits {
|
||||
match op {
|
||||
4 => if !flow.contains(name.as_str()) { return false; } // isset
|
||||
3 => if flow.contains(name.as_str()) { return false; } // isnotset
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Return false if any PCRE pattern fails (or negated pattern matches).
|
||||
///
|
||||
/// Any PCRE2 error (match_limit / depth_limit exceeded, internal failure) causes
|
||||
/// the entire signature to be suppressed regardless of whether the pattern is
|
||||
/// negated. This is fail-closed: a limit breach on a negated pattern would
|
||||
/// otherwise silently pass, producing false positives.
|
||||
fn verify_pcres(pcres: &[BoundedPcre], pkt: &ParsedPacket) -> bool {
|
||||
for p in pcres {
|
||||
let buf = pkt.buffer(p.buffer);
|
||||
let hit = match p.is_match(buf) {
|
||||
Ok(h) => h,
|
||||
Err(()) => return false,
|
||||
};
|
||||
if p.negated && hit { return false; }
|
||||
if !p.negated && !hit { return false; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ─── Byte-level inspection ────────────────────────────────────────────────────
|
||||
|
||||
/// Evaluate all byte ops in sequence. Returns false if any check fails.
|
||||
///
|
||||
/// `cursor` starts at `last_match_end` (end of final content match in the chain).
|
||||
/// byte_jump / byte_extract advance the cursor; byte_test / isdataat do not.
|
||||
fn eval_byte_ops(byte_ops: &[ByteOp], pkt: &ParsedPacket, last_match_end: usize) -> bool {
|
||||
let mut vars: [u64; 8] = [0; 8];
|
||||
let mut cursor = last_match_end;
|
||||
|
||||
for op in byte_ops {
|
||||
let buf = pkt.buffer(op.buffer);
|
||||
let base: usize = if op.relative { cursor } else { 0 };
|
||||
let abs_pos = (base as i64).saturating_add(op.offset as i64) as usize;
|
||||
|
||||
match op.kind {
|
||||
0 => { // byte_test
|
||||
if !eval_byte_test(op, buf, abs_pos, &vars) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1 => { // byte_jump
|
||||
match eval_byte_jump(op, buf, abs_pos) {
|
||||
Some(new_pos) => cursor = new_pos,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
2 => { // byte_extract
|
||||
match read_bytes(op, buf, abs_pos) {
|
||||
Some(val) => {
|
||||
let idx = op.be_var_idx as usize;
|
||||
if idx < 8 {
|
||||
let mult = if op.be_multiplier > 0 { op.be_multiplier as u64 } else { 1 };
|
||||
vars[idx] = val.wrapping_mul(mult);
|
||||
}
|
||||
cursor = abs_pos + op.count as usize;
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
3 => { // isdataat: assert data exists (or doesn't) at abs_pos
|
||||
let exists = abs_pos < buf.len();
|
||||
// bt_negated=true means "!isdataat" → assert no data
|
||||
if op.bt_negated == exists {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn eval_byte_test(op: &ByteOp, buf: &[u8], pos: usize, vars: &[u64; 8]) -> bool {
|
||||
let raw = match read_bytes(op, buf, pos) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let val = if op.bt_bitmask != 0 {
|
||||
raw & op.bt_bitmask as u64
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
|
||||
let rhs = if op.bt_uses_var {
|
||||
let idx = op.bt_value as usize;
|
||||
if idx < 8 { vars[idx] } else { return false; }
|
||||
} else {
|
||||
op.bt_value
|
||||
};
|
||||
|
||||
let result = match op.bt_op {
|
||||
0 => val < rhs,
|
||||
1 => val > rhs,
|
||||
2 => val == rhs,
|
||||
3 => val != rhs,
|
||||
4 => val <= rhs,
|
||||
5 => val >= rhs,
|
||||
6 => (val & rhs) != 0,
|
||||
7 => (val ^ rhs) != 0,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if op.bt_negated { !result } else { result }
|
||||
}
|
||||
|
||||
fn eval_byte_jump(op: &ByteOp, buf: &[u8], pos: usize) -> Option<usize> {
|
||||
let raw = read_bytes(op, buf, pos)?;
|
||||
let jump = (raw as usize).saturating_mul(op.bj_multiplier as usize);
|
||||
|
||||
let base = if op.bj_from_beginning {
|
||||
0usize
|
||||
} else {
|
||||
pos.saturating_add(op.count as usize)
|
||||
};
|
||||
|
||||
let mut new_pos = base.saturating_add(jump);
|
||||
|
||||
if op.bj_align {
|
||||
new_pos = (new_pos + 3) & !3;
|
||||
}
|
||||
|
||||
let post = op.bj_post_offset;
|
||||
new_pos = if post >= 0 {
|
||||
new_pos.saturating_add(post as usize)
|
||||
} else {
|
||||
new_pos.saturating_sub((-post) as usize)
|
||||
};
|
||||
|
||||
Some(new_pos)
|
||||
}
|
||||
|
||||
/// Read `op.count` bytes from `buf` at `pos` and return as u64.
|
||||
/// Handles big-endian, little-endian, and ASCII string modes.
|
||||
fn read_bytes(op: &ByteOp, buf: &[u8], pos: usize) -> Option<u64> {
|
||||
let n = op.count as usize;
|
||||
if n == 0 || n > 8 || pos + n > buf.len() {
|
||||
return None;
|
||||
}
|
||||
let slice = &buf[pos..pos + n];
|
||||
|
||||
if op.string_mode {
|
||||
let s = std::str::from_utf8(slice).ok()?.trim();
|
||||
let base = match op.string_base { 1 => 16, 2 => 8, _ => 10 };
|
||||
u64::from_str_radix(s, base).ok()
|
||||
} else if op.endian_little {
|
||||
let mut val: u64 = 0;
|
||||
for (i, &b) in slice.iter().enumerate() {
|
||||
val |= (b as u64) << (i * 8);
|
||||
}
|
||||
Some(val)
|
||||
} else {
|
||||
let mut val: u64 = 0;
|
||||
for &b in slice {
|
||||
val = (val << 8) | (b as u64);
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SQLite loaders ───────────────────────────────────────────────────────────
|
||||
|
||||
fn load_patterns(conn: &Connection) -> Option<Vec<Pattern>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, expression, nocase FROM patterns ORDER BY id")
|
||||
.ok()?;
|
||||
let patterns: Vec<Pattern> = stmt
|
||||
.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let expression: Vec<u8> = row.get(1)?;
|
||||
let nocase: i64 = row.get(2)?;
|
||||
let flags = if nocase != 0 { Flag::CASELESS } else { Flag::default() };
|
||||
Ok(Pattern::new(expression, flags, Some(id as u32)))
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Some(patterns)
|
||||
}
|
||||
|
||||
fn load_sigs(conn: &Connection) -> Option<Vec<Sig>> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, sid, flow_dir, flow_established, src_ip_flag, dst_ip_flag, msg, \
|
||||
alproto, alproto_neg \
|
||||
FROM signatures ORDER BY id"
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let sig_rows: Vec<(i64, u32, u8, bool, u8, u8, String, u8, bool)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, u32>(1)?,
|
||||
row.get::<_, u8>(2)?,
|
||||
row.get::<_, i64>(3)? != 0,
|
||||
row.get::<_, u8>(4)?,
|
||||
row.get::<_, u8>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, u8>(7)?,
|
||||
row.get::<_, i64>(8)? != 0,
|
||||
))
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let mut sigs = Vec::with_capacity(sig_rows.len());
|
||||
|
||||
for (sig_id, sid, flow_dir, flow_established, src_ip_flag, dst_ip_flag, msg, alproto, alproto_neg) in sig_rows {
|
||||
// Destination ports
|
||||
let mut port_stmt = conn
|
||||
.prepare("SELECT dport FROM signature_ports WHERE sig_id = ?1")
|
||||
.ok()?;
|
||||
let dports: Vec<u16> = port_stmt
|
||||
.query_map(params![sig_id], |row| row.get::<_, u16>(0))
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Content chain
|
||||
let mut entry_stmt = conn
|
||||
.prepare(
|
||||
"SELECT pattern, nocase, negated, \
|
||||
has_distance, has_within, has_offset, has_depth, \
|
||||
distance, within, off, depth, buffer \
|
||||
FROM content_entries WHERE sig_id = ?1 ORDER BY entry_order",
|
||||
)
|
||||
.ok()?;
|
||||
let chain: Vec<ContentEntry> = entry_stmt
|
||||
.query_map(params![sig_id], |row| {
|
||||
Ok(ContentEntry {
|
||||
pattern: row.get(0)?,
|
||||
nocase: row.get::<_, i64>(1)? != 0,
|
||||
negated: row.get::<_, i64>(2)? != 0,
|
||||
has_distance: row.get::<_, i64>(3)? != 0,
|
||||
has_within: row.get::<_, i64>(4)? != 0,
|
||||
has_offset: row.get::<_, i64>(5)? != 0,
|
||||
has_depth: row.get::<_, i64>(6)? != 0,
|
||||
distance: row.get::<_, i32>(7)?,
|
||||
within: row.get::<_, i32>(8)?,
|
||||
offset: row.get::<_, u16>(9)?,
|
||||
depth: row.get::<_, u16>(10)?,
|
||||
buffer: row.get::<_, u8>(11)?,
|
||||
})
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// PCRE entries — compile at load time.
|
||||
// If any pattern fails to compile the whole signature is dropped (fail-closed):
|
||||
// an uncompilable PCRE silently removed would produce false positives.
|
||||
let mut pcre_stmt = conn
|
||||
.prepare(
|
||||
"SELECT pattern, negated, buffer FROM pcre_entries \
|
||||
WHERE sig_id = ?1 ORDER BY entry_order",
|
||||
)
|
||||
.ok()?;
|
||||
let raw_pcres: Vec<(String, bool, u8)> = pcre_stmt
|
||||
.query_map(params![sig_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)? != 0,
|
||||
row.get::<_, u8>(2)?,
|
||||
))
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let mut pcres: Vec<BoundedPcre> = Vec::with_capacity(raw_pcres.len());
|
||||
let mut pcre_ok = true;
|
||||
for (pattern, negated, buffer) in raw_pcres {
|
||||
match BoundedPcre::new(&pattern, negated, buffer) {
|
||||
Some(bp) => pcres.push(bp),
|
||||
None => { pcre_ok = false; break; }
|
||||
}
|
||||
}
|
||||
if !pcre_ok {
|
||||
// Drop this sig entirely — better to miss a detection than fire falsely.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flowbit entries
|
||||
let mut fb_stmt = conn
|
||||
.prepare("SELECT op, name FROM flowbit_entries WHERE sig_id = ?1")
|
||||
.ok()?;
|
||||
let flowbits: Vec<(u8, String)> = fb_stmt
|
||||
.query_map(params![sig_id], |row| {
|
||||
Ok((row.get::<_, u8>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Byte ops
|
||||
let mut bo_stmt = conn
|
||||
.prepare(
|
||||
"SELECT kind, count, offset, relative, endian_little, \
|
||||
string_mode, string_base, buffer, \
|
||||
bt_op, bt_value, bt_uses_var, bt_bitmask, bt_negated, \
|
||||
bj_align, bj_multiplier, bj_from_beg, bj_post_offset, \
|
||||
be_var_idx, be_multiplier \
|
||||
FROM byte_ops WHERE sig_id = ?1 ORDER BY op_order",
|
||||
)
|
||||
.ok()?;
|
||||
let byte_ops: Vec<ByteOp> = bo_stmt
|
||||
.query_map(params![sig_id], |row| {
|
||||
Ok(ByteOp {
|
||||
kind: row.get::<_, u8>(0)?,
|
||||
count: row.get::<_, u8>(1)?,
|
||||
offset: row.get::<_, i32>(2)?,
|
||||
relative: row.get::<_, i64>(3)? != 0,
|
||||
endian_little: row.get::<_, i64>(4)? != 0,
|
||||
string_mode: row.get::<_, i64>(5)? != 0,
|
||||
string_base: row.get::<_, u8>(6)?,
|
||||
buffer: row.get::<_, u8>(7)?,
|
||||
bt_op: row.get::<_, u8>(8)?,
|
||||
bt_value: row.get::<_, i64>(9)? as u64,
|
||||
bt_uses_var: row.get::<_, i64>(10)? != 0,
|
||||
bt_bitmask: row.get::<_, i64>(11)? as u64,
|
||||
bt_negated: row.get::<_, i64>(12)? != 0,
|
||||
bj_align: row.get::<_, i64>(13)? != 0,
|
||||
bj_multiplier: row.get::<_, i64>(14)? as u32,
|
||||
bj_from_beginning: row.get::<_, i64>(15)? != 0,
|
||||
bj_post_offset: row.get::<_, i32>(16)?,
|
||||
be_var_idx: row.get::<_, u8>(17)?,
|
||||
be_multiplier: row.get::<_, i64>(18)? as u32,
|
||||
})
|
||||
})
|
||||
.ok()?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let mut th_stmt = conn
|
||||
.prepare(
|
||||
"SELECT kind, track, count, seconds FROM threshold_entries WHERE sig_id = ?1"
|
||||
)
|
||||
.ok()?;
|
||||
let threshold = th_stmt.query_row(params![sig_id], |row| {
|
||||
Ok(ThresholdInfo {
|
||||
kind: row.get::<_, u8>(0)?,
|
||||
track: row.get::<_, u8>(1)?,
|
||||
count: row.get::<_, u32>(2)?,
|
||||
seconds: row.get::<_, u32>(3)?,
|
||||
})
|
||||
}).ok();
|
||||
|
||||
sigs.push(Sig {
|
||||
base: RuleSig { sid, flow_dir, dports, msg, chain },
|
||||
flow_established,
|
||||
src_ip_flag,
|
||||
dst_ip_flag,
|
||||
pcres,
|
||||
flowbits,
|
||||
alproto,
|
||||
alproto_neg,
|
||||
byte_ops,
|
||||
threshold,
|
||||
});
|
||||
}
|
||||
|
||||
Some(sigs)
|
||||
}
|
||||
|
||||
// ─── Chain verification ───────────────────────────────────────────────────────
|
||||
|
||||
/// Returns Some(last_match_end) on success, None on failure.
|
||||
/// last_match_end is the byte offset past the final content match — used as
|
||||
/// the base position for subsequent byte_test / byte_jump / isdataat ops.
|
||||
fn verify_chain(chain: &[ContentEntry], pkt: &ParsedPacket) -> Option<usize> {
|
||||
verify_from(chain, pkt, 0, 0)
|
||||
}
|
||||
|
||||
fn verify_from(chain: &[ContentEntry], pkt: &ParsedPacket, idx: usize, prev_end: usize) -> Option<usize> {
|
||||
if idx >= chain.len() {
|
||||
return Some(prev_end);
|
||||
}
|
||||
|
||||
let entry = &chain[idx];
|
||||
let payload = pkt.buffer(entry.buffer);
|
||||
|
||||
if payload.is_empty() && !entry.negated {
|
||||
return None;
|
||||
}
|
||||
|
||||
let buf_len = payload.len();
|
||||
let (ws, we) = compute_window(entry, prev_end, buf_len);
|
||||
|
||||
if ws >= we || ws >= buf_len {
|
||||
return if entry.negated {
|
||||
verify_from(chain, pkt, idx + 1, prev_end)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
let window = &payload[ws..we];
|
||||
|
||||
if entry.negated {
|
||||
if find_in(window, &entry.pattern, entry.nocase).is_some() {
|
||||
return None;
|
||||
}
|
||||
return verify_from(chain, pkt, idx + 1, prev_end);
|
||||
}
|
||||
|
||||
let next_is_relative = idx + 1 < chain.len() && {
|
||||
let next = &chain[idx + 1];
|
||||
next.has_distance || next.has_within
|
||||
};
|
||||
|
||||
let mut search_from = 0usize;
|
||||
loop {
|
||||
let sub = &window[search_from..];
|
||||
let Some(rel_pos) = find_in(sub, &entry.pattern, entry.nocase) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let match_end = ws + search_from + rel_pos + entry.pattern.len();
|
||||
|
||||
if let Some(end) = verify_from(chain, pkt, idx + 1, match_end) {
|
||||
return Some(end);
|
||||
}
|
||||
|
||||
if !next_is_relative {
|
||||
break;
|
||||
}
|
||||
|
||||
search_from += rel_pos + 1;
|
||||
if search_from >= window.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn compute_window(entry: &ContentEntry, prev_end: usize, buf_len: usize) -> (usize, usize) {
|
||||
let (mut start, mut end) = if entry.has_distance || entry.has_within {
|
||||
let dist = entry.distance;
|
||||
let s = if dist >= 0 {
|
||||
prev_end.saturating_add(dist as usize)
|
||||
} else {
|
||||
prev_end.saturating_sub((-dist) as usize)
|
||||
};
|
||||
let e = if entry.has_within {
|
||||
let dist_non_neg = dist.max(0) as usize;
|
||||
prev_end.saturating_add(dist_non_neg).saturating_add(entry.within as usize)
|
||||
} else {
|
||||
buf_len
|
||||
};
|
||||
(s, e)
|
||||
} else {
|
||||
let s = if entry.has_offset { entry.offset as usize } else { 0 };
|
||||
let e = if entry.has_depth { entry.depth as usize } else { buf_len };
|
||||
(s, e)
|
||||
};
|
||||
|
||||
if entry.has_offset {
|
||||
start = start.max(entry.offset as usize);
|
||||
}
|
||||
if entry.has_depth {
|
||||
end = end.min(entry.depth as usize);
|
||||
}
|
||||
|
||||
(start, end.min(buf_len))
|
||||
}
|
||||
|
||||
fn find_in(haystack: &[u8], needle: &[u8], nocase: bool) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
if haystack.len() < needle.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if nocase {
|
||||
let needle_lc: Vec<u8> = needle.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|w| w.iter().zip(&needle_lc).all(|(a, b)| a.to_ascii_lowercase() == *b))
|
||||
} else {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
}
|
||||
@ -1,427 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::c_void;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use protolens::{Prolens, TransProto};
|
||||
|
||||
use super::app_layer;
|
||||
use super::rule_engine::RuleEngine;
|
||||
use crate::model::error::rule::RuleError;
|
||||
use crate::model::rule_detection::{FlowKey, FlowProto, FlowState, RawPkt, RuleMatch};
|
||||
|
||||
/// Shared per-flow context surfaced to protolens callbacks via Rc<RefCell<>>.
|
||||
/// Populated in process_tcp() before each run_task() call.
|
||||
struct ScanCtx {
|
||||
sport: u16,
|
||||
dport: u16,
|
||||
proto: u8, // FlowProto as u8
|
||||
established: bool,
|
||||
flowbits: HashSet<String>,
|
||||
sip: IpAddr,
|
||||
dip: IpAddr,
|
||||
}
|
||||
|
||||
impl Default for ScanCtx {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sport: 0,
|
||||
dport: 0,
|
||||
proto: 0,
|
||||
established: false,
|
||||
flowbits: HashSet::new(),
|
||||
sip: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
dip: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-thread TCP stream reassembler backed by protolens.
|
||||
/// Not Send (Prolens uses Rc/RefCell) — must be created inside the XSK thread.
|
||||
pub struct StreamReassembler {
|
||||
engine: Prolens<RawPkt>,
|
||||
flows: HashMap<FlowKey, FlowState>,
|
||||
rule_engine: Arc<RuleEngine>,
|
||||
pending: Rc<RefCell<Vec<RuleMatch>>>,
|
||||
ctx: Rc<RefCell<ScanCtx>>,
|
||||
|
||||
/// (flow_key, sid) -> cumulative match count for min_matches threshold
|
||||
flow_sid_counts: HashMap<(FlowKey, u32), u32>,
|
||||
min_matches: u32,
|
||||
is_ingress: bool,
|
||||
}
|
||||
|
||||
impl StreamReassembler {
|
||||
pub fn new(rule_engine: Arc<RuleEngine>, min_matches: u32, is_ingress: bool) -> Self {
|
||||
let pending = Rc::new(RefCell::new(Vec::<RuleMatch>::new()));
|
||||
let ctx = Rc::new(RefCell::new(ScanCtx::default()));
|
||||
|
||||
let p_c2s = Rc::clone(&pending);
|
||||
let re_c2s = Arc::clone(&rule_engine);
|
||||
let cx_c2s = Rc::clone(&ctx);
|
||||
|
||||
let p_s2c = Rc::clone(&pending);
|
||||
let re_s2c = Arc::clone(&rule_engine);
|
||||
let cx_s2c = Rc::clone(&ctx);
|
||||
|
||||
let mut engine = Prolens::<RawPkt>::default();
|
||||
|
||||
engine.set_cb_task_c2s(move |data: &[u8], _seq: u32, _ctx: *const c_void| {
|
||||
let c = cx_c2s.borrow();
|
||||
if c.proto == FlowProto::Tls as u8 {
|
||||
return;
|
||||
}
|
||||
let pkt = app_layer::detect_and_parse(data, c.sport, c.dport);
|
||||
if pkt.is_tls {
|
||||
drop(c);
|
||||
cx_c2s.borrow_mut().proto = FlowProto::Tls as u8;
|
||||
return;
|
||||
}
|
||||
let matches = re_c2s.scan(
|
||||
&pkt, true, c.dport,
|
||||
c.established, &c.flowbits,
|
||||
c.sip, c.dip,
|
||||
);
|
||||
drop(c);
|
||||
p_c2s.borrow_mut().extend(matches);
|
||||
});
|
||||
|
||||
engine.set_cb_task_s2c(move |data: &[u8], _seq: u32, _ctx: *const c_void| {
|
||||
let c = cx_s2c.borrow();
|
||||
if c.proto == FlowProto::Tls as u8 {
|
||||
return;
|
||||
}
|
||||
let pkt = app_layer::detect_and_parse(data, c.sport, c.dport);
|
||||
if pkt.is_tls {
|
||||
drop(c);
|
||||
cx_s2c.borrow_mut().proto = FlowProto::Tls as u8;
|
||||
return;
|
||||
}
|
||||
let matches = re_s2c.scan(
|
||||
&pkt, false, c.dport,
|
||||
c.established, &c.flowbits,
|
||||
c.sip, c.dip,
|
||||
);
|
||||
drop(c);
|
||||
p_s2c.borrow_mut().extend(matches);
|
||||
});
|
||||
|
||||
Self {
|
||||
engine,
|
||||
flows: HashMap::new(),
|
||||
rule_engine,
|
||||
pending,
|
||||
ctx,
|
||||
flow_sid_counts: HashMap::new(),
|
||||
min_matches,
|
||||
is_ingress,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a raw Ethernet frame. Returns alerts that have crossed min_matches threshold.
|
||||
pub fn process(&mut self, packet_data: &[u8]) -> Result<Vec<RuleMatch>, RuleError> {
|
||||
let Some(pkt) = parse_raw(packet_data) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
if is_multicast_or_broadcast(&pkt.dip) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let src = format!("{}:{}", pkt.sip, pkt.sport);
|
||||
let dst = format!("{}:{}", pkt.dip, pkt.dport);
|
||||
let flow_key = FlowKey {
|
||||
sip: pkt.sip,
|
||||
sport: pkt.sport,
|
||||
dip: pkt.dip,
|
||||
dport: pkt.dport,
|
||||
};
|
||||
|
||||
let raw_matches = if matches!(pkt.trans_proto, TransProto::Udp) {
|
||||
let parsed = app_layer::detect_and_parse(&pkt.payload, pkt.sport, pkt.dport);
|
||||
self.rule_engine.scan(
|
||||
&parsed, self.is_ingress, pkt.dport,
|
||||
false,
|
||||
&HashSet::new(),
|
||||
pkt.sip, pkt.dip,
|
||||
)
|
||||
} else {
|
||||
self.process_tcp(pkt, &flow_key)?
|
||||
};
|
||||
|
||||
Ok(self.apply_threshold(raw_matches, &flow_key, src, dst))
|
||||
}
|
||||
|
||||
fn process_tcp(&mut self, pkt: RawPkt, key: &FlowKey) -> Result<Vec<RuleMatch>, RuleError> {
|
||||
let rev = key.reversed();
|
||||
let is_syn = pkt.syn;
|
||||
let is_rst = pkt.rst;
|
||||
let is_fin = pkt.fin;
|
||||
|
||||
let actual_key = if self.flows.contains_key(key) {
|
||||
key.clone()
|
||||
} else if self.flows.contains_key(&rev) {
|
||||
rev
|
||||
} else {
|
||||
let task = self.engine.new_task(TransProto::Tcp);
|
||||
self.flows.insert(
|
||||
key.clone(),
|
||||
FlowState {
|
||||
task,
|
||||
last_seen: Instant::now(),
|
||||
fin_count: 0,
|
||||
rst_seen: false,
|
||||
detected_proto: FlowProto::Unknown,
|
||||
established: false,
|
||||
flowbits: HashSet::new(),
|
||||
},
|
||||
);
|
||||
key.clone()
|
||||
};
|
||||
|
||||
let Some(state) = self.flows.get_mut(&actual_key) else {
|
||||
return Err(RuleError::FlowStateMissing);
|
||||
};
|
||||
state.last_seen = Instant::now();
|
||||
if is_rst { state.rst_seen = true; }
|
||||
if is_fin { state.fin_count += 1; }
|
||||
|
||||
// Mark established on first non-SYN packet — handshake complete.
|
||||
if !is_syn && !state.established {
|
||||
state.established = true;
|
||||
}
|
||||
|
||||
// Populate the shared scan context for callbacks.
|
||||
{
|
||||
let mut c = self.ctx.borrow_mut();
|
||||
c.sport = actual_key.sport;
|
||||
c.dport = actual_key.dport;
|
||||
c.proto = state.detected_proto as u8;
|
||||
c.established = state.established;
|
||||
c.flowbits = state.flowbits.clone();
|
||||
c.sip = actual_key.sip;
|
||||
c.dip = actual_key.dip;
|
||||
}
|
||||
|
||||
self.engine.run_task(&mut state.task, pkt);
|
||||
|
||||
// Persist protocol detection back into FlowState.
|
||||
let proto_byte = self.ctx.borrow().proto;
|
||||
if let Some(s) = self.flows.get_mut(&actual_key) {
|
||||
if s.detected_proto == FlowProto::Unknown && proto_byte != 0 {
|
||||
s.detected_proto = if proto_byte == FlowProto::Tls as u8 {
|
||||
FlowProto::Tls
|
||||
} else {
|
||||
FlowProto::Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let all_matches: Vec<RuleMatch> = self.pending.borrow_mut().drain(..).collect();
|
||||
|
||||
// Apply flowbit mutations (set/unset/toggle) to FlowState.
|
||||
if let Some(s) = self.flows.get_mut(&actual_key) {
|
||||
for m in &all_matches {
|
||||
apply_flowbit_mutations(&m.flowbit_ops, &mut s.flowbits);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip noalert matches before threshold counting.
|
||||
let alert_matches: Vec<RuleMatch> = all_matches
|
||||
.into_iter()
|
||||
.filter(|m| !m.is_noalert)
|
||||
.collect();
|
||||
|
||||
if self.flows.get(&actual_key).map(|s| s.rst_seen || s.fin_count >= 2).unwrap_or(false) {
|
||||
self.flows.remove(&actual_key);
|
||||
}
|
||||
|
||||
Ok(alert_matches)
|
||||
}
|
||||
|
||||
fn apply_threshold(
|
||||
&mut self,
|
||||
raw: Vec<RuleMatch>,
|
||||
flow_key: &FlowKey,
|
||||
src: String,
|
||||
dst: String,
|
||||
) -> Vec<RuleMatch> {
|
||||
let threshold = self.min_matches.max(1);
|
||||
let mut alerts = Vec::new();
|
||||
for mut m in raw {
|
||||
let count = self.flow_sid_counts.entry((flow_key.clone(), m.sid)).or_insert(0);
|
||||
*count += 1;
|
||||
if *count == threshold {
|
||||
m.src = src.clone();
|
||||
m.dst = dst.clone();
|
||||
alerts.push(m);
|
||||
}
|
||||
}
|
||||
alerts
|
||||
}
|
||||
|
||||
/// Drop flows idle longer than `max_idle`. Call periodically from the XSK thread.
|
||||
pub fn cleanup_stale(&mut self, max_idle: Duration) {
|
||||
let now = Instant::now();
|
||||
self.flows.retain(|_, s| now.duration_since(s.last_seen) < max_idle);
|
||||
self.flow_sid_counts.retain(|(k, _), _| self.flows.contains_key(k));
|
||||
}
|
||||
|
||||
pub fn flow_count(&self) -> usize {
|
||||
self.flows.len()
|
||||
}
|
||||
|
||||
pub fn match_counts(&self) -> Vec<u32> {
|
||||
self.flow_sid_counts.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn min_matches(&self) -> u32 {
|
||||
self.min_matches
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Flowbit mutation helper ──────────────────────────────────────────────────
|
||||
|
||||
fn apply_flowbit_mutations(ops: &[(u8, String)], bits: &mut HashSet<String>) {
|
||||
for (op, name) in ops {
|
||||
match op {
|
||||
0 => { bits.insert(name.clone()); } // set
|
||||
1 => { // toggle
|
||||
if bits.contains(name.as_str()) {
|
||||
bits.remove(name.as_str());
|
||||
} else {
|
||||
bits.insert(name.clone());
|
||||
}
|
||||
}
|
||||
2 => { bits.remove(name.as_str()); } // unset
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Raw frame parser ─────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_raw(data: &[u8]) -> Option<RawPkt> {
|
||||
if data.len() < 14 {
|
||||
return None;
|
||||
}
|
||||
match u16::from_be_bytes([data[12], data[13]]) {
|
||||
0x0800 => parse_ipv4(data),
|
||||
0x86DD => parse_ipv6(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ipv4(data: &[u8]) -> Option<RawPkt> {
|
||||
if data.len() < 34 {
|
||||
return None;
|
||||
}
|
||||
let ip = &data[14..];
|
||||
let proto = ip[9];
|
||||
if proto != 6 && proto != 17 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sip = IpAddr::V4(Ipv4Addr::from(u32::from_be_bytes([ip[12], ip[13], ip[14], ip[15]])));
|
||||
let dip = IpAddr::V4(Ipv4Addr::from(u32::from_be_bytes([ip[16], ip[17], ip[18], ip[19]])));
|
||||
let ihl = (ip[0] & 0x0F) as usize * 4;
|
||||
let total_len = u16::from_be_bytes([ip[2], ip[3]]) as usize;
|
||||
|
||||
if data.len() < 14 + ihl + 4 {
|
||||
return None;
|
||||
}
|
||||
let tp = &ip[ihl..];
|
||||
let sport = u16::from_be_bytes([tp[0], tp[1]]);
|
||||
let dport = u16::from_be_bytes([tp[2], tp[3]]);
|
||||
|
||||
if proto == 6 {
|
||||
if data.len() < 14 + ihl + 20 {
|
||||
return None;
|
||||
}
|
||||
let seq = u32::from_be_bytes([tp[4], tp[5], tp[6], tp[7]]);
|
||||
let hlen = (tp[12] >> 4) as usize * 4;
|
||||
let flags = tp[13];
|
||||
let payload_start = 14 + ihl + hlen;
|
||||
let payload_end = (14 + total_len).min(data.len());
|
||||
Some(RawPkt {
|
||||
trans_proto: TransProto::Tcp,
|
||||
sip, dip, sport, dport, seq,
|
||||
syn: flags & 0x02 != 0,
|
||||
fin: flags & 0x01 != 0,
|
||||
rst: flags & 0x04 != 0,
|
||||
payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(),
|
||||
})
|
||||
} else {
|
||||
let payload_start = 14 + ihl + 8;
|
||||
let payload_end = (14 + total_len).min(data.len());
|
||||
Some(RawPkt {
|
||||
trans_proto: TransProto::Udp,
|
||||
sip, dip, sport, dport, seq: 0,
|
||||
syn: false, fin: false, rst: false,
|
||||
payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ipv6(data: &[u8]) -> Option<RawPkt> {
|
||||
if data.len() < 54 {
|
||||
return None;
|
||||
}
|
||||
let ip = &data[14..];
|
||||
let proto = ip[6];
|
||||
if proto != 6 && proto != 17 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut s = [0u8; 16];
|
||||
s.copy_from_slice(&ip[8..24]);
|
||||
let mut d = [0u8; 16];
|
||||
d.copy_from_slice(&ip[24..40]);
|
||||
let sip = IpAddr::V6(Ipv6Addr::from(s));
|
||||
let dip = IpAddr::V6(Ipv6Addr::from(d));
|
||||
|
||||
let payload_len = u16::from_be_bytes([ip[4], ip[5]]) as usize;
|
||||
let tp = &ip[40..];
|
||||
let sport = u16::from_be_bytes([tp[0], tp[1]]);
|
||||
let dport = u16::from_be_bytes([tp[2], tp[3]]);
|
||||
|
||||
if proto == 6 {
|
||||
if data.len() < 54 + 20 {
|
||||
return None;
|
||||
}
|
||||
let seq = u32::from_be_bytes([tp[4], tp[5], tp[6], tp[7]]);
|
||||
let hlen = (tp[12] >> 4) as usize * 4;
|
||||
let flags = tp[13];
|
||||
let payload_start = 14 + 40 + hlen;
|
||||
let payload_end = (14 + 40 + payload_len).min(data.len());
|
||||
Some(RawPkt {
|
||||
trans_proto: TransProto::Tcp,
|
||||
sip, dip, sport, dport, seq,
|
||||
syn: flags & 0x02 != 0,
|
||||
fin: flags & 0x01 != 0,
|
||||
rst: flags & 0x04 != 0,
|
||||
payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(),
|
||||
})
|
||||
} else {
|
||||
let payload_start = 14 + 40 + 8;
|
||||
let payload_end = (14 + 40 + payload_len).min(data.len());
|
||||
Some(RawPkt {
|
||||
trans_proto: TransProto::Udp,
|
||||
sip, dip, sport, dport, seq: 0,
|
||||
syn: false, fin: false, rst: false,
|
||||
payload: data.get(payload_start..payload_end).unwrap_or(&[]).to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// True for IPv4 multicast (224.0.0.0/4), limited broadcast (255.255.255.255),
|
||||
/// and IPv6 multicast (ff00::/8).
|
||||
fn is_multicast_or_broadcast(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_multicast() || v4.is_broadcast(),
|
||||
IpAddr::V6(v6) => v6.is_multicast(),
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
use std::net::IpAddr;
|
||||
use ipnet::IpNet;
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub struct SuppressEntry {
|
||||
pub sid: u32,
|
||||
/// 0=any(TRACK_RULE) 1=TRACK_SRC 2=TRACK_DST 4=TRACK_EITHER
|
||||
pub track: u8,
|
||||
pub nets: Vec<IpNet>,
|
||||
}
|
||||
|
||||
pub struct SuppressList {
|
||||
entries: Vec<SuppressEntry>,
|
||||
}
|
||||
|
||||
impl SuppressList {
|
||||
pub fn load_from_db(conn: &Connection) -> Self {
|
||||
let mut stmt = match conn.prepare("SELECT sid, track, ip_net FROM suppress") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Self { entries: Vec::new() },
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let sid: u32 = row.get(0)?;
|
||||
let track: u8 = row.get(1)?;
|
||||
let ip_net: String = row.get(2)?;
|
||||
Ok((sid, track, ip_net))
|
||||
});
|
||||
if let Ok(iter) = rows {
|
||||
for result in iter.flatten() {
|
||||
let (sid, track, ip_net_str) = result;
|
||||
let mut nets = Vec::new();
|
||||
if !ip_net_str.is_empty() {
|
||||
if let Ok(net) = ip_net_str.parse::<IpNet>() {
|
||||
nets.push(net);
|
||||
} else if let Ok(ip) = ip_net_str.parse::<IpAddr>() {
|
||||
let prefix = if ip.is_ipv4() { 32 } else { 128 };
|
||||
if let Ok(net) = format!("{}/{}", ip, prefix).parse::<IpNet>() {
|
||||
nets.push(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.push(SuppressEntry { sid, track, nets });
|
||||
}
|
||||
}
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
/// Mirrors Suricata threshold.conf suppress matching logic.
|
||||
///
|
||||
/// track 0 (no track/ip): suppress all instances (TRACK_RULE).
|
||||
/// track 1 (TRACK_SRC): suppress when src_ip is in nets.
|
||||
/// track 2 (TRACK_DST): suppress when dst_ip is in nets.
|
||||
/// track 4 (TRACK_EITHER): suppress when either src or dst is in nets.
|
||||
pub fn is_suppressed(&self, sid: u32, src: IpAddr, dst: IpAddr) -> bool {
|
||||
for e in &self.entries {
|
||||
if e.sid != sid { continue; }
|
||||
let hit = match e.track {
|
||||
0 => true,
|
||||
1 => e.nets.is_empty() || e.nets.iter().any(|n| n.contains(&src)),
|
||||
2 => e.nets.is_empty() || e.nets.iter().any(|n| n.contains(&dst)),
|
||||
4 => e.nets.is_empty()
|
||||
|| e.nets.iter().any(|n| n.contains(&src))
|
||||
|| e.nets.iter().any(|n| n.contains(&dst)),
|
||||
_ => false,
|
||||
};
|
||||
if hit { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
159
net-guardia/src/detection/suricata/engine.rs
Normal file
159
net-guardia/src/detection/suricata/engine.rs
Normal file
@ -0,0 +1,159 @@
|
||||
use std::mem;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use crossbeam::channel::{bounded, Sender};
|
||||
use macros::log;
|
||||
|
||||
use crate::detection::fusion::FusionEngine;
|
||||
use crate::model::error::suricata::SuricataError;
|
||||
use crate::model::log::suricata::SuricataLog;
|
||||
|
||||
use super::output;
|
||||
|
||||
const MIRROR_IFACE: &str = "ng-mirror";
|
||||
const MIRROR_PEER: &str = "ng-mirror-peer";
|
||||
|
||||
/* Channel capacity matches the old libsuricata engine. */
|
||||
const CHANNEL_CAP: usize = 4096;
|
||||
|
||||
pub struct SuricataEngine {
|
||||
tx: Sender<Vec<u8>>,
|
||||
child: std::sync::Mutex<Child>,
|
||||
}
|
||||
|
||||
impl SuricataEngine {
|
||||
pub fn start(
|
||||
yaml_path: PathBuf,
|
||||
eve_socket: PathBuf,
|
||||
fusion: Arc<FusionEngine>,
|
||||
) -> Result<Arc<Self>, SuricataError> {
|
||||
Self::setup_veth()?;
|
||||
|
||||
let ifindex = Self::get_ifindex(MIRROR_IFACE)?;
|
||||
|
||||
if let Some(path) = eve_socket.to_str() {
|
||||
output::start_eve_reader(path, fusion);
|
||||
}
|
||||
|
||||
let yaml = yaml_path.to_str()
|
||||
.ok_or_else(|| SuricataError::InvalidPath { path: yaml_path.display().to_string() })?;
|
||||
|
||||
let child = Command::new("suricata")
|
||||
.args(["-c", yaml, "-i", MIRROR_PEER, "--runmode=workers"])
|
||||
.spawn()
|
||||
.map_err(|e| SuricataError::ProcessSpawnFailed { reason: e.to_string() })?;
|
||||
|
||||
let (tx, rx) = bounded::<Vec<u8>>(CHANNEL_CAP);
|
||||
|
||||
/* Mirror worker: reads packet data from channel, sends via raw socket. */
|
||||
thread::Builder::new()
|
||||
.name("suricata-mirror".into())
|
||||
.spawn(move || {
|
||||
let fd = match Self::open_raw_socket() {
|
||||
Ok(fd) => fd,
|
||||
Err(e) => {
|
||||
log!(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() };
|
||||
sll.sll_family = libc::AF_PACKET as u16;
|
||||
sll.sll_protocol = (libc::ETH_P_ALL as u16).to_be();
|
||||
sll.sll_ifindex = ifindex as i32;
|
||||
|
||||
log!(SuricataLog::MirrorReady { iface: MIRROR_IFACE.into() });
|
||||
|
||||
while let Ok(data) = rx.recv() {
|
||||
unsafe {
|
||||
libc::sendto(
|
||||
fd,
|
||||
data.as_ptr() as *const libc::c_void,
|
||||
data.len(),
|
||||
0,
|
||||
&sll as *const libc::sockaddr_ll as *const libc::sockaddr,
|
||||
mem::size_of::<libc::sockaddr_ll>() as libc::socklen_t,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe { libc::close(fd) };
|
||||
log!(SuricataLog::MirrorStopped);
|
||||
})
|
||||
.map_err(|e| SuricataError::ProcessSpawnFailed { reason: e.to_string() })?;
|
||||
|
||||
log!(SuricataLog::Initialized);
|
||||
|
||||
Ok(Arc::new(Self { tx, child: std::sync::Mutex::new(child) }))
|
||||
}
|
||||
|
||||
/* Non-blocking: drops silently when the channel is full under load. */
|
||||
pub fn inject(&self, data: Vec<u8>) {
|
||||
match self.tx.try_send(data) {
|
||||
Ok(()) => {}
|
||||
Err(crossbeam::channel::TrySendError::Full(_)) => {
|
||||
log!(SuricataLog::ChannelFull);
|
||||
}
|
||||
Err(crossbeam::channel::TrySendError::Disconnected(_)) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_veth() -> Result<(), SuricataError> {
|
||||
/* Remove stale pair if it exists. */
|
||||
let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output();
|
||||
|
||||
Command::new("ip")
|
||||
.args(["link", "add", MIRROR_IFACE, "type", "veth", "peer", "name", MIRROR_PEER])
|
||||
.output()
|
||||
.map_err(|e| SuricataError::MirrorSetupFailed { reason: e.to_string() })?;
|
||||
|
||||
for iface in [MIRROR_IFACE, MIRROR_PEER] {
|
||||
Command::new("ip")
|
||||
.args(["link", "set", iface, "up"])
|
||||
.output()
|
||||
.map_err(|e| SuricataError::MirrorSetupFailed { reason: e.to_string() })?;
|
||||
}
|
||||
|
||||
log!(SuricataLog::VethCreated { iface: MIRROR_IFACE.into(), peer: MIRROR_PEER.into() });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ifindex(name: &str) -> Result<u32, SuricataError> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{}/ifindex", name))
|
||||
.map_err(|e| SuricataError::MirrorSetupFailed { reason: e.to_string() })?
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e: std::num::ParseIntError| SuricataError::MirrorSetupFailed {
|
||||
reason: format!("ifindex parse: {e}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_raw_socket() -> Result<i32, SuricataError> {
|
||||
let fd = unsafe {
|
||||
libc::socket(
|
||||
libc::AF_PACKET,
|
||||
libc::SOCK_RAW,
|
||||
(libc::ETH_P_ALL as u16).to_be() as i32,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
let errno = unsafe { *libc::__errno_location() };
|
||||
return Err(SuricataError::MirrorSetupFailed {
|
||||
reason: format!("socket(AF_PACKET): errno {errno}"),
|
||||
});
|
||||
}
|
||||
Ok(fd)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SuricataEngine {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut child) = self.child.lock() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output();
|
||||
}
|
||||
}
|
||||
3
net-guardia/src/detection/suricata/mod.rs
Normal file
3
net-guardia/src/detection/suricata/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
mod output;
|
||||
pub mod engine;
|
||||
pub use engine::SuricataEngine;
|
||||
110
net-guardia/src/detection/suricata/output.rs
Normal file
110
net-guardia/src/detection/suricata/output.rs
Normal file
@ -0,0 +1,110 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use macros::log;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::detection::fusion::FusionEngine;
|
||||
use crate::model::log::suricata::SuricataLog;
|
||||
use crate::model::rule_detection::RuleMatch;
|
||||
|
||||
/* ── EVE JSON structs (alert subset) ─────────────────────────────────────── */
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EveEvent {
|
||||
event_type: String,
|
||||
src_ip: Option<String>,
|
||||
src_port: Option<u16>,
|
||||
dest_ip: Option<String>,
|
||||
dest_port: Option<u16>,
|
||||
alert: Option<EveAlert>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EveAlert {
|
||||
#[serde(rename = "signature_id")]
|
||||
signature_id: u32,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
/* ── Unix socket EVE reader ──────────────────────────────────────────────── */
|
||||
|
||||
pub fn start_eve_reader(socket_path: &str, fusion: Arc<FusionEngine>) {
|
||||
let path = socket_path.to_owned();
|
||||
|
||||
if Path::new(&path).exists() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&path).unwrap_or_else(|e| {
|
||||
panic!("suricata EVE socket bind failed at {path}: {e}");
|
||||
});
|
||||
|
||||
log!(SuricataLog::EveReaderReady { path: path.clone() });
|
||||
|
||||
thread::Builder::new()
|
||||
.name("suricata-eve-reader".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(s) => {
|
||||
let fusion_clone = fusion.clone();
|
||||
thread::Builder::new()
|
||||
.name("suricata-eve-conn".into())
|
||||
.spawn(move || handle_eve_stream(s, fusion_clone))
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log!(crate::model::error::suricata::SuricataError::EveSocketAcceptFailed {
|
||||
reason: e.to_string(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("suricata-eve-reader thread spawn failed");
|
||||
}
|
||||
|
||||
fn handle_eve_stream(stream: std::os::unix::net::UnixStream, fusion: Arc<FusionEngine>) {
|
||||
let reader = BufReader::new(stream);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(event) = serde_json::from_str::<EveEvent>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if event.event_type != "alert" {
|
||||
continue;
|
||||
}
|
||||
let (Some(alert), Some(src_ip), Some(dst_ip)) =
|
||||
(event.alert, event.src_ip, event.dest_ip)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let src_port = event.src_port.unwrap_or(0);
|
||||
let dst_port = event.dest_port.unwrap_or(0);
|
||||
|
||||
let m = RuleMatch {
|
||||
sid: alert.signature_id,
|
||||
msg: alert.signature,
|
||||
src: format!("{}:{}", src_ip, src_port),
|
||||
dst: format!("{}:{}", dst_ip, dst_port),
|
||||
};
|
||||
|
||||
log!(SuricataLog::RuleAlert {
|
||||
src: m.src.clone(),
|
||||
dst: m.dst.clone(),
|
||||
sid: m.sid,
|
||||
msg: m.msg.clone(),
|
||||
});
|
||||
|
||||
fusion.record_rule(&m);
|
||||
}
|
||||
}
|
||||
@ -30,16 +30,15 @@ pub struct Config {
|
||||
pub flow_timeout_us: u64,
|
||||
pub traffic_logging_mode: bool,
|
||||
pub traffic_log_csv_path: String,
|
||||
pub min_signature_matches: u32,
|
||||
pub home_net: Vec<String>,
|
||||
/// Optional path to an NSS key log file (SSLKEYLOGFILE) for TLS decryption.
|
||||
/// Only useful in [external]->NetGuardia->[internal] deployments where the
|
||||
/// internal server can be configured to write TLS session keys.
|
||||
pub tls_keylog_path: Option<String>,
|
||||
/// First CPU core assigned to XSK packet threads. Each queue pair gets one core
|
||||
/// starting from this base (e.g. base=0 with 4 queues pins to cores 0-3).
|
||||
/// CPU core range [start, end] (inclusive) for XSK packet threads.
|
||||
/// Threads are distributed round-robin: core = start + (queue_id % (end - start + 1)).
|
||||
/// Example: [0, 3] with 8 queues spreads 16 threads across cores 0-3 (4 threads each).
|
||||
/// If absent, no affinity is set.
|
||||
pub xsk_cpu_base: Option<u32>,
|
||||
pub xsk_cpu_set: Option<[u32; 2]>,
|
||||
/// CPU core pinned to the ML inference spawn_blocking thread.
|
||||
/// If absent, defaults to the last available core.
|
||||
pub ml_cpu: Option<u32>,
|
||||
@ -56,6 +55,10 @@ pub struct Config {
|
||||
/// "mean+1std", "Q3+1.5IQR", "Q3+3.0IQR". Defaults to "95" when absent.
|
||||
#[serde(default = "default_ae_threshold_method")]
|
||||
pub ae_threshold_method: String,
|
||||
|
||||
/// Path to suricata.yaml. If absent, Suricata rule engine is disabled.
|
||||
pub suricata_name: String,
|
||||
|
||||
}
|
||||
|
||||
fn default_fusion_mode() -> String {
|
||||
@ -68,4 +71,4 @@ fn default_fusion_window_secs() -> u64 {
|
||||
|
||||
fn default_ae_threshold_method() -> String {
|
||||
"95".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ pub mod io;
|
||||
pub mod misc;
|
||||
pub mod ml;
|
||||
pub mod system;
|
||||
pub mod rule;
|
||||
pub mod suricata;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -13,7 +13,7 @@ use crate::model::error::http::HttpError;
|
||||
use crate::model::error::io::IOError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::error::ml::MLError;
|
||||
use crate::model::error::rule::RuleError;
|
||||
use crate::model::error::suricata::SuricataError;
|
||||
use crate::model::error::system::SystemError;
|
||||
|
||||
#[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)]
|
||||
@ -29,7 +29,7 @@ pub enum Error {
|
||||
#[error("{0}")]
|
||||
Misc(MiscError),
|
||||
#[error("{0}")]
|
||||
Rule(RuleError),
|
||||
Suricata(SuricataError),
|
||||
#[error("{0}")]
|
||||
System(SystemError),
|
||||
}
|
||||
@ -70,8 +70,8 @@ impl From<MLError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuleError> for Error {
|
||||
fn from(error: RuleError) -> Self {
|
||||
Self::Rule(error)
|
||||
impl From<SuricataError> for Error {
|
||||
fn from(error: SuricataError) -> Self {
|
||||
Self::Suricata(error)
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
use macros::traceable;
|
||||
|
||||
traceable! {
|
||||
RuleError {
|
||||
#[no_source]
|
||||
#[error("BlockDatabase::new failed: {error}")]
|
||||
BlockDatabaseFailed { error: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Flow state missing for TCP key; skipping packet processing")]
|
||||
FlowStateMissing => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
25
net-guardia/src/model/error/suricata.rs
Normal file
25
net-guardia/src/model/error/suricata.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use macros::traceable;
|
||||
|
||||
traceable! {
|
||||
SuricataError {
|
||||
#[no_source]
|
||||
#[error("Suricata process spawn failed: {reason}")]
|
||||
ProcessSpawnFailed { reason: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Mirror veth setup failed: {reason}")]
|
||||
MirrorSetupFailed { reason: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("EVE socket bind failed at {path}: {reason}")]
|
||||
EveSocketBindFailed { path: String, reason: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("EVE socket accept failed: {reason}")]
|
||||
EveSocketAcceptFailed { reason: String } => tracing::Level::ERROR,
|
||||
|
||||
#[no_source]
|
||||
#[error("Invalid path (non-UTF-8): {path}")]
|
||||
InvalidPath { path: String } => tracing::Level::ERROR,
|
||||
}
|
||||
}
|
||||
@ -4,4 +4,4 @@ pub mod ml;
|
||||
pub mod system;
|
||||
pub mod misc;
|
||||
pub mod health;
|
||||
pub mod rule;
|
||||
pub mod suricata;
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
use macros::loggable;
|
||||
use tracing;
|
||||
|
||||
loggable! {
|
||||
RuleLog {
|
||||
#[error("Signature rules loaded: {count} patterns")]
|
||||
RulesLoaded { count: usize } => tracing::Level::INFO,
|
||||
|
||||
#[error("Signature rules disabled (no rules found in rules/)")]
|
||||
RulesDisabled => tracing::Level::INFO,
|
||||
|
||||
#[error("Signature match [{direction}]: {src}->{dst} sid={sid} \"{msg}\"")]
|
||||
SignatureMatch { direction: String, src: String, dst: String, sid: u32, msg: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Signature stats [{direction}]: active_flows={active_flows}, min_matches={min_matches}, match_counts: {counts}")]
|
||||
SignatureStats { direction: String, active_flows: usize, min_matches: u32, counts: String } => tracing::Level::INFO,
|
||||
}
|
||||
}
|
||||
27
net-guardia/src/model/log/suricata.rs
Normal file
27
net-guardia/src/model/log/suricata.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use macros::loggable;
|
||||
use tracing;
|
||||
|
||||
loggable! {
|
||||
SuricataLog {
|
||||
#[error("Suricata engine initialized (daemon mode)")]
|
||||
Initialized => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata veth pair created: {iface} <-> {peer}")]
|
||||
VethCreated { iface: String, peer: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata mirror socket ready on {iface}")]
|
||||
MirrorReady { iface: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata mirror socket stopped")]
|
||||
MirrorStopped => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata EVE reader listening at {path}")]
|
||||
EveReaderReady { path: String } => tracing::Level::INFO,
|
||||
|
||||
#[error("Suricata rule alert: {src}->{dst} sid={sid} \"{msg}\"")]
|
||||
RuleAlert { src: String, dst: String, sid: u32, msg: String } => tracing::Level::WARN,
|
||||
|
||||
#[error("Suricata mirror channel full — packet dropped")]
|
||||
ChannelFull => tracing::Level::WARN,
|
||||
}
|
||||
}
|
||||
@ -1,300 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Instant;
|
||||
use protolens::{Packet as ProtoPacket, Task, TransProto};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuleMatch {
|
||||
pub sid: u32,
|
||||
pub msg: String,
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
/// Flowbit mutations to apply after this match: (op, name).
|
||||
/// op: 0=set, 1=toggle, 2=unset (Suricata DETECT_FLOWBITS_CMD_* order)
|
||||
pub flowbit_ops: Vec<(u8, String)>,
|
||||
/// When true, do not count this match toward alert threshold.
|
||||
pub is_noalert: bool,
|
||||
}
|
||||
|
||||
pub struct ContentEntry {
|
||||
pub pattern: Vec<u8>,
|
||||
pub nocase: bool,
|
||||
pub negated: bool,
|
||||
pub has_distance: bool,
|
||||
pub has_within: bool,
|
||||
pub has_offset: bool,
|
||||
pub has_depth: bool,
|
||||
pub distance: i32,
|
||||
pub within: i32,
|
||||
pub offset: u16,
|
||||
pub depth: u16,
|
||||
|
||||
/// Target buffer ID (0 = raw; see buf module for named constants).
|
||||
pub buffer: u8,
|
||||
}
|
||||
|
||||
/// Buffer IDs — must match the constants used in `build.rs` and `rule_engine.rs`.
|
||||
/// 0 (Raw) has no named constant; it is the default when no sticky buffer is set.
|
||||
pub mod buf {
|
||||
// HTTP/1.x
|
||||
pub const HTTP_URI: u8 = 1;
|
||||
pub const HTTP_HEADER: u8 = 2;
|
||||
pub const HTTP_CLIENT_BODY: u8 = 3;
|
||||
pub const HTTP_SERVER_BODY: u8 = 4;
|
||||
pub const HTTP_METHOD: u8 = 5;
|
||||
pub const HTTP_USER_AGENT: u8 = 6;
|
||||
pub const HTTP_HOST: u8 = 7;
|
||||
pub const HTTP_STAT_CODE: u8 = 8;
|
||||
pub const HTTP_COOKIE: u8 = 9;
|
||||
pub const HTTP_RAW_URI: u8 = 10;
|
||||
// TLS
|
||||
pub const TLS_SNI: u8 = 11;
|
||||
pub const DNS_QUERY: u8 = 12;
|
||||
pub const TLS_VERSION: u8 = 13;
|
||||
pub const TLS_JA3: u8 = 14;
|
||||
// DNS
|
||||
pub const DNS_RCODE: u8 = 15;
|
||||
pub const DNS_QTYPE: u8 = 16;
|
||||
pub const DNS_ANSWER: u8 = 17;
|
||||
// SSH
|
||||
pub const SSH_PROTO: u8 = 18;
|
||||
pub const SSH_SOFTWARE: u8 = 19;
|
||||
pub const SSH_HASSH: u8 = 20;
|
||||
// SMTP
|
||||
pub const SMTP_HELO: u8 = 21;
|
||||
pub const SMTP_FROM: u8 = 22;
|
||||
pub const SMTP_RCPT: u8 = 23;
|
||||
pub const SMTP_REPLY: u8 = 24;
|
||||
// FTP
|
||||
pub const FTP_COMMAND: u8 = 25;
|
||||
pub const FTP_FILENAME: u8 = 26;
|
||||
pub const FTP_REPLY: u8 = 27;
|
||||
// MQTT
|
||||
pub const MQTT_CLIENTID: u8 = 28;
|
||||
pub const MQTT_TOPIC: u8 = 29;
|
||||
pub const MQTT_USERNAME: u8 = 30;
|
||||
// HTTP/2
|
||||
pub const HTTP2_METHOD: u8 = 31;
|
||||
pub const HTTP2_PATH: u8 = 32;
|
||||
pub const HTTP2_AUTHORITY: u8 = 33;
|
||||
pub const HTTP2_STATUS: u8 = 34;
|
||||
pub const HTTP2_UA: u8 = 35;
|
||||
// TLS extended
|
||||
pub const TLS_JA3S: u8 = 36;
|
||||
pub const TLS_JA4: u8 = 37;
|
||||
pub const TLS_CERT_SUBJECT: u8 = 38;
|
||||
pub const TLS_CERT_ISSUER: u8 = 39;
|
||||
pub const TLS_CERT_FINGERPRINT: u8 = 40;
|
||||
pub const TLS_CERT_NOT_BEFORE: u8 = 41;
|
||||
pub const TLS_CERT_NOT_AFTER: u8 = 42;
|
||||
pub const TLS_ALPN: u8 = 43;
|
||||
// QUIC
|
||||
pub const QUIC_SNI: u8 = 44;
|
||||
pub const QUIC_VERSION: u8 = 45;
|
||||
pub const QUIC_UA: u8 = 46;
|
||||
}
|
||||
|
||||
/// All application-layer fields extracted from a single payload chunk.
|
||||
/// Uses a sparse buffer map so that adding new protocols requires no struct changes.
|
||||
pub struct ParsedPacket {
|
||||
pub raw: Vec<u8>,
|
||||
/// True when the payload is TLS Application Data (encrypted).
|
||||
pub tls_app_data: bool,
|
||||
/// True when this payload was identified as TLS (any record type, or port-based fallback).
|
||||
pub is_tls: bool,
|
||||
/// Application-layer protocol detected for this packet.
|
||||
pub proto: AppProto,
|
||||
bufs: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl ParsedPacket {
|
||||
pub fn new(raw: Vec<u8>) -> Self {
|
||||
Self { raw, tls_app_data: false, is_tls: false, proto: AppProto::Unknown, bufs: Vec::new() }
|
||||
}
|
||||
|
||||
/// Store `data` under buffer `id`. No-op if data is empty.
|
||||
pub fn set(&mut self, id: u8, data: Vec<u8>) {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some((_, v)) = self.bufs.iter_mut().find(|(k, _)| *k == id) {
|
||||
*v = data;
|
||||
} else {
|
||||
self.bufs.push((id, data));
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the byte slice for the given buffer ID.
|
||||
pub fn buffer(&self, id: u8) -> &[u8] {
|
||||
if id == 0 {
|
||||
return &self.raw;
|
||||
}
|
||||
self.bufs.iter().find(|(k, _)| *k == id).map(|(_, v)| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte-level inspection operation stored alongside the content chain.
|
||||
///
|
||||
/// Suricata keywords: byte_test, byte_jump, byte_extract, isdataat.
|
||||
/// Operations are evaluated in order after the content chain succeeds.
|
||||
/// For `relative` ops, the base position is the end of the last content match.
|
||||
///
|
||||
/// kind encoding:
|
||||
/// 0 = byte_test — read N bytes, compare against a value or extracted var
|
||||
/// 1 = byte_jump — advance the cursor by the value read at offset
|
||||
/// 2 = byte_extract — read N bytes into a named variable slot
|
||||
/// 3 = isdataat — assert that data exists (or doesn't) at a position
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ByteOp {
|
||||
/// 0=byte_test 1=byte_jump 2=byte_extract 3=isdataat
|
||||
pub kind: u8,
|
||||
/// Number of bytes to read from the buffer (byte_test/jump/extract: 1–8).
|
||||
pub count: u8,
|
||||
/// Signed byte offset from base position.
|
||||
pub offset: i32,
|
||||
/// If true, offset is from the previous match end; otherwise from buffer start.
|
||||
pub relative: bool,
|
||||
/// Read value as little-endian (default: big-endian).
|
||||
pub endian_little: bool,
|
||||
/// Parse numeric value from ASCII text rather than raw binary.
|
||||
pub string_mode: bool,
|
||||
/// 0=decimal 1=hex 2=octal (only when string_mode is true).
|
||||
pub string_base: u8,
|
||||
/// Buffer ID the operation applies to (same IDs as ContentEntry::buffer).
|
||||
pub buffer: u8,
|
||||
|
||||
// ── byte_test fields ──────────────────────────────────────────────────
|
||||
/// Comparison operator: 0=< 1=> 2=== 3=!= 4=<= 5=>= 6=& 7=^
|
||||
pub bt_op: u8,
|
||||
/// Value to test against (or variable slot index when bt_uses_var is true).
|
||||
pub bt_value: u64,
|
||||
/// If true, bt_value is a byte_extract variable slot index, not a literal.
|
||||
pub bt_uses_var: bool,
|
||||
/// Bitmask ANDed with the extracted value before comparison; 0 = disabled.
|
||||
pub bt_bitmask: u64,
|
||||
/// Negate the operator result.
|
||||
pub bt_negated: bool,
|
||||
|
||||
// ── byte_jump fields ──────────────────────────────────────────────────
|
||||
/// Align the new cursor to the next multiple of 4 bytes.
|
||||
pub bj_align: bool,
|
||||
/// Multiply the extracted value by this before jumping.
|
||||
pub bj_multiplier: u32,
|
||||
/// Jump from the start of the buffer rather than the current cursor.
|
||||
pub bj_from_beginning: bool,
|
||||
/// Additional signed offset applied after the jump.
|
||||
pub bj_post_offset: i32,
|
||||
|
||||
// ── byte_extract fields ───────────────────────────────────────────────
|
||||
/// Variable slot index (0–7) where the extracted value is stored.
|
||||
pub be_var_idx: u8,
|
||||
/// Multiply the extracted value before storing.
|
||||
pub be_multiplier: u32,
|
||||
}
|
||||
|
||||
pub struct RuleSig {
|
||||
pub sid: u32,
|
||||
/// 0=any, 1=to_server (ingress), 2=to_client (egress)
|
||||
pub flow_dir: u8,
|
||||
/// empty = any port
|
||||
pub dports: Vec<u16>,
|
||||
pub msg: String,
|
||||
pub chain: Vec<ContentEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RawPkt {
|
||||
pub trans_proto: TransProto,
|
||||
pub sip: IpAddr,
|
||||
pub dip: IpAddr,
|
||||
pub sport: u16,
|
||||
pub dport: u16,
|
||||
pub seq: u32,
|
||||
pub syn: bool,
|
||||
pub fin: bool,
|
||||
pub rst: bool,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ProtoPacket for RawPkt {
|
||||
fn trans_proto(&self) -> TransProto { self.trans_proto }
|
||||
fn sip(&self) -> IpAddr { self.sip }
|
||||
fn dip(&self) -> IpAddr { self.dip }
|
||||
fn tu_sport(&self) -> u16 { self.sport }
|
||||
fn tu_dport(&self) -> u16 { self.dport }
|
||||
fn seq(&self) -> u32 { self.seq }
|
||||
fn syn(&self) -> bool { self.syn }
|
||||
fn fin(&self) -> bool { self.fin }
|
||||
fn payload_len(&self) -> usize { self.payload.len() }
|
||||
fn payload(&self) -> &[u8] { &self.payload }
|
||||
}
|
||||
|
||||
#[derive(Hash, Eq, PartialEq, Clone)]
|
||||
pub struct FlowKey {
|
||||
pub sip: IpAddr,
|
||||
pub sport: u16,
|
||||
pub dip: IpAddr,
|
||||
pub dport: u16,
|
||||
}
|
||||
|
||||
impl FlowKey {
|
||||
pub fn reversed(&self) -> Self {
|
||||
Self { sip: self.dip, sport: self.dport, dip: self.sip, dport: self.sport }
|
||||
}
|
||||
}
|
||||
|
||||
/// Application-layer protocol identified during packet parsing.
|
||||
/// Used by app-layer-protocol: rule keyword (Suricata-compatible).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[repr(u8)]
|
||||
pub enum AppProto {
|
||||
#[default]
|
||||
Unknown = 0,
|
||||
Http = 1,
|
||||
Http2 = 2,
|
||||
Tls = 3,
|
||||
Dns = 4,
|
||||
Ssh = 5,
|
||||
Smtp = 6,
|
||||
Ftp = 7,
|
||||
Mqtt = 8,
|
||||
Quic = 9,
|
||||
}
|
||||
|
||||
/// Application-layer protocol detected for a flow.
|
||||
/// Mirrors Suricata's `flow->alproto` — once set, never cleared.
|
||||
/// Only TLS is tracked for now; HTTP and DNS don't benefit from per-flow
|
||||
/// skip logic because they're cheap to detect and should still be scanned.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum FlowProto {
|
||||
#[default]
|
||||
Unknown = 0,
|
||||
Tls = 1,
|
||||
}
|
||||
|
||||
/// Inline threshold from Suricata threshold: keyword.
|
||||
/// Mirrors Suricata detect-threshold.h DetectThresholdData.
|
||||
pub struct ThresholdInfo {
|
||||
/// 1=limit 2=both 3=threshold
|
||||
pub kind: u8,
|
||||
/// 1=TRACK_DST 2=TRACK_SRC 3=TRACK_RULE 5=TRACK_BOTH 6=TRACK_FLOW
|
||||
pub track: u8,
|
||||
pub count: u32,
|
||||
pub seconds: u32,
|
||||
}
|
||||
|
||||
pub struct FlowState {
|
||||
pub task: Task<RawPkt>,
|
||||
pub last_seen: Instant,
|
||||
pub fin_count: u8,
|
||||
pub rst_seen: bool,
|
||||
|
||||
/// Protocol identified on the first parseable chunk of this flow.
|
||||
pub detected_proto: FlowProto,
|
||||
|
||||
/// True once the first non-SYN packet is seen, indicating TCP handshake complete.
|
||||
pub established: bool,
|
||||
|
||||
/// Per-flow flowbits set by matching rules (Suricata flowbits:set/unset/toggle).
|
||||
pub flowbits: HashSet<String>,
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
// this's open rule folder, so you can download the open rule from https://rules.emergingthreats.net/open/suricata-8.0/emerging.rules.tar.gz
|
||||
// this's open rule folder, so you can download the open rule from https://rules.emergingthreats.net/open/suricata-8.0/emerging.rules.tar.gz
|
||||
81
net-guardia/static/rules/suricata.yaml
Normal file
81
net-guardia/static/rules/suricata.yaml
Normal file
@ -0,0 +1,81 @@
|
||||
%YAML 1.1
|
||||
---
|
||||
|
||||
vars:
|
||||
address-groups:
|
||||
HOME_NET: "[140.130.34.0/24]"
|
||||
EXTERNAL_NET: "!$HOME_NET"
|
||||
HTTP_SERVERS: "$HOME_NET"
|
||||
SMTP_SERVERS: "$HOME_NET"
|
||||
SQL_SERVERS: "$HOME_NET"
|
||||
DNS_SERVERS: "$HOME_NET"
|
||||
TELNET_SERVERS: "$HOME_NET"
|
||||
AIM_SERVERS: "$EXTERNAL_NET"
|
||||
DC_SERVERS: "$HOME_NET"
|
||||
DNP3_SERVER: "$HOME_NET"
|
||||
DNP3_CLIENT: "$HOME_NET"
|
||||
MODBUS_CLIENT: "$HOME_NET"
|
||||
MODBUS_SERVER: "$HOME_NET"
|
||||
ENIP_CLIENT: "$HOME_NET"
|
||||
ENIP_SERVER: "$HOME_NET"
|
||||
port-groups:
|
||||
HTTP_PORTS: "80"
|
||||
SHELLCODE_PORTS: "!80"
|
||||
ORACLE_PORTS: 1521
|
||||
SSH_PORTS: 22
|
||||
DNP3_PORTS: 20000
|
||||
MODBUS_PORTS: 502
|
||||
FILE_DATA_PORTS: "[$HTTP_PORTS,110,143]"
|
||||
FTP_PORTS: 21
|
||||
VXLAN_PORTS: 4789
|
||||
TEREDO_PORTS: 3544
|
||||
|
||||
default-rule-path: ./net-guardia/static/rules
|
||||
|
||||
rule-files:
|
||||
- "*.rules"
|
||||
|
||||
threshold-file: ./net-guardia/static/rules/suppress.conf
|
||||
|
||||
outputs:
|
||||
- eve-log:
|
||||
enabled: yes
|
||||
filetype: unix_stream
|
||||
filename: /tmp/suricata-alerts.sock
|
||||
types:
|
||||
- alert:
|
||||
payload: no
|
||||
packet: no
|
||||
metadata: no
|
||||
http-body: no
|
||||
tagged-packets: no
|
||||
- fast:
|
||||
enabled: no
|
||||
- stats:
|
||||
enabled: no
|
||||
|
||||
app-layer:
|
||||
protocols:
|
||||
tls:
|
||||
enabled: yes
|
||||
http:
|
||||
enabled: yes
|
||||
dns:
|
||||
enabled: yes
|
||||
smtp:
|
||||
enabled: yes
|
||||
ssh:
|
||||
enabled: yes
|
||||
|
||||
af-packet:
|
||||
- interface: ng-mirror-peer
|
||||
use-mmap: yes
|
||||
tpacket-v3: yes
|
||||
ring-size: 2048
|
||||
block-size: 131072
|
||||
|
||||
threading:
|
||||
set-cpu-affinity: no
|
||||
|
||||
legacy:
|
||||
uricontent: enabled
|
||||
Loading…
x
Reference in New Issue
Block a user