Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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