diff --git a/.gitignore b/.gitignore index a90874f7f..7507ce7d2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ dist/ # Rust src-tauri/target/ +src-tauri/target-test/debug/ +src-tauri/target-test/release/ +src-tauri/target-test # IDE .vscode/ @@ -18,3 +21,5 @@ Thumbs.db # Env .env .env.local + + diff --git a/DSREGCMD_TROUBLESHOOTING.md b/DSREGCMD_TROUBLESHOOTING.md new file mode 100644 index 000000000..0326b808e --- /dev/null +++ b/DSREGCMD_TROUBLESHOOTING.md @@ -0,0 +1,332 @@ +# DSRegCmd Troubleshooting + +This guide explains how to use the DSRegCmd workspace in CMTrace Open to +triage Microsoft Entra join, hybrid join, PRT, MDM visibility, and Windows +Hello for Business issues without manually reading raw `dsregcmd /status` +output line by line. + +![DSRegCmd workspace overview](references/dsregcmd1.png) + +## What This Workspace Is For + +The DSRegCmd workspace is designed to answer four questions quickly: + +1. What join posture does this device actually have? +2. Which stage is failing right now: discovery, auth, join, or post-join? +3. How trustworthy is this capture? +4. Do registry-backed Windows Hello for Business policy signals explain what + `dsregcmd` did not report directly? + +The workspace does not replace raw output. It reorganizes it into a triage view +so you can move from symptom to likely cause faster. + +## Supported Input Methods + +You can analyze DSRegCmd data in four ways: + +1. `Capture` +2. `Paste` +3. `Open Text File` +4. `Open Evidence Folder` + +### Capture + +`Capture` runs a live `dsregcmd /status` collection and stages a temporary +bundle that includes both command output and sibling registry evidence. + +This is the preferred option when you are troubleshooting the local device and +want the highest-confidence result. + +### Paste + +`Paste` is useful when someone already copied `dsregcmd /status` output from a +remote machine or ticket. + +Use this when you only have text and no supporting evidence bundle. + +### Open Text File + +`Open Text File` loads a plain text `dsregcmd /status` capture. + +If that file lives inside a supported evidence bundle, the workspace will also +try to discover the bundle root and load sibling registry evidence. + +### Open Evidence Folder + +`Open Evidence Folder` accepts any of these folder shapes: + +1. Bundle root +2. Bundle `evidence` folder +3. Bundle `command-output` folder + +The expected command output file is: + +```text +evidence/command-output/dsregcmd-status.txt +``` + +Top-level `dsregcmd-status.txt` is also supported when it exists inside a valid +bundle root. + +Standalone folders that only contain a loose `dsregcmd-status.txt` file are not +treated as evidence bundles. + +## Evidence Bundle Layout + +Live capture and bundle-based analysis work best when the evidence looks like +this: + +```text +bundle-root/ + manifest.json + evidence/ + command-output/ + dsregcmd-status.txt + registry/ + policymanager-device.reg + policymanager-providers.reg + hkcu-policies.reg + hklm-policies.reg + hkcu-microsoft-policies.reg + hklm-microsoft-policies.reg +``` + +Those registry exports let the analyzer correlate Windows Hello for Business +policy even when `dsregcmd` leaves policy-backed fields unreported. + +## How To Read The Workspace + +### 1. Start With The Header Cards + +The top cards are the fastest read of the capture: + +1. `Join Type` +2. `Current Stage` +3. `Capture Confidence` +4. `PRT State` +5. `MDM Signals` +6. `NGC` +7. `Certificate` + +The first two cards tell you what the device looks like and where the current +problem appears to sit. + +`Capture Confidence` matters more than most people expect. A stale bundle or a +SYSTEM remote-session capture can make user-scoped token state look worse or +different than the real interactive user session. + +![Health summary and issue-driven triage](references/dsregcmd2.png) + +### 2. Read The Health Summary Before The Raw Facts + +The `Health Summary` section gives you a short narrative of the current result, +including: + +1. source type +2. derived join posture +3. dominant failure stage +4. confidence note +5. highest-priority issue + +The `Issue spotlight` is the fastest way to explain the likely root cause to +someone else without handing them the raw command output. + +### 3. Use Issues Overview For Root-Cause Triage + +`Issues Overview` contains the ordered diagnostic findings produced by the Rust +analyzer. + +Each issue card includes: + +1. severity +2. category +3. summary +4. evidence lines +5. recommended checks +6. suggested fixes + +This is where federation, SCP, DRS discovery, PRT, TPM, and Windows Hello +signals are turned into actionable troubleshooting guidance. + +### 4. Use Facts By Group To Verify The Diagnosis + +The grouped facts are the proof layer behind the issue cards. The main groups +to pay attention to are: + +1. `Phase and Confidence` +2. `Join State` +3. `Management and MDM` +4. `SSO and PRT` +5. `Diagnostics and Errors` +6. `Pre-Join and Registration` +7. `Windows Hello and NGC` +8. `Policy Evidence` +9. `Source Details` + +Use `Show Not Reported Fields` when you need to see exactly what the source did +not provide. + +This is especially useful when comparing a live capture against a pasted or +historical bundle. + +![Grouped facts including Windows Hello and policy evidence](references/dsregcmd3.png) + +### 5. Treat Policy Evidence As Supporting Context, Not Magic + +The `Policy Evidence` group exists to fill in gaps when `dsregcmd` does not +surface Windows Hello for Business policy clearly. + +It can derive evidence from: + +1. PolicyManager current device state +2. PolicyManager provider state +3. Windows machine policy hive +4. Windows user policy hive + +That includes PassportForWork values under Microsoft policy hives such as: + +```text +HKLM\SOFTWARE\Microsoft\Policies\PassportForWork +HKCU\SOFTWARE\Microsoft\Policies\PassportForWork +``` + +If policy values are still `Not Reported`, that does not automatically mean the +feature is broken. It can also mean the captured registry artifacts simply do +not contain mapped PassportForWork values. + +### 6. Use Timeline And Flows For Sanity Checking + +The `Timeline` and `Flows` sections help you verify whether the rest of the +analysis makes sense in time and context. + +The timeline surfaces key timestamps such as: + +1. certificate validity +2. previous PRT attempt +3. Azure AD PRT update time +4. client reference time + +The flow boxes compress the state into practical readouts for: + +1. current phase +2. join posture +3. device authentication +4. management +5. PRT and session +6. NGC readiness +7. capture trust + +If the flow boxes and grouped facts disagree, trust the grouped facts and raw +evidence first. + +## Practical Troubleshooting Workflow + +Use this order when you are triaging a real device: + +1. Capture a fresh live result if possible. +2. Check `Current Stage` and `Capture Confidence` first. +3. Read the first error in `Issues Overview`. +4. Verify the evidence lines in `Facts by Group`. +5. Check whether the issue is pre-join, auth, join, or post-join. +6. Review `Policy Evidence` only when Windows Hello or NGC fields are unclear. +7. Export JSON or Summary when you need to attach the result to a case. + +This keeps you from overreacting to low-signal fields like missing MDM URLs or +N/A-style Windows Hello fields that are context-dependent. + +## Interpreting Common Signals + +### Capture Confidence + +`High` usually means a recent interactive capture from the current session. + +`Medium` means the capture is still usable, but freshness or context is not +ideal. + +`Low` usually means one of these: + +1. remote-session SYSTEM capture +2. old historical evidence +3. missing timing context + +### Missing MDM Fields + +Missing `MdmUrl` or `MdmComplianceUrl` is not proof that the device is broken. + +Those fields are tenant-, scope-, and context-dependent. Treat them as part of +the whole picture, not as standalone failure proof. + +### Windows Hello And NGC + +The workspace treats Windows Hello data as readiness context unless stronger +signals exist. + +The most important fields are: + +1. `NgcSet` +2. `Policy Enabled` +3. `Post-Logon Enabled` +4. `Device Eligible` +5. `PreReq Result` +6. `Key Sign Test` +7. `AAD Recovery Enabled` + +For example: + +1. `Key Sign Test: FAILED` usually points to current WHfB key health trouble. +2. `AAD Recovery Enabled: YES` suggests the device is already in a recovery + path. +3. `Policy Enabled: No` can be explained by registry-backed policy evidence even + when raw `dsregcmd` text is incomplete. + +### Source Details + +Always check `Source Details` before making a hard call. It tells you: + +1. where the analysis came from +2. which file was actually resolved +3. which evidence file was used +4. how much text was processed + +That helps prevent debugging the wrong file or an older capture by mistake. + +## Export Options + +The workspace supports: + +1. `Copy Status` +2. `Copy JSON` +3. `Copy Summary` +4. `Save JSON` +5. `Save Summary` +6. `Show Raw Input` + +Use JSON when another engineer needs the full structured result. + +Use Summary when you need a short human-readable handoff. + +Use Raw Input when you need to compare the analyzer output to the original +`dsregcmd /status` lines. + +![Export controls and final handoff workflow](references/dsregcmd4.png) + +## Recommended Usage Notes + +1. Prefer live capture over pasted text when you can reproduce locally. +2. Prefer bundle analysis over loose text when registry-backed WHfB policy is + relevant. +3. Treat low-confidence results as directional, not conclusive. +4. Compare issue cards against grouped facts before remediation. +5. Re-run capture after a fix and confirm the stage or evidence actually moves. + +## Bottom Line + +The DSRegCmd workspace is most effective when used as a staged triage tool: + +1. identify the failure stage +2. assess whether the capture is trustworthy +3. verify the diagnosis against grouped facts +4. use registry-backed policy evidence only where it adds signal + +If you follow that order, the workspace is much faster and more reliable than +reading raw `dsregcmd /status` output in isolation. diff --git a/references/dsregcmd1.png b/references/dsregcmd1.png new file mode 100644 index 000000000..622db88b0 Binary files /dev/null and b/references/dsregcmd1.png differ diff --git a/references/dsregcmd2.png b/references/dsregcmd2.png new file mode 100644 index 000000000..65583081f Binary files /dev/null and b/references/dsregcmd2.png differ diff --git a/references/dsregcmd3.png b/references/dsregcmd3.png new file mode 100644 index 000000000..f32cf134d Binary files /dev/null and b/references/dsregcmd3.png differ diff --git a/references/dsregcmd4.png b/references/dsregcmd4.png new file mode 100644 index 000000000..117721c04 Binary files /dev/null and b/references/dsregcmd4.png differ diff --git a/scripts/collection/intune-evidence-profile.json b/scripts/collection/intune-evidence-profile.json index 25a541955..339b5f1a2 100644 --- a/scripts/collection/intune-evidence-profile.json +++ b/scripts/collection/intune-evidence-profile.json @@ -1,6 +1,6 @@ { "profileName": "intune-windows-endpoint-v1", - "profileVersion": "1.3.0", + "profileVersion": "1.3.2", "logs": [ { "id": "ime-logs", @@ -94,6 +94,13 @@ "fileName": "policymanager-device.reg", "notes": "Current device PolicyManager state relevant to MDM troubleshooting." }, + { + "id": "policymanager-providers", + "family": "policymanager-provider-state", + "path": "HKLM\\SOFTWARE\\Microsoft\\PolicyManager\\Providers", + "fileName": "policymanager-providers.reg", + "notes": "Provider-delivered PolicyManager state used to compare intended values against the current effective device policy." + }, { "id": "autopilot-esp-diagnostics", "family": "autopilot-esp-diagnostics", @@ -128,6 +135,41 @@ "path": "HKLM\\SOFTWARE\\Microsoft\\Provisioning\\OMADM\\SyncML\\ODJApplied", "fileName": "omadm-syncml-odjapplied.reg", "notes": "Hybrid join offline domain join application marker referenced during Autopilot and ODJ troubleshooting." + }, + { + "id": "cloud-domain-join-tenantinfo", + "family": "cloud-domain-join-tenantinfo", + "path": "HKLM\\SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin\\TenantInfo", + "fileName": "cloud-domain-join-tenantinfo.reg", + "notes": "Cloud domain join tenant metadata, including MDM enrollment URL hints that help correlate tenant-scoped policy evidence." + }, + { + "id": "policy-hkcu", + "family": "windows-user-policy-hive", + "path": "HKCU\\Software\\Policies", + "fileName": "hkcu-policies.reg", + "notes": "User-scoped Windows policy hive captured alongside dsregcmd evidence for future deterministic policy correlation." + }, + { + "id": "policy-hklm", + "family": "windows-machine-policy-hive", + "path": "HKLM\\Software\\Policies", + "fileName": "hklm-policies.reg", + "notes": "Machine-scoped Windows policy hive captured alongside dsregcmd evidence for future deterministic policy correlation." + }, + { + "id": "policy-microsoft-hkcu", + "family": "windows-user-microsoft-policy-hive", + "path": "HKCU\\Software\\Microsoft\\Policies", + "fileName": "hkcu-microsoft-policies.reg", + "notes": "User-scoped Microsoft policy hive captured for PassportForWork and related policy correlation not covered by the generic Policies export." + }, + { + "id": "policy-microsoft-hklm", + "family": "windows-machine-microsoft-policy-hive", + "path": "HKLM\\Software\\Microsoft\\Policies", + "fileName": "hklm-microsoft-policies.reg", + "notes": "Machine-scoped Microsoft policy hive captured for PassportForWork and related policy correlation not covered by the generic Policies export." } ], "eventLogs": [ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 11eff772e..591f9ed13 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -438,6 +438,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", + "tempfile", "tokio", ] @@ -4140,6 +4141,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b11977520..a704fddb3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tokio = { version = "1", features = ["fs", "io-util", "sync", "rt"] } [dev-dependencies] criterion = "0.5" +tempfile = "3" [[bench]] name = "intune_pipeline" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0e8f63aac..3941b5625 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,9 +11,10 @@ "dialog:allow-open", "dialog:allow-save", "fs:default", - "fs:allow-read-file", - "fs:allow-read-dir", + "fs:read-all", + "fs:write-all", "clipboard-manager:default", + "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text" ] } diff --git a/src-tauri/src/commands/dsregcmd.rs b/src-tauri/src/commands/dsregcmd.rs new file mode 100644 index 000000000..90835af2a --- /dev/null +++ b/src-tauri/src/commands/dsregcmd.rs @@ -0,0 +1,882 @@ +use crate::dsregcmd::{analyze_text, registry, DsregcmdAnalysisResult}; + +use serde::{Deserialize, Serialize}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::ffi::c_void; +#[cfg(target_os = "windows")] +use std::fs::{self, File}; +#[cfg(target_os = "windows")] +use std::os::windows::ffi::OsStrExt; +#[cfg(target_os = "windows")] +use std::os::windows::io::AsRawHandle; +#[cfg(target_os = "windows")] +use std::path::PathBuf; +#[cfg(target_os = "windows")] +use std::ptr::{null, null_mut}; +#[cfg(target_os = "windows")] +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdCaptureResult { + pub input: String, + pub bundle_path: Option, + pub evidence_file_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DsregcmdPathSourceKind { + File, + Folder, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdResolvedSource { + pub input: String, + pub bundle_path: Option, + pub resolved_path: Option, + pub evidence_file_path: Option, +} + +#[tauri::command] +pub fn analyze_dsregcmd( + input: String, + bundle_path: Option, +) -> Result { + eprintln!( + "event=dsregcmd_analysis_start input_chars={} input_lines={}", + input.len(), + input.lines().count() + ); + + let mut result = analyze_text(&input)?; + + if let Some(bundle_path) = bundle_path.as_deref() { + result.policy_evidence = registry::load_whfb_policy_evidence(Path::new(bundle_path)); + } + + eprintln!( + "event=dsregcmd_analysis_complete diagnostics_count={} join_type={:?}", + result.diagnostics.len(), + result.derived.join_type + ); + + Ok(result) +} + +#[tauri::command] +pub fn capture_dsregcmd() -> Result { + capture_dsregcmd_impl() +} + +#[tauri::command] +pub fn load_dsregcmd_source( + kind: DsregcmdPathSourceKind, + path: String, +) -> Result { + load_dsregcmd_source_impl(kind, Path::new(&path)) +} + +#[cfg(target_os = "windows")] +fn capture_dsregcmd_impl() -> Result { + eprintln!("event=dsregcmd_capture_start platform=windows"); + + cleanup_old_capture_bundles(); + + let dsregcmd_path = resolve_system32_binary("dsregcmd.exe")?; + verify_dsregcmd_signature(&dsregcmd_path)?; + + let output = std::process::Command::new(&dsregcmd_path) + .arg("/status") + .output() + .map_err(|error| { + format!( + "Failed to execute '{}' /status: {}", + dsregcmd_path.display(), + error + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let exit_code = output.status.code().unwrap_or_default(); + return Err(if stderr.is_empty() { + format!("dsregcmd.exe /status failed with exit code {}", exit_code) + } else { + format!( + "dsregcmd.exe /status failed with exit code {}: {}", + exit_code, stderr + ) + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let capture_bundle = stage_live_capture_bundle(&stdout)?; + + eprintln!( + "event=dsregcmd_capture_complete platform=windows stdout_chars={} stdout_lines={} bundle_path={}", + stdout.len(), + stdout.lines().count(), + capture_bundle.bundle_path.display() + ); + + Ok(DsregcmdCaptureResult { + input: stdout, + bundle_path: Some(capture_bundle.bundle_path.to_string_lossy().to_string()), + evidence_file_path: Some(capture_bundle.evidence_file_path.to_string_lossy().to_string()), + }) +} + +#[cfg(not(target_os = "windows"))] +fn capture_dsregcmd_impl() -> Result { + Err("dsregcmd capture is only supported on Windows.".to_string()) +} + +#[cfg(target_os = "windows")] +fn load_dsregcmd_source_impl( + kind: DsregcmdPathSourceKind, + path: &Path, +) -> Result { + match kind { + DsregcmdPathSourceKind::File => { + let input = fs::read_to_string(path).map_err(|error| { + format!( + "Failed to read the dsregcmd file '{}': {}", + path.display(), + error + ) + })?; + let bundle_path = resolve_bundle_root_from_file_path(path) + .map(|value| value.to_string_lossy().to_string()); + Ok(DsregcmdResolvedSource { + input, + bundle_path, + resolved_path: Some(path.to_string_lossy().to_string()), + evidence_file_path: Some(path.to_string_lossy().to_string()), + }) + } + DsregcmdPathSourceKind::Folder => { + let (bundle_path, evidence_file_path) = resolve_folder_bundle_evidence(path)?; + let input = fs::read_to_string(&evidence_file_path).map_err(|error| { + format!( + "Failed to read the dsregcmd evidence file '{}': {}", + evidence_file_path.display(), + error + ) + })?; + Ok(DsregcmdResolvedSource { + input, + bundle_path: Some(bundle_path.to_string_lossy().to_string()), + resolved_path: Some(evidence_file_path.to_string_lossy().to_string()), + evidence_file_path: Some(evidence_file_path.to_string_lossy().to_string()), + }) + } + } +} + +#[cfg(not(target_os = "windows"))] +fn load_dsregcmd_source_impl( + _kind: DsregcmdPathSourceKind, + _path: &Path, +) -> Result { + Err("dsregcmd source loading is only supported on Windows.".to_string()) +} + +#[cfg(target_os = "windows")] +struct LiveCaptureBundle { + bundle_path: PathBuf, + evidence_file_path: PathBuf, +} + +#[cfg(target_os = "windows")] +struct RegistryExportSpec { + key_path: &'static str, + file_name: &'static str, +} + +#[cfg(target_os = "windows")] +const LIVE_CAPTURE_REGISTRY_EXPORTS: &[RegistryExportSpec] = &[ + RegistryExportSpec { + key_path: r"HKLM\SOFTWARE\Microsoft\PolicyManager\Current\Device", + file_name: "policymanager-device.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\SOFTWARE\Microsoft\PolicyManager\Providers", + file_name: "policymanager-providers.reg", + }, + RegistryExportSpec { + key_path: r"HKCU\Software\Policies", + file_name: "hkcu-policies.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\Software\Policies", + file_name: "hklm-policies.reg", + }, + RegistryExportSpec { + key_path: r"HKCU\Software\Microsoft\Policies", + file_name: "hkcu-microsoft-policies.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\Software\Microsoft\Policies", + file_name: "hklm-microsoft-policies.reg", + }, +]; + +#[cfg(target_os = "windows")] +const DSREGCMD_EVIDENCE_RELATIVE_PATH: [&str; 3] = ["evidence", "command-output", "dsregcmd-status.txt"]; +#[cfg(target_os = "windows")] +const DSREGCMD_TOP_LEVEL_FALLBACK_FILE: &str = "dsregcmd-status.txt"; +#[cfg(target_os = "windows")] +const MANIFEST_FILE: &str = "manifest.json"; +#[cfg(target_os = "windows")] +const EVIDENCE_FOLDER_NAME: &str = "evidence"; +#[cfg(target_os = "windows")] +const COMMAND_OUTPUT_FOLDER_NAME: &str = "command-output"; + +#[cfg(target_os = "windows")] +fn stage_live_capture_bundle(stdout: &str) -> Result { + let bundle_path = create_capture_bundle_root()?; + let evidence_command_output = bundle_path.join("evidence").join("command-output"); + let evidence_registry = bundle_path.join("evidence").join("registry"); + fs::create_dir_all(&evidence_command_output).map_err(|error| { + format!( + "Failed to create the live capture command-output folder '{}': {}", + evidence_command_output.display(), + error + ) + })?; + fs::create_dir_all(&evidence_registry).map_err(|error| { + format!( + "Failed to create the live capture registry folder '{}': {}", + evidence_registry.display(), + error + ) + })?; + + let evidence_file_path = evidence_command_output.join("dsregcmd-status.txt"); + fs::write(&evidence_file_path, stdout).map_err(|error| { + format!( + "Failed to write the live dsregcmd capture to '{}': {}", + evidence_file_path.display(), + error + ) + })?; + + let manifest_path = bundle_path.join("manifest.json"); + fs::write( + &manifest_path, + "{\n \"manifestPath\": \"manifest.json\",\n \"source\": \"live-dsregcmd-capture\"\n}\n", + ) + .map_err(|error| { + format!( + "Failed to write the live capture manifest '{}': {}", + manifest_path.display(), + error + ) + })?; + + export_live_registry_evidence(&evidence_registry); + + Ok(LiveCaptureBundle { + bundle_path, + evidence_file_path, + }) +} + +#[cfg(target_os = "windows")] +fn resolve_bundle_root_from_file_path(path: &Path) -> Option { + let mut candidate = path.parent(); + + while let Some(directory) = candidate { + if directory.join(MANIFEST_FILE).is_file() { + return Some(directory.to_path_buf()); + } + + candidate = directory.parent(); + } + + None +} + +#[cfg(target_os = "windows")] +fn resolve_folder_bundle_evidence(folder_path: &Path) -> Result<(PathBuf, PathBuf), String> { + let bundle_root = resolve_canonical_bundle_root_from_folder_path(folder_path).ok_or_else(|| { + "Selected folder is not a supported dsregcmd evidence bundle location. Choose the bundle root, the bundle's evidence folder, or the bundle's command-output folder.".to_string() + })?; + + let evidence_file_path = DSREGCMD_EVIDENCE_RELATIVE_PATH + .iter() + .fold(bundle_root.clone(), |path, segment| path.join(segment)); + if evidence_file_path.is_file() { + return Ok((bundle_root, evidence_file_path)); + } + + let top_level_path = bundle_root.join(DSREGCMD_TOP_LEVEL_FALLBACK_FILE); + if top_level_path.is_file() { + return Ok((bundle_root, top_level_path)); + } + + Err(format!( + "Resolved bundle root does not contain dsregcmd evidence. Expected '{}' or '{}'.", + DSREGCMD_EVIDENCE_RELATIVE_PATH.join("/"), + DSREGCMD_TOP_LEVEL_FALLBACK_FILE + )) +} + +#[cfg(target_os = "windows")] +fn resolve_canonical_bundle_root_from_folder_path(folder_path: &Path) -> Option { + if folder_path.join(MANIFEST_FILE).is_file() { + return Some(folder_path.to_path_buf()); + } + + if path_ends_with_directory(folder_path, COMMAND_OUTPUT_FOLDER_NAME) { + let bundle_root = folder_path.parent().and_then(Path::parent); + if let Some(bundle_root) = bundle_root { + if bundle_root.join(MANIFEST_FILE).is_file() { + return Some(bundle_root.to_path_buf()); + } + } + } + + if path_ends_with_directory(folder_path, EVIDENCE_FOLDER_NAME) { + if let Some(bundle_root) = folder_path.parent() { + if bundle_root.join(MANIFEST_FILE).is_file() { + return Some(bundle_root.to_path_buf()); + } + } + } + + let mut candidate = Some(folder_path); + while let Some(directory) = candidate { + if directory.join(MANIFEST_FILE).is_file() { + return Some(directory.to_path_buf()); + } + + candidate = directory.parent(); + } + + None +} + +#[cfg(target_os = "windows")] +fn path_ends_with_directory(path: &Path, directory_name: &str) -> bool { + path.file_name() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case(directory_name)) + .unwrap_or(false) +} + +#[cfg(target_os = "windows")] +fn create_capture_bundle_root() -> Result { + let temp_root = std::env::temp_dir(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_millis(); + let bundle_path = temp_root.join(format!( + "cmtraceopen-dsregcmd-capture-{}-{}", + std::process::id(), + timestamp + )); + fs::create_dir_all(&bundle_path).map_err(|error| { + format!( + "Failed to create the live capture bundle root '{}': {}", + bundle_path.display(), + error + ) + })?; + Ok(bundle_path) +} + +#[cfg(target_os = "windows")] +fn export_live_registry_evidence(registry_root: &Path) { + let Ok(reg_path) = resolve_system32_binary("reg.exe") else { + eprintln!( + "event=dsregcmd_registry_export_skipped reason=reg_not_found registry_root={}", + registry_root.display() + ); + return; + }; + + for export in LIVE_CAPTURE_REGISTRY_EXPORTS { + let output_path = registry_root.join(export.file_name); + match std::process::Command::new(®_path) + .args(["export", export.key_path, &output_path.to_string_lossy(), "/y"]) + .output() + { + Ok(output) if output.status.success() => { + eprintln!( + "event=dsregcmd_registry_export_complete key={} file={}", + export.key_path, + output_path.display() + ); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + eprintln!( + "event=dsregcmd_registry_export_failed key={} file={} exit_code={} stderr={}", + export.key_path, + output_path.display(), + output.status.code().unwrap_or_default(), + stderr + ); + } + Err(error) => { + eprintln!( + "event=dsregcmd_registry_export_failed key={} file={} error={}", + export.key_path, + output_path.display(), + error + ); + } + } + } +} + +#[cfg(target_os = "windows")] +fn cleanup_old_capture_bundles() { + let temp_root = std::env::temp_dir(); + let Ok(entries) = fs::read_dir(&temp_root) else { + return; + }; + + let cutoff = SystemTime::now() + .checked_sub(Duration::from_secs(24 * 60 * 60)) + .unwrap_or(SystemTime::UNIX_EPOCH); + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with("cmtraceopen-dsregcmd-capture-") { + continue; + } + + let is_old = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(|modified| modified <= cutoff) + .unwrap_or(false); + + if is_old { + let _ = fs::remove_dir_all(&path); + } + } +} + +#[cfg(target_os = "windows")] +fn resolve_system32_binary(file_name: &str) -> Result { + let Some(windir) = std::env::var_os("WINDIR") else { + return Err("WINDIR is not set; could not resolve the Windows system path.".to_string()); + }; + + let path = PathBuf::from(windir).join("System32").join(file_name); + if !path.is_file() { + return Err(format!( + "Expected Windows system binary was not found at '{}'.", + path.display() + )); + } + + Ok(path) +} + +#[cfg(target_os = "windows")] +fn verify_dsregcmd_signature(dsregcmd_path: &Path) -> Result<(), String> { + let mut wide_path = dsregcmd_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + let mut file_info = WinTrustFileInfo { + cb_struct: std::mem::size_of::() as u32, + pcwsz_file_path: wide_path.as_mut_ptr(), + h_file: 0, + pg_known_subject: null(), + }; + + let mut trust_data = WinTrustData { + cb_struct: std::mem::size_of::() as u32, + p_policy_callback_data: null_mut(), + p_sip_client_data: null_mut(), + dw_ui_choice: WTD_UI_NONE, + fdw_revocation_checks: WTD_REVOKE_NONE, + dw_union_choice: WTD_CHOICE_FILE, + anonymous: WinTrustDataChoice { + p_file: &mut file_info, + }, + dw_state_action: WTD_STATEACTION_IGNORE, + h_wvtstate_data: 0, + pwsz_url_reference: null(), + dw_prov_flags: 0, + dw_ui_context: 0, + p_signature_settings: null_mut(), + }; + + let status = unsafe { + WinVerifyTrust( + null_mut(), + &WINTRUST_ACTION_GENERIC_VERIFY_V2, + &mut trust_data as *mut _ as *mut c_void, + ) + }; + + if status == 0 { + return Ok(()); + } + + if status as u32 == TRUST_E_NOSIGNATURE && verify_catalog_signature(dsregcmd_path)? { + eprintln!( + "event=dsregcmd_signature_verification_fallback method=catalog status=valid path={}", + dsregcmd_path.display() + ); + return Ok(()); + } + + Err(format!( + "Refusing to execute '{}': expected a valid Authenticode signature but WinVerifyTrust returned {}.", + dsregcmd_path.display(), + format_winverifytrust_status(status) + )) +} + +#[cfg(target_os = "windows")] +fn verify_catalog_signature(dsregcmd_path: &Path) -> Result { + let file = File::open(dsregcmd_path).map_err(|error| { + format!( + "Failed to open '{}' for catalog signature verification: {}", + dsregcmd_path.display(), + error + ) + })?; + let file_handle = file.as_raw_handle() as isize; + + let mut cat_admin_handle = 0isize; + let acquired = unsafe { + CryptCATAdminAcquireContext(&mut cat_admin_handle, &DRIVER_ACTION_VERIFY, 0) + }; + if acquired == 0 { + return Err(format!( + "Failed to acquire a catalog admin context for '{}': {}", + dsregcmd_path.display(), + std::io::Error::last_os_error() + )); + } + let cat_admin = CatalogAdminHandle(cat_admin_handle); + + let mut hash_len = 0u32; + let hash_size_status = unsafe { + CryptCATAdminCalcHashFromFileHandle(file_handle, &mut hash_len, null_mut(), 0) + }; + if hash_size_status == 0 && hash_len == 0 { + return Err(format!( + "Failed to determine the catalog hash size for '{}': {}", + dsregcmd_path.display(), + std::io::Error::last_os_error() + )); + } + + let mut hash = vec![0u8; hash_len as usize]; + let hash_status = unsafe { + CryptCATAdminCalcHashFromFileHandle(file_handle, &mut hash_len, hash.as_mut_ptr(), 0) + }; + if hash_status == 0 { + return Err(format!( + "Failed to calculate the catalog hash for '{}': {}", + dsregcmd_path.display(), + std::io::Error::last_os_error() + )); + } + hash.truncate(hash_len as usize); + + let mut previous_catalog_context = 0isize; + let catalog_context = unsafe { + CryptCATAdminEnumCatalogFromHash( + cat_admin.0, + hash.as_ptr(), + hash_len, + 0, + &mut previous_catalog_context, + ) + }; + if catalog_context == 0 { + return Ok(false); + } + let catalog = CatalogContextHandle { + admin_handle: cat_admin.0, + catalog_handle: catalog_context, + }; + + let mut catalog_info = CatalogInfo { + cb_struct: std::mem::size_of::() as u32, + wsz_catalog_file: [0; 260], + }; + let catalog_info_status = unsafe { + CryptCATCatalogInfoFromContext(catalog.catalog_handle, &mut catalog_info, 0) + }; + if catalog_info_status == 0 { + return Err(format!( + "Failed to read catalog metadata for '{}': {}", + dsregcmd_path.display(), + std::io::Error::last_os_error() + )); + } + + let member_tag = hex_encode_wide(&hash); + let member_path = dsregcmd_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + let mut catalog_trust_info = WinTrustCatalogInfo { + cb_struct: std::mem::size_of::() as u32, + dw_catalog_version: 0, + pcwsz_catalog_file_path: catalog_info.wsz_catalog_file.as_ptr(), + pcwsz_member_tag: member_tag.as_ptr(), + pcwsz_member_file_path: member_path.as_ptr(), + h_member_file: file_handle, + pb_calculated_file_hash: hash.as_mut_ptr(), + cb_calculated_file_hash: hash_len, + pc_catalog_context: null_mut(), + h_cat_admin: cat_admin.0, + }; + + let mut trust_data = WinTrustData { + cb_struct: std::mem::size_of::() as u32, + p_policy_callback_data: null_mut(), + p_sip_client_data: null_mut(), + dw_ui_choice: WTD_UI_NONE, + fdw_revocation_checks: WTD_REVOKE_NONE, + dw_union_choice: WTD_CHOICE_CATALOG, + anonymous: WinTrustDataChoice { + p_catalog: &mut catalog_trust_info, + }, + dw_state_action: WTD_STATEACTION_IGNORE, + h_wvtstate_data: 0, + pwsz_url_reference: null(), + dw_prov_flags: 0, + dw_ui_context: 0, + p_signature_settings: null_mut(), + }; + + let status = unsafe { + WinVerifyTrust( + null_mut(), + &WINTRUST_ACTION_GENERIC_VERIFY_V2, + &mut trust_data as *mut _ as *mut c_void, + ) + }; + + Ok(status == 0) +} + +#[cfg(target_os = "windows")] +fn hex_encode_wide(bytes: &[u8]) -> Vec { + let mut wide = Vec::with_capacity((bytes.len() * 2) + 1); + for byte in bytes { + let upper = byte >> 4; + let lower = byte & 0x0F; + wide.push(nibble_to_hex(upper) as u16); + wide.push(nibble_to_hex(lower) as u16); + } + wide.push(0); + wide +} + +#[cfg(target_os = "windows")] +const fn nibble_to_hex(nibble: u8) -> u8 { + match nibble { + 0..=9 => b'0' + nibble, + _ => b'A' + (nibble - 10), + } +} + +#[cfg(target_os = "windows")] +fn format_winverifytrust_status(status: i32) -> String { + match status as u32 { + 0x800B0100 => "0x800B0100 (TRUST_E_NOSIGNATURE)".to_string(), + 0x800B0101 => "0x800B0101 (CERT_E_EXPIRED)".to_string(), + 0x800B0109 => "0x800B0109 (CERT_E_UNTRUSTEDROOT)".to_string(), + 0x80096010 => "0x80096010 (TRUST_E_BAD_DIGEST)".to_string(), + code => format!("0x{code:08X}"), + } +} + +#[cfg(target_os = "windows")] +#[repr(C)] +struct Guid { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], +} + +#[cfg(target_os = "windows")] +#[repr(C)] +struct WinTrustFileInfo { + cb_struct: u32, + pcwsz_file_path: *mut u16, + h_file: isize, + pg_known_subject: *const Guid, +} + +#[cfg(target_os = "windows")] +#[repr(C)] +union WinTrustDataChoice { + p_file: *mut WinTrustFileInfo, + p_catalog: *mut WinTrustCatalogInfo, +} + +#[cfg(target_os = "windows")] +#[repr(C)] +struct WinTrustData { + cb_struct: u32, + p_policy_callback_data: *mut c_void, + p_sip_client_data: *mut c_void, + dw_ui_choice: u32, + fdw_revocation_checks: u32, + dw_union_choice: u32, + anonymous: WinTrustDataChoice, + dw_state_action: u32, + h_wvtstate_data: isize, + pwsz_url_reference: *const u16, + dw_prov_flags: u32, + dw_ui_context: u32, + p_signature_settings: *mut c_void, +} + +#[cfg(target_os = "windows")] +#[repr(C)] +struct WinTrustCatalogInfo { + cb_struct: u32, + dw_catalog_version: u32, + pcwsz_catalog_file_path: *const u16, + pcwsz_member_tag: *const u16, + pcwsz_member_file_path: *const u16, + h_member_file: isize, + pb_calculated_file_hash: *mut u8, + cb_calculated_file_hash: u32, + pc_catalog_context: *mut c_void, + h_cat_admin: isize, +} + +#[cfg(target_os = "windows")] +#[repr(C)] +struct CatalogInfo { + cb_struct: u32, + wsz_catalog_file: [u16; 260], +} + +#[cfg(target_os = "windows")] +struct CatalogAdminHandle(isize); + +#[cfg(target_os = "windows")] +impl Drop for CatalogAdminHandle { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { + CryptCATAdminReleaseContext(self.0, 0); + } + } + } +} + +#[cfg(target_os = "windows")] +struct CatalogContextHandle { + admin_handle: isize, + catalog_handle: isize, +} + +#[cfg(target_os = "windows")] +impl Drop for CatalogContextHandle { + fn drop(&mut self) { + if self.catalog_handle != 0 { + unsafe { + CryptCATAdminReleaseCatalogContext(self.admin_handle, self.catalog_handle, 0); + } + } + } +} + +#[cfg(target_os = "windows")] +const WINTRUST_ACTION_GENERIC_VERIFY_V2: Guid = Guid { + data1: 0x00AAC56B, + data2: 0xCD44, + data3: 0x11D0, + data4: [0x8C, 0xC2, 0x00, 0xC0, 0x4F, 0xC2, 0x95, 0xEE], +}; + +#[cfg(target_os = "windows")] +const WTD_UI_NONE: u32 = 2; +#[cfg(target_os = "windows")] +const WTD_REVOKE_NONE: u32 = 0; +#[cfg(target_os = "windows")] +const WTD_CHOICE_FILE: u32 = 1; +#[cfg(target_os = "windows")] +const WTD_CHOICE_CATALOG: u32 = 2; +#[cfg(target_os = "windows")] +const WTD_STATEACTION_IGNORE: u32 = 0; +#[cfg(target_os = "windows")] +const TRUST_E_NOSIGNATURE: u32 = 0x800B0100; + +#[cfg(target_os = "windows")] +const DRIVER_ACTION_VERIFY: Guid = Guid { + data1: 0xF750E6C3, + data2: 0x38EE, + data3: 0x11D1, + data4: [0x85, 0xE5, 0x00, 0xC0, 0x4F, 0xC2, 0x95, 0xEE], +}; + +#[cfg(target_os = "windows")] +#[link(name = "wintrust")] +extern "system" { + fn WinVerifyTrust(hwnd: *mut c_void, pg_action_id: *const Guid, p_wvt_data: *mut c_void) -> i32; + fn CryptCATAdminAcquireContext( + ph_cat_admin: *mut isize, + pg_subsystem: *const Guid, + dw_flags: u32, + ) -> i32; + fn CryptCATAdminCalcHashFromFileHandle( + h_file: isize, + pcb_hash: *mut u32, + pb_hash: *mut u8, + dw_flags: u32, + ) -> i32; + fn CryptCATAdminEnumCatalogFromHash( + h_cat_admin: isize, + pb_hash: *const u8, + cb_hash: u32, + dw_flags: u32, + ph_prev_cat_info: *mut isize, + ) -> isize; + fn CryptCATCatalogInfoFromContext( + h_cat_info: isize, + ps_cat_info: *mut CatalogInfo, + dw_flags: u32, + ) -> i32; + fn CryptCATAdminReleaseCatalogContext( + h_cat_admin: isize, + h_cat_info: isize, + dw_flags: u32, + ) -> i32; + fn CryptCATAdminReleaseContext(h_cat_admin: isize, dw_flags: u32) -> i32; +} + +#[cfg(test)] +mod tests { + #[cfg(not(target_os = "windows"))] + use super::capture_dsregcmd; + + #[cfg(not(target_os = "windows"))] + #[test] + fn capture_command_returns_clear_error_on_unsupported_platform() { + let error = capture_dsregcmd().expect_err("expected unsupported platform error"); + assert!(error.contains("only supported on Windows")); + } +} diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 84e3e5c34..a33cedca3 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -29,6 +29,14 @@ pub enum LogSourceKind { Known, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathKind { + File, + Folder, + Unknown, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum KnownSourcePathKind { @@ -148,6 +156,31 @@ pub fn open_log_file(path: String, state: State<'_, AppState>) -> Result Result { + let requested_path = PathBuf::from(&path); + + if !requested_path.exists() { + return Ok(PathKind::Unknown); + } + + if requested_path.is_dir() { + return Ok(PathKind::Folder); + } + + if requested_path.is_file() { + return Ok(PathKind::File); + } + + Ok(PathKind::Unknown) +} + +#[tauri::command] +pub fn write_text_output_file(path: String, contents: String) -> Result<(), String> { + fs::write(&path, contents) + .map_err(|error| format!("failed to write file {}: {}", path, error)) +} + fn normalize_path_string(path: &Path) -> String { path.to_string_lossy().to_string() } @@ -648,12 +681,12 @@ fn windows_known_log_sources() -> Vec { ] } -#[cfg(target_os = "windows")] fn build_known_log_sources() -> Vec { #[cfg(target_os = "windows")] { windows_known_log_sources() } + #[cfg(not(target_os = "windows"))] { Vec::new() diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1e78ca6cc..927e5d356 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod dsregcmd; pub mod error_lookup; pub mod file_ops; pub mod filter; diff --git a/src-tauri/src/dsregcmd/mod.rs b/src-tauri/src/dsregcmd/mod.rs new file mode 100644 index 000000000..aa1acdc98 --- /dev/null +++ b/src-tauri/src/dsregcmd/mod.rs @@ -0,0 +1,15 @@ +pub mod models; +pub mod parser; +pub mod registry; +pub mod rules; + +pub use models::{ + DsregcmdAnalysisResult, DsregcmdDerived, DsregcmdDiagnosticInsight, + DsregcmdEvidenceSource, DsregcmdFacts, DsregcmdJoinType, DsregcmdPolicyEvidenceValue, + DsregcmdWhfbPolicyEvidence, +}; + +pub fn analyze_text(input: &str) -> Result { + let facts = parser::parse_dsregcmd(input)?; + Ok(rules::analyze_facts(facts, input)) +} diff --git a/src-tauri/src/dsregcmd/models.rs b/src-tauri/src/dsregcmd/models.rs new file mode 100644 index 000000000..fee743db2 --- /dev/null +++ b/src-tauri/src/dsregcmd/models.rs @@ -0,0 +1,333 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::intune::models::IntuneDiagnosticSeverity; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum DsregcmdJoinType { + HybridEntraIdJoined, + EntraIdJoined, + NotJoined, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum DsregcmdDiagnosticPhase { + Precheck, + Discover, + Auth, + Join, + PostJoin, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum DsregcmdCaptureConfidence { + High, + #[default] + Medium, + Low, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DsregcmdEvidenceSource { + Dsregcmd, + PolicyManagerCurrent, + PolicyManagerProvider, + PolicyManagerComparison, + WindowsPolicyMachine, + WindowsPolicyUser, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdPolicyEvidenceValue { + pub display_value: Option, + pub current_value: Option, + pub provider_value: Option, + pub source: Option, + pub note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdWhfbPolicyEvidence { + pub policy_enabled: DsregcmdPolicyEvidenceValue, + pub post_logon_enabled: DsregcmdPolicyEvidenceValue, + pub pin_recovery_enabled: DsregcmdPolicyEvidenceValue, + pub require_security_device: DsregcmdPolicyEvidenceValue, + pub use_certificate_for_on_prem_auth: DsregcmdPolicyEvidenceValue, + pub use_cloud_trust_for_on_prem_auth: DsregcmdPolicyEvidenceValue, + #[serde(default)] + pub artifact_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdJoinState { + pub azure_ad_joined: Option, + pub domain_joined: Option, + pub workplace_joined: Option, + pub enterprise_joined: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdDeviceDetails { + pub device_id: Option, + pub thumbprint: Option, + pub device_certificate_validity: Option, + pub key_container_id: Option, + pub key_provider: Option, + pub tpm_protected: Option, + pub device_auth_status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdTenantDetails { + pub tenant_id: Option, + pub tenant_name: Option, + pub domain_name: Option, + pub idp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdManagementDetails { + pub mdm_url: Option, + pub mdm_compliance_url: Option, + pub mdm_tou_url: Option, + pub settings_url: Option, + pub device_management_srv_ver: Option, + pub device_management_srv_url: Option, + pub device_management_srv_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdServiceEndpoints { + pub auth_code_url: Option, + pub access_token_url: Option, + pub join_srv_version: Option, + pub join_srv_url: Option, + pub join_srv_id: Option, + pub key_srv_version: Option, + pub key_srv_url: Option, + pub key_srv_id: Option, + pub web_authn_srv_version: Option, + pub web_authn_srv_url: Option, + pub web_authn_srv_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdUserState { + pub ngc_set: Option, + pub ngc_key_id: Option, + pub can_reset: Option, + pub wam_default_set: Option, + pub wam_default_authority: Option, + pub wam_default_id: Option, + pub wam_default_guid: Option, + pub is_device_joined: Option, + pub is_user_azure_ad: Option, + pub policy_enabled: Option, + pub post_logon_enabled: Option, + pub device_eligible: Option, + pub session_is_not_remote: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdSsoState { + pub azure_ad_prt: Option, + pub azure_ad_prt_authority: Option, + pub azure_ad_prt_update_time: Option, + pub acquire_prt_diagnostics: Option, + pub enterprise_prt: Option, + pub enterprise_prt_update_time: Option, + pub enterprise_prt_expiry_time: Option, + pub enterprise_prt_authority: Option, + pub on_prem_tgt: Option, + pub cloud_tgt: Option, + pub adfs_refresh_token: Option, + pub adfs_ra_is_ready: Option, + pub kerb_top_level_names: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdDiagnosticFields { + pub previous_prt_attempt: Option, + pub attempt_status: Option, + pub user_identity: Option, + pub credential_type: Option, + pub correlation_id: Option, + pub endpoint_uri: Option, + pub http_method: Option, + pub http_error: Option, + pub http_status: Option, + pub request_id: Option, + pub diagnostics_reference: Option, + pub user_context: Option, + pub client_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdPreJoinTests { + pub ad_connectivity_test: Option, + pub ad_configuration_test: Option, + pub drs_discovery_test: Option, + pub drs_connectivity_test: Option, + pub token_acquisition_test: Option, + pub fallback_to_sync_join: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdRegistrationState { + pub previous_registration: Option, + pub error_phase: Option, + pub cert_enrollment: Option, + pub logon_cert_template_ready: Option, + pub pre_req_result: Option, + pub client_error_code: Option, + pub server_error_code: Option, + pub server_message: Option, + pub server_error_description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdPostJoinDiagnostics { + pub aad_recovery_enabled: Option, + pub key_sign_test: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdFacts { + pub join_state: DsregcmdJoinState, + pub device_details: DsregcmdDeviceDetails, + pub tenant_details: DsregcmdTenantDetails, + pub management_details: DsregcmdManagementDetails, + pub service_endpoints: DsregcmdServiceEndpoints, + pub user_state: DsregcmdUserState, + pub sso_state: DsregcmdSsoState, + pub diagnostics: DsregcmdDiagnosticFields, + pub pre_join_tests: DsregcmdPreJoinTests, + pub registration: DsregcmdRegistrationState, + pub post_join_diagnostics: DsregcmdPostJoinDiagnostics, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdDerived { + pub join_type: DsregcmdJoinType, + pub join_type_label: String, + pub dominant_phase: DsregcmdDiagnosticPhase, + pub phase_summary: String, + pub capture_confidence: DsregcmdCaptureConfidence, + pub capture_confidence_reason: String, + pub mdm_enrolled: Option, + pub missing_mdm: Option, + pub compliance_url_present: Option, + pub missing_compliance_url: Option, + pub azure_ad_prt_present: Option, + pub stale_prt: Option, + pub prt_last_update: Option>, + pub prt_reference_time: Option>, + pub prt_age_hours: Option, + pub tpm_protected: Option, + pub certificate_valid_from: Option>, + pub certificate_valid_to: Option>, + pub certificate_expiring_soon: Option, + pub certificate_days_remaining: Option, + pub network_error_code: Option, + pub has_network_error: bool, + pub remote_session_system: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdDiagnosticInsight { + pub id: String, + pub severity: IntuneDiagnosticSeverity, + pub category: String, + pub title: String, + pub summary: String, + pub evidence: Vec, + pub next_checks: Vec, + #[serde(default)] + pub suggested_fixes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdAnalysisResult { + pub facts: DsregcmdFacts, + pub derived: DsregcmdDerived, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub policy_evidence: DsregcmdWhfbPolicyEvidence, +} + +#[cfg(test)] +mod tests { + use super::{ + DsregcmdCaptureConfidence, DsregcmdDiagnosticPhase, DsregcmdEvidenceSource, + DsregcmdJoinType, + }; + + #[test] + fn join_type_serializes_with_pascal_case_variants() { + assert_eq!( + serde_json::to_string(&DsregcmdJoinType::HybridEntraIdJoined).expect("serialize join type"), + "\"HybridEntraIdJoined\"" + ); + assert_eq!( + serde_json::to_string(&DsregcmdJoinType::EntraIdJoined).expect("serialize join type"), + "\"EntraIdJoined\"" + ); + assert_eq!( + serde_json::to_string(&DsregcmdJoinType::NotJoined).expect("serialize join type"), + "\"NotJoined\"" + ); + assert_eq!( + serde_json::to_string(&DsregcmdJoinType::Unknown).expect("serialize join type"), + "\"Unknown\"" + ); + } + + #[test] + fn phase_and_confidence_serialize_with_expected_strings() { + assert_eq!( + serde_json::to_string(&DsregcmdDiagnosticPhase::PostJoin) + .expect("serialize diagnostic phase"), + "\"post_join\"" + ); + assert_eq!( + serde_json::to_string(&DsregcmdCaptureConfidence::High) + .expect("serialize capture confidence"), + "\"high\"" + ); + assert_eq!( + serde_json::to_string(&DsregcmdEvidenceSource::PolicyManagerCurrent) + .expect("serialize evidence source"), + "\"policy_manager_current\"" + ); + assert_eq!( + serde_json::to_string(&DsregcmdEvidenceSource::WindowsPolicyMachine) + .expect("serialize evidence source"), + "\"windows_policy_machine\"" + ); + } +} diff --git a/src-tauri/src/dsregcmd/parser.rs b/src-tauri/src/dsregcmd/parser.rs new file mode 100644 index 000000000..533bd9bad --- /dev/null +++ b/src-tauri/src/dsregcmd/parser.rs @@ -0,0 +1,338 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::dsregcmd::models::DsregcmdFacts; + +static FIELD_LINE_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?m)^\s*([^\r\n:=][^\r\n:=]*?)\s*[:=]\s*([^\r\n]*)\s*$") + .expect("valid dsregcmd field regex") +}); + +pub fn parse_dsregcmd(input: &str) -> Result { + if input.trim().is_empty() { + return Err("dsregcmd input was empty".to_string()); + } + + let mut facts = DsregcmdFacts::default(); + let mut recognized_fields = 0usize; + + for captures in FIELD_LINE_RE.captures_iter(input) { + let Some(raw_key) = captures.get(1) else { + continue; + }; + let Some(raw_value) = captures.get(2) else { + continue; + }; + + let key = normalize_key(raw_key.as_str()); + let value = raw_value.as_str().trim(); + + if apply_field(&mut facts, &key, value) { + recognized_fields += 1; + } + } + + if recognized_fields == 0 { + return Err("Input did not contain recognizable dsregcmd /status fields".to_string()); + } + + Ok(facts) +} + +fn apply_field(facts: &mut DsregcmdFacts, key: &str, value: &str) -> bool { + match key { + "azureadjoined" => { + facts.join_state.azure_ad_joined = parse_bool(value); + true + } + "domainjoined" => { + facts.join_state.domain_joined = parse_bool(value); + true + } + "workplacejoined" => { + facts.join_state.workplace_joined = parse_bool(value); + true + } + "enterprisejoined" => { + facts.join_state.enterprise_joined = parse_bool(value); + true + } + "tenantid" => set_string(&mut facts.tenant_details.tenant_id, value), + "tenantname" => set_string(&mut facts.tenant_details.tenant_name, value), + "domainname" => set_string(&mut facts.tenant_details.domain_name, value), + "deviceid" => set_string(&mut facts.device_details.device_id, value), + "thumbprint" => set_string(&mut facts.device_details.thumbprint, value), + "devicecertificatevalidity" => { + set_string(&mut facts.device_details.device_certificate_validity, value) + } + "keycontainerid" => set_string(&mut facts.device_details.key_container_id, value), + "keyprovider" => set_string(&mut facts.device_details.key_provider, value), + "tpmprotected" => { + facts.device_details.tpm_protected = parse_bool(value); + true + } + "deviceauthstatus" => set_string(&mut facts.device_details.device_auth_status, value), + "mdmurl" => set_string(&mut facts.management_details.mdm_url, value), + "mdmcomplianceurl" | "dmcomplianceurl" => { + set_string(&mut facts.management_details.mdm_compliance_url, value) + } + "mdmtouurl" => set_string(&mut facts.management_details.mdm_tou_url, value), + "settingsurl" => set_string(&mut facts.management_details.settings_url, value), + "devicemanagementsrvver" => set_string( + &mut facts.management_details.device_management_srv_ver, + value, + ), + "devicemanagementsrvurl" => set_string( + &mut facts.management_details.device_management_srv_url, + value, + ), + "devicemanagementsrvid" => set_string( + &mut facts.management_details.device_management_srv_id, + value, + ), + "idp" => set_string(&mut facts.tenant_details.idp, value), + "authcodeurl" => set_string(&mut facts.service_endpoints.auth_code_url, value), + "accesstokenurl" => set_string(&mut facts.service_endpoints.access_token_url, value), + "joinsrvversion" => set_string(&mut facts.service_endpoints.join_srv_version, value), + "joinsrvurl" => set_string(&mut facts.service_endpoints.join_srv_url, value), + "joinsrvid" => set_string(&mut facts.service_endpoints.join_srv_id, value), + "keysrvversion" => set_string(&mut facts.service_endpoints.key_srv_version, value), + "keysrvurl" => set_string(&mut facts.service_endpoints.key_srv_url, value), + "keysrvid" => set_string(&mut facts.service_endpoints.key_srv_id, value), + "webauthnsrvversion" => { + set_string(&mut facts.service_endpoints.web_authn_srv_version, value) + } + "webauthnsrvurl" => set_string(&mut facts.service_endpoints.web_authn_srv_url, value), + "webauthnsrvid" => set_string(&mut facts.service_endpoints.web_authn_srv_id, value), + "ngcset" => { + facts.user_state.ngc_set = parse_bool(value); + true + } + "ngckeyid" => set_string(&mut facts.user_state.ngc_key_id, value), + "canreset" => set_string(&mut facts.user_state.can_reset, value), + "wamdefaultset" => { + facts.user_state.wam_default_set = parse_bool(value); + true + } + "wamdefaultauthority" => set_string(&mut facts.user_state.wam_default_authority, value), + "wamdefaultid" => set_string(&mut facts.user_state.wam_default_id, value), + "wamdefaultguid" => set_string(&mut facts.user_state.wam_default_guid, value), + "isdevicejoined" => { + facts.user_state.is_device_joined = parse_bool(value); + true + } + "isuserazuread" => { + facts.user_state.is_user_azure_ad = parse_bool(value); + true + } + "policyenabled" => { + facts.user_state.policy_enabled = parse_bool(value); + true + } + "postlogonenabled" => { + facts.user_state.post_logon_enabled = parse_bool(value); + true + } + "deviceeligible" => { + facts.user_state.device_eligible = parse_bool(value); + true + } + "sessionisnotremote" => { + facts.user_state.session_is_not_remote = parse_bool(value); + true + } + "azureadprt" => { + facts.sso_state.azure_ad_prt = parse_bool(value); + true + } + "azureadprtauthority" => set_string(&mut facts.sso_state.azure_ad_prt_authority, value), + "azureadprtupdatetime" => set_string(&mut facts.sso_state.azure_ad_prt_update_time, value), + "acquireprtdiagnostics" => set_string(&mut facts.sso_state.acquire_prt_diagnostics, value), + "enterpriseprt" => { + facts.sso_state.enterprise_prt = parse_bool(value); + true + } + "enterpriseprtupdatetime" => { + set_string(&mut facts.sso_state.enterprise_prt_update_time, value) + } + "enterpriseprtexpirytime" => { + set_string(&mut facts.sso_state.enterprise_prt_expiry_time, value) + } + "enterpriseprtauthority" => { + set_string(&mut facts.sso_state.enterprise_prt_authority, value) + } + "onpremtgt" => { + facts.sso_state.on_prem_tgt = parse_bool(value); + true + } + "cloudtgt" => { + facts.sso_state.cloud_tgt = parse_bool(value); + true + } + "adfsrefreshtoken" => { + facts.sso_state.adfs_refresh_token = parse_bool(value); + true + } + "adfsraisready" => { + facts.sso_state.adfs_ra_is_ready = parse_bool(value); + true + } + "kerbtoplevelnames" => set_string(&mut facts.sso_state.kerb_top_level_names, value), + "previousprtattempt" => set_string(&mut facts.diagnostics.previous_prt_attempt, value), + "attemptstatus" => set_string(&mut facts.diagnostics.attempt_status, value), + "useridentity" => set_string(&mut facts.diagnostics.user_identity, value), + "credentialtype" => set_string(&mut facts.diagnostics.credential_type, value), + "correlationid" => set_string(&mut facts.diagnostics.correlation_id, value), + "endpointuri" => set_string(&mut facts.diagnostics.endpoint_uri, value), + "httpmethod" => set_string(&mut facts.diagnostics.http_method, value), + "httperror" => set_string(&mut facts.diagnostics.http_error, value), + "httpstatus" => { + facts.diagnostics.http_status = parse_u16(value); + true + } + "requestid" => set_string(&mut facts.diagnostics.request_id, value), + "diagnosticsreference" => set_string(&mut facts.diagnostics.diagnostics_reference, value), + "usercontext" => set_string(&mut facts.diagnostics.user_context, value), + "clienttime" => set_string(&mut facts.diagnostics.client_time, value), + "adconnectivitytest" => set_string(&mut facts.pre_join_tests.ad_connectivity_test, value), + "adconfigurationtest" => set_string(&mut facts.pre_join_tests.ad_configuration_test, value), + "drsdiscoverytest" => set_string(&mut facts.pre_join_tests.drs_discovery_test, value), + "drsconnectivitytest" => set_string(&mut facts.pre_join_tests.drs_connectivity_test, value), + "tokenacquisitiontest" => { + set_string(&mut facts.pre_join_tests.token_acquisition_test, value) + } + "fallbacktosyncjoin" => set_string(&mut facts.pre_join_tests.fallback_to_sync_join, value), + "previousregistration" => set_string(&mut facts.registration.previous_registration, value), + "errorphase" => set_string(&mut facts.registration.error_phase, value), + "certenrollment" => set_string(&mut facts.registration.cert_enrollment, value), + "logoncerttemplateready" => { + set_string(&mut facts.registration.logon_cert_template_ready, value) + } + "prereqresult" => set_string(&mut facts.registration.pre_req_result, value), + "clienterrorcode" => set_string(&mut facts.registration.client_error_code, value), + "servererrorcode" => set_string(&mut facts.registration.server_error_code, value), + "servermessage" => set_string(&mut facts.registration.server_message, value), + "servererrordescription" => { + set_string(&mut facts.registration.server_error_description, value) + } + "aadrecoveryenabled" => { + facts.post_join_diagnostics.aad_recovery_enabled = parse_bool(value); + true + } + "keysigntest" => set_string(&mut facts.post_join_diagnostics.key_sign_test, value), + _ => false, + } +} + +fn normalize_key(key: &str) -> String { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + +fn normalize_absent(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed == "-" || trimmed.eq_ignore_ascii_case("n/a") { + return None; + } + + Some(trimmed.to_string()) +} + +fn set_string(slot: &mut Option, value: &str) -> bool { + *slot = normalize_absent(value); + true +} + +fn parse_bool(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "yes" | "true" | "1" => Some(true), + "no" | "false" | "0" => Some(false), + _ => None, + } +} + +fn parse_u16(value: &str) -> Option { + value.trim().parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::parse_dsregcmd; + + const SAMPLE: &str = r#" ++----------------------------------------------------------------------+ +| Device State | ++----------------------------------------------------------------------+ + AzureAdJoined : YES + DomainJoined = NO + WorkplaceJoined : NO + EnterpriseJoined : NO + TenantId : 11111111-2222-3333-4444-555555555555 + DeviceId : abcdefab-1111-2222-3333-abcdefabcdef + MdmUrl : https://enrollment.manage.microsoft.com/enrollmentserver/discovery.svc + dmComplianceUrl : https://portal.manage.microsoft.com/Compliance + AzureAdPrt : YES + AzureAdPrtUpdateTime : 2025-03-10 10:00:00.000 UTC + Previous Prt Attempt : 2025-03-10 09:55:00.000 UTC + Attempt Status : 0xc000006d + HTTP status : 401 + User Context : SYSTEM + SessionIsNotRemote : NO + AD Connectivity Test : PASS + DRS Discovery Test : FAIL [0x801c0021] + Client ErrorCode : 0x801c03f2 + KeySignTest : PASSED + AadRecoveryEnabled : NO + DeviceCertificateValidity : [ 2025-03-01 00:00:00.000 UTC -- 2025-03-20 00:00:00.000 UTC ] +"#; + + #[test] + fn parses_high_value_fields_and_fallback_names() { + let facts = parse_dsregcmd(SAMPLE).expect("parse sample dsregcmd"); + + assert_eq!(facts.join_state.azure_ad_joined, Some(true)); + assert_eq!(facts.join_state.domain_joined, Some(false)); + assert_eq!( + facts.management_details.mdm_compliance_url.as_deref(), + Some("https://portal.manage.microsoft.com/Compliance") + ); + assert_eq!(facts.diagnostics.http_status, Some(401)); + assert_eq!(facts.diagnostics.user_context.as_deref(), Some("SYSTEM")); + assert_eq!(facts.user_state.session_is_not_remote, Some(false)); + assert_eq!( + facts.pre_join_tests.drs_discovery_test.as_deref(), + Some("FAIL [0x801c0021]") + ); + assert_eq!( + facts.registration.client_error_code.as_deref(), + Some("0x801c03f2") + ); + assert_eq!( + facts.post_join_diagnostics.key_sign_test.as_deref(), + Some("PASSED") + ); + assert_eq!(facts.post_join_diagnostics.aad_recovery_enabled, Some(false)); + } + + #[test] + fn rejects_non_dsregcmd_input() { + let error = parse_dsregcmd("totally unrelated text").expect_err("expected parse error"); + assert!(error.contains("recognizable dsregcmd")); + } + + #[test] + fn treats_placeholder_values_as_absent() { + let facts = parse_dsregcmd("MdmUrl : -\nTenantName : n/a\nAzureAdJoined : YES") + .expect("parse placeholder sample"); + + assert_eq!(facts.management_details.mdm_url, None); + assert_eq!(facts.tenant_details.tenant_name, None); + assert_eq!(facts.join_state.azure_ad_joined, Some(true)); + } +} diff --git a/src-tauri/src/dsregcmd/registry.rs b/src-tauri/src/dsregcmd/registry.rs new file mode 100644 index 000000000..63e20088e --- /dev/null +++ b/src-tauri/src/dsregcmd/registry.rs @@ -0,0 +1,476 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::dsregcmd::models::{ + DsregcmdEvidenceSource, DsregcmdPolicyEvidenceValue, DsregcmdWhfbPolicyEvidence, +}; + +const REGISTRY_FOLDER: [&str; 2] = ["evidence", "registry"]; +const POLICYMANAGER_CURRENT_FILE: &str = "policymanager-device.reg"; +const POLICYMANAGER_PROVIDERS_FILE: &str = "policymanager-providers.reg"; +const HKCU_POLICIES_FILE: &str = "hkcu-policies.reg"; +const HKLM_POLICIES_FILE: &str = "hklm-policies.reg"; +const HKCU_MICROSOFT_POLICIES_FILE: &str = "hkcu-microsoft-policies.reg"; +const HKLM_MICROSOFT_POLICIES_FILE: &str = "hklm-microsoft-policies.reg"; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RegistryValue { + Dword(u32), + String(String), +} + +type RegistryKeyMap = HashMap>; + +pub fn load_whfb_policy_evidence(bundle_path: &Path) -> DsregcmdWhfbPolicyEvidence { + let mut evidence = DsregcmdWhfbPolicyEvidence::default(); + + let current_path = registry_file_path(bundle_path, POLICYMANAGER_CURRENT_FILE); + let provider_path = registry_file_path(bundle_path, POLICYMANAGER_PROVIDERS_FILE); + let hkcu_policies_path = registry_file_path(bundle_path, HKCU_POLICIES_FILE); + let hklm_policies_path = registry_file_path(bundle_path, HKLM_POLICIES_FILE); + let hkcu_microsoft_policies_path = + registry_file_path(bundle_path, HKCU_MICROSOFT_POLICIES_FILE); + let hklm_microsoft_policies_path = + registry_file_path(bundle_path, HKLM_MICROSOFT_POLICIES_FILE); + + let current_registry = load_registry_map(¤t_path, &mut evidence.artifact_paths); + let provider_registry = load_registry_map(&provider_path, &mut evidence.artifact_paths); + let hkcu_policy_registry = load_registry_map(&hkcu_policies_path, &mut evidence.artifact_paths); + let hklm_policy_registry = load_registry_map(&hklm_policies_path, &mut evidence.artifact_paths); + let hkcu_microsoft_policy_registry = + load_registry_map(&hkcu_microsoft_policies_path, &mut evidence.artifact_paths); + let hklm_microsoft_policy_registry = + load_registry_map(&hklm_microsoft_policies_path, &mut evidence.artifact_paths); + + evidence.policy_enabled = build_policy_value( + current_policy_value(¤t_registry, "UsePassportForWork"), + provider_policy_value(&provider_registry, "UsePassportForWork"), + machine_policy_value(&hklm_policy_registry, &hklm_microsoft_policy_registry, "UsePassportForWork"), + user_policy_value(&hkcu_policy_registry, &hkcu_microsoft_policy_registry, "UsePassportForWork"), + false, + ); + evidence.post_logon_enabled = build_policy_value( + current_policy_value(¤t_registry, "DisablePostLogonProvisioning"), + provider_policy_value(&provider_registry, "DisablePostLogonProvisioning"), + machine_policy_value(&hklm_policy_registry, &hklm_microsoft_policy_registry, "DisablePostLogonProvisioning"), + user_policy_value(&hkcu_policy_registry, &hkcu_microsoft_policy_registry, "DisablePostLogonProvisioning"), + true, + ); + evidence.pin_recovery_enabled = build_policy_value( + current_policy_value(¤t_registry, "EnablePinRecovery"), + provider_policy_value(&provider_registry, "EnablePinRecovery"), + machine_policy_value(&hklm_policy_registry, &hklm_microsoft_policy_registry, "EnablePinRecovery"), + user_policy_value(&hkcu_policy_registry, &hkcu_microsoft_policy_registry, "EnablePinRecovery"), + false, + ); + evidence.require_security_device = build_policy_value( + current_policy_value(¤t_registry, "RequireSecurityDevice"), + provider_policy_value(&provider_registry, "RequireSecurityDevice"), + machine_policy_value(&hklm_policy_registry, &hklm_microsoft_policy_registry, "RequireSecurityDevice"), + user_policy_value(&hkcu_policy_registry, &hkcu_microsoft_policy_registry, "RequireSecurityDevice"), + false, + ); + evidence.use_certificate_for_on_prem_auth = build_policy_value( + current_policy_value(¤t_registry, "UseCertificateForOnPremAuth"), + provider_policy_value(&provider_registry, "UseCertificateForOnPremAuth"), + machine_policy_value(&hklm_policy_registry, &hklm_microsoft_policy_registry, "UseCertificateForOnPremAuth"), + user_policy_value(&hkcu_policy_registry, &hkcu_microsoft_policy_registry, "UseCertificateForOnPremAuth"), + false, + ); + evidence.use_cloud_trust_for_on_prem_auth = build_policy_value( + current_policy_value(¤t_registry, "UseCloudTrustForOnPremAuth"), + provider_policy_value(&provider_registry, "UseCloudTrustForOnPremAuth"), + machine_policy_value(&hklm_policy_registry, &hklm_microsoft_policy_registry, "UseCloudTrustForOnPremAuth"), + user_policy_value(&hkcu_policy_registry, &hkcu_microsoft_policy_registry, "UseCloudTrustForOnPremAuth"), + false, + ); + + annotate_missing_policy_evidence(&mut evidence); + + evidence +} + +fn annotate_missing_policy_evidence(evidence: &mut DsregcmdWhfbPolicyEvidence) { + let has_artifacts = !evidence.artifact_paths.is_empty(); + if !has_artifacts { + return; + } + + let missing_note = Some( + "Registry artifacts were captured, but no mapped PassportForWork policy values were present in this bundle.".to_string(), + ); + + if evidence.policy_enabled.display_value.is_none() && evidence.policy_enabled.note.is_none() { + evidence.policy_enabled.note = missing_note.clone(); + } + + if evidence.post_logon_enabled.display_value.is_none() && evidence.post_logon_enabled.note.is_none() { + evidence.post_logon_enabled.note = missing_note; + } +} + +fn registry_file_path(bundle_path: &Path, file_name: &str) -> PathBuf { + REGISTRY_FOLDER + .iter() + .fold(bundle_path.to_path_buf(), |path, segment| path.join(segment)) + .join(file_name) +} + +fn load_registry_map(path: &Path, artifact_paths: &mut Vec) -> RegistryKeyMap { + if !path.is_file() { + return HashMap::new(); + } + + artifact_paths.push(path.to_string_lossy().to_string()); + + match fs::read_to_string(path) { + Ok(content) => parse_reg_snapshot(&content), + Err(_) => match fs::read(path) + .ok() + .and_then(|bytes| decode_reg_content(&bytes)) + { + Some(content) => parse_reg_snapshot(&content), + None => HashMap::new(), + }, + } +} + +fn decode_reg_content(bytes: &[u8]) -> Option { + if bytes.starts_with(&[0xFF, 0xFE]) { + let units = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + return Some(String::from_utf16_lossy(&units)); + } + + if bytes.starts_with(&[0xFE, 0xFF]) { + let units = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect::>(); + return Some(String::from_utf16_lossy(&units)); + } + + String::from_utf8(bytes.to_vec()).ok() +} + +fn current_policy_value(registry: &RegistryKeyMap, value_name: &str) -> Option { + policy_value_from_keys(registry, value_name, |path| { + let normalized = path.to_ascii_lowercase(); + normalized.contains("\\policymanager\\current\\device\\") + && normalized.contains("\\passportforwork") + && normalized.contains("\\policies") + }) +} + +fn provider_policy_value(registry: &RegistryKeyMap, value_name: &str) -> Option { + policy_value_from_keys(registry, value_name, |path| { + let normalized = path.to_ascii_lowercase(); + normalized.contains("\\policymanager\\providers\\") + && normalized.contains("\\default\\device\\") + && normalized.contains("\\passportforwork") + && normalized.contains("\\policies") + }) +} + +fn machine_policy_value( + policy_registry: &RegistryKeyMap, + microsoft_policy_registry: &RegistryKeyMap, + value_name: &str, +) -> Option { + policy_value_from_keys(policy_registry, value_name, |path| { + let normalized = path.to_ascii_lowercase(); + normalized.contains("\\software\\policies\\microsoft\\passportforwork") + || normalized.contains("\\software\\policies\\passportforwork") + }) + .or_else(|| { + policy_value_from_keys(microsoft_policy_registry, value_name, |path| { + path.to_ascii_lowercase() + .contains("\\software\\microsoft\\policies\\passportforwork") + }) + }) +} + +fn user_policy_value( + policy_registry: &RegistryKeyMap, + microsoft_policy_registry: &RegistryKeyMap, + value_name: &str, +) -> Option { + policy_value_from_keys(policy_registry, value_name, |path| { + let normalized = path.to_ascii_lowercase(); + normalized.contains("\\software\\policies\\microsoft\\passportforwork") + || normalized.contains("\\software\\policies\\passportforwork") + }) + .or_else(|| { + policy_value_from_keys(microsoft_policy_registry, value_name, |path| { + path.to_ascii_lowercase() + .contains("\\software\\microsoft\\policies\\passportforwork") + }) + }) +} + +fn policy_value_from_keys( + registry: &RegistryKeyMap, + value_name: &str, + matches_key: impl Fn(&str) -> bool, +) -> Option { + let value_name = value_name.to_ascii_lowercase(); + + registry.iter().find_map(|(path, values)| { + if !matches_key(path) { + return None; + } + + values + .get(&value_name) + .and_then(parse_registry_bool) + }) +} + +fn build_policy_value( + current_value: Option, + provider_value: Option, + machine_policy_value: Option, + user_policy_value: Option, + invert: bool, +) -> DsregcmdPolicyEvidenceValue { + let adjusted_current = current_value.map(|value| if invert { !value } else { value }); + let adjusted_provider = provider_value.map(|value| if invert { !value } else { value }); + let adjusted_machine = machine_policy_value.map(|value| if invert { !value } else { value }); + let adjusted_user = user_policy_value.map(|value| if invert { !value } else { value }); + + let (display_value, source, note) = match (adjusted_current, adjusted_provider) { + (Some(current), Some(provider)) if current == provider => ( + Some(current), + Some(DsregcmdEvidenceSource::PolicyManagerComparison), + Some("PolicyManager current state and provider state agree.".to_string()), + ), + (Some(current), Some(provider)) => ( + Some(current), + Some(DsregcmdEvidenceSource::PolicyManagerCurrent), + Some(format!( + "Policy delivered but not effective. Provider state says {} while current effective state says {}.", + format_bool(provider), + format_bool(current) + )), + ), + (Some(current), None) => ( + Some(current), + Some(DsregcmdEvidenceSource::PolicyManagerCurrent), + None, + ), + (None, Some(provider)) => ( + Some(provider), + Some(DsregcmdEvidenceSource::PolicyManagerProvider), + Some("Only provider-delivered policy was available, so this may not reflect the current effective state yet.".to_string()), + ), + (None, None) => match (adjusted_machine, adjusted_user) { + (Some(machine), _) => ( + Some(machine), + Some(DsregcmdEvidenceSource::WindowsPolicyMachine), + Some("Derived from the exported Windows machine policy hive.".to_string()), + ), + (None, Some(user)) => ( + Some(user), + Some(DsregcmdEvidenceSource::WindowsPolicyUser), + Some("Derived from the exported Windows user policy hive.".to_string()), + ), + (None, None) => (None, None, None), + }, + }; + + DsregcmdPolicyEvidenceValue { + display_value, + current_value: adjusted_current, + provider_value: adjusted_provider, + source, + note, + } +} + +fn format_bool(value: bool) -> &'static str { + if value { + "Yes" + } else { + "No" + } +} + +fn parse_reg_snapshot(content: &str) -> RegistryKeyMap { + let mut registry = HashMap::new(); + let mut current_key: Option = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() + || line.starts_with(';') + || line.starts_with("Windows Registry Editor") + { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + current_key = Some(line[1..line.len() - 1].trim().to_string()); + continue; + } + + let Some(key_path) = current_key.as_ref() else { + continue; + }; + let Some((value_name, value)) = parse_reg_value(line) else { + continue; + }; + + registry + .entry(key_path.clone()) + .or_insert_with(HashMap::new) + .insert(value_name.to_ascii_lowercase(), value); + } + + registry +} + +fn parse_reg_value(line: &str) -> Option<(String, RegistryValue)> { + let (name, value) = line.split_once('=')?; + let normalized_name = name.trim().trim_matches('"'); + if normalized_name.is_empty() || normalized_name == "@" { + return None; + } + + let value = parse_registry_value(value.trim())?; + Some((normalized_name.to_string(), value)) +} + +fn parse_registry_value(value: &str) -> Option { + if let Some(raw_value) = value.strip_prefix("dword:") { + let parsed = u32::from_str_radix(raw_value.trim(), 16).ok()?; + return Some(RegistryValue::Dword(parsed)); + } + + if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 { + return Some(RegistryValue::String(value[1..value.len() - 1].replace("\\\\", "\\"))); + } + + Some(RegistryValue::String(value.to_string())) +} + +fn parse_registry_bool(value: &RegistryValue) -> Option { + match value { + RegistryValue::Dword(raw) => Some(*raw != 0), + RegistryValue::String(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" => Some(true), + "0" | "false" | "no" => Some(false), + _ => None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::{decode_reg_content, load_whfb_policy_evidence, parse_reg_snapshot}; + use crate::dsregcmd::models::DsregcmdEvidenceSource; + + #[test] + fn parses_registry_snapshot_values() { + let sample = r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\Current\Device\PassportForWork\Policies] +"UsePassportForWork"=dword:00000001 +"DisablePostLogonProvisioning"=dword:00000000 +"EnablePinRecovery"=dword:00000001 +"#; + + let registry = parse_reg_snapshot(sample); + assert_eq!(registry.len(), 1); + let key = registry + .keys() + .next() + .expect("expected a parsed registry key"); + assert!(key.contains("PassportForWork")); + } + + #[test] + fn derives_whfb_policy_evidence_from_bundle_files() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let registry_dir = temp_dir.path().join("evidence").join("registry"); + std::fs::create_dir_all(®istry_dir).expect("create registry dir"); + + std::fs::write( + registry_dir.join("policymanager-device.reg"), + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\Current\Device\PassportForWork\Policies] +"UsePassportForWork"=dword:00000001 +"DisablePostLogonProvisioning"=dword:00000001 +"EnablePinRecovery"=dword:00000000 +"#, + ) + .expect("write current registry sample"); + + std::fs::write( + registry_dir.join("policymanager-providers.reg"), + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\Providers\{11111111-1111-1111-1111-111111111111}\default\Device\PassportForWork\Policies] +"UsePassportForWork"=dword:00000001 +"DisablePostLogonProvisioning"=dword:00000000 +"#, + ) + .expect("write provider registry sample"); + + let evidence = load_whfb_policy_evidence(temp_dir.path()); + assert_eq!(evidence.policy_enabled.display_value, Some(true)); + assert_eq!( + evidence.policy_enabled.source, + Some(DsregcmdEvidenceSource::PolicyManagerComparison) + ); + assert_eq!(evidence.post_logon_enabled.display_value, Some(false)); + assert_eq!( + evidence.post_logon_enabled.source, + Some(DsregcmdEvidenceSource::PolicyManagerCurrent) + ); + assert!(evidence + .post_logon_enabled + .note + .as_deref() + .unwrap_or_default() + .contains("Policy delivered but not effective")); + assert_eq!(evidence.pin_recovery_enabled.display_value, Some(false)); + } + + #[test] + fn decodes_utf16le_reg_exports() { + let utf16 = vec![ + 0xFF, 0xFE, 0x57, 0x00, 0x69, 0x00, 0x6E, 0x00, 0x64, 0x00, 0x6F, 0x00, 0x77, + 0x00, 0x73, 0x00, + ]; + let decoded = decode_reg_content(&utf16).expect("decode utf16 reg export"); + assert_eq!(decoded, "Windows"); + } + + #[test] + fn falls_back_to_windows_policy_hive_values() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let registry_dir = temp_dir.path().join("evidence").join("registry"); + std::fs::create_dir_all(®istry_dir).expect("create registry dir"); + + let utf16_content = "Windows Registry Editor Version 5.00\r\n\r\n[HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Policies\\PassportForWork]\r\n\"UsePassportForWork\"=dword:00000001\r\n\"DisablePostLogonProvisioning\"=dword:00000000\r\n"; + let mut bytes = vec![0xFF, 0xFE]; + for unit in utf16_content.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + + std::fs::write(registry_dir.join("hklm-microsoft-policies.reg"), bytes) + .expect("write hklm policies sample"); + + let evidence = load_whfb_policy_evidence(temp_dir.path()); + assert_eq!(evidence.policy_enabled.display_value, Some(true)); + assert_eq!( + evidence.policy_enabled.source, + Some(DsregcmdEvidenceSource::WindowsPolicyMachine) + ); + assert_eq!(evidence.post_logon_enabled.display_value, Some(true)); + } +} \ No newline at end of file diff --git a/src-tauri/src/dsregcmd/rules.rs b/src-tauri/src/dsregcmd/rules.rs new file mode 100644 index 000000000..8388428e8 --- /dev/null +++ b/src-tauri/src/dsregcmd/rules.rs @@ -0,0 +1,2122 @@ +use chrono::{DateTime, Local, LocalResult, NaiveDateTime, TimeZone, Utc}; +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::dsregcmd::models::{ + DsregcmdAnalysisResult, DsregcmdCaptureConfidence, DsregcmdDerived, + DsregcmdDiagnosticInsight, DsregcmdDiagnosticPhase, DsregcmdFacts, DsregcmdJoinType, +}; +use crate::intune::models::IntuneDiagnosticSeverity; + +const NETWORK_ERROR_MARKERS: &[&str] = &[ + "ERROR_WINHTTP_TIMEOUT", + "ERROR_WINHTTP_NAME_NOT_RESOLVED", + "ERROR_WINHTTP_CANNOT_CONNECT", + "ERROR_WINHTTP_CONNECTION_ERROR", +]; + +static CERTIFICATE_TIMESTAMP_RE: Lazy = Lazy::new(|| { + Regex::new( + r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?(?: UTC|Z)?|\d{1,2}/\d{1,2}/\d{4} \d{2}:\d{2}:\d{2}(?:\.\d+)?(?: UTC|Z)?", + ) + .expect("valid certificate timestamp regex") +}); + +pub fn analyze_facts(facts: DsregcmdFacts, raw_input: &str) -> DsregcmdAnalysisResult { + let derived = derive_facts(&facts, raw_input); + let diagnostics = build_diagnostics(&facts, &derived); + + DsregcmdAnalysisResult { + facts, + derived, + diagnostics, + policy_evidence: Default::default(), + } +} + +fn derive_facts(facts: &DsregcmdFacts, raw_input: &str) -> DsregcmdDerived { + let join_type = derive_join_type(facts); + let join_type_label = join_type_label(join_type).to_string(); + let mdm_enrolled = if facts.management_details.mdm_url.is_some() + || facts.management_details.mdm_compliance_url.is_some() + { + Some(true) + } else { + None + }; + let missing_mdm = match ( + facts.management_details.mdm_url.is_some(), + facts.management_details.mdm_compliance_url.is_some(), + ) { + (false, true) => Some(true), + (true, _) => Some(false), + (false, false) => None, + }; + let compliance_url_present = if facts.management_details.mdm_compliance_url.is_some() { + Some(true) + } else if facts.management_details.mdm_url.is_some() { + Some(false) + } else { + None + }; + let missing_compliance_url = match ( + facts.management_details.mdm_url.is_some(), + facts.management_details.mdm_compliance_url.is_some(), + ) { + (true, false) => Some(true), + (true, true) => Some(false), + (false, _) => None, + }; + let azure_ad_prt_present = facts.sso_state.azure_ad_prt; + let prt_reference_time = facts + .diagnostics + .client_time + .as_deref() + .and_then(parse_dsregcmd_timestamp) + .or_else(|| Some(Utc::now())); + let prt_last_update = facts + .sso_state + .azure_ad_prt_update_time + .as_deref() + .and_then(parse_dsregcmd_timestamp); + let prt_age_hours = match (prt_reference_time, prt_last_update) { + (Some(reference_time), Some(last_update)) => { + let age_hours = reference_time + .signed_duration_since(last_update) + .num_minutes() as f64 + / 60.0; + Some(age_hours.max(0.0)) + } + _ => None, + }; + let stale_prt = prt_age_hours.map(|hours| hours > 4.0); + let tpm_protected = facts.device_details.tpm_protected; + let (certificate_valid_from, certificate_valid_to) = facts + .device_details + .device_certificate_validity + .as_deref() + .map(parse_certificate_validity) + .unwrap_or((None, None)); + let certificate_days_remaining = match (prt_reference_time, certificate_valid_to) { + (Some(reference_time), Some(valid_to)) => { + Some(valid_to.signed_duration_since(reference_time).num_days()) + } + _ => None, + }; + let certificate_expiring_soon = certificate_days_remaining.map(|days| days < 30); + let network_error_code = detect_network_error(raw_input); + let has_network_error = network_error_code.is_some(); + let remote_session_system = match ( + facts.diagnostics.user_context.as_deref(), + facts.user_state.session_is_not_remote, + ) { + (Some(user_context), Some(false)) if user_context.eq_ignore_ascii_case("SYSTEM") => { + Some(true) + } + (Some(_), Some(_)) => Some(false), + _ => None, + }; + let dominant_phase = derive_dominant_phase(facts); + let phase_summary = phase_summary(dominant_phase).to_string(); + let (capture_confidence, capture_confidence_reason) = + derive_capture_confidence(facts, prt_reference_time, remote_session_system); + + DsregcmdDerived { + join_type, + join_type_label, + dominant_phase, + phase_summary, + capture_confidence, + capture_confidence_reason, + mdm_enrolled, + missing_mdm, + compliance_url_present, + missing_compliance_url, + azure_ad_prt_present, + stale_prt, + prt_last_update, + prt_reference_time, + prt_age_hours, + tpm_protected, + certificate_valid_from, + certificate_valid_to, + certificate_expiring_soon, + certificate_days_remaining, + network_error_code, + has_network_error, + remote_session_system, + } +} + +fn build_diagnostics( + facts: &DsregcmdFacts, + derived: &DsregcmdDerived, +) -> Vec { + let mut diagnostics = Vec::new(); + let aggregated_errors = aggregated_error_text(facts); + + if facts.join_state.azure_ad_joined == Some(false) { + diagnostics.push(issue( + "not-aadj", + IntuneDiagnosticSeverity::Error, + "authentication", + "Device is not Entra ID joined", + "AzureAdJoined is NO, so this device is not currently joined to Entra ID.", + vec![render_bool("AzureAdJoined", facts.join_state.azure_ad_joined)], + vec![ + "Confirm whether the device should be Entra ID joined or hybrid joined.".to_string(), + "Review the registration section for client or server error codes.".to_string(), + ], + vec![ + "Retry the join or registration workflow from the intended user context.".to_string(), + "Check tenant targeting, licensing, and connectivity to Entra device registration endpoints.".to_string(), + ], + )); + } + + if is_missing(&facts.tenant_details.tenant_id) { + diagnostics.push(issue( + "missing-tenant", + IntuneDiagnosticSeverity::Error, + "configuration", + "Tenant identifier is missing", + "The dsregcmd output did not include TenantId, which usually indicates registration never completed or the device is not properly scoped to a tenant.", + vec![render_optional("TenantId", &facts.tenant_details.tenant_id)], + vec![ + "Verify the device is targeting the expected Entra tenant.".to_string(), + "Check registration errors and the join server endpoints in the dsregcmd output.".to_string(), + ], + vec![ + "Re-run device registration after confirming tenant discovery and network access.".to_string(), + ], + )); + } + + if is_missing(&facts.device_details.device_id) { + diagnostics.push(issue( + "missing-deviceid", + IntuneDiagnosticSeverity::Error, + "configuration", + "Device identifier is missing", + "The dsregcmd output did not include DeviceId, so the device is not presenting a stable Entra device identity.", + vec![render_optional("DeviceId", &facts.device_details.device_id)], + vec![ + "Check whether the device certificate and join state are populated.".to_string(), + "Review previous registration attempts and pre-join test results.".to_string(), + ], + vec![ + "Complete or repair device registration before troubleshooting downstream MDM or PRT issues.".to_string(), + ], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c03f2") + || contains_text(&facts.registration.server_error_code, "directoryerror") + || aggregated_errors.contains("directory sync pending") + { + diagnostics.push(issue( + "entra-sync-pending", + IntuneDiagnosticSeverity::Error, + "sync", + "Directory synchronization appears to be pending", + "The registration errors match the common hybrid join state where the device object has not fully synchronized to Entra ID yet.", + vec![ + render_optional("Client ErrorCode", &facts.registration.client_error_code), + render_optional("Server ErrorCode", &facts.registration.server_error_code), + ], + vec![ + "Confirm the corresponding on-premises device object has synchronized to Entra ID.".to_string(), + "Check Azure AD Connect or Cloud Sync health and object writeback timing.".to_string(), + ], + vec![ + "Wait for directory synchronization to complete, then retry registration.".to_string(), + ], + )); + } + + if aggregated_errors.contains("aadsts50155") { + diagnostics.push(issue( + "aadsts50155", + IntuneDiagnosticSeverity::Error, + "authentication", + "Device authentication failed with AADSTS50155", + "The tenant rejected the authentication request because device authentication requirements were not satisfied.", + vec![ + render_optional("Server Message", &facts.registration.server_message), + render_optional( + "Server Error Description", + &facts.registration.server_error_description, + ), + ], + vec![ + "Confirm the device object exists and is enabled in Entra ID.".to_string(), + "Validate certificate trust and device authentication state.".to_string(), + ], + vec![ + "Repair the device registration or remove stale device objects before retrying sign-in.".to_string(), + ], + )); + } + + if aggregated_errors.contains("aadsts50034") { + diagnostics.push(issue( + "aadsts50034", + IntuneDiagnosticSeverity::Error, + "user", + "User account was not found in the tenant", + "The dsregcmd error fields contain AADSTS50034, which points to an unknown or mismatched user account during sign-in or registration.", + vec![render_optional("Server Message", &facts.registration.server_message)], + vec![ + "Check the user identity shown in the diagnostics block.".to_string(), + "Confirm the user belongs to the expected tenant and is synchronized.".to_string(), + ], + vec![ + "Retry the sign-in flow with the correct tenant-aligned user account.".to_string(), + ], + )); + } + + push_test_failure( + &mut diagnostics, + "drs-discovery-failed", + "discovery", + "DRS discovery test failed", + &facts.pre_join_tests.drs_discovery_test, + vec![ + "Validate DNS resolution and reachability for the join service URLs.".to_string(), + "Check whether the tenant discovery endpoints are correct for this tenant.".to_string(), + ], + vec!["Resolve discovery failures before retrying registration.".to_string()], + ); + push_test_failure( + &mut diagnostics, + "drs-connectivity-failed", + "connectivity", + "DRS connectivity test failed", + &facts.pre_join_tests.drs_connectivity_test, + vec![ + "Verify outbound HTTPS connectivity to the DRS endpoint.".to_string(), + "Check proxy, TLS inspection, and firewall behavior.".to_string(), + ], + vec!["Restore connectivity to the DRS service and re-run dsregcmd.".to_string()], + ); + push_test_failure( + &mut diagnostics, + "ad-connectivity-failed", + "connectivity", + "Active Directory connectivity test failed", + &facts.pre_join_tests.ad_connectivity_test, + vec![ + "Confirm the device can reach a domain controller.".to_string(), + "Review VPN, line-of-sight, and DNS configuration for domain connectivity.".to_string(), + ], + vec!["Restore AD connectivity before retrying hybrid join.".to_string()], + ); + push_test_failure( + &mut diagnostics, + "ad-config-failed", + "configuration", + "Active Directory configuration test failed", + &facts.pre_join_tests.ad_configuration_test, + vec![ + "Review SCP configuration and tenant targeting in on-premises Active Directory." + .to_string(), + "Confirm the domain is configured for hybrid join.".to_string(), + ], + vec!["Correct the AD hybrid join configuration and retry registration.".to_string()], + ); + + if has_code(facts, "0xcaa90017") { + diagnostics.push(issue( + "adal-protocol-not-supported", + IntuneDiagnosticSeverity::Error, + "authentication", + "Federation service does not support the required WS-Trust protocol", + "The failure pattern matches ERROR_ADAL_PROTOCOL_NOT_SUPPORTED (0xcaa90017), which means the federated identity provider is not exposing the WS-Trust protocol flow that hybrid join expects.", + vec![render_phase_code_evidence(facts, "0xcaa90017")], + vec![ + "Review federation settings and confirm the on-premises identity provider supports WS-Trust for device registration flows.".to_string(), + "Check whether the endpoint returned by federation metadata matches the protocol expected by Windows.".to_string(), + ], + vec!["Enable or repair the required WS-Trust support in the identity provider.".to_string()], + )); + } + + if has_code(facts, "0xcaa9002c") { + diagnostics.push(issue( + "adal-parse-xml-failed", + IntuneDiagnosticSeverity::Error, + "configuration", + "Federation metadata XML could not be parsed", + "The failure pattern matches ERROR_ADAL_FAILED_TO_PARSE_XML (0xcaa9002c), which usually means the MEX or WS-Trust endpoint returned malformed or unexpected XML.", + vec![render_phase_code_evidence(facts, "0xcaa9002c")], + vec![ + "Inspect the MEX endpoint response for valid XML and correct WS-Trust metadata.".to_string(), + "Check whether a proxy is replacing the expected federation XML with an HTML or otherwise modified response.".to_string(), + ], + vec!["Repair the federation metadata response so it returns valid XML.".to_string()], + )); + } + + if has_code(facts, "0xcaa90023") { + diagnostics.push(issue( + "adal-password-endpoint-missing", + IntuneDiagnosticSeverity::Error, + "configuration", + "Federation metadata is missing the username or password endpoint", + "The failure pattern matches ERROR_ADAL_COULDNOT_DISCOVER_USERNAME_PASSWORD_ENDPOINT (0xcaa90023), which means the MEX response does not advertise the WS-Trust endpoint Windows expects to use.", + vec![render_phase_code_evidence(facts, "0xcaa90023")], + vec![ + "Review the MEX response for the required WS-Trust username/password endpoints.".to_string(), + "Confirm the identity provider publishes the correct endpoints for federated hybrid join.".to_string(), + ], + vec!["Fix the federation metadata so the required endpoints are advertised.".to_string()], + )); + } + + if has_code(facts, "0xcaa82ee2") { + diagnostics.push(issue( + "adal-timeout", + IntuneDiagnosticSeverity::Error, + "network", + "Federated authentication timed out", + "The failure pattern matches ERROR_ADAL_INTERNET_TIMEOUT (0xcaa82ee2), which means the device could not complete communication with Microsoft Entra or the federation endpoint in time.", + vec![render_phase_code_evidence(facts, "0xcaa82ee2")], + vec![ + "Verify connectivity to https://login.microsoftonline.com and the federated identity provider from the effective system or user context.".to_string(), + "Check proxy behavior, network stability, and endpoint reachability.".to_string(), + ], + vec!["Resolve the network timeout path before retrying authentication.".to_string()], + )); + } + + if has_code(facts, "0xcaa82efe") { + diagnostics.push(issue( + "adal-connection-aborted", + IntuneDiagnosticSeverity::Error, + "network", + "Federated authentication connection was aborted", + "The failure pattern matches ERROR_ADAL_INTERNET_CONNECTION_ABORTED (0xcaa82efe), which means the connection to the authorization endpoint terminated unexpectedly.", + vec![render_phase_code_evidence(facts, "0xcaa82efe")], + vec![ + "Check for unstable network links or intermediary devices terminating the connection.".to_string(), + "Retry from a more stable network path and compare results.".to_string(), + ], + vec!["Stabilize the network path or endpoint availability before retrying.".to_string()], + )); + } + + if has_code(facts, "0xcaa82f8f") { + diagnostics.push(issue( + "adal-secure-failure", + IntuneDiagnosticSeverity::Error, + "network", + "TLS validation failed during federated authentication", + "The failure pattern matches ERROR_ADAL_INTERNET_SECURE_FAILURE (0xcaa82f8f), which usually means certificate validation or time skew blocked the secure connection.", + vec![render_phase_code_evidence(facts, "0xcaa82f8f")], + vec![ + "Check device time skew and certificate trust for the federated endpoint.".to_string(), + "Review whether TLS inspection or certificate replacement is breaking trust.".to_string(), + ], + vec!["Fix certificate trust or time skew before retrying federated authentication.".to_string()], + )); + } + + if has_code(facts, "0xcaa82efd") { + diagnostics.push(issue( + "adal-cannot-connect", + IntuneDiagnosticSeverity::Error, + "network", + "The device could not connect to the federated or Microsoft Entra endpoint", + "The failure pattern matches ERROR_ADAL_INTERNET_CANNOT_CONNECT (0xcaa82efd), which means the connection to the authentication endpoint could not be established.", + vec![render_phase_code_evidence(facts, "0xcaa82efd")], + vec![ + "Check endpoint URI reachability and whether outbound proxy rules allow the traffic.".to_string(), + "Validate DNS resolution and HTTPS connectivity for the authentication endpoint.".to_string(), + ], + vec!["Restore connectivity to the authentication endpoint and retry.".to_string()], + )); + } + + if has_code(facts, "0xcaa20003") { + diagnostics.push(issue( + "adal-invalid-grant", + IntuneDiagnosticSeverity::Error, + "authentication", + "Microsoft Entra rejected the federated assertion as an invalid grant", + "The failure pattern matches ERROR_ADAL_SERVER_ERROR_INVALID_GRANT (0xcaa20003), which means Microsoft Entra rejected the SAML assertion supplied by the federated identity provider.", + vec![render_phase_code_evidence(facts, "0xcaa20003")], + vec![ + "Review federation logs and server error details for why the assertion was rejected.".to_string(), + "Check claim issuance rules and token content from the identity provider.".to_string(), + ], + vec!["Repair the federation assertion flow before retrying sign-in.".to_string()], + )); + } + + if has_code(facts, "0xcaa90014") { + diagnostics.push(issue( + "adal-wstrust-request-failed", + IntuneDiagnosticSeverity::Error, + "authentication", + "The WS-Trust request failed with a server-side fault", + "The failure pattern matches ERROR_ADAL_WSTRUST_REQUEST_SECURITYTOKEN_FAILED (0xcaa90014), which means the federation service returned a WS-Trust fault instead of the expected assertion.", + vec![render_phase_code_evidence(facts, "0xcaa90014")], + vec![ + "Review federation server logs for the specific WS-Trust fault returned to the device.".to_string(), + "Check whether metadata, claim rules, or certificate trust issues are causing the failure.".to_string(), + ], + vec!["Repair the WS-Trust issuance path before retrying authentication.".to_string()], + )); + } + + if has_code(facts, "0xcaa90006") { + diagnostics.push(issue( + "adal-token-request-failed", + IntuneDiagnosticSeverity::Error, + "authentication", + "The federated token request failed before an access token was returned", + "The failure pattern matches ERROR_ADAL_WSTRUST_TOKEN_REQUEST_FAIL (0xcaa90006), which means the device could not complete token acquisition against the WS-Trust endpoint.", + vec![render_phase_code_evidence(facts, "0xcaa90006")], + vec![ + "Check the underlying federation endpoint error and whether the request reached the token service.".to_string(), + "Review WS-Trust endpoint health and metadata correctness.".to_string(), + ], + vec!["Resolve the WS-Trust token issuance failure and retry.".to_string()], + )); + } + + if has_code(facts, "0xcaa1002d") { + diagnostics.push(issue( + "adal-operation-pending", + IntuneDiagnosticSeverity::Warning, + "authentication", + "The federated authentication operation is still reported as pending", + "The failure pattern matches ERROR_ADAL_OPERATION_PENDING (0xcaa1002d), which is a broad ADAL failure that usually needs the underlying suberror or server code to narrow down the root cause.", + vec![render_phase_code_evidence(facts, "0xcaa1002d")], + vec![ + "Check any accompanying federation server or suberror details in the capture.".to_string(), + "Look for a more specific network, WS-Trust, or server-side error in the surrounding fields.".to_string(), + ], + Vec::new(), + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c001d") + || contains_text(&facts.pre_join_tests.ad_configuration_test, "0x801c001d") + { + diagnostics.push(issue( + "scp-read-failed", + IntuneDiagnosticSeverity::Error, + "configuration", + "Service Connection Point lookup failed", + "The failure pattern matches DSREG_AUTOJOIN_ADCONFIG_READ_FAILED (0x801c001d), which usually means the device could not read or validate the Service Connection Point for hybrid join.", + vec![ + render_optional("AD Configuration Test", &facts.pre_join_tests.ad_configuration_test), + render_optional("Client ErrorCode", &facts.registration.client_error_code), + ], + vec![ + "Verify the SCP exists in the correct forest and points to the expected verified tenant domain.".to_string(), + "Check whether the device can read the SCP from a reachable domain controller.".to_string(), + ], + vec![ + "Correct the hybrid join SCP configuration before retrying registration.".to_string(), + ], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c0021") + || contains_text(&facts.pre_join_tests.drs_discovery_test, "0x801c0021") + { + diagnostics.push(issue( + "drs-discovery-code", + IntuneDiagnosticSeverity::Error, + "discovery", + "DRS discovery metadata retrieval failed", + "The failure pattern matches DSREG_AUTOJOIN_DISC_FAILED (0x801c0021), which points to tenant discovery metadata retrieval failing before registration can proceed.", + vec![ + render_optional("DRS Discovery Test", &facts.pre_join_tests.drs_discovery_test), + render_optional("Client ErrorCode", &facts.registration.client_error_code), + ], + vec![ + "Verify access to https://enterpriseregistration.windows.net from the effective system context.".to_string(), + "Check proxy, DNS, and TLS inspection behavior around discovery endpoints.".to_string(), + ], + vec!["Restore discovery endpoint access and retry hybrid join.".to_string()], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c001f") + || contains_text(&facts.pre_join_tests.drs_discovery_test, "0x801c001f") + { + diagnostics.push(issue( + "drs-discovery-timeout", + IntuneDiagnosticSeverity::Error, + "network", + "DRS discovery timed out", + "The failure pattern matches DSREG_AUTOJOIN_DISC_WAIT_TIMEOUT (0x801c001f), which usually indicates the discovery endpoint could not be reached reliably from the current system context.", + vec![ + render_optional("DRS Discovery Test", &facts.pre_join_tests.drs_discovery_test), + render_optional("Client ErrorCode", &facts.registration.client_error_code), + ], + vec![ + "Confirm outbound HTTPS connectivity to https://enterpriseregistration.windows.net.".to_string(), + "Check WinHTTP proxy configuration and whether the computer account can authenticate through the proxy.".to_string(), + ], + vec!["Resolve the discovery timeout path before retrying join.".to_string()], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c003d") { + diagnostics.push(issue( + "user-realm-discovery-failed", + IntuneDiagnosticSeverity::Error, + "authentication", + "User realm discovery failed", + "The failure pattern matches DSREG_AUTOJOIN_USERREALM_DISCOVERY_FAILED (0x801c003d), which means the device could not determine whether the user domain is managed or federated.", + vec![render_optional("Client ErrorCode", &facts.registration.client_error_code)], + vec![ + "Verify the user realm lookup can reach https://login.microsoftonline.com from the system context.".to_string(), + "Check whether proxy requirements or tenant domain configuration are interfering with realm discovery.".to_string(), + ], + vec!["Restore user realm discovery before retrying hybrid join.".to_string()], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x8007000d") + || contains_text(&facts.diagnostics.http_error, "0x8007000d") + { + diagnostics.push(issue( + "invalid-discovery-response", + IntuneDiagnosticSeverity::Error, + "network", + "Discovery response could not be parsed", + "The failure pattern matches E_INVALIDDATA (0x8007000d), which often happens when a proxy or intermediary returns HTML or a modified response instead of the expected discovery JSON.", + vec![ + render_optional("Client ErrorCode", &facts.registration.client_error_code), + render_optional("HTTP Error", &facts.diagnostics.http_error), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Check whether an outbound proxy is intercepting or rewriting the discovery response.".to_string(), + "Compare the endpoint behavior from the system context against a healthy device.".to_string(), + ], + vec!["Fix the proxy or response path so discovery returns the expected JSON.".to_string()], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c0002") { + diagnostics.push(issue( + "join-device-authentication-error", + IntuneDiagnosticSeverity::Error, + "authentication", + "Join failed because device authentication was rejected", + "The failure pattern matches DSREG_E_DEVICE_AUTHENTICATION_ERROR (0x801c0002), which means the DRS service rejected device authentication during join.", + vec![ + render_optional("Client ErrorCode", &facts.registration.client_error_code), + render_optional("Server ErrorCode", &facts.registration.server_error_code), + render_optional("Server Message", &facts.registration.server_message), + ], + vec![ + "Validate the device certificate, device object state, and sync status in Entra ID.".to_string(), + "Check whether the server error indicates an authentication mismatch or stale registration state.".to_string(), + ], + vec!["Repair device registration state before retrying join.".to_string()], + )); + } + + if contains_text(&facts.registration.client_error_code, "0x801c0006") { + diagnostics.push(issue( + "join-internal-service-error", + IntuneDiagnosticSeverity::Error, + "join", + "Join failed because the registration service returned an internal error", + "The failure pattern matches DSREG_E_DEVICE_INTERNALSERVICE_ERROR (0x801c0006), which means the DRS service returned an internal failure during join processing.", + vec![ + render_optional("Client ErrorCode", &facts.registration.client_error_code), + render_optional("Server ErrorCode", &facts.registration.server_error_code), + render_optional("Server Message", &facts.registration.server_message), + ], + vec![ + "Check whether the failure is transient or whether server-side throttling or directory errors are also present.".to_string(), + "Compare with another registration attempt after a short wait.".to_string(), + ], + vec!["Retry the join after confirming the service-side condition has cleared.".to_string()], + )); + } + + if facts.sso_state.azure_ad_prt == Some(false) { + diagnostics.push(issue( + "no-azure-prt", + IntuneDiagnosticSeverity::Error, + "authentication", + "No Azure AD PRT is present", + "AzureAdPrt is NO, so the current sign-in context does not have a Primary Refresh Token available.", + vec![render_bool("AzureAdPrt", facts.sso_state.azure_ad_prt)], + vec![ + "Check the diagnostics block for the last PRT acquisition attempt.".to_string(), + "Review WAM, credentials, and device authentication health.".to_string(), + ], + vec![ + "Have the user sign out and back in after correcting registration or credential issues.".to_string(), + ], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc000006d") { + diagnostics.push(issue( + "invalid-credentials", + IntuneDiagnosticSeverity::Error, + "credentials", + "PRT acquisition failed because credentials were rejected", + "Attempt Status contains 0xc000006d, which maps to invalid credentials during the sign-in flow.", + vec![render_optional("Attempt Status", &facts.diagnostics.attempt_status)], + vec![ + "Check whether the user recently changed their password or entered the wrong credentials.".to_string(), + "Review the credential type and user identity fields in the diagnostics section.".to_string(), + ], + vec![ + "Retry authentication with the correct credentials or refreshed password.".to_string(), + ], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc000006a") { + diagnostics.push(issue( + "wrong-password", + IntuneDiagnosticSeverity::Error, + "credentials", + "PRT acquisition failed because the password was rejected", + "Attempt Status contains 0xc000006a, which maps to STATUS_WRONG_PASSWORD during PRT acquisition.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("User Identity", &facts.diagnostics.user_identity), + ], + vec![ + "Check whether the user recently changed their password and Entra password sync has not completed yet.".to_string(), + "Confirm the user is signing in with the intended UPN and password.".to_string(), + ], + vec!["Retry sign-in after password sync finishes or after correcting credentials.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc00000d0") { + diagnostics.push(issue( + "request-not-accepted", + IntuneDiagnosticSeverity::Error, + "authentication", + "PRT request was not accepted by the authentication endpoint", + "Attempt Status contains 0xc00000d0, which lines up with an HTTP 400-style rejection from the Microsoft Entra or WS-Trust endpoint.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("HTTP Status", &facts.diagnostics.http_status.as_ref().map(|value| value.to_string())), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Review server error details and endpoint URI for the rejected request.".to_string(), + "Check for federation or proxy behavior that alters the request flow.".to_string(), + ], + vec!["Resolve the endpoint rejection cause before retrying PRT acquisition.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc000023c") + || contains_text(&facts.diagnostics.attempt_status, "0xc00000be") + || contains_text(&facts.diagnostics.attempt_status, "0xc00000c4") + { + diagnostics.push(issue( + "prt-network-path-error", + IntuneDiagnosticSeverity::Error, + "network", + "PRT acquisition failed because the network path was unavailable", + "Attempt Status matches a documented network-path failure during PRT acquisition, which usually means the required Microsoft Entra or federation endpoint was unreachable or the connection failed mid-flight.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + render_optional("HTTP Error", &facts.diagnostics.http_error), + ], + vec![ + "Check network reachability to the endpoint URI from the current device context.".to_string(), + "Review proxy requirements and intermittent network stability issues.".to_string(), + ], + vec!["Restore the endpoint network path and retry token acquisition.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc000005f") { + diagnostics.push(issue( + "prt-user-realm-not-found", + IntuneDiagnosticSeverity::Error, + "user", + "PRT acquisition could not resolve the user realm", + "Attempt Status contains 0xc000005f, which usually means Microsoft Entra could not find the user's domain during realm discovery.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("User Identity", &facts.diagnostics.user_identity), + ], + vec![ + "Check whether the user's UPN suffix is a verified custom domain in the target tenant.".to_string(), + "If the on-premises domain is nonroutable, review Alternate Login ID configuration.".to_string(), + ], + vec!["Correct the user realm or UPN configuration before retrying sign-in.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc004844c") { + diagnostics.push(issue( + "malformed-upn", + IntuneDiagnosticSeverity::Error, + "user", + "The user principal name format is invalid for PRT acquisition", + "Attempt Status contains 0xc004844c, which indicates the user's UPN is not in the expected internet-style format.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("User Identity", &facts.diagnostics.user_identity), + ], + vec![ + "Validate the returned UPN format for the signed-in user.".to_string(), + "For hybrid join, compare with whoami /upn on the device and confirm the domain controller returns the expected value.".to_string(), + ], + vec!["Correct the user's UPN formatting or Alternate Login ID configuration.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc0048442") { + diagnostics.push(issue( + "missing-user-sid-in-token", + IntuneDiagnosticSeverity::Error, + "authentication", + "The returned identity token did not include a user SID", + "Attempt Status contains 0xc0048442, which means the token returned by Microsoft Entra did not contain the expected user SID claim.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Check whether a network proxy is rewriting or interfering with the token response.".to_string(), + "Compare the behavior from another network path without TLS or proxy interception.".to_string(), + ], + vec!["Fix the token response path before retrying sign-in.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc00484c1") { + diagnostics.push(issue( + "wstrust-empty-saml", + IntuneDiagnosticSeverity::Error, + "authentication", + "The WS-Trust endpoint returned an unusable SAML response", + "Attempt Status contains 0xc00484c1, which indicates the WS-Trust response did not contain the expected SAML tokens for federated authentication.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Review the WS-Trust endpoint response and whether a proxy is modifying it.".to_string(), + "Check federation logs for server-side faults during token issuance.".to_string(), + ], + vec!["Repair the WS-Trust response path before retrying authentication.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc004848b") + || contains_text(&facts.diagnostics.attempt_status, "0xc004848c") + { + diagnostics.push(issue( + "mex-endpoint-misconfigured", + IntuneDiagnosticSeverity::Error, + "configuration", + "The federation MEX metadata is missing required endpoints", + "Attempt Status matches the documented MEX endpoint misconfiguration where password or certificate URLs are missing from the federation metadata response.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Review the federation MEX response for missing WS-Trust password or certificate endpoints.".to_string(), + "Check whether a proxy is modifying the federation metadata response.".to_string(), + ], + vec!["Fix the MEX configuration to advertise the required endpoints.".to_string()], + )); + } + + if contains_text(&facts.diagnostics.attempt_status, "0xc00cee4f") { + diagnostics.push(issue( + "federation-xml-dtd-prohibited", + IntuneDiagnosticSeverity::Error, + "configuration", + "The federation XML response included a prohibited DTD", + "Attempt Status contains 0xc00cee4f, which means the WS-Trust XML response included a DTD that the parser rejects.", + vec![ + render_optional("Attempt Status", &facts.diagnostics.attempt_status), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Inspect the federation XML response for a DTD or other invalid additions.".to_string(), + "Check whether the identity provider or proxy is modifying the XML document.".to_string(), + ], + vec!["Remove the DTD from the federation XML response and retry authentication.".to_string()], + )); + } + + if contains_text(&facts.registration.server_error_description, "aadsts50126") { + diagnostics.push(issue( + "aadsts50126-detailed", + IntuneDiagnosticSeverity::Error, + "credentials", + "Server error description reports AADSTS50126", + "The detailed server error description indicates invalid username or password during authentication.", + vec![render_optional( + "Server Error Description", + &facts.registration.server_error_description, + )], + vec![ + "Compare the user identity in dsregcmd with the expected sign-in account.".to_string(), + "Review conditional access or federation prompts that may have redirected the flow.".to_string(), + ], + vec![ + "Retry sign-in with valid credentials after confirming the correct account.".to_string(), + ], + )); + } + + if has_code(facts, "0x80090016") { + diagnostics.push(issue( + "tpm-bad-keyset", + IntuneDiagnosticSeverity::Error, + "configuration", + "TPM key material is missing or invalid", + "The failure pattern matches NTE_BAD_KEYSET (0x80090016), which usually means the TPM-backed keyset no longer exists or the device image was prepared from a bad joined-state source.", + vec![render_phase_code_evidence(facts, "0x80090016")], + vec![ + "Check whether the TPM was cleared or whether the device image came from a machine that was already registered or joined.".to_string(), + "Compare with device registration history and account recovery behavior on the machine.".to_string(), + ], + vec!["Repair or re-register the device after correcting the TPM keyset issue.".to_string()], + )); + } + + if has_code(facts, "0x80290407") { + diagnostics.push(issue( + "tpm-internal-error", + IntuneDiagnosticSeverity::Error, + "configuration", + "The TPM reported an internal failure during join", + "The failure pattern matches TPM_E_PCP_INTERNAL_ERROR (0x80290407), which indicates a TPM failure that can block TPM-backed device registration.", + vec![render_phase_code_evidence(facts, "0x80290407")], + vec![ + "Check TPM health and whether Windows can use the TPM normally on this device.".to_string(), + "Compare with a retry that uses a non-TPM path if the platform and Windows version support it.".to_string(), + ], + vec!["Resolve or bypass the unhealthy TPM path before retrying join.".to_string()], + )); + } + + if has_code(facts, "0x80280036") { + diagnostics.push(issue( + "tpm-not-fips", + IntuneDiagnosticSeverity::Error, + "configuration", + "The TPM is in an unsupported FIPS mode for this flow", + "The failure pattern matches TPM_E_NOTFIPS (0x80280036), which means the TPM state is not supported for this registration flow.", + vec![render_phase_code_evidence(facts, "0x80280036")], + vec![ + "Review the TPM configuration and whether the device is enforcing a TPM mode unsupported for this join path.".to_string(), + "Check whether the Windows version can fall back to a non-TPM registration path.".to_string(), + ], + vec!["Correct the TPM mode or use a supported fallback path before retrying.".to_string()], + )); + } + + if has_code(facts, "0x80090031") { + diagnostics.push(issue( + "tpm-locked-out", + IntuneDiagnosticSeverity::Warning, + "configuration", + "The TPM appears to be locked out temporarily", + "The failure pattern matches NTE_AUTHENTICATION_IGNORED (0x80090031), which is commonly a transient TPM lockout or anti-hammering condition.", + vec![render_phase_code_evidence(facts, "0x80090031")], + vec![ + "Wait for the TPM lockout cool-down period to expire before retrying.".to_string(), + "Check whether repeated recent authentication failures triggered TPM anti-hammering.".to_string(), + ], + Vec::new(), + )); + } + + if aggregated_errors.contains("aadsts90002") + || aggregated_errors.contains("tenant uuid not found") + { + diagnostics.push(issue( + "tenant-uuid-not-found", + IntuneDiagnosticSeverity::Error, + "dynamic", + "Tenant identifier could not be resolved", + "The aggregated dsregcmd error fields contain an AADSTS90002-style tenant lookup failure.", + vec![ + render_optional("TenantId", &facts.tenant_details.tenant_id), + render_optional("Server Message", &facts.registration.server_message), + ], + vec![ + "Verify the tenant ID and tenant discovery URLs in the capture.".to_string(), + "Confirm the user and device are targeting the correct cloud tenant.".to_string(), + ], + vec!["Correct the tenant targeting information and retry registration.".to_string()], + )); + } + + if aggregated_errors.contains("1312") || aggregated_errors.contains("1317") { + diagnostics.push(issue( + "ad-replication-issue", + IntuneDiagnosticSeverity::Error, + "dynamic", + "Directory replication or lookup issue detected", + "The aggregated registration errors contain 1312 or 1317, which commonly show up during AD replication or object lookup problems.", + vec![ + render_optional("Client ErrorCode", &facts.registration.client_error_code), + render_optional("Server ErrorCode", &facts.registration.server_error_code), + ], + vec![ + "Check the health of the on-premises AD object and replication status.".to_string(), + "Verify the computer account exists and is consistent across domain controllers.".to_string(), + ], + vec!["Resolve the directory replication issue, then retry hybrid join.".to_string()], + )); + } + + if let Some(device_auth_status) = facts.device_details.device_auth_status.as_deref() { + if !device_auth_status.eq_ignore_ascii_case("SUCCESS") { + diagnostics.push(issue( + "device-auth-failed", + IntuneDiagnosticSeverity::Error, + "authentication", + "Device authentication status is not SUCCESS", + "DeviceAuthStatus reports a failing or incomplete state, so the device is not currently authenticating cleanly with Entra ID.", + vec![render_optional( + "DeviceAuthStatus", + &facts.device_details.device_auth_status, + )], + vec![ + "Compare device authentication status with certificate, TPM, and join state details.".to_string(), + "Look for upstream registration or certificate errors in the capture.".to_string(), + ], + vec!["Repair device registration and certificate trust before retrying authentication.".to_string()], + )); + } + } + + if derived.missing_mdm == Some(true) { + diagnostics.push(issue( + "no-mdm", + IntuneDiagnosticSeverity::Info, + "configuration", + "MDM enrollment URL is not present", + "MdmComplianceUrl is present but MdmUrl is not. That usually means the capture is incomplete or the tenant advertises only part of the management metadata, not that the device is definitively broken.", + vec![ + render_optional("MdmUrl", &facts.management_details.mdm_url), + render_optional( + "MdmComplianceUrl", + &facts.management_details.mdm_compliance_url, + ), + ], + vec![ + "Confirm whether this tenant and user are actually in scope for automatic MDM enrollment.".to_string(), + "Compare with a healthy capture from the same org before treating missing MDM metadata as a failure.".to_string(), + ], + Vec::new(), + )); + } + + if derived.missing_compliance_url == Some(true) { + diagnostics.push(issue( + "no-compliance", + IntuneDiagnosticSeverity::Info, + "configuration", + "Compliance URL is not present", + "MdmUrl is present but MdmComplianceUrl is not. dsregcmd management fields are tenant- and scope-dependent, so this is context rather than proof of an enrollment problem.", + vec![render_optional( + "MdmComplianceUrl", + &facts.management_details.mdm_compliance_url, + )], + vec![ + "Confirm whether compliance reporting is expected for this org and user scope.".to_string(), + "Compare with another healthy capture from the same tenant before escalating.".to_string(), + ], + Vec::new(), + )); + } + + if facts.user_state.wam_default_set == Some(false) { + diagnostics.push(issue( + "wam-not-default", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Web Account Manager default account is not set", + "WamDefaultSet is NO, which often lines up with user sign-in or token acquisition issues.", + vec![render_bool("WamDefaultSet", facts.user_state.wam_default_set)], + vec![ + "Check the signed-in account and WAM authority values.".to_string(), + "Review whether the user is fully signed in to Windows with a work account.".to_string(), + ], + vec!["Refresh the account session or sign in again to restore WAM defaults.".to_string()], + )); + } + + if contains_text(&facts.registration.server_message, "aadsts50126") { + diagnostics.push(issue( + "aadsts50126", + IntuneDiagnosticSeverity::Warning, + "credentials", + "Server message reports AADSTS50126", + "The high-level server message indicates invalid credentials or an authentication mismatch.", + vec![render_optional("Server Message", &facts.registration.server_message)], + vec![ + "Compare the user identity, credential type, and endpoint URI in the diagnostics block.".to_string(), + ], + vec!["Retry sign-in with the correct account and credentials.".to_string()], + )); + } + + let has_specific_network_issue = diagnostics.iter().any(|item| { + matches!( + item.id.as_str(), + "drs-discovery-timeout" + | "invalid-discovery-response" + | "prt-network-path-error" + | "adal-timeout" + | "adal-connection-aborted" + | "adal-secure-failure" + | "adal-cannot-connect" + ) + }); + + if let Some(network_error_code) = derived.network_error_code.as_deref() { + if !has_specific_network_issue { + diagnostics.push(issue( + "network-issue", + IntuneDiagnosticSeverity::Warning, + "network", + "Network connectivity marker detected", + &format!( + "The capture contains {}, which points to a network, DNS, proxy, or transport-layer problem during registration or token acquisition.", + network_error_code + ), + vec![ + format!("NetworkErrorCode: {}", network_error_code), + render_optional("HTTP Error", &facts.diagnostics.http_error), + render_optional("Endpoint URI", &facts.diagnostics.endpoint_uri), + ], + vec![ + "Test name resolution and HTTPS connectivity to the endpoint URI.".to_string(), + "Check WinHTTP proxy configuration and outbound firewall policy.".to_string(), + ], + vec!["Resolve the network path issue and re-run dsregcmd /status.".to_string()], + )); + } + } + + if derived.stale_prt == Some(true) { + let age_text = derived + .prt_age_hours + .map(|hours| format!("{hours:.1} hours")) + .unwrap_or_else(|| "more than 4 hours".to_string()); + diagnostics.push(issue( + "stale-prt", + IntuneDiagnosticSeverity::Warning, + "dynamic", + "Azure AD PRT appears stale", + &format!( + "AzureAdPrtUpdateTime is older than the 4-hour threshold ({age_text})." + ), + vec![ + render_optional( + "AzureAdPrtUpdateTime", + &facts.sso_state.azure_ad_prt_update_time, + ), + render_optional("Client Time", &facts.diagnostics.client_time), + ], + vec![ + "Check whether token renewal is being blocked by sign-in, network, or device auth issues.".to_string(), + "Review the last PRT acquisition attempt and any AADSTS codes.".to_string(), + ], + vec!["Refresh the user sign-in session after fixing the root cause.".to_string()], + )); + } + + if facts.device_details.tpm_protected == Some(false) { + let has_specific_tpm_issue = diagnostics.iter().any(|item| { + matches!( + item.id.as_str(), + "tpm-bad-keyset" | "tpm-internal-error" | "tpm-not-fips" | "tpm-locked-out" + ) + }); + if !has_specific_tpm_issue { + diagnostics.push(issue( + "no-tpm-protection", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Device keys are not TPM protected", + "TpmProtected is NO, so the device registration keys are not currently backed by TPM protection.", + vec![render_bool("TpmProtected", facts.device_details.tpm_protected)], + vec![ + "Confirm whether the device has a healthy TPM and that it is available to Windows.".to_string(), + "Compare the key provider and key container details with a healthy device.".to_string(), + ], + vec!["Resolve TPM availability issues or re-register the device using hardware-backed keys.".to_string()], + )); + } + } + + if let Some(logon_cert_template_ready) = facts.registration.logon_cert_template_ready.as_deref() + { + if !logon_cert_template_ready.contains("StateReady") + && equals_text(&facts.registration.cert_enrollment, "enrollment authority") + { + diagnostics.push(issue( + "logon-cert-not-ready", + IntuneDiagnosticSeverity::Info, + "configuration", + "Logon certificate template is not ready", + "LogonCertTemplateReady is present but does not report StateReady. This is supporting context for Windows Hello certificate-trust readiness, not a standalone device health failure.", + vec![render_optional( + "LogonCertTemplateReady", + &facts.registration.logon_cert_template_ready, + )], + vec![ + "Review certificate enrollment prerequisites and issuance policy.".to_string(), + "Check whether the device can reach the issuing CA or enrollment service when certificate-trust WHfB is expected." + .to_string(), + ], + Vec::new(), + )); + } + } + + if derived.certificate_expiring_soon == Some(true) { + let certificate_summary = match derived.certificate_days_remaining { + Some(days_remaining) if days_remaining < 0 => { + format!( + "The device certificate already expired {} days ago.", + days_remaining.abs() + ) + } + Some(days_remaining) => { + format!("The device certificate expires in {} days.", days_remaining) + } + None => "The device certificate validity window is near expiry.".to_string(), + }; + diagnostics.push(issue( + "cert-expiring-soon", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Device certificate validity is near expiry", + &certificate_summary, + vec![render_optional( + "DeviceCertificateValidity", + &facts.device_details.device_certificate_validity, + )], + vec![ + "Check whether automatic device certificate renewal is functioning.".to_string(), + "Review device auth state and certificate enrollment prerequisites.".to_string(), + ], + vec![ + "Renew or repair the device certificate before authentication starts failing." + .to_string(), + ], + )); + } + + if derived.remote_session_system == Some(true) { + diagnostics.push(issue( + "remote-session-system", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Capture was taken as SYSTEM in a remote session", + "The diagnostics block shows User Context as SYSTEM while SessionIsNotRemote is NO, which can produce misleading token and user-state output.", + vec![ + render_optional("User Context", &facts.diagnostics.user_context), + render_bool("SessionIsNotRemote", facts.user_state.session_is_not_remote), + ], + vec![ + "Compare with a capture taken interactively as the affected user.".to_string(), + "Be cautious when interpreting PRT and WAM fields from SYSTEM remote sessions.".to_string(), + ], + vec!["Re-run dsregcmd /status in the intended interactive user session when possible.".to_string()], + )); + } + + if facts.join_state.workplace_joined == Some(true) { + diagnostics.push(issue( + "workplace-joined-present", + IntuneDiagnosticSeverity::Info, + "configuration", + "Workplace join is present", + "WorkplaceJoined is YES.", + vec![render_bool("WorkplaceJoined", facts.join_state.workplace_joined)], + vec![ + "Workplace join is scenario-specific and is not required for most standard Entra joined device flows.".to_string(), + "Confirm that this registration is expected before treating it as important during triage.".to_string(), + ], + Vec::new(), + )); + } + + if facts.join_state.domain_joined == Some(true) { + diagnostics.push(issue( + "onprem-domain-joined", + IntuneDiagnosticSeverity::Info, + "configuration", + "Device is joined to on-premises Active Directory", + "DomainJoined is YES.", + vec![render_bool("DomainJoined", facts.join_state.domain_joined)], + vec!["Use this together with AzureAdJoined to understand whether the device is hybrid joined.".to_string()], + Vec::new(), + )); + } + + match derived.join_type { + DsregcmdJoinType::EntraIdJoined => diagnostics.push(issue( + "join-type-entraid", + IntuneDiagnosticSeverity::Info, + "configuration", + "Join type is Entra ID Joined", + "AzureAdJoined is YES and DomainJoined is NO.", + vec![format!("JoinType: {}", derived.join_type_label)], + vec!["This is the expected join state for cloud-only Entra ID joined devices.".to_string()], + Vec::new(), + )), + DsregcmdJoinType::HybridEntraIdJoined => diagnostics.push(issue( + "join-type-hybrid", + IntuneDiagnosticSeverity::Info, + "configuration", + "Join type is Hybrid Entra ID Joined", + "AzureAdJoined is YES and DomainJoined is YES.", + vec![format!("JoinType: {}", derived.join_type_label)], + vec!["Hybrid join scenarios depend on both AD connectivity and Entra registration health.".to_string()], + Vec::new(), + )), + _ => {} + } + + if facts.user_state.ngc_set == Some(false) + && equals_text(&facts.registration.pre_req_result, "WillProvision") + { + diagnostics.push(issue( + "ngc-will-provision", + IntuneDiagnosticSeverity::Info, + "configuration", + "Windows Hello for Business is expected to provision", + "NgcSet is NO but PreReqResult is WillProvision, which means prerequisites are satisfied and provisioning is expected later.", + vec![ + render_bool("NgcSet", facts.user_state.ngc_set), + render_optional("PreReqResult", &facts.registration.pre_req_result), + ], + vec!["Monitor the next sign-in or policy refresh to confirm WHfB provisioning completes.".to_string()], + Vec::new(), + )); + } + + if is_failure_text(&facts.post_join_diagnostics.key_sign_test) { + diagnostics.push(issue( + "ngc-key-sign-failed", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Windows Hello key health check failed", + "KeySignTest did not pass, which means the device could not validate the current Windows Hello key material during post-join diagnostics.", + vec![render_optional( + "KeySignTest", + &facts.post_join_diagnostics.key_sign_test, + )], + vec![ + "Re-run dsregcmd /status from an elevated prompt because KeySignTest requires elevation to be reliable.".to_string(), + "Check whether the device is entering a recovery or re-registration flow for Windows Hello for Business.".to_string(), + ], + vec![ + "Repair or recover the Windows Hello key state before expecting WHfB sign-in to behave normally.".to_string(), + ], + )); + } + + if facts.post_join_diagnostics.aad_recovery_enabled == Some(true) { + diagnostics.push(issue( + "ngc-recovery-enabled", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Windows Hello key recovery is enabled", + "AadRecoveryEnabled is YES, which means the current device key state is marked for recovery and the next sign-in may trigger device recovery or re-registration behavior.", + vec![render_bool( + "AadRecoveryEnabled", + facts.post_join_diagnostics.aad_recovery_enabled, + )], + vec![ + "Confirm whether recent WHfB sign-in, PIN reset, or recovery prompts were observed on the device.".to_string(), + "Review adjacent device registration or Hello for Business logs for the recovery trigger.".to_string(), + ], + vec![ + "Allow the device recovery flow to complete and then capture dsregcmd again to confirm the key state stabilizes.".to_string(), + ], + )); + } + + if derived.join_type == DsregcmdJoinType::HybridEntraIdJoined + && contains_text(&facts.pre_join_tests.fallback_to_sync_join, "enabled") + { + diagnostics.push(issue( + "hybrid-fallback-enabled", + IntuneDiagnosticSeverity::Info, + "configuration", + "Hybrid join fallback to sync-join is enabled", + "Fallback to Sync-Join reports ENABLED while the device is hybrid joined.", + vec![render_optional( + "Fallback to Sync-Join", + &facts.pre_join_tests.fallback_to_sync_join, + )], + vec![ + "This is informational context for hybrid join timing and registration behavior." + .to_string(), + ], + Vec::new(), + )); + } + + diagnostics +} + +fn derive_join_type(facts: &DsregcmdFacts) -> DsregcmdJoinType { + match ( + facts.join_state.azure_ad_joined, + facts.join_state.domain_joined, + ) { + (Some(true), Some(true)) => DsregcmdJoinType::HybridEntraIdJoined, + (Some(true), Some(false)) => DsregcmdJoinType::EntraIdJoined, + (Some(false), _) => DsregcmdJoinType::NotJoined, + _ => DsregcmdJoinType::Unknown, + } +} + +fn join_type_label(join_type: DsregcmdJoinType) -> &'static str { + match join_type { + DsregcmdJoinType::HybridEntraIdJoined => "Hybrid Entra ID Joined", + DsregcmdJoinType::EntraIdJoined => "Entra ID Joined", + DsregcmdJoinType::NotJoined => "Not Joined", + DsregcmdJoinType::Unknown => "Unknown", + } +} + +fn derive_dominant_phase(facts: &DsregcmdFacts) -> DsregcmdDiagnosticPhase { + if let Some(phase) = facts + .registration + .error_phase + .as_deref() + .and_then(parse_phase) + { + return phase; + } + + if facts.diagnostics.attempt_status.is_some() + || facts.diagnostics.previous_prt_attempt.is_some() + || facts.sso_state.acquire_prt_diagnostics.is_some() + { + return DsregcmdDiagnosticPhase::PostJoin; + } + + if is_failure(&facts.pre_join_tests.ad_connectivity_test) { + return DsregcmdDiagnosticPhase::Precheck; + } + + if is_failure(&facts.pre_join_tests.ad_configuration_test) + || is_failure(&facts.pre_join_tests.drs_discovery_test) + || is_failure(&facts.pre_join_tests.drs_connectivity_test) + { + return DsregcmdDiagnosticPhase::Discover; + } + + if is_failure(&facts.pre_join_tests.token_acquisition_test) + || has_any_code( + facts, + &[ + "0xcaa90017", + "0xcaa9002c", + "0xcaa90023", + "0xcaa82ee2", + "0xcaa82efe", + "0xcaa82f8f", + "0xcaa82efd", + "0xcaa20003", + "0xcaa90014", + "0xcaa90006", + "0xcaa1002d", + ], + ) + { + return DsregcmdDiagnosticPhase::Auth; + } + + if facts.registration.client_error_code.is_some() + || facts.registration.server_error_code.is_some() + || facts.registration.server_message.is_some() + { + return DsregcmdDiagnosticPhase::Join; + } + + if facts.sso_state.azure_ad_prt == Some(false) || facts.sso_state.azure_ad_prt_update_time.is_some() { + return DsregcmdDiagnosticPhase::PostJoin; + } + + DsregcmdDiagnosticPhase::Unknown +} + +fn phase_summary(phase: DsregcmdDiagnosticPhase) -> &'static str { + match phase { + DsregcmdDiagnosticPhase::Precheck => { + "Current evidence points to a precheck failure before discovery could complete." + } + DsregcmdDiagnosticPhase::Discover => { + "Current evidence points to a discover-phase failure while locating or reaching registration services." + } + DsregcmdDiagnosticPhase::Auth => { + "Current evidence points to an authentication-phase failure during federation or token acquisition." + } + DsregcmdDiagnosticPhase::Join => { + "Current evidence points to a join-phase failure while registering the device with Entra." + } + DsregcmdDiagnosticPhase::PostJoin => { + "Current evidence points to a post-join token, session, or refresh problem." + } + DsregcmdDiagnosticPhase::Unknown => { + "Current evidence does not isolate a single failure phase from this capture." + } + } +} + +fn derive_capture_confidence( + facts: &DsregcmdFacts, + reference_time: Option>, + remote_session_system: Option, +) -> (DsregcmdCaptureConfidence, String) { + if remote_session_system == Some(true) { + return ( + DsregcmdCaptureConfidence::Low, + "Capture was taken as SYSTEM in a remote session, so user-scoped token and session evidence may be distorted.".to_string(), + ); + } + + if let Some(client_time) = facts + .diagnostics + .client_time + .as_deref() + .and_then(parse_dsregcmd_timestamp) + { + let age_minutes = Utc::now().signed_duration_since(client_time).num_minutes().abs(); + if age_minutes <= 15 + && facts.user_state.session_is_not_remote == Some(true) + && !matches!(facts.diagnostics.user_context.as_deref(), Some(context) if context.eq_ignore_ascii_case("SYSTEM")) + { + return ( + DsregcmdCaptureConfidence::High, + "Capture looks recent and interactive, so user-scoped evidence should be trustworthy.".to_string(), + ); + } + + if age_minutes <= 24 * 60 { + return ( + DsregcmdCaptureConfidence::Medium, + "Capture looks reasonably recent, but it may not exactly match the current device state.".to_string(), + ); + } + + return ( + DsregcmdCaptureConfidence::Low, + "Capture looks old relative to the device clock, so conclusions may no longer match the current state.".to_string(), + ); + } + + if reference_time.is_some() { + return ( + DsregcmdCaptureConfidence::Medium, + "Capture included enough timing context to analyze, but it did not provide a clearly recent interactive client timestamp.".to_string(), + ); + } + + ( + DsregcmdCaptureConfidence::Medium, + "Capture confidence is moderate because the source lacked enough timing and session context to judge freshness precisely.".to_string(), + ) +} + +fn parse_phase(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "pre-check" | "precheck" => Some(DsregcmdDiagnosticPhase::Precheck), + "discover" => Some(DsregcmdDiagnosticPhase::Discover), + "auth" | "authentication" => Some(DsregcmdDiagnosticPhase::Auth), + "join" => Some(DsregcmdDiagnosticPhase::Join), + "post_join" | "post-join" | "postjoin" => Some(DsregcmdDiagnosticPhase::PostJoin), + _ => None, + } +} + +fn parse_dsregcmd_timestamp(value: &str) -> Option> { + let trimmed = value.trim(); + if let Ok(parsed) = DateTime::parse_from_rfc3339(trimmed) { + return Some(parsed.with_timezone(&Utc)); + } + + for format in [ + "%Y-%m-%d %H:%M:%S%.f UTC", + "%Y-%m-%d %H:%M:%S UTC", + "%m/%d/%Y %H:%M:%S%.f UTC", + "%m/%d/%Y %H:%M:%S UTC", + ] { + if let Ok(parsed) = NaiveDateTime::parse_from_str(trimmed, format) { + return Some(DateTime::::from_naive_utc_and_offset(parsed, Utc)); + } + } + + for format in [ + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%d %H:%M:%S", + "%m/%d/%Y %H:%M:%S%.f", + "%m/%d/%Y %H:%M:%S", + ] { + if let Ok(parsed) = NaiveDateTime::parse_from_str(trimmed, format) { + return match Local.from_local_datetime(&parsed) { + LocalResult::Single(local_time) => Some(local_time.with_timezone(&Utc)), + LocalResult::Ambiguous(local_time, _) => Some(local_time.with_timezone(&Utc)), + LocalResult::None => None, + }; + } + } + + None +} + +fn parse_certificate_validity(value: &str) -> (Option>, Option>) { + let timestamps: Vec> = CERTIFICATE_TIMESTAMP_RE + .find_iter(value) + .filter_map(|capture| parse_dsregcmd_timestamp(capture.as_str())) + .collect(); + + match timestamps.as_slice() { + [valid_from, valid_to, ..] => (Some(*valid_from), Some(*valid_to)), + [valid_to] => (None, Some(*valid_to)), + _ => (None, None), + } +} + +fn detect_network_error(raw_input: &str) -> Option { + let uppercase = raw_input.to_ascii_uppercase(); + NETWORK_ERROR_MARKERS + .iter() + .find(|marker| uppercase.contains(**marker)) + .map(|marker| (*marker).to_string()) +} + +fn aggregated_error_text(facts: &DsregcmdFacts) -> String { + [ + facts.registration.client_error_code.as_deref(), + facts.registration.server_error_code.as_deref(), + facts.registration.server_message.as_deref(), + facts.registration.server_error_description.as_deref(), + facts.diagnostics.attempt_status.as_deref(), + facts.diagnostics.http_error.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +fn has_code(facts: &DsregcmdFacts, code: &str) -> bool { + contains_text(&facts.registration.client_error_code, code) + || contains_text(&facts.registration.server_error_code, code) + || contains_text(&facts.registration.server_message, code) + || contains_text(&facts.registration.server_error_description, code) + || contains_text(&facts.diagnostics.attempt_status, code) + || contains_text(&facts.diagnostics.http_error, code) + || contains_text(&facts.pre_join_tests.token_acquisition_test, code) + || contains_text(&facts.pre_join_tests.drs_discovery_test, code) + || contains_text(&facts.pre_join_tests.ad_configuration_test, code) +} + +fn has_any_code(facts: &DsregcmdFacts, codes: &[&str]) -> bool { + codes.iter().any(|code| has_code(facts, code)) +} + +fn is_failure(field: &Option) -> bool { + field + .as_deref() + .map(|value| value.to_ascii_uppercase().contains("FAIL")) + .unwrap_or(false) +} + +fn is_failure_text(field: &Option) -> bool { + field + .as_deref() + .map(|value| { + let normalized = value.to_ascii_uppercase(); + normalized.contains("FAIL") || normalized.contains("ERROR") + }) + .unwrap_or(false) +} + +fn render_phase_code_evidence(facts: &DsregcmdFacts, code: &str) -> String { + let sources = [ + ("Client ErrorCode", facts.registration.client_error_code.as_deref()), + ("Attempt Status", facts.diagnostics.attempt_status.as_deref()), + ("HTTP Error", facts.diagnostics.http_error.as_deref()), + ( + "Token Acquisition Test", + facts.pre_join_tests.token_acquisition_test.as_deref(), + ), + ]; + + for (label, value) in sources { + if let Some(value) = value { + if value.to_ascii_lowercase().contains(&code.to_ascii_lowercase()) { + return format!("{label}: {value}"); + } + } + } + + format!("Code: {code}") +} + +fn push_test_failure( + diagnostics: &mut Vec, + id: &str, + category: &str, + title: &str, + field: &Option, + next_checks: Vec, + suggested_fixes: Vec, +) { + let Some(value) = field.as_deref() else { + return; + }; + + if !value.to_ascii_uppercase().contains("FAIL") { + return; + } + + let mut evidence = vec![format!("Result: {value}")]; + if let Some(detail) = extract_bracket_detail(value) { + evidence.push(format!("Detail: {detail}")); + } + + diagnostics.push(issue( + id, + IntuneDiagnosticSeverity::Error, + category, + title, + &format!("{title}."), + evidence, + next_checks, + suggested_fixes, + )); +} + +fn extract_bracket_detail(value: &str) -> Option { + let start = value.find('[')?; + let end = value[start + 1..].find(']')?; + let detail = &value[start + 1..start + 1 + end]; + (!detail.trim().is_empty()).then(|| detail.trim().to_string()) +} + +#[expect( + clippy::too_many_arguments, + reason = "diagnostic construction keeps explicit backend contract fields together" +)] +fn issue( + id: &str, + severity: IntuneDiagnosticSeverity, + category: &str, + title: &str, + summary: &str, + evidence: Vec, + next_checks: Vec, + suggested_fixes: Vec, +) -> DsregcmdDiagnosticInsight { + DsregcmdDiagnosticInsight { + id: id.to_string(), + severity, + category: category.to_string(), + title: title.to_string(), + summary: summary.to_string(), + evidence, + next_checks, + suggested_fixes, + } +} + +fn render_optional(label: &str, value: &Option) -> String { + match value { + Some(value) => format!("{label}: {value}"), + None => format!("{label}: (missing)"), + } +} + +fn render_bool(label: &str, value: Option) -> String { + match value { + Some(true) => format!("{label}: YES"), + Some(false) => format!("{label}: NO"), + None => format!("{label}: (unknown)"), + } +} + +fn contains_text(field: &Option, needle: &str) -> bool { + field + .as_deref() + .map(|value| { + value + .to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()) + }) + .unwrap_or(false) +} + +fn equals_text(field: &Option, expected: &str) -> bool { + field + .as_deref() + .map(|value| value.eq_ignore_ascii_case(expected)) + .unwrap_or(false) +} + +fn is_missing(field: &Option) -> bool { + field.is_none() +} + +#[cfg(test)] +mod tests { + use super::analyze_facts; + use crate::dsregcmd::models::{ + DsregcmdCaptureConfidence, DsregcmdDiagnosticPhase, DsregcmdJoinType, + }; + use crate::dsregcmd::parser::parse_dsregcmd; + use crate::intune::models::IntuneDiagnosticSeverity; + use chrono::Utc; + + const HYBRID_SAMPLE: &str = r#" + AzureAdJoined : YES + DomainJoined : YES + WorkplaceJoined : NO + EnterpriseJoined : YES + NgcSet : NO + TenantId : 11111111-2222-3333-4444-555555555555 + TenantName : Contoso + DeviceId : abcdefab-1111-2222-3333-abcdefabcdef + DeviceAuthStatus : FAILED. Device is either disabled or deleted + MdmUrl : https://enrollment.manage.microsoft.com/enrollmentserver/discovery.svc + MdmComplianceUrl : https://portal.manage.microsoft.com/Compliance + AzureAdPrt : YES + AzureAdPrtUpdateTime : 2025-03-10 05:00:00.000 UTC + TpmProtected : NO + DeviceCertificateValidity : [ 2025-03-01 00:00:00.000 UTC -- 2025-03-20 00:00:00.000 UTC ] + Previous Prt Attempt : 2025-03-10 08:30:00.000 UTC + Attempt Status : 0xc000006d + User Context : SYSTEM + SessionIsNotRemote : NO + Client Time : 2025-03-10 10:30:00.000 UTC + DRS Discovery Test : FAIL [0x801c0021] + AD Connectivity Test : FAIL [0x54b] + Fallback to Sync-Join : ENABLED + Server Message : AADSTS50126 Invalid username or password ERROR_WINHTTP_TIMEOUT + Server Error Description : AADSTS50126: Invalid username or password. + CertEnrollment : enrollment authority + LogonCertTemplateReady : Pending + PreReqResult : WillProvision + KeySignTest : FAILED + AadRecoveryEnabled : YES +"#; + + const NOT_JOINED_SAMPLE: &str = r#" + AzureAdJoined : NO + DomainJoined : NO + WorkplaceJoined : NO + TenantId : - + DeviceId : - + MdmUrl : - + MdmComplianceUrl : - + AzureAdPrt : NO +"#; + + const PHASE_AWARE_SAMPLE: &str = r#" + AzureAdJoined : NO + DomainJoined : YES + WorkplaceJoined : NO + TenantId : 11111111-2222-3333-4444-555555555555 + DeviceId : abcdefab-1111-2222-3333-abcdefabcdef + DRS Discovery Test : FAIL [0x801c0021/0x80072ee2] + AD Configuration Test : FAIL [0x801c001d] + Client ErrorCode : 0x801c001d + Error Phase : discover + Attempt Status : 0xc004844c + User Identity : user@contoso.local + Endpoint URI : https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/oauth2/token/ + HTTP Error : 0x8007000d + HTTP status : 400 + AzureAdPrt : NO +"#; + + const ADAL_AND_TPM_SAMPLE: &str = r#" + AzureAdJoined : NO + DomainJoined : YES + WorkplaceJoined : NO + TenantId : 11111111-2222-3333-4444-555555555555 + DeviceId : abcdefab-1111-2222-3333-abcdefabcdef + Token Acquisition Test : FAIL [0xcaa90017] + Client ErrorCode : 0xcaa9002c + Attempt Status : 0xcaa90023 + HTTP Error : 0xcaa82f8f + Server ErrorDescription : 0xcaa20003 + Server Message : 0xcaa90014 0xcaa90006 0xcaa1002d + Endpoint URI : https://fs.contoso.com/adfs/services/trust/mex + AzureAdPrt : NO + DeviceAuthStatus : FAILED + TpmProtected : NO + Server ErrorCode : 0x80090016 0x80290407 0x80280036 0x80090031 +"#; + + #[test] + fn derives_join_type_and_high_value_flags() { + let facts = parse_dsregcmd(HYBRID_SAMPLE).expect("parse hybrid sample"); + let analysis = analyze_facts(facts, HYBRID_SAMPLE); + + assert_eq!( + analysis.derived.join_type, + DsregcmdJoinType::HybridEntraIdJoined + ); + assert_eq!(analysis.derived.azure_ad_prt_present, Some(true)); + assert_eq!(analysis.derived.stale_prt, Some(true)); + assert_eq!(analysis.derived.tpm_protected, Some(false)); + assert_eq!(analysis.derived.certificate_expiring_soon, Some(true)); + assert_eq!( + analysis.derived.network_error_code.as_deref(), + Some("ERROR_WINHTTP_TIMEOUT") + ); + assert_eq!(analysis.derived.remote_session_system, Some(true)); + assert_eq!(analysis.derived.dominant_phase, DsregcmdDiagnosticPhase::PostJoin); + assert_eq!( + analysis.derived.capture_confidence, + DsregcmdCaptureConfidence::Low + ); + } + + #[test] + fn emits_expected_error_warning_and_info_rules() { + let facts = parse_dsregcmd(HYBRID_SAMPLE).expect("parse hybrid sample"); + let analysis = analyze_facts(facts, HYBRID_SAMPLE); + let ids: Vec<&str> = analysis + .diagnostics + .iter() + .map(|item| item.id.as_str()) + .collect(); + + for expected in [ + "device-auth-failed", + "drs-discovery-failed", + "ad-connectivity-failed", + "invalid-credentials", + "aadsts50126-detailed", + "aadsts50126", + "network-issue", + "stale-prt", + "no-tpm-protection", + "logon-cert-not-ready", + "cert-expiring-soon", + "remote-session-system", + "join-type-hybrid", + "hybrid-fallback-enabled", + "ngc-will-provision", + ] { + assert!(ids.contains(&expected), "missing diagnostic: {expected}"); + } + + let remote_rule = analysis + .diagnostics + .iter() + .find(|item| item.id == "remote-session-system") + .expect("remote session rule present"); + assert_eq!(remote_rule.severity, IntuneDiagnosticSeverity::Warning); + } + + #[test] + fn emits_core_not_joined_rules() { + let facts = parse_dsregcmd(NOT_JOINED_SAMPLE).expect("parse not joined sample"); + let analysis = analyze_facts(facts, NOT_JOINED_SAMPLE); + let ids: Vec<&str> = analysis + .diagnostics + .iter() + .map(|item| item.id.as_str()) + .collect(); + + assert_eq!(analysis.derived.join_type, DsregcmdJoinType::NotJoined); + for expected in [ + "not-aadj", + "missing-tenant", + "missing-deviceid", + "no-azure-prt", + ] { + assert!(ids.contains(&expected), "missing diagnostic: {expected}"); + } + } + + #[test] + fn missing_mdm_urls_do_not_create_warnings_by_default() { + let facts = parse_dsregcmd(NOT_JOINED_SAMPLE).expect("parse not joined sample"); + let analysis = analyze_facts(facts, NOT_JOINED_SAMPLE); + + assert_eq!(analysis.derived.mdm_enrolled, None); + assert_eq!(analysis.derived.missing_mdm, None); + assert_eq!(analysis.derived.missing_compliance_url, None); + assert!(!analysis + .diagnostics + .iter() + .any(|item| item.id == "no-mdm" || item.id == "no-compliance")); + } + + #[test] + fn ngc_prereq_fields_stay_lightweight_when_context_is_healthy() { + let sample = r#" + AzureAdJoined : YES + DomainJoined : YES + WorkplaceJoined : NO + TenantId : 11111111-2222-3333-4444-555555555555 + DeviceId : abcdefab-1111-2222-3333-abcdefabcdef + AzureAdPrt : YES + AzureAdPrtUpdateTime : 2025-03-10 09:00:00.000 UTC + Client Time : 2025-03-10 10:00:00.000 UTC + NgcSet : NO + IsDeviceJoined : YES + IsUserAzureAD : YES + PolicyEnabled : YES + PostLogonEnabled : YES + DeviceEligible : YES + SessionIsNotRemote : YES + CertEnrollment : none + PreReqResult : WillProvision + KeySignTest : PASSED + AadRecoveryEnabled : NO +"#; + + let facts = parse_dsregcmd(sample).expect("parse ngc sample"); + let analysis = analyze_facts(facts, sample); + + assert!(analysis.diagnostics.iter().any(|item| item.id == "ngc-will-provision")); + assert!(!analysis + .diagnostics + .iter() + .any(|item| { + item.id == "ngc-not-set" + || item.id == "logon-cert-not-ready" + || item.id == "ngc-key-sign-failed" + || item.id == "ngc-recovery-enabled" + })); + } + + #[test] + fn emits_ngc_post_join_health_diagnostics_when_present() { + let sample = r#" + AzureAdJoined : YES + DomainJoined : NO + WorkplaceJoined : NO + NgcSet : YES + KeySignTest : FAILED + AadRecoveryEnabled : YES + AzureAdPrt : YES +"#; + + let facts = parse_dsregcmd(sample).expect("parse ngc post join sample"); + let analysis = analyze_facts(facts, sample); + let ids: Vec<&str> = analysis + .diagnostics + .iter() + .map(|item| item.id.as_str()) + .collect(); + + assert!(ids.contains(&"ngc-key-sign-failed")); + assert!(ids.contains(&"ngc-recovery-enabled")); + } + + #[test] + fn emits_phase_aware_discovery_and_prt_code_diagnostics() { + let facts = parse_dsregcmd(PHASE_AWARE_SAMPLE).expect("parse phase aware sample"); + let analysis = analyze_facts(facts, PHASE_AWARE_SAMPLE); + let ids: Vec<&str> = analysis + .diagnostics + .iter() + .map(|item| item.id.as_str()) + .collect(); + + for expected in [ + "scp-read-failed", + "drs-discovery-code", + "invalid-discovery-response", + "malformed-upn", + "no-azure-prt", + ] { + assert!(ids.contains(&expected), "missing diagnostic: {expected}"); + } + + assert_eq!(analysis.derived.dominant_phase, DsregcmdDiagnosticPhase::Discover); + } + + #[test] + fn emits_remaining_adal_and_tpm_mappings() { + let facts = parse_dsregcmd(ADAL_AND_TPM_SAMPLE).expect("parse adal and tpm sample"); + let analysis = analyze_facts(facts, ADAL_AND_TPM_SAMPLE); + let ids: Vec<&str> = analysis + .diagnostics + .iter() + .map(|item| item.id.as_str()) + .collect(); + + for expected in [ + "adal-protocol-not-supported", + "adal-parse-xml-failed", + "adal-password-endpoint-missing", + "adal-secure-failure", + "adal-invalid-grant", + "adal-wstrust-request-failed", + "adal-token-request-failed", + "adal-operation-pending", + "tpm-bad-keyset", + "tpm-internal-error", + "tpm-not-fips", + "tpm-locked-out", + ] { + assert!(ids.contains(&expected), "missing diagnostic: {expected}"); + } + } + + #[test] + fn derives_high_capture_confidence_for_recent_interactive_capture() { + let now = Utc::now().format("%Y-%m-%d %H:%M:%S%.3f UTC").to_string(); + let sample = format!( + "\n AzureAdJoined : YES\n DomainJoined : YES\n AzureAdPrt : YES\n AzureAdPrtUpdateTime : {now}\n Client Time : {now}\n User Context : UN-ELEVATED User\n SessionIsNotRemote : YES\n" + ); + + let facts = parse_dsregcmd(&sample).expect("parse high confidence sample"); + let analysis = analyze_facts(facts, &sample); + + assert_eq!(analysis.derived.capture_confidence, DsregcmdCaptureConfidence::High); + } + + #[test] + fn derives_low_capture_confidence_for_remote_system_capture() { + let sample = r#" + AzureAdJoined : YES + DomainJoined : YES + AzureAdPrt : YES + AzureAdPrtUpdateTime : 2025-03-10 05:00:00.000 UTC + Client Time : 2025-03-10 10:30:00.000 UTC + User Context : SYSTEM + SessionIsNotRemote : NO +"#; + + let facts = parse_dsregcmd(sample).expect("parse low confidence sample"); + let analysis = analyze_facts(facts, sample); + + assert_eq!(analysis.derived.capture_confidence, DsregcmdCaptureConfidence::Low); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b56959684..38a2f39d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +pub mod dsregcmd; mod error_db; pub mod intune; mod menu; @@ -30,6 +31,8 @@ pub fn run() { commands::file_ops::open_log_file, commands::file_ops::list_log_folder, commands::file_ops::get_known_log_sources, + commands::file_ops::inspect_path_kind, + commands::file_ops::write_text_output_file, commands::parsing::start_tail, commands::parsing::stop_tail, commands::parsing::pause_tail, @@ -37,6 +40,9 @@ pub fn run() { commands::filter::apply_filter, commands::error_lookup::lookup_error_code, commands::intune::analyze_intune_logs, + commands::dsregcmd::analyze_dsregcmd, + commands::dsregcmd::capture_dsregcmd, + commands::dsregcmd::load_dsregcmd_source, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/dsregcmd/DsregcmdWorkspace.tsx b/src/components/dsregcmd/DsregcmdWorkspace.tsx new file mode 100644 index 000000000..e659e1062 --- /dev/null +++ b/src/components/dsregcmd/DsregcmdWorkspace.tsx @@ -0,0 +1,1824 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useAppActions } from "../layout/Toolbar"; +import { writeTextOutputFile } from "../../lib/commands"; +import type { + DsregcmdAnalysisResult, + DsregcmdDiagnosticInsight, + DsregcmdEvidenceSource, + DsregcmdFacts, + DsregcmdPolicyEvidenceValue, + DsregcmdSeverity, + DsregcmdSourceContext, +} from "../../types/dsregcmd"; + +interface FactRow { + label: string; + value: string; + tone?: "neutral" | "good" | "warn" | "bad"; + isNotReported?: boolean; +} + +interface FactGroup { + id: string; + title: string; + caption: string; + rows: FactRow[]; +} + +interface DisplayPhaseAssessment { + phase: DsregcmdAnalysisResult["derived"]["dominantPhase"]; + label: string; + tone: FactRow["tone"]; + summary: string; +} + +interface DisplayConfidenceAssessment { + confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"]; + reason: string; +} + +const localDateTimeFormatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", +}); + +const NOT_REPORTED_LABEL = "Not Reported"; + +function formatBool(value: boolean | null): string { + if (value === true) { + return "Yes"; + } + + if (value === false) { + return "No"; + } + + return "Unknown"; +} + +function formatValue(value: string | number | boolean | null | undefined): string { + if (value === null || value === undefined || value === "") { + return NOT_REPORTED_LABEL; + } + + if (typeof value === "boolean") { + return formatBool(value); + } + + return String(value); +} + +function formatEvidenceSource(source: DsregcmdEvidenceSource | null | undefined): string { + switch (source) { + case "dsregcmd": + return "dsregcmd"; + case "policy_manager_current": + return "PolicyManager current"; + case "policy_manager_provider": + return "PolicyManager provider"; + case "policy_manager_comparison": + return "PolicyManager current + provider"; + case "windows_policy_machine": + return "Windows policy (machine)"; + case "windows_policy_user": + return "Windows policy (user)"; + default: + return ""; + } +} + +function getPathBaseName(path: string): string { + return path.split(/[\\/]/).pop() ?? path; +} + +function getPolicyDisplayValue( + dsregValue: boolean | null | undefined, + policyValue: DsregcmdPolicyEvidenceValue +): string { + if (dsregValue != null) { + return `${formatBool(dsregValue)} (dsregcmd)`; + } + + if (policyValue.displayValue != null) { + const sourceLabel = formatEvidenceSource(policyValue.source); + return sourceLabel + ? `${formatBool(policyValue.displayValue)} (${sourceLabel})` + : formatBool(policyValue.displayValue); + } + + return NOT_REPORTED_LABEL; +} + +function getPolicyValueTone( + dsregValue: boolean | null | undefined, + policyValue: DsregcmdPolicyEvidenceValue +): FactRow["tone"] { + if (dsregValue != null) { + return toneForBool(dsregValue); + } + + return toneForBool(policyValue.displayValue); +} + +function formatPolicyEvidenceValue(value: DsregcmdPolicyEvidenceValue): string { + if (value.displayValue == null) { + return NOT_REPORTED_LABEL; + } + + const currentLabel = + value.currentValue == null ? null : `effective ${formatBool(value.currentValue)}`; + const providerLabel = + value.providerValue == null ? null : `provider ${formatBool(value.providerValue)}`; + const sourceLabel = formatEvidenceSource(value.source); + const parts = [currentLabel, providerLabel].filter((part): part is string => Boolean(part)); + + if (parts.length > 0 && sourceLabel) { + return `${parts.join(" / ")} (${sourceLabel})`; + } + + if (parts.length > 0) { + return parts.join(" / "); + } + + return sourceLabel + ? `${formatBool(value.displayValue)} (${sourceLabel})` + : formatBool(value.displayValue); +} + +function getPolicyEvidenceSummary(result: DsregcmdAnalysisResult): string { + const notes = [ + result.policyEvidence.policyEnabled.note, + result.policyEvidence.postLogonEnabled.note, + ].filter((note): note is string => Boolean(note)); + + const uniqueNotes = Array.from(new Set(notes)); + if (uniqueNotes.length === 0) { + return NOT_REPORTED_LABEL; + } + + const firstNote = uniqueNotes[0]; + if (firstNote.includes("no mapped PassportForWork PolicyManager values were present")) { + return "Registry captured, but no mapped WHfB policy values were found."; + } + + return firstNote; +} + +function formatRegistryArtifacts(paths: string[]): string { + if (paths.length === 0) { + return NOT_REPORTED_LABEL; + } + + const names = Array.from(new Set(paths.map(getPathBaseName))); + if (names.length <= 2) { + return names.join(" | "); + } + + return `${names.slice(0, 2).join(" | ")} +${names.length - 2} more`; +} + +function getEffectivePolicyEnabled(result: DsregcmdAnalysisResult): boolean | null { + return result.facts.userState.policyEnabled ?? result.policyEvidence.policyEnabled.displayValue; +} + +function getEffectivePostLogonEnabled(result: DsregcmdAnalysisResult): boolean | null { + return result.facts.userState.postLogonEnabled ?? result.policyEvidence.postLogonEnabled.displayValue; +} + +function parseDsregcmdDateTime(value: string | null | undefined): Date | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const normalized = /^\d{4}-\d{2}-\d{2} /.test(trimmed) && trimmed.endsWith(" UTC") + ? `${trimmed.slice(0, -4).replace(" ", "T")}Z` + : trimmed; + + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatLocalDateTime(value: string | null | undefined): string | null { + const parsed = parseDsregcmdDateTime(value); + return parsed ? localDateTimeFormatter.format(parsed) : null; +} + +function parseCertificateValidityRange(value: string | null | undefined): { from: string; to: string } | null { + if (!value) { + return null; + } + + const match = value.trim().match(/^\[\s*(.*?)\s*--\s*(.*?)\s*\]$/); + if (!match) { + return null; + } + + return { from: match[1], to: match[2] }; +} + +function formatCertificateValidityRange( + rawValue: string | null | undefined, + validFrom: string | null | undefined, + validTo: string | null | undefined +): string { + const parsedRange = parseCertificateValidityRange(rawValue); + const from = formatLocalDateTime(validFrom) ?? formatLocalDateTime(parsedRange?.from); + const to = formatLocalDateTime(validTo) ?? formatLocalDateTime(parsedRange?.to); + + if (from && to) { + return `${from} to ${to}`; + } + + return formatValue(rawValue); +} + +function formatHourDuration(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return "(unknown)"; + } + + const totalMinutes = Math.max(0, Math.round(value * 60)); + if (totalMinutes < 60) { + return `${totalMinutes} min`; + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (minutes === 0) { + return `${hours} hr`; + } + + return `${hours} hr ${minutes} min`; +} + +function formatDateTimeValue(value: string | null | undefined): string { + return formatLocalDateTime(value) ?? formatValue(value); +} + +function toneForBool(value: boolean | null | undefined): FactRow["tone"] { + if (value === true) { + return "good"; + } + + if (value === false) { + return "bad"; + } + + return "neutral"; +} + +function toneForWorkplaceJoined(value: boolean | null | undefined): FactRow["tone"] { + if (value === true) { + return "warn"; + } + + return "neutral"; +} + +function toneForEnterpriseJoined(_value: boolean | null | undefined): FactRow["tone"] { + return "neutral"; +} + +function toneForDomainJoined(value: boolean | null | undefined): FactRow["tone"] { + if (value === true) { + return "good"; + } + + return "neutral"; +} + +function toneForEnterprisePrt(value: boolean | null | undefined): FactRow["tone"] { + if (value === true) { + return "good"; + } + + return "neutral"; +} + +function toneForJoinType(joinType: DsregcmdAnalysisResult["derived"]["joinType"]): FactRow["tone"] { + return joinType === "NotJoined" ? "bad" : "good"; +} + +function toneForPrtState( + prtPresent: boolean | null, + stalePrt: boolean | null | undefined +): FactRow["tone"] { + if (prtPresent === null) { + return "neutral"; + } + + if (!prtPresent) { + return "bad"; + } + + return stalePrt ? "warn" : "good"; +} + +function formatPhaseLabel(phase: DsregcmdAnalysisResult["derived"]["dominantPhase"]): string { + switch (phase) { + case "precheck": + return "Precheck"; + case "discover": + return "Discover"; + case "auth": + return "Authentication"; + case "join": + return "Join"; + case "post_join": + return "Post-Join"; + case "unknown": + return "Unknown"; + } +} + +function toneForPhase(phase: DsregcmdAnalysisResult["derived"]["dominantPhase"]): FactRow["tone"] { + if (phase === "unknown") { + return "neutral"; + } + + return phase === "post_join" ? "warn" : "bad"; +} + +function formatConfidenceLabel( + confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"] +): string { + switch (confidence) { + case "high": + return "High"; + case "medium": + return "Medium"; + case "low": + return "Low"; + } +} + +function toneForCaptureConfidence( + confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"] +): FactRow["tone"] { + switch (confidence) { + case "high": + return "good"; + case "medium": + return "warn"; + case "low": + return "bad"; + } +} + +function qualifyByCaptureConfidence( + confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"], + text: string +): string { + return confidence === "high" ? text : `Based on this capture, ${text.charAt(0).toLowerCase()}${text.slice(1)}`; +} + +function getDisplayPhaseAssessment( + result: DsregcmdAnalysisResult, + errorCount: number, + warningCount: number +): DisplayPhaseAssessment { + if (errorCount === 0 && warningCount === 0) { + return { + phase: "unknown", + label: "No Active Issue", + tone: "good", + summary: "Current evidence does not show an active failure phase in this capture.", + }; + } + + return { + phase: result.derived.dominantPhase, + label: formatPhaseLabel(result.derived.dominantPhase), + tone: toneForPhase(result.derived.dominantPhase), + summary: result.derived.phaseSummary, + }; +} + +function getDisplayConfidenceAssessment( + result: DsregcmdAnalysisResult, + sourceContext: DsregcmdSourceContext +): DisplayConfidenceAssessment { + if (sourceContext.source?.kind === "capture" && result.derived.remoteSessionSystem !== true) { + return { + confidence: "high", + reason: + "Live capture was taken from this session, so freshness is based on the capture action rather than dsregcmd diagnostic timestamps.", + }; + } + + return { + confidence: result.derived.captureConfidence, + reason: result.derived.captureConfidenceReason, + }; +} + +function toneForMdmVisibility( + derived: DsregcmdAnalysisResult["derived"] +): FactRow["tone"] { + if (derived.mdmEnrolled === true) { + return derived.missingMdm || derived.missingComplianceUrl ? "neutral" : "good"; + } + + return "neutral"; +} + +function getMdmVisibilityLabel(derived: DsregcmdAnalysisResult["derived"]): string { + if (derived.mdmEnrolled === true) { + return derived.missingMdm || derived.missingComplianceUrl ? "Partial" : "Present"; + } + + return "Unknown"; +} + +function getNgcReadinessValue(result: DsregcmdAnalysisResult): string { + const { facts } = result; + const policyEnabled = getEffectivePolicyEnabled(result); + + if (facts.postJoinDiagnostics.aadRecoveryEnabled === true) { + return "Recovery Required"; + } + + if ((facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail")) { + return "Key Health Issue"; + } + + if (facts.userState.ngcSet === true) { + return "Configured"; + } + + if ((facts.registration.preReqResult ?? "").toLowerCase() === "willprovision") { + return "Will Provision"; + } + + if (policyEnabled === false) { + return "Policy Off"; + } + + if (facts.userState.deviceEligible === false) { + return "Not Eligible"; + } + + return "Context Only"; +} + +function toneForNgcReadiness(result: DsregcmdAnalysisResult): FactRow["tone"] { + const { facts } = result; + + if (facts.postJoinDiagnostics.aadRecoveryEnabled === true) { + return "warn"; + } + + if ((facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail")) { + return "warn"; + } + + if (facts.userState.ngcSet === true) { + return "good"; + } + + if ((facts.registration.preReqResult ?? "").toLowerCase() === "willprovision") { + return "good"; + } + + return "neutral"; +} + +function getNgcCaption(result: DsregcmdAnalysisResult): string { + const { facts } = result; + const policyEnabled = getEffectivePolicyEnabled(result); + const postLogonEnabled = getEffectivePostLogonEnabled(result); + + if (facts.postJoinDiagnostics.aadRecoveryEnabled === true) { + return "Post-join diagnostics indicate the current Windows Hello key state is marked for recovery."; + } + + if ((facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail")) { + return "Post-join diagnostics indicate the Windows Hello key health check did not pass."; + } + + if (policyEnabled === false) { + return "Windows Hello for Business is disabled by policy evidence for this bundle."; + } + + if (postLogonEnabled === false && facts.userState.ngcSet !== true) { + return "Post-logon Windows Hello provisioning is disabled by policy evidence for this bundle."; + } + + if (facts.userState.ngcSet === true) { + return "Windows Hello for Business is already configured for the current user."; + } + + if ((facts.registration.preReqResult ?? "").toLowerCase() === "willprovision") { + return "Prerequisites look satisfied enough for Windows Hello provisioning to happen later."; + } + + return "Windows Hello fields are shown as readiness context and should not be treated as a failure by default."; +} + +function getSeverityColor(severity: DsregcmdSeverity) { + switch (severity) { + case "Error": + return { border: "#fecaca", background: "#fef2f2", text: "#991b1b" }; + case "Warning": + return { border: "#fde68a", background: "#fffbeb", text: "#92400e" }; + case "Info": + return { border: "#bfdbfe", background: "#eff6ff", text: "#1e40af" }; + } +} + +function withNotReportedMetadata(rows: FactRow[]): FactRow[] { + return rows.map((row) => ({ + ...row, + isNotReported: row.isNotReported ?? row.value === NOT_REPORTED_LABEL, + })); +} + +function getFactGroups( + result: DsregcmdAnalysisResult, + displayedPrtAgeHours: number | null, + displayPhase: DisplayPhaseAssessment, + displayConfidence: DisplayConfidenceAssessment, + sourceContext: DsregcmdSourceContext +): FactGroup[] { + const { facts, derived } = result; + const policyEnabledDisplay = getPolicyDisplayValue( + facts.userState.policyEnabled, + result.policyEvidence.policyEnabled + ); + const postLogonEnabledDisplay = getPolicyDisplayValue( + facts.userState.postLogonEnabled, + result.policyEvidence.postLogonEnabled + ); + const ngcRows = withNotReportedMetadata([ + { + label: "NGC Set", + value: formatBool(facts.userState.ngcSet), + tone: facts.userState.ngcSet ? "good" : "neutral", + }, + { + label: "Device Joined for NGC", + value: formatBool(facts.userState.isDeviceJoined), + tone: facts.userState.isDeviceJoined ? "good" : "neutral", + }, + { + label: "User Azure AD", + value: formatBool(facts.userState.isUserAzureAd), + tone: facts.userState.isUserAzureAd ? "good" : "neutral", + }, + { + label: "Policy Enabled", + value: policyEnabledDisplay, + tone: getPolicyValueTone(facts.userState.policyEnabled, result.policyEvidence.policyEnabled), + }, + { + label: "Post-Logon Enabled", + value: postLogonEnabledDisplay, + tone: getPolicyValueTone( + facts.userState.postLogonEnabled, + result.policyEvidence.postLogonEnabled + ), + }, + { + label: "Device Eligible", + value: formatBool(facts.userState.deviceEligible), + tone: facts.userState.deviceEligible ? "good" : "neutral", + }, + { + label: "Session Is Not Remote", + value: formatBool(facts.userState.sessionIsNotRemote), + tone: facts.userState.sessionIsNotRemote ? "good" : "neutral", + }, + { + label: "PreReq Result", + value: formatValue(facts.registration.preReqResult), + tone: toneForNgcReadiness(result), + }, + ]); + + if (facts.registration.certEnrollment && facts.registration.certEnrollment.toLowerCase() !== "none") { + ngcRows.push({ + label: "Cert Enrollment", + value: formatValue(facts.registration.certEnrollment), + tone: "neutral", + }); + } + + if (facts.ssoState.adfsRefreshToken != null) { + ngcRows.push({ + label: "ADFS Refresh Token", + value: formatBool(facts.ssoState.adfsRefreshToken), + tone: facts.ssoState.adfsRefreshToken ? "good" : "neutral", + }); + } + + if (facts.ssoState.adfsRaIsReady != null) { + ngcRows.push({ + label: "ADFS RA Ready", + value: formatBool(facts.ssoState.adfsRaIsReady), + tone: facts.ssoState.adfsRaIsReady ? "good" : "neutral", + }); + } + + if (facts.registration.logonCertTemplateReady) { + ngcRows.push({ + label: "Logon Cert Template", + value: formatValue(facts.registration.logonCertTemplateReady), + tone: facts.registration.logonCertTemplateReady.includes("StateReady") ? "good" : "neutral", + }); + } + + if (facts.postJoinDiagnostics.keySignTest != null) { + ngcRows.push({ + label: "Key Sign Test", + value: formatValue(facts.postJoinDiagnostics.keySignTest), + tone: facts.postJoinDiagnostics.keySignTest.toLowerCase().includes("pass") ? "good" : "warn", + }); + } + + if (facts.postJoinDiagnostics.aadRecoveryEnabled != null) { + ngcRows.push({ + label: "AAD Recovery Enabled", + value: formatBool(facts.postJoinDiagnostics.aadRecoveryEnabled), + tone: facts.postJoinDiagnostics.aadRecoveryEnabled ? "warn" : "good", + }); + } + + return [ + { + id: "phase-evidence", + title: "Phase and Confidence", + caption: "Derived stage and evidence used to explain where the current problem appears to sit.", + rows: withNotReportedMetadata([ + { + label: "Dominant Phase", + value: displayPhase.label, + tone: displayPhase.tone, + }, + { + label: "Phase Summary", + value: displayPhase.summary, + tone: "neutral", + }, + { + label: "Capture Confidence", + value: formatConfidenceLabel(displayConfidence.confidence), + tone: toneForCaptureConfidence(displayConfidence.confidence), + }, + { + label: "Confidence Reason", + value: displayConfidence.reason, + tone: "neutral", + }, + { label: "Error Phase", value: formatValue(facts.registration.errorPhase) }, + { label: "Client Error", value: formatValue(facts.registration.clientErrorCode) }, + { label: "DRS Discovery", value: formatValue(facts.preJoinTests.drsDiscoveryTest) }, + { + label: "Token Acquisition", + value: formatValue(facts.preJoinTests.tokenAcquisitionTest), + }, + { label: "Attempt Status", value: formatValue(facts.diagnostics.attemptStatus) }, + { label: "HTTP Status", value: formatValue(facts.diagnostics.httpStatus) }, + { label: "Endpoint URI", value: formatValue(facts.diagnostics.endpointUri) }, + { label: "User Context", value: formatValue(facts.diagnostics.userContext) }, + ]), + }, + { + id: "join-state", + title: "Join State", + caption: "Identity, join posture, and major derived signals.", + rows: withNotReportedMetadata([ + { label: "Join Type", value: formatValue(derived.joinTypeLabel), tone: "good" }, + { + label: "Azure AD Joined", + value: formatBool(facts.joinState.azureAdJoined), + tone: toneForBool(facts.joinState.azureAdJoined), + }, + { + label: "Domain Joined", + value: formatBool(facts.joinState.domainJoined), + tone: toneForDomainJoined(facts.joinState.domainJoined), + }, + { + label: "Workplace Joined", + value: formatBool(facts.joinState.workplaceJoined), + tone: toneForWorkplaceJoined(facts.joinState.workplaceJoined), + }, + { + label: "Enterprise Joined", + value: formatBool(facts.joinState.enterpriseJoined), + tone: toneForEnterpriseJoined(facts.joinState.enterpriseJoined), + }, + { + label: "Device Auth Status", + value: formatValue(facts.deviceDetails.deviceAuthStatus), + tone: + facts.deviceDetails.deviceAuthStatus?.toUpperCase() === "SUCCESS" + ? "good" + : facts.deviceDetails.deviceAuthStatus + ? "bad" + : "neutral", + }, + ]), + }, + { + id: "tenant-device", + title: "Tenant and Device", + caption: "Core identifiers and certificate-related device details.", + rows: withNotReportedMetadata([ + { label: "Tenant Id", value: formatValue(facts.tenantDetails.tenantId) }, + { label: "Tenant Name", value: formatValue(facts.tenantDetails.tenantName) }, + { label: "Domain Name", value: formatValue(facts.tenantDetails.domainName) }, + { label: "Device Id", value: formatValue(facts.deviceDetails.deviceId) }, + { label: "Thumbprint", value: formatValue(facts.deviceDetails.thumbprint) }, + { + label: "TPM Protected", + value: formatBool(facts.deviceDetails.tpmProtected), + tone: toneForBool(facts.deviceDetails.tpmProtected), + }, + { + label: "Certificate Validity", + value: formatCertificateValidityRange( + facts.deviceDetails.deviceCertificateValidity, + derived.certificateValidFrom, + derived.certificateValidTo + ), + tone: derived.certificateExpiringSoon ? "warn" : "neutral", + }, + ]), + }, + { + id: "management", + title: "Management and MDM", + caption: "Management visibility and tenant-advertised endpoints. Missing values can be out of scope, unconfigured, or simply absent from this capture.", + rows: withNotReportedMetadata([ + { + label: "MDM Visibility", + value: getMdmVisibilityLabel(derived), + tone: toneForMdmVisibility(derived), + }, + { + label: "MDM URL", + value: formatValue(facts.managementDetails.mdmUrl), + tone: derived.missingMdm ? "neutral" : "neutral", + }, + { + label: "Compliance URL", + value: formatValue(facts.managementDetails.mdmComplianceUrl), + tone: derived.missingComplianceUrl ? "neutral" : "neutral", + }, + { label: "Settings URL", value: formatValue(facts.managementDetails.settingsUrl) }, + { + label: "DM Service URL", + value: formatValue(facts.managementDetails.deviceManagementSrvUrl), + }, + { + label: "DM Service ID", + value: formatValue(facts.managementDetails.deviceManagementSrvId), + }, + ]), + }, + { + id: "sso-prt", + title: "SSO and PRT", + caption: "Token presence, freshness, and user session indicators.", + rows: withNotReportedMetadata([ + { + label: "Azure AD PRT", + value: formatBool(facts.ssoState.azureAdPrt), + tone: toneForBool(facts.ssoState.azureAdPrt), + }, + { + label: "PRT Update Time", + value: formatDateTimeValue(facts.ssoState.azureAdPrtUpdateTime), + tone: derived.stalePrt ? "warn" : "neutral", + }, + { + label: "PRT Age Hours", + value: formatHourDuration(displayedPrtAgeHours), + tone: derived.stalePrt ? "warn" : "neutral", + }, + { + label: "Enterprise PRT", + value: formatBool(facts.ssoState.enterprisePrt), + tone: toneForEnterprisePrt(facts.ssoState.enterprisePrt), + }, + { + label: "WAM Default Set", + value: formatBool(facts.userState.wamDefaultSet), + tone: toneForBool(facts.userState.wamDefaultSet), + }, + { + label: "User Context", + value: formatValue(facts.diagnostics.userContext), + tone: derived.remoteSessionSystem ? "warn" : "neutral", + }, + ]), + }, + { + id: "diagnostics", + title: "Diagnostics and Errors", + caption: "Correlation, transport, and registration error fields.", + rows: withNotReportedMetadata([ + { label: "Attempt Status", value: formatValue(facts.diagnostics.attemptStatus) }, + { label: "HTTP Error", value: formatValue(facts.diagnostics.httpError) }, + { label: "HTTP Status", value: formatValue(facts.diagnostics.httpStatus) }, + { label: "Endpoint URI", value: formatValue(facts.diagnostics.endpointUri) }, + { label: "Correlation ID", value: formatValue(facts.diagnostics.correlationId) }, + { label: "Request ID", value: formatValue(facts.diagnostics.requestId) }, + { label: "Client Error", value: formatValue(facts.registration.clientErrorCode) }, + { label: "Server Error", value: formatValue(facts.registration.serverErrorCode) }, + { label: "Server Message", value: formatValue(facts.registration.serverMessage) }, + ]), + }, + { + id: "prejoin-registration", + title: "Pre-Join and Registration", + caption: "Hybrid join readiness and registration workflow checks.", + rows: withNotReportedMetadata([ + { label: "AD Connectivity", value: formatValue(facts.preJoinTests.adConnectivityTest) }, + { label: "AD Configuration", value: formatValue(facts.preJoinTests.adConfigurationTest) }, + { label: "DRS Discovery", value: formatValue(facts.preJoinTests.drsDiscoveryTest) }, + { label: "DRS Connectivity", value: formatValue(facts.preJoinTests.drsConnectivityTest) }, + { + label: "Token Acquisition", + value: formatValue(facts.preJoinTests.tokenAcquisitionTest), + }, + { + label: "Fallback to Sync-Join", + value: formatValue(facts.preJoinTests.fallbackToSyncJoin), + }, + { label: "Error Phase", value: formatValue(facts.registration.errorPhase) }, + { + label: "Logon Cert Template", + value: formatValue(facts.registration.logonCertTemplateReady), + }, + ]), + }, + { + id: "ngc-readiness", + title: "Windows Hello and NGC", + caption: "Lightweight Windows Hello for Business readiness context. These fields are posture signals, not default failure indicators.", + rows: ngcRows, + }, + { + id: "policy-evidence", + title: "Policy Evidence", + caption: "Registry-backed WHfB policy state used only when dsregcmd leaves policy fields unreported.", + rows: withNotReportedMetadata([ + { + label: "Policy Enabled Evidence", + value: formatPolicyEvidenceValue(result.policyEvidence.policyEnabled), + tone: toneForBool(result.policyEvidence.policyEnabled.displayValue), + }, + { + label: "Post-Logon Evidence", + value: formatPolicyEvidenceValue(result.policyEvidence.postLogonEnabled), + tone: toneForBool(result.policyEvidence.postLogonEnabled.displayValue), + }, + { + label: "PIN Recovery Policy", + value: formatPolicyEvidenceValue(result.policyEvidence.pinRecoveryEnabled), + tone: toneForBool(result.policyEvidence.pinRecoveryEnabled.displayValue), + }, + { + label: "Require Security Device", + value: formatPolicyEvidenceValue(result.policyEvidence.requireSecurityDevice), + tone: toneForBool(result.policyEvidence.requireSecurityDevice.displayValue), + }, + { + label: "Use Certificate Trust", + value: formatPolicyEvidenceValue(result.policyEvidence.useCertificateForOnPremAuth), + tone: toneForBool(result.policyEvidence.useCertificateForOnPremAuth.displayValue), + }, + { + label: "Use Cloud Trust", + value: formatPolicyEvidenceValue(result.policyEvidence.useCloudTrustForOnPremAuth), + tone: toneForBool(result.policyEvidence.useCloudTrustForOnPremAuth.displayValue), + }, + { + label: "Evidence Status", + value: getPolicyEvidenceSummary(result), + }, + { + label: "Registry Artifacts", + value: formatRegistryArtifacts(result.policyEvidence.artifactPaths), + }, + ]), + }, + { + id: "service-endpoints", + title: "Service Endpoints", + caption: "Relevant identity and registration service URLs.", + rows: withNotReportedMetadata([ + { label: "Join Server URL", value: formatValue(facts.serviceEndpoints.joinSrvUrl) }, + { label: "Join Server ID", value: formatValue(facts.serviceEndpoints.joinSrvId) }, + { label: "Key Server URL", value: formatValue(facts.serviceEndpoints.keySrvUrl) }, + { label: "Auth Code URL", value: formatValue(facts.serviceEndpoints.authCodeUrl) }, + { label: "Access Token URL", value: formatValue(facts.serviceEndpoints.accessTokenUrl) }, + { + label: "WebAuthn Service URL", + value: formatValue(facts.serviceEndpoints.webAuthnSrvUrl), + }, + ]), + }, + { + id: "source-details", + title: "Source Details", + caption: "Where this dsregcmd analysis came from and how much text was processed.", + rows: withNotReportedMetadata([ + { label: "Source", value: sourceContext.displayLabel }, + { label: "Resolved Path", value: formatValue(sourceContext.resolvedPath) }, + { label: "Evidence File", value: formatValue(sourceContext.evidenceFilePath) }, + { label: "Lines", value: String(sourceContext.rawLineCount) }, + { label: "Characters", value: String(sourceContext.rawCharCount) }, + ]), + }, + ]; +} + +function getSummaryText( + result: DsregcmdAnalysisResult, + sourceLabel: string, + displayPhase: DisplayPhaseAssessment, + displayConfidence: DisplayConfidenceAssessment +): string { + const errorCount = result.diagnostics.filter((item) => item.severity === "Error").length; + const warningCount = result.diagnostics.filter((item) => item.severity === "Warning").length; + const infoCount = result.diagnostics.filter((item) => item.severity === "Info").length; + const criticalIssue = result.diagnostics.find((item) => item.severity === "Error"); + + return [ + `Source: ${sourceLabel}`, + `Join type: ${result.derived.joinTypeLabel}`, + `Current stage: ${displayPhase.label}`, + `Stage summary: ${displayPhase.summary}`, + `Capture confidence: ${formatConfidenceLabel(displayConfidence.confidence)}`, + `Confidence note: ${displayConfidence.reason}`, + `Diagnostics: ${errorCount} errors, ${warningCount} warnings, ${infoCount} info`, + criticalIssue ? `Top issue: ${criticalIssue.title}` : "Top issue: No critical issues detected", + qualifyByCaptureConfidence( + displayConfidence.confidence, + `PRT present: ${formatBool(result.derived.azureAdPrtPresent)}` + ), + qualifyByCaptureConfidence( + displayConfidence.confidence, + `MDM visibility: ${getMdmVisibilityLabel(result.derived)}` + ), + qualifyByCaptureConfidence( + displayConfidence.confidence, + `NGC readiness: ${getNgcReadinessValue(result)}` + ), + qualifyByCaptureConfidence( + displayConfidence.confidence, + `Device auth status: ${formatValue(result.facts.deviceDetails.deviceAuthStatus)}` + ), + ].join("\n"); +} + +function StatCard({ + title, + value, + caption, + tone = "neutral", +}: { + title: string; + value: string; + caption: string; + tone?: "neutral" | "good" | "warn" | "bad"; +}) { + const tones = { + neutral: { border: "#d1d5db", background: "#ffffff", value: "#111827" }, + good: { border: "#bbf7d0", background: "#f0fdf4", value: "#166534" }, + warn: { border: "#fde68a", background: "#fffbeb", value: "#92400e" }, + bad: { border: "#fecaca", background: "#fef2f2", value: "#991b1b" }, + } as const; + + const colors = tones[tone]; + + return ( +
+
+ {title} +
+
+ {value} +
+
+ {caption} +
+
+ ); +} + +function SectionFrame({ title, caption, children }: { title: string; caption: string; children: ReactNode }) { + return ( +
+
+
{title}
+
{caption}
+
+
{children}
+
+ ); +} + +function IssueCard({ issue }: { issue: DsregcmdDiagnosticInsight }) { + const colors = getSeverityColor(issue.severity); + + return ( +
+
+ + {issue.severity} + + + {issue.category} + +
+
{issue.title}
+
{issue.summary}
+ + {issue.suggestedFixes.length > 0 && ( +
+
Suggested fixes
+
    + {issue.suggestedFixes.map((item) => ( +
  • {item}
  • + ))} +
+
+ )} + + {issue.nextChecks.length > 0 && ( +
+
Next checks
+
    + {issue.nextChecks.map((item) => ( +
  • {item}
  • + ))} +
+
+ )} + + {issue.evidence.length > 0 && ( +
+
Evidence
+
    + {issue.evidence.map((item) => ( +
  • {item}
  • + ))} +
+
+ )} +
+ ); +} + +function FactsTable({ group, showNotReported }: { group: FactGroup; showNotReported: boolean }) { + const visibleRows = showNotReported + ? group.rows + : group.rows.filter((row) => row.isNotReported !== true); + const hiddenCount = group.rows.length - visibleRows.length; + + return ( +
+
+
{group.title}
+
{group.caption}
+
+
+ {visibleRows.length === 0 ? ( +
+ All fields in this group were not reported by dsregcmd for this capture. +
+ ) : visibleRows.map((row) => { + const tones = { + neutral: { value: "#111827", background: "#ffffff" }, + good: { value: "#166534", background: "#f0fdf4" }, + warn: { value: "#92400e", background: "#fffbeb" }, + bad: { value: "#991b1b", background: "#fef2f2" }, + } as const; + const palette = tones[row.tone ?? "neutral"]; + + return ( +
+
{row.label}
+
+ {row.value} +
+
+ ); + })} + {!showNotReported && hiddenCount > 0 && ( +
+ {hiddenCount} not reported {hiddenCount === 1 ? "field" : "fields"} hidden. +
+ )} +
+
+ ); +} + +function EmptyWorkspace({ title, body }: { title: string; body: string }) { + return ( +
+
{title}
+
{body}
+
+ ); +} + +function buildTimelineItems(facts: DsregcmdFacts, result: DsregcmdAnalysisResult) { + return [ + { + id: "cert-valid-from", + label: "Certificate valid from", + value: + formatLocalDateTime(result.derived.certificateValidFrom) ?? + result.derived.certificateValidFrom ?? + facts.deviceDetails.deviceCertificateValidity, + tone: "neutral" as const, + }, + { + id: "cert-valid-to", + label: "Certificate valid to", + value: + formatLocalDateTime(result.derived.certificateValidTo) ?? + result.derived.certificateValidTo ?? + facts.deviceDetails.deviceCertificateValidity, + tone: result.derived.certificateExpiringSoon ? "warn" as const : "neutral" as const, + }, + { + id: "previous-prt", + label: "Previous PRT attempt", + value: formatDateTimeValue(facts.diagnostics.previousPrtAttempt), + tone: "neutral" as const, + }, + { + id: "prt-update", + label: "Azure AD PRT update", + value: formatDateTimeValue(facts.ssoState.azureAdPrtUpdateTime), + tone: result.derived.stalePrt ? "warn" as const : "good" as const, + }, + { + id: "client-time", + label: "Client reference time", + value: formatDateTimeValue(facts.diagnostics.clientTime), + tone: "neutral" as const, + }, + ].filter((item) => item.value); +} + +function FlowBox({ title, detail, tone = "neutral" }: { title: string; detail: string; tone?: "neutral" | "good" | "warn" | "bad" }) { + const colors = { + neutral: { border: "#d1d5db", background: "#ffffff", text: "#111827" }, + good: { border: "#bbf7d0", background: "#f0fdf4", text: "#166534" }, + warn: { border: "#fde68a", background: "#fffbeb", text: "#92400e" }, + bad: { border: "#fecaca", background: "#fef2f2", text: "#991b1b" }, + } as const; + const palette = colors[tone]; + + return ( +
+
{title}
+
{detail}
+
+ ); +} + +export function DsregcmdWorkspace() { + const result = useDsregcmdStore((s) => s.result); + const rawInput = useDsregcmdStore((s) => s.rawInput); + const sourceContext = useDsregcmdStore((s) => s.sourceContext); + const analysisState = useDsregcmdStore((s) => s.analysisState); + const isAnalyzing = useDsregcmdStore((s) => s.isAnalyzing); + const { openSourceFileDialog, openSourceFolderDialog, pasteDsregcmdSource, captureDsregcmdSource } = useAppActions(); + const [exportStatus, setExportStatus] = useState<{ tone: "success" | "error"; message: string } | null>(null); + const [showRawInput, setShowRawInput] = useState(false); + const [showNotReported, setShowNotReported] = useState(false); + + const diagnostics = result?.diagnostics ?? []; + const errorCount = diagnostics.filter((item) => item.severity === "Error").length; + const warningCount = diagnostics.filter((item) => item.severity === "Warning").length; + + const displayedPrtAgeHours = useMemo(() => { + if (!result) { + return null; + } + + if (sourceContext.source?.kind === "capture") { + const lastUpdate = parseDsregcmdDateTime(result.derived.prtLastUpdate); + if (!lastUpdate) { + return result.derived.prtAgeHours; + } + + return Math.max(0, (Date.now() - lastUpdate.getTime()) / 3_600_000); + } + + return result.derived.prtAgeHours; + }, [result, sourceContext.source]); + + const displayPhase = useMemo( + () => (result ? getDisplayPhaseAssessment(result, errorCount, warningCount) : null), + [errorCount, result, warningCount] + ); + const displayConfidence = useMemo( + () => (result ? getDisplayConfidenceAssessment(result, sourceContext) : null), + [result, sourceContext] + ); + + const factGroups = useMemo( + () => + result && displayPhase && displayConfidence + ? getFactGroups(result, displayedPrtAgeHours, displayPhase, displayConfidence, sourceContext) + : [], + [displayConfidence, displayPhase, displayedPrtAgeHours, result, sourceContext] + ); + const summaryText = useMemo( + () => + result && displayPhase && displayConfidence + ? getSummaryText(result, sourceContext.displayLabel, displayPhase, displayConfidence) + : "", + [displayConfidence, displayPhase, result, sourceContext.displayLabel] + ); + const timelineItems = useMemo( + () => (result ? buildTimelineItems(result.facts, result) : []), + [result] + ); + + useEffect(() => { + if (!exportStatus) { + return undefined; + } + + const timer = window.setTimeout(() => { + setExportStatus(null); + }, 5000); + + return () => { + window.clearTimeout(timer); + }; + }, [exportStatus]); + + const setExportSuccess = useCallback((message: string) => { + setExportStatus({ tone: "success", message }); + }, []); + + const setExportError = useCallback((message: string) => { + setExportStatus({ tone: "error", message }); + }, []); + + const handleCopyJson = async () => { + if (!result) { + return; + } + + try { + await writeText(JSON.stringify(result, null, 2)); + setExportSuccess("Copied dsregcmd analysis JSON to the clipboard."); + } catch (error) { + console.error("[dsregcmd] failed to copy JSON export", { error }); + setExportError( + error instanceof Error ? error.message : "Could not copy dsregcmd JSON to the clipboard." + ); + } + }; + + const handleCopySummary = async () => { + if (!result) { + return; + } + + try { + await writeText(summaryText); + setExportSuccess("Copied dsregcmd summary to the clipboard."); + } catch (error) { + console.error("[dsregcmd] failed to copy summary export", { error }); + setExportError( + error instanceof Error ? error.message : "Could not copy the dsregcmd summary to the clipboard." + ); + } + }; + + const handleCopyStatus = async () => { + if (!rawInput.trim()) { + setExportError("No dsregcmd status text is available to copy."); + return; + } + + try { + await writeText(rawInput); + setExportSuccess("Copied dsregcmd status text to the clipboard."); + } catch (error) { + console.error("[dsregcmd] failed to copy raw status", { error }); + setExportError( + error instanceof Error ? error.message : "Could not copy dsregcmd status text to the clipboard." + ); + } + }; + + const handleSaveExport = async (kind: "json" | "summary") => { + if (!result) { + return; + } + + const defaultPath = + kind === "json" ? "dsregcmd-analysis.json" : "dsregcmd-summary.txt"; + + try { + const destination = await save({ + defaultPath, + filters: + kind === "json" + ? [{ name: "JSON", extensions: ["json"] }] + : [{ name: "Text", extensions: ["txt"] }], + }); + + if (!destination) { + return; + } + + const contents = kind === "json" ? JSON.stringify(result, null, 2) : summaryText; + await writeTextOutputFile(destination, contents); + setExportSuccess( + `Saved ${kind === "json" ? "JSON export" : "summary export"} to ${destination}.` + ); + } catch (error) { + console.error("[dsregcmd] failed to save export", { error, kind }); + setExportError( + error instanceof Error + ? error.message + : `Could not save the ${kind === "json" ? "JSON" : "summary"} export.` + ); + } + }; + + if (!result && isAnalyzing) { + return ( + + ); + } + + if (!result && analysisState.phase === "error") { + return ( + + ); + } + + if (!result) { + return ( +
+
+
+
dsregcmd Workspace
+
+ Capture a live snapshot, paste clipboard text, open a text file, or select an evidence bundle folder. +
+
+
+ + + + +
+
+ + +
+ ); + } + + const issueSpotlight = diagnostics.find((item) => item.severity === "Error") ?? diagnostics[0] ?? null; + const stage = displayPhase ?? { + phase: result.derived.dominantPhase, + label: formatPhaseLabel(result.derived.dominantPhase), + tone: toneForPhase(result.derived.dominantPhase), + summary: result.derived.phaseSummary, + }; + const confidence = displayConfidence ?? { + confidence: result.derived.captureConfidence, + reason: result.derived.captureConfidenceReason, + }; + + return ( +
+
+
+
dsregcmd Workspace
+
+ {sourceContext.displayLabel} + {sourceContext.resolvedPath && ` • ${sourceContext.resolvedPath}`} + {sourceContext.evidenceFilePath && sourceContext.evidenceFilePath !== sourceContext.resolvedPath + ? ` • evidence ${sourceContext.evidenceFilePath}` + : ""} +
+
+
+ + + + +
+
+ +
+
+ + + + + + + +
+ + +
+
+
{summaryText}
+ {issueSpotlight && ( +
+
Issue spotlight
+
{issueSpotlight.title}
+
+ {issueSpotlight.summary} {confidence.confidence === "high" ? "" : `Interpret this in the context of ${formatConfidenceLabel(confidence.confidence).toLowerCase()} capture confidence.`} +
+
+ )} +
+
+
Quick interpretation
+
    +
  • {stage.summary}
  • +
  • {`Capture confidence is ${formatConfidenceLabel(confidence.confidence).toLowerCase()}: ${confidence.reason}`}
  • +
  • {result.policyEvidence.artifactPaths.length > 0 ? "Registry-backed WHfB policy evidence is available for this bundle." : "No sibling registry policy evidence was available for this capture."}
  • +
  • {result.derived.hasNetworkError ? `Network marker detected: ${result.derived.networkErrorCode}.` : "No explicit network marker was detected in the capture."}
  • +
  • {result.derived.remoteSessionSystem ? "Capture looks like SYSTEM in a remote session, so user token fields may be misleading." : "Capture does not look like a SYSTEM remote-session snapshot."}
  • +
  • {result.derived.certificateExpiringSoon ? "Device certificate is nearing expiry and deserves follow-up." : "Certificate expiry was not flagged as near-term."}
  • +
+
+
+
+ + + {diagnostics.length === 0 ? ( +
No diagnostics were produced for this dsregcmd capture.
+ ) : ( +
+ {diagnostics.map((issue) => ( + + ))} +
+ )} +
+ + +
+ +
+
+ {factGroups.map((group) => ( + + ))} +
+
+ + + {timelineItems.length === 0 ? ( +
No timeline-friendly timestamps were found in this capture.
+ ) : ( +
+ {timelineItems.map((item, index) => { + const palette = + item.tone === "warn" + ? { line: "#f59e0b", dot: "#f59e0b", card: "#fffbeb" } + : item.tone === "good" + ? { line: "#16a34a", dot: "#16a34a", card: "#f0fdf4" } + : { line: "#94a3b8", dot: "#64748b", card: "#f8fafc" }; + + return ( +
+
+
+ {index < timelineItems.length - 1 && ( +
+ )} +
+
+
{item.label}
+
{item.value}
+
+
+ ); + })} +
+ )} + + + +
+ + + + + + + +
+
+ + +
+
+
What the health cards mean
+
+ Cards summarize join posture, token state, MDM visibility, certificate lifetime, and issue counts. They are not a replacement for the raw dsregcmd output, but they do make triage faster. +
+
+
+
When the capture may mislead
+
+ SYSTEM and remote-session captures can distort user-scoped token state. Evidence bundle captures can also be older than the current device state, so compare timestamps before acting. +
+
+
+
Suggested next step
+
+ Start with the highest-severity issue card, validate the evidence line items against the grouped facts below, and then re-run capture after remediation to confirm the signal changes. +
+
+
+
+ + +
+ + + + + + +
+ {exportStatus && ( +
+ {exportStatus.message} +
+ )} + {showRawInput && ( +