From 401eda64ac72a5f456546bc73e3b96cae2097be2 Mon Sep 17 00:00:00 2001 From: Ilan Harel Date: Mon, 9 Nov 2026 09:19:12 +0200 Subject: [PATCH] fix(diff-logs): ignore OCSF network Refuse (policy DNS) records OpenShell logs a class 4001 / activity 5 "Refuse" record (status_detail policy_dns_ineligible) when policy DNS refuses a name. It has no port or actor, and the connection attempt that follows is logged separately as a denied Open. parse_json_line counted it as a second denial and produced a bogus "manual review: host:?" item. Skip it, matching the shorthand parser, which ignores NET:REFUSE. Closes #69 Signed-off-by: Ilan Harel --- CHANGELOG.md | 1 + src/diff_logs.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df37f0..b674428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ version section and adds the squash-merged PR titles since the previous tag. Ver ## [Unreleased] - chore(deps): build against OpenShell v0.1.7 (`openshell-policy-schema`, `openshell-policy`); docs and generated policy headers now name v0.1.7 +- fix(diff-logs): ignore OCSF Network Activity Refuse records (policy DNS `policy_dns_ineligible`) that have no port, so OCSF JSON and shorthand logs report the same denials ## [0.1.0-rc.3] - 2026-11-09 diff --git a/src/diff_logs.rs b/src/diff_logs.rs index 441d921..778174f 100644 --- a/src/diff_logs.rs +++ b/src/diff_logs.rs @@ -100,11 +100,21 @@ fn strip_query(p: &str) -> String { /// Only network activity (class 4001; activity 4 is a failure) and HTTP activity (class 4002) are used. Returns `None` /// for other classes, records without a destination host or an action, and the binary is dropped unless it is an /// absolute path. +/// +/// Network "Refuse" records (activity 5, e.g. `status_detail: policy_dns_ineligible` when policy DNS refuses a name the +/// policy does not allow) are skipped as well: they carry no port or process, and the connection attempt that follows +/// is logged separately as a denied Open with host, port and binary. The shorthand parser ignores `NET:REFUSE` too. fn parse_json_line(v: &serde_json::Value, source: &str, line: usize) -> Option { let class_uid = v.get("class_uid")?.as_u64()?; if class_uid != 4001 && class_uid != 4002 { return None; } + if class_uid == 4001 + && (v.get("activity_id").and_then(|a| a.as_u64()) == Some(5) + || v.get("status_detail").and_then(|d| d.as_str()) == Some("policy_dns_ineligible")) + { + return None; + } let failed = class_uid == 4001 && v.get("activity_id").and_then(|a| a.as_u64()) == Some(4); let action = match v.get("action").and_then(|a| a.as_str()) { _ if failed => Action::Failed, @@ -949,6 +959,35 @@ garbage line\n"; ); } + #[test] + fn skips_ocsf_network_refuse_records() { + // Real OpenShell v0.1.7 (OCSF 1.8.0) policy-DNS refusal followed by the denied connection it precedes. + let t = concat!( + "{\"class_uid\":4001,\"activity_id\":5,\"activity_name\":\"Refuse\",\"status_detail\":\"policy_dns_ineligible\",", + "\"dst_endpoint\":{\"domain\":\"example.com\"},\"action_id\":2,\"action\":\"Denied\",\"metadata\":{\"version\":\"1.8.0\"}}\n", + "{\"class_uid\":4001,\"activity_id\":1,\"status_detail\":\"policy_dns_ineligible\",\"dst_endpoint\":{\"domain\":\"b.example\"},\"action\":\"Denied\"}\n", + "{\"class_uid\":4001,\"activity_id\":1,\"status_detail\":\"transparent_tcp_policy_denied\",", + "\"dst_endpoint\":{\"domain\":\"example.com\",\"ip\":\"198.18.0.3\",\"port\":443},", + "\"actor\":{\"process\":{\"name\":\"/usr/bin/curl\",\"pid\":0}},\"action_id\":2,\"action\":\"Denied\",\"metadata\":{\"version\":\"1.8.0\"}}\n", + ); + let e = parse_logs(t, "x"); + assert_eq!(e.len(), 1); + assert_eq!( + ( + e[0].action, + e[0].host.as_str(), + e[0].port, + e[0].binary.as_deref() + ), + ( + Action::Denied, + "example.com", + Some(443), + Some("/usr/bin/curl") + ) + ); + } + #[test] fn git_helpers_match_their_parent_binary() { assert!(bin_matches(