Add desktop security enforcement diagnostics (#39067)

## What changed

- Add a `desktop.security.enforcement` doctor check for macOS that assesses the app with Gatekeeper and classifies recent Gatekeeper and XProtect events.
- Add the same check on Windows by inspecting recent Microsoft Defender, AppLocker, and Windows App Control events for Codex executables.
- Report blocked or quarantined executions as failures, audit-only or unavailable evidence as warnings, and include actionable remediation while bounding and redacting collected event details.

## Testing

- Add coverage for platform event classification, trusted executable matching, unavailable diagnostics, remediation, and bounded redacted evidence.

GitOrigin-RevId: 792844390cd2cf92d3bc20e6a0973020b4364e51
This commit is contained in:
chess
2026-08-17 19:46:23 +00:00
committed by copyberry
parent d327527a3d
commit 51a9edc083
5 changed files with 640 additions and 5 deletions

View File

@@ -14,7 +14,11 @@ use chrono::Utc;
use super::CheckStatus;
use super::DoctorCheck;
#[cfg(target_os = "macos")]
mod macos_security;
mod platform;
#[cfg(any(target_os = "windows", test))]
mod windows_security;
const MAX_DIRECTORY_ENTRIES: usize = 256;
const MAX_LOG_FILES: usize = 64;
@@ -55,10 +59,14 @@ pub(super) async fn collect() -> Option<DesktopDiagnostics> {
Ok(None) => return None,
Err(_) => {
return Some(DesktopDiagnostics {
checks: vec![unavailable(
"desktop.app.version",
"the desktop application installation could not be inspected",
)],
checks: vec![
unavailable(
"desktop.app.version",
"the desktop application installation could not be inspected",
),
#[cfg(target_os = "windows")]
windows_security::collect().await,
],
});
}
};
@@ -84,7 +92,14 @@ pub(super) async fn collect() -> Option<DesktopDiagnostics> {
.detail(format!("log directory: {log_directory}"));
Some(DesktopDiagnostics {
checks: vec![application_check, handshake],
checks: vec![
application_check,
handshake,
#[cfg(target_os = "macos")]
macos_security::collect(&application.bundle).await,
#[cfg(target_os = "windows")]
windows_security::collect().await,
],
})
}

View File

@@ -0,0 +1,236 @@
use super::super::CheckStatus;
use super::super::DoctorCheck;
use super::platform::desktop_check;
use std::io;
use std::path::Path;
use std::process::Output;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::time::timeout;
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_OUTPUT_BYTES: usize = 256 * 1024;
const SECURITY_LOG_PREDICATE: &str = concat!(
"(process == 'syspolicyd' OR process == 'AppleSystemPolicy' ",
"OR process BEGINSWITH 'XProtect' OR subsystem BEGINSWITH 'com.apple.syspolicy' ",
"OR subsystem CONTAINS[c] 'XProtect' ",
"OR subsystem == 'com.apple.security.assessment') AND ",
"(eventMessage CONTAINS[c] 'codex' OR eventMessage CONTAINS[c] 'chatgpt' ",
"OR eventMessage CONTAINS[c] '.plugin-appserver' ",
"OR eventMessage CONTAINS[c] '100024' OR eventMessage CONTAINS[c] 'EMFILE' ",
"OR eventMessage CONTAINS[c] 'ENFILE' ",
"OR eventMessage CONTAINS[c] 'too many open files' ",
"OR eventMessage CONTAINS[c] 'Unexpected Xprotect assessment')"
);
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum Evidence {
Clear,
Audit,
Exhausted,
Blocked,
Malware,
Unavailable,
}
pub(super) async fn collect(bundle: &Path) -> DoctorCheck {
let (gatekeeper, events) = tokio::join!(inspect_gatekeeper(bundle), inspect_security_events());
enforcement_check(gatekeeper, events)
}
async fn inspect_gatekeeper(bundle: &Path) -> Evidence {
let mut command = Command::new("/usr/sbin/spctl");
command
.args(["--assess", "--type", "execute", "--verbose=2"])
.arg(bundle);
classify_gatekeeper(run_command(&mut command).await.as_ref())
}
fn classify_gatekeeper(output: Option<&Output>) -> Evidence {
let Some(output) = output.filter(|output| !is_truncated(output)) else {
return Evidence::Unavailable;
};
if output.status.success() {
return Evidence::Clear;
}
let error = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
if error.contains("rejected") || error.contains("no usable signature") {
Evidence::Blocked
} else {
Evidence::Unavailable
}
}
async fn inspect_security_events() -> Evidence {
let mut command = Command::new("/usr/bin/log");
command.args([
"show",
"--last",
"30m",
"--info",
"--style",
"compact",
"--predicate",
SECURITY_LOG_PREDICATE,
]);
let Some(output) = run_command(&mut command)
.await
.filter(|output| output.status.success())
else {
return Evidence::Unavailable;
};
let evidence = classify_security_events(&String::from_utf8_lossy(&output.stdout));
if is_truncated(&output) && evidence < Evidence::Exhausted {
Evidence::Unavailable
} else {
evidence
}
}
fn classify_security_events(output: &str) -> Evidence {
let mut evidence = Evidence::Clear;
for line in output.lines().map(str::to_ascii_lowercase) {
let contains = |values: &[&str]| values.iter().any(|value| line.contains(value));
if !contains(&[
"com.openai.codex",
"codex.app",
"chatgpt.app",
".plugin-appserver",
"codex-command-runner",
]) || contains(&["not blocked", "not denied"])
{
continue;
}
let event = if contains(&["100024", "emfile", "enfile", "too many open files"]) {
Evidence::Exhausted
} else if contains(&["would block", "would deny"]) {
Evidence::Audit
} else if contains(&[
"xp_malware_detected",
"xp_malware_remediated",
"malware detected",
"malware blocked",
"malware removed",
"remediat",
]) {
Evidence::Malware
} else if contains(&[
"blocked",
"denied",
"rejected",
"notarization failed",
"signature invalid",
"execution prevented",
"unexpected xprotect assessment",
"damaged and",
]) {
Evidence::Blocked
} else if contains(&["audit"]) {
Evidence::Audit
} else {
Evidence::Clear
};
evidence = evidence.max(event);
}
evidence
}
fn enforcement_check(gatekeeper: Evidence, events: Evidence) -> DoctorCheck {
let id = "desktop.security.enforcement";
let (status, summary, remedy) = if events == Evidence::Malware {
(
CheckStatus::Fail,
"macos XProtect blocked or remediated the desktop application",
"collect the XProtect detection and ask your security administrator to review the official Codex installation",
)
} else if gatekeeper == Evidence::Blocked {
(
CheckStatus::Fail,
"macos gatekeeper rejected the desktop application",
"ask your security administrator to review the application policy",
)
} else if events == Evidence::Blocked {
(
CheckStatus::Fail,
"a recent macos security event blocked the desktop application",
"ask your security administrator to review the matching prevention event",
)
} else if events == Evidence::Exhausted {
(
CheckStatus::Warning,
"macos system-policy diagnostics indicate file descriptor exhaustion",
"restart your Mac, retry Codex once, and contact support if the problem returns",
)
} else if events == Evidence::Audit {
(
CheckStatus::Warning,
"recent desktop security events are audit-only",
"ask your security administrator to verify the application policy",
)
} else if gatekeeper == Evidence::Unavailable {
(
CheckStatus::Warning,
"the desktop security assessment was unavailable",
"check access to macos gatekeeper diagnostics",
)
} else if events == Evidence::Unavailable {
(
CheckStatus::Warning,
"recent macos security enforcement history was unavailable",
"check access to macos unified security logs and rerun codex doctor",
)
} else {
return desktop_check(
id,
CheckStatus::Ok,
"the desktop application passed available macos security assessments",
)
.detail("gatekeeper: accepted");
};
desktop_check(id, status, summary).remediation(remedy)
}
fn is_truncated(output: &Output) -> bool {
output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES
}
async fn run_command(command: &mut Command) -> Option<Output> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = command.spawn().ok()?;
let (stdout, stderr) = (child.stdout.take()?, child.stderr.take()?);
timeout(PROBE_TIMEOUT, async {
let (stdout, stderr, status) =
tokio::join!(read_bounded(stdout), read_bounded(stderr), child.wait());
Some(Output {
status: status.ok()?,
stdout: stdout.ok()?,
stderr: stderr.ok()?,
})
})
.await
.ok()
.flatten()
}
async fn read_bounded<R: AsyncRead + Unpin>(mut reader: R) -> io::Result<Vec<u8>> {
let mut bytes = Vec::new();
(&mut reader)
.take(MAX_OUTPUT_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.await?;
if bytes.len() > MAX_OUTPUT_BYTES {
tokio::io::copy(&mut reader, &mut tokio::io::sink()).await?;
}
Ok(bytes)
}
#[cfg(test)]
#[path = "macos_security_tests.rs"]
mod tests;

View File

@@ -0,0 +1,67 @@
use super::CheckStatus;
use super::Evidence;
use super::classify_gatekeeper;
use super::classify_security_events;
use super::enforcement_check;
use pretty_assertions::assert_eq;
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
use std::process::Output;
#[test]
fn only_matching_enforced_apple_security_events_are_failures() {
for (event, expected) in [
("denied com.openai.codex", Evidence::Blocked),
(
"denied /Applications/Codex.app/Contents/MacOS/Codex",
Evidence::Blocked,
),
("malware detected ChatGPT.app", Evidence::Malware),
("audit token blocked ChatGPT.app", Evidence::Blocked),
(
"audit token XP_MALWARE_DETECTED ChatGPT.app",
Evidence::Malware,
),
("XP_MALWARE_REMEDIATED ChatGPT.app", Evidence::Malware),
("denied .plugin-appserver", Evidence::Blocked),
("audit would block ChatGPT.app", Evidence::Audit),
("OSStatus 100024 ChatGPT.app", Evidence::Exhausted),
("not blocked ChatGPT.app", Evidence::Clear),
("denied EMFILE com.example.other", Evidence::Clear),
("EMFILE\nmalware detected ChatGPT.app", Evidence::Malware),
] {
assert_eq!(classify_security_events(event), expected, "{event}");
}
}
#[test]
fn gatekeeper_failures_require_actionable_security_evidence() {
for (message, expected) in [
("ChatGPT.app: rejected", Evidence::Blocked),
("operation not permitted", Evidence::Unavailable),
] {
let output = Output {
status: ExitStatus::from_raw(1 << 8),
stdout: Vec::new(),
stderr: message.as_bytes().to_vec(),
};
assert_eq!(classify_gatekeeper(Some(&output)), expected);
}
}
#[test]
fn exhaustion_and_unavailable_history_have_actionable_warnings() {
for (events, remedy) in [
(Evidence::Exhausted, "restart"),
(Evidence::Unavailable, "unified security logs"),
] {
let check = enforcement_check(Evidence::Clear, events);
assert_eq!(check.status, CheckStatus::Warning);
assert!(
check
.remediation
.as_deref()
.is_some_and(|value| value.contains(remedy))
);
}
}

View File

@@ -0,0 +1,232 @@
use std::env;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::time::timeout;
use super::super::CheckStatus;
use super::super::DoctorCheck;
use super::platform::desktop_check;
const MAX_EVENTS_PER_CHANNEL: usize = 256;
const MAX_RENDERED_EVENTS: usize = 16;
const MAX_EVENT_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
type Channel = (&'static str, &'static str, &'static [u32]);
type Evidence = (CheckStatus, String);
const CHANNELS: [Channel; 5] = [
(
"microsoft_defender",
"Microsoft-Windows-Windows Defender/Operational",
&[
1006, 1007, 1116, 1117, 1121, 1122, 1123, 1124, 1125, 1126, 1127, 1128,
],
),
(
"applocker",
"Microsoft-Windows-AppLocker/EXE and DLL",
&[8003, 8004],
),
(
"applocker",
"Microsoft-Windows-AppLocker/Packaged app-Execution",
&[8021, 8022],
),
(
"applocker",
"Microsoft-Windows-AppLocker/MSI and Script",
&[8006, 8007],
),
(
"windows_app_control",
"Microsoft-Windows-CodeIntegrity/Operational",
&[3076, 3077],
),
];
pub(super) async fn collect() -> DoctorCheck {
let Some(system_root) = env::var_os("SystemRoot") else {
return classify(&[]);
};
let wevtutil = Path::new(&system_root).join("System32/wevtutil.exe");
let mut channels = Vec::with_capacity(CHANNELS.len());
for pair in CHANNELS.chunks(/*chunk_size*/ 2) {
let first = query_channel(&wevtutil, pair[0]);
if let Some(second) = pair.get(/*index*/ 1) {
let (first, second) = tokio::join!(first, query_channel(&wevtutil, *second));
channels.extend([first, second]);
} else {
channels.push(first.await);
}
}
classify(&channels)
}
async fn query_channel(wevtutil: &Path, channel: Channel) -> Option<Vec<Evidence>> {
let events = channel
.2
.iter()
.map(|id| format!("EventID={id}"))
.collect::<Vec<_>>()
.join(" or ");
let mut child = Command::new(wevtutil)
.args(["qe", channel.1])
.arg(format!(
"/q:*[System[({events}) and TimeCreated[timediff(@SystemTime) <= 604800000]]]"
))
.arg(format!("/c:{MAX_EVENTS_PER_CHANNEL}"))
.args(["/rd:true", "/f:xml"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.ok()?;
let mut output = Vec::new();
let status = timeout(Duration::from_secs(10), async {
let mut reader = child.stdout.take()?.take(MAX_EVENT_OUTPUT_BYTES as u64 + 1);
reader.read_to_end(&mut output).await.ok()?;
if output.len() > MAX_EVENT_OUTPUT_BYTES {
return None;
}
child.wait().await.ok()
})
.await
.ok()
.flatten()?;
status
.success()
.then(|| parse_events(&String::from_utf8_lossy(&output), channel))
}
fn parse_events(xml: &str, (source, _, ids): Channel) -> Vec<Evidence> {
xml.split("</Event>")
.take(MAX_EVENTS_PER_CHANNEL)
.filter_map(|event| {
let id = element(event, "EventID")?.parse().ok()?;
if !ids.contains(&id) {
return None;
}
let target = event
.split("<Data")
.skip(/*n*/ 1)
.filter_map(|data| data.split_once('>')?.1.split_once("</Data>").map(|v| v.0))
.chain(
["FilePath", "FileName", "Image", "PackageName", "PackageFamilyName"]
.into_iter()
.filter_map(|name| element(event, name)),
)
.find_map(event_target)?;
let timestamp = event.split_once("SystemTime=\"")?.1.split_once('"')?.0;
let (status, action) = match id {
1121 | 1123 | 1126 | 1127 | 8004 | 8007 | 8022 | 3077 => {
(CheckStatus::Fail, "blocked")
}
1122 | 1124 | 1125 | 1128 | 8003 | 8006 | 8021 | 3076 => {
(CheckStatus::Warning, "audited")
}
1006 | 1116 => (CheckStatus::Warning, "detected"),
1007 | 1117 => defender_action(event),
_ => return None,
};
Some((
status,
format!(
"source: {source}; event: {id}; target: {target}; action: {action}; time: {timestamp}"
),
))
})
.collect()
}
fn event_target(value: &str) -> Option<&'static str> {
let value = value.to_ascii_lowercase();
let parts = value.split(['\\', '/']).map(str::trim).collect::<Vec<_>>();
let name = *parts.last()?;
let package = parts.iter().any(|part| part.starts_with("openai.codex_"));
let trusted = package || parts.windows(2).any(|pair| pair == ["openai", "codex"]);
match name {
"codex-windows-sandbox-setup.exe" => Some("sandbox_setup"),
"codex-command-runner.exe" => Some("command_runner"),
"codex.exe" => Some("codex"),
"codex-desktop.exe" => Some("codex_desktop"),
"chatgpt.exe" | "electron.exe" if trusted => Some("codex_desktop"),
"rg.exe" if trusted => Some("ripgrep"),
_ if package => Some("codex_desktop_package"),
_ => None,
}
}
fn defender_action(event: &str) -> (CheckStatus, &'static str) {
let action = [
"Action Name",
"ActionName",
"Action",
"Action ID",
"ActionID",
]
.into_iter()
.find_map(|name| data_value(event, name))
.or_else(|| element(event, "ActionName"))
.unwrap_or_default()
.to_ascii_lowercase();
match action.as_str() {
"quarantine" | "2" => (CheckStatus::Fail, "quarantined"),
"clean" | "remove" | "block" | "1" | "3" | "10" => (CheckStatus::Fail, "blocked"),
"allow" | "ignore" | "none" | "no action" | "user defined" | "6" | "8" | "9" | "11" => {
(CheckStatus::Ok, "allowed")
}
_ => (CheckStatus::Warning, "detected"),
}
}
fn classify(channels: &[Option<Vec<Evidence>>]) -> DoctorCheck {
let visible = channels.iter().any(Option::is_some);
let mut status = if visible {
CheckStatus::Ok
} else {
CheckStatus::Warning
};
let mut details = Vec::new();
for (event_status, detail) in channels.iter().flatten().flatten() {
status = status.max(*event_status);
if details.len() < MAX_RENDERED_EVENTS {
details.push(detail.clone());
}
}
let summary = match (status, visible) {
(CheckStatus::Ok, _) if channels.contains(&None) => "security event coverage is incomplete",
(CheckStatus::Ok, _) => "no locally visible recent Codex security enforcement was found",
(CheckStatus::Warning, false) => "security event channels could not be inspected",
(CheckStatus::Warning, true) => "recent Codex security audit or detection requires review",
(CheckStatus::Fail, _) => "endpoint security blocked or quarantined a Codex executable",
};
let mut check = desktop_check("desktop.security.enforcement", status, summary).details(details);
if status != CheckStatus::Ok {
check = check.remediation(
"ask your organization's security administrator to review endpoint security events and the approved Codex application policy",
);
}
check
}
fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let body = xml.split_once(&format!("<{name}"))?.1.split_once('>')?.1;
Some(body.split_once(&format!("</{name}>"))?.0.trim())
}
fn data_value<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let body = xml
.split_once(&format!("Name=\"{name}\""))?
.1
.split_once('>')?
.1;
Some(body.split_once("</Data>")?.0.trim())
}
#[cfg(test)]
#[path = "windows_security_tests.rs"]
mod tests;

View File

@@ -0,0 +1,85 @@
use pretty_assertions::assert_eq;
use super::CHANNELS;
use super::CheckStatus;
use super::MAX_RENDERED_EVENTS;
use super::classify;
use super::parse_events;
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn an_unavailable_reader_is_not_reported_as_clean() {
assert_eq!(super::collect().await.status, CheckStatus::Warning);
}
#[test]
fn every_source_distinguishes_audits_from_blocks() {
for (channel, audit, block) in [
(0, 1122, 1121),
(0, 1125, 1126),
(1, 8003, 8004),
(2, 8021, 8022),
(3, 8006, 8007),
(4, 3076, 3077),
] {
for (id, expected) in [(audit, CheckStatus::Warning), (block, CheckStatus::Fail)] {
let events = parse_events(&fixture(id, "codex.exe", &[]), CHANNELS[channel]);
assert_eq!(events[0].0, expected, "misclassified event {id}");
}
}
}
#[test]
fn defender_distinguishes_detection_and_remediation() {
for (id, action, expected) in [
(1116, "", CheckStatus::Warning),
(1117, "Allow", CheckStatus::Ok),
(1117, "Quarantine", CheckStatus::Fail),
(1117, "Remove", CheckStatus::Fail),
] {
let events = parse_events(
&fixture(id, "codex.exe", &[("Action Name", action)]),
CHANNELS[0],
);
assert_eq!(classify(&[Some(events)]).status, expected);
}
}
#[test]
fn only_trusted_codex_executables_are_reported() {
for (path, expected) in [
("codex.exe", true),
(r"OpenAI.Codex_2p2nqsd0c76g0\ChatGPT.exe", true),
("evil-codex.exe", false),
(r"C:\Other\ChatGPT.exe", false),
(r"OpenAI.CodexEvil_1\ChatGPT.exe", false),
] {
let events = parse_events(&fixture(/*id*/ 1121, path, &[]), CHANNELS[0]);
assert_eq!(!events.is_empty(), expected, "misclassified {path}");
}
}
#[test]
fn evidence_is_bounded_redacted_and_correctly_classified() {
assert_eq!(classify(&[None]).status, CheckStatus::Warning);
assert_eq!(classify(&[Some(Vec::new()), None]).status, CheckStatus::Ok);
let audits = fixture(/*id*/ 1122, "codex.exe", &[]).repeat(MAX_RENDERED_EVENTS);
let secret = "private-customer-secret";
let block = fixture(/*id*/ 1121, "codex.exe", &[("User", secret)]);
let events = parse_events(&format!("{audits}{block}"), CHANNELS[0]);
let check = classify(&[Some(events)]);
assert_eq!(check.id, "desktop.security.enforcement");
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.details.len(), MAX_RENDERED_EVENTS);
assert!(!serde_json::to_string(&check).unwrap().contains(secret));
}
fn fixture(id: u32, path: &str, fields: &[(&str, &str)]) -> String {
let data = fields
.iter()
.map(|(name, value)| format!("<Data Name=\"{name}\">{value}</Data>"))
.collect::<String>();
format!(
"<Event><System><EventID>{id}</EventID><TimeCreated SystemTime=\"2026-01-01T00:00:00Z\"/></System><EventData><Data Name=\"Path\">{path}</Data>{data}</EventData></Event>"
)
}