Add shadow security finding inbox
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s

This commit is contained in:
igor04091968
2026-07-01 06:12:08 +03:00
parent fe87c85a31
commit 757fd3125d
27 changed files with 5524 additions and 19 deletions
-1
View File
@@ -710,7 +710,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"serde_json",
]
[[package]]
+2
View File
@@ -36,6 +36,7 @@ members = [
"crates/aw-rus-healthd",
"crates/detmir-check",
"crates/detmir-core",
"crates/security-finding-inbox",
"crates/dlp-health-check",
"crates/dlp-content-analyzer",
"crates/dlp-admin-cli",
@@ -57,6 +58,7 @@ members = [
"crates/detmir-heal-safe",
"crates/detmir-status",
"crates/detmir-state",
"crates/containment-engine",
"crates/tsj-guardian-status",
"crates/tsj-guardian-watchdog",
]
+4
View File
@@ -176,6 +176,10 @@ fn run() -> Result<i32> {
&root.join("detections/open_cases_from_detections.sql"),
&mut summary,
)?;
let security_inbox_schema = root.join("security/security_finding_inbox.sql");
if security_inbox_schema.exists() {
run_sql_file(&client, &security_inbox_schema, &mut summary)?;
}
if !cli.skip_briefs {
let _ = run_optional_script(&root.join("ops/run_manager_brief.sh"));
let _ = run_optional_script(&root.join("ops/run_recovery_brief.sh"));
@@ -0,0 +1,15 @@
[package]
name = "containment-engine"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,8 @@ reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
urlencoding.workspace = true
sha2.workspace = true
zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tempfile.workspace = true
@@ -1,17 +1,22 @@
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use chrono::Utc;
use clap::Parser;
use fs2::FileExt;
use hayabusa_tools::{guess_host_from_filename, read_json_file};
use hayabusa_tools::{env_bool, env_string, guess_host_from_filename, read_json_file};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use zip::ZipArchive;
const LOCK_PATH: &str = "/opt/hayabusa/state/aw-hayabusa-autoprocess.lock";
const WRAPPER: &str = "/usr/local/bin/aw-hayabusa";
const LINKER: &str = "/usr/local/bin/aw-hayabusa-link-case";
const CASE_ALERT: &str = "/usr/local/bin/aw-hayabusa-case-alert";
const SECURITY_FINDING_INBOX: &str = "/usr/local/bin/security-finding-inbox";
const LATEST_INTAKE: &str = "/opt/hayabusa/state/latest-intake.json";
#[derive(Debug, Parser)]
@@ -20,6 +25,9 @@ struct Cli {
#[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/drop")]
drop_dir: PathBuf,
#[arg(long, default_value = "/opt/hayabusa/quarantine/drop")]
quarantine_dir: PathBuf,
#[arg(long, default_value_t = true)]
once: bool,
}
@@ -65,16 +73,43 @@ fn run() -> Result<i32> {
println!("no zip packages in drop dir");
return Ok(0);
}
let mut operational_failures = 0usize;
for zip_path in zips {
let result = process_one(&zip_path)?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"processed": zip_path.display().to_string(),
"latest_intake": result.latest_intake,
"case_alert": result.case_alert,
}))?
);
if let Err(err) = validate_drop_inputs(&zip_path) {
let quarantine_dir = quarantine_drop_package(&cli.quarantine_dir, &zip_path, &err)?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"quarantined": zip_path.display().to_string(),
"quarantine_dir": quarantine_dir.display().to_string(),
"reason": err.to_string(),
}))?
);
continue;
}
match process_one(&zip_path) {
Ok(result) => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"processed": zip_path.display().to_string(),
"latest_intake": result.latest_intake,
"case_alert": result.case_alert,
"security_finding_ingest": result.security_finding_ingest,
}))?
);
}
Err(err) => {
operational_failures += 1;
eprintln!(
"ERROR: operational failure while processing {}: {err:#}",
zip_path.display()
);
}
}
}
if operational_failures > 0 {
bail!("{operational_failures} operational Hayabusa package failure(s)");
}
Ok(0)
}
@@ -82,6 +117,7 @@ fn run() -> Result<i32> {
struct ProcessResult {
latest_intake: Value,
case_alert: Option<Value>,
security_finding_ingest: Option<Value>,
}
fn list_zips(drop_dir: &Path) -> Result<Vec<PathBuf>> {
@@ -96,6 +132,42 @@ fn list_zips(drop_dir: &Path) -> Result<Vec<PathBuf>> {
Ok(zips)
}
fn validate_drop_inputs(zip_path: &Path) -> Result<()> {
validate_zip_package(zip_path)?;
load_sidecars(zip_path)?;
Ok(())
}
fn validate_zip_package(zip_path: &Path) -> Result<()> {
let file = File::open(zip_path).with_context(|| format!("open {}", zip_path.display()))?;
let mut archive =
ZipArchive::new(file).with_context(|| format!("read zip {}", zip_path.display()))?;
if archive.is_empty() {
bail!("zip package has no entries: {}", zip_path.display());
}
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.with_context(|| format!("read zip entry {index} from {}", zip_path.display()))?;
let name = entry.name().replace('\\', "/");
if name.starts_with('/') || name.split('/').any(|part| part == "..") {
bail!(
"unsafe zip entry in {}: {}",
zip_path.display(),
entry.name()
);
}
io::copy(&mut entry, &mut io::sink()).with_context(|| {
format!(
"test zip entry {} from {}",
entry.name(),
zip_path.display()
)
})?;
}
Ok(())
}
fn process_one(zip_path: &Path) -> Result<ProcessResult> {
let sidecars = load_sidecars(zip_path)?;
let host = guess_host(zip_path, &sidecars);
@@ -123,6 +195,7 @@ fn process_one(zip_path: &Path) -> Result<ProcessResult> {
],
)?;
let latest = read_json_file(Path::new(LATEST_INTAKE))?;
let security_finding_ingest = ingest_security_finding_best_effort(Path::new(LATEST_INTAKE))?;
let report_dir = PathBuf::from(
latest
.get("report_dir")
@@ -154,6 +227,7 @@ fn process_one(zip_path: &Path) -> Result<ProcessResult> {
return Ok(ProcessResult {
latest_intake: latest,
case_alert,
security_finding_ingest,
});
}
run_checked(
@@ -171,9 +245,63 @@ fn process_one(zip_path: &Path) -> Result<ProcessResult> {
Ok(ProcessResult {
latest_intake: latest,
case_alert,
security_finding_ingest,
})
}
fn ingest_security_finding_best_effort(intake_path: &Path) -> Result<Option<Value>> {
if !env_bool("AW_SECURITY_FINDING_INBOX_ENABLED", false) {
return Ok(None);
}
let binary = PathBuf::from(env_string(
"AW_SECURITY_FINDING_INBOX_BIN",
SECURITY_FINDING_INBOX,
));
let required = env_bool("AW_SECURITY_FINDING_INBOX_REQUIRED", false);
if !binary.is_file() {
let message = format!(
"security finding inbox binary not found: {}",
binary.display()
);
if required {
bail!("{message}");
}
eprintln!("WARNING: {message}");
return Ok(Some(json!({"ok": false, "warning": message})));
}
let min_severity = env_string("AW_SECURITY_FINDING_INBOX_MIN_SEVERITY", "medium");
let output = Command::new(&binary)
.arg("ingest-hayabusa")
.arg("--intake")
.arg(intake_path)
.arg("--min-severity")
.arg(min_severity)
.output()
.with_context(|| format!("run {}", binary.display()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let message = format!(
"security finding ingest failed: status={} stderr={}",
output.status,
stderr.trim()
);
if required {
bail!("{message}");
}
eprintln!("WARNING: {message}");
return Ok(Some(json!({"ok": false, "warning": message})));
}
let payload = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
json!({
"ok": true,
"stdout": stdout.trim(),
"stderr": stderr.trim()
})
});
Ok(Some(payload))
}
fn load_sidecars(zip_path: &Path) -> Result<Sidecars> {
let base = zip_path.with_extension("");
let caseid_path = base.with_extension("caseid");
@@ -241,6 +369,108 @@ fn archive_drop_package(report_dir: &Path, zip_path: &Path) -> Result<()> {
Ok(())
}
fn quarantine_drop_package(
quarantine_root: &Path,
zip_path: &Path,
err: &anyhow::Error,
) -> Result<PathBuf> {
fs::create_dir_all(quarantine_root)
.with_context(|| format!("create {}", quarantine_root.display()))?;
let name = zip_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("package.zip");
let stamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let mut quarantine_dir = quarantine_root.join(format!("{stamp}_{}", sanitize_component(name)));
if quarantine_dir.exists() {
quarantine_dir = quarantine_root.join(format!(
"{stamp}_{}_{}",
sanitize_component(name),
std::process::id()
));
}
fs::create_dir_all(&quarantine_dir)
.with_context(|| format!("create {}", quarantine_dir.display()))?;
let sha256 = if zip_path.is_file() {
Some(sha256_file(zip_path)?)
} else {
None
};
move_if_exists(zip_path, &quarantine_dir)?;
let base = zip_path.with_extension("");
for sidecar in [
base.with_extension("caseid"),
base.with_extension("meta.json"),
zip_path.with_extension("zip.sha256"),
] {
move_if_exists(&sidecar, &quarantine_dir)?;
}
let reason = json!({
"quarantined_at": Utc::now().to_rfc3339(),
"source": "aw-hayabusa-autoprocess-rust",
"original_path": zip_path.display().to_string(),
"sha256": sha256,
"reason": err.to_string(),
"detail": format!("{err:#}"),
"operator_action": "inspect source package, re-export EVTX archive if needed, then replay by moving a fixed package back to the drop directory",
});
fs::write(
quarantine_dir.join("reason.json"),
serde_json::to_string_pretty(&reason)?,
)
.with_context(|| format!("write {}", quarantine_dir.join("reason.json").display()))?;
Ok(quarantine_dir)
}
fn move_if_exists(path: &Path, target_dir: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let target = target_dir.join(path.file_name().context("quarantine file name")?);
fs::rename(path, &target)
.or_else(|_| {
fs::copy(path, &target)?;
fs::remove_file(path)
})
.with_context(|| format!("move {} to {}", path.display(), target.display()))?;
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let read = file
.read(&mut buf)
.with_context(|| format!("read {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn sanitize_component(value: &str) -> String {
let clean = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch
} else {
'_'
}
})
.collect::<String>();
if clean.is_empty() {
"package".to_string()
} else {
clean
}
}
fn guess_host(zip_path: &Path, sidecars: &Sidecars) -> Option<String> {
if let Some(host) = &sidecars.host {
if !host.is_empty() {
@@ -302,3 +532,57 @@ fn run_capture(program: &Path, args: &[String]) -> Result<Captured> {
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn invalid_zip_is_rejected_before_accept() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("bad.zip");
fs::write(&zip_path, b"not a zip").unwrap();
let err = validate_drop_inputs(&zip_path).unwrap_err();
assert!(err.to_string().contains("read zip"));
}
#[test]
fn quarantine_moves_package_sidecars_and_writes_reason() {
let dir = tempfile::tempdir().unwrap();
let drop = dir.path().join("drop");
let quarantine = dir.path().join("quarantine");
fs::create_dir_all(&drop).unwrap();
let zip_path = drop.join("HOST-20260624.zip");
fs::write(&zip_path, b"bad").unwrap();
fs::write(drop.join("HOST-20260624.meta.json"), b"{bad").unwrap();
fs::write(drop.join("HOST-20260624.caseid"), b"30").unwrap();
let err = anyhow::anyhow!("bad zip");
let target = quarantine_drop_package(&quarantine, &zip_path, &err).unwrap();
assert!(!zip_path.exists());
assert!(target.join("HOST-20260624.zip").is_file());
assert!(target.join("HOST-20260624.meta.json").is_file());
assert!(target.join("HOST-20260624.caseid").is_file());
let reason = fs::read_to_string(target.join("reason.json")).unwrap();
assert!(reason.contains("bad zip"));
assert!(reason.contains("aw-hayabusa-autoprocess-rust"));
}
#[test]
fn valid_zip_with_backslash_entry_is_accepted_by_precheck() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("ok.zip");
let file = File::create(&zip_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("evtx\\sample.evtx", options).unwrap();
zip.write_all(b"evtx").unwrap();
zip.finish().unwrap();
validate_drop_inputs(&zip_path).unwrap();
}
}
@@ -0,0 +1,20 @@
[package]
name = "security-finding-inbox"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
hayabusa-tools = { path = "../hayabusa-tools" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff