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..f17230a63 --- /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::{candidate_source_kind, 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..d09783c43 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs @@ -0,0 +1,261 @@ +//! 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. `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 { + 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, + /// A retained `{policyId}_{runId}.output` / `.error` artifact. Its contents + /// are raw script output and are never parsed; its *name* is the evidence. + ScriptOutput, + 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 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..0e3323b13 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/redaction.rs @@ -0,0 +1,268 @@ +//! 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 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[^\\/\r\n\x22\[][^\\/\r\n\x22]*)") + .expect("user path regex must compile") + }) +} + +/// 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|Password|Secret|ClientSecret|Token|AccessToken|ApiKey|Api[-_]?Key|Credential|Authorization)\s+)(?P[^\r\n]+)", + ) + .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<'_>| { + // 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() + .replace_all(&masked, |caps: ®ex::Captures<'_>| { + 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(']') { + 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 carry no free text of their own; everything sensitive + // lives on the observations and artifacts below. + transactions: analysis.transactions.clone(), + 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 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 + // 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"); + 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..03487bbc1 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/reducer.rs @@ -0,0 +1,659 @@ +//! 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::{ + 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 { + 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. +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()) + } + // 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 => { + 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; + // (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() { + // 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. 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.component.as_deref(), &line.message); + if classification.execution_shaped_but_unmatched { + unknown_version_observed = true; + } + // 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; + } + + let timestamp = line.timestamp.as_ref().map(|raw| ScriptTimestamp { + raw_text: raw.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 { + 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 { + 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(); + 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], + 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 + // 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; + let mut exit_token: Option = None; + let mut attempts = 0u32; + let mut bitness = ScriptInterpreterBitness::Unknown; + let mut has_output_evidence = has_output_artifact; + let mut executions = 0u32; + let mut transaction_saw_executor = false; + let mut transaction_unknown_version = 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; + } + // 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 + { + 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. + // + // 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 { + exit_token = classification.exit_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, + transaction_unknown_version, + transaction_saw_executor, + ); + + ScriptTransaction { + key, + 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..d2b8d509d --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/rules.rs @@ -0,0 +1,745 @@ +//! 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(?:ss|ssfully|eded)\b", + ) +} + +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, + /// 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 { + 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, + in_scope: 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. + // 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) + }; + + 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. +/// 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. + // + // 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 !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; + 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, + Some("AgentExecutor"), + message, + ) + } + + fn ime(message: &str) -> RecordClassification { + classify_record( + ScriptSourceKind::IntuneManagementExtension, + Some("PowerShell"), + 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, + Some("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 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, + Some("AgentExecutor"), + &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, + 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 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( + 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)); + } + + #[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..94071fbbe --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/scripts/sources.rs @@ -0,0 +1,304 @@ +//! 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. 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(), live_ordinal); + }; + + 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(), 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, + "agentexecutor" => ScriptSourceKind::AgentExecutor, + "healthscripts" => ScriptSourceKind::HealthScripts, + _ => ScriptSourceKind::Unknown, + } +} + +/// 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 { + 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 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 + }; + + 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_a_rotation_without_a_trustworthy_ordinal() { + let (stem, ordinal) = split_rotation("_AgentExecutor.log"); + assert_eq!(stem, "AgentExecutor"); + // 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] + 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/download_stats.rs b/crates/cmtraceopen-parser/src/intune/download_stats.rs index 54de24bd5..d4fa58a4a 100644 --- a/crates/cmtraceopen-parser/src/intune/download_stats.rs +++ b/crates/cmtraceopen-parser/src/intune/download_stats.rs @@ -526,6 +526,8 @@ mod tests { timestamp_utc: None, 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, @@ -533,6 +535,8 @@ 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, + timezone_offset: None, }, ]; @@ -551,6 +555,8 @@ mod tests { timestamp_utc: None, 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, @@ -558,6 +564,8 @@ 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, + timezone_offset: None, }, ]; @@ -575,6 +583,8 @@ mod tests { message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890" .to_string(), component: None, + thread: None, + timezone_offset: None, }]; let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log", &empty_registry()); @@ -590,6 +600,8 @@ mod tests { timestamp_utc: None, 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, @@ -597,6 +609,8 @@ 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, + timezone_offset: None, }, ]; @@ -613,6 +627,8 @@ 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, + timezone_offset: None, }]; let downloads = extract_downloads( @@ -631,6 +647,8 @@ 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, + timezone_offset: None, }]; let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log", &empty_registry()); @@ -646,6 +664,8 @@ 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, + timezone_offset: None, }, ImeLine { line_number: 2, @@ -653,6 +673,8 @@ 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, + timezone_offset: None, }, ]; @@ -674,6 +696,8 @@ mod tests { timestamp_utc: None, 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, @@ -681,6 +705,8 @@ 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, + timezone_offset: None, }, ]; diff --git a/crates/cmtraceopen-parser/src/intune/event_tracker.rs b/crates/cmtraceopen-parser/src/intune/event_tracker.rs index a8619ff93..a28832cff 100644 --- a/crates/cmtraceopen-parser/src/intune/event_tracker.rs +++ b/crates/cmtraceopen-parser/src/intune/event_tracker.rs @@ -1696,6 +1696,8 @@ mod tests { timestamp_utc: Some(timestamp.to_string()), message: message.to_string(), component: None, + thread: None, + timezone_offset: None, } } @@ -1708,6 +1710,8 @@ 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, + 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 fb3829102..ecc5c0c3d 100644 --- a/crates/cmtraceopen-parser/src/intune/guid_registry.rs +++ b/crates/cmtraceopen-parser/src/intune/guid_registry.rs @@ -435,6 +435,8 @@ mod tests { timestamp_utc: None, 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 ebb3b6f59..6f99ead32 100644 --- a/crates/cmtraceopen-parser/src/intune/ime_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/ime_parser.rs @@ -14,6 +14,24 @@ 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, + /// 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. + /// + /// 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, } fn ime_record_re() -> &'static Regex { @@ -40,10 +58,8 @@ struct ParsedImeRecord { timestamp_millis: Option, timestamp_display: Option, severity: Severity, - thread: Option, thread_display: Option, source_file: Option, - timezone_offset: Option, format: LogFormat, } @@ -78,12 +94,12 @@ 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, 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, @@ -254,14 +270,14 @@ fn parse_record( timestamp_utc: line_timestamp_utc, message, component, + thread, + timezone_offset, }, timestamp_millis, timestamp_display, severity, - thread, thread_display, source_file, - timezone_offset, format: LogFormat::Ccm, }) } @@ -596,14 +612,14 @@ fn push_unmatched_segment( timestamp_utc: None, message: trimmed.to_string(), component: None, + thread: None, + timezone_offset: None, }, timestamp_millis: None, timestamp_display: None, severity: detect_severity_from_text(trimmed), - thread: None, thread_display: None, source_file: None, - timezone_offset: None, format: LogFormat::Plain, }); *parse_errors += 1; @@ -635,14 +651,14 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { timestamp_utc, message: message.clone(), component: None, + thread: None, + timezone_offset: None, }, timestamp_millis: None, timestamp_display: timestamp, severity: detect_severity_from_text(&message), - thread: None, thread_display: None, source_file: None, - timezone_offset: None, format: LogFormat::Plain, }); } else { @@ -653,14 +669,14 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { timestamp_utc: None, message: trimmed.to_string(), component: None, + thread: None, + timezone_offset: None, }, timestamp_millis: None, timestamp_display: None, severity: detect_severity_from_text(trimmed), - thread: None, thread_display: None, source_file: None, - timezone_offset: None, format: LogFormat::Plain, }); } 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/src/intune/policy_parser.rs b/crates/cmtraceopen-parser/src/intune/policy_parser.rs index d7b5ef7fd..38db263fd 100644 --- a/crates/cmtraceopen-parser/src/intune/policy_parser.rs +++ b/crates/cmtraceopen-parser/src/intune/policy_parser.rs @@ -238,6 +238,8 @@ mod tests { timestamp_utc: None, 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/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/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/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/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..142c9828b --- /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": "AgentExecutor.log context around the failed launch, and the IntuneManagementExtension.log record for this policy", + "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/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/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..add78373f --- /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 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", + "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-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/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/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/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..eeff73627 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/windows/scripts/success-device-context/expected-full.json @@ -0,0 +1,277 @@ +{ + "transactions": [ + { + "key": { + "policyId": "11111111-1111-4111-8111-111111111111", + "runId": "aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1", + "context": "system" + }, + "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": null + }, + "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": null + }, + "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": null + }, + "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": null + }, + "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": null + }, + "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": null + }, + "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": null + }, + "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..e31184b2e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/intune_windows_scripts.rs @@ -0,0 +1,437 @@ +//! 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(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"); + 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(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"); + 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"); +} + +#[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); + } +} + +#[test] +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] +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", + "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"); +}