Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/cmtraceopen-parser/src/intune/apps/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions crates/cmtraceopen-parser/src/intune/apps/windows/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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#"<![LOG[Starting Powershell Execution]LOG]!><time="10:15:22.100+000" "#,
//! r#"date="3-12-2026" component="AgentExecutor" context="" type="1" thread="12" file="">"#,
//! "\n",
//! r#"<![LOG[Powershell script is: C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\11111111-2222-3333-4444-555555555555_66666666-7777-8888-9999-000000000000.ps1]LOG]!><time="10:15:22.200+000" "#,
//! r#"date="3-12-2026" component="AgentExecutor" context="" type="1" thread="12" file="">"#,
//! "\n",
//! r#"<![LOG[Powershell execution is done, exitCode = 0]LOG]!><time="10:15:31.900+000" "#,
//! r#"date="3-12-2026" component="AgentExecutor" context="" type="1" thread="12" file="">"#,
//! );
//!
//! 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};
261 changes: 261 additions & 0 deletions crates/cmtraceopen-parser/src/intune/apps/windows/scripts/models.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> Self {
Self {
value: value.into(),
sensitivity: ScriptSensitivity::Public,
}
}

pub fn sensitive(value: impl Into<String>) -> 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<String>,
pub normalized_utc: Option<String>,
}

/// 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<u32>,
}

/// 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<i64>,
pub hex_text: Option<String>,
}

/// 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<ScriptClassifiedString>,
pub source_kind: ScriptSourceKind,
/// Rotation ordinal when the file name identifies one; 0 is the live file.
pub rotation_ordinal: Option<u32>,
}

/// 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<ScriptTimestamp>,
pub signal: ScriptSignal,
pub policy_id: Option<String>,
pub run_id: Option<String>,
pub context: ScriptExecutionContext,
pub bitness: ScriptInterpreterBitness,
pub attempt: Option<u32>,
pub exit_token: Option<ScriptExitToken>,
/// 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<String>,
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<String>,
pub last_confirmed_phase: Option<ScriptPhase>,
pub state: ScriptState,
pub exit_token: Option<ScriptExitToken>,
/// Number of distinct launch attempts proven by evidence.
pub attempts: u32,
pub confidence: ScriptConfidence,
pub evidence: Vec<ScriptEvidenceRef>,
/// The smallest artifact that would advance this diagnosis.
pub next_evidence_request: Option<String>,
}

/// 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<ScriptArtifact>,
/// 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<String>,
/// 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<ScriptTransaction>,
pub observations: Vec<ScriptObservation>,
/// 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<String>,
pub coverage: ScriptCoverage,
}
Loading