mirror of
https://github.com/ParrotXray/Mantis.git
synced 2026-08-24 19:00:27 +09:00
1105 lines
36 KiB
Rust
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
|
|
} |