Mantis/net-guardia/build.rs
2026-05-08 19:07:01 +08:00

1105 lines
36 KiB
Rust

use std::env;
use std::fs;
use std::io::{BufRead as _, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::SystemTime;
use cargo_metadata::{Artifact, CompilerMessage, Message, Metadata, MetadataCommand, Package, Target, TargetKind};
/// Buffer IDs — must stay in sync with app_layer/mod.rs and rule_engine.rs.
const BUF_RAW: u8 = 0;
const BUF_HTTP_URI: u8 = 1;
const BUF_HTTP_HEADER: u8 = 2;
const BUF_HTTP_CLIENT_BODY: u8 = 3;
const BUF_HTTP_SERVER_BODY: u8 = 4;
const BUF_HTTP_METHOD: u8 = 5;
const BUF_HTTP_USER_AGENT: u8 = 6;
const BUF_HTTP_HOST: u8 = 7;
const BUF_HTTP_STAT_CODE: u8 = 8;
const BUF_HTTP_COOKIE: u8 = 9;
const BUF_HTTP_RAW_URI: u8 = 10;
const BUF_TLS_SNI: u8 = 11;
const BUF_DNS_QUERY: u8 = 12;
struct ContentMatch {
pattern: Vec<u8>,
nocase: bool,
negated: bool,
is_fast_pattern: bool,
has_distance: bool,
has_within: bool,
has_offset: bool,
has_depth: bool,
distance: i32,
within: i32,
offset: u16,
depth: u16,
/// Target buffer (0 = raw payload, 1 = http_uri, etc.)
buffer: u8,
}
struct SigEntry {
sid: u32,
flow_dir: u8,
dports: Vec<u16>,
msg: String,
chain: Vec<ContentMatch>,
fast_pattern_idx: usize,
}
fn main() {
build_ingress_ebpf();
build_egress_ebpf();
build_frontend();
build_vectorscan_db();
}
fn build_ingress_ebpf() {
let Metadata { packages, .. } = MetadataCommand::new().no_deps().exec().unwrap();
let ebpf_package = packages
.into_iter()
.find(|Package { name, .. }| **name == "ingress-ebpf")
.unwrap();
let out_dir = env::var_os("OUT_DIR").unwrap();
let out_dir = PathBuf::from(out_dir);
let endian = env::var_os("CARGO_CFG_TARGET_ENDIAN").unwrap();
let target = if endian == "big" {
"bpfeb"
} else if endian == "little" {
"bpfel"
} else {
panic!("unsupported endian={:?}", endian)
};
let build_ebpf = true;
if build_ebpf {
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
let target = format!("{target}-unknown-none");
let Package { manifest_path, .. } = ebpf_package;
let ebpf_dir = manifest_path.parent().unwrap();
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
let mut cmd = Command::new("cargo");
cmd.args([
"build",
"-Z",
"build-std=core",
"--bins",
"--message-format=json",
"--release",
"--target",
&target,
]);
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] {
cmd.env_remove(key);
}
cmd.current_dir(ebpf_dir);
let ebpf_target_dir = out_dir.join("../ingress-ebpf");
cmd.arg("--target-dir").arg(&ebpf_target_dir);
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|err| panic!("failed to spawn {cmd:?}: {err}"));
let Child { stdout, stderr, .. } = &mut child;
let stderr = stderr.take().unwrap();
let stderr = BufReader::new(stderr);
let stderr = std::thread::spawn(move || {
for line in stderr.lines() {
let line = line.unwrap();
println!("{line}");
}
});
let stdout = stdout.take().unwrap();
let stdout = BufReader::new(stdout);
let mut executables = Vec::new();
for message in Message::parse_stream(stdout) {
#[allow(clippy::collapsible_match)]
match message.expect("valid JSON") {
Message::CompilerArtifact(Artifact {
executable,
target: Target { name, .. },
..
}) => {
if let Some(executable) = executable {
executables.push((name, executable.into_std_path_buf()));
}
}
Message::CompilerMessage(CompilerMessage { message, .. }) => {
for line in message.rendered.unwrap_or_default().split('\n') {
println!("{line}");
}
}
Message::TextLine(line) => {
println!("{line}");
}
_ => {}
}
}
let status = child
.wait()
.unwrap_or_else(|err| panic!("failed to wait for {cmd:?}: {err}"));
assert_eq!(status.code(), Some(0), "{cmd:?} failed: {status:?}");
stderr.join().map_err(std::panic::resume_unwind).unwrap();
for (name, binary) in executables {
let dst = out_dir.join(name);
let _: u64 =
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
}
} else {
let Package { targets, .. } = ebpf_package;
for Target { name, kind, .. } in targets {
if *kind != [TargetKind::Bin] {
continue;
}
let dst = out_dir.join(name);
fs::write(&dst, []).unwrap_or_else(|err| panic!("failed to create {dst:?}: {err}"));
}
}
}
fn build_egress_ebpf() {
let Metadata { packages, .. } = MetadataCommand::new().no_deps().exec().unwrap();
let ebpf_package = packages
.into_iter()
.find(|Package { name, .. }| **name == "egress-ebpf")
.unwrap();
let out_dir = env::var_os("OUT_DIR").unwrap();
let out_dir = PathBuf::from(out_dir);
let endian = env::var_os("CARGO_CFG_TARGET_ENDIAN").unwrap();
let target = if endian == "big" {
"bpfeb"
} else if endian == "little" {
"bpfel"
} else {
panic!("unsupported endian={:?}", endian)
};
let build_ebpf = true;
if build_ebpf {
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
let target = format!("{target}-unknown-none");
let Package { manifest_path, .. } = ebpf_package;
let ebpf_dir = manifest_path.parent().unwrap();
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
let mut cmd = Command::new("cargo");
cmd.args([
"build",
"-Z",
"build-std=core",
"--bins",
"--message-format=json",
"--release",
"--target",
&target,
]);
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
cmd.env("CARGO_TERM_COLOR", "always");
for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] {
cmd.env_remove(key);
}
cmd.current_dir(ebpf_dir);
let ebpf_target_dir = out_dir.join("../egress-ebpf");
cmd.arg("--target-dir").arg(&ebpf_target_dir);
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|err| panic!("failed to spawn {cmd:?}: {err}"));
let Child { stdout, stderr, .. } = &mut child;
let stderr = stderr.take().unwrap();
let stderr = BufReader::new(stderr);
let stderr = std::thread::spawn(move || {
for line in stderr.lines() {
let line = line.unwrap();
println!("{line}");
}
});
let stdout = stdout.take().unwrap();
let stdout = BufReader::new(stdout);
let mut executables = Vec::new();
for message in Message::parse_stream(stdout) {
#[allow(clippy::collapsible_match)]
match message.expect("valid JSON") {
Message::CompilerArtifact(Artifact {
executable,
target: Target { name, .. },
..
}) => {
if let Some(executable) = executable {
executables.push((name, executable.into_std_path_buf()));
}
}
Message::CompilerMessage(CompilerMessage { message, .. }) => {
for line in message.rendered.unwrap_or_default().split('\n') {
println!("{line}");
}
}
Message::TextLine(line) => {
println!("{line}");
}
_ => {}
}
}
let status = child
.wait()
.unwrap_or_else(|err| panic!("failed to wait for {cmd:?}: {err}"));
assert_eq!(status.code(), Some(0), "{cmd:?} failed: {status:?}");
stderr.join().map_err(std::panic::resume_unwind).unwrap();
for (name, binary) in executables {
let dst = out_dir.join(name);
let _: u64 =
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
}
} else {
let Package { targets, .. } = ebpf_package;
for Target { name, kind, .. } in targets {
if *kind != [TargetKind::Bin] {
continue;
}
let dst = out_dir.join(name);
fs::write(&dst, []).unwrap_or_else(|err| panic!("failed to create {dst:?}: {err}"));
}
}
}
fn build_frontend() {
let _ = dotenvy::dotenv();
// let Some(frontend_dir) = env::var_os("FRONTEND_DIR") else {
// panic!("FRONTEND_DIR environment variable is required but not set");
// };
let project_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let static_dir = project_root.join("static").join("web");
let project_name = project_root.file_name().unwrap().to_string_lossy();
let frontend_dir = project_root
.parent()
.unwrap()
.join(format!("{}-frontend", project_name));
if !frontend_dir.exists() {
panic!("Frontend directory {:?} does not exist", frontend_dir);
}
println!("cargo:rerun-if-changed={}", frontend_dir.join("src").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("public").display());
println!("cargo:rerun-if-changed={}", frontend_dir.join("package.json").display());
println!(
"cargo:rerun-if-changed={}",
frontend_dir.join("package-lock.json").display()
);
println!(
"cargo:rerun-if-changed={}",
frontend_dir.join("next.config.js").display()
);
println!(
"cargo:rerun-if-changed={}",
frontend_dir.join("tailwind.config.js").display()
);
println!(
"cargo:rerun-if-changed={}",
frontend_dir.join("postcss.config.js").display()
);
println!(
"cargo:rerun-if-changed={}",
frontend_dir.join("tsconfig.json").display()
);
let out_dir = frontend_dir.join("out");
let need_build = needs_frontend_rebuild(&frontend_dir, &out_dir, &static_dir);
if !need_build {
return;
}
let mut cmd = Command::new("npm");
cmd.arg("install")
.current_dir(&frontend_dir);
let status = cmd
.status()
.unwrap_or_else(|err| panic!("failed to run npm install: {err}"));
if !status.success() {
panic!("npm install failed with exit code: {:?}", status.code());
}
let mut cmd = Command::new("npx");
cmd.args(["next", "build"])
.current_dir(&frontend_dir);
let status = cmd
.status()
.unwrap_or_else(|err| panic!("failed to run next build: {err}"));
if !status.success() {
panic!("next build failed with exit code: {:?}", status.code());
}
if static_dir.exists() {
fs::remove_dir_all(&static_dir).unwrap_or_else(|err| panic!("failed to remove {:?}: {err}", static_dir));
}
fs::create_dir_all(&static_dir).unwrap_or_else(|err| panic!("failed to create {:?}: {err}", static_dir));
copy_dir_all(&out_dir, &static_dir).unwrap_or_else(|err| panic!("failed to copy frontend build: {err}"));
}
fn needs_frontend_rebuild(frontend_dir: &PathBuf, out_dir: &PathBuf, static_dir: &PathBuf) -> bool {
if !out_dir.exists() {
return true;
}
if !static_dir.exists() {
return true;
}
let out_modified = match fs::metadata(out_dir).and_then(|m| m.modified()) {
Ok(time) => time,
Err(_) => {
return true;
}
};
let static_modified = match fs::metadata(static_dir).and_then(|m| m.modified()) {
Ok(time) => time,
Err(_) => {
return true;
}
};
let essential_items = [
"src",
"public",
"package.json",
"next.config.js",
"tailwind.config.js",
"postcss.config.js",
"tsconfig.json",
"package-lock.json",
];
for item_name in essential_items {
let item_path = frontend_dir.join(item_name);
if !item_path.exists() {
continue;
}
let item_modified = match get_dir_last_modified(&item_path) {
Some(time) => time,
None => continue,
};
if item_modified > out_modified {
return true;
}
}
if out_modified > static_modified {
return true;
}
false
}
fn get_dir_last_modified(path: &PathBuf) -> Option<SystemTime> {
if path.is_file() {
return fs::metadata(path).and_then(|m| m.modified()).ok();
}
if path.is_dir() {
let mut latest = fs::metadata(path).and_then(|m| m.modified()).ok()?;
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
if let Some(modified) = get_dir_last_modified(&entry.path()) {
if modified > latest {
latest = modified;
}
}
}
}
return Some(latest);
}
None
}
fn copy_dir_all(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
fs::create_dir_all(&dst_path)?;
copy_dir_all(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn build_vectorscan_db() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = &manifest_dir.join("static").join("db");
let artifact_dir = &manifest_dir.join("static").join("artifacts");
let rules_dir = &manifest_dir.join("static").join("rules");
println!("cargo:rerun-if-changed={}", rules_dir.display());
if let Ok(rd) = fs::read_dir(&rules_dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("rules") {
println!("cargo:rerun-if-changed={}", p.display());
}
}
}
let sigs = collect_sig_entries(&rules_dir);
let db_path = out_dir.join("rules.db");
write_rules_db(&db_path, &sigs);
println!("cargo:rustc-env=RULES_DB_PATH={}", out_dir.display());
println!("cargo:rustc-env=ARTIFACTCS_PATH={}", artifact_dir.display());
let total_contents: usize = sigs.iter().map(|s| s.chain.len()).sum();
println!(
"cargo:warning=NetGuardia: {} signatures ({} content entries) ready",
sigs.len(),
total_contents
);
}
fn collect_sig_entries(rules_dir: &PathBuf) -> Vec<SigEntry> {
if !rules_dir.exists() {
return Vec::new();
}
let mut paths: Vec<_> = fs::read_dir(rules_dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rules"))
.collect();
paths.sort();
let mut sigs = Vec::new();
for path in paths {
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => continue,
};
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if line.contains("noalert") {
continue;
}
let chain = extract_content_chain(line);
if chain.is_empty() {
continue;
}
let Some(fast_idx) = select_fast_pattern(&chain) else {
continue;
};
sigs.push(SigEntry {
sid: extract_sid(line).unwrap_or(0),
flow_dir: extract_flow_dir(line),
dports: extract_dst_ports(line),
msg: extract_msg(line).unwrap_or_default(),
chain,
fast_pattern_idx: fast_idx,
});
}
}
sigs
}
/// Map a Suricata/Snort buffer keyword to its buffer ID.
/// Handles both new-style (`http.uri`) and old-style (`http_uri`) forms.
fn detect_buffer_keyword(kw: &str) -> Option<u8> {
match kw {
"http.uri" | "http_uri" => Some(BUF_HTTP_URI),
"http.header" | "http_header" => Some(BUF_HTTP_HEADER),
"http.request_body" | "http_client_body" => Some(BUF_HTTP_CLIENT_BODY),
"http.response_body"| "http_server_body" => Some(BUF_HTTP_SERVER_BODY),
"http.method" | "http_method" => Some(BUF_HTTP_METHOD),
"http.user_agent" | "http_user_agent" => Some(BUF_HTTP_USER_AGENT),
"http.host" | "http_host" => Some(BUF_HTTP_HOST),
"http.stat_code" | "http_stat_code" => Some(BUF_HTTP_STAT_CODE),
"http.cookie" | "http_cookie" => Some(BUF_HTTP_COOKIE),
"http.raw_uri" | "http_raw_uri" => Some(BUF_HTTP_RAW_URI),
"tls.sni" | "tls_sni" => Some(BUF_TLS_SNI),
"dns_query" | "dns.query" => Some(BUF_DNS_QUERY),
_ => None,
}
}
/// Tokenise the rule options section into `(keyword, value?)` pairs,
/// splitting on `;` while respecting double-quoted strings.
fn tokenize_options(rule: &str) -> Vec<String> {
let opts_start = rule.find('(').map(|p| p + 1).unwrap_or(0);
let opts_end = rule.rfind(')').unwrap_or(rule.len());
let opts = &rule[opts_start..opts_end.max(opts_start)];
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in opts.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
current.push(ch);
}
';' if !in_quotes => {
let t = current.trim().to_string();
if !t.is_empty() {
tokens.push(t);
}
current.clear();
}
_ => current.push(ch),
}
}
let t = current.trim().to_string();
if !t.is_empty() {
tokens.push(t);
}
tokens
}
fn extract_content_chain(rule: &str) -> Vec<ContentMatch> {
let tokens = tokenize_options(rule);
let mut entries: Vec<ContentMatch> = Vec::new();
let mut sticky_buffer: u8 = BUF_RAW;
let mut i = 0;
while i < tokens.len() {
let tok = tokens[i].as_str();
// New-style sticky buffer keyword (e.g. `http.uri`)
if let Some(buf) = detect_buffer_keyword(tok) {
sticky_buffer = buf;
i += 1;
continue;
}
// content: keyword
let content_rest = if let Some(r) = tok.strip_prefix("content:") {
r
} else {
i += 1;
continue;
};
let negated = content_rest.starts_with('!');
let quoted = if negated { &content_rest[1..] } else { content_rest };
if !quoted.starts_with('"') {
i += 1;
continue;
}
// Unquote: content token already has the full quoted string because
// tokenize_options preserves quotes inside options.
let inner = unquote_content(quoted);
let Some(raw_bytes) = parse_content_bytes(&inner) else {
i += 1;
continue;
};
if raw_bytes.contains(&0u8) || raw_bytes.len() < 2 {
i += 1;
continue;
}
let mut entry = ContentMatch {
pattern: raw_bytes,
nocase: false,
negated,
is_fast_pattern: false,
has_distance: false,
has_within: false,
has_offset: false,
has_depth: false,
distance: 0,
within: 0,
offset: 0,
depth: 0,
buffer: sticky_buffer,
};
i += 1;
// Collect modifier tokens until the next content: or new buffer keyword.
while i < tokens.len() {
let mod_tok = tokens[i].as_str();
// Stop at next content: keyword — it will be handled in the outer loop.
if mod_tok.starts_with("content:") {
break;
}
// Old-style buffer keyword overrides the sticky buffer for this entry.
if let Some(buf) = detect_buffer_keyword(mod_tok) {
entry.buffer = buf;
// A new-style sticky keyword also updates the global sticky state.
if mod_tok.contains('.') {
sticky_buffer = buf;
}
i += 1;
// Stop if new-style (becomes sticky for next content).
if mod_tok.contains('.') { break; }
continue;
}
// Apply other modifiers.
match mod_tok {
"nocase" => entry.nocase = true,
"fast_pattern" => entry.is_fast_pattern = true,
_ => {
if let Some(v) = mod_tok.strip_prefix("distance:") {
if let Ok(n) = v.trim().parse::<i32>() {
entry.distance = n;
entry.has_distance = true;
}
} else if let Some(v) = mod_tok.strip_prefix("within:") {
if let Ok(n) = v.trim().parse::<i32>() {
entry.within = n;
entry.has_within = true;
}
} else if let Some(v) = mod_tok.strip_prefix("offset:") {
if let Ok(n) = v.trim().parse::<u16>() {
entry.offset = n;
entry.has_offset = true;
}
} else if let Some(v) = mod_tok.strip_prefix("depth:") {
if let Ok(n) = v.trim().parse::<u16>() {
entry.depth = n;
entry.has_depth = true;
}
} else if mod_tok.starts_with("fast_pattern:") {
entry.is_fast_pattern = true;
}
}
}
i += 1;
}
entries.push(entry);
}
entries
}
/// Strip the surrounding double-quotes from a content token like `"foo|0d0a|"`.
fn unquote_content(s: &str) -> String {
if s.starts_with('"') && s.len() >= 2 {
// Find the closing unescaped quote.
let inner = &s[1..];
let mut out = String::new();
let mut chars = inner.chars();
loop {
match chars.next() {
None | Some('"') => break,
Some('\\') => {
if let Some(c) = chars.next() {
out.push('\\');
out.push(c);
}
}
Some(c) => out.push(c),
}
}
out
} else {
s.to_string()
}
}
/// Select the fast-pattern index for vectorscan prefilter.
/// Prefers explicit fast_pattern keyword, then the longest non-negated pattern.
fn select_fast_pattern(chain: &[ContentMatch]) -> Option<usize> {
if let Some(idx) = chain.iter().position(|e| e.is_fast_pattern && !e.negated) {
return Some(idx);
}
chain.iter()
.enumerate()
.filter(|(_, e)| !e.negated && e.pattern.len() >= 4)
.max_by_key(|(_, e)| e.pattern.len())
.map(|(i, _)| i)
}
/// Write all signature data to a SQLite rules.db.
///
/// Schema:
/// patterns(id, expression BLOB, nocase) — vectorscan prefilter; id = sig index
/// signatures(id, sid, flow_dir, msg)
/// signature_ports(sig_id, dport)
/// content_entries(sig_id, entry_order, pattern BLOB, nocase, negated,
/// has_distance, has_within, has_offset, has_depth,
/// distance, within, off, depth, buffer)
fn write_rules_db(path: &Path, sigs: &[SigEntry]) {
let _ = fs::remove_file(path);
let conn = rusqlite::Connection::open(path)
.unwrap_or_else(|e| panic!("failed to open rules.db at {path:?}: {e}"));
conn.execute_batch("
CREATE TABLE patterns (
id INTEGER PRIMARY KEY,
expression BLOB NOT NULL,
nocase INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE signatures (
id INTEGER PRIMARY KEY,
sid INTEGER NOT NULL,
flow_dir INTEGER NOT NULL,
msg TEXT NOT NULL
);
CREATE TABLE signature_ports (
sig_id INTEGER NOT NULL,
dport INTEGER NOT NULL
);
CREATE TABLE content_entries (
sig_id INTEGER NOT NULL,
entry_order INTEGER NOT NULL,
pattern BLOB NOT NULL,
nocase INTEGER NOT NULL DEFAULT 0,
negated INTEGER NOT NULL DEFAULT 0,
has_distance INTEGER NOT NULL DEFAULT 0,
has_within INTEGER NOT NULL DEFAULT 0,
has_offset INTEGER NOT NULL DEFAULT 0,
has_depth INTEGER NOT NULL DEFAULT 0,
distance INTEGER NOT NULL DEFAULT 0,
within INTEGER NOT NULL DEFAULT 0,
off INTEGER NOT NULL DEFAULT 0,
depth INTEGER NOT NULL DEFAULT 0,
buffer INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (sig_id, entry_order)
);
").unwrap_or_else(|e| panic!("rules.db schema failed: {e}"));
let tx = conn.unchecked_transaction()
.unwrap_or_else(|e| panic!("rules.db transaction failed: {e}"));
for (sig_idx, sig) in sigs.iter().enumerate() {
let id = sig_idx as i64;
let fp = &sig.chain[sig.fast_pattern_idx];
let expression = regex_escape(&fp.pattern);
tx.execute(
"INSERT INTO patterns (id, expression, nocase) VALUES (?1, ?2, ?3)",
rusqlite::params![id, expression, fp.nocase as i64],
).unwrap();
tx.execute(
"INSERT INTO signatures (id, sid, flow_dir, msg) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![id, sig.sid as i64, sig.flow_dir as i64, &sig.msg],
).unwrap();
for &dport in &sig.dports {
tx.execute(
"INSERT INTO signature_ports (sig_id, dport) VALUES (?1, ?2)",
rusqlite::params![id, dport as i64],
).unwrap();
}
for (order, entry) in sig.chain.iter().enumerate() {
tx.execute(
"INSERT INTO content_entries \
(sig_id, entry_order, pattern, nocase, negated, \
has_distance, has_within, has_offset, has_depth, \
distance, within, off, depth, buffer) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
rusqlite::params![
id,
order as i64,
&entry.pattern,
entry.nocase as i64,
entry.negated as i64,
entry.has_distance as i64,
entry.has_within as i64,
entry.has_offset as i64,
entry.has_depth as i64,
entry.distance as i64,
entry.within as i64,
entry.offset as i64,
entry.depth as i64,
entry.buffer as i64,
],
).unwrap();
}
}
tx.commit().unwrap_or_else(|e| panic!("rules.db commit failed: {e}"));
}
fn extract_sid(rule: &str) -> Option<u32> {
let pos = rule.find("sid:")?;
let rest = &rule[pos + 4..];
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn extract_msg(rule: &str) -> Option<String> {
let pos = rule.find("msg:\"")?;
let rest = &rule[pos + 5..];
let mut out = String::new();
let mut chars = rest.chars();
loop {
match chars.next() {
None | Some('"') => break,
Some('\\') => {
if let Some(c) = chars.next() {
out.push(c);
}
}
Some(c) => out.push(c),
}
}
Some(out)
}
/// Decode a Snort/Suricata content string into raw bytes.
/// Handles `|XX XX|` hex sections and `\;` / `\\` escapes.
fn parse_content_bytes(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'|' => {
let mut hex = String::new();
loop {
match chars.next() {
Some('|') | None => break,
Some(h) => hex.push(h),
}
}
let hex = hex.replace(' ', "");
if hex.len() % 2 != 0 {
return None;
}
for i in (0..hex.len()).step_by(2) {
out.push(u8::from_str_radix(&hex[i..i + 2], 16).ok()?);
}
}
'\\' => match chars.next() {
Some(';') => out.push(b';'),
Some('\\') => out.push(b'\\'),
Some(c) => {
out.push(b'\\');
out.push(c as u8);
}
None => {}
},
c => out.push(c as u8),
}
}
Some(out)
}
/// Escape regex metacharacters so literal byte patterns work in vectorscan.
fn regex_escape(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len() * 2);
for &b in bytes {
if matches!(
b,
b'.' | b'^' | b'$' | b'*' | b'+' | b'?' | b'(' | b')' |
b'[' | b']' | b'{' | b'}' | b'\\' | b'|'
) {
out.push(b'\\');
}
out.push(b);
}
out
}
/// Returns flow direction: 0=any, 1=to_server (ingress), 2=to_client (egress).
fn extract_flow_dir(rule: &str) -> u8 {
let Some(pos) = rule.find("flow:") else { return 0 };
let rest = &rule[pos + 5..];
let seg_end = rest.find(';').unwrap_or(rest.len());
let opts = &rest[..seg_end];
if opts.contains("to_server") { 1 }
else if opts.contains("to_client") { 2 }
else { 0 }
}
/// Parse destination ports from the rule header (token index 6, before the '(').
/// Returns empty Vec for "any" or negation-only groups (= match all).
/// Expands common Suricata/Snort port variables.
fn extract_dst_ports(rule: &str) -> Vec<u16> {
// Rule header ends at '('
let header = match rule.find('(') {
Some(pos) => &rule[..pos],
None => return Vec::new(),
};
// Tokenise the header — we need token index 6 (0-based):
// action proto src_ip src_port direction dst_ip dst_port
let tokens: Vec<&str> = header.split_whitespace().collect();
let port_token = match tokens.get(6) {
Some(t) => *t,
None => return Vec::new(),
};
expand_port_token(port_token)
}
fn expand_port_token(token: &str) -> Vec<u16> {
match token {
// HTTP / Web
"$HTTP_PORTS" => return vec![80, 443, 8080, 8443, 8000, 8888],
"$HTTP_PORTS2" => return vec![80, 8080],
"$HTTPS_PORTS" => return vec![443, 8443],
"$FILE_DATA_PORTS" => return vec![80, 443, 8080, 8443, 110, 143],
"$PROXY_PORTS" => return vec![3128, 8080, 8118, 8888],
// Mail
"$SMTP_PORTS" => return vec![25, 587, 465],
"$IMAP_PORTS" => return vec![143, 993],
"$POP3_PORTS" => return vec![110, 995],
// File transfer / Remote
"$FTP_PORTS" => return vec![21],
"$FTP_DATA" => return vec![20],
"$SSH_PORTS" => return vec![22],
"$TELNET_PORTS" => return vec![23],
"$RDP_PORTS" => return vec![3389],
"$VNC_PORTS" => return vec![5900, 5901, 5902, 5903],
// Database
"$SQL_PORTS" => return vec![3306, 5432, 1433, 1521],
"$ORACLE_PORTS" => return vec![1521, 1526],
"$MSSQL_PORTS" => return vec![1433, 1434],
"$MYSQL_PORTS" => return vec![3306],
"$PGSQL_PORTS" => return vec![5432],
"$MONGODB_PORTS" => return vec![27017, 27018],
"$REDIS_PORTS" => return vec![6379],
"$MEMCACHED_PORTS" => return vec![11211],
"$ELASTICSEARCH_PORTS" => return vec![9200, 9300],
// DNS / Directory
"$DNS_PORTS" => return vec![53],
"$LDAP_PORTS" => return vec![389, 636, 3268, 3269],
"$KERBEROS_PORTS" => return vec![88, 464],
// VoIP / Messaging
"$SIP_PORTS" => return vec![5060, 5061],
"$IRC_PORTS" => return vec![6667, 6668, 6669, 7000],
"$JABBER_PORTS" => return vec![5222, 5223],
"$MSN_PORTS" => return vec![1863],
"$AIM_PORTS" => return vec![5190],
// Network management
"$SNMP_PORTS" => return vec![161, 162],
"$SYSLOG_PORTS" => return vec![514],
// Industrial / SCADA
"$MODBUS_PORTS" => return vec![502],
"$DNP3_PORTS" => return vec![20000],
"$ENIP_PORTS" => return vec![44818],
"$BACnet_PORTS" => return vec![47808],
"$VXLAN_PORTS" => return vec![4789],
"$TEREDO_PORTS" => return vec![3544],
// Shellcode / generic catch-alls
// Suricata default: !80 — we can't enumerate the complement, treat as any
"$SHELLCODE_PORTS" => return Vec::new(),
// Wildcard
"any" | "!any" => return Vec::new(),
_ => {}
}
let token = token; // keep the &str binding
// Any remaining unknown $VAR — treat as any (no port filter) to avoid
// infinite recursion in the group-expansion loop below.
if token.starts_with('$') {
return Vec::new();
}
// Negation-only → treat as any (we can't enumerate the complement)
if token.starts_with('!') && !token.starts_with("![") {
return Vec::new();
}
// Port group: [80,443,!8080] — strip outer brackets
let inner = if token.starts_with('[') && token.ends_with(']') {
&token[1..token.len() - 1]
} else {
token
};
let mut ports = Vec::new();
let mut has_negation_only = true;
for part in inner.split(',') {
let part = part.trim();
if part.is_empty() { continue; }
if part.starts_with('!') {
// negated entry — skip but don't block the group
continue;
}
has_negation_only = false;
// Recurse only for variable names (start with '$') or nested groups
if part.starts_with('$') || part.starts_with('[') {
ports.extend(expand_port_token(part));
continue;
}
// Port range: 1024:2048
if let Some(colon) = part.find(':') {
let lo: u16 = part[..colon].parse().unwrap_or(0);
let hi: u16 = part[colon + 1..].parse().unwrap_or(65535);
// Don't expand huge ranges — treat as any
if hi.saturating_sub(lo) > 1024 {
return Vec::new();
}
for p in lo..=hi { ports.push(p); }
continue;
}
if let Ok(p) = part.parse::<u16>() {
ports.push(p);
}
}
// If the group contained only negations, fall back to any
if has_negation_only && ports.is_empty() {
return Vec::new();
}
ports
}