refactor: replace native vectorscan rule engine with Suricata daemon mode

This commit is contained in:
ParrotXray 2026-05-21 11:51:47 +00:00
parent b410099c5e
commit 9b33d24cea
38 changed files with 463 additions and 7228 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ node_modules/
*.rules
*.db
net-guardia/static/rules/*.txt

440
Cargo.lock generated
View File

@ -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"

View File

@ -19,29 +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.
# Distribute XSK packet threads across a core range [start, end] (inclusive).
# Threads are assigned round-robin: core = start + (queue_id % (end - start + 1)).
# Example: xsk_cpu_set=[0, 3] with combined_queue_count=8 spreads 16 threads
# across cores 0-3, with 4 threads per core.
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"

View File

@ -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"

File diff suppressed because it is too large Load Diff

View File

@ -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);

View File

@ -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();
@ -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)?;
@ -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)
@ -240,13 +210,6 @@ impl XskPair {
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;
@ -269,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))),
}
@ -285,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();
}
@ -336,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) };
@ -349,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 {

View File

@ -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(),
})
}

View File

@ -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(())

View File

@ -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};

View File

@ -1,3 +1,3 @@
pub mod fusion;
pub mod ml;
pub mod rule;
pub mod suricata;

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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);
}

View File

@ -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() })
}

View File

@ -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 }

View File

@ -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..]
}

View File

@ -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 })
}

View File

@ -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
}

View File

@ -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);
}
}
}

View File

@ -1,5 +0,0 @@
pub mod app_layer;
pub mod bounded_pcre;
pub mod rule_engine;
pub mod stream_reassembler;
pub mod suppress;

View File

@ -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)
}
}

View File

@ -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(),
}
}

View File

@ -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
}
}

View 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();
}
}

View File

@ -0,0 +1,3 @@
mod output;
pub mod engine;
pub use engine::SuricataEngine;

View 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);
}
}

View File

@ -30,8 +30,6 @@ 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.
@ -57,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 {
@ -69,4 +71,4 @@ fn default_fusion_window_secs() -> u64 {
fn default_ae_threshold_method() -> String {
"95".to_string()
}
}

View File

@ -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)
}
}

View File

@ -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,
}
}

View 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,
}
}

View File

@ -4,4 +4,4 @@ pub mod ml;
pub mod system;
pub mod misc;
pub mod health;
pub mod rule;
pub mod suricata;

View File

@ -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,
}
}

View 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,
}
}

View File

@ -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: 18).
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 (07) 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>,
}

View 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