From 5fef12130cc412c6efa39777625b3a236060ef78 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:55:19 -0400 Subject: [PATCH 1/6] refactor(intune): surface the CCM thread attribute on ImeLine Before: `ImeLine` exposed line number, timestamp, message, and component. The `thread=` attribute was parsed out of every CCM record but kept private on `ParsedImeRecord`, so it reached `LogEntry` and stopped there. Any consumer of `parse_ime_content` that needed to group a sequence of records belonging to one operation had only adjacency to go on. This moves the field onto `ImeLine` and drops the now-duplicated copy from `ParsedImeRecord`, which reads it back through `entry.line.thread`. This is the right seam because thread is record-level evidence, not a derived display value: it belongs on the record projection alongside component, and keeping one owner avoids the same value living in two structs. The upcoming `intune::apps::windows::scripts` reducer needs it to key interleaved AgentExecutor executions without merging on timestamp proximity, which the issue explicitly forbids. This is an additive public field on a published struct. No in-repo consumer constructs `ImeLine` outside tests; those literals are updated here. Verified with `cargo test --locked -p cmtraceopen-parser`: 355 unit and 222 integration tests pass, identical to the pre-change baseline. Refs #359 Co-Authored-By: Claude Opus 5 --- .../src/intune/download_stats.rs | 13 +++++++++++++ .../cmtraceopen-parser/src/intune/event_tracker.rs | 2 ++ .../cmtraceopen-parser/src/intune/guid_registry.rs | 1 + crates/cmtraceopen-parser/src/intune/ime_parser.rs | 14 ++++++++------ .../cmtraceopen-parser/src/intune/policy_parser.rs | 1 + 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/download_stats.rs b/crates/cmtraceopen-parser/src/intune/download_stats.rs index 54de24bd5..566935e18 100644 --- a/crates/cmtraceopen-parser/src/intune/download_stats.rs +++ b/crates/cmtraceopen-parser/src/intune/download_stats.rs @@ -526,6 +526,7 @@ mod tests { timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }, ImeLine { line_number: 2, @@ -533,6 +534,7 @@ mod tests { timestamp_utc: None, message: "Download completed successfully. Content size: 5242880 bytes, speed: 1048576 Bps, Delivery Optimization: 75.5%".to_string(), component: None, + thread: None, }, ]; @@ -551,6 +553,7 @@ mod tests { timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }, ImeLine { line_number: 2, @@ -558,6 +561,7 @@ mod tests { timestamp_utc: None, message: "Content download stalled with no progress for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }, ]; @@ -575,6 +579,7 @@ mod tests { message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890" .to_string(), component: None, + thread: None, }]; let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log", &empty_registry()); @@ -590,6 +595,7 @@ mod tests { timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }, ImeLine { line_number: 2, @@ -597,6 +603,7 @@ mod tests { timestamp_utc: None, message: "Download failed, retrying content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }, ]; @@ -613,6 +620,7 @@ mod tests { timestamp_utc: None, message: "Adding new state transition - From: Install In Progress To: Download In Progress With Event: Download Started.".to_string(), component: None, + thread: None, }]; let downloads = extract_downloads( @@ -631,6 +639,7 @@ mod tests { timestamp_utc: None, message: r#"RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"MaxRetries\":3,\"RetryIntervalInMinutes\":5,\"DownloadStartTimeUTC\":\"\\/Date(-62135578800000)\\/\"}"#.to_string(), component: None, + thread: None, }]; let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log", &empty_registry()); @@ -646,6 +655,7 @@ mod tests { timestamp_utc: None, message: r#"Starting content download RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Contoso App\"}"#.to_string(), component: None, + thread: None, }, ImeLine { line_number: 2, @@ -653,6 +663,7 @@ mod tests { timestamp_utc: None, message: r#"Download completed successfully RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Contoso App\"}"#.to_string(), component: None, + thread: None, }, ]; @@ -674,6 +685,7 @@ mod tests { timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }, ImeLine { line_number: 2, @@ -681,6 +693,7 @@ mod tests { timestamp_utc: None, message: "Download completed successfully. Content size: 5242880 bytes, speed: 1048576 Bps, Delivery Optimization: 75.5%".to_string(), component: None, + thread: None, }, ]; diff --git a/crates/cmtraceopen-parser/src/intune/event_tracker.rs b/crates/cmtraceopen-parser/src/intune/event_tracker.rs index a8619ff93..37c04aef6 100644 --- a/crates/cmtraceopen-parser/src/intune/event_tracker.rs +++ b/crates/cmtraceopen-parser/src/intune/event_tracker.rs @@ -1696,6 +1696,7 @@ mod tests { timestamp_utc: Some(timestamp.to_string()), message: message.to_string(), component: None, + thread: None, } } @@ -1708,6 +1709,7 @@ mod tests { timestamp_utc: Some("2026-03-12T11:16:42.332Z".to_string()), message: "Assignment evaluation failed for app with id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, + thread: None, }], "C:/Logs/AppActionProcessor.log", &empty_registry(), diff --git a/crates/cmtraceopen-parser/src/intune/guid_registry.rs b/crates/cmtraceopen-parser/src/intune/guid_registry.rs index fb3829102..5de5274cc 100644 --- a/crates/cmtraceopen-parser/src/intune/guid_registry.rs +++ b/crates/cmtraceopen-parser/src/intune/guid_registry.rs @@ -435,6 +435,7 @@ mod tests { timestamp_utc: None, message: msg.to_string(), component: None, + thread: None, } } diff --git a/crates/cmtraceopen-parser/src/intune/ime_parser.rs b/crates/cmtraceopen-parser/src/intune/ime_parser.rs index ebb3b6f59..c1f8534cd 100644 --- a/crates/cmtraceopen-parser/src/intune/ime_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/ime_parser.rs @@ -14,6 +14,9 @@ pub struct ImeLine { pub timestamp_utc: Option, pub message: String, pub component: Option, + /// CCM `thread=` attribute. Consumers that correlate a sequence of records + /// belonging to one operation need this to avoid joining interleaved work. + pub thread: Option, } fn ime_record_re() -> &'static Regex { @@ -40,7 +43,6 @@ struct ParsedImeRecord { timestamp_millis: Option, timestamp_display: Option, severity: Severity, - thread: Option, thread_display: Option, source_file: Option, timezone_offset: Option, @@ -78,7 +80,7 @@ pub fn parse_ime_entries(content: &str, file_path: &str) -> (Vec, u32) timestamp: entry.timestamp_millis, timestamp_display: entry.timestamp_display, severity: entry.severity, - thread: entry.thread, + thread: entry.line.thread, thread_display: entry.thread_display, source_file: entry.source_file, format: entry.format, @@ -254,11 +256,11 @@ fn parse_record( timestamp_utc: line_timestamp_utc, message, component, + thread, }, timestamp_millis, timestamp_display, severity, - thread, thread_display, source_file, timezone_offset, @@ -596,11 +598,11 @@ fn push_unmatched_segment( timestamp_utc: None, message: trimmed.to_string(), component: None, + thread: None, }, timestamp_millis: None, timestamp_display: None, severity: detect_severity_from_text(trimmed), - thread: None, thread_display: None, source_file: None, timezone_offset: None, @@ -635,11 +637,11 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { timestamp_utc, message: message.clone(), component: None, + thread: None, }, timestamp_millis: None, timestamp_display: timestamp, severity: detect_severity_from_text(&message), - thread: None, thread_display: None, source_file: None, timezone_offset: None, @@ -653,11 +655,11 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { timestamp_utc: None, message: trimmed.to_string(), component: None, + thread: None, }, timestamp_millis: None, timestamp_display: None, severity: detect_severity_from_text(trimmed), - thread: None, thread_display: None, source_file: None, timezone_offset: None, diff --git a/crates/cmtraceopen-parser/src/intune/policy_parser.rs b/crates/cmtraceopen-parser/src/intune/policy_parser.rs index d7b5ef7fd..5189daa92 100644 --- a/crates/cmtraceopen-parser/src/intune/policy_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/policy_parser.rs @@ -238,6 +238,7 @@ mod tests { timestamp_utc: None, message: msg.to_string(), component: Some("AppWorkload".to_string()), + thread: None, } } From 765a385f6c618a08faa2ef534f5e2bfa02530126 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:10:08 -0400 Subject: [PATCH 2/6] feat(intune): add the platform-script execution analyzer Before: Intune platform-script evidence had no owner. `intune/event_tracker.rs` recognised an `AgentExecutor` source kind and emitted loose events from it, but there was no lifecycle: no way to say whether a policy was received, whether it was launched, whether it exited, or whether the result ever reached the service. A nonzero exit code was just a severity keyword in a line. This adds `cmtraceopen_parser::intune::apps::windows::scripts`, a semantic analyzer over supplied IME evidence: - `sources.rs` classifies each artifact from its name *and* the CCM components actually inside it, so a file called `AgentExecutor.log` full of unrelated records stays `Unknown` and shows up as coverage rather than as evidence; - `rules.rs` classifies one record at a time and decides nothing; - `reducer.rs` groups records into transactions keyed on policy id, run id, and execution context, and reduces each to a state, a last confirmed phase, a typed exit token, an attempt count, a confidence, and the smallest next artifact worth asking for; - `redaction.rs` masks UPNs, user profile segments, and command lines with a stable FNV-1a token, leaving the policy/run GUIDs a reader needs to follow the correlation. This is the right seam because platform scripts, remediations, and Win32 installers reach terminal states for different reasons and must not share one state machine. `HealthScripts` records are recognised as a distinct source and never classified into a platform-script signal; issue #360 owns that lifecycle. Two keying decisions are worth review. Within one AgentExecutor artifact, records inherit the key of their own execution block -- a contiguous run on one CCM thread starting at a launch record -- because that block is written by a single execution. Blocks never span artifacts, so an execution split across a rotation boundary leaves a visible unkeyed tail instead of a guessed join. Separately, a record naming only a policy may adopt a run id when that policy has exactly one run in the supplied evidence; with two or more it keeps its partial key. Neither rule ever merges on timestamp, display name, or a shared `AgentExecutor` component. Verified from the repository root: - `cargo test --locked -p cmtraceopen-parser`: 392 unit (was 355), 222 esp integration, 18 new `intune_windows_scripts` integration, 1 doc test; 0 failed. - `cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings`: clean. The 15 scenarios required by the issue are present under `tests/fixtures/intune/windows/scripts/`, each with a manifest, its evidence, and a written-out `expected.json`. `success-device-context` additionally carries `expected-full.json`, a golden of the complete redacted export that pins the serialized camelCase shape. Closes #359 Co-Authored-By: Claude Opus 5 --- .../cmtraceopen-parser/src/intune/apps/mod.rs | 7 + .../src/intune/apps/windows/mod.rs | 6 + .../src/intune/apps/windows/scripts/mod.rs | 61 ++ .../src/intune/apps/windows/scripts/models.rs | 255 ++++++++ .../intune/apps/windows/scripts/redaction.rs | 194 ++++++ .../intune/apps/windows/scripts/reducer.rs | 577 +++++++++++++++++ .../src/intune/apps/windows/scripts/rules.rs | 611 ++++++++++++++++++ .../intune/apps/windows/scripts/sources.rs | 232 +++++++ crates/cmtraceopen-parser/src/intune/mod.rs | 1 + .../AgentExecutor.log | 3 + .../IntuneManagementExtension.log | 3 + .../expected.json | 22 + .../manifest.json | 17 + .../scripts/launch-failure/AgentExecutor.log | 3 + .../scripts/launch-failure/expected.json | 24 + .../scripts/launch-failure/manifest.json | 11 + .../multiline-ccm-record/AgentExecutor.log | 6 + .../multiline-ccm-record/expected.json | 28 + .../multiline-ccm-record/manifest.json | 11 + .../AgentExecutor.log | 4 + .../nonzero-exit-with-output/expected.json | 24 + .../nonzero-exit-with-output/manifest.json | 11 + .../AgentExecutor.log | 3 + .../nonzero-exit-without-output/expected.json | 24 + .../nonzero-exit-without-output/manifest.json | 11 + .../IntuneManagementExtension.log | 2 + .../expected.json | 24 + .../manifest.json | 11 + .../privacy-redaction/AgentExecutor.log | 5 + .../IntuneManagementExtension.log | 1 + .../scripts/privacy-redaction/expected.json | 30 + .../scripts/privacy-redaction/manifest.json | 17 + .../retries-exhausted/AgentExecutor.log | 9 + .../IntuneManagementExtension.log | 2 + .../scripts/retries-exhausted/expected.json | 22 + .../scripts/retries-exhausted/manifest.json | 17 + .../rotation-boundary/AgentExecutor-1.log | 2 + .../rotation-boundary/AgentExecutor.log | 1 + .../scripts/rotation-boundary/expected.json | 24 + .../scripts/rotation-boundary/manifest.json | 17 + .../success-device-context/AgentExecutor.log | 3 + .../IntuneManagementExtension.log | 4 + .../success-device-context/expected-full.json | 278 ++++++++ .../success-device-context/expected.json | 22 + .../success-device-context/manifest.json | 17 + .../success-user-context/AgentExecutor.log | 4 + .../IntuneManagementExtension.log | 3 + .../success-user-context/expected.json | 22 + .../success-user-context/manifest.json | 17 + .../AgentExecutor.log | 12 + .../three-retries-then-success/expected.json | 24 + .../three-retries-then-success/manifest.json | 11 + .../windows/scripts/timeout/AgentExecutor.log | 3 + .../windows/scripts/timeout/expected.json | 24 + .../windows/scripts/timeout/manifest.json | 11 + .../two-scripts-same-minute/AgentExecutor.log | 6 + .../two-scripts-same-minute/expected.json | 37 ++ .../two-scripts-same-minute/manifest.json | 11 + .../unknown-agent-version/AgentExecutor.log | 3 + .../unknown-agent-version/expected.json | 24 + .../unknown-agent-version/manifest.json | 11 + .../tests/intune_windows_scripts.rs | 353 ++++++++++ 62 files changed, 3233 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/intune/apps/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs create mode 100644 crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor-1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/intune_windows_scripts.rs diff --git a/crates/cmtraceopen-parser/src/intune/apps/mod.rs b/crates/cmtraceopen-parser/src/intune/apps/mod.rs new file mode 100644 index 000000000..0105733b2 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/mod.rs @@ -0,0 +1,7 @@ +//! Canonical, evidence-backed analyzers for Intune app and script workloads. +//! +//! Each leaf owns one workload lifecycle. They deliberately do not share a +//! single "app" state machine, because a platform script, a remediation pair, +//! and a Win32 installer reach terminal states for different reasons. + +pub mod windows; diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/mod.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/mod.rs new file mode 100644 index 000000000..11de2d83e --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/mod.rs @@ -0,0 +1,6 @@ +//! Windows-side Intune workload analyzers. +//! +//! These modules are pure: they consume artifacts the caller already read and +//! decoded, and they never touch the filesystem, registry, or network. + +pub mod scripts; diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs new file mode 100644 index 000000000..20f64f7bb --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs @@ -0,0 +1,61 @@ +//! Intune Windows platform-script execution evidence. +//! +//! This is a **semantic analyzer over supplied IME evidence**, not a log +//! format. Raw records are framed by the shared CCM parser first +//! ([`crate::intune::ime_parser`]); only then are signals classified and +//! reduced. The module performs no I/O of any kind. +//! +//! Platform scripts have a deliberately separate public lifecycle from +//! remediations and from Win32 installers, because their phases and terminal +//! semantics differ. A `HealthScripts` record is remediation evidence and is +//! never classified into a platform-script signal here. +//! +//! What the analyzer will not do: +//! +//! - promote a nonzero exit code to a root cause; it is an execution outcome; +//! - let a timeout or exit record terminate a transaction it cannot be keyed to; +//! - merge two executions on timestamp, display name, or a shared +//! `AgentExecutor` component; +//! - treat a missing output artifact as proof that a script produced no output. +//! +//! ``` +//! use cmtraceopen_parser::intune::apps::windows::scripts::{ +//! analyze_script_bundle, ScriptSourceInput, +//! }; +//! +//! let agent_executor = concat!( +//! r#""#, +//! "\n", +//! r#""#, +//! "\n", +//! r#""#, +//! ); +//! +//! let analysis = analyze_script_bundle(&[ScriptSourceInput { +//! artifact_id: "agent-executor".to_string(), +//! file_name: "AgentExecutor.log".to_string(), +//! file_path: None, +//! content: agent_executor.to_string(), +//! }]); +//! +//! assert_eq!(analysis.transactions.len(), 1); +//! assert_eq!( +//! analysis.transactions[0].key.policy_id, +//! "11111111-2222-3333-4444-555555555555" +//! ); +//! ``` + +mod models; +mod redaction; +mod reducer; +mod rules; +mod sources; + +pub use models::*; +pub use redaction::{redact_text, redacted_export_projection}; +pub use reducer::analyze_script_bundle; +pub use rules::{classify_record, RecordClassification}; +pub use sources::{classify_artifact, ScriptSourceInput}; diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs new file mode 100644 index 000000000..3f8fb4d5e --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs @@ -0,0 +1,255 @@ +//! Public types for Intune Windows platform-script execution evidence. +//! +//! These types describe *what the evidence showed*, not what the parser guessed. +//! Every state that implies an outcome is only reachable from an explicit record; +//! everything else lands in [`ScriptState::InsufficientEvidence`] plus a coverage +//! request naming the smallest artifact that would resolve it. + +use serde::{Deserialize, Serialize}; + +/// Whether a captured string may be exported as-is. +/// +/// `Sensitive` values are masked by the redacted export projection. The value is +/// still retained in memory so an interactive, consenting operator can see it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptSensitivity { + Public, + Sensitive, +} + +/// A string carrying its own privacy classification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptClassifiedString { + pub value: String, + pub sensitivity: ScriptSensitivity, +} + +impl ScriptClassifiedString { + pub fn public(value: impl Into) -> Self { + Self { + value: value.into(), + sensitivity: ScriptSensitivity::Public, + } + } + + pub fn sensitive(value: impl Into) -> Self { + Self { + value: value.into(), + sensitivity: ScriptSensitivity::Sensitive, + } + } +} + +/// A source timestamp. The original text is always preserved; the UTC form is +/// only populated when the source carried an offset we could resolve, so +/// ordering never silently invents a timezone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptTimestamp { + pub raw_text: String, + pub original_offset: Option, + pub normalized_utc: Option, +} + +/// A pointer back to the exact record a conclusion came from. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptEvidenceRef { + pub artifact_id: String, + pub record_number: u32, + pub line_number: Option, +} + +/// Which supplied artifact a record came from. +/// +/// `HealthScripts` is deliberately present but supplemental: remediations own +/// that lifecycle (issue #360), and a `HealthScripts` record may only join a +/// platform-script transaction when it explicitly names the handoff. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptSourceKind { + IntuneManagementExtension, + AgentExecutor, + HealthScripts, + ScriptOutput, + PolicyMetadata, + Unknown, +} + +/// The context a script was launched in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptExecutionContext { + System, + User, + Unknown, +} + +/// PowerShell host bitness, retained only when a record states it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptInterpreterBitness { + Bit32, + Bit64, + Unknown, +} + +/// Lifecycle phases, ordered. `last_confirmed_phase` is the furthest phase with +/// direct evidence -- not the furthest phase we assume was reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptPhase { + PolicyReceived, + Scheduled, + Launched, + Executed, + Reported, +} + +/// Terminal or in-flight state of one script transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptState { + PolicyReceived, + Scheduled, + Launched, + ExitedZero, + ExitedNonZero, + FailedToLaunch, + TimedOut, + Retried, + ReportSubmitted, + ReportFailed, + InsufficientEvidence, +} + +/// Confidence in the reduced state, kept separate from severity and from cause. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptConfidence { + High, + Medium, + Low, +} + +/// An exit/error token exactly as the source wrote it. +/// +/// A nonzero exit is an execution *outcome*. This type deliberately carries no +/// interpretation of what the code means. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptExitToken { + pub raw_text: String, + pub decimal: Option, + pub hex_text: Option, +} + +/// A classified record-level signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScriptSignal { + PolicyReceived, + Scheduled, + LaunchAttempted, + LaunchFailed, + ExecutionCompleted, + ExecutionTimedOut, + OutputCaptured, + RetryScheduled, + ReportSubmitted, + ReportFailed, + Unclassified, +} + +/// One supplied artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptArtifact { + pub artifact_id: String, + pub file_name: String, + /// Full path is privacy-sensitive: it commonly contains a user profile name. + pub file_path: Option, + pub source_kind: ScriptSourceKind, + /// Rotation ordinal when the file name identifies one; 0 is the live file. + pub rotation_ordinal: Option, +} + +/// One classified record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptObservation { + pub observation_id: String, + pub evidence: ScriptEvidenceRef, + pub source_kind: ScriptSourceKind, + pub timestamp: Option, + pub signal: ScriptSignal, + pub policy_id: Option, + pub run_id: Option, + pub context: ScriptExecutionContext, + pub bitness: ScriptInterpreterBitness, + pub attempt: Option, + pub exit_token: Option, + /// Verbatim record text. Sensitive because IME records quote command lines, + /// UPNs, and captured stdout/stderr. + pub message: ScriptClassifiedString, +} + +/// The identity a transaction is keyed on. +/// +/// Two records merge only when this key matches. Timestamp proximity, display +/// name, and a bare `AgentExecutor` component are explicitly not part of it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptTransactionKey { + pub policy_id: String, + pub run_id: Option, + pub context: ScriptExecutionContext, +} + +/// A reduced platform-script execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptTransaction { + pub key: ScriptTransactionKey, + pub display_name: Option, + pub bitness: ScriptInterpreterBitness, + /// Observation ids in source order. + pub observations: Vec, + pub last_confirmed_phase: Option, + pub state: ScriptState, + pub exit_token: Option, + /// Number of distinct launch attempts proven by evidence. + pub attempts: u32, + pub confidence: ScriptConfidence, + pub evidence: Vec, + /// The smallest artifact that would advance this diagnosis. + pub next_evidence_request: Option, +} + +/// What the supplied bundle did and did not cover. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptCoverage { + pub artifacts: Vec, + /// Records that parsed but carried no platform-script signal. + pub unclassified_records: u32, + /// Expected-but-absent sources, as artifact file names. + pub missing_expected_sources: Vec, + /// True when a record matched a script shape we do not have a version rule + /// for. Callers must treat affected transactions as lower confidence. + pub unknown_version_observed: bool, +} + +/// The public result of reducing a platform-script bundle. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptAnalysis { + pub transactions: Vec, + pub observations: Vec, + /// Signals that could not be keyed to a policy. These can never terminate a + /// transaction; they are surfaced so the gap stays visible. + pub unkeyed_observations: Vec, + pub coverage: ScriptCoverage, +} diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs new file mode 100644 index 000000000..86fe4cf0f --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs @@ -0,0 +1,194 @@ +//! Deterministic privacy projection for platform-script evidence. +//! +//! Platform-script records quote UPNs, user profile paths, and command lines. +//! The export projection masks those spans while leaving the diagnostic grammar +//! -- signals, phases, exit codes, and the synthetic policy/run GUIDs the +//! transaction is keyed on -- intact, because removing those would destroy the +//! very correlation the export exists to show. +//! +//! Masking is a pure function of the masked text, so the same input always +//! produces the same token and two records that mentioned the same user still +//! visibly mention the same user. The projection is idempotent: replacement +//! tokens cannot themselves match a rule. + +use std::sync::OnceLock; + +use regex::Regex; + +use super::models::{ + ScriptAnalysis, ScriptArtifact, ScriptClassifiedString, ScriptObservation, ScriptSensitivity, +}; + +/// FNV-1a. Stable across runs and platforms, which `DefaultHasher` is not. +fn stable_token(kind: &str, value: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("[{kind}:{:016x}]", hash) +} + +fn upn_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") + .expect("UPN regex must compile") + }) +} + +/// The profile segment of a user path, in either slash direction. +fn user_path_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + // The leading `[` exclusion is what makes the projection idempotent: + // an already-masked `[user:...]` segment must not be masked again. + Regex::new(r"(?i)(?P[\\/]Users[\\/])(?P[^\\/\s\x22\[][^\\/\s\x22]*)") + .expect("user path regex must compile") + }) +} + +/// A PowerShell command line supplied inline. +fn command_line_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(?i)(?P-(?:Command|EncodedCommand)\s+)(?P\S.*)$") + .expect("command line regex must compile") + }) +} + +/// Mask the sensitive spans inside a free-text value. +pub fn redact_text(value: &str) -> String { + let masked = upn_re().replace_all(value, |caps: ®ex::Captures<'_>| { + stable_token("upn", &caps[0]) + }); + + let masked = user_path_re().replace_all(&masked, |caps: ®ex::Captures<'_>| { + format!("{}{}", &caps["prefix"], stable_token("user", &caps["user"])) + }); + + command_line_re() + .replace_all(&masked, |caps: ®ex::Captures<'_>| { + let value = &caps["value"]; + // Already masked: re-masking would hash the token and break + // idempotence. + if value.starts_with("[command:") && value.ends_with(']') { + return format!("{}{}", &caps["flag"], value); + } + format!("{}{}", &caps["flag"], stable_token("command", value)) + }) + .into_owned() +} + +fn redact_classified(value: &ScriptClassifiedString) -> ScriptClassifiedString { + match value.sensitivity { + ScriptSensitivity::Public => value.clone(), + ScriptSensitivity::Sensitive => ScriptClassifiedString { + value: redact_text(&value.value), + sensitivity: ScriptSensitivity::Sensitive, + }, + } +} + +fn redact_artifact(artifact: &ScriptArtifact) -> ScriptArtifact { + ScriptArtifact { + file_path: artifact.file_path.as_ref().map(redact_classified), + ..artifact.clone() + } +} + +fn redact_observation(observation: &ScriptObservation) -> ScriptObservation { + ScriptObservation { + message: redact_classified(&observation.message), + ..observation.clone() + } +} + +/// Project an analysis into its default-safe export form. +/// +/// Everything a transaction is keyed on survives. Only classified-sensitive +/// text is masked. +pub fn redacted_export_projection(analysis: &ScriptAnalysis) -> ScriptAnalysis { + let mut coverage = analysis.coverage.clone(); + coverage.artifacts = coverage.artifacts.iter().map(redact_artifact).collect(); + + ScriptAnalysis { + transactions: analysis + .transactions + .iter() + .map(|transaction| { + let mut redacted = transaction.clone(); + redacted.display_name = transaction.display_name.as_ref().map(redact_classified); + redacted + }) + .collect(), + observations: analysis + .observations + .iter() + .map(redact_observation) + .collect(), + unkeyed_observations: analysis.unkeyed_observations.clone(), + coverage, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upn_is_masked_deterministically() { + let first = redact_text("Running as adele.vance@contoso.example"); + let second = redact_text("Reported for adele.vance@contoso.example"); + assert!(!first.contains("adele.vance")); + let token = first.split_whitespace().last().unwrap(); + assert!(second.contains(token), "same UPN must yield the same token"); + } + + #[test] + fn different_users_get_different_tokens() { + let a = redact_text("adele.vance@contoso.example"); + let b = redact_text("alex.wilber@contoso.example"); + assert_ne!(a, b); + } + + #[test] + fn user_profile_segment_is_masked_but_the_path_shape_survives() { + let redacted = redact_text(r"C:\Users\adele.vance\AppData\Local\Temp\out.txt"); + assert!(!redacted.contains("adele.vance")); + assert!(redacted.starts_with(r"C:\Users\")); + assert!(redacted.ends_with(r"\AppData\Local\Temp\out.txt")); + } + + #[test] + fn command_line_value_is_masked() { + let redacted = redact_text("powershell.exe -Command Set-Secret -Value hunter2"); + assert!(!redacted.contains("hunter2")); + assert!(redacted.contains("-Command ")); + } + + #[test] + fn policy_guids_survive_redaction() { + let guid = "11111111-2222-3333-4444-555555555555"; + let redacted = redact_text(&format!( + r"C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\{guid}_{guid}.ps1" + )); + assert!(redacted.contains(guid), "correlation keys must not be lost"); + } + + #[test] + fn projection_is_idempotent() { + let once = redact_text(r"adele.vance@contoso.example at C:\Users\adele.vance\a.ps1"); + let twice = redact_text(&once); + assert_eq!(once, twice); + } + + #[test] + fn public_values_are_left_alone() { + let value = ScriptClassifiedString::public("adele.vance@contoso.example"); + assert_eq!( + redact_classified(&value).value, + "adele.vance@contoso.example" + ); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs new file mode 100644 index 000000000..43f87dd65 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs @@ -0,0 +1,577 @@ +//! Reduction of classified platform-script records into keyed transactions. +//! +//! Two records join a transaction only when they carry the same +//! [`ScriptTransactionKey`]. Within the AgentExecutor log a record may inherit +//! the key of its own execution block -- a contiguous run of records on one +//! CCM thread -- because that block is written by one execution. It may never +//! inherit a key across threads, across artifacts, or from a neighbouring +//! timestamp. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::intune::ime_parser::parse_ime_content; + +use super::models::{ + ScriptAnalysis, ScriptArtifact, ScriptClassifiedString, ScriptConfidence, ScriptCoverage, + ScriptEvidenceRef, ScriptExecutionContext, ScriptExitToken, ScriptInterpreterBitness, + ScriptObservation, ScriptPhase, ScriptSignal, ScriptSourceKind, ScriptState, ScriptTimestamp, + ScriptTransaction, ScriptTransactionKey, +}; +use super::rules::{classify_record, RecordClassification}; +use super::sources::{classify_artifact, ScriptSourceInput}; + +/// Lifecycle rank. A later-ranked signal replaces an earlier one; equal ranks +/// resolve to the later record, which is how a retry sequence lands on its +/// final attempt instead of its first. +fn state_rank(state: ScriptState) -> u8 { + match state { + ScriptState::InsufficientEvidence => 0, + ScriptState::PolicyReceived => 1, + ScriptState::Scheduled => 2, + ScriptState::Retried => 3, + ScriptState::Launched => 4, + ScriptState::FailedToLaunch => 5, + ScriptState::TimedOut => 6, + ScriptState::ExitedZero | ScriptState::ExitedNonZero => 7, + ScriptState::ReportSubmitted | ScriptState::ReportFailed => 8, + } +} + +fn phase_for(signal: ScriptSignal) -> Option { + match signal { + ScriptSignal::PolicyReceived => Some(ScriptPhase::PolicyReceived), + ScriptSignal::Scheduled | ScriptSignal::RetryScheduled => Some(ScriptPhase::Scheduled), + ScriptSignal::LaunchAttempted | ScriptSignal::LaunchFailed => Some(ScriptPhase::Launched), + ScriptSignal::ExecutionCompleted + | ScriptSignal::ExecutionTimedOut + | ScriptSignal::OutputCaptured => Some(ScriptPhase::Executed), + ScriptSignal::ReportSubmitted | ScriptSignal::ReportFailed => Some(ScriptPhase::Reported), + ScriptSignal::Unclassified => None, + } +} + +fn state_for(signal: ScriptSignal, exit_token: Option<&ScriptExitToken>) -> Option { + match signal { + ScriptSignal::PolicyReceived => Some(ScriptState::PolicyReceived), + ScriptSignal::Scheduled => Some(ScriptState::Scheduled), + ScriptSignal::RetryScheduled => Some(ScriptState::Retried), + ScriptSignal::LaunchAttempted => Some(ScriptState::Launched), + ScriptSignal::LaunchFailed => Some(ScriptState::FailedToLaunch), + ScriptSignal::ExecutionTimedOut => Some(ScriptState::TimedOut), + ScriptSignal::ExecutionCompleted => match exit_token.and_then(|token| token.decimal) { + Some(0) => Some(ScriptState::ExitedZero), + // A completion record whose code we could not read is still a + // completion, but it cannot claim success. + Some(_) | None => Some(ScriptState::ExitedNonZero), + }, + ScriptSignal::ReportSubmitted => Some(ScriptState::ReportSubmitted), + ScriptSignal::ReportFailed => Some(ScriptState::ReportFailed), + ScriptSignal::OutputCaptured | ScriptSignal::Unclassified => None, + } +} + +/// A classified record plus where it came from, before keying. +struct PendingRecord { + artifact_index: usize, + artifact_id: String, + source_kind: ScriptSourceKind, + record_number: u32, + line_number: Option, + thread: Option, + timestamp: Option, + message: String, + classification: RecordClassification, + /// Assigned during block resolution for AgentExecutor records. + resolved_policy_id: Option, + resolved_run_id: Option, + resolved_context: ScriptExecutionContext, +} + +impl PendingRecord { + fn observation_id(&self) -> String { + format!("{}:{}", self.artifact_id, self.record_number) + } + + fn evidence(&self) -> ScriptEvidenceRef { + ScriptEvidenceRef { + artifact_id: self.artifact_id.clone(), + record_number: self.record_number, + line_number: self.line_number, + } + } +} + +/// Resolve keys inside each AgentExecutor artifact. +/// +/// Records are grouped per artifact and per CCM thread. Within a thread a new +/// block begins at each `LaunchAttempted`; every record in a block shares the +/// block's key, which comes from whichever record in that block named a policy. +/// A block that never names a policy stays unkeyed -- its timeout or exit code +/// cannot terminate somebody else's script. +/// +/// Blocks never span artifacts. Two rotations of the same log reuse thread +/// numbers freely, so an execution split across a rotation boundary leaves an +/// unkeyed tail rather than a guessed join. +fn resolve_agent_executor_blocks(records: &mut [PendingRecord]) { + // Collect indices per artifact and thread, preserving source order. + let mut by_thread: BTreeMap<(usize, Option), Vec> = BTreeMap::new(); + for (index, record) in records.iter().enumerate() { + if record.source_kind != ScriptSourceKind::AgentExecutor { + continue; + } + by_thread + .entry((record.artifact_index, record.thread)) + .or_default() + .push(index); + } + + for indices in by_thread.into_values() { + let mut block: Vec = Vec::new(); + + for &index in &indices { + let starts_new_block = records[index].classification.signal + == ScriptSignal::LaunchAttempted + && !block.is_empty(); + + if starts_new_block { + apply_block_key(records, &block); + block.clear(); + } + block.push(index); + } + apply_block_key(records, &block); + } +} + +/// Give every record in a block the block's single key, if it has one. +fn apply_block_key(records: &mut [PendingRecord], block: &[usize]) { + if block.is_empty() { + return; + } + + let mut policy_id: Option = None; + let mut run_id: Option = None; + let mut context = ScriptExecutionContext::Unknown; + let mut conflicting = false; + + for &index in block { + let classification = &records[index].classification; + if let Some(candidate) = &classification.policy_id { + match &policy_id { + Some(existing) if existing != candidate => conflicting = true, + Some(_) => {} + None => policy_id = Some(candidate.clone()), + } + } + if let Some(candidate) = &classification.run_id { + if run_id.is_none() { + run_id = Some(candidate.clone()); + } + } + if classification.context != ScriptExecutionContext::Unknown + && context == ScriptExecutionContext::Unknown + { + context = classification.context; + } + } + + // Two different policies inside one block means our block boundary is + // wrong for this agent version. Refuse to key rather than merge. + if conflicting { + return; + } + + let Some(policy_id) = policy_id else { + return; + }; + + for &index in block { + records[index].resolved_policy_id = Some(policy_id.clone()); + records[index].resolved_run_id = run_id.clone(); + records[index].resolved_context = context; + } +} + +/// Complete partial keys using *identity*, never time. +/// +/// The primary IME log names a policy but almost never names the run; the +/// AgentExecutor log names both. A record that knows only the policy may adopt +/// a run id when that policy has exactly one run in the supplied evidence -- +/// there is then no other execution it could belong to. When a policy has two +/// or more runs the record keeps its partial key, so an ambiguous report is +/// reported as ambiguous instead of being attached to a guess. +/// +/// The same reasoning widens an unknown execution context to the single +/// context actually observed for that policy. +fn reconcile_partial_keys(records: &mut [PendingRecord]) { + // policy id -> run id -> contexts seen for that run + let mut runs: BTreeMap>> = + BTreeMap::new(); + + for record in records.iter() { + if let (Some(policy), Some(run)) = (&record.resolved_policy_id, &record.resolved_run_id) { + runs.entry(policy.clone()) + .or_default() + .entry(run.clone()) + .or_default() + .insert(record.resolved_context); + } + } + + for record in records.iter_mut() { + let Some(policy) = record.resolved_policy_id.clone() else { + continue; + }; + let Some(by_run) = runs.get(&policy) else { + continue; + }; + + // Exactly one run for this policy: a policy-only record can only mean + // that run. + if record.resolved_run_id.is_none() { + if by_run.len() == 1 { + let (run, _) = by_run.iter().next().expect("checked len"); + record.resolved_run_id = Some(run.clone()); + } else { + continue; + } + } + } + + // Now that policy-only records carry a run, unify the execution context + // across each run. The AgentExecutor log frequently omits the context that + // the primary IME log states explicitly, and vice versa; when exactly one + // context was ever stated for a run, every record of that run shares it. + let mut contexts: BTreeMap<(String, String), BTreeSet> = + BTreeMap::new(); + for record in records.iter() { + if let (Some(policy), Some(run)) = (&record.resolved_policy_id, &record.resolved_run_id) { + if record.resolved_context != ScriptExecutionContext::Unknown { + contexts + .entry((policy.clone(), run.clone())) + .or_default() + .insert(record.resolved_context); + } + } + } + + for record in records.iter_mut() { + if record.resolved_context != ScriptExecutionContext::Unknown { + continue; + } + let (Some(policy), Some(run)) = (&record.resolved_policy_id, &record.resolved_run_id) + else { + continue; + }; + if let Some(observed) = contexts.get(&(policy.clone(), run.clone())) { + if observed.len() == 1 { + record.resolved_context = *observed.iter().next().expect("checked len"); + } + } + } +} + +fn to_observation(record: &PendingRecord) -> ScriptObservation { + ScriptObservation { + observation_id: record.observation_id(), + evidence: record.evidence(), + source_kind: record.source_kind, + timestamp: record.timestamp.clone(), + signal: record.classification.signal, + policy_id: record.resolved_policy_id.clone(), + run_id: record.resolved_run_id.clone(), + context: record.resolved_context, + bitness: record.classification.bitness, + attempt: record.classification.attempt, + exit_token: record.classification.exit_token.clone(), + message: ScriptClassifiedString::sensitive(record.message.clone()), + } +} + +fn next_evidence_request(state: ScriptState, has_output_evidence: bool) -> Option { + match state { + ScriptState::PolicyReceived | ScriptState::Scheduled | ScriptState::Retried => { + Some("AgentExecutor.log covering the scheduled execution window".to_string()) + } + ScriptState::Launched => { + Some("AgentExecutor.log rotation containing the execution result".to_string()) + } + ScriptState::ExitedNonZero | ScriptState::TimedOut | ScriptState::FailedToLaunch + if !has_output_evidence => + { + Some("retained script output artifact for this policy and run".to_string()) + } + ScriptState::ExitedZero | ScriptState::ExitedNonZero => { + Some("IntuneManagementExtension.log records reporting this result".to_string()) + } + ScriptState::InsufficientEvidence => { + Some("IntuneManagementExtension.log and AgentExecutor.log for this policy".to_string()) + } + _ => None, + } +} + +/// Confidence describes how well evidenced the *reduced state* is. +/// +/// It is not a judgement about the bundle's completeness -- a missing source is +/// reported through coverage and the next-evidence request instead. An exit +/// code written by the executor is strong evidence that the script exited with +/// that code, even when the reporting half of the lifecycle was never supplied. +fn confidence_for( + state: ScriptState, + key: &ScriptTransactionKey, + unknown_version: bool, + transaction_saw_executor: bool, +) -> ScriptConfidence { + let terminal = matches!( + state, + ScriptState::ExitedZero + | ScriptState::ExitedNonZero + | ScriptState::TimedOut + | ScriptState::FailedToLaunch + | ScriptState::ReportSubmitted + | ScriptState::ReportFailed + ); + + if unknown_version { + return ScriptConfidence::Low; + } + if !terminal { + return ScriptConfidence::Low; + } + // A terminal execution outcome we never saw the executor write, or one we + // cannot pin to a specific run, is corroborating rather than conclusive. + if key.run_id.is_none() || !transaction_saw_executor { + return ScriptConfidence::Medium; + } + ScriptConfidence::High +} + +/// Reduce a supplied platform-script bundle. +/// +/// The caller owns all I/O: it reads and decodes each artifact and passes the +/// text in. This function performs no filesystem, registry, or network access. +pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { + let mut artifacts: Vec = Vec::new(); + let mut records: Vec = Vec::new(); + let mut unclassified_records = 0u32; + let mut unknown_version_observed = false; + + for (artifact_index, input) in inputs.iter().enumerate() { + let lines = parse_ime_content(&input.content); + let components: Vec> = + lines.iter().map(|line| line.component.clone()).collect(); + let artifact = classify_artifact(input, &components); + let source_kind = artifact.source_kind; + artifacts.push(artifact); + + for (record_index, line) in lines.iter().enumerate() { + let classification = classify_record(source_kind, &line.message); + if classification.execution_shaped_but_unmatched { + unknown_version_observed = true; + } + if classification.signal == ScriptSignal::Unclassified + && classification.policy_id.is_none() + { + unclassified_records += 1; + } + + let timestamp = line.timestamp.as_ref().map(|raw| ScriptTimestamp { + raw_text: raw.clone(), + // `ime_parser` resolves the CCM offset while producing + // `timestamp_utc`; a present `normalized_utc` therefore means + // the offset was valid. The offset text itself is not carried + // on `ImeLine`, so it is reported as absent rather than guessed. + original_offset: None, + normalized_utc: line.timestamp_utc.clone(), + }); + + records.push(PendingRecord { + artifact_index, + artifact_id: input.artifact_id.clone(), + source_kind, + record_number: (record_index + 1) as u32, + line_number: Some(line.line_number), + thread: line.thread, + timestamp, + message: line.message.clone(), + resolved_policy_id: classification.policy_id.clone(), + resolved_run_id: classification.run_id.clone(), + resolved_context: classification.context, + classification, + }); + } + } + + resolve_agent_executor_blocks(&mut records); + reconcile_partial_keys(&mut records); + + // Group into transactions. + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut unkeyed_observations: Vec = Vec::new(); + + for (index, record) in records.iter().enumerate() { + let has_signal = record.classification.signal != ScriptSignal::Unclassified; + // A record that named the policy itself is evidence even when it + // carries no signal: it is what proves the transaction's identity, and + // a transaction that cannot cite it is citing an incomplete case. A + // record that merely inherited its block's key is not included, so the + // evidence list stays the records that actually said something. + let names_its_own_policy = record.classification.policy_id.is_some(); + let belongs = has_signal || names_its_own_policy; + + match &record.resolved_policy_id { + Some(policy_id) if belongs => { + grouped + .entry(ScriptTransactionKey { + policy_id: policy_id.clone(), + run_id: record.resolved_run_id.clone(), + context: record.resolved_context, + }) + .or_default() + .push(index); + } + _ if has_signal => unkeyed_observations.push(record.observation_id()), + _ => {} + } + } + + let saw_agent_executor = artifacts + .iter() + .any(|artifact| artifact.source_kind == ScriptSourceKind::AgentExecutor); + let saw_ime = artifacts + .iter() + .any(|artifact| artifact.source_kind == ScriptSourceKind::IntuneManagementExtension); + + let mut transactions: Vec = Vec::new(); + for (key, indices) in grouped { + transactions.push(reduce_transaction( + key, + &indices, + &records, + unknown_version_observed, + )); + } + + let mut missing_expected_sources = Vec::new(); + if !saw_ime { + missing_expected_sources.push("IntuneManagementExtension.log".to_string()); + } + if !saw_agent_executor { + missing_expected_sources.push("AgentExecutor.log".to_string()); + } + + let observations = records.iter().map(to_observation).collect(); + + ScriptAnalysis { + transactions, + observations, + unkeyed_observations, + coverage: ScriptCoverage { + artifacts, + unclassified_records, + missing_expected_sources, + unknown_version_observed, + }, + } +} + +fn reduce_transaction( + key: ScriptTransactionKey, + indices: &[usize], + records: &[PendingRecord], + unknown_version_observed: bool, +) -> ScriptTransaction { + let mut ordered: Vec = indices.to_vec(); + ordered.sort_by_key(|&index| (records[index].artifact_index, records[index].record_number)); + + let mut state = ScriptState::InsufficientEvidence; + let mut last_confirmed_phase: Option = None; + let mut exit_token: Option = None; + let mut attempts = 0u32; + let mut bitness = ScriptInterpreterBitness::Unknown; + let mut has_output_evidence = false; + let mut executions = 0u32; + let mut transaction_saw_executor = false; + + for &index in &ordered { + let record = &records[index]; + let classification = &record.classification; + + if classification.signal == ScriptSignal::LaunchAttempted { + attempts += 1; + } + if matches!( + classification.signal, + ScriptSignal::ExecutionCompleted | ScriptSignal::ExecutionTimedOut + ) { + executions += 1; + } + if classification.signal == ScriptSignal::OutputCaptured { + has_output_evidence = true; + } + if record.source_kind == ScriptSourceKind::AgentExecutor { + transaction_saw_executor = true; + } + if classification.bitness != ScriptInterpreterBitness::Unknown + && bitness == ScriptInterpreterBitness::Unknown + { + bitness = classification.bitness; + } + + if let Some(phase) = phase_for(classification.signal) { + last_confirmed_phase = Some(match last_confirmed_phase { + Some(existing) if existing > phase => existing, + _ => phase, + }); + } + + if let Some(candidate) = + state_for(classification.signal, classification.exit_token.as_ref()) + { + if state_rank(candidate) >= state_rank(state) { + state = candidate; + } + } + + // The exit token always reflects the most recent completion record, so + // a retry that finally succeeds does not keep reporting the old code. + if classification.signal == ScriptSignal::ExecutionCompleted { + if let Some(token) = &classification.exit_token { + exit_token = Some(token.clone()); + } + } + } + + // A launch we never saw start, but whose result we did, still counts. + if attempts == 0 { + attempts = executions; + } + + let confidence = confidence_for( + state, + &key, + unknown_version_observed, + transaction_saw_executor, + ); + + ScriptTransaction { + key, + display_name: None, + bitness, + observations: ordered + .iter() + .map(|&index| records[index].observation_id()) + .collect(), + last_confirmed_phase, + state, + exit_token, + attempts, + confidence, + evidence: ordered + .iter() + .map(|&index| records[index].evidence()) + .collect(), + next_evidence_request: next_evidence_request(state, has_output_evidence), + } +} diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs new file mode 100644 index 000000000..35af56d3f --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs @@ -0,0 +1,611 @@ +//! Record-level classification for platform-script evidence. +//! +//! Every rule here answers one question about one record: what did this line +//! *say*? Nothing in this module decides an outcome, merges records, or infers +//! a value that is not written down. That is the reducer's job, and it only +//! gets to work from what these rules could actually prove. + +use std::sync::OnceLock; + +use regex::Regex; + +use super::models::{ + ScriptExecutionContext, ScriptExitToken, ScriptInterpreterBitness, ScriptSignal, + ScriptSourceKind, +}; + +const GUID: &str = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; + +fn re(cell: &'static OnceLock, pattern: &str) -> &'static Regex { + cell.get_or_init(|| Regex::new(pattern).expect("platform-script regex must compile")) +} + +/// `...\Policies\Scripts\{policyId}_{runId}.ps1` -- the strongest identity in +/// the AgentExecutor log, because it names both halves of the key at once. +fn script_path_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + &format!(r"(?i)[\\/]Scripts[\\/](?P{GUID})_(?P{GUID})\.ps1"), + ) +} + +/// Result artifacts use the same `{policyId}_{runId}` stem. +fn result_path_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + &format!(r"(?i)[\\/]Results[\\/](?P{GUID})_(?P{GUID})\.(?:output|error)"), + ) +} + +fn policy_id_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + &format!(r"(?i)policy\s*(?:with\s+)?id\s*[:=]\s*(?P{GUID})"), + ) +} + +fn policy_id_field_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + &format!(r"(?i)\bPolicyId\s*[:=]\s*(?P{GUID})"), + ) +} + +/// `Schedule policy for execution` -- the IME log names a policy this +/// way too, without an `id =` label. +fn policy_bare_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re(&CELL, &format!(r"(?i)\bpolicy\s+(?P{GUID})\b")) +} + +fn run_id_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + &format!(r"(?i)\b(?:run\s*id|executionId)\s*[:=]\s*(?P{GUID})"), + ) +} + +fn context_field_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\b(?:ExecutionContext|RunAsAccount|context)\s*[:=]\s*(?Psystem|user)\b", + ) +} + +fn context_phrase_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re(&CELL, r"(?i)\bin\s+(?Psystem|user)\s+context\b") +} + +fn bitness_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re(&CELL, r"(?i)\b(?P32|64)[\s-]?bit\b") +} + +fn exit_code_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\b(?:exit\s*code|exitCode)\b[^-\dxA-F]{0,12}(?P-?(?:0x[0-9a-fA-F]+|\d+))", + ) +} + +fn attempt_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\b(?:attempt|retry)\s*(?:#|number\s*)?(?P\d+)\b", + ) +} + +fn launch_start_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bstart(?:ing)?\s+(?:the\s+)?powershell\s+(?:execution|host|process)\b", + ) +} + +fn launch_failed_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bfail(?:ed|ure)\s+to\s+(?:create|start|launch|spawn)\s+(?:the\s+)?(?:process|powershell|script)\b", + ) +} + +fn execution_done_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bpowershell\s+execution\s+is\s+done\b|\bprocess\s+exit\s+code\s+is\b", + ) +} + +fn timeout_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\btimeout\s+to\s+execute\s+the\s+script\b|\bscript\s+(?:execution\s+)?timed\s+out\b", + ) +} + +fn retry_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bwill\s+retry\b|\bretrying\b|\bscheduling\s+a\s+retry\b", + ) +} + +fn policy_received_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bprocessing\s+policy\s+with\s+id\b|\breceived\s+policy\b|\bget\s+policies\b", + ) +} + +fn scheduled_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bschedul(?:e|ed|ing)\s+(?:the\s+)?(?:policy|script)\b|\bnext\s+run\s+time\b", + ) +} + +fn report_sent_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bresult\s+(?:has\s+been\s+)?sent\s+to\s+(?:the\s+)?service\s+successfully\b|\breport(?:ed|ing)?\s+result\s+succe", + ) +} + +fn report_failed_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bfail(?:ed|ure)\s+to\s+send\s+(?:the\s+)?(?:result|report)\b|\bresult\s+send\s+failed\b", + ) +} + +fn report_sending_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\bsend(?:ing)?\s+(?:the\s+)?result\s+to\s+(?:the\s+)?service\b", + ) +} + +fn output_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\b(?:output|stdout|stderr|error)\s+file\b|\bscript\s+output\b", + ) +} + +/// Everything one record could prove. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordClassification { + pub signal: ScriptSignal, + pub policy_id: Option, + pub run_id: Option, + pub context: ScriptExecutionContext, + pub bitness: ScriptInterpreterBitness, + pub attempt: Option, + pub exit_token: Option, + /// True when the record looks like platform-script execution but matched no + /// signal rule. The reducer turns this into a coverage flag, never a state. + pub execution_shaped_but_unmatched: bool, +} + +impl RecordClassification { + fn empty() -> Self { + Self { + signal: ScriptSignal::Unclassified, + policy_id: None, + run_id: None, + context: ScriptExecutionContext::Unknown, + bitness: ScriptInterpreterBitness::Unknown, + attempt: None, + exit_token: None, + execution_shaped_but_unmatched: false, + } + } + + /// Does this record name a policy, and therefore anchor a transaction? + pub fn is_key_bearing(&self) -> bool { + self.policy_id.is_some() + } +} + +fn parse_exit_token(raw: &str) -> ScriptExitToken { + let trimmed = raw.trim(); + let (negative, digits) = match trimmed.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, trimmed), + }; + + let (decimal, hex_text) = if let Some(hex) = digits + .strip_prefix("0x") + .or_else(|| digits.strip_prefix("0X")) + { + let value = i64::from_str_radix(hex, 16).ok(); + ( + value.map(|v| if negative { -v } else { v }), + Some(format!("0x{}", hex.to_ascii_uppercase())), + ) + } else { + let value = digits.parse::().ok(); + let signed = value.map(|v| if negative { -v } else { v }); + // Render the hex form for the unsigned 32-bit view operators expect. + let hex_text = signed.map(|v| format!("0x{:08X}", v as u32)); + (signed, hex_text) + }; + + ScriptExitToken { + raw_text: trimmed.to_string(), + decimal, + hex_text, + } +} + +fn extract_context(message: &str) -> ScriptExecutionContext { + let captured = context_field_re() + .captures(message) + .or_else(|| context_phrase_re().captures(message)); + + match captured + .and_then(|caps| { + caps.name("context") + .map(|m| m.as_str().to_ascii_lowercase()) + }) + .as_deref() + { + Some("system") => ScriptExecutionContext::System, + Some("user") => ScriptExecutionContext::User, + _ => ScriptExecutionContext::Unknown, + } +} + +fn extract_bitness(message: &str) -> ScriptInterpreterBitness { + match bitness_re() + .captures(message) + .and_then(|caps| caps.name("bits").map(|m| m.as_str())) + { + Some("32") => ScriptInterpreterBitness::Bit32, + Some("64") => ScriptInterpreterBitness::Bit64, + _ => ScriptInterpreterBitness::Unknown, + } +} + +/// Pull the policy/run pair out of a record, preferring the script path because +/// it proves both halves in one token. +fn extract_ids(message: &str) -> (Option, Option) { + for pattern in [script_path_re(), result_path_re()] { + if let Some(caps) = pattern.captures(message) { + return ( + caps.name("policy").map(|m| m.as_str().to_ascii_lowercase()), + caps.name("run").map(|m| m.as_str().to_ascii_lowercase()), + ); + } + } + + let policy = policy_id_field_re() + .captures(message) + .or_else(|| policy_id_re().captures(message)) + .or_else(|| policy_bare_re().captures(message)) + .and_then(|caps| caps.name("policy").map(|m| m.as_str().to_ascii_lowercase())); + + let run = run_id_re() + .captures(message) + .and_then(|caps| caps.name("run").map(|m| m.as_str().to_ascii_lowercase())); + + (policy, run) +} + +/// Classify one already-framed logical record. +/// +/// `source_kind` gates which vocabulary applies: an `AgentExecutor` phrase in +/// the primary IME log is not treated as an execution record, because the two +/// files describe different halves of the lifecycle. +pub fn classify_record(source_kind: ScriptSourceKind, message: &str) -> RecordClassification { + let mut result = RecordClassification::empty(); + + let (policy_id, run_id) = extract_ids(message); + result.policy_id = policy_id; + result.run_id = run_id; + result.context = extract_context(message); + result.bitness = extract_bitness(message); + result.attempt = attempt_re() + .captures(message) + .and_then(|caps| caps.name("attempt")) + .and_then(|m| m.as_str().parse::().ok()); + + result.signal = match source_kind { + ScriptSourceKind::AgentExecutor => classify_agent_executor(message, &mut result), + ScriptSourceKind::IntuneManagementExtension => classify_ime(message), + // HealthScripts records are remediation evidence. They are only carried + // as supplemental context and never classified into a script signal + // here; issue #360 owns that lifecycle. + _ => ScriptSignal::Unclassified, + }; + + if source_kind == ScriptSourceKind::AgentExecutor + && result.signal == ScriptSignal::Unclassified + && looks_like_execution(message) + { + result.execution_shaped_but_unmatched = true; + } + + result +} + +/// Outcome vocabulary for the unknown-version heuristic. +/// +/// Matched on word boundaries rather than as substrings. A substring test +/// treats the perfectly ordinary `-ExecutionPolicy Bypass` as an unrecognised +/// execution outcome, which would flag a healthy bundle as an unknown agent +/// version and drop every transaction in it to low confidence. +fn unknown_outcome_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + re( + &CELL, + r"(?i)\b(?:exit|exits|exited|completed?|fail|fails|failed|failure|finish|finished|done|terminated|termination)\b", + ) +} + +/// Execution-shaped but unmatched: mentions powershell plus an outcome word, +/// which is how an unrecognised agent version usually shows up. +/// +/// The vocabulary is deliberately narrow. It must not match the key-bearing +/// `Powershell script is: ...` record, which is unclassified by design and +/// present in every healthy bundle. +fn looks_like_execution(message: &str) -> bool { + message.to_ascii_lowercase().contains("powershell") && unknown_outcome_re().is_match(message) +} + +fn classify_agent_executor(message: &str, result: &mut RecordClassification) -> ScriptSignal { + if timeout_re().is_match(message) { + return ScriptSignal::ExecutionTimedOut; + } + if launch_failed_re().is_match(message) { + return ScriptSignal::LaunchFailed; + } + if execution_done_re().is_match(message) { + if let Some(code) = exit_code_re() + .captures(message) + .and_then(|caps| caps.name("code")) + { + result.exit_token = Some(parse_exit_token(code.as_str())); + } + return ScriptSignal::ExecutionCompleted; + } + if launch_start_re().is_match(message) { + return ScriptSignal::LaunchAttempted; + } + if output_re().is_match(message) { + return ScriptSignal::OutputCaptured; + } + ScriptSignal::Unclassified +} + +fn classify_ime(message: &str) -> ScriptSignal { + if report_failed_re().is_match(message) { + return ScriptSignal::ReportFailed; + } + if report_sent_re().is_match(message) { + return ScriptSignal::ReportSubmitted; + } + if retry_re().is_match(message) { + return ScriptSignal::RetryScheduled; + } + if scheduled_re().is_match(message) { + return ScriptSignal::Scheduled; + } + if policy_received_re().is_match(message) { + return ScriptSignal::PolicyReceived; + } + // "Send result to service, PolicyId = ..." is the attempt, not the success. + if report_sending_re().is_match(message) { + return ScriptSignal::Unclassified; + } + ScriptSignal::Unclassified +} + +#[cfg(test)] +mod tests { + use super::*; + + const POLICY: &str = "11111111-2222-3333-4444-555555555555"; + const RUN: &str = "66666666-7777-8888-9999-000000000000"; + + fn agent(message: &str) -> RecordClassification { + classify_record(ScriptSourceKind::AgentExecutor, message) + } + + fn ime(message: &str) -> RecordClassification { + classify_record(ScriptSourceKind::IntuneManagementExtension, message) + } + + #[test] + fn script_path_yields_both_key_halves() { + let result = agent(&format!( + r"Powershell script is: C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\{POLICY}_{RUN}.ps1" + )); + assert_eq!(result.policy_id.as_deref(), Some(POLICY)); + assert_eq!(result.run_id.as_deref(), Some(RUN)); + assert!(result.is_key_bearing()); + } + + #[test] + fn execution_done_captures_a_zero_exit() { + let result = agent("Powershell execution is done, exitCode = 0"); + assert_eq!(result.signal, ScriptSignal::ExecutionCompleted); + let token = result.exit_token.unwrap(); + assert_eq!(token.decimal, Some(0)); + assert_eq!(token.raw_text, "0"); + } + + #[test] + fn execution_done_captures_an_unknown_decimal_exit() { + let result = agent("Powershell execution is done, exitCode = 3221225477"); + let token = result.exit_token.unwrap(); + assert_eq!(token.decimal, Some(3_221_225_477)); + assert_eq!(token.hex_text.as_deref(), Some("0xC0000005")); + } + + #[test] + fn execution_done_captures_a_hex_exit_verbatim() { + let result = agent("Powershell execution is done, exitCode = 0x80070005"); + let token = result.exit_token.unwrap(); + assert_eq!(token.raw_text, "0x80070005"); + assert_eq!(token.hex_text.as_deref(), Some("0x80070005")); + assert_eq!(token.decimal, Some(0x8007_0005)); + } + + #[test] + fn negative_exit_codes_survive_round_trip() { + let result = agent("Powershell execution is done, exitCode = -1"); + let token = result.exit_token.unwrap(); + assert_eq!(token.decimal, Some(-1)); + assert_eq!(token.raw_text, "-1"); + } + + #[test] + fn timeout_outranks_a_trailing_exit_code() { + // A killed process still logs a code; the timeout is the real story. + let result = agent("Timeout to execute the script, kill the process. exitCode = 1"); + assert_eq!(result.signal, ScriptSignal::ExecutionTimedOut); + } + + #[test] + fn launch_failure_is_distinct_from_a_nonzero_exit() { + let result = agent("Failed to create process for the script"); + assert_eq!(result.signal, ScriptSignal::LaunchFailed); + assert!(result.exit_token.is_none()); + } + + #[test] + fn launch_start_is_recognised() { + assert_eq!( + agent("Starting Powershell Execution").signal, + ScriptSignal::LaunchAttempted + ); + } + + #[test] + fn execution_context_is_read_from_an_explicit_field() { + let result = ime(&format!( + "[PowerShell] Send result to service, PolicyId = {POLICY}, ExecutionContext = System" + )); + assert_eq!(result.context, ScriptExecutionContext::System); + assert_eq!(result.policy_id.as_deref(), Some(POLICY)); + } + + #[test] + fn user_context_phrase_is_read() { + let result = agent("Executing the script in user context"); + assert_eq!(result.context, ScriptExecutionContext::User); + } + + #[test] + fn bitness_is_retained_when_stated() { + assert_eq!( + agent("Starting Powershell Execution using 64-bit host").bitness, + ScriptInterpreterBitness::Bit64 + ); + assert_eq!( + agent("Starting Powershell Execution").bitness, + ScriptInterpreterBitness::Unknown + ); + } + + #[test] + fn report_success_and_failure_are_separate_signals() { + assert_eq!( + ime("[PowerShell] Policy result has been sent to service successfully").signal, + ScriptSignal::ReportSubmitted + ); + assert_eq!( + ime("[PowerShell] Failed to send result to service").signal, + ScriptSignal::ReportFailed + ); + } + + #[test] + fn sending_a_result_is_not_the_same_as_having_sent_it() { + let result = ime(&format!( + "[PowerShell] Send result to service, PolicyId = {POLICY}" + )); + assert_eq!(result.signal, ScriptSignal::Unclassified); + } + + #[test] + fn policy_receipt_is_recognised_with_its_id() { + let result = ime(&format!( + "[PowerShell] Processing policy with id = {POLICY}" + )); + assert_eq!(result.signal, ScriptSignal::PolicyReceived); + assert_eq!(result.policy_id.as_deref(), Some(POLICY)); + } + + #[test] + fn agent_executor_vocabulary_does_not_apply_to_the_primary_ime_log() { + // The same sentence in the wrong file must not become an execution. + assert_eq!( + ime("Powershell execution is done, exitCode = 0").signal, + ScriptSignal::Unclassified + ); + } + + #[test] + fn health_scripts_records_never_classify_as_a_script_signal() { + let result = classify_record( + ScriptSourceKind::HealthScripts, + "Powershell execution is done, exitCode = 1", + ); + assert_eq!(result.signal, ScriptSignal::Unclassified); + } + + #[test] + fn unrecognised_execution_wording_is_flagged_for_coverage_not_guessed() { + let result = agent("Powershell runspace finished with an unexpected condition"); + assert_eq!(result.signal, ScriptSignal::Unclassified); + assert!(result.execution_shaped_but_unmatched); + } + + #[test] + fn unrelated_text_is_not_flagged_as_unknown_version() { + let result = agent("Cleaning up temporary folder"); + assert!(!result.execution_shaped_but_unmatched); + } + + #[test] + fn execution_policy_argument_is_not_an_unknown_version_signal() { + // `-ExecutionPolicy` contains "execut" as a substring; a healthy bundle + // must not be demoted to low confidence because of it. + let result = agent(r"Launching: powershell.exe -ExecutionPolicy Bypass -File script.ps1"); + assert!(!result.execution_shaped_but_unmatched); + } + + #[test] + fn attempt_number_is_extracted_when_present() { + assert_eq!(agent("Retry attempt 3 for the script").attempt, Some(3)); + } + + #[test] + fn guid_matching_is_case_insensitive_and_normalises() { + let upper = POLICY.to_ascii_uppercase(); + let result = ime(&format!("[PowerShell] Processing policy with id = {upper}")); + assert_eq!(result.policy_id.as_deref(), Some(POLICY)); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs new file mode 100644 index 000000000..5db948386 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs @@ -0,0 +1,232 @@ +//! Source classification for platform-script evidence. +//! +//! A file name selects a *candidate* source kind. It is never proof on its own: +//! the candidate is only confirmed when the records inside carry a component +//! that belongs to that source. A file called `AgentExecutor.log` full of +//! unrelated CCM records classifies as [`ScriptSourceKind::Unknown`], which +//! keeps it visible in coverage instead of feeding the reducer. + +use super::models::{ScriptArtifact, ScriptClassifiedString, ScriptSourceKind}; + +/// One supplied artifact, before parsing. +/// +/// The pure crate never opens files. Callers read the bytes, decode them, and +/// hand over the text with its original name and path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScriptSourceInput { + pub artifact_id: String, + pub file_name: String, + pub file_path: Option, + pub content: String, +} + +/// Components that confirm a primary IME artifact. +const IME_COMPONENTS: &[&str] = &["IntuneManagementExtension", "PowerShell", "Win32App"]; + +/// Components that confirm an AgentExecutor artifact. +const AGENT_EXECUTOR_COMPONENTS: &[&str] = &["AgentExecutor"]; + +/// Components that confirm a HealthScripts artifact. +const HEALTH_SCRIPTS_COMPONENTS: &[&str] = &["HealthScripts"]; + +/// Strip a rotation suffix and the `.log` extension, returning the stem and the +/// rotation ordinal when the name identifies one. +/// +/// Recognised shapes, all observed in IME log directories: +/// `AgentExecutor.log`, `AgentExecutor-20260312-101522.log`, `AgentExecutor-1.log`, +/// and the underscore-prefixed archive form `_AgentExecutor.log`. +fn split_rotation(file_name: &str) -> (String, Option) { + let trimmed = file_name.trim(); + let without_ext = trimmed + .strip_suffix(".log") + .or_else(|| trimmed.strip_suffix(".LOG")) + .unwrap_or(trimmed); + + // `_Name` marks an archived copy; treat the underscore as rotation evidence + // without an ordinal we can trust to be sequential. + let (without_ext, underscore_archive) = match without_ext.strip_prefix('_') { + Some(rest) => (rest, true), + None => (without_ext, false), + }; + + let Some((stem, suffix)) = without_ext.rsplit_once('-') else { + return ( + without_ext.to_string(), + if underscore_archive { Some(1) } else { Some(0) }, + ); + }; + + if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { + // `-20260312-101522`: a datestamped rotation. The date half is what + // identifies it, so check the preceding segment before deciding this + // trailing run of digits is an ordinal. + if let Some((stem2, date_part)) = stem.rsplit_once('-') { + if date_part.len() >= 8 && date_part.chars().all(|c| c.is_ascii_digit()) { + return (stem2.to_string(), None); + } + } + + // `-20260312`: a bare datestamp. + if suffix.len() >= 8 { + return (stem.to_string(), None); + } + + // `-1`, `-2`: an explicit ordinal. + if let Ok(ordinal) = suffix.parse::() { + return (stem.to_string(), Some(ordinal)); + } + } + + ( + without_ext.to_string(), + if underscore_archive { Some(1) } else { Some(0) }, + ) +} + +/// The source kind a file name suggests, before content confirms it. +fn candidate_from_name(file_name: &str) -> ScriptSourceKind { + let (stem, _) = split_rotation(file_name); + match stem.to_ascii_lowercase().as_str() { + "intunemanagementextension" => ScriptSourceKind::IntuneManagementExtension, + "agentexecutor" => ScriptSourceKind::AgentExecutor, + "healthscripts" => ScriptSourceKind::HealthScripts, + _ => ScriptSourceKind::Unknown, + } +} + +/// Does any record component confirm the candidate? +fn components_confirm(candidate: ScriptSourceKind, components: &[Option]) -> bool { + let expected: &[&str] = match candidate { + ScriptSourceKind::IntuneManagementExtension => IME_COMPONENTS, + ScriptSourceKind::AgentExecutor => AGENT_EXECUTOR_COMPONENTS, + ScriptSourceKind::HealthScripts => HEALTH_SCRIPTS_COMPONENTS, + _ => return false, + }; + + components.iter().flatten().any(|component| { + expected + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(component)) + }) +} + +/// Classify one artifact from its name and the components actually present in it. +/// +/// `components` is every record's `component=` attribute, in source order. +pub fn classify_artifact( + input: &ScriptSourceInput, + components: &[Option], +) -> ScriptArtifact { + let candidate = candidate_from_name(&input.file_name); + let (_, rotation_ordinal) = split_rotation(&input.file_name); + + let source_kind = if components_confirm(candidate, components) { + candidate + } else { + ScriptSourceKind::Unknown + }; + + ScriptArtifact { + artifact_id: input.artifact_id.clone(), + file_name: input.file_name.clone(), + file_path: input + .file_path + .as_ref() + .map(|path| ScriptClassifiedString::sensitive(path.clone())), + source_kind, + rotation_ordinal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(file_name: &str) -> ScriptSourceInput { + ScriptSourceInput { + artifact_id: "a1".to_string(), + file_name: file_name.to_string(), + file_path: None, + content: String::new(), + } + } + + fn components(values: &[&str]) -> Vec> { + values.iter().map(|v| Some((*v).to_string())).collect() + } + + #[test] + fn agent_executor_name_with_matching_component_is_confirmed() { + let artifact = + classify_artifact(&input("AgentExecutor.log"), &components(&["AgentExecutor"])); + assert_eq!(artifact.source_kind, ScriptSourceKind::AgentExecutor); + assert_eq!(artifact.rotation_ordinal, Some(0)); + } + + #[test] + fn agent_executor_name_without_matching_component_stays_unknown() { + // The whole point of the rule: the name alone must not classify. + let artifact = classify_artifact(&input("AgentExecutor.log"), &components(&["CcmExec"])); + assert_eq!(artifact.source_kind, ScriptSourceKind::Unknown); + } + + #[test] + fn ime_name_is_confirmed_by_any_expected_component() { + let artifact = classify_artifact( + &input("IntuneManagementExtension.log"), + &components(&["PowerShell"]), + ); + assert_eq!( + artifact.source_kind, + ScriptSourceKind::IntuneManagementExtension + ); + } + + #[test] + fn health_scripts_is_classified_but_remains_a_distinct_kind() { + let artifact = + classify_artifact(&input("HealthScripts.log"), &components(&["HealthScripts"])); + assert_eq!(artifact.source_kind, ScriptSourceKind::HealthScripts); + } + + #[test] + fn unrelated_name_is_unknown_even_with_a_script_component() { + let artifact = classify_artifact(&input("CcmExec.log"), &components(&["AgentExecutor"])); + assert_eq!(artifact.source_kind, ScriptSourceKind::Unknown); + } + + #[test] + fn numeric_rotation_ordinal_is_extracted() { + let (stem, ordinal) = split_rotation("AgentExecutor-1.log"); + assert_eq!(stem, "AgentExecutor"); + assert_eq!(ordinal, Some(1)); + } + + #[test] + fn datestamped_rotation_has_no_ordinal_but_keeps_its_stem() { + let (stem, ordinal) = split_rotation("IntuneManagementExtension-20260312-101522.log"); + assert_eq!(stem, "IntuneManagementExtension"); + assert_eq!(ordinal, None); + } + + #[test] + fn underscore_archive_form_is_recognised_as_a_rotation() { + let (stem, ordinal) = split_rotation("_AgentExecutor.log"); + assert_eq!(stem, "AgentExecutor"); + assert_eq!(ordinal, Some(1)); + } + + #[test] + fn file_path_is_classified_sensitive() { + let mut source = input("AgentExecutor.log"); + source.file_path = Some( + r"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AgentExecutor.log" + .to_string(), + ); + let artifact = classify_artifact(&source, &components(&["AgentExecutor"])); + assert_eq!( + artifact.file_path.unwrap().sensitivity, + crate::intune::apps::windows::scripts::ScriptSensitivity::Sensitive + ); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/mod.rs b/crates/cmtraceopen-parser/src/intune/mod.rs index 13af0222f..dd502ac38 100644 --- a/crates/cmtraceopen-parser/src/intune/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/mod.rs @@ -1,3 +1,4 @@ +pub mod apps; pub mod download_stats; pub mod event_tracker; pub mod guid_registry; diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/AgentExecutor.log new file mode 100644 index 000000000..043a229b5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/IntuneManagementExtension.log new file mode 100644 index 000000000..0be291494 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/IntuneManagementExtension.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/expected.json new file mode 100644 index 000000000..d34753580 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/expected.json @@ -0,0 +1,22 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "state": "reportFailed", + "lastConfirmedPhase": "reported", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": null, + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/manifest.json new file mode 100644 index 000000000..b71e9102c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/execution-success-report-failure/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "The script succeeded locally and the report failed: both facts survive.", + "artifacts": [ + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + }, + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/AgentExecutor.log new file mode 100644 index 000000000..dd2c5b8ef --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json new file mode 100644 index 000000000..d3bb64ad3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "failedToLaunch", + "lastConfirmedPhase": "launched", + "attempts": 1, + "confidence": "high", + "exitDecimal": null, + "exitRaw": null, + "nextEvidenceRequest": "retained script output artifact for this policy and run", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/manifest.json new file mode 100644 index 000000000..b195c1c71 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A launch failure is a distinct state from a nonzero exit and carries no exit token.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/AgentExecutor.log new file mode 100644 index 000000000..995a4748f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/AgentExecutor.log @@ -0,0 +1,6 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/expected.json new file mode 100644 index 000000000..fd65eb1e0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/expected.json @@ -0,0 +1,28 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 1, + "exitRaw": "1", + "nextEvidenceRequest": "retained script output artifact for this policy and run", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + }, + "messageMustContain": { + "observationId": "agent:3", + "text": "+ throw \"boom\"" + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/manifest.json new file mode 100644 index 000000000..0c48191c8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multiline-ccm-record/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A multiline record is one logical record and its inner lines are preserved verbatim.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/AgentExecutor.log new file mode 100644 index 000000000..053bc12f8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/AgentExecutor.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/expected.json new file mode 100644 index 000000000..71982fff6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 1, + "exitRaw": "1", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json new file mode 100644 index 000000000..6d6bd970f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A nonzero exit with a retained error artifact: the outcome is reported, not diagnosed.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/AgentExecutor.log new file mode 100644 index 000000000..1bfaced1b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/expected.json new file mode 100644 index 000000000..1d6d279e3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 3221225477, + "exitRaw": "3221225477", + "nextEvidenceRequest": "retained script output artifact for this policy and run", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/manifest.json new file mode 100644 index 000000000..d8e89bc9b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-without-output/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A nonzero exit with no retained output: absence of output is coverage, not proof of silence.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/IntuneManagementExtension.log new file mode 100644 index 000000000..7dcc73597 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/IntuneManagementExtension.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/expected.json new file mode 100644 index 000000000..dacdb0442 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": null, + "context": "unknown", + "state": "scheduled", + "lastConfirmedPhase": "scheduled", + "attempts": 0, + "confidence": "low", + "exitDecimal": null, + "exitRaw": null, + "nextEvidenceRequest": "AgentExecutor.log covering the scheduled execution window", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "AgentExecutor.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/manifest.json new file mode 100644 index 000000000..8e4ad0205 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/policy-received-no-execution/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "Policy receipt with no execution evidence stays non-terminal and names the missing artifact.", + "artifacts": [ + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/AgentExecutor.log new file mode 100644 index 000000000..1d0764039 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/AgentExecutor.log @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/IntuneManagementExtension.log new file mode 100644 index 000000000..44f7f52e4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/IntuneManagementExtension.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/expected.json new file mode 100644 index 000000000..da292a25c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/expected.json @@ -0,0 +1,30 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [], + "unknownVersionObserved": false + }, + "redactionMustNotContain": [ + "adele.vance", + "hunter2" + ], + "redactionMustContain": [ + "11111111-1111-4111-8111-111111111111", + "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1" + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/manifest.json new file mode 100644 index 000000000..5d1997dcf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/privacy-redaction/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "Synthetic UPN, command line, and user path are masked while correlation keys survive.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\Users\\adele.vance\\AppData\\Local\\Temp\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + }, + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/AgentExecutor.log new file mode 100644 index 000000000..4c6c09f4a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/AgentExecutor.log @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/IntuneManagementExtension.log new file mode 100644 index 000000000..ef927eb26 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/IntuneManagementExtension.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/expected.json new file mode 100644 index 000000000..f81e414cd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/expected.json @@ -0,0 +1,22 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 3, + "confidence": "high", + "exitDecimal": 1, + "exitRaw": "1", + "nextEvidenceRequest": "retained script output artifact for this policy and run", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/manifest.json new file mode 100644 index 000000000..7475fc6d2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/retries-exhausted/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "Retries exhausted: a later retry notice does not outrank the execution outcome.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + }, + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor-1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor-1.log new file mode 100644 index 000000000..7549a63c8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor-1.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor.log new file mode 100644 index 000000000..a70ea5de4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/AgentExecutor.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/expected.json new file mode 100644 index 000000000..d5b3bee72 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "launched", + "lastConfirmedPhase": "launched", + "attempts": 1, + "confidence": "low", + "exitDecimal": null, + "exitRaw": null, + "nextEvidenceRequest": "AgentExecutor.log rotation containing the execution result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 1, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/manifest.json new file mode 100644 index 000000000..5f01ddd24 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/rotation-boundary/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "An execution split across a rotation leaves an unkeyed tail rather than a guessed join.", + "artifacts": [ + { + "artifactId": "agent-rot", + "fileName": "AgentExecutor-1.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor-1.log", + "contentFile": "AgentExecutor-1.log" + }, + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/AgentExecutor.log new file mode 100644 index 000000000..77b603eaa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/IntuneManagementExtension.log new file mode 100644 index 000000000..4388b92bd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/IntuneManagementExtension.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json new file mode 100644 index 000000000..255f98796 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json @@ -0,0 +1,278 @@ +{ + "transactions": [ + { + "key": { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system" + }, + "displayName": null, + "bitness": "bit64", + "observations": [ + "ime:1", + "ime:2", + "ime:3", + "ime:4", + "agent:1", + "agent:2", + "agent:3" + ], + "lastConfirmedPhase": "reported", + "state": "reportSubmitted", + "exitToken": { + "rawText": "0", + "decimal": 0, + "hexText": "0x00000000" + }, + "attempts": 1, + "confidence": "high", + "evidence": [ + { + "artifactId": "ime", + "recordNumber": 1, + "lineNumber": 1 + }, + { + "artifactId": "ime", + "recordNumber": 2, + "lineNumber": 2 + }, + { + "artifactId": "ime", + "recordNumber": 3, + "lineNumber": 3 + }, + { + "artifactId": "ime", + "recordNumber": 4, + "lineNumber": 4 + }, + { + "artifactId": "agent", + "recordNumber": 1, + "lineNumber": 1 + }, + { + "artifactId": "agent", + "recordNumber": 2, + "lineNumber": 2 + }, + { + "artifactId": "agent", + "recordNumber": 3, + "lineNumber": 3 + } + ], + "nextEvidenceRequest": null + } + ], + "observations": [ + { + "observationId": "ime:1", + "evidence": { + "artifactId": "ime", + "recordNumber": 1, + "lineNumber": 1 + }, + "sourceKind": "intuneManagementExtension", + "timestamp": { + "rawText": "03-12-2026 10:15:20.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:20.000Z" + }, + "signal": "policyReceived", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "unknown", + "attempt": null, + "exitToken": null, + "message": { + "value": "[PowerShell] Processing policy with id = 11111111-1111-4111-8111-111111111111", + "sensitivity": "sensitive" + } + }, + { + "observationId": "ime:2", + "evidence": { + "artifactId": "ime", + "recordNumber": 2, + "lineNumber": 2 + }, + "sourceKind": "intuneManagementExtension", + "timestamp": { + "rawText": "03-12-2026 10:15:20.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:20.000Z" + }, + "signal": "scheduled", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "unknown", + "attempt": null, + "exitToken": null, + "message": { + "value": "[PowerShell] Schedule policy 11111111-1111-4111-8111-111111111111 for execution", + "sensitivity": "sensitive" + } + }, + { + "observationId": "ime:3", + "evidence": { + "artifactId": "ime", + "recordNumber": 3, + "lineNumber": 3 + }, + "sourceKind": "intuneManagementExtension", + "timestamp": { + "rawText": "03-12-2026 10:15:32.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:32.000Z" + }, + "signal": "unclassified", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "unknown", + "attempt": null, + "exitToken": null, + "message": { + "value": "[PowerShell] Send result to service, PolicyId = 11111111-1111-4111-8111-111111111111, ExecutionContext = System", + "sensitivity": "sensitive" + } + }, + { + "observationId": "ime:4", + "evidence": { + "artifactId": "ime", + "recordNumber": 4, + "lineNumber": 4 + }, + "sourceKind": "intuneManagementExtension", + "timestamp": { + "rawText": "03-12-2026 10:15:32.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:32.000Z" + }, + "signal": "reportSubmitted", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "unknown", + "attempt": null, + "exitToken": null, + "message": { + "value": "[PowerShell] Policy result has been sent to service successfully, PolicyId = 11111111-1111-4111-8111-111111111111, ExecutionContext = System", + "sensitivity": "sensitive" + } + }, + { + "observationId": "agent:1", + "evidence": { + "artifactId": "agent", + "recordNumber": 1, + "lineNumber": 1 + }, + "sourceKind": "agentExecutor", + "timestamp": { + "rawText": "03-12-2026 10:15:22.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:22.000Z" + }, + "signal": "launchAttempted", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "bit64", + "attempt": null, + "exitToken": null, + "message": { + "value": "Starting Powershell Execution using 64-bit host", + "sensitivity": "sensitive" + } + }, + { + "observationId": "agent:2", + "evidence": { + "artifactId": "agent", + "recordNumber": 2, + "lineNumber": 2 + }, + "sourceKind": "agentExecutor", + "timestamp": { + "rawText": "03-12-2026 10:15:22.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:22.000Z" + }, + "signal": "unclassified", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "unknown", + "attempt": null, + "exitToken": null, + "message": { + "value": "Powershell script is: C:\\Program Files (x86)\\Microsoft Intune Management Extension\\Policies\\Scripts\\11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.ps1", + "sensitivity": "sensitive" + } + }, + { + "observationId": "agent:3", + "evidence": { + "artifactId": "agent", + "recordNumber": 3, + "lineNumber": 3 + }, + "sourceKind": "agentExecutor", + "timestamp": { + "rawText": "03-12-2026 10:15:31.000", + "originalOffset": null, + "normalizedUtc": "2026-03-12T14:15:31.000Z" + }, + "signal": "executionCompleted", + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "bitness": "unknown", + "attempt": null, + "exitToken": { + "rawText": "0", + "decimal": 0, + "hexText": "0x00000000" + }, + "message": { + "value": "Powershell execution is done, exitCode = 0", + "sensitivity": "sensitive" + } + } + ], + "unkeyedObservations": [], + "coverage": { + "artifacts": [ + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": { + "value": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "sensitivity": "sensitive" + }, + "sourceKind": "intuneManagementExtension", + "rotationOrdinal": 0 + }, + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": { + "value": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "sensitivity": "sensitive" + }, + "sourceKind": "agentExecutor", + "rotationOrdinal": 0 + } + ], + "unclassifiedRecords": 0, + "missingExpectedSources": [], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected.json new file mode 100644 index 000000000..73250bd49 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected.json @@ -0,0 +1,22 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system", + "state": "reportSubmitted", + "lastConfirmedPhase": "reported", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": null, + "bitness": "bit64" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/manifest.json new file mode 100644 index 000000000..6c42bb3d9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "A device-context script that ran to a zero exit and whose result reached the service.", + "artifacts": [ + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + }, + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/AgentExecutor.log new file mode 100644 index 000000000..f212ced0e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/AgentExecutor.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/IntuneManagementExtension.log new file mode 100644 index 000000000..011a86365 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/IntuneManagementExtension.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/expected.json new file mode 100644 index 000000000..072d3a205 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/expected.json @@ -0,0 +1,22 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "user", + "state": "reportSubmitted", + "lastConfirmedPhase": "reported", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": null, + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/manifest.json new file mode 100644 index 000000000..26a37e5b2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-user-context/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "A user-context script, proving context comes from the record and not from a default.", + "artifacts": [ + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + }, + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/AgentExecutor.log new file mode 100644 index 000000000..9b6f0b4f1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/AgentExecutor.log @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/expected.json new file mode 100644 index 000000000..b01271e65 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedZero", + "lastConfirmedPhase": "executed", + "attempts": 4, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/manifest.json new file mode 100644 index 000000000..10f96d14e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/three-retries-then-success/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "Four launch blocks on one thread: the final attempt decides the state, not the first.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/AgentExecutor.log new file mode 100644 index 000000000..8712cbd5c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/expected.json new file mode 100644 index 000000000..29cc99599 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "timedOut", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": null, + "exitRaw": null, + "nextEvidenceRequest": "retained script output artifact for this policy and run", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/manifest.json new file mode 100644 index 000000000..727b9d226 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/timeout/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A timeout terminates only the run it is keyed to.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/AgentExecutor.log new file mode 100644 index 000000000..81e9b7564 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/AgentExecutor.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/expected.json new file mode 100644 index 000000000..26a9c6698 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/expected.json @@ -0,0 +1,37 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result", + "bitness": "unknown" + }, + { + "policyId": "22222222-2222-4222-8222-222222222222", + "runId": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbb2", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 1, + "exitRaw": "1", + "nextEvidenceRequest": "retained script output artifact for this policy and run", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/manifest.json new file mode 100644 index 000000000..d8f5e733c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/two-scripts-same-minute/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "Identical timestamps, two threads: identifiers decide the join, never time proximity.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/AgentExecutor.log new file mode 100644 index 000000000..4a229db94 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/expected.json new file mode 100644 index 000000000..c12f28fb6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "launched", + "lastConfirmedPhase": "launched", + "attempts": 1, + "confidence": "low", + "exitDecimal": null, + "exitRaw": null, + "nextEvidenceRequest": "AgentExecutor.log rotation containing the execution result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": true + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/manifest.json new file mode 100644 index 000000000..39d906c8e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/unknown-agent-version/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "Unrecognised execution wording lowers confidence and raises coverage; it never invents a state.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs new file mode 100644 index 000000000..4b7a6f677 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs @@ -0,0 +1,353 @@ +//! Focused fixture matrix for `intune::apps::windows::scripts`. +//! +//! Each scenario directory holds a `manifest.json` naming its artifacts, the +//! artifact contents themselves, and an `expected.json` describing the contract +//! the reduction must satisfy. The expectations are written out rather than +//! snapshotted so a reviewer can see what each scenario is asserting and why. +//! +//! One scenario additionally carries `expected-full.json`: a complete golden of +//! the redacted export projection, which is what pins the serialized shape. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::intune::apps::windows::scripts::{ + analyze_script_bundle, redacted_export_projection, ScriptAnalysis, ScriptSourceInput, +}; +use serde_json::Value; + +fn fixtures_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/intune/windows/scripts") +} + +fn read_json(path: &Path) -> Value { + let text = fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", path.display())) +} + +fn load_scenario(scenario: &str) -> (ScriptAnalysis, Value) { + let dir = fixtures_root().join(scenario); + let manifest = read_json(&dir.join("manifest.json")); + let expected = read_json(&dir.join("expected.json")); + + let inputs: Vec = manifest["artifacts"] + .as_array() + .expect("manifest.artifacts must be an array") + .iter() + .map(|artifact| { + let content_file = artifact["contentFile"].as_str().expect("contentFile"); + ScriptSourceInput { + artifact_id: artifact["artifactId"] + .as_str() + .expect("artifactId") + .to_string(), + file_name: artifact["fileName"].as_str().expect("fileName").to_string(), + file_path: artifact["filePath"].as_str().map(str::to_string), + content: fs::read_to_string(dir.join(content_file)) + .unwrap_or_else(|error| panic!("failed to read {content_file}: {error}")), + } + }) + .collect(); + + (analyze_script_bundle(&inputs), expected) +} + +/// Compare the reduction against the scenario's stated contract. +fn assert_scenario(scenario: &str) -> ScriptAnalysis { + let (analysis, expected) = load_scenario(scenario); + let value = serde_json::to_value(&analysis).expect("analysis must serialize"); + + let expected_transactions = expected["transactions"].as_array().expect("transactions"); + let actual_transactions = value["transactions"].as_array().expect("transactions"); + + assert_eq!( + actual_transactions.len(), + expected_transactions.len(), + "{scenario}: transaction count. actual keys: {:?}", + actual_transactions + .iter() + .map(|t| &t["key"]) + .collect::>() + ); + + for (index, (actual, want)) in actual_transactions + .iter() + .zip(expected_transactions) + .enumerate() + { + let at = format!("{scenario}[{index}]"); + assert_eq!( + actual["key"]["policyId"], want["policyId"], + "{at}: policyId" + ); + assert_eq!(actual["key"]["runId"], want["runId"], "{at}: runId"); + assert_eq!(actual["key"]["context"], want["context"], "{at}: context"); + assert_eq!(actual["state"], want["state"], "{at}: state"); + assert_eq!( + actual["lastConfirmedPhase"], want["lastConfirmedPhase"], + "{at}: lastConfirmedPhase" + ); + assert_eq!(actual["attempts"], want["attempts"], "{at}: attempts"); + assert_eq!(actual["confidence"], want["confidence"], "{at}: confidence"); + assert_eq!(actual["bitness"], want["bitness"], "{at}: bitness"); + assert_eq!( + actual["nextEvidenceRequest"], want["nextEvidenceRequest"], + "{at}: nextEvidenceRequest" + ); + + match want["exitDecimal"].as_i64() { + Some(code) => { + assert_eq!( + actual["exitToken"]["decimal"].as_i64(), + Some(code), + "{at}: exit decimal" + ); + assert_eq!( + actual["exitToken"]["rawText"], want["exitRaw"], + "{at}: exit raw text" + ); + } + None => assert!( + actual["exitToken"].is_null(), + "{at}: expected no exit token, got {}", + actual["exitToken"] + ), + } + + // Every transaction must cite the records it was built from. + assert!( + !actual["evidence"].as_array().expect("evidence").is_empty(), + "{at}: a transaction must cite evidence" + ); + assert_eq!( + actual["evidence"].as_array().unwrap().len(), + actual["observations"].as_array().unwrap().len(), + "{at}: evidence and observations must stay in step" + ); + } + + assert_eq!( + value["unkeyedObservations"].as_array().unwrap().len() as u64, + expected["unkeyedObservationCount"].as_u64().unwrap(), + "{scenario}: unkeyed observation count" + ); + + let coverage = &expected["coverage"]; + assert_eq!( + value["coverage"]["missingExpectedSources"], coverage["missingExpectedSources"], + "{scenario}: missing expected sources" + ); + assert_eq!( + value["coverage"]["unknownVersionObserved"], coverage["unknownVersionObserved"], + "{scenario}: unknown version flag" + ); + + if let Some(rule) = expected.get("messageMustContain") { + let id = rule["observationId"].as_str().expect("observationId"); + let text = rule["text"].as_str().expect("text"); + let observation = analysis + .observations + .iter() + .find(|observation| observation.observation_id == id) + .unwrap_or_else(|| panic!("{scenario}: no observation {id}")); + assert!( + observation.message.value.contains(text), + "{scenario}: observation {id} lost {text:?}; got {:?}", + observation.message.value + ); + } + + if let Some(forbidden) = expected.get("redactionMustNotContain") { + let redacted = redacted_export_projection(&analysis); + let text = serde_json::to_string(&redacted).expect("redacted analysis must serialize"); + for needle in forbidden.as_array().expect("redactionMustNotContain") { + let needle = needle.as_str().expect("needle"); + assert!( + !text.contains(needle), + "{scenario}: redacted export still contains {needle:?}" + ); + } + } + + if let Some(required) = expected.get("redactionMustContain") { + let redacted = redacted_export_projection(&analysis); + let text = serde_json::to_string(&redacted).expect("redacted analysis must serialize"); + for needle in required.as_array().expect("redactionMustContain") { + let needle = needle.as_str().expect("needle"); + assert!( + text.contains(needle), + "{scenario}: redacted export dropped correlation key {needle:?}" + ); + } + } + + analysis +} + +// -- The required fixture matrix ------------------------------------------- + +#[test] +fn successful_device_context_script_and_successful_report() { + assert_scenario("success-device-context"); +} + +#[test] +fn successful_user_context_script() { + assert_scenario("success-user-context"); +} + +#[test] +fn nonzero_exit_with_retained_output() { + assert_scenario("nonzero-exit-with-output"); +} + +#[test] +fn nonzero_exit_without_retained_output_requests_the_output_artifact() { + assert_scenario("nonzero-exit-without-output"); +} + +#[test] +fn process_launch_failure() { + assert_scenario("launch-failure"); +} + +#[test] +fn execution_timeout() { + assert_scenario("timeout"); +} + +#[test] +fn three_retries_followed_by_success() { + assert_scenario("three-retries-then-success"); +} + +#[test] +fn retries_exhausted() { + assert_scenario("retries-exhausted"); +} + +#[test] +fn execution_success_with_reporting_failure() { + assert_scenario("execution-success-report-failure"); +} + +#[test] +fn policy_received_with_no_execution_evidence() { + assert_scenario("policy-received-no-execution"); +} + +#[test] +fn two_scripts_in_the_same_minute_stay_separate() { + let analysis = assert_scenario("two-scripts-same-minute"); + + // The point of the scenario: no observation is shared between the two. + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for transaction in &analysis.transactions { + for observation in &transaction.observations { + assert!( + seen.insert(observation.as_str()), + "observation {observation} was claimed by two transactions" + ); + } + } +} + +#[test] +fn multiline_ccm_record_stays_one_logical_record() { + assert_scenario("multiline-ccm-record"); +} + +#[test] +fn rotation_boundary_leaves_an_unkeyed_tail() { + let analysis = assert_scenario("rotation-boundary"); + assert_eq!( + analysis.unkeyed_observations, + vec!["agent:1".to_string()], + "the orphaned completion record must remain visible and unkeyed" + ); +} + +#[test] +fn unknown_agent_version_lowers_confidence_and_raises_coverage() { + assert_scenario("unknown-agent-version"); +} + +#[test] +fn privacy_fixture_redacts_deterministically() { + let analysis = assert_scenario("privacy-redaction"); + + // Determinism: the same input must produce byte-identical exports. + let first = serde_json::to_string(&redacted_export_projection(&analysis)).unwrap(); + let second = serde_json::to_string(&redacted_export_projection(&analysis)).unwrap(); + assert_eq!(first, second, "redacted export must be deterministic"); +} + +// -- Cross-cutting contract ------------------------------------------------- + +#[test] +fn redacted_export_projection_is_idempotent() { + let (analysis, _) = load_scenario("privacy-redaction"); + let once = redacted_export_projection(&analysis); + let twice = redacted_export_projection(&once); + assert_eq!( + serde_json::to_value(&once).unwrap(), + serde_json::to_value(&twice).unwrap() + ); +} + +#[test] +fn analysis_serialization_is_camel_case_and_stable() { + let (analysis, _) = load_scenario("success-device-context"); + let value = serde_json::to_value(&analysis).unwrap(); + + for key in [ + "transactions", + "observations", + "unkeyedObservations", + "coverage", + ] { + assert!(value.get(key).is_some(), "missing top-level key {key}"); + } + let transaction = &value["transactions"][0]; + for key in [ + "key", + "displayName", + "bitness", + "observations", + "lastConfirmedPhase", + "state", + "exitToken", + "attempts", + "confidence", + "evidence", + "nextEvidenceRequest", + ] { + assert!( + transaction.get(key).is_some(), + "missing transaction key {key}" + ); + } +} + +/// Full golden of the redacted export for one representative scenario. +/// +/// Regenerate with `UPDATE_SCRIPT_GOLDEN=1 cargo test --locked -p +/// cmtraceopen-parser --test intune_windows_scripts` and review the diff. +#[test] +fn redacted_export_matches_the_golden() { + let (analysis, _) = load_scenario("success-device-context"); + let redacted = redacted_export_projection(&analysis); + let actual = serde_json::to_string_pretty(&redacted).expect("serialize") + "\n"; + + let golden = fixtures_root().join("success-device-context/expected-full.json"); + if std::env::var("UPDATE_SCRIPT_GOLDEN").is_ok() { + fs::write(&golden, &actual).expect("write golden"); + return; + } + + let expected = fs::read_to_string(&golden).expect("golden must exist; see this test's docs"); + assert_eq!(actual, expected, "redacted export golden drifted"); +} From 8240867cc9a5c094082a73a26fbd1d3decc3de02 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:25:42 -0400 Subject: [PATCH 3/6] fix(intune): close six defects found by review of the script analyzer Code review of #386 surfaced six real defects. Each is fixed with a regression test that fails without the fix. 1. Unconfirmed artifacts could still mint a transaction. `classify_record` extracted policy and run ids before consulting the source kind, so a file that merely happened to be named `AgentExecutor.log` -- one that `classify_artifact` had correctly refused to confirm -- could create a transaction from any line containing a GUID. This directly contradicted the guarantee in `sources.rs`. Identifiers are now extracted only from a confirmed AgentExecutor or primary IME source. HealthScripts is excluded for the mirror-image reason: it is remediation evidence, and letting it carry a key would attach remediation records to a platform-script transaction before #360 defines the handoff. 2. `normalized_utc` was not what its own doc comment claimed. `ime_parser` fills `timestamp_utc` from the *parsing machine's* local offset when a record carries none, which IME records usually do not. The exported value was therefore UTC-labelled but derived from whoever opened the log. Proof that this was user-visible: the previous golden file had this machine's timezone baked into it and would have failed for any developer elsewhere. `ImeLine` now carries `timezone_offset`, and `normalized_utc` is populated only when the record stated its own offset; `original_offset` reports it. 3. Confidence was demoted bundle-wide. A single unrecognised record anywhere in the supplied evidence dropped *every* transaction to low confidence, including fully evidenced ones for unrelated policies -- contradicting the field's own doc comment. The signal is now scoped to the transaction that contains the record. `unknown_version_observed` remains bundle-level, as coverage. 4. Multi-line records skipped command-line redaction entirely. The regex anchored on `$`, which does not match at a line break, so a `-Command` in a multi-line CCM record matched nothing and exported verbatim. Multi-line records are not hypothetical; a fixture already proves them. 5. Redaction covered only `-Command`/`-EncodedCommand`. `-Password`, `-ApiKey`, `-ClientSecret`, and similar credential switches exported their values in full. The vocabulary now covers them. 6. Transaction ordering ignored time even when time was trustworthy. Ordering decides which attempt a retry sequence reports. When every record in a transaction states its own offset, real time is now used; otherwise source order is kept, and the expectation that callers pass rotations oldest-first is written down rather than assumed. Two fixtures were added: `multi-policy-partial-unknown-version` pins fix 3, and `explicit-timezone-offset` pins fix 2 from the positive direction. Verified from the repository root: - `cargo test --locked -p cmtraceopen-parser`: 397 unit, 222 esp integration, 21 `intune_windows_scripts` integration, 1 doc test; 0 failed. - `cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings`: clean. Refs #359 Co-Authored-By: Claude Opus 5 --- .../src/intune/apps/windows/scripts/models.rs | 12 +++- .../intune/apps/windows/scripts/redaction.rs | 57 +++++++++++++-- .../intune/apps/windows/scripts/reducer.rs | 71 +++++++++++++++---- .../src/intune/apps/windows/scripts/rules.rs | 39 ++++++++++ .../src/intune/download_stats.rs | 13 ++++ .../src/intune/event_tracker.rs | 2 + .../src/intune/guid_registry.rs | 1 + .../src/intune/ime_parser.rs | 18 +++-- .../src/intune/policy_parser.rs | 1 + .../AgentExecutor.log | 3 + .../explicit-timezone-offset/expected.json | 29 ++++++++ .../explicit-timezone-offset/manifest.json | 11 +++ .../AgentExecutor.log | 6 ++ .../expected.json | 37 ++++++++++ .../manifest.json | 11 +++ .../success-device-context/expected-full.json | 14 ++-- .../tests/intune_windows_scripts.rs | 54 ++++++++++++++ 17 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/manifest.json diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs index 3f8fb4d5e..36f6fca79 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs @@ -42,9 +42,15 @@ impl ScriptClassifiedString { } } -/// A source timestamp. The original text is always preserved; the UTC form is -/// only populated when the source carried an offset we could resolve, so -/// ordering never silently invents a timezone. +/// A source timestamp. +/// +/// The original text is always preserved. `normalized_utc` is populated **only** +/// when the record embedded its own UTC offset, which `original_offset` then +/// reports. IME records frequently carry no offset at all; in that case the +/// underlying parser can still render a UTC-looking value, but it derives it +/// from the *parsing machine's* local offset rather than from the evidence. A +/// value like that is not source-accurate, so it is deliberately not surfaced +/// here and ordering never silently inherits a timezone the log never stated. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScriptTimestamp { diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs index 86fe4cf0f..e9220c03f 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs @@ -48,12 +48,24 @@ fn user_path_re() -> &'static Regex { }) } -/// A PowerShell command line supplied inline. +/// A command line or credential value supplied inline after a flag. +/// +/// Two properties matter here and both were bugs before: +/// +/// * The value must stop at a line break rather than at the end of the string. +/// A CCM record is one *logical* record and routinely contains newlines (see +/// the `multiline-ccm-record` fixture). Anchoring on `$` meant a `-Command` +/// inside a multi-line record matched nothing at all and leaked in full. +/// * The flag vocabulary covers credential-bearing switches, not just +/// `-Command`. A launch record reading `-Password hunter2` is exactly as +/// sensitive as an inline command and was previously exported verbatim. fn command_line_re() -> &'static Regex { static CELL: OnceLock = OnceLock::new(); CELL.get_or_init(|| { - Regex::new(r"(?i)(?P-(?:Command|EncodedCommand)\s+)(?P\S.*)$") - .expect("command line regex must compile") + Regex::new( + r"(?i)(?P-(?:Command|EncodedCommand|Password|Secret|ClientSecret|Token|AccessToken|ApiKey|Api[-_]?Key|Credential|Authorization)\s+)(?P[^\r\n]+)", + ) + .expect("command line regex must compile") }) } @@ -69,7 +81,7 @@ pub fn redact_text(value: &str) -> String { command_line_re() .replace_all(&masked, |caps: ®ex::Captures<'_>| { - let value = &caps["value"]; + let value = caps["value"].trim_end(); // Already masked: re-masking would hash the token and break // idempotence. if value.starts_with("[command:") && value.ends_with(']') { @@ -183,6 +195,43 @@ mod tests { assert_eq!(once, twice); } + #[test] + fn command_value_is_masked_inside_a_multiline_record() { + // A CCM record is one logical record and may contain newlines. The + // value must be found and masked without an end-of-string anchor. + let record = + "Launching: powershell.exe -Command Set-Secret hunter2\nAt line:1 char:1\n+ throw"; + let redacted = redact_text(record); + assert!(!redacted.contains("hunter2"), "got {redacted:?}"); + assert!( + redacted.contains("At line:1 char:1"), + "the rest of the record must survive: {redacted:?}" + ); + } + + #[test] + fn credential_flags_beyond_command_are_masked() { + for (flag, secret) in [ + ("-Password", "hunter2"), + ("-ApiKey", "abc123def"), + ("-ClientSecret", "s3cr3tvalue"), + ("-Token", "tok-9999"), + ] { + let redacted = redact_text(&format!("powershell.exe {flag} {secret}")); + assert!( + !redacted.contains(secret), + "{flag} leaked its value: {redacted:?}" + ); + } + } + + #[test] + fn multiline_redaction_is_idempotent() { + let once = redact_text("cmd -Command Set-Secret hunter2\nsecond line"); + let twice = redact_text(&once); + assert_eq!(once, twice); + } + #[test] fn public_values_are_left_alone() { let value = ScriptClassifiedString::public("adele.vance@contoso.example"); diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs index 43f87dd65..b332c8e52 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs @@ -20,6 +20,13 @@ use super::models::{ use super::rules::{classify_record, RecordClassification}; use super::sources::{classify_artifact, ScriptSourceInput}; +/// Render a CCM offset in minutes as `+HH:MM` / `-HH:MM`. +fn format_offset(minutes: i32) -> String { + let sign = if minutes < 0 { '-' } else { '+' }; + let absolute = minutes.unsigned_abs(); + format!("{sign}{:02}:{:02}", absolute / 60, absolute % 60) +} + /// Lifecycle rank. A later-ranked signal replaces an earlier one; equal ranks /// resolve to the later record, which is how a retry sequence lands on its /// final attempt instead of its first. @@ -378,12 +385,12 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { let timestamp = line.timestamp.as_ref().map(|raw| ScriptTimestamp { raw_text: raw.clone(), - // `ime_parser` resolves the CCM offset while producing - // `timestamp_utc`; a present `normalized_utc` therefore means - // the offset was valid. The offset text itself is not carried - // on `ImeLine`, so it is reported as absent rather than guessed. - original_offset: None, - normalized_utc: line.timestamp_utc.clone(), + original_offset: line.timezone_offset.map(format_offset), + // Only trust the UTC form when the record stated its own + // offset. Without one, `ime_parser` fills `timestamp_utc` from + // the parsing machine's local offset, which is a property of + // whoever opened the log rather than of the evidence. + normalized_utc: line.timezone_offset.and(line.timestamp_utc.clone()), }); records.push(PendingRecord { @@ -445,12 +452,7 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { let mut transactions: Vec = Vec::new(); for (key, indices) in grouped { - transactions.push(reduce_transaction( - key, - &indices, - &records, - unknown_version_observed, - )); + transactions.push(reduce_transaction(key, &indices, &records)); } let mut missing_expected_sources = Vec::new(); @@ -480,10 +482,42 @@ fn reduce_transaction( key: ScriptTransactionKey, indices: &[usize], records: &[PendingRecord], - unknown_version_observed: bool, ) -> ScriptTransaction { let mut ordered: Vec = indices.to_vec(); - ordered.sort_by_key(|&index| (records[index].artifact_index, records[index].record_number)); + // Ordering decides which attempt a retry sequence reports, so it has to be + // defensible. When every record in this transaction states its own UTC + // offset, real time is the better order and is used. Otherwise we fall back + // to source order, because the alternative -- a UTC value derived from the + // parsing machine's timezone -- is not evidence. + // + // Source order means artifact order as the caller supplied it. That matters + // when one transaction spans a live log and a rotation: the caller is + // expected to pass rotations oldest first. + let all_timestamps_trustworthy = ordered.iter().all(|&index| { + records[index] + .timestamp + .as_ref() + .is_some_and(|timestamp| timestamp.normalized_utc.is_some()) + }); + + if all_timestamps_trustworthy { + ordered.sort_by(|&left, &right| { + let key = |index: usize| { + ( + records[index] + .timestamp + .as_ref() + .and_then(|timestamp| timestamp.normalized_utc.clone()) + .unwrap_or_default(), + records[index].artifact_index, + records[index].record_number, + ) + }; + key(left).cmp(&key(right)) + }); + } else { + ordered.sort_by_key(|&index| (records[index].artifact_index, records[index].record_number)); + } let mut state = ScriptState::InsufficientEvidence; let mut last_confirmed_phase: Option = None; @@ -493,6 +527,7 @@ fn reduce_transaction( let mut has_output_evidence = false; let mut executions = 0u32; let mut transaction_saw_executor = false; + let mut transaction_unknown_version = false; for &index in &ordered { let record = &records[index]; @@ -513,6 +548,12 @@ fn reduce_transaction( if record.source_kind == ScriptSourceKind::AgentExecutor { transaction_saw_executor = true; } + // Scoped to this transaction on purpose. An unrecognised record + // belonging to some other policy in the same bundle must not silently + // demote a fully evidenced result over here. + if classification.execution_shaped_but_unmatched { + transaction_unknown_version = true; + } if classification.bitness != ScriptInterpreterBitness::Unknown && bitness == ScriptInterpreterBitness::Unknown { @@ -551,7 +592,7 @@ fn reduce_transaction( let confidence = confidence_for( state, &key, - unknown_version_observed, + transaction_unknown_version, transaction_saw_executor, ); diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs index 35af56d3f..773f12136 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs @@ -320,6 +320,22 @@ fn extract_ids(message: &str) -> (Option, Option) { pub fn classify_record(source_kind: ScriptSourceKind, message: &str) -> RecordClassification { let mut result = RecordClassification::empty(); + // Identifiers are only extracted from a *confirmed* platform-script source. + // + // Without this gate, an artifact that merely happens to be named + // `AgentExecutor.log` -- and which `classify_artifact` correctly refused to + // confirm -- could still mint a transaction from any line that happened to + // contain a policy GUID. HealthScripts is excluded for the same reason from + // the other direction: it is remediation evidence, and until #360 defines + // the handoff, letting it carry a key would attach remediation records to a + // platform-script transaction. + if !matches!( + source_kind, + ScriptSourceKind::AgentExecutor | ScriptSourceKind::IntuneManagementExtension + ) { + return result; + } + let (policy_id, run_id) = extract_ids(message); result.policy_id = policy_id; result.run_id = run_id; @@ -597,6 +613,29 @@ mod tests { assert!(!result.execution_shaped_but_unmatched); } + #[test] + fn an_unconfirmed_artifact_yields_no_identifiers_at_all() { + // `classify_artifact` refused to confirm this source. If identifiers + // were still extracted, an unrelated file that merely mentions a policy + // GUID could mint a transaction. + let result = classify_record( + ScriptSourceKind::Unknown, + &format!(r"Powershell script is: C:\\Policies\\Scripts\\{POLICY}_{RUN}.ps1"), + ); + assert_eq!(result.policy_id, None); + assert_eq!(result.run_id, None); + assert!(!result.is_key_bearing()); + } + + #[test] + fn health_scripts_records_carry_no_key_into_a_script_transaction() { + let result = classify_record( + ScriptSourceKind::HealthScripts, + &format!("Remediation for PolicyId = {POLICY}"), + ); + assert_eq!(result.policy_id, None); + } + #[test] fn attempt_number_is_extracted_when_present() { assert_eq!(agent("Retry attempt 3 for the script").attempt, Some(3)); diff --git a/crates/cmtraceopen-parser/src/intune/download_stats.rs b/crates/cmtraceopen-parser/src/intune/download_stats.rs index 566935e18..d4fa58a4a 100644 --- a/crates/cmtraceopen-parser/src/intune/download_stats.rs +++ b/crates/cmtraceopen-parser/src/intune/download_stats.rs @@ -527,6 +527,7 @@ mod tests { message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }, ImeLine { line_number: 2, @@ -535,6 +536,7 @@ mod tests { message: "Download completed successfully. Content size: 5242880 bytes, speed: 1048576 Bps, Delivery Optimization: 75.5%".to_string(), component: None, thread: None, + timezone_offset: None, }, ]; @@ -554,6 +556,7 @@ mod tests { message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }, ImeLine { line_number: 2, @@ -562,6 +565,7 @@ mod tests { message: "Content download stalled with no progress for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }, ]; @@ -580,6 +584,7 @@ mod tests { .to_string(), component: None, thread: None, + timezone_offset: None, }]; let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log", &empty_registry()); @@ -596,6 +601,7 @@ mod tests { message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }, ImeLine { line_number: 2, @@ -604,6 +610,7 @@ mod tests { message: "Download failed, retrying content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }, ]; @@ -621,6 +628,7 @@ mod tests { message: "Adding new state transition - From: Install In Progress To: Download In Progress With Event: Download Started.".to_string(), component: None, thread: None, + timezone_offset: None, }]; let downloads = extract_downloads( @@ -640,6 +648,7 @@ mod tests { message: r#"RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"MaxRetries\":3,\"RetryIntervalInMinutes\":5,\"DownloadStartTimeUTC\":\"\\/Date(-62135578800000)\\/\"}"#.to_string(), component: None, thread: None, + timezone_offset: None, }]; let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log", &empty_registry()); @@ -656,6 +665,7 @@ mod tests { message: r#"Starting content download RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Contoso App\"}"#.to_string(), component: None, thread: None, + timezone_offset: None, }, ImeLine { line_number: 2, @@ -664,6 +674,7 @@ mod tests { message: r#"Download completed successfully RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Contoso App\"}"#.to_string(), component: None, thread: None, + timezone_offset: None, }, ]; @@ -686,6 +697,7 @@ mod tests { message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }, ImeLine { line_number: 2, @@ -694,6 +706,7 @@ mod tests { message: "Download completed successfully. Content size: 5242880 bytes, speed: 1048576 Bps, Delivery Optimization: 75.5%".to_string(), component: None, thread: None, + timezone_offset: None, }, ]; diff --git a/crates/cmtraceopen-parser/src/intune/event_tracker.rs b/crates/cmtraceopen-parser/src/intune/event_tracker.rs index 37c04aef6..a28832cff 100644 --- a/crates/cmtraceopen-parser/src/intune/event_tracker.rs +++ b/crates/cmtraceopen-parser/src/intune/event_tracker.rs @@ -1697,6 +1697,7 @@ mod tests { message: message.to_string(), component: None, thread: None, + timezone_offset: None, } } @@ -1710,6 +1711,7 @@ mod tests { message: "Assignment evaluation failed for app with id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, thread: None, + timezone_offset: None, }], "C:/Logs/AppActionProcessor.log", &empty_registry(), diff --git a/crates/cmtraceopen-parser/src/intune/guid_registry.rs b/crates/cmtraceopen-parser/src/intune/guid_registry.rs index 5de5274cc..ecc5c0c3d 100644 --- a/crates/cmtraceopen-parser/src/intune/guid_registry.rs +++ b/crates/cmtraceopen-parser/src/intune/guid_registry.rs @@ -436,6 +436,7 @@ mod tests { message: msg.to_string(), component: None, thread: None, + timezone_offset: None, } } diff --git a/crates/cmtraceopen-parser/src/intune/ime_parser.rs b/crates/cmtraceopen-parser/src/intune/ime_parser.rs index c1f8534cd..c92c5ca11 100644 --- a/crates/cmtraceopen-parser/src/intune/ime_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/ime_parser.rs @@ -17,6 +17,13 @@ pub struct ImeLine { /// CCM `thread=` attribute. Consumers that correlate a sequence of records /// belonging to one operation need this to avoid joining interleaved work. pub thread: Option, + /// Timezone offset in minutes, present only when the record embedded one. + /// + /// `None` means the log carried no offset. `timestamp_utc` is still filled + /// in for display, but it is derived from the *parsing machine's* local + /// offset in that case, so consumers that need trustworthy ordering must + /// check this field rather than assume `timestamp_utc` is source-accurate. + pub timezone_offset: Option, } fn ime_record_re() -> &'static Regex { @@ -45,7 +52,6 @@ struct ParsedImeRecord { severity: Severity, thread_display: Option, source_file: Option, - timezone_offset: Option, format: LogFormat, } @@ -85,7 +91,7 @@ pub fn parse_ime_entries(content: &str, file_path: &str) -> (Vec, u32) source_file: entry.source_file, format: entry.format, file_path: file_path.to_string(), - timezone_offset: entry.timezone_offset, + timezone_offset: entry.line.timezone_offset, error_code_spans: Vec::new(), ip_address: None, host_name: None, @@ -257,13 +263,13 @@ fn parse_record( message, component, thread, + timezone_offset, }, timestamp_millis, timestamp_display, severity, thread_display, source_file, - timezone_offset, format: LogFormat::Ccm, }) } @@ -599,13 +605,13 @@ fn push_unmatched_segment( message: trimmed.to_string(), component: None, thread: None, + timezone_offset: None, }, timestamp_millis: None, timestamp_display: None, severity: detect_severity_from_text(trimmed), thread_display: None, source_file: None, - timezone_offset: None, format: LogFormat::Plain, }); *parse_errors += 1; @@ -638,13 +644,13 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { message: message.clone(), component: None, thread: None, + timezone_offset: None, }, timestamp_millis: None, timestamp_display: timestamp, severity: detect_severity_from_text(&message), thread_display: None, source_file: None, - timezone_offset: None, format: LogFormat::Plain, }); } else { @@ -656,13 +662,13 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { message: trimmed.to_string(), component: None, thread: None, + timezone_offset: None, }, timestamp_millis: None, timestamp_display: None, severity: detect_severity_from_text(trimmed), thread_display: None, source_file: None, - timezone_offset: None, format: LogFormat::Plain, }); } diff --git a/crates/cmtraceopen-parser/src/intune/policy_parser.rs b/crates/cmtraceopen-parser/src/intune/policy_parser.rs index 5189daa92..38db263fd 100644 --- a/crates/cmtraceopen-parser/src/intune/policy_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/policy_parser.rs @@ -239,6 +239,7 @@ mod tests { message: msg.to_string(), component: Some("AppWorkload".to_string()), thread: None, + timezone_offset: None, } } diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/AgentExecutor.log new file mode 100644 index 000000000..aa447180a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/expected.json new file mode 100644 index 000000000..9b70e94e4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/expected.json @@ -0,0 +1,29 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + }, + "timestampMustBe": { + "observationId": "agent:1", + "originalOffset": "+01:00", + "normalizedUtc": "2026-03-12T09:15:22.000Z" + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/manifest.json new file mode 100644 index 000000000..76a26d1f6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/explicit-timezone-offset/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A record carrying its own UTC offset yields a source-accurate normalized time.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/AgentExecutor.log new file mode 100644 index 000000000..6ca2cf5f2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/AgentExecutor.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/expected.json new file mode 100644 index 000000000..d9a256f63 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/expected.json @@ -0,0 +1,37 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result", + "bitness": "unknown" + }, + { + "policyId": "22222222-2222-4222-8222-222222222222", + "runId": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbb2", + "context": "unknown", + "state": "launched", + "lastConfirmedPhase": "launched", + "attempts": 1, + "confidence": "low", + "exitDecimal": null, + "exitRaw": null, + "nextEvidenceRequest": "AgentExecutor.log rotation containing the execution result", + "bitness": "unknown" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": true + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/manifest.json new file mode 100644 index 000000000..0da9443dd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/multi-policy-partial-unknown-version/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "An unrecognised record under one policy must not demote a fully evidenced result under another.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json index 255f98796..2ee5ef60e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json @@ -78,7 +78,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:20.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:20.000Z" + "normalizedUtc": null }, "signal": "policyReceived", "policyId": "11111111-1111-4111-8111-111111111111", @@ -103,7 +103,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:20.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:20.000Z" + "normalizedUtc": null }, "signal": "scheduled", "policyId": "11111111-1111-4111-8111-111111111111", @@ -128,7 +128,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:32.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:32.000Z" + "normalizedUtc": null }, "signal": "unclassified", "policyId": "11111111-1111-4111-8111-111111111111", @@ -153,7 +153,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:32.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:32.000Z" + "normalizedUtc": null }, "signal": "reportSubmitted", "policyId": "11111111-1111-4111-8111-111111111111", @@ -178,7 +178,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:22.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:22.000Z" + "normalizedUtc": null }, "signal": "launchAttempted", "policyId": "11111111-1111-4111-8111-111111111111", @@ -203,7 +203,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:22.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:22.000Z" + "normalizedUtc": null }, "signal": "unclassified", "policyId": "11111111-1111-4111-8111-111111111111", @@ -228,7 +228,7 @@ "timestamp": { "rawText": "03-12-2026 10:15:31.000", "originalOffset": null, - "normalizedUtc": "2026-03-12T14:15:31.000Z" + "normalizedUtc": null }, "signal": "executionCompleted", "policyId": "11111111-1111-4111-8111-111111111111", diff --git a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs index 4b7a6f677..588ebc1b5 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs @@ -160,6 +160,29 @@ fn assert_scenario(scenario: &str) -> ScriptAnalysis { ); } + if let Some(rule) = expected.get("timestampMustBe") { + let id = rule["observationId"].as_str().expect("observationId"); + let observation = analysis + .observations + .iter() + .find(|observation| observation.observation_id == id) + .unwrap_or_else(|| panic!("{scenario}: no observation {id}")); + let timestamp = observation + .timestamp + .as_ref() + .unwrap_or_else(|| panic!("{scenario}: observation {id} has no timestamp")); + assert_eq!( + timestamp.original_offset.as_deref(), + rule["originalOffset"].as_str(), + "{scenario}: original offset" + ); + assert_eq!( + timestamp.normalized_utc.as_deref(), + rule["normalizedUtc"].as_str(), + "{scenario}: normalized utc" + ); + } + if let Some(forbidden) = expected.get("redactionMustNotContain") { let redacted = redacted_export_projection(&analysis); let text = serde_json::to_string(&redacted).expect("redacted analysis must serialize"); @@ -285,6 +308,37 @@ fn privacy_fixture_redacts_deterministically() { assert_eq!(first, second, "redacted export must be deterministic"); } +#[test] +fn an_unrecognised_record_demotes_only_its_own_transaction() { + assert_scenario("multi-policy-partial-unknown-version"); +} + +#[test] +fn a_record_stating_its_own_offset_yields_a_trustworthy_utc_value() { + assert_scenario("explicit-timezone-offset"); +} + +/// A record with no embedded offset must not report a UTC value, because the +/// only one available would be derived from the parsing machine's timezone. +/// Without this rule the golden below would differ per developer machine. +#[test] +fn a_record_without_an_offset_reports_no_normalized_utc() { + let (analysis, _) = load_scenario("success-device-context"); + for observation in &analysis.observations { + let timestamp = observation.timestamp.as_ref().expect("timestamp"); + assert!( + !timestamp.raw_text.is_empty(), + "raw timestamp text must always survive" + ); + assert_eq!( + timestamp.normalized_utc, None, + "{} invented a UTC value from a record with no offset", + observation.observation_id + ); + assert_eq!(timestamp.original_offset, None); + } +} + // -- Cross-cutting contract ------------------------------------------------- #[test] From 3522e3da72f61a26c0682e71044b712651fba93b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:32:37 -0400 Subject: [PATCH 4/6] fix(intune): address Copilot review of the script analyzer Three findings from Copilot's review of #386, all valid. 1. `_AgentExecutor.log` reported rotation ordinal `Some(1)`, which is indistinguishable from an explicit `-1` and would mislead ordering. The underscore prefix says a file is an archive, not which archive, so the ordinal is now `None` -- matching what the surrounding comment already claimed. 2. `report_sent_re` ended in the bare fragment `succe`, an accidental truncation that matched any word with that prefix. It now requires a complete success token with a word boundary. 3. `ScriptSourceKind::ScriptOutput` and `PolicyMetadata` were unreachable, and `ScriptTransaction::display_name` was always `None`. Retained output artifacts are named `{policyId}_{runId}.output` / `.error`, which is not a bare word any file could coincidentally carry -- it encodes both halves of a transaction key -- so they are now classified from that identity and satisfy the "retained script output artifact" evidence request for their own transaction. Their *contents* are deliberately never parsed: raw script stdout is unbounded and frequently sensitive, and the file name is the evidence being claimed. `PolicyMetadata` and `display_name` are removed rather than left dead. There is no supplied-metadata input contract in this issue, so neither could ever be populated, and a serialized field that is structurally always null is worse than an absent one. Fixture `nonzero-exit-with-retained-artifact` pins the third fix: the same bundle that asks for an output artifact when it is absent asks for the reporting half instead once it is supplied. Verified from the repository root: - `cargo test --locked -p cmtraceopen-parser`: 399 unit, 222 esp integration, 22 `intune_windows_scripts` integration, 1 doc test; 0 failed. - `cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings`: clean. Refs #359 Co-Authored-By: Claude Opus 5 --- .../src/intune/apps/windows/scripts/models.rs | 4 +- .../intune/apps/windows/scripts/redaction.rs | 12 +-- .../intune/apps/windows/scripts/reducer.rs | 28 +++++- .../src/intune/apps/windows/scripts/rules.rs | 2 +- .../intune/apps/windows/scripts/sources.rs | 90 ++++++++++++++++--- ...aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error | 2 + .../AgentExecutor.log | 3 + .../expected.json | 24 +++++ .../manifest.json | 17 ++++ .../success-device-context/expected-full.json | 1 - .../tests/intune_windows_scripts.rs | 6 +- 11 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/manifest.json diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs index 36f6fca79..d09783c43 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs @@ -79,8 +79,9 @@ pub enum ScriptSourceKind { IntuneManagementExtension, AgentExecutor, HealthScripts, + /// A retained `{policyId}_{runId}.output` / `.error` artifact. Its contents + /// are raw script output and are never parsed; its *name* is the evidence. ScriptOutput, - PolicyMetadata, Unknown, } @@ -219,7 +220,6 @@ pub struct ScriptTransactionKey { #[serde(rename_all = "camelCase")] pub struct ScriptTransaction { pub key: ScriptTransactionKey, - pub display_name: Option, pub bitness: ScriptInterpreterBitness, /// Observation ids in source order. pub observations: Vec, diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs index e9220c03f..eba0fe5c3 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs @@ -125,15 +125,9 @@ pub fn redacted_export_projection(analysis: &ScriptAnalysis) -> ScriptAnalysis { coverage.artifacts = coverage.artifacts.iter().map(redact_artifact).collect(); ScriptAnalysis { - transactions: analysis - .transactions - .iter() - .map(|transaction| { - let mut redacted = transaction.clone(); - redacted.display_name = transaction.display_name.as_ref().map(redact_classified); - redacted - }) - .collect(), + // Transactions carry no free text of their own; everything sensitive + // lives on the observations and artifacts below. + transactions: analysis.transactions.clone(), observations: analysis .observations .iter() diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs index b332c8e52..175e2ce0e 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs @@ -18,7 +18,7 @@ use super::models::{ ScriptTransaction, ScriptTransactionKey, }; use super::rules::{classify_record, RecordClassification}; -use super::sources::{classify_artifact, ScriptSourceInput}; +use super::sources::{classify_artifact, output_artifact_identity, ScriptSourceInput}; /// Render a CCM offset in minutes as `+HH:MM` / `-HH:MM`. fn format_offset(minutes: i32) -> String { @@ -363,6 +363,8 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { let mut records: Vec = Vec::new(); let mut unclassified_records = 0u32; let mut unknown_version_observed = false; + // (policy, run) pairs proven to have a retained output artifact. + let mut output_artifact_keys: BTreeSet<(String, String)> = BTreeSet::new(); for (artifact_index, input) in inputs.iter().enumerate() { let lines = parse_ime_content(&input.content); @@ -372,6 +374,16 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { let source_kind = artifact.source_kind; artifacts.push(artifact); + // A retained output artifact is evidence by its identity alone. Its + // contents are raw script stdout/stderr -- unbounded and frequently + // sensitive -- so they are registered, never parsed. + if source_kind == ScriptSourceKind::ScriptOutput { + if let Some(identity) = output_artifact_identity(input) { + output_artifact_keys.insert(identity); + } + continue; + } + for (record_index, line) in lines.iter().enumerate() { let classification = classify_record(source_kind, &line.message); if classification.execution_shaped_but_unmatched { @@ -452,7 +464,15 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { let mut transactions: Vec = Vec::new(); for (key, indices) in grouped { - transactions.push(reduce_transaction(key, &indices, &records)); + let has_output_artifact = key.run_id.as_ref().is_some_and(|run| { + output_artifact_keys.contains(&(key.policy_id.clone(), run.clone())) + }); + transactions.push(reduce_transaction( + key, + &indices, + &records, + has_output_artifact, + )); } let mut missing_expected_sources = Vec::new(); @@ -482,6 +502,7 @@ fn reduce_transaction( key: ScriptTransactionKey, indices: &[usize], records: &[PendingRecord], + has_output_artifact: bool, ) -> ScriptTransaction { let mut ordered: Vec = indices.to_vec(); // Ordering decides which attempt a retry sequence reports, so it has to be @@ -524,7 +545,7 @@ fn reduce_transaction( let mut exit_token: Option = None; let mut attempts = 0u32; let mut bitness = ScriptInterpreterBitness::Unknown; - let mut has_output_evidence = false; + let mut has_output_evidence = has_output_artifact; let mut executions = 0u32; let mut transaction_saw_executor = false; let mut transaction_unknown_version = false; @@ -598,7 +619,6 @@ fn reduce_transaction( ScriptTransaction { key, - display_name: None, bitness, observations: ordered .iter() diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs index 773f12136..d1dd64285 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs @@ -164,7 +164,7 @@ fn report_sent_re() -> &'static Regex { static CELL: OnceLock = OnceLock::new(); re( &CELL, - r"(?i)\bresult\s+(?:has\s+been\s+)?sent\s+to\s+(?:the\s+)?service\s+successfully\b|\breport(?:ed|ing)?\s+result\s+succe", + r"(?i)\bresult\s+(?:has\s+been\s+)?sent\s+to\s+(?:the\s+)?service\s+successfully\b|\breport(?:ed|ing)?\s+result\s+succe(?:ss|ssfully|eded)\b", ) } diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs index 5db948386..304cc3b69 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs @@ -42,18 +42,18 @@ fn split_rotation(file_name: &str) -> (String, Option) { .or_else(|| trimmed.strip_suffix(".LOG")) .unwrap_or(trimmed); - // `_Name` marks an archived copy; treat the underscore as rotation evidence - // without an ordinal we can trust to be sequential. + // `_Name` marks an archived copy. It says the file is a rotation but not + // which one, so the ordinal stays `None`; reporting `Some(1)` would make it + // indistinguishable from an explicit `-1` and mislead ordering. let (without_ext, underscore_archive) = match without_ext.strip_prefix('_') { Some(rest) => (rest, true), None => (without_ext, false), }; + let live_ordinal = if underscore_archive { None } else { Some(0) }; + let Some((stem, suffix)) = without_ext.rsplit_once('-') else { - return ( - without_ext.to_string(), - if underscore_archive { Some(1) } else { Some(0) }, - ); + return (without_ext.to_string(), live_ordinal); }; if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { @@ -77,14 +77,54 @@ fn split_rotation(file_name: &str) -> (String, Option) { } } - ( - without_ext.to_string(), - if underscore_archive { Some(1) } else { Some(0) }, - ) + (without_ext.to_string(), live_ordinal) +} + +/// `{policyId}_{runId}.output` / `.error` -- a retained script output artifact. +/// +/// Unlike `AgentExecutor.log`, this name is not a bare word that any file could +/// coincidentally carry: it encodes both halves of a transaction key plus a +/// known extension, which is structure enough to classify on. The file's +/// *contents* are raw script stdout/stderr and are deliberately never parsed. +fn output_artifact_key(file_name: &str) -> Option<(String, String)> { + let trimmed = file_name.trim(); + let stem = trimmed + .strip_suffix(".output") + .or_else(|| trimmed.strip_suffix(".error")) + .or_else(|| trimmed.strip_suffix(".OUTPUT")) + .or_else(|| trimmed.strip_suffix(".ERROR"))?; + + let (policy, run) = stem.split_once('_')?; + if !is_guid(policy) || !is_guid(run) { + return None; + } + Some((policy.to_ascii_lowercase(), run.to_ascii_lowercase())) +} + +fn is_guid(value: &str) -> bool { + let groups = [8usize, 4, 4, 4, 12]; + let mut parts = value.split('-'); + for expected in groups { + let Some(part) = parts.next() else { + return false; + }; + if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + } + parts.next().is_none() +} + +/// The transaction key a retained output artifact belongs to, if it is one. +pub fn output_artifact_identity(input: &ScriptSourceInput) -> Option<(String, String)> { + output_artifact_key(&input.file_name) } /// The source kind a file name suggests, before content confirms it. fn candidate_from_name(file_name: &str) -> ScriptSourceKind { + if output_artifact_key(file_name).is_some() { + return ScriptSourceKind::ScriptOutput; + } let (stem, _) = split_rotation(file_name); match stem.to_ascii_lowercase().as_str() { "intunemanagementextension" => ScriptSourceKind::IntuneManagementExtension, @@ -120,7 +160,10 @@ pub fn classify_artifact( let candidate = candidate_from_name(&input.file_name); let (_, rotation_ordinal) = split_rotation(&input.file_name); - let source_kind = if components_confirm(candidate, components) { + let source_kind = if candidate == ScriptSourceKind::ScriptOutput { + // Self-identifying by name; there is no CCM component to confirm. + candidate + } else if components_confirm(candidate, components) { candidate } else { ScriptSourceKind::Unknown @@ -210,10 +253,31 @@ mod tests { } #[test] - fn underscore_archive_form_is_recognised_as_a_rotation() { + fn underscore_archive_form_is_a_rotation_without_a_trustworthy_ordinal() { let (stem, ordinal) = split_rotation("_AgentExecutor.log"); assert_eq!(stem, "AgentExecutor"); - assert_eq!(ordinal, Some(1)); + // Not `Some(1)`: that would be indistinguishable from `-1`. + assert_eq!(ordinal, None); + } + + #[test] + fn retained_output_artifact_is_classified_from_its_encoded_identity() { + let policy = "11111111-1111-4111-8111-111111111111"; + let run = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1"; + let artifact = classify_artifact(&input(&format!("{policy}_{run}.output")), &[]); + assert_eq!(artifact.source_kind, ScriptSourceKind::ScriptOutput); + assert_eq!( + output_artifact_key(&format!("{policy}_{run}.error")), + Some((policy.to_string(), run.to_string())) + ); + } + + #[test] + fn an_arbitrary_output_file_is_not_a_script_output_artifact() { + assert_eq!(output_artifact_key("build.output"), None); + assert_eq!(output_artifact_key("notaguid_alsonotaguid.output"), None); + let artifact = classify_artifact(&input("build.output"), &[]); + assert_eq!(artifact.source_kind, ScriptSourceKind::Unknown); } #[test] diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error new file mode 100644 index 000000000..175c1938c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error @@ -0,0 +1,2 @@ +Set-ItemProperty : Requested registry access is not allowed. +At line:12 char:5 diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/AgentExecutor.log new file mode 100644 index 000000000..4e5bfa9c2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/expected.json new file mode 100644 index 000000000..b793f4b29 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 1, + "exitRaw": "1", + "bitness": "unknown", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/manifest.json new file mode 100644 index 000000000..80c3f2cba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-retained-artifact/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "A retained output artifact is proven by its file name; its contents are never parsed.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + }, + { + "artifactId": "output", + "fileName": "11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error", + "filePath": "C:\\Program Files (x86)\\Microsoft Intune Management Extension\\Policies\\Results\\11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error", + "contentFile": "11111111-1111-4111-8111-111111111111_aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1.error" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json index 2ee5ef60e..eeff73627 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json @@ -6,7 +6,6 @@ "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", "context": "system" }, - "displayName": null, "bitness": "bit64", "observations": [ "ime:1", diff --git a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs index 588ebc1b5..1bbf906dc 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs @@ -339,6 +339,11 @@ fn a_record_without_an_offset_reports_no_normalized_utc() { } } +#[test] +fn a_retained_output_artifact_satisfies_the_output_evidence_request() { + assert_scenario("nonzero-exit-with-retained-artifact"); +} + // -- Cross-cutting contract ------------------------------------------------- #[test] @@ -368,7 +373,6 @@ fn analysis_serialization_is_camel_case_and_stable() { let transaction = &value["transactions"][0]; for key in [ "key", - "displayName", "bitness", "observations", "lastConfirmedPhase", From 00512a1d9c4392a8dcfaea95fdf46aaa080b48c0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:46:10 -0400 Subject: [PATCH 5/6] fix(intune): address CodeRabbit review of the script analyzer Six of seven CodeRabbit findings were valid and are fixed here. The seventh is answered rather than implemented; see the end. 1. A Windows profile name containing a space was only partially masked. Both halves of the `user` capture excluded `\s`, so `C:\Users\John Doe\...` exported as `C:\Users\[user:...] Doe\...` and leaked the surname. The segment is now bounded by the path separator, a quote, or the line break. This is the most serious finding in the batch: it is a live privacy leak on a path Windows permits. 2. Retained output artifacts were parsed before being skipped. The module promises never to read their contents -- they are raw script stdout, which is unbounded and often sensitive -- but the skip ran after `parse_ime_content` had already built an owned line vector for the whole file. Classification is now name-first, so the promise is kept in fact and not only in the comment. 3. An exit code outside the u32 range rendered a truncated hex view: `4294967297` printed as `0x00000001`, a different number than the log recorded. Out-of-range values now carry no hex form at all. 4. The primary IME log is shared by every workload, and record classification did not check the component. A `Win32App` record mentioning "Get policies" and a policy GUID produced a platform-script signal and a policy key -- inventing a script transaction out of app-deployment evidence. Record signals now require a script-scope component. `Win32App` still confirms that the *file* is the IME log; it just cannot speak for this workload. 5. `FailedToLaunch` asked for a "retained script output artifact". A process that never started cannot have written one, so this sent an operator after an artifact that does not exist. It now asks for the surrounding launch context. 6. The `nonzero-exit-with-output` manifest described a retained error artifact it does not contain; that belongs to `nonzero-exit-with-retained-artifact`. Corrected. Not implemented: CodeRabbit also observed that `download_stats` and `event_tracker` still read `timestamp_utc` unconditionally and stay machine-timezone dependent for offset-less records. That is true and pre-existing. `ImeLine::timezone_offset` reports provenance and does not change `timestamp_utc`, so nothing here regresses those paths. Changing how the Intune workspace orders records is a behavioural change that needs its own commit and its own regression coverage, not a field addition. The contract is narrowed in the field's documentation instead, which is the alternative CodeRabbit offered. Verified from the repository root: - `cargo test --locked -p cmtraceopen-parser`: 404 unit, 222 esp integration, 22 `intune_windows_scripts` integration, 1 doc test; 0 failed. - `cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings`: clean. - `git diff --check`: clean. Refs #359 Co-Authored-By: Claude Opus 5 --- .../src/intune/apps/windows/scripts/mod.rs | 2 +- .../intune/apps/windows/scripts/redaction.rs | 35 ++++++- .../intune/apps/windows/scripts/reducer.rs | 41 ++++++--- .../src/intune/apps/windows/scripts/rules.rs | 92 +++++++++++++++++-- .../intune/apps/windows/scripts/sources.rs | 8 ++ .../src/intune/ime_parser.rs | 8 ++ .../scripts/launch-failure/expected.json | 2 +- .../nonzero-exit-with-output/manifest.json | 2 +- 8 files changed, 163 insertions(+), 27 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs index 20f64f7bb..f17230a63 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/mod.rs @@ -58,4 +58,4 @@ pub use models::*; pub use redaction::{redact_text, redacted_export_projection}; pub use reducer::analyze_script_bundle; pub use rules::{classify_record, RecordClassification}; -pub use sources::{classify_artifact, ScriptSourceInput}; +pub use sources::{candidate_source_kind, classify_artifact, ScriptSourceInput}; diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs index eba0fe5c3..0e3323b13 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs @@ -41,9 +41,14 @@ fn upn_re() -> &'static Regex { fn user_path_re() -> &'static Regex { static CELL: OnceLock = OnceLock::new(); CELL.get_or_init(|| { + // The segment is bounded by the path separator, a quote, or the end of + // the line -- not by whitespace. Windows permits spaces in profile + // directory names, and bounding on `\s` masked only the first word: + // `C:\Users\John Doe\...` leaked "Doe" into the export. + // // The leading `[` exclusion is what makes the projection idempotent: // an already-masked `[user:...]` segment must not be masked again. - Regex::new(r"(?i)(?P[\\/]Users[\\/])(?P[^\\/\s\x22\[][^\\/\s\x22]*)") + Regex::new(r"(?i)(?P[\\/]Users[\\/])(?P[^\\/\r\n\x22\[][^\\/\r\n\x22]*)") .expect("user path regex must compile") }) } @@ -76,7 +81,17 @@ pub fn redact_text(value: &str) -> String { }); let masked = user_path_re().replace_all(&masked, |caps: ®ex::Captures<'_>| { - format!("{}{}", &caps["prefix"], stable_token("user", &caps["user"])) + // Trailing whitespace is not part of the profile name; keeping it out + // of the hashed value means `C:\Users\John Doe ` and `C:\Users\John Doe` + // still resolve to the same user. + let user = caps["user"].trim_end(); + let trailing = &caps["user"][user.len()..]; + format!( + "{}{}{}", + &caps["prefix"], + stable_token("user", user), + trailing + ) }); command_line_re() @@ -189,6 +204,22 @@ mod tests { assert_eq!(once, twice); } + #[test] + fn a_profile_name_containing_a_space_is_fully_masked() { + // Windows allows spaces in profile directory names. Bounding the + // segment on whitespace leaked everything after the first word. + let redacted = redact_text(r"C:\Users\John Doe\AppData\Local\Temp\out.txt"); + assert!(!redacted.contains("John"), "got {redacted:?}"); + assert!(!redacted.contains("Doe"), "got {redacted:?}"); + assert!(redacted.ends_with(r"\AppData\Local\Temp\out.txt")); + } + + #[test] + fn a_spaced_profile_name_is_still_idempotent() { + let once = redact_text(r"C:\Users\John Doe\a.ps1"); + assert_eq!(once, redact_text(&once)); + } + #[test] fn command_value_is_masked_inside_a_multiline_record() { // A CCM record is one logical record and may contain newlines. The diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs index 175e2ce0e..8eda2cabf 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs @@ -18,7 +18,9 @@ use super::models::{ ScriptTransaction, ScriptTransactionKey, }; use super::rules::{classify_record, RecordClassification}; -use super::sources::{classify_artifact, output_artifact_identity, ScriptSourceInput}; +use super::sources::{ + candidate_source_kind, classify_artifact, output_artifact_identity, ScriptSourceInput, +}; /// Render a CCM offset in minutes as `+HH:MM` / `-HH:MM`. fn format_offset(minutes: i32) -> String { @@ -303,9 +305,15 @@ fn next_evidence_request(state: ScriptState, has_output_evidence: bool) -> Optio ScriptState::Launched => { Some("AgentExecutor.log rotation containing the execution result".to_string()) } - ScriptState::ExitedNonZero | ScriptState::TimedOut | ScriptState::FailedToLaunch - if !has_output_evidence => - { + // A process that never started cannot have written script output, so + // asking for it would send an operator after an artifact that does not + // exist. Ask for the surrounding launch context instead. + ScriptState::FailedToLaunch => Some( + "AgentExecutor.log context around the failed launch, and the \ + IntuneManagementExtension.log record for this policy" + .to_string(), + ), + ScriptState::ExitedNonZero | ScriptState::TimedOut if !has_output_evidence => { Some("retained script output artifact for this policy and run".to_string()) } ScriptState::ExitedZero | ScriptState::ExitedNonZero => { @@ -367,25 +375,30 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { let mut output_artifact_keys: BTreeSet<(String, String)> = BTreeSet::new(); for (artifact_index, input) in inputs.iter().enumerate() { - let lines = parse_ime_content(&input.content); - let components: Vec> = - lines.iter().map(|line| line.component.clone()).collect(); - let artifact = classify_artifact(input, &components); - let source_kind = artifact.source_kind; - artifacts.push(artifact); - // A retained output artifact is evidence by its identity alone. Its // contents are raw script stdout/stderr -- unbounded and frequently - // sensitive -- so they are registered, never parsed. - if source_kind == ScriptSourceKind::ScriptOutput { + // sensitive -- so they are registered, never parsed. The check is on + // the *name* and runs before parsing, because parsing first and + // discarding after would pay full time and peak memory for data the + // module has just promised never to read. + if candidate_source_kind(input) == ScriptSourceKind::ScriptOutput { + artifacts.push(classify_artifact(input, &[])); if let Some(identity) = output_artifact_identity(input) { output_artifact_keys.insert(identity); } continue; } + let lines = parse_ime_content(&input.content); + let components: Vec> = + lines.iter().map(|line| line.component.clone()).collect(); + let artifact = classify_artifact(input, &components); + let source_kind = artifact.source_kind; + artifacts.push(artifact); + for (record_index, line) in lines.iter().enumerate() { - let classification = classify_record(source_kind, &line.message); + let classification = + classify_record(source_kind, line.component.as_deref(), &line.message); if classification.execution_shaped_but_unmatched { unknown_version_observed = true; } diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs index d1dd64285..557695c2a 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs @@ -247,7 +247,15 @@ fn parse_exit_token(raw: &str) -> ScriptExitToken { let value = digits.parse::().ok(); let signed = value.map(|v| if negative { -v } else { v }); // Render the hex form for the unsigned 32-bit view operators expect. - let hex_text = signed.map(|v| format!("0x{:08X}", v as u32)); + // A value outside that view gets no hex form at all: truncating it + // would print a different number than the log recorded. + let hex_text = signed + .and_then(|v| { + u32::try_from(v) + .ok() + .or_else(|| i32::try_from(v).ok().map(|v| v as u32)) + }) + .map(|v| format!("0x{v:08X}")); (signed, hex_text) }; @@ -317,7 +325,32 @@ fn extract_ids(message: &str) -> (Option, Option) { /// `source_kind` gates which vocabulary applies: an `AgentExecutor` phrase in /// the primary IME log is not treated as an execution record, because the two /// files describe different halves of the lifecycle. -pub fn classify_record(source_kind: ScriptSourceKind, message: &str) -> RecordClassification { +/// Components whose records belong to the platform-script workload. +/// +/// The primary IME log is shared by every workload. `Win32App` confirms that a +/// *file* is the IME log, but a `Win32App` record saying "Get policies" is app +/// deployment, not a platform script -- classifying it here would invent a +/// script transaction out of another workload's evidence. +const SCRIPT_SCOPE_COMPONENTS: &[&str] = &["PowerShell", "IntuneManagementExtension"]; + +fn component_is_in_scope(source_kind: ScriptSourceKind, component: Option<&str>) -> bool { + let expected: &[&str] = match source_kind { + ScriptSourceKind::IntuneManagementExtension => SCRIPT_SCOPE_COMPONENTS, + ScriptSourceKind::AgentExecutor => &["AgentExecutor"], + _ => return false, + }; + component.is_some_and(|component| { + expected + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(component)) + }) +} + +pub fn classify_record( + source_kind: ScriptSourceKind, + component: Option<&str>, + message: &str, +) -> RecordClassification { let mut result = RecordClassification::empty(); // Identifiers are only extracted from a *confirmed* platform-script source. @@ -329,10 +362,7 @@ pub fn classify_record(source_kind: ScriptSourceKind, message: &str) -> RecordCl // the other direction: it is remediation evidence, and until #360 defines // the handoff, letting it carry a key would attach remediation records to a // platform-script transaction. - if !matches!( - source_kind, - ScriptSourceKind::AgentExecutor | ScriptSourceKind::IntuneManagementExtension - ) { + if !component_is_in_scope(source_kind, component) { return result; } @@ -445,11 +475,19 @@ mod tests { const RUN: &str = "66666666-7777-8888-9999-000000000000"; fn agent(message: &str) -> RecordClassification { - classify_record(ScriptSourceKind::AgentExecutor, message) + classify_record( + ScriptSourceKind::AgentExecutor, + Some("AgentExecutor"), + message, + ) } fn ime(message: &str) -> RecordClassification { - classify_record(ScriptSourceKind::IntuneManagementExtension, message) + classify_record( + ScriptSourceKind::IntuneManagementExtension, + Some("PowerShell"), + message, + ) } #[test] @@ -587,6 +625,7 @@ mod tests { fn health_scripts_records_never_classify_as_a_script_signal() { let result = classify_record( ScriptSourceKind::HealthScripts, + Some("HealthScripts"), "Powershell execution is done, exitCode = 1", ); assert_eq!(result.signal, ScriptSignal::Unclassified); @@ -620,6 +659,7 @@ mod tests { // GUID could mint a transaction. let result = classify_record( ScriptSourceKind::Unknown, + Some("AgentExecutor"), &format!(r"Powershell script is: C:\\Policies\\Scripts\\{POLICY}_{RUN}.ps1"), ); assert_eq!(result.policy_id, None); @@ -631,11 +671,47 @@ mod tests { fn health_scripts_records_carry_no_key_into_a_script_transaction() { let result = classify_record( ScriptSourceKind::HealthScripts, + Some("HealthScripts"), &format!("Remediation for PolicyId = {POLICY}"), ); assert_eq!(result.policy_id, None); } + #[test] + fn a_win32app_record_in_the_ime_log_is_not_a_platform_script_signal() { + // The primary IME log is shared by every workload. Only script-scope + // components may produce a script signal or a policy key. + let result = classify_record( + ScriptSourceKind::IntuneManagementExtension, + Some("Win32App"), + &format!("Get policies for policy {POLICY}"), + ); + assert_eq!(result.signal, ScriptSignal::Unclassified); + assert_eq!(result.policy_id, None); + } + + #[test] + fn a_record_without_a_component_cannot_produce_a_signal() { + let result = classify_record( + ScriptSourceKind::IntuneManagementExtension, + None, + &format!("Processing policy with id = {POLICY}"), + ); + assert_eq!(result.signal, ScriptSignal::Unclassified); + assert_eq!(result.policy_id, None); + } + + #[test] + fn out_of_range_exit_codes_get_no_truncated_hex_view() { + // 4294967297 truncates to 1 in a u32 cast; printing 0x00000001 would + // report a different number than the log recorded. + let result = agent("Powershell execution is done, exitCode = 4294967297"); + let token = result.exit_token.unwrap(); + assert_eq!(token.decimal, Some(4_294_967_297)); + assert_eq!(token.hex_text, None); + assert_eq!(token.raw_text, "4294967297"); + } + #[test] fn attempt_number_is_extracted_when_present() { assert_eq!(agent("Retry attempt 3 for the script").attempt, Some(3)); diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs index 304cc3b69..94071fbbe 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs @@ -134,6 +134,14 @@ fn candidate_from_name(file_name: &str) -> ScriptSourceKind { } } +/// The source kind a file name suggests, before any content is read. +/// +/// Exposed so a caller can decide not to parse an artifact at all -- which is +/// what the reducer does for retained script output. +pub fn candidate_source_kind(input: &ScriptSourceInput) -> ScriptSourceKind { + candidate_from_name(&input.file_name) +} + /// Does any record component confirm the candidate? fn components_confirm(candidate: ScriptSourceKind, components: &[Option]) -> bool { let expected: &[&str] = match candidate { diff --git a/crates/cmtraceopen-parser/src/intune/ime_parser.rs b/crates/cmtraceopen-parser/src/intune/ime_parser.rs index c92c5ca11..6f99ead32 100644 --- a/crates/cmtraceopen-parser/src/intune/ime_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/ime_parser.rs @@ -23,6 +23,14 @@ pub struct ImeLine { /// in for display, but it is derived from the *parsing machine's* local /// offset in that case, so consumers that need trustworthy ordering must /// check this field rather than assume `timestamp_utc` is source-accurate. + /// + /// This field reports provenance; it does not change `timestamp_utc`. + /// Existing consumers such as `download_stats` and `event_tracker` still + /// take `timestamp_utc` unconditionally and therefore remain + /// machine-timezone dependent for offset-less records. That is pre-existing + /// behaviour and is deliberately left alone here: changing how the Intune + /// workspace orders records is a behavioural change that belongs in its own + /// commit with its own regression coverage, not in a field addition. pub timezone_offset: Option, } diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json index d3bb64ad3..142c9828b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/launch-failure/expected.json @@ -10,7 +10,7 @@ "confidence": "high", "exitDecimal": null, "exitRaw": null, - "nextEvidenceRequest": "retained script output artifact for this policy and run", + "nextEvidenceRequest": "AgentExecutor.log context around the failed launch, and the IntuneManagementExtension.log record for this policy", "bitness": "unknown" } ], diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json index 6d6bd970f..add78373f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/nonzero-exit-with-output/manifest.json @@ -1,5 +1,5 @@ { - "description": "A nonzero exit with a retained error artifact: the outcome is reported, not diagnosed.", + "description": "A nonzero exit whose output was captured by an in-log record: the outcome is reported, not diagnosed. The retained artifact form is covered by nonzero-exit-with-retained-artifact.", "artifacts": [ { "artifactId": "agent", From f09632ca2bbc80bc318e8aea140743e1f9516966 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:57:12 -0400 Subject: [PATCH 6/6] fix(intune): stop coverage inflation and stale exit tokens Two findings from the second review cycle on #386, both valid. 1. Out-of-scope records inflated platform-script coverage. The previous commit correctly stopped `Win32App` records in the shared IME log from producing a script signal, but the reducer then counted those empty classifications in `unclassified_records`. Because the primary IME log carries every workload, the effect was that a busier device reported worse platform-script coverage than a quiet one, for reasons having nothing to do with scripts. `RecordClassification` now carries `in_scope`, and only in-scope records can be counted as unclassified script records. 2. A transaction could report a stale exit code. `exit_token` was only overwritten when a completion record carried a readable code, so a later completion we could not parse left the earlier attempt's code in place. The state in that situation is `exitedNonZero` -- a completion whose code is unreadable cannot claim success -- so the transaction displayed `exitedNonZero` next to exit code `0`, a contradiction the evidence does not support. The assignment is now unconditional, including `None`. Two fixtures pin the behaviour. `shared-ime-log-other-workload` puts three `Win32App` records in the same IME log as a real script transaction and asserts that script coverage stays at zero. `later-completion-without-readable-code` runs two attempts where the second completion has no readable code and asserts the earlier `0` does not survive. Verified from the repository root: - `cargo test --locked -p cmtraceopen-parser`: 405 unit, 222 esp integration, 24 `intune_windows_scripts` integration, 1 doc test; 0 failed. - `cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings`: clean. Refs #359 Co-Authored-By: Claude Opus 5 --- .../intune/apps/windows/scripts/reducer.rs | 16 +++++++++--- .../src/intune/apps/windows/scripts/rules.rs | 19 ++++++++++++++ .../AgentExecutor.log | 6 +++++ .../expected.json | 24 +++++++++++++++++ .../manifest.json | 11 ++++++++ .../AgentExecutor.log | 3 +++ .../IntuneManagementExtension.log | 4 +++ .../expected.json | 23 ++++++++++++++++ .../manifest.json | 17 ++++++++++++ .../tests/intune_windows_scripts.rs | 26 +++++++++++++++++++ 10 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/AgentExecutor.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/IntuneManagementExtension.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/manifest.json diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs index 8eda2cabf..03487bbc1 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs @@ -402,7 +402,11 @@ pub fn analyze_script_bundle(inputs: &[ScriptSourceInput]) -> ScriptAnalysis { if classification.execution_shaped_but_unmatched { unknown_version_observed = true; } - if classification.signal == ScriptSignal::Unclassified + // Only in-scope records can be "unclassified script records". A + // Win32App line in the shared IME log is another workload's + // evidence, not a gap in this one's coverage. + if classification.in_scope + && classification.signal == ScriptSignal::Unclassified && classification.policy_id.is_none() { unclassified_records += 1; @@ -611,10 +615,14 @@ fn reduce_transaction( // The exit token always reflects the most recent completion record, so // a retry that finally succeeds does not keep reporting the old code. + // + // This assigns unconditionally, including `None`. A later completion + // whose code we could not read leaves the transaction in + // `ExitedNonZero`; continuing to display the previous attempt's `0` + // alongside that state would be a contradiction the evidence does not + // support. if classification.signal == ScriptSignal::ExecutionCompleted { - if let Some(token) = &classification.exit_token { - exit_token = Some(token.clone()); - } + exit_token = classification.exit_token.clone(); } } diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs index 557695c2a..d2b8d509d 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs @@ -205,6 +205,12 @@ pub struct RecordClassification { /// True when the record looks like platform-script execution but matched no /// signal rule. The reducer turns this into a coverage flag, never a state. pub execution_shaped_but_unmatched: bool, + /// False when the record belongs to another workload sharing the same log. + /// + /// The primary IME log carries every workload's records. An out-of-scope + /// record is not an *unclassified script record*; counting it as one would + /// make platform-script coverage look worse the busier the device is. + pub in_scope: bool, } impl RecordClassification { @@ -218,6 +224,7 @@ impl RecordClassification { attempt: None, exit_token: None, execution_shaped_but_unmatched: false, + in_scope: false, } } @@ -365,6 +372,7 @@ pub fn classify_record( if !component_is_in_scope(source_kind, component) { return result; } + result.in_scope = true; let (policy_id, run_id) = extract_ids(message); result.policy_id = policy_id; @@ -690,6 +698,17 @@ mod tests { assert_eq!(result.policy_id, None); } + #[test] + fn out_of_scope_records_are_marked_out_of_scope() { + let result = classify_record( + ScriptSourceKind::IntuneManagementExtension, + Some("Win32App"), + &format!("Get policies for policy {POLICY}"), + ); + assert!(!result.in_scope); + assert!(ime("Processing policy with id = 11111111-1111-4111-8111-111111111111").in_scope); + } + #[test] fn a_record_without_a_component_cannot_produce_a_signal() { let result = classify_record( diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/AgentExecutor.log new file mode 100644 index 000000000..bee2014b6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/AgentExecutor.log @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/expected.json new file mode 100644 index 000000000..215035c8c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/expected.json @@ -0,0 +1,24 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedNonZero", + "lastConfirmedPhase": "executed", + "attempts": 2, + "confidence": "high", + "exitDecimal": null, + "exitRaw": null, + "bitness": "unknown", + "nextEvidenceRequest": "retained script output artifact for this policy and run" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [ + "IntuneManagementExtension.log" + ], + "unknownVersionObserved": false + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/manifest.json new file mode 100644 index 000000000..4ff514341 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/later-completion-without-readable-code/manifest.json @@ -0,0 +1,11 @@ +{ + "description": "A later completion with no readable exit code must not keep reporting the previous attempt's code.", + "artifacts": [ + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/AgentExecutor.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/AgentExecutor.log new file mode 100644 index 000000000..8cde8b47f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/AgentExecutor.log @@ -0,0 +1,3 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/IntuneManagementExtension.log b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/IntuneManagementExtension.log new file mode 100644 index 000000000..59666a7a1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/IntuneManagementExtension.log @@ -0,0 +1,4 @@ + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/expected.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/expected.json new file mode 100644 index 000000000..b9e0db445 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/expected.json @@ -0,0 +1,23 @@ +{ + "transactions": [ + { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "unknown", + "state": "exitedZero", + "lastConfirmedPhase": "executed", + "attempts": 1, + "confidence": "high", + "exitDecimal": 0, + "exitRaw": "0", + "bitness": "unknown", + "nextEvidenceRequest": "IntuneManagementExtension.log records reporting this result" + } + ], + "unkeyedObservationCount": 0, + "coverage": { + "missingExpectedSources": [], + "unknownVersionObserved": false + }, + "unclassifiedRecords": 0 +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/manifest.json new file mode 100644 index 000000000..e04a156ce --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/shared-ime-log-other-workload/manifest.json @@ -0,0 +1,17 @@ +{ + "description": "Another workload's records share the IME log; they must not create a script transaction nor inflate script coverage.", + "artifacts": [ + { + "artifactId": "ime", + "fileName": "IntuneManagementExtension.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "contentFile": "IntuneManagementExtension.log" + }, + { + "artifactId": "agent", + "fileName": "AgentExecutor.log", + "filePath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\AgentExecutor.log", + "contentFile": "AgentExecutor.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs index 1bbf906dc..e31184b2e 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs @@ -145,6 +145,13 @@ fn assert_scenario(scenario: &str) -> ScriptAnalysis { "{scenario}: unknown version flag" ); + if let Some(count) = expected.get("unclassifiedRecords") { + assert_eq!( + value["coverage"]["unclassifiedRecords"], *count, + "{scenario}: unclassified record count" + ); + } + if let Some(rule) = expected.get("messageMustContain") { let id = rule["observationId"].as_str().expect("observationId"); let text = rule["text"].as_str().expect("text"); @@ -344,6 +351,25 @@ fn a_retained_output_artifact_satisfies_the_output_evidence_request() { assert_scenario("nonzero-exit-with-retained-artifact"); } +/// The primary IME log is shared by every Intune workload. Another workload's +/// records must neither create a script transaction nor make script coverage +/// look worse -- otherwise a busy device reports worse coverage than a quiet one. +#[test] +fn other_workload_records_in_the_shared_ime_log_are_ignored() { + let analysis = assert_scenario("shared-ime-log-other-workload"); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!(analysis.coverage.unclassified_records, 0); +} + +/// A completion record whose code could not be read leaves the transaction in +/// `exitedNonZero`. Continuing to display an earlier attempt's `0` next to that +/// state would be a contradiction the evidence does not support. +#[test] +fn a_later_completion_without_a_readable_code_clears_the_earlier_one() { + let analysis = assert_scenario("later-completion-without-readable-code"); + assert!(analysis.transactions[0].exit_token.is_none()); +} + // -- Cross-cutting contract ------------------------------------------------- #[test]