mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
@@ -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,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
236
codex-rs/cli/src/doctor/desktop/macos_security.rs
Normal file
236
codex-rs/cli/src/doctor/desktop/macos_security.rs
Normal 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;
|
||||
67
codex-rs/cli/src/doctor/desktop/macos_security_tests.rs
Normal file
67
codex-rs/cli/src/doctor/desktop/macos_security_tests.rs
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
232
codex-rs/cli/src/doctor/desktop/windows_security.rs
Normal file
232
codex-rs/cli/src/doctor/desktop/windows_security.rs
Normal 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;
|
||||
85
codex-rs/cli/src/doctor/desktop/windows_security_tests.rs
Normal file
85
codex-rs/cli/src/doctor/desktop/windows_security_tests.rs
Normal 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>"
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user