diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 000000000..5be498e25 --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,42 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..9b967e3a1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -rn \"const analyzeDsregcmdSource\\\\|function analyzeDsregcmdSource\" /c/Users/AdamGell/Documents/GitHub/cmtraceopen/src --include=*.ts --include=*.tsx)", + "Bash(find /c/Users/AdamGell/Documents/GitHub/cmtraceopen -type f -name *evidence* -o -name *intune-profile*)", + "Bash(cargo check:*)", + "Bash(npx tsc:*)", + "Bash(cargo test:*)" + ] + } +} diff --git a/.claude/skills/frontend-design/LICENSE.txt b/.claude/skills/frontend-design/LICENSE.txt new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/.claude/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 000000000..5be498e25 --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -0,0 +1,42 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/.gitignore b/.gitignore index 7507ce7d2..a4a59358f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ Thumbs.db .env .env.local - +#logcollection +loginventory/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..55295f0ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CMTrace Open is an open-source log viewer and Windows troubleshooting tool built with **Tauri v2 + React + TypeScript + Rust**. It replaces Microsoft's CMTrace.exe with modern features including Intune diagnostics, DSRegCmd analysis, and real-time log tailing. + +## Build & Development Commands + +```bash +# Install dependencies (run once after clone) +npm ci + +# Development - full Tauri app with hot reload +npm run app:dev + +# Development - frontend only (Vite dev server on :1420) +npm run frontend:dev + +# Production builds +npm run app:build:release # Full release with bundler (MSI, DMG, etc.) +npm run app:build:debug # Debug build (incremental) +npm run app:build:exe-only # Executable only, no bundler + +# Frontend only +npm run frontend:build # tsc + vite build +``` + +### Rust Commands (run from `src-tauri/`) + +```bash +cargo check # Type check +cargo test # Run all tests +cargo clippy -- -D warnings # Lint (CI enforces zero warnings) +cargo bench # Criterion benchmarks (intune_pipeline) +``` + +### TypeScript Check + +```bash +npx tsc --noEmit +``` + +### CI Checks (what PR gates enforce) + +1. `cargo check` + `cargo test` + `cargo clippy -- -D warnings` (Ubuntu) +2. `npx tsc --noEmit` (Node 20) +3. Tauri build on macOS-arm64, Windows-x64, Linux-x64 + +## Architecture + +### Two-Process Model (Tauri v2) + +- **Frontend** (`src/`): React 19 + TypeScript, Fluent UI components, Zustand stores, TanStack Virtual for scrolling +- **Backend** (`src-tauri/src/`): Rust, exposes IPC commands via `tauri::generate_handler!` in `lib.rs` + +Communication is through Tauri's `invoke()` (frontend→backend) and `emit()` (backend→frontend events, e.g., tail updates). + +### Backend Module Map (`src-tauri/src/`) + +| Module | Purpose | +|--------|---------| +| `commands/` | Tauri IPC command handlers — the API surface between frontend and backend | +| `parser/` | Log format auto-detection and parsing (CCM, simple, CBS, DISM, Panther, plain text) | +| `intune/` | IME diagnostics pipeline: event tracking, timeline, download stats, EVTX parsing | +| `dsregcmd/` | Device registration analysis: output parsing, diagnostic rules, registry hives | +| `error_db/` | Embedded error code database (120+ Windows/SCCM/Intune codes) | +| `models/` | Shared types: `LogEntry`, `ParseResult`, `FilterCriteria` | +| `state/` | `AppState` (Mutex-wrapped) — tracks open files, tail sessions | +| `watcher/` | File watching and real-time tailing via `notify` crate | +| `menu.rs` | Native application menu | + +### Frontend Module Map (`src/`) + +| Module | Purpose | +|--------|---------| +| `components/log-view/` | Main log list with virtual scrolling, row rendering, info pane | +| `components/layout/` | AppShell, toolbar, sidebar, status bar | +| `components/dialogs/` | Modal dialogs (find, filter, error lookup) | +| `components/intune/` | Intune analysis workspace | +| `components/dsregcmd/` | DSRegCmd troubleshooting workspace | +| `stores/` | 5 Zustand stores: log, filter, intune, dsregcmd, ui | +| `hooks/` | Custom hooks for drag-drop, menus, file association | +| `types/` | TypeScript type definitions | + +### Parser Architecture + +The parser system in `src-tauri/src/parser/` uses a `ResolvedParser` that bundles: +- `ParserKind` — format variant (CCM, Simple, ReportingEvents, etc.) +- `ParserImplementation` — actual parsing logic +- `ParseQuality` — Structured / SemiStructured / Unstructured +- `RecordFraming` — PhysicalLine vs LogicalRecord (multi-line) +- `ParserSpecialization` — optional (e.g., IME for Intune logs) + +Format detection (`detect.rs`) samples the first lines of a file to auto-select the parser. + +### Key Patterns + +- **IPC commands** are defined in `commands/*.rs` and registered in `lib.rs` via `invoke_handler` +- **State** is shared across commands via Tauri's `manage()` with `AppState` (Mutex) +- **Encoding fallback**: UTF-8 → Windows-1252 (via `encoding_rs`) +- **Parallelism**: Rayon for batch log line processing, Tokio for async file I/O +- **Windows-specific code** is gated with `#[cfg(target_os = "windows")]` and the `windows`/`winreg` crates + +## Testing + +- **Unit/integration tests**: `src-tauri/tests/` — parser regression tests with synthetic fixtures +- **Benchmarks**: `src-tauri/benches/intune_pipeline.rs` — Criterion benchmarks for the Intune pipeline (10K records) +- Run a single test: `cargo test test_name` from `src-tauri/` +- Run benchmarks: `cargo bench` from `src-tauri/` + +## Prerequisites + +- Node.js 18+ (v20 LTS recommended) +- Rust 1.77.2+ (MSVC toolchain on Windows) +- Windows: Visual Studio Build Tools with C++ workload + Windows SDK + WebView2 Runtime +- Automated Windows setup: `powershell -ExecutionPolicy Bypass -File .\scripts\Install-CMTraceOpenBuildPrereqs.ps1` diff --git a/FEATURE_IMPROVEMENTS.md b/FEATURE_IMPROVEMENTS.md index e66e02bb1..f88826a52 100644 --- a/FEATURE_IMPROVEMENTS.md +++ b/FEATURE_IMPROVEMENTS.md @@ -1,100 +1,234 @@ # CMTrace Open — Feature Improvements Roadmap -Internal planning document focused on unfinished work. Active priorities stay near the top; shipped work is summarized at the bottom for reference. +Internal planning document focused on unfinished work. Active priorities stay +near the top; shipped work is summarized at the bottom for reference. -**Status key**: Active Focus = current priority, Next Slice = recommended near-term implementation, Completed = shipped unless a regression is found -**Priority key**: P0 = critical path, P1 = high value, P2 = useful follow-on, P3 = future consideration -**Effort key**: S = small (< 4 hours), M = medium (4–16 hours), L = large (16–40 hours), XL = 40+ hours +**Status key**: Active Focus = current priority, Next Slice = recommended +near-term implementation, In Progress = partially shipped with clear follow-on +work, Completed = shipped unless a regression is found +**Priority key**: P0 = critical path, P1 = high value, P2 = useful follow-on, P3 += future consideration +**Effort key**: S = small (< 4 hours), M = medium (4–16 hours), L = large (16–40 +hours), XL = 40+ hours ## Active Focus -The current focus is sample-driven hardening and broader evidence-bundle intake. The tracked template under `templates/evidence-bundle/` and the PowerShell collector at `scripts/collection/Invoke-CmtraceEvidenceCollection.ps1` now define the working bundle shape, so the next work should prioritize better sample intake, evidence inventory, parser hardening from real samples, registry state capture, curated adjacent evidence sources, and remote collection that feeds the same structure. +The evidence-bundle intake baseline is now in place. The current focus is +finishing Push 3 parser hardening beyond the first CBS/DISM/Panther salvage +pass, then moving into the first correlated investigation views and the +remaining workflow backlog without re-opening already-shipped intake work. ## Recommended Next Implementation Slice -1. Make the tracked template and script-produced evidence bundle shape the canonical intake contract for investigations. -2. Add a practical sample-intake flow that records what evidence was provided, what was recognized, and what is still unknown or unsupported. -3. Build an evidence inventory so each local investigation starts with coverage and provenance instead of assumptions. -4. Harden existing parsers and Intune evidence rules from real samples before expanding speculative source coverage. -5. Treat registry snapshots and curated event-log exports as first-class adjacent evidence sources for live-device investigations. -6. Ensure remote collection paths feed the same evidence-bundle shape instead of introducing a separate intake format. +1. Continue Push 3 with another sample-driven hardening slice, prioritizing + remaining noisy parser families and IME rule gaps rather than adding + speculative formats. +2. Start Push 4 by turning the existing evidence inventory, registry snapshot + preview, and curated event export preview into correlated investigation + views. +3. Pull in isolated Push 5 workflow wins only where they do not interrupt the + evidence-first path. +4. Leave broad parser expansion, scale work, and advanced integrations behind + validated evidence gaps. --- -## Prioritized Backlog: Next Implementation Slices +## Push Backlog Status -### 0.1 Sample Intake Baseline — P0 / M +This is the current state of the evidence-first push backlog that replaced the +older phase ordering. -**Expected outcome**: a dropped local evidence bundle produces a stable intake summary showing recognized artifacts, unsupported artifacts, parse status, and obvious evidence gaps. +### Push 1 — Evidence Intake Foundation — Completed -- Treat the tracked template under `templates/evidence-bundle/` as the canonical local intake layout. -- Accept the same layout whether it came from a manual local copy workflow or from `scripts/collection/Invoke-CmtraceEvidenceCollection.ps1`. -- Accept a local investigation folder as a mixed evidence bundle, not just a log directory. -- Classify inputs into logs, registry exports, event-log exports, and unknown artifacts. -- Show recognized source families, parse success/failure, and any high-value missing evidence. -- Preserve deterministic ordering so intake results are easy to compare across runs and samples. +- Canonical evidence-bundle intake now follows the tracked bundle shape and + script-produced `manifest.json` + `notes.md` + `evidence/` layout. +- Bundle inspection classifies artifacts into logs, registry snapshots, + event-log exports, command output, screenshots, exports, and unknown + artifacts. +- Intake now exposes recognized source families, parser selection, parse + diagnostics, and obvious missing-evidence gaps through the bundle summary + flow. +- The first evidence inventory UI is shipped via the bundle summary dialog, with + artifact inventory, expected evidence, notes, and manifest preview. -### 0.2 Evidence Inventory and Provenance — P0 / M +### Push 2 — Adjacent Evidence Foundation — Mostly Completed -**Expected outcome**: each diagnostic summary can answer which artifacts were included, which were ignored, and which file or snapshot produced each notable event. +- Registry snapshot inspection is shipped for exported `.reg` snapshots and is + surfaced as structured adjacent evidence in the bundle flow. +- Curated event-log export intake is shipped as adjacent evidence preview for + offline/exported artifacts under the same bundle shape. +- Provenance is retained across log, Intune, and dsregcmd bundle-backed views. +- Remaining work is to turn these previews into richer correlated investigation + surfaces instead of stopping at intake and preview. -- Use `manifest.json` and `notes.md` as first-class inventory inputs when they are present. -- Add an evidence inventory view with source type, origin path, time coverage, and parse status. -- Surface provenance throughout summaries and timelines instead of collapsing everything into a single implied source. -- Make it obvious when conclusions are based on partial evidence or a narrow subset of the supplied bundle. +### Push 3 — Parser Hardening and Evidence Quality — In Progress -### 0.3 Parser Hardening from Real Samples — P1 / M +- Artifact-level parser quality diagnostics are shipped in the intake summary. +- CBS, DISM, and Panther now salvage structurally valid timestamped records with + unexpected level tokens instead of dropping them to raw fallback. +- Remaining work is to keep hardening the next highest-value parser and IME rule + gaps from real samples, using the same regression-driven approach. -**Expected outcome**: existing Windows and IME parsers fail less often on real samples and produce fewer generic or misclassified events. +### Push 4 — Correlated Investigation Views — Not Started -- Use real investigation samples to tighten detection, multiline handling, timestamp parsing, and severity mapping. -- Prioritize the parsers that already unlock common device investigations before adding niche formats. -- Track unsupported line patterns and unknown source families so hardening work stays sample-led. +- The prerequisites now exist: bundle inventory, registry snapshot preview, + curated event export preview, provenance, and parser quality diagnostics. +- The next slice should correlate logs, registry state, and adjacent event + evidence into source-aware investigation views. -### 0.4 Registry Snapshot Support — P1 / L +### Push 5 — Workflow and CMTrace Parity — Not Started -**Expected outcome**: registry evidence is ingested as structured device state that can be queried, compared, and correlated with log timelines. +- Time delta, Save As/export, regex find/filter, quick severity filters, richer + status-bar counts, and stronger embedded error lookup remain open. -- Treat registry data as state snapshots, not as another line-log parser. -- Start with exported `.reg` and other practical snapshot inputs that show enrollment, policy, and app-management state. -- Align collector output and manual bundle intake so registry snapshots land under the same `evidence/registry/` shape. -- Normalize keys, values, and hives so policy, enrollment, and health views can reference them directly. -- Support side-by-side comparison of intended state, effective state, and observed failures where that evidence exists. +### Push 6 — Parser and Diagnostics Expansion — Not Started -### 0.5 Curated Event Log Intake — P1 / M +- Additional parser families such as WindowsUpdate, SetupAPI, MSI, + ReportingEvents expansion, and follow-on Intune maintenance remain open behind + sample demand. -**Expected outcome**: adjacent event evidence can be added to a local investigation in a focused way without requiring a full generic event viewer first. +### Push 7 — Performance and Scale — Not Started -- Start with curated channels relevant to MDM, Autopilot, enrollment, BitLocker, LAPS, and Defender. -- Prefer practical intake paths such as saved exports or offline investigation artifacts before expanding to a broad live-channel browser. -- Align collector output and manual bundle intake so curated event exports land under the same `evidence/event-logs/` shape. -- Correlate event IDs, levels, and timestamps back into the shared evidence inventory and timelines. +- Incremental parsing, multi-file preparation, and large-investigation scaling + remain open. -### 0.6 Remote Collection Feeding Shared Bundle Shape — P1 / M +### Push 8 — Advanced Integration and Polish — Not Started -**Expected outcome**: evidence collected remotely still lands in the same reviewable bundle shape as local manual copies and local script runs. +- Remote collection convergence, MDM report viewing, print/history/bookmarks, + CLI shaping, and optional Graph enrichment remain open. + +--- -- Keep remote execution focused on producing `manifest.json`, `notes.md`, and the existing `evidence/` folder layout. -- Support Intune or other management delivery only as transport and execution layers, not as a different evidence schema. -- Reuse the same intake, inventory, and provenance logic regardless of whether the bundle was assembled locally or remotely. +## Prioritized Backlog: Next Implementation Slices -### 0.7 Intune Rule Maintenance from New Samples — P2 / S +### 0.1 Sample Intake Baseline — Completed + +**Expected outcome**: a dropped local evidence bundle produces a stable intake +summary showing recognized artifacts, unsupported artifacts, parse status, and +obvious evidence gaps. + +- Treat the tracked template under `templates/evidence-bundle/` as the canonical + local intake layout. +- Accept the same layout whether it came from a manual local copy workflow or + from `scripts/collection/Invoke-CmtraceEvidenceCollection.ps1`. +- Accept a local investigation folder as a mixed evidence bundle, not just a log + directory. +- Classify inputs into logs, registry exports, event-log exports, and unknown + artifacts. +- Show recognized source families, parse success/failure, and any high-value + missing evidence. +- Preserve deterministic ordering so intake results are easy to compare across + runs and samples. +- Shipped status: bundle inspection, intake classification, expected-evidence + gap reporting, and bundle summary UI now cover this baseline. + +### 0.2 Evidence Inventory and Provenance — Completed + +**Expected outcome**: each diagnostic summary can answer which artifacts were +included, which were ignored, and which file or snapshot produced each notable +event. + +- Use `manifest.json` and `notes.md` as first-class inventory inputs when they + are present. +- Add an evidence inventory view with source type, origin path, time coverage, + and parse status. +- Surface provenance throughout summaries and timelines instead of collapsing + everything into a single implied source. +- Make it obvious when conclusions are based on partial evidence or a narrow + subset of the supplied bundle. +- Shipped status: inventory, provenance, expected evidence, notes, and manifest + preview are now exposed in the bundle dialog, and bundle context is surfaced + in Intune and dsregcmd views. + +### 0.3 Parser Hardening from Real Samples — In Progress + +**Expected outcome**: existing Windows and IME parsers fail less often on real +samples and produce fewer generic or misclassified events. + +- Use real investigation samples to tighten detection, multiline handling, + timestamp parsing, and severity mapping. +- Prioritize the parsers that already unlock common device investigations before + adding niche formats. +- Track unsupported line patterns and unknown source families so hardening work + stays sample-led. +- Current status: artifact-level parse diagnostics are live, and + CBS/DISM/Panther already salvage unexpected-level structural records more + cleanly. Remaining work is further sample-led hardening for other parser + families and IME rules. + +### 0.4 Registry Snapshot Support — Mostly Completed + +**Expected outcome**: registry evidence is ingested as structured device state +that can be queried, compared, and correlated with log timelines. -**Expected outcome**: current Intune diagnostics stays useful without becoming the primary feature track. +- Treat registry data as state snapshots, not as another line-log parser. +- Start with exported `.reg` and other practical snapshot inputs that show + enrollment, policy, and app-management state. +- Align collector output and manual bundle intake so registry snapshots land + under the same `evidence/registry/` shape. +- Normalize keys, values, and hives so policy, enrollment, and health views can + reference them directly. +- Support side-by-side comparison of intended state, effective state, and + observed failures where that evidence exists. +- Current status: exported `.reg` inspection and preview are shipped; richer + state comparison and correlation views remain open. + +### 0.5 Curated Event Log Intake — Mostly Completed + +**Expected outcome**: adjacent event evidence can be added to a local +investigation in a focused way without requiring a full generic event viewer +first. + +- Start with curated channels relevant to MDM, Autopilot, enrollment, BitLocker, + LAPS, and Defender. +- Prefer practical intake paths such as saved exports or offline investigation + artifacts before expanding to a broad live-channel browser. +- Align collector output and manual bundle intake so curated event exports land + under the same `evidence/event-logs/` shape. +- Correlate event IDs, levels, and timestamps back into the shared evidence + inventory and timelines. +- Current status: curated event-log exports are classified and previewed in the + bundle flow. Full event extraction and correlation remain open. + +### 0.6 Remote Collection Feeding Shared Bundle Shape — Not Started + +**Expected outcome**: evidence collected remotely still lands in the same +reviewable bundle shape as local manual copies and local script runs. + +- Keep remote execution focused on producing `manifest.json`, `notes.md`, and + the existing `evidence/` folder layout. +- Support Intune or other management delivery only as transport and execution + layers, not as a different evidence schema. +- Reuse the same intake, inventory, and provenance logic regardless of whether + the bundle was assembled locally or remotely. + +### 0.7 Intune Rule Maintenance from New Samples — In Progress + +**Expected outcome**: current Intune diagnostics stays useful without becoming +the primary feature track. - Keep refining IME rules only when new samples expose repeatable gaps. -- Expand remediation guidance only where evidence remains deterministic and reviewable. -- Avoid large new Intune-only UI work unless it directly supports the broader evidence-bundle intake flow. +- Expand remediation guidance only where evidence remains deterministic and + reviewable. +- Avoid large new Intune-only UI work unless it directly supports the broader + evidence-bundle intake flow. +- Current status: diagnostics coverage, provenance, repeated-failure grouping, + and evidence-based summaries are already stronger; more IME rule maintenance + remains sample-led rather than roadmap-led. --- ## 1. Parser Expansion -Current state: core CMTrace, simple, plain text, and timestamped parsing are in place. Additional Windows log families remain useful, but parser work should now be driven by real evidence intake and evidence gaps rather than format coverage for its own sake. +Current state: core CMTrace, simple, plain text, and timestamped parsing are in +place. Additional Windows log families remain useful, but parser work should now +be driven by real evidence intake and evidence gaps rather than format coverage +for its own sake. ### 1.1 CBS/Panther Format Family — P1 / M -**Covers**: `CBS.log`, `dism.log`, `DPX\setupact.log`, `setupact.log`, `setuperr.log`, `WinSetup`, `MoSetup` +**Covers**: `CBS.log`, `dism.log`, `DPX\setupact.log`, `setupact.log`, +`setuperr.log`, `WinSetup`, `MoSetup` Shared line pattern: @@ -119,7 +253,8 @@ YYYY-MM-DD HH:MM:SS, **Covers**: `setupapi.dev.log`, `setupapi.setup.log` -Section-delimited format using `>>>` and `<<<` markers rather than one entry per line. +Section-delimited format using `>>>` and `<<<` markers rather than one entry per +line. ```text >>> [Device Install (Hardware initiated) - USB\VID_045E&PID_07A5\...] @@ -138,7 +273,8 @@ Section-delimited format using `>>>` and `<<<` markers rather than one entry per - Model each section as a collapsed logical entry. - Use the section title as the message and the section type as the component. - Map `FAILURE` to Error and `SUCCESS` to Info. -- Tree-like expansion is optional follow-on UI work, not a prerequisite for parser support. +- Tree-like expansion is optional follow-on UI work, not a prerequisite for + parser support. ### 1.3 MSI Multi-Format Parser — P2 / M @@ -179,7 +315,8 @@ YYYY/MM/DD HH:MM:SS.mmmmmmm PID TID **Covers**: `C:\Windows\SoftwareDistribution\ReportingEvents.log` -Tab-delimited rows containing GUID, timestamp, event ID, category, level, update GUID, HRESULT, agent, status, operation, and message. +Tab-delimited rows containing GUID, timestamp, event ID, category, level, update +GUID, HRESULT, agent, status, operation, and message. **Implementation notes**: @@ -198,7 +335,8 @@ Self-describing format using a `#Fields:` header. - Parse the `#Fields:` header into a dynamic column map. - Skip comment lines beginning with `#`. - Map `DROP` to Warning and `ALLOW` to Info. -- Opening firewall logs should still explain that logging is often disabled by default. +- Opening firewall logs should still explain that logging is often disabled by + default. ### 1.7 XML Log Format — P3 / S @@ -208,7 +346,8 @@ Self-describing format using a `#Fields:` header. - Use `quick-xml`. - Map each `` element to one log entry. -- Default `Diagerr.xml` to Error and `Diagwrn.xml` to Warning unless the XML payload says otherwise. +- Default `Diagerr.xml` to Error and `Diagwrn.xml` to Warning unless the XML + payload says otherwise. ### 1.8 UTF-16 Encoded Logs — P3 / S @@ -218,7 +357,8 @@ Self-describing format using a `#Fields:` header. - Add UTF-16 LE BOM detection to the encoding path. - `PFRO.log` should parse operation lines and error codes. -- `SrtTrail.txt` should surface section and key-value content as structured entries. +- `SrtTrail.txt` should surface section and key-value content as structured + entries. ### 1.9 Auto-Detection Update @@ -241,59 +381,72 @@ Extend `parser/detect.rs` in roughly this priority order: ## 2. Intune Diagnostics -Current state: folder-based IME analysis, source provenance, sidecar-aware event extraction, timeline attribution, deterministic counters, issue clustering, and first-pass suggested fixes are already in place. This area is good enough for now; remaining work should be sample-driven maintenance plus correlation with broader device evidence. +Current state: folder-based IME analysis, source provenance, sidecar-aware event +extraction, timeline attribution, deterministic counters, issue clustering, and +first-pass suggested fixes are already in place. This area is good enough for +now; remaining work should be sample-driven maintenance plus correlation with +broader device evidence. ### 2.1 IME Rule Hardening from Real Samples — P1 / M -The core sidecar set is already ingested. The remaining gap is richer extraction and stronger evidence quality from the highest-value files in `C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\`, but that work should stay behind real sample demand instead of driving the roadmap on its own. - -| Log File | Diagnostic Value | Remaining Gap | -| --- | --- | --- | -| `AppWorkload.log` | Detailed Win32 and WinGet download, staging, and install flow | Better classification of stalled, partial, and retried download/install paths | -| `AppActionProcessor.log` | Assignment decisions, applicability, and workflow transitions | Better evidence for applicability and policy-evaluation failures | -| `AgentExecutor.log` | Script command lines, output, and exit codes | More reliable remediation of script-specific failure patterns | -| `HealthScripts.log` | Proactive remediation scheduling and execution | Better detection vs. remediation separation and recurring failure patterns | -| `ClientHealth.log` | Agent startup and service health | Low-volume but useful health evidence still missing | -| `ClientCertCheck.log` | Certificate validation | Specific certificate-failure signatures still missing | -| `DeviceHealthMonitoring.log` | Readiness and app-crash telemetry | Not yet tied into diagnostics summaries | -| `Sensor.log` | SensorFramework events | Likely lower-value unless sample sets prove otherwise | -| `Win32AppInventory.log` | Inventory scans | Useful for app state context, not yet correlated deeply | -| `ImeUI.log` | End-user notification flow | Nice context, low priority for root-cause diagnostics | +The core sidecar set is already ingested. The remaining gap is richer extraction +and stronger evidence quality from the highest-value files in +`C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\`, but that work should +stay behind real sample demand instead of driving the roadmap on its own. + +| Log File | Diagnostic Value | Remaining Gap | +| ---------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `AppWorkload.log` | Detailed Win32 and WinGet download, staging, and install flow | Better classification of stalled, partial, and retried download/install paths | +| `AppActionProcessor.log` | Assignment decisions, applicability, and workflow transitions | Better evidence for applicability and policy-evaluation failures | +| `AgentExecutor.log` | Script command lines, output, and exit codes | More reliable remediation of script-specific failure patterns | +| `HealthScripts.log` | Proactive remediation scheduling and execution | Better detection vs. remediation separation and recurring failure patterns | +| `ClientHealth.log` | Agent startup and service health | Low-volume but useful health evidence still missing | +| `ClientCertCheck.log` | Certificate validation | Specific certificate-failure signatures still missing | +| `DeviceHealthMonitoring.log` | Readiness and app-crash telemetry | Not yet tied into diagnostics summaries | +| `Sensor.log` | SensorFramework events | Likely lower-value unless sample sets prove otherwise | +| `Win32AppInventory.log` | Inventory scans | Useful for app state context, not yet correlated deeply | +| `ImeUI.log` | End-user notification flow | Nice context, low priority for root-cause diagnostics | **Implementation notes**: -- Keep `AppWorkload.log` first because service release 2408 shifted more download and install detail there. -- Bias toward explicit rule coverage for retries, stalled content, applicability rejection, timeout loops, and recurring remediation failures. +- Keep `AppWorkload.log` first because service release 2408 shifted more + download and install detail there. +- Bias toward explicit rule coverage for retries, stalled content, applicability + rejection, timeout loops, and recurring remediation failures. - Prefer evidence that can be quoted back to the user in the summary panel. ### 2.2 Curated Event Log Channel Integration — P1 / M -Event logs remain the largest adjacent diagnostic gap for MDM, Autopilot, BitLocker, LAPS, and Defender. +Event logs remain the largest adjacent diagnostic gap for MDM, Autopilot, +BitLocker, LAPS, and Defender. **Priority channels**: -| Channel | Use Case | -| --- | --- | -| `DeviceManagement-Enterprise-Diagnostics-Provider/Admin` | Primary MDM failures and CSP operations | -| `DeviceManagement-Enterprise-Diagnostics-Provider/Operational` | Ongoing MDM activity | -| `Microsoft-Windows-AAD/Operational` | Entra registration and token flow | -| `Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot` | Autopilot deployment events | -| `Microsoft-Windows-BitLocker/BitLocker Management` | Encryption and key rotation | -| `Microsoft-Windows-LAPS/Operational` | LAPS events | -| `Microsoft-Windows-SENSE/Operational` | Defender onboarding and connectivity | -| `Intune-Bootstrapper-Agent` | Autopilot Device Preparation | +| Channel | Use Case | +| ------------------------------------------------------------------- | --------------------------------------- | +| `DeviceManagement-Enterprise-Diagnostics-Provider/Admin` | Primary MDM failures and CSP operations | +| `DeviceManagement-Enterprise-Diagnostics-Provider/Operational` | Ongoing MDM activity | +| `Microsoft-Windows-AAD/Operational` | Entra registration and token flow | +| `Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot` | Autopilot deployment events | +| `Microsoft-Windows-BitLocker/BitLocker Management` | Encryption and key rotation | +| `Microsoft-Windows-LAPS/Operational` | LAPS events | +| `Microsoft-Windows-SENSE/Operational` | Defender onboarding and connectivity | +| `Intune-Bootstrapper-Agent` | Autopilot Device Preparation | **Implementation notes**: -- Start with curated exports and focused channel intake before building a broad event-log browser. -- Use the `windows` crate for native EVTX access on Windows where native channel access is needed. +- Start with curated exports and focused channel intake before building a broad + event-log browser. +- Use the `windows` crate for native EVTX access on Windows where native channel + access is needed. - Map Event ID, Level, and message text into the existing entry model. - Start with curated channel presets instead of a full generic event viewer. - Offline `.evtx` file support can follow later. ### 2.3 Autopilot Diagnostics Panel — P1 / L -Build a dedicated Autopilot view that correlates profile files, ESP state, relevant event logs, and setup logs. +Build a dedicated Autopilot view that correlates profile files, ESP state, +relevant event logs, and setup logs. **Panel goals**: @@ -320,7 +473,8 @@ Read structured device state from registry paths such as: **Implementation note**: -- Treat registry intake as structured state and correlation data, not as another log stream. +- Treat registry intake as structured state and correlation data, not as another + log stream. ### 2.5 macOS Intune Log Support — P3 / XL @@ -338,14 +492,19 @@ DateTime | Process | LogLevel | PID | Task | TaskInfo ### 2.6 Diagnostics Coverage and Guided Insight — P1 / M -The current diagnostics panel is useful, but it still needs better confidence and coverage reporting. +The current diagnostics panel is useful, but it still needs better confidence +and coverage reporting. **Remaining work**: -- Add per-file counts, oldest/newest timestamps, rotation awareness, and dominant-source reporting. -- Improve repeated-failure grouping for apps, scripts, downloads, and timeout loops. -- Expand rule-based suggested fixes only where known error codes or evidence are strong enough. -- Keep the output auditable with sections such as `Likely Cause`, `Evidence`, `Next Checks`, and `Suggested Fix`. +- Add per-file counts, oldest/newest timestamps, rotation awareness, and + dominant-source reporting. +- Improve repeated-failure grouping for apps, scripts, downloads, and timeout + loops. +- Expand rule-based suggested fixes only where known error codes or evidence are + strong enough. +- Keep the output auditable with sections such as `Likely Cause`, `Evidence`, + `Next Checks`, and `Suggested Fix`. --- @@ -377,7 +536,8 @@ Export the current filtered view to: ### 3.3 Preferences / Settings Dialog — P1 / M -Persist application settings such as highlight behavior, column state, window placement, theme, font size, recent files, and find history. +Persist application settings such as highlight behavior, column state, window +placement, theme, font size, recent files, and find history. **Implementation notes**: @@ -419,15 +579,18 @@ Allow resize, reorder, and show/hide column state with persistence. ### 4.1 Severity Quick-Filter Buttons — P0 / S -Add toolbar toggles for Errors, Warnings, and Info with count badges and keyboard shortcuts. +Add toolbar toggles for Errors, Warnings, and Info with count badges and +keyboard shortcuts. ### 4.2 Dark Theme — P1 / S -Add theme support using CSS custom properties plus System, Light, and Dark selection. +Add theme support using CSS custom properties plus System, Light, and Dark +selection. ### 4.3 Entry Count and Position Indicator — P1 / S -Show current position, total entries, filtered count, and severity totals in the status bar. +Show current position, total entries, filtered count, and severity totals in the +status bar. ### 4.4 Go-To Line / Go-To Timestamp — P2 / S @@ -439,11 +602,13 @@ Support session bookmarks, quick navigation, and a visible bookmark indicator. ### 4.6 Log Entry Detail Enhancement — P2 / S -Improve the info pane with structured parsed fields, source metadata, inline error lookup, and clickable URLs. +Improve the info pane with structured parsed fields, source metadata, inline +error lookup, and clickable URLs. ### 4.7 Tail Mode Indicator — P1 / S -Make live tail state obvious with a `LIVE` badge, jump-to-latest affordance, and entry-rate context. +Make live tail state obvious with a `LIVE` badge, jump-to-latest affordance, and +entry-rate context. --- @@ -466,15 +631,18 @@ Current coverage is still well below CMTrace's embedded set. - Consider generating from public Microsoft references where practical. - Use `FormatMessage` on Windows as a fallback for unknown codes. -- A data file may be easier to maintain than an ever-growing hardcoded Rust table. +- A data file may be easier to maintain than an ever-growing hardcoded Rust + table. ### 5.2 Inline Error Detection — P1 / S -Detect common hex and decimal error-code patterns in messages and expose hover or click lookup. +Detect common hex and decimal error-code patterns in messages and expose hover +or click lookup. ### 5.3 Error Code Hyperlinking — P2 / S -Link recognized codes to inline lookup and, where appropriate, external documentation for that error family. +Link recognized codes to inline lookup and, where appropriate, external +documentation for that error family. --- @@ -482,19 +650,23 @@ Link recognized codes to inline lookup and, where appropriate, external document ### 6.1 Incremental Parsing — P1 / M -Avoid full re-parse on each tail cycle by tracking byte offsets and appending only newly parsed entries. +Avoid full re-parse on each tail cycle by tracking byte offsets and appending +only newly parsed entries. ### 6.2 Background Parsing with Progress — P2 / M -For large files, show parse progress, stream results progressively, and allow cancellation. +For large files, show parse progress, stream results progressively, and allow +cancellation. ### 6.3 Parser Plugin System — P3 / XL -Custom parser definitions remain a long-term architectural option, not a near-term priority. +Custom parser definitions remain a long-term architectural option, not a +near-term priority. ### 6.4 Memory-Mapped File Reading — P3 / M -Use mapped I/O for very large files only if simpler parsing improvements stop being sufficient. +Use mapped I/O for very large files only if simpler parsing improvements stop +being sufficient. --- @@ -502,48 +674,54 @@ Use mapped I/O for very large files only if simpler parsing improvements stop be ### 7.1 Collect Diagnostics Bundle Support — P1 / L -Open remotely collected diagnostics bundles and expose logs, registry exports, event-log exports, and command output from one entry point, using the same evidence-bundle shape as the tracked template and local PowerShell collector. +Open remotely collected diagnostics bundles and expose logs, registry exports, +event-log exports, and command output from one entry point, using the same +evidence-bundle shape as the tracked template and local PowerShell collector. ### 7.2 MDM Diagnostic Report Viewer — P2 / M -Parse `MDMDiagHtmlReport.html` and `MDMDiagReport.xml` to surface policy values, enrollment variables, certificates, and conflicts. +Parse `MDMDiagHtmlReport.html` and `MDMDiagReport.xml` to surface policy values, +enrollment variables, certificates, and conflicts. ### 7.3 Graph API GUID Resolution — P2 / L -Optionally resolve extracted Intune app and policy GUIDs to friendly names through Microsoft Graph. +Optionally resolve extracted Intune app and policy GUIDs to friendly names +through Microsoft Graph. ### 7.4 Command-Line Interface — P2 / S -Support startup arguments for opening files, launching Intune analysis, and applying an initial filter. +Support startup arguments for opening files, launching Intune analysis, and +applying an initial filter. ### 7.5 Log File Health Check — P3 / S -When opening an IME directory, summarize rotation state, time coverage, large gaps, and size vs. configured limits. +When opening an IME directory, summarize rotation state, time coverage, large +gaps, and size vs. configured limits. --- ## 8. Prioritized Implementation Phases -These phases reflect unfinished work only and keep the near-term sequence aligned with the current evidence-bundle-first focus. +These phases now reflect what is still unfinished after the completed +evidence-intake pushes landed. -### Phase 1 — Evidence Intake Baseline +### Phase 1 — Remaining Push 3 Work Target: immediate next slice. Estimated effort: 3–5 days. -1. Sample intake baseline using the tracked template and script-produced bundle shape (0.1) -2. Evidence inventory and provenance foundation (0.2) -3. Remote collection feeding the shared bundle shape (0.6) -4. Collect diagnostics bundle support (7.1) +1. Parser hardening from real samples (0.3) +2. IME rule maintenance from new samples (0.7) +3. Log file health check (7.5) -### Phase 2 — Evidence Quality and Adjacent Evidence +### Phase 2 — Correlated Investigation Views Target: following release. Estimated effort: 1–2 weeks. -1. Parser hardening from real samples (0.3) -2. Registry snapshot support (0.4) -3. Curated event-log intake (0.5) -4. IME rule maintenance from new samples (0.7) -5. Event log channel integration foundation (2.2) +1. Collect diagnostics bundle support (7.1) +2. Event log channel integration foundation (2.2) +3. Autopilot diagnostics panel (2.3) +4. MDM policy viewer (2.4) +5. MDM diagnostic report viewer (7.2) ### Phase 3 — Workflow and Investigation UX @@ -561,14 +739,12 @@ Target: after intake and evidence quality land. Estimated effort: 2–3 weeks. Target: later follow-on release. Estimated effort: 2–3 weeks. -1. CBS/Panther parser (1.1) -2. WindowsUpdate.log parser (1.4) -3. SetupAPI section parser (1.2) -4. MSI multi-format parser (1.3) -5. ReportingEvents.log parser (1.5) -6. W3C extended log parser (1.6) -7. Autopilot diagnostics panel (2.3) -8. MDM diagnostic report viewer (7.2) +1. WindowsUpdate.log parser (1.4) +2. SetupAPI section parser (1.2) +3. MSI multi-format parser (1.3) +4. ReportingEvents.log parser (1.5) +5. W3C extended log parser (1.6) +6. Background parsing with progress (6.2) ### Phase 5 — Longer-Term Additions @@ -596,17 +772,45 @@ Target: no fixed timeline. Shipped work that should no longer appear as active roadmap items: -- The tracked evidence-bundle template lives under `templates/evidence-bundle/` and is intended to be copied to a local working folder outside the repo. -- The PowerShell collector at `scripts/collection/Invoke-CmtraceEvidenceCollection.ps1` produces the same high-level `manifest.json` + `notes.md` + `evidence/` bundle shape for local or remote execution. +- The tracked evidence-bundle template lives under `templates/evidence-bundle/` + and is intended to be copied to a local working folder outside the repo. +- The PowerShell collector at + `scripts/collection/Invoke-CmtraceEvidenceCollection.ps1` produces the same + high-level `manifest.json` + `notes.md` + `evidence/` bundle shape for local + or remote execution. +- Evidence bundle inspection is available from the app and now exposes artifact + intake classification, expected evidence, manifest and notes preview, and + parser-quality summary. +- Bundle inventory is surfaced through the shipped bundle summary dialog instead + of living only in the collector output. +- Registry snapshot exports can be inspected as structured adjacent evidence + from the bundle flow. +- Curated event-log exports can be classified and previewed from the same bundle + flow. +- Log, Intune, and dsregcmd workspaces now retain bundle provenance so + investigations can tell when results came from a bundle-backed source. +- Artifact-level parse diagnostics are available for recognized log artifacts. +- CBS, DISM, and Panther parsers now salvage structurally valid timestamped + records with unexpected level tokens instead of dropping them to raw fallback. - Log sources are first-class inputs: file, folder, and known platform presets. -- Folder open and file browsing are available in the main log workflow, including toolbar access. -- Known source presets include Windows IME logs and route through the shared source-loading flow. +- Folder open and file browsing are available in the main log workflow, + including toolbar access. +- Known source presets include Windows IME logs and route through the shared + source-loading flow. - Intune analysis accepts an IME folder path, not only a single file. -- IME folder discovery includes the documented sidecar bundle instead of narrowing to `IntuneManagementExtension*.log` only. -- Intune results retain and display expanded source-file provenance in the frontend model, summary/header, and event timeline. -- File-aware Intune extraction covers `AppWorkload.log`, `AppActionProcessor.log`, `AgentExecutor.log`, and `HealthScripts.log`. -- Sidecar events use more specific heuristic naming for install phases, policy evaluation, and detection/remediation context. -- Aggregated IME timelines sort by parsed timestamps and collapse duplicate completion events more reliably. -- Intune summary output includes deterministic counters for pending, timed out, failed downloads, successful downloads, and failed scripts. -- Intune diagnostics include evidence-based issue clustering, next checks, and rule-based suggested fixes where evidence is specific enough. -- Parser expansion has been intentionally deprioritized behind sample-driven Intune diagnostics refinement. +- IME folder discovery includes the documented sidecar bundle instead of + narrowing to `IntuneManagementExtension*.log` only. +- Intune results retain and display expanded source-file provenance in the + frontend model, summary/header, and event timeline. +- File-aware Intune extraction covers `AppWorkload.log`, + `AppActionProcessor.log`, `AgentExecutor.log`, and `HealthScripts.log`. +- Sidecar events use more specific heuristic naming for install phases, policy + evaluation, and detection/remediation context. +- Aggregated IME timelines sort by parsed timestamps and collapse duplicate + completion events more reliably. +- Intune summary output includes deterministic counters for pending, timed out, + failed downloads, successful downloads, and failed scripts. +- Intune diagnostics include evidence-based issue clustering, next checks, and + rule-based suggested fixes where evidence is specific enough. +- Parser expansion has been intentionally deprioritized behind sample-driven + evidence quality and correlated investigation work. diff --git a/cmtraceopen.code-workspace b/cmtraceopen.code-workspace new file mode 100644 index 000000000..876a1499c --- /dev/null +++ b/cmtraceopen.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ce6540c11..44df46685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "cmtrace-open", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cmtrace-open", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { + "@fluentui/react-components": "^9.73.3", "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", @@ -261,6 +262,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -309,6 +319,21 @@ "node": ">=6.9.0" } }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -751,6 +776,1621 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/devtools": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/devtools/-/devtools-0.2.3.tgz", + "integrity": "sha512-ZTcxTvgo9CRlP7vJV62yCxdqmahHTGpSTi5QaTDgGoyQq0OyjaVZhUhXv/qdkQFOI3Sxlfmz0XGG4HaZMsDf8Q==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/dom": "^1.0.0" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fluentui/keyboard-keys": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@fluentui/keyboard-keys/-/keyboard-keys-9.0.8.tgz", + "integrity": "sha512-iUSJUUHAyTosnXK8O2Ilbfxma+ZyZPMua5vB028Ys96z80v+LFwntoehlFsdH3rMuPsA8GaC1RE7LMezwPBPdw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/priority-overflow": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fluentui/priority-overflow/-/priority-overflow-9.3.0.tgz", + "integrity": "sha512-yaBC0R4e+4ZlCWDulB5S+xBrlnLwfzdg68GaarCqQO8OHjLg7Ah05xTj7PsAYcoHeEg/9vYeBwGXBpRO8+Tjqw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-accordion": { + "version": "9.9.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-accordion/-/react-accordion-9.9.2.tgz", + "integrity": "sha512-Mmi5nVKfQrBiBiD1JPVtCmIMrR1CpCy8hsWZLwv/pHt+uHHyW9HyrPXwiOitj3ookA5ec1kXyl34BN8RUi7DGQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-alert": { + "version": "9.0.0-beta.135", + "resolved": "https://registry.npmjs.org/@fluentui/react-alert/-/react-alert-9.0.0-beta.135.tgz", + "integrity": "sha512-Qkr89e6tl4q0fhzfx9Wzb3ltiqbFtZj7AhT+CHZdW0I6KtpfGmJnvzaqvz0KXMdrKROTgvkA1Ny3Epf9ortc0Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.10.2", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-icons": "^2.0.239", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-aria": { + "version": "9.17.10", + "resolved": "https://registry.npmjs.org/@fluentui/react-aria/-/react-aria-9.17.10.tgz", + "integrity": "sha512-KqS2XcdN84XsgVG4fAESyOBfixN7zbObWfQVLNZ2gZrp2b1hPGVYfQ6J4WOO0vXMKYp0rre/QMOgDm6/srL0XQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-avatar": { + "version": "9.10.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-avatar/-/react-avatar-9.10.2.tgz", + "integrity": "sha512-0qy3U1S80c2Z0A8O/3Ko8XmG4d/NCof1XZ1jclbneKLDT0PeoX3BUlDDgCalOEwb0s1x6TjLabam5FtY4E30cg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-badge": "^9.4.15", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-badge": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-badge/-/react-badge-9.4.15.tgz", + "integrity": "sha512-KgFUJHBHP76vE3EDuPg/ml7lGqxs9zJ634e+vtxn8D7ghCZ6h9P6A0WbmgsPcN6MZoBZYLzzYT3OJ6Vmu3BM8g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-breadcrumb": { + "version": "9.3.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-breadcrumb/-/react-breadcrumb-9.3.17.tgz", + "integrity": "sha512-POnwCFyvXabq7lNtJRslASNkrm0iRoXpnrWwh0LyBTFZRDiGDKaV18Bpk0UiuQNTUurVQiH513164XKHIP+d7Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-link": "^9.7.4", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-button": { + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.8.2.tgz", + "integrity": "sha512-T2xBn6s6DRNH17Y+kLO+uEOaRe89Q20WP1Rs6OzC45cSpOGc+q9ogbPbYBqU7Tr1fur+Xd8LRHxdQJ3j5ufbdw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-card": { + "version": "9.5.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-card/-/react-card-9.5.11.tgz", + "integrity": "sha512-0W3BmDER/aKx+7+ttGy+M6LO09DW7DkJlO8F0x13L1ssOVxJ0OhyhSGiCF0cJliOK1tiGPveYf6+X2xMq2MT6g==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-text": "^9.6.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-carousel": { + "version": "9.9.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-carousel/-/react-carousel-9.9.4.tgz", + "integrity": "sha512-mzGZUOe3tB+86/WPsQTgppYRoqeM1vl8LswISl7FVrxk7PREnzZLW4BEZnFOKuP29dThcjJNzF0mM/5kq1lKug==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "embla-carousel": "^8.5.1", + "embla-carousel-autoplay": "^8.5.1", + "embla-carousel-fade": "^8.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-checkbox": { + "version": "9.5.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-checkbox/-/react-checkbox-9.5.16.tgz", + "integrity": "sha512-jjbj5RTy78OzFT95zj6SI7RMV1JF7FLT1CiYIL13bFTsL9tiPyAqXRcdXGJOnt/EuyD3uKs2nyOu4M3QFVy0ng==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-color-picker": { + "version": "9.2.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-color-picker/-/react-color-picker-9.2.15.tgz", + "integrity": "sha512-RMmawl7g4gUYLuTQG2QwCcR9fGC+vDD+snsBlXtObpj/cKpeDmYif46g88pYv86jeIXY1zsjINmLpELmz+uFmw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.3.4", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-combobox": { + "version": "9.16.18", + "resolved": "https://registry.npmjs.org/@fluentui/react-combobox/-/react-combobox-9.16.18.tgz", + "integrity": "sha512-nmyleswOSS9O/3gn8AWQ9Uuyis0WTHO1zZnDVapFUdgd2+hAcUSjJXPQv6NGftuUB5bgS2qAx9prRJg17ZrZvA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-components": { + "version": "9.73.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-components/-/react-components-9.73.3.tgz", + "integrity": "sha512-8JqxJuQmcBungWH8KxgBjiNe4sP5UXiiVWTqQGJ8l23gua3SC8uHufoOEKneEDWULR4HHJThscbqDLsGpkcJaw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-accordion": "^9.9.2", + "@fluentui/react-alert": "9.0.0-beta.135", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.2", + "@fluentui/react-badge": "^9.4.15", + "@fluentui/react-breadcrumb": "^9.3.17", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-card": "^9.5.11", + "@fluentui/react-carousel": "^9.9.4", + "@fluentui/react-checkbox": "^9.5.16", + "@fluentui/react-color-picker": "^9.2.15", + "@fluentui/react-combobox": "^9.16.18", + "@fluentui/react-dialog": "^9.17.2", + "@fluentui/react-divider": "^9.6.2", + "@fluentui/react-drawer": "^9.11.5", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-image": "^9.3.15", + "@fluentui/react-infobutton": "9.0.0-beta.112", + "@fluentui/react-infolabel": "^9.4.17", + "@fluentui/react-input": "^9.7.16", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-link": "^9.7.4", + "@fluentui/react-list": "^9.6.11", + "@fluentui/react-menu": "^9.22.0", + "@fluentui/react-message-bar": "^9.6.21", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-nav": "^9.3.20", + "@fluentui/react-overflow": "^9.7.1", + "@fluentui/react-persona": "^9.6.2", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-progress": "^9.4.16", + "@fluentui/react-provider": "^9.22.15", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-rating": "^9.3.15", + "@fluentui/react-search": "^9.3.16", + "@fluentui/react-select": "^9.4.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-skeleton": "^9.5.0", + "@fluentui/react-slider": "^9.5.16", + "@fluentui/react-spinbutton": "^9.5.16", + "@fluentui/react-spinner": "^9.7.15", + "@fluentui/react-swatch-picker": "^9.5.0", + "@fluentui/react-switch": "^9.6.1", + "@fluentui/react-table": "^9.19.11", + "@fluentui/react-tabs": "^9.11.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-tag-picker": "^9.8.2", + "@fluentui/react-tags": "^9.7.17", + "@fluentui/react-teaching-popover": "^9.6.18", + "@fluentui/react-text": "^9.6.15", + "@fluentui/react-textarea": "^9.6.16", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-toast": "^9.7.15", + "@fluentui/react-toolbar": "^9.7.4", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-tree": "^9.15.13", + "@fluentui/react-utilities": "^9.26.2", + "@fluentui/react-virtualizer": "9.0.0-alpha.111", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-context-selector": { + "version": "9.2.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-context-selector/-/react-context-selector-9.2.15.tgz", + "integrity": "sha512-QymBntFLJNZ9VfTOaBn2ApUSSSC5UuDW8ZcgPJPA+06XEFH+U9Zny2d9QAg1xYNYwIGWahWGQ+7ATOuLxtB8Jw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0", + "scheduler": ">=0.19.0" + } + }, + "node_modules/@fluentui/react-dialog": { + "version": "9.17.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-dialog/-/react-dialog-9.17.2.tgz", + "integrity": "sha512-mZdKylSvh2fRf0e3wMX3ZNccb9DahsOE7A5Y9LG97ghYvndMBVG2YwScIzUFVvLS206ari6HMOl0lC5JRB1bKA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-divider": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-divider/-/react-divider-9.6.2.tgz", + "integrity": "sha512-jfHlpSoJys78STe/SSjqdcn+W7QjEO1xCGiedWp/MdTBi3pH5vEeYbt2u8RU+zP32IF0Clta85KsUEEG0DYELQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-drawer": { + "version": "9.11.5", + "resolved": "https://registry.npmjs.org/@fluentui/react-drawer/-/react-drawer-9.11.5.tgz", + "integrity": "sha512-eoZY+jKZwbJo1PUsb7Ico7u/8aObHL4BhPP6hd+HHNzB7seTpN7rLd0DpASLZsxJUy5yvch4QF2TrjOu6V8kRA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-dialog": "^9.17.2", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-field": { + "version": "9.4.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-field/-/react-field-9.4.16.tgz", + "integrity": "sha512-2mfuYGldeqr9Llt8QSfwdj1hQofScvNQ/1Rns9TE4QUP6cdqs3cPX2+FZNJzpgO9vq5bk0hJpKqo7lvXZdyEzw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-icons": { + "version": "2.0.321", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.321.tgz", + "integrity": "sha512-29aafyoCrpl0OoJk0tNbHAFj/eOza3y9cljW2NurUF9+TcVmYpdLKIlcRFMWPFw7QeKhEbvWey3Lt8dzHWKs+A==", + "license": "MIT", + "dependencies": { + "@griffel/react": "^1.6.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-image": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-image/-/react-image-9.3.15.tgz", + "integrity": "sha512-k8ftGUc5G3Hj5W9nOFnWEKZ1oXmoZE3EvAEdyI6Cn9R8E6zW2PZ1+cug0p6rr01JCDG8kbry1LAITcObMrlPdw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infobutton": { + "version": "9.0.0-beta.112", + "resolved": "https://registry.npmjs.org/@fluentui/react-infobutton/-/react-infobutton-9.0.0-beta.112.tgz", + "integrity": "sha512-Fhqoc6b1MQtHW+Mm5sBhfa5ZrRdOV4azuUa5WyBvwD4Ozq/z2pBOC/wi/A/WCjKMnGoMlQ2CggoLaMhQmenzAQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.237", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infolabel": { + "version": "9.4.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-infolabel/-/react-infolabel-9.4.17.tgz", + "integrity": "sha512-zLw52jn2wAuEKWFzaNj3aKhuB4BAEI8LqblryCg0LKPKHcv/z9d9RllCqcVz+ngdK1tQGtCIPH/wxNlZXx/I3Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-input": { + "version": "9.7.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-input/-/react-input-9.7.16.tgz", + "integrity": "sha512-dr6tBWbyDiP2KR7LDvJlxFwxucWfeFETumFo3fAtUSpjbTHMG0ZShh3cq0/c7Gqvq/ypl12jVB1Tj6E4RimV8g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-jsx-runtime": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-jsx-runtime/-/react-jsx-runtime-9.4.1.tgz", + "integrity": "sha512-ZodSm7jRa4kaLKDi+emfHFMP/IDnYwFQQAI2BdtKbVrvfwvzPRprGcnTgivnqKBT1ROvKOCY2ddz7+yZzesnNw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-label": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-label/-/react-label-9.3.15.tgz", + "integrity": "sha512-ycmaQwC4tavA8WeDfgcay1Ywu/4goHq1NOeVxkyzWTPGA7rs+tdCgdZBQZLAsBK2XFaZiHs7l+KG9r1oIRKolA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-link": { + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-link/-/react-link-9.7.4.tgz", + "integrity": "sha512-ILKFpo/QH1SRsLN9gopAyZT/b/xsGcdO4JxthEeuTRvpLD6gImvRplum8ySIlbTskVVzog6038bHUSYLMdN7OA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-list": { + "version": "9.6.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-list/-/react-list-9.6.11.tgz", + "integrity": "sha512-ao1WdgWDrz4mTvic3dOD3Jk1V9XcppxX3Y3DI7Emsw2QI9Y2AsZBtiUrqYNEQ0ym3yFobURYJ3ZIhrW11VCKAw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-checkbox": "^9.5.16", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-menu": { + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-menu/-/react-menu-9.22.0.tgz", + "integrity": "sha512-RPZvqHsxMDEArsz80mJabs1fVGPlCrhMntzM/wt3Bga+fyPv4yEuDdN5FB8JqUpIAjRZneiW0RLC0Mr3WqmatA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-message-bar": { + "version": "9.6.21", + "resolved": "https://registry.npmjs.org/@fluentui/react-message-bar/-/react-message-bar-9.6.21.tgz", + "integrity": "sha512-Vba3+7+TuzH2Ma6YB/Sd5dy+dm4DWwacZc0a78CetVqCzYZ4u/5opdmiBs8JY1Qr8uYW38siHLbY8kLnu6OOjA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-link": "^9.7.4", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.13.0.tgz", + "integrity": "sha512-YdOpW6e7qfvzoWKcqh8hReCqwYEoiEmNBcCprGaupKjWOi9jBbF/JESM1AHI9nOjPd8aY90WUG2+ahvrqfL9LA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion-components-preview": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.2.tgz", + "integrity": "sha512-KqHRV8lLmVwOWiHBdpUFA+TwMbuYu9cyzNvmhbMFLVKzZyr3MPgN+97Tf+6QYPf22o99SMT0BPySDv/HiNYanA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-motion": "*", + "@fluentui/react-utilities": "*", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-nav": { + "version": "9.3.20", + "resolved": "https://registry.npmjs.org/@fluentui/react-nav/-/react-nav-9.3.20.tgz", + "integrity": "sha512-YIObOcR92Nz4OUePrDhRdLQ5m9ph0y+U7U9NYgE/XFrLtWl+uqUS7u36m3NJl9QGgZVpUHO4nbNjizGLkncCCA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-divider": "^9.6.2", + "@fluentui/react-drawer": "^9.11.5", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-overflow": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-overflow/-/react-overflow-9.7.1.tgz", + "integrity": "sha512-Ml1GlcLrAUv31d9WN15WGOZv32gzDtZD5Mp1MOQ3ichDfTtxrswIch7MDzZ8hLMGf/7Y2IzBpV8iFR1XdSrGBA==", + "license": "MIT", + "dependencies": { + "@fluentui/priority-overflow": "^9.3.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-persona": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-persona/-/react-persona-9.6.2.tgz", + "integrity": "sha512-60kOmljlYjUiySWDN1bZh1FB4C7jbJS2dobtBJQh5agnKg34p3egO+6MwsBHRcwaGhVMh4T8XcbE6t2hw+iqyQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.10.2", + "@fluentui/react-badge": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-popover": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.14.0.tgz", + "integrity": "sha512-XrZlSfSYhA12j5bna4Sq8N/If2vul7gl8woVrN8U3iQUjdaHB6OAMZ/WMNUdMm35Z+4e4rHClAZxU2dUsbHrmw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-portal": { + "version": "9.8.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-portal/-/react-portal-9.8.11.tgz", + "integrity": "sha512-2eg4MdW7e2UGRYWPg05GCytAjWYNd55YOP9+iUDINoQwwto9oeFTtZRyn08HYw37cSNqoH24qGz/VBctzTkqDA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-positioning": { + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-positioning/-/react-positioning-9.22.0.tgz", + "integrity": "sha512-i3DLC4jd4MoYSZMYLKQNUTpkjKAJ0snIcihvkrjt2jpvv34CifKJhqVtjFQ470pRW4XNx/pBBX07vdXpA3poxA==", + "license": "MIT", + "dependencies": { + "@floating-ui/devtools": "^0.2.3", + "@floating-ui/dom": "^1.6.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-progress": { + "version": "9.4.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-progress/-/react-progress-9.4.16.tgz", + "integrity": "sha512-IWVuD1hQoyIBK+RIGOCTc3HUPkdtOQghJPZ5uGwRrUlxGgpUV1h7rdAApiuQTWitrFfN6bP4PrsJmHT2DM2OFw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-provider": { + "version": "9.22.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-provider/-/react-provider-9.22.15.tgz", + "integrity": "sha512-a+ImgL9DOlylDM4UYPnxQTA3yXxbVj+O0iNEyTZ6fMzdMsHzpALU4GAq6tOyW4L7RaQtRBmNpVfwTCEKpqaTJQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/core": "^1.16.0", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-radio": { + "version": "9.5.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-radio/-/react-radio-9.5.16.tgz", + "integrity": "sha512-xHRqm+MTkIf6JLEz/dMLlHSL9X+ysXAkig+VOV5QTPZwDIr3SqfJVvBmLNUVmtzf+cmWsRKrrIbVGpFGo/CvxA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-rating": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-rating/-/react-rating-9.3.15.tgz", + "integrity": "sha512-MH/Jgoco8p+haf1d5Gi+d5VCjwd0qE6y/uP0YJsB9m11+DFnDxgKhzJKIiIzs3yzB2M4bMM8z9SqEHzQGCQEPg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-search": { + "version": "9.3.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-search/-/react-search-9.3.16.tgz", + "integrity": "sha512-7dKzGqIXzfhYxIKI1arGARkUDyQHYfwArlR6jKrhmYppXJh7U174xsjkMH62B78rDdNVer3G38MXXjpQ5MvNAQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-input": "^9.7.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-select": { + "version": "9.4.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-select/-/react-select-9.4.16.tgz", + "integrity": "sha512-YsHMZsiKxH8suBtNTBXhtsvjM0u9UUXH641cEumgtjUz7SzeKNc/cWToLVyNz7GIoANL49rvubkByTeAQVCo2g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-shared-contexts": { + "version": "9.26.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-shared-contexts/-/react-shared-contexts-9.26.2.tgz", + "integrity": "sha512-upKXkwlIp5oIhELr4clAZXQkuCd4GDXM6GZEz8BOmRO+PnxyqmycCXvxDxsmi6XN+0vkGM4joiIgkB14o/FctQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-theme": "^9.2.1", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-skeleton": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-skeleton/-/react-skeleton-9.5.0.tgz", + "integrity": "sha512-hYmtzFV47HezmW+v6EcHJOz560uuBahn3iZQpUrfyOmKFMM5Ou1Hc1lq62vuxuA9pybEqwZsaMRydGP3Ms23YQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-slider": { + "version": "9.5.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-slider/-/react-slider-9.5.16.tgz", + "integrity": "sha512-IgFdKcnX1KXLpfaB9/CYPgAmC7lfJ0FGEl1Y1uHYiL2YV6Dc+4yoAsCBABC1/KcEeafqCiaFTdNhS62QRK7Tbg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinbutton": { + "version": "9.5.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinbutton/-/react-spinbutton-9.5.16.tgz", + "integrity": "sha512-V4U9PSJM26BXrFqJ9K/VYYQeusBf8ldx5KOlZZ7hRamPsKTS5hyytWrF39lTLqCRlGckXPCLNzJpb1DLB+ID1g==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinner": { + "version": "9.7.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinner/-/react-spinner-9.7.15.tgz", + "integrity": "sha512-ZMJ7y08yvVXL9HuiMLLCy1cRn8plR9A4mL57CM2/otaXVWQbOwRaFD0/+Dx3u9A8sEtdYLo6O9gJIjU8fZGaYw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-swatch-picker": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-swatch-picker/-/react-swatch-picker-9.5.0.tgz", + "integrity": "sha512-sl7MifqQGR4QGDhhgBIYc25YgPuFQW7+BOfNRMO5DYPq33lX5xHNcczhXywcBESAVHrjM0MC1lsE7glv6gU8RA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-switch": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-switch/-/react-switch-9.6.1.tgz", + "integrity": "sha512-UVHJViXSR5jrNyjtU3yqhr1F14TbY8V59wMw9N1vP027ztrLx3Q30sEt0xG1TXv5BoAERnXhHws9HVIxBpRvEA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-table": { + "version": "9.19.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-table/-/react-table-9.19.11.tgz", + "integrity": "sha512-0ivIFR2JAp3HYlPnDrV5axBaOH06wtsQArBSOw6HXbQEz9JQ8Gi9SqEhQo6DBQ1/pcY3XeZjP+3r2HoFZXGaqA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.2", + "@fluentui/react-checkbox": "^9.5.16", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabs": { + "version": "9.11.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabs/-/react-tabs-9.11.2.tgz", + "integrity": "sha512-zmWzySlPM9EwHJNW0/JhyxBCqBvmfZIj1OZLdRDpbPDsKjhO0aGZV6WjLHFYJmq58kbN0wHKUbxc7LfafHHUwA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabster": { + "version": "9.26.13", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabster/-/react-tabster-9.26.13.tgz", + "integrity": "sha512-uOuJj7jn1ME52Vc685/Ielf6srK/sfFQA5zBIbXIvy2Eisfp7R1RmJe2sXWoszz/Fu/XDkPwdM/GLv23N3vrvQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "keyborg": "^2.6.0", + "tabster": "^8.5.5" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tag-picker": { + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-tag-picker/-/react-tag-picker-9.8.2.tgz", + "integrity": "sha512-j8a9X3jychd9KQ7uhzjoyDT8hcAH40d+ZeHXCLQ8PcYfDdoZSDWcmLNc+xCGmlf+UkhWQU1Ks7hdWqBjGpr0MA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-combobox": "^9.16.18", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-tags": "^9.7.17", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tags": { + "version": "9.7.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-tags/-/react-tags-9.7.17.tgz", + "integrity": "sha512-LCJJqoXIiN+aNqFHC/5nddsQJqh56xzrywwpMbMrQYI/dbIk5UYlmZ6arIPhQ9HVKat3YzGKAvOGlhFhEHIwDg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.2", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-teaching-popover": { + "version": "9.6.18", + "resolved": "https://registry.npmjs.org/@fluentui/react-teaching-popover/-/react-teaching-popover-9.6.18.tgz", + "integrity": "sha512-cf76vSRZs40geZEw/RChfQvu6ioMyFKR0qvPc52QstPDC/cgGkOg+45G7SZo11IpYwBdkpUVWasnWUWSxTMiHw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-text": { + "version": "9.6.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-text/-/react-text-9.6.15.tgz", + "integrity": "sha512-YB1azhq8MGfnYTGlEAX1mzcFZ6CvqkkaxaCogU4TM9BtPgQ1YUAxE01RMenl8VVi8W9hNbJKkuc8R8GzYwzT4Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-textarea": { + "version": "9.6.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-textarea/-/react-textarea-9.6.16.tgz", + "integrity": "sha512-d72Ufs//9T+X7lIrY1D28/9BiVqtKSjZ5hHVgBnJJwuPSFAKn5b4jlysXkNKHEdMjJz57kYMK4Ieneyz+Xkhrw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-theme": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-theme/-/react-theme-9.2.1.tgz", + "integrity": "sha512-lJxfz7LmmglFz+c9C41qmMqaRRZZUPtPPl9DWQ79vH+JwZd4dkN7eA78OTRwcGCOTPEKoLTX72R+EFaWEDlX+w==", + "license": "MIT", + "dependencies": { + "@fluentui/tokens": "1.0.0-alpha.23", + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-toast": { + "version": "9.7.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-toast/-/react-toast-9.7.15.tgz", + "integrity": "sha512-iuk4rf/WumpGrNIpRVLNamlPBY0rT9BhI4qTnVmzXqz5pY+8GmAq/TKUPER9/withtQW8V9srj91FWblxzpHRg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-toolbar": { + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-toolbar/-/react-toolbar-9.7.4.tgz", + "integrity": "sha512-cpr+vJAzHJckN4S+JFSIeH4cg6q8pQuLVldH3ETrtNnWKERHeiY9ljAq3fbi/fU7ohgDit0DZnWUACrNu0pQQA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-divider": "^9.6.2", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tooltip": { + "version": "9.9.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-tooltip/-/react-tooltip-9.9.3.tgz", + "integrity": "sha512-a351JFoaBAOn0SnQ76tzuNv2ieHzAS+VO8Ncy4m9/emrIs5lvBBfKX8fvA4/efVxY+683XEQdoL1LuApuJuTWw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tree": { + "version": "9.15.13", + "resolved": "https://registry.npmjs.org/@fluentui/react-tree/-/react-tree-9.15.13.tgz", + "integrity": "sha512-ITT8SlYXfG+Wi0FYPJOqwROTa6Po2VZEtolUq9jPjMy5/q+Vto++fdHyWaVn3a5Joq6w576RDP1ZnlS7qoFPgg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.2", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-checkbox": "^9.5.16", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-utilities": { + "version": "9.26.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-utilities/-/react-utilities-9.26.2.tgz", + "integrity": "sha512-Yp2GGNoWifj8Z/VVir4HyRumRsqXnLJd4IP/Y70vEm9ruAvyqUvfn+1lQUuA+k/Reqw8GI+Ix7FTo3rogixZBg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-shared-contexts": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-virtualizer": { + "version": "9.0.0-alpha.111", + "resolved": "https://registry.npmjs.org/@fluentui/react-virtualizer/-/react-virtualizer-9.0.0-alpha.111.tgz", + "integrity": "sha512-yku++0779Ve1RNz6y/HWjlXKd2x1wCSbWMydT2IdCICBVwolXjPYMpkqqZUSjbJ0N9gl6BfsCBpU9Dfe2bR8Zg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/tokens": { + "version": "1.0.0-alpha.23", + "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", + "integrity": "sha512-uxrzF9Z+J10naP0pGS7zPmzSkspSS+3OJDmYIK3o1nkntQrgBXq3dBob4xSlTDm5aOQ0kw6EvB9wQgtlyy4eKQ==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@griffel/core": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.20.1.tgz", + "integrity": "sha512-ld1mX04zpmeHn8agx4slSEh8kJ+8or3Y0x9gsJNKSKn6GdCkZBSiGUh+oBXCBn8RKzz8l60TA9IhVSStnyKekA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@griffel/style-types": "^1.4.0", + "csstype": "^3.1.3", + "rtl-css-js": "^1.16.1", + "stylis": "^4.2.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@griffel/react": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.6.1.tgz", + "integrity": "sha512-mNM4/+dIXzqeHboWpVZ1/jiwTAYNc5/8y/V/HasnQ2QXnV6gSUYpeUk/0n6IFU3NJmVJly9JrLSfNo0hM/IFeA==", + "license": "MIT", + "dependencies": { + "@griffel/core": "^1.20.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@griffel/style-types": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.0.tgz", + "integrity": "sha512-vNDfOGV7RN/XkA7vxgf7Z5HgW8eiBm5cHT9wQPhsKB4pxWom5u6eQ9CkYE5mCCTSPl9H6Nd1NBai04d4P6BD7Q==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1158,6 +2798,15 @@ "win32" ] }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.21", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz", @@ -1495,7 +3144,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1505,7 +3153,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1611,7 +3258,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1639,6 +3285,30 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-fade": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-fade/-/embla-carousel-fade-8.6.0.tgz", + "integrity": "sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1767,6 +3437,12 @@ "node": ">=6" } }, + "node_modules/keyborg": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/keyborg/-/keyborg-2.6.0.tgz", + "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1935,6 +3611,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1961,6 +3646,41 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/tabster": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.7.0.tgz", + "integrity": "sha512-AKYquti8AdWzuqJdQo4LUMQDZrHoYQy6V+8yUq2PmgLZV10EaB+8BD0nWOfC/3TBp4mPNg4fbHkz6SFtkr0PpA==", + "license": "MIT", + "dependencies": { + "keyborg": "2.6.0", + "tslib": "^2.8.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.53.3" + } + }, + "node_modules/tabster/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1978,6 +3698,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2023,6 +3749,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index b453b5b1a..64205491a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "frontend:preview": "vite preview", "app:dev": "tauri dev", "app:build:debug": "tauri build --debug", + "app:build:exe-only": "tauri build --no-bundle", "app:build:release": "tauri build", "dev": "npm run frontend:dev", "build": "npm run frontend:build", @@ -18,6 +19,7 @@ "license": "MIT", "type": "module", "dependencies": { + "@fluentui/react-components": "^9.73.3", "@tanstack/react-virtual": "^3.13.21", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", @@ -35,4 +37,4 @@ "typescript": "^5.9.3", "vite": "^7.3.1" } -} \ No newline at end of file +} diff --git a/plan-winEvtLiveEventLogsAndLocalTimeDisplay.prompt.md b/plan-winEvtLiveEventLogsAndLocalTimeDisplay.prompt.md new file mode 100644 index 000000000..eac2db269 --- /dev/null +++ b/plan-winEvtLiveEventLogsAndLocalTimeDisplay.prompt.md @@ -0,0 +1,88 @@ +## Plan: WinEvt Live Event Logs And Local Time Display + +Replace the current live Windows Event Log path in the Intune workflow with official WinEvt bindings from the windows crate, while preserving the existing evidence-bundle .evtx path. At the same time, make displayed dates and times resolve in the end user's system timezone instead of relying on naive timestamp strings. The backend change should stay isolated behind the current live-vs-bundle decision point, and the UI should be updated to show live event-log query status and results in the new Intune workspace and left sidebar so users can tell that event channels were actually queried. + +**Steps** + +1. Phase 1: Lock the live-query boundary. Reuse the existing decision point in c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\commands\intune.rs inside load_event_log_analysis so only the live path changes. Keep parse_bundle_event_logs and downstream correlation logic unchanged. This step blocks all later backend work. +2. Phase 2: Add WinEvt dependencies and wrapper module. Update c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\Cargo.toml to add the windows crate with the minimum required features for Win32 event logging and foundation error handling. Create a focused Windows-only wrapper module, preferably c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\intune\eventlog_win32.rs, to own UTF-16 conversion, Win32 error mapping, and RAII handle cleanup via EvtClose. This depends on step 1. +3. Phase 3: Implement live channel querying through WinEvt. In the new wrapper module, implement local-machine querying for the existing curated channel list using EvtQuery and EvtNext. Keep the current channel list in c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\intune\evtx_parser.rs unless there is a deliberate product change. Query each channel independently so access failures or missing channels produce partial results instead of aborting the full live analysis. This depends on step 2. +4. Phase 4: Render event records into the existing EventLogEntry model. Prefer extracting stable system properties through WinEvt rather than regex over shell output. Use EvtCreateRenderContext plus EvtRender for structured system fields such as provider, event id, level, channel, computer, activity id, and timestamp. For message text, use EvtOpenPublisherMetadata and EvtFormatMessage as a best-effort path; if message formatting fails because publisher metadata is unavailable or access is restricted, fall back to a synthesized message built from event data or XML so entries still surface in the UI. This depends on step 3. +5. Phase 5: Normalize timestamps and preserve current correlation behavior. Convert WinEvt timestamps to the same UTC ISO-8601 shape currently expected by EventLogEntry and the correlation code. Keep ordering, severity mapping, and event id assignment stable so c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\commands\intune.rs and the existing event-log correlation helpers continue to work without schema changes. This depends on step 4. +6. Phase 6: Make frontend display use the viewer's system timezone as the source of truth. Update the display path centered on c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\lib\date-time-format.ts so UI formatting prefers timezone-aware values or UTC-normalized instants instead of reparsing naive display strings as local dates. The target behavior is that rendered dates and times in the Intune workspace, event-log views, and timeline surfaces show in the current user's system timezone. +7. Phase 7: Normalize timezone handling for Intune and other timeline inputs. Review c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\intune\timeline.rs and any event serialization paths that still emit naive timestamps, then standardize them to emit timezone-safe values that the frontend can consistently convert into the viewer's local timezone. This depends on steps 5 and 6. +8. Phase 8: Surface live event-log progress and results in the UI. Keep the existing event-log surface in c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\intune\EventLogSurface.tsx and store plumbing in c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\stores\intune-store.ts, but update c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\layout\FileSidebar.tsx to show live event-log status, queried channel count, and signal counts alongside the IME file list. Also confirm c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\intune\NewIntuneWorkspace.tsx exposes clearer status when live event-log parsing is in progress or when zero accessible entries are returned. This can begin in parallel with step 6 once the returned metadata shape is confirmed. +9. Phase 9: Harden error handling and diagnostics. Distinguish between no entries, inaccessible channel, missing channel, and query failure in backend logs and progress messages. Also distinguish between source timestamps that were timezone-aware and source timestamps that were only local/naive so the display layer does not silently misrepresent time. This depends on steps 6 through 8. +10. Phase 10: Add validation and regression coverage. Add or update Rust tests around severity mapping, timestamp normalization, and partial-channel failure handling. Add frontend validation around local-time rendering in c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\lib\date-time-format.ts and verify key surfaces such as the Intune timeline and Event Log Evidence use the end user's system timezone consistently. This depends on steps 6 through 9. + +**Relevant files** + +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\Cargo.toml — add the windows crate and required Win32 feature flags for event log access and error handling. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\commands\intune.rs — keep load_event_log_analysis as the stable boundary between bundle and live event-log modes. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\intune\evtx_parser.rs — retain bundle parsing and shared EventLogAnalysis assembly; replace only the live parser internals. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\intune\models.rs — preserve EventLogEntry and EventLogAnalysis shapes so frontend/state code does not need schema churn. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\layout\Toolbar.tsx — keep the existing includeLiveEventLogs trigger for the known live Intune source. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\intune\NewIntuneWorkspace.tsx — keep the live-analysis entry point and tighten user-facing status around event-log querying outcomes. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\intune\EventLogSurface.tsx — reuse the existing event-log display surface without changing its contract. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\lib\date-time-format.ts — make frontend display formatting use timezone-aware or UTC-normalized values and render them in the viewer's local timezone. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src-tauri\src\intune\timeline.rs — remove or isolate naive timestamp parsing so timeline bounds and emitted values can be displayed correctly in the user's system timezone. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\intune\EventTimeline.tsx — confirm timeline rendering consumes the corrected display formatter without reintroducing local-string parsing. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\components\layout\FileSidebar.tsx — add event-log visibility to the left sidebar for live analysis. +- c:\Users\AdamGell\Documents\GitHub\cmtraceopen\src\stores\intune-store.ts — reuse existing eventLogAnalysis storage and selection/filter state. + +**Verification** + +1. Build the Rust backend on Windows and confirm the new windows crate features compile cleanly with the existing Tauri target. +2. Run the live Intune analysis flow from the known source and verify that Querying live Windows Event Logs progress is emitted and the resulting eventLogAnalysis is non-null when accessible channels contain matching entries. +3. Confirm that partial failures still produce visible results when some channels are inaccessible or empty. +4. Verify the Event Log Evidence surface renders entries, channel summaries, and correlations without any frontend schema changes. +5. Verify the left sidebar shows event-log query status and event-log counts in the new Intune workspace, not only IME log files. +6. Run the existing frontend build and Rust tests, then add focused backend tests for timestamp conversion, severity mapping, and per-channel error handling. +7. Verify that the Intune timeline, dashboard timestamp summaries, and event-log rows all display in the viewer's system timezone on the machine running CMTrace Open. +8. Verify that UTC-labeled or UTC-normalized backend values are converted for display rather than shown as raw naive strings. + +**Decisions** + +- In scope: replacing only the live Windows Event Log query path with official WinEvt bindings. +- In scope: preserving the current bundle .evtx parsing path and current EventLogAnalysis schema. +- In scope: exposing live event-log activity in the sidebar and workspace status so the user can see that channels were queried. +- In scope: making user-facing date and time rendering resolve in the system timezone of the machine running the app. +- In scope: replacing naive string-based display parsing with timezone-safe values where available. +- Out of scope: preserving source-machine wall-clock display when it conflicts with the viewer-local requirement, unless a later product decision adds an explicit toggle. +- Out of scope: changing the curated channel list unless validation shows a concrete coverage gap. + +**Summary** +Accepted defaults: live event-log queries should return a successful empty result instead of null when nothing matches; the backend should track both channels attempted and channels that produced entries; timestamps should only be converted to viewer-local time when the source instant is timezone-aware or normalized to UTC; timestamps with unknown timezone should remain visible as raw wall-clock values with uncertainty surfaced; and the first WinEvt implementation should preserve the current effective per-channel cap and general correlation behavior. With those decisions locked, the revised scope becomes a focused backend contract change plus a timestamp-normalization cleanup across all consumers, not just a parser swap and formatter tweak. + +**Implementation steps** + +1. Keep the current live-versus-bundle boundary unchanged at the existing decision point in intune.rs. Only the live event-log implementation changes; the evidence-bundle EVTX path and downstream correlation entry points stay intact. +2. Replace the current live event-log transport in evtx_parser.rs with a Windows-only WinEvt wrapper module that owns query execution, UTF-16 conversion, handle cleanup, structured property rendering, and Win32 error mapping. +3. Extend the live event-log result contract in models.rs and the matching frontend types in event-log.ts to carry live-query metadata. That metadata should include at minimum channelsAttempted, channelsSucceeded, channelsWithResults, channelsEmpty, channelsInaccessible, channelsMissing, channelsFailed, and per-channel outcome details. +4. Change live query semantics so a successful query run with zero matching entries still returns an EventLogAnalysis object. The object should contain zero entries and zero counts plus live-query metadata, allowing the UI to distinguish queried-but-empty from not-run or failed. +5. Preserve the existing curated channel list for the initial WinEvt migration. Query each channel independently, tolerate partial failure, and keep the current effective recent-entry cap per channel so the first migration does not also change product volume or performance characteristics. +6. Build event records from WinEvt system properties using structured rendering rather than XML scraping. The backend should extract provider, event id, level, channel, computer, activity id, and timestamp directly from WinEvt-rendered system properties, then best-effort format the message through publisher metadata with a documented fallback path when formatting fails. +7. Preserve the current EventLogEntry and correlation behavior wherever possible. Event timestamps should remain normalized to UTC ISO-8601 strings, severity mapping should stay stable, and correlation thresholds in evtx_parser.rs should not change in this scope unless normalization exposes a correctness bug. +8. Expand the timezone work beyond the shared formatter in date-time-format.ts. Unify timestamp interpretation across the backend timeline parser in timeline.rs, dashboard filtering in IntuneDashboard.tsx, and timestamp-bound comparison helpers in intune-store.ts. +9. Treat timestamp confidence explicitly. If a timestamp is already UTC or carries an offset, render it in the viewer’s system timezone. If a timestamp is naive and the source timezone is unknown, preserve the raw time string and surface that it is timezone-uncertain rather than silently interpreting it as trustworthy local time. +10. Keep the current UTC-first IME event pipeline intact where it already exists, especially in event_tracker.rs and download_stats.rs, and focus the remaining work on downstream consumers that still parse or compare timestamps naively. +11. Update the UI contract in NewIntuneWorkspace.tsx, FileSidebar.tsx, and EventLogSurface.tsx so the app can show: + successful live query with entries + successful live query with zero entries + partial success with some inaccessible or missing channels + full failure +12. Add regression coverage for three areas: WinEvt severity and timestamp normalization, partial-channel failure handling with tolerated query errors, and timezone-safe ordering and filtering across timeline, dashboard, and event-log views. + +**Edge cases to handle** + +- Live query succeeds but every channel returns zero relevant entries. +- Some channels return entries while others are inaccessible, missing, or fail. +- Provider metadata lookup fails and event messages must fall back to synthesized text. +- Viewer-local rendering occurs on a machine in a different timezone from the evidence source. +- Naive timestamps cross daylight saving transitions and cannot be mapped confidently. +- Timeline ordering mixes RFC3339 UTC values with legacy naive strings. +- The sidebar and workspace must still show that querying occurred even when the event-log entry list is empty. +- The Windows-only WinEvt path must not break non-Windows builds or bundle-only workflows. + +**Open questions** +None are required to proceed with this scope. Optional later decisions, but not blockers for this revision, are whether to raise the per-channel live-query cap after migration and whether to add a future UI toggle for raw source wall-clock time versus viewer-local display. diff --git a/references/collection/Detect-CmtraceEvidenceBootstrap.ps1 b/references/collection/Detect-CmtraceEvidenceBootstrap.ps1 new file mode 100644 index 000000000..a9805a41c --- /dev/null +++ b/references/collection/Detect-CmtraceEvidenceBootstrap.ps1 @@ -0,0 +1,180 @@ +[CmdletBinding()] +param( + [string]$StatePath = (Join-Path $env:ProgramData 'CmtraceOpen\State\collection-bootstrap.json'), + [string]$TaskName = 'CmtraceOpen-EvidenceCollection-Once', + [int]$ThrottleHours = 0 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-DetectionResult { + param( + [Parameter(Mandatory = $true)] + [int]$ExitCode, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Output $Message + exit $ExitCode +} + +function Get-ForwardedArgumentList { + $argumentList = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $PSBoundParameters.GetEnumerator()) { + $argumentList.Add('-{0}' -f $entry.Key) + $argumentList.Add([string]$entry.Value) + } + + return $argumentList +} + +function Invoke-In64BitPowerShell { + if (-not [Environment]::Is64BitOperatingSystem) { + return + } + + if ([Environment]::Is64BitProcess) { + return + } + + $sysNativePowerShell = Join-Path $env:WINDIR 'SysNative\WindowsPowerShell\v1.0\powershell.exe' + if (-not (Test-Path -LiteralPath $sysNativePowerShell -PathType Leaf)) { + return + } + + # Re-enter through 64-bit PowerShell so Task Scheduler and ProgramData access stay in the expected view. + & $sysNativePowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @(Get-ForwardedArgumentList) + exit $LASTEXITCODE +} + +function Remove-ThrottleStateFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return + } + + try { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } + catch { + } +} + +function ConvertFrom-JsonCompat { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter()] + [int]$Depth = 10 + ) + + $command = Get-Command -Name ConvertFrom-Json -ErrorAction Stop + if ($command.Parameters.ContainsKey('Depth')) { + return ($Content | ConvertFrom-Json -Depth $Depth -ErrorAction Stop) + } + + return ($Content | ConvertFrom-Json -ErrorAction Stop) +} + +function Read-ThrottleState { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + $content = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($content)) { + return $null + } + + return (ConvertFrom-JsonCompat -Content $content -Depth 10) + } + catch { + return $null + } +} + +function Get-RegisteredUtc { + param( + [Parameter(Mandatory = $true)] + [object]$State + ) + + $registeredText = [string]$State.registeredUtc + if ([string]::IsNullOrWhiteSpace($registeredText)) { + return $null + } + + $registeredUtc = [datetime]::MinValue + $dateStyles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal + if (-not [datetime]::TryParse($registeredText, [System.Globalization.CultureInfo]::InvariantCulture, $dateStyles, [ref]$registeredUtc)) { + return $null + } + + return $registeredUtc.ToUniversalTime() +} + +function Resolve-TaskName { + param( + [Parameter(Mandatory = $true)] + [object]$State, + [Parameter(Mandatory = $true)] + [string]$DefaultTaskName + ) + + $stateTaskName = [string]$State.taskName + if ([string]::IsNullOrWhiteSpace($stateTaskName)) { + return $DefaultTaskName + } + + return $stateTaskName +} + +function Get-Task { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue +} + +Invoke-In64BitPowerShell + +if (-not (Test-Path -LiteralPath $StatePath -PathType Leaf)) { + Write-DetectionResult -ExitCode 1 -Message 'no-state' +} + +$state = Read-ThrottleState -Path $StatePath +if ($null -eq $state) { + Remove-ThrottleStateFile -Path $StatePath + Write-DetectionResult -ExitCode 1 -Message 'invalid-state' +} + +$registeredUtc = Get-RegisteredUtc -State $state +if ($null -eq $registeredUtc) { + Remove-ThrottleStateFile -Path $StatePath + Write-DetectionResult -ExitCode 1 -Message 'invalid-state' +} + +$expiresUtc = $registeredUtc.AddHours($ThrottleHours) +if ($expiresUtc -le (Get-Date).ToUniversalTime()) { + Remove-ThrottleStateFile -Path $StatePath + Write-DetectionResult -ExitCode 1 -Message 'expired-state' +} + +$resolvedTaskName = Resolve-TaskName -State $state -DefaultTaskName $TaskName +if (-not (Get-Task -Name $resolvedTaskName)) { + Remove-ThrottleStateFile -Path $StatePath + Write-DetectionResult -ExitCode 1 -Message 'missing-task' +} + +Write-DetectionResult -ExitCode 0 -Message 'throttled' \ No newline at end of file diff --git a/references/collection/Invoke-CmtraceEvidenceBootstrap.ps1 b/references/collection/Invoke-CmtraceEvidenceBootstrap.ps1 new file mode 100644 index 000000000..7bf34e138 --- /dev/null +++ b/references/collection/Invoke-CmtraceEvidenceBootstrap.ps1 @@ -0,0 +1,814 @@ +[CmdletBinding()] +param( + [string]$StagingRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Staging'), + [string]$StateRoot = (Join-Path $env:ProgramData 'CmtraceOpen\State'), + [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), + [string]$TaskName = 'CmtraceOpen-EvidenceCollection-Once', + [int]$DelayMinutes = 2, + [int]$ThrottleHours = 24, + [version]$RequiredPowerShellVersion = [version]'7.5.4', + [string]$PowerShellMsiUrl = 'https://github.com/PowerShell/PowerShell/releases/download/v7.5.4/PowerShell-7.5.4-win-x64.msi', + [string]$CollectorProfileUrl = '', #fill out + [string]$CollectorScriptUrl = '', #fill out + [string]$SasUrl = '', #fill out + [string]$BundleLabel = 'intune-endpoint-evidence', + [string]$CaseReference = '', + [string]$BlobName = '', + [string]$OperatorName = 'SYSTEM', + [string]$OperatorTeam = 'Intune', + [string]$OperatorContact = '', + [switch]$LocalOnly, + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Step { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Host "==> $Message" -ForegroundColor Cyan +} + +function Get-UtcTimestamp { + return (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +} + +function Initialize-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Write-JsonFile { + param( + [Parameter(Mandatory = $true)] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $utf8Encoding = New-Object System.Text.UTF8Encoding($false) + $json = $InputObject | ConvertTo-Json -Depth 10 + [System.IO.File]::WriteAllText($Path, $json, $utf8Encoding) +} + +function Test-ConvertFromJsonDepthSupport { + if ($null -ne $script:ConvertFromJsonDepthSupported) { + return $script:ConvertFromJsonDepthSupported + } + + $command = Get-Command -Name ConvertFrom-Json -ErrorAction Stop + $script:ConvertFromJsonDepthSupported = $command.Parameters.ContainsKey('Depth') + return $script:ConvertFromJsonDepthSupported +} + +function ConvertFrom-JsonCompat { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyString()] + [string]$InputObject, + [int]$Depth = 1024 + ) + + process { + $convertFromJsonParameters = @{ + InputObject = $InputObject + } + + if ($PSBoundParameters.ContainsKey('ErrorAction')) { + $convertFromJsonParameters.ErrorAction = $PSBoundParameters.ErrorAction + } + + if (Test-ConvertFromJsonDepthSupport) { + $convertFromJsonParameters.Depth = $Depth + } + + return (ConvertFrom-Json @convertFromJsonParameters) + } +} + +function Read-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $null + } + + return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-JsonCompat -Depth 10) +} + +function Format-TaskArgument { + param( + [AllowEmptyString()] + [string]$Value + ) + + return '"{0}"' -f ($Value -replace '"', '\"') +} + +function Test-HttpsUrl { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $false + } + + $uri = $null + if (-not [System.Uri]::TryCreate($Value, [System.UriKind]::Absolute, [ref]$uri)) { + return $false + } + + return $uri.Scheme -eq 'https' +} + +function Test-PlaceholderUrl { + param( + [AllowEmptyString()] + [string]$Value + ) + + return $Value -like 'https://example.invalid/*' +} + +function Get-File { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-HttpsUrl -Value $Url)) { + throw "Only HTTPS URLs are allowed: $Url" + } + + Invoke-WebRequest -Uri $Url -OutFile $DestinationPath -UseBasicParsing +} + +function Get-CommandExecutablePath { + param( + [AllowEmptyString()] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { + return $null + } + + $command = Get-Command -Name $Name -CommandType Application -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + + foreach ($propertyName in @('Path', 'Source', 'Definition')) { + $property = $command.PSObject.Properties[$propertyName] + if ($null -eq $property) { + continue + } + + $propertyValue = [string]$property.Value + if ([string]::IsNullOrWhiteSpace($propertyValue)) { + continue + } + + return $propertyValue + } + + return $null +} + +function Get-FileContentIdentifier { + param( + [AllowEmptyString()] + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $null + } + + try { + return ('sha256:{0}' -f (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()) + } + catch { + return $null + } +} + +function Get-PowerShellExecutableCandidates { + $candidatePaths = New-Object System.Collections.Generic.List[string] + $resolvedPwshPath = Get-CommandExecutablePath -Name 'pwsh.exe' + + foreach ($preferredPath in @( + (Join-Path ${env:ProgramFiles} 'PowerShell\7\pwsh.exe'), + $resolvedPwshPath, + (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe') + )) { + if ([string]::IsNullOrWhiteSpace($preferredPath)) { + continue + } + + if (-not $candidatePaths.Contains($preferredPath)) { + $candidatePaths.Add($preferredPath) + } + } + + return $candidatePaths +} + +function Get-PowerShellExecutableVersion { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + $versionOutput = & $Path -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>$null + if ($LASTEXITCODE -ne 0) { + return $null + } + + $versionText = [string]($versionOutput | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($versionText)) { + return $null + } + + $parsedVersion = $null + if ([version]::TryParse($versionText.Trim(), [ref]$parsedVersion)) { + return $parsedVersion + } + } + catch { + return $null + } + + return $null +} + +function Resolve-PowerShellExecutable { + param( + [Parameter(Mandatory = $true)] + [version]$MinimumVersion + ) + + $fallbackCandidate = $null + + foreach ($candidatePath in (Get-PowerShellExecutableCandidates)) { + if (-not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) { + continue + } + + $candidateVersion = Get-PowerShellExecutableVersion -Path $candidatePath + if ($null -eq $candidateVersion) { + continue + } + + $source = if ($candidatePath -eq (Join-Path ${env:ProgramFiles} 'PowerShell\7\pwsh.exe')) { + 'programfiles-x64' + } + elseif ($candidatePath -eq (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe')) { + 'programfiles-x86' + } + else { + 'command-path' + } + + $candidate = [ordered]@{ + Found = $true + Acceptable = ($candidateVersion -ge $MinimumVersion) + Path = $candidatePath + Version = $candidateVersion + Source = $source + } + + if ($candidate.Acceptable) { + return [pscustomobject]$candidate + } + + if ($null -eq $fallbackCandidate) { + $fallbackCandidate = [pscustomobject]$candidate + } + } + + if ($null -ne $fallbackCandidate) { + return $fallbackCandidate + } + + return [pscustomobject]@{ + Found = $false + Acceptable = $false + Path = $null + Version = $null + Source = 'not-found' + } +} + +function Install-PowerShellMsi { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-HttpsUrl -Value $Url)) { + throw "PowerShellMsiUrl must be an HTTPS URL: $Url" + } + + Write-Step 'Downloading PowerShell MSI payload' + Get-File -Url $Url -DestinationPath $DestinationPath + + Write-Step 'Installing PowerShell MSI payload' + $installerProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList ('/i "{0}" /qn /norestart ALLUSERS=1' -f $DestinationPath) -Wait -PassThru -WindowStyle Hidden + $restartRequired = $installerProcess.ExitCode -in @(3010, 1641) + $installSucceeded = $installerProcess.ExitCode -in @(0, 3010, 1641) + + return [pscustomobject]@{ + StagedMsiPath = $DestinationPath + InstallerExitCode = $installerProcess.ExitCode + RestartRequired = $restartRequired + InstallSucceeded = $installSucceeded + } +} + +function Initialize-PowerShellExecutable { + param( + [Parameter(Mandatory = $true)] + [version]$MinimumVersion, + [Parameter(Mandatory = $true)] + [string]$MsiUrl, + [Parameter(Mandatory = $true)] + [string]$MsiPath + ) + + $initialResolution = Resolve-PowerShellExecutable -MinimumVersion $MinimumVersion + $details = [ordered]@{ + requiredVersion = $MinimumVersion.ToString() + msiUrl = $MsiUrl + action = if ($initialResolution.Acceptable) { 'existing' } else { 'install-required' } + installAttempted = $false + initialPath = $initialResolution.Path + initialVersion = if ($null -ne $initialResolution.Version) { $initialResolution.Version.ToString() } else { $null } + initialSource = $initialResolution.Source + stagedMsiPath = $null + installerExitCode = $null + restartRequired = $false + finalPath = $initialResolution.Path + finalVersion = if ($null -ne $initialResolution.Version) { $initialResolution.Version.ToString() } else { $null } + finalSource = $initialResolution.Source + } + + if ($initialResolution.Acceptable) { + return [pscustomobject]@{ + ExecutablePath = $initialResolution.Path + Version = $initialResolution.Version + Details = [pscustomobject]$details + } + } + + $details.installAttempted = $true + $details.stagedMsiPath = $MsiPath + + $installResult = Install-PowerShellMsi -Url $MsiUrl -DestinationPath $MsiPath + $details.action = 'installed' + $details.installerExitCode = $installResult.InstallerExitCode + $details.restartRequired = $installResult.RestartRequired + $details.stagedMsiPath = $installResult.StagedMsiPath + + $finalResolution = Resolve-PowerShellExecutable -MinimumVersion $MinimumVersion + $details.finalPath = $finalResolution.Path + $details.finalVersion = if ($null -ne $finalResolution.Version) { $finalResolution.Version.ToString() } else { $null } + $details.finalSource = $finalResolution.Source + + if (-not $finalResolution.Acceptable) { + throw ('PowerShell {0} or later is required, but pwsh.exe could not be resolved after MSI install. Installer exit code: {1}. MSI path: {2}.' -f $MinimumVersion, $installResult.InstallerExitCode, $installResult.StagedMsiPath) + } + + if (-not $installResult.InstallSucceeded) { + throw ('PowerShell MSI install reported exit code {0} even though pwsh.exe resolved afterward. Treating this as a failure to avoid masking an incomplete installation.' -f $installResult.InstallerExitCode) + } + + return [pscustomobject]@{ + ExecutablePath = $finalResolution.Path + Version = $finalResolution.Version + Details = [pscustomobject]$details + } +} + +function Test-PowerShellFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $parseErrors = $null + $tokens = $null + [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors) | Out-Null + return ($parseErrors.Count -eq 0) +} + +function Test-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + Get-Content -LiteralPath $Path -Raw | ConvertFrom-JsonCompat -Depth 20 | Out-Null + return $true + } + catch { + return $false + } +} + +function Get-RedactedUrl { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '[not provided]' + } + + $uri = $null + if (-not [System.Uri]::TryCreate($Value, [System.UriKind]::Absolute, [ref]$uri)) { + return $Value + } + + if ([string]::IsNullOrWhiteSpace($uri.Query)) { + return $uri.AbsoluteUri + } + + return ('{0} [query redacted]' -f $uri.GetLeftPart([System.UriPartial]::Path)) +} + +function Get-FilePreview { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + $rawContent = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + } + catch { + return ('[preview unavailable: {0}]' -f $_.Exception.Message) + } + + if ([string]::IsNullOrWhiteSpace($rawContent)) { + return '[empty file]' + } + + $normalizedPreview = (($rawContent -replace [char]0xFEFF, '') -replace '\r?\n', ' ') + $normalizedPreview = ($normalizedPreview -replace '\s+', ' ').Trim() + + if ($normalizedPreview.Length -gt 200) { + return ('{0}...' -f $normalizedPreview.Substring(0, 200)) + } + + return $normalizedPreview +} + +function Get-JsonPayloadHint { + param( + [AllowEmptyString()] + [string]$Preview + ) + + if ([string]::IsNullOrWhiteSpace($Preview) -or $Preview -eq '[empty file]') { + return 'Payload is empty.' + } + + $trimmedPreview = $Preview.TrimStart() + if ($trimmedPreview.StartsWith('<')) { + return 'Payload preview suggests HTML or XML content rather than JSON.' + } + + if ((-not $trimmedPreview.StartsWith('{')) -and (-not $trimmedPreview.StartsWith('['))) { + return 'Payload preview suggests plain text or another non-JSON format.' + } + + return 'Payload could not be parsed as JSON.' +} + +function Assert-ValidJsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$SourceContext + ) + + try { + $rawContent = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + } + catch { + throw ('Downloaded profile payload could not be read. Staged path: {0}. Source: {1}. Error: {2}' -f $Path, (Get-RedactedUrl -Value $SourceContext), $_.Exception.Message) + } + + if ([string]::IsNullOrWhiteSpace($rawContent)) { + throw ('Downloaded profile payload is empty. Staged path: {0}. Source: {1}.' -f $Path, (Get-RedactedUrl -Value $SourceContext)) + } + + try { + $rawContent | ConvertFrom-JsonCompat -Depth 20 -ErrorAction Stop | Out-Null + } + catch { + $preview = Get-FilePreview -Path $Path + $payloadHint = Get-JsonPayloadHint -Preview $preview + throw ('Downloaded profile payload is not valid JSON. Staged path: {0}. Source: {1}. {2} Parse error: {3}. Payload preview: {4}' -f $Path, (Get-RedactedUrl -Value $SourceContext), $payloadHint, $_.Exception.Message, $preview) + } +} + +function Assert-StagedPayloads { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorPath, + [Parameter(Mandatory = $true)] + [string]$ProfilePath, + [Parameter(Mandatory = $true)] + [string]$ProfileSource + ) + + if (-not (Test-PowerShellFile -Path $CollectorPath)) { + throw "Downloaded collector payload is not valid PowerShell: $CollectorPath. Check CollectorScriptUrl." + } + + Assert-ValidJsonFile -Path $ProfilePath -SourceContext $ProfileSource +} + +function Get-Task { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue +} + +function Remove-TaskIfPresent { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $existingTask = Get-Task -Name $Name + if ($existingTask) { + Unregister-ScheduledTask -TaskName $Name -Confirm:$false + } +} + +function Get-ShouldThrottle { + param( + [Parameter(Mandatory = $true)] + [string]$StatePath, + [Parameter(Mandatory = $true)] + [int]$WindowHours, + [switch]$IgnoreState + ) + + if ($IgnoreState) { + return $false + } + + $state = Read-JsonFile -Path $StatePath + if ($null -eq $state) { + return $false + } + + if ([string]::IsNullOrWhiteSpace([string]$state.registeredUtc)) { + return $false + } + + $registeredUtc = [datetime]::Parse([string]$state.registeredUtc).ToUniversalTime() + $expiresUtc = $registeredUtc.AddHours($WindowHours) + return $expiresUtc -gt (Get-Date).ToUniversalTime() +} + +function New-CollectorArgumentString { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorPath, + [Parameter(Mandatory = $true)] + [string]$ProfilePath, + [Parameter(Mandatory = $true)] + [string]$CollectorOutputRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorBundleLabel, + [AllowEmptyString()] + [string]$CollectorCaseReference, + [AllowEmptyString()] + [string]$CollectorBlobName, + [AllowEmptyString()] + [string]$CollectorOperatorName, + [AllowEmptyString()] + [string]$CollectorOperatorTeam, + [AllowEmptyString()] + [string]$CollectorOperatorContact, + [AllowEmptyString()] + [string]$ResolvedSasUrl, + [switch]$RunLocalOnly + ) + + $arguments = New-Object System.Collections.Generic.List[string] + $arguments.Add('-NoProfile') + $arguments.Add('-ExecutionPolicy') + $arguments.Add('Bypass') + $arguments.Add('-File') + $arguments.Add((Format-TaskArgument -Value $CollectorPath)) + $arguments.Add('-CollectorProfilePath') + $arguments.Add((Format-TaskArgument -Value $ProfilePath)) + $arguments.Add('-OutputRoot') + $arguments.Add((Format-TaskArgument -Value $CollectorOutputRoot)) + $arguments.Add('-BundleLabel') + $arguments.Add((Format-TaskArgument -Value $CollectorBundleLabel)) + $arguments.Add('-OperatorName') + $arguments.Add((Format-TaskArgument -Value $CollectorOperatorName)) + $arguments.Add('-OperatorTeam') + $arguments.Add((Format-TaskArgument -Value $CollectorOperatorTeam)) + + if (-not [string]::IsNullOrWhiteSpace($CollectorCaseReference)) { + $arguments.Add('-CaseReference') + $arguments.Add((Format-TaskArgument -Value $CollectorCaseReference)) + } + + if (-not [string]::IsNullOrWhiteSpace($CollectorBlobName)) { + $arguments.Add('-BlobName') + $arguments.Add((Format-TaskArgument -Value $CollectorBlobName)) + } + + if (-not [string]::IsNullOrWhiteSpace($CollectorOperatorContact)) { + $arguments.Add('-OperatorContact') + $arguments.Add((Format-TaskArgument -Value $CollectorOperatorContact)) + } + + if ($RunLocalOnly) { + $arguments.Add('-LocalOnly') + } + elseif (-not [string]::IsNullOrWhiteSpace($ResolvedSasUrl)) { + $arguments.Add('-SasUrl') + $arguments.Add((Format-TaskArgument -Value $ResolvedSasUrl)) + } + + return ($arguments -join ' ') +} + +function New-StagedPayloadPath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + [Parameter(Mandatory = $true)] + [string]$BaseName, + [Parameter(Mandatory = $true)] + [string]$RunId + ) + + $extension = [System.IO.Path]::GetExtension($BaseName) + $stem = [System.IO.Path]::GetFileNameWithoutExtension($BaseName) + return (Join-Path $Root ('{0}-{1}{2}' -f $stem, $RunId, $extension)) +} + +$bootstrapRunId = [guid]::NewGuid().ToString('N') +$bootstrapScriptPath = if ([string]::IsNullOrWhiteSpace($PSCommandPath)) { $MyInvocation.MyCommand.Path } else { $PSCommandPath } +$bootstrapScriptContentId = Get-FileContentIdentifier -Path $bootstrapScriptPath +$statePath = Join-Path $StateRoot 'collection-bootstrap.json' +$stagedCollectorPath = New-StagedPayloadPath -Root $StagingRoot -BaseName 'Invoke-CmtraceEvidenceCollection.ps1' -RunId $bootstrapRunId +$stagedProfilePath = New-StagedPayloadPath -Root $StagingRoot -BaseName 'intune-evidence-profile.json' -RunId $bootstrapRunId +$stagedPowerShellMsiPath = Join-Path $StagingRoot ('PowerShell-{0}-win-x64.msi' -f $RequiredPowerShellVersion) + +Write-Step 'Preparing bootstrap directories' +Initialize-Directory -Path $StagingRoot +Initialize-Directory -Path $StateRoot +Initialize-Directory -Path $OutputRoot + +if (Test-PlaceholderUrl -Value $CollectorScriptUrl) { + throw 'CollectorScriptUrl still points to the example.invalid placeholder. Provide a reachable HTTPS URL for the collector payload.' +} + +if (Test-PlaceholderUrl -Value $CollectorProfileUrl) { + throw 'CollectorProfileUrl still points to the example.invalid placeholder. Provide a reachable HTTPS URL for the collector profile.' +} + +if ((-not $LocalOnly) -and [string]::IsNullOrWhiteSpace($SasUrl)) { + throw 'SasUrl is required unless you use -LocalOnly.' +} + +if ((-not $LocalOnly) -and (-not (Test-HttpsUrl -Value $SasUrl))) { + throw 'SasUrl must be an HTTPS URL.' +} + +if ((-not $LocalOnly) -and ($SasUrl -notmatch '\?')) { + throw 'SasUrl does not appear to contain a query string.' +} + +if (Get-ShouldThrottle -StatePath $statePath -WindowHours $ThrottleHours -IgnoreState:$Force) { + $existingTask = Get-Task -Name $TaskName + $status = if ($existingTask) { 'skipped-throttled-task-present' } else { 'skipped-throttled' } + [pscustomobject]@{ + Status = $status + TaskName = $TaskName + StatePath = $statePath + BootstrapScriptPath = $bootstrapScriptPath + BootstrapScriptContentId = $bootstrapScriptContentId + Message = 'Bootstrap skipped because the throttle window is still active.' + } + exit 0 +} + +Write-Step 'Downloading staged collector payloads' +Get-File -Url $CollectorScriptUrl -DestinationPath $stagedCollectorPath +Get-File -Url $CollectorProfileUrl -DestinationPath $stagedProfilePath + +Write-Step 'Validating staged collector payloads' +Assert-StagedPayloads -CollectorPath $stagedCollectorPath -ProfilePath $stagedProfilePath -ProfileSource $CollectorProfileUrl + +Write-Step 'Resolving PowerShell runtime' +$powerShellResolution = Initialize-PowerShellExecutable -MinimumVersion $RequiredPowerShellVersion -MsiUrl $PowerShellMsiUrl -MsiPath $stagedPowerShellMsiPath + +$resolvedSasUrl = $SasUrl + +$caseReferenceValue = if ([string]::IsNullOrWhiteSpace($CaseReference)) { + 'bootstrap-{0}' -f (Get-Date -Format 'yyyyMMdd-HHmmss') +} +else { + $CaseReference +} + +$taskArguments = New-CollectorArgumentString -CollectorPath $stagedCollectorPath -ProfilePath $stagedProfilePath -CollectorOutputRoot $OutputRoot -CollectorBundleLabel $BundleLabel -CollectorCaseReference $caseReferenceValue -CollectorBlobName $BlobName -CollectorOperatorName $OperatorName -CollectorOperatorTeam $OperatorTeam -CollectorOperatorContact $OperatorContact -ResolvedSasUrl $resolvedSasUrl -RunLocalOnly:$LocalOnly +$powerShellExecutable = $powerShellResolution.ExecutablePath + +Write-Step 'Registering one-time SYSTEM scheduled task' +if ($Force) { + Remove-TaskIfPresent -Name $TaskName +} +elseif (Get-Task -Name $TaskName) { + Remove-TaskIfPresent -Name $TaskName +} + +$triggerTime = (Get-Date).AddMinutes($DelayMinutes) +$taskAction = New-ScheduledTaskAction -Execute $powerShellExecutable -Argument $taskArguments +$taskTrigger = New-ScheduledTaskTrigger -Once -At $triggerTime +$taskSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 8) -StartWhenAvailable + +Register-ScheduledTask -TaskName $TaskName -Action $taskAction -Trigger $taskTrigger -User 'SYSTEM' -RunLevel Highest -Settings $taskSettings -Force | Out-Null + +$state = [ordered]@{ + registeredUtc = Get-UtcTimestamp + triggerUtc = $triggerTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + bootstrapRunId = $bootstrapRunId + bootstrapScriptPath = $bootstrapScriptPath + bootstrapScriptContentId = $bootstrapScriptContentId + taskName = $TaskName + stagingRoot = $StagingRoot + stagedCollectorPath = $stagedCollectorPath + stagedProfilePath = $stagedProfilePath + outputRoot = $OutputRoot + collectorScriptUrl = $CollectorScriptUrl + collectorProfileUrl = $CollectorProfileUrl + powerShell = $powerShellResolution.Details + powerShellExecutable = $powerShellExecutable + sasUrlConfigured = (-not [string]::IsNullOrWhiteSpace($SasUrl)) + localOnly = [bool]$LocalOnly + caseReference = $caseReferenceValue +} + +Write-JsonFile -InputObject $state -Path $statePath + +Write-Step 'Bootstrap complete' +[pscustomobject]@{ + Status = 'scheduled' + BootstrapRunId = $bootstrapRunId + BootstrapScriptPath = $bootstrapScriptPath + BootstrapScriptContentId = $bootstrapScriptContentId + TaskName = $TaskName + TriggerTimeUtc = $triggerTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + StatePath = $statePath + StagedCollectorPath = $stagedCollectorPath + StagedProfilePath = $stagedProfilePath + PowerShell = $powerShellResolution.Details + PowerShellExecutable = $powerShellExecutable + SasUrlConfigured = (-not [string]::IsNullOrWhiteSpace($SasUrl)) + OutputRoot = $OutputRoot +} \ No newline at end of file diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 new file mode 100644 index 000000000..97edf12fd --- /dev/null +++ b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -0,0 +1,1202 @@ +[CmdletBinding()] +param( + [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), + [string]$BundleLabel = 'intune-endpoint-evidence', + [string]$CaseReference = '', + [string]$CollectorProfilePath, + [string]$SasUrl, + [string]$BlobName, + [string]$OperatorName = 'SYSTEM', + [string]$OperatorTeam = 'Intune', + [string]$OperatorContact = '', + [switch]$LocalOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($CollectorProfilePath)) { + $CollectorProfilePath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) 'intune-evidence-profile.json' +} + +$script:CollectorVersion = '1.1.0' +$script:ArtifactCounters = @{} +$script:CollectorRunLogPath = $null +$script:CollectorTranscriptStarted = $false + +function Protect-SecretText { + param( + [AllowNull()] + [string]$Text + ) + + if ($null -eq $Text) { + return $null + } + + return [System.Text.RegularExpressions.Regex]::Replace( + $Text, + '(https?://[^\s''""<>]+)\?[^\s''""<>]+', + '$1?' + ) +} + +function Write-Step { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + $sanitizedMessage = Protect-SecretText -Text $Message + Write-Host "==> $sanitizedMessage" -ForegroundColor Cyan +} + +function Get-UtcTimestamp { + return (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +} + +function Initialize-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Initialize-ParentDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $parentPath = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($parentPath)) { + Initialize-Directory -Path $parentPath + } +} + +function ConvertTo-SafeFileName { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return 'unknown' + } + + $safeValue = $Value.Trim() + foreach ($character in [System.IO.Path]::GetInvalidFileNameChars()) { + $safeValue = $safeValue.Replace($character, '-') + } + + $safeValue = $safeValue -replace '\s+', '-' + $safeValue = $safeValue.Trim('-') + + if ([string]::IsNullOrWhiteSpace($safeValue)) { + return 'unknown' + } + + return $safeValue +} + +function Join-RelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$Left, + [Parameter(Mandatory = $true)] + [string]$Right + ) + + return (($Left.TrimEnd('/')) + '/' + ($Right.TrimStart('/'))) +} + +function ConvertTo-PhysicalPath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + [Parameter(Mandatory = $true)] + [string]$RelativePath + ) + + $normalizedRelativePath = $RelativePath -replace '/', '\\' + return Join-Path $Root $normalizedRelativePath +} + +function Write-JsonFile { + param( + [Parameter(Mandatory = $true)] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + Initialize-ParentDirectory -Path $Path + $utf8Encoding = New-Object System.Text.UTF8Encoding($false) + $json = $InputObject | ConvertTo-Json -Depth 12 + [System.IO.File]::WriteAllText($Path, $json, $utf8Encoding) +} + +function Write-TextFile { + param( + [Parameter(Mandatory = $true)] + [string]$Content, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + Initialize-ParentDirectory -Path $Path + $utf8Encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $utf8Encoding) +} + +function Start-CollectorTranscript { + $logRoot = Join-Path $env:ProgramData 'CmtraceOpen\Logs\Collection' + $logFileName = 'collector-{0}-{1}-{2}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'), (ConvertTo-SafeFileName -Value $env:COMPUTERNAME), $PID + $script:CollectorRunLogPath = Join-Path $logRoot $logFileName + + Initialize-Directory -Path $logRoot + + try { + $null = Start-Transcript -LiteralPath $script:CollectorRunLogPath -Force -UseMinimalHeader -IncludeInvocationHeader -ErrorAction Stop + $script:CollectorTranscriptStarted = $true + Write-Step ('Collector run log: {0}' -f $script:CollectorRunLogPath) + } + catch { + $script:CollectorTranscriptStarted = $false + Write-Warning ('Collector transcript could not be started at {0}: {1}' -f $script:CollectorRunLogPath, (Protect-SecretText -Text $_.Exception.Message)) + } +} + +function Stop-CollectorTranscript { + if (-not $script:CollectorTranscriptStarted) { + return + } + + try { + $null = Stop-Transcript -ErrorAction Stop + } + catch { + Write-Warning ('Collector transcript could not be stopped cleanly: {0}' -f (Protect-SecretText -Text $_.Exception.Message)) + } + finally { + $script:CollectorTranscriptStarted = $false + } +} + +function Get-FileSha256 { + param( + [AllowNull()] + [string]$Path + ) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return $null + } + + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Expand-EnvironmentPath { + param( + [AllowEmptyString()] + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + + return [System.Environment]::ExpandEnvironmentVariables($Path) +} + +function Get-ObjectPropertyValue { + param( + [AllowNull()] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Name, + [AllowNull()] + [object]$DefaultValue = $null + ) + + if ($null -eq $InputObject) { + return $DefaultValue + } + + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { + return $DefaultValue + } + + return $property.Value +} + +function Test-ArrayValue { + param( + [AllowNull()] + [object]$Value + ) + + return ($Value -is [System.Array]) -or ($Value -is [System.Collections.IList]) +} + +function Assert-ProfileRequiredString { + param( + [AllowNull()] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Context, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $value = Get-ObjectPropertyValue -InputObject $InputObject -Name $Name + if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$value)) { + throw ('Collector profile is invalid: {0}. {1}.{2} must be a non-empty string.' -f $Path, $Context, $Name) + } +} + +function Assert-ProfileRequiredArray { + param( + [AllowNull()] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $property = $null + if ($null -ne $InputObject) { + $property = $InputObject.PSObject.Properties[$Name] + } + + if ($null -eq $property) { + throw ('Collector profile is invalid: {0}. Top-level section "{1}" is missing.' -f $Path, $Name) + } + + if (-not (Test-ArrayValue -Value $property.Value)) { + throw ('Collector profile is invalid: {0}. Top-level section "{1}" must be an array.' -f $Path, $Name) + } +} + +function Assert-CollectorProfileShape { + param( + [Parameter(Mandatory = $true)] + [object]$CollectorProfile, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + Assert-ProfileRequiredString -InputObject $CollectorProfile -Name 'profileName' -Context 'profile' -Path $Path + Assert-ProfileRequiredString -InputObject $CollectorProfile -Name 'profileVersion' -Context 'profile' -Path $Path + + foreach ($sectionName in @('logs', 'registry', 'eventLogs', 'exports', 'commands')) { + Assert-ProfileRequiredArray -InputObject $CollectorProfile -Name $sectionName -Path $Path + } + + $sectionDefinitions = @( + @{ + name = 'logs' + requiredStrings = @('id', 'family', 'sourcePattern', 'destinationFolder') + optionalArrays = @('parseHints') + }, + @{ + name = 'registry' + requiredStrings = @('id', 'family', 'path', 'fileName') + optionalArrays = @() + }, + @{ + name = 'eventLogs' + requiredStrings = @('id', 'family', 'channel', 'fileName') + optionalArrays = @() + }, + @{ + name = 'exports' + requiredStrings = @('id', 'family', 'sourcePath') + optionalArrays = @('parseHints') + }, + @{ + name = 'commands' + requiredStrings = @('id', 'family', 'command', 'fileName') + optionalArrays = @('arguments') + } + ) + + foreach ($sectionDefinition in $sectionDefinitions) { + $sectionName = [string]$sectionDefinition.name + $sectionItems = @(Get-ObjectPropertyValue -InputObject $CollectorProfile -Name $sectionName -DefaultValue @()) + + for ($index = 0; $index -lt $sectionItems.Count; $index++) { + $item = $sectionItems[$index] + $itemContext = '{0}[{1}]' -f $sectionName, $index + + if ($null -eq $item) { + throw ('Collector profile is invalid: {0}. {1} must be an object.' -f $Path, $itemContext) + } + + foreach ($propertyName in @($sectionDefinition.requiredStrings)) { + Assert-ProfileRequiredString -InputObject $item -Name $propertyName -Context $itemContext -Path $Path + } + + foreach ($propertyName in @($sectionDefinition.optionalArrays)) { + $propertyValue = Get-ObjectPropertyValue -InputObject $item -Name $propertyName + if ($null -ne $propertyValue -and -not (Test-ArrayValue -Value $propertyValue)) { + throw ('Collector profile is invalid: {0}. {1}.{2} must be an array when present.' -f $Path, $itemContext, $propertyName) + } + } + } + } +} + +function Read-CollectorProfile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw ('Collector profile file was not found: {0}' -f $Path) + } + + try { + $rawProfile = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + } + catch { + throw ('Collector profile file could not be read: {0}. Error: {1}' -f $Path, $_.Exception.Message) + } + + if ([string]::IsNullOrWhiteSpace($rawProfile)) { + throw ('Collector profile file is empty: {0}' -f $Path) + } + + try { + $collectorProfile = $rawProfile | ConvertFrom-Json -Depth 20 -ErrorAction Stop + } + catch { + throw ('Collector profile contains invalid JSON: {0}. Error: {1}' -f $Path, $_.Exception.Message) + } + + Assert-CollectorProfileShape -CollectorProfile $collectorProfile -Path $Path + return $collectorProfile +} + +function New-ArtifactId { + param( + [Parameter(Mandatory = $true)] + [string]$Category + ) + + $prefix = switch ($Category) { + 'event-log' { 'event' } + 'command-output' { 'command' } + default { $Category } + } + + if (-not $script:ArtifactCounters.ContainsKey($prefix)) { + $script:ArtifactCounters[$prefix] = 0 + } + + $script:ArtifactCounters[$prefix] += 1 + return ('{0}-{1:D3}' -f $prefix, $script:ArtifactCounters[$prefix]) +} + +function New-ArtifactRecord { + param( + [Parameter(Mandatory = $true)] + [string]$Category, + [Parameter(Mandatory = $true)] + [string]$Family, + [Parameter(Mandatory = $true)] + [string]$RelativePath, + [Parameter(Mandatory = $true)] + [string]$OriginPath, + [Parameter(Mandatory = $true)] + [ValidateSet('collected', 'missing', 'failed', 'skipped')] + [string]$Status, + [string[]]$ParseHints = @(), + [AllowNull()] + [string]$FilePath, + [AllowNull()] + [string]$Notes, + [AllowNull()] + [string]$StartUtc, + [AllowNull()] + [string]$EndUtc + ) + + return [ordered]@{ + artifactId = New-ArtifactId -Category $Category + category = $Category + family = $Family + relativePath = $RelativePath + originPath = $OriginPath + collectedUtc = Get-UtcTimestamp + status = $Status + parseHints = @($ParseHints) + timeCoverage = [ordered]@{ + startUtc = $StartUtc + endUtc = $EndUtc + } + hashes = [ordered]@{ + sha256 = (Get-FileSha256 -Path $FilePath) + } + notes = $Notes + } +} + +function ConvertTo-RegistryProviderPath { + param( + [Parameter(Mandatory = $true)] + [string]$RegistryPath + ) + + return ('Registry::{0}' -f $RegistryPath) +} + +function Test-RegistryKeyExists { + param( + [Parameter(Mandatory = $true)] + [string]$RegistryPath + ) + + return (Test-Path -LiteralPath (ConvertTo-RegistryProviderPath -RegistryPath $RegistryPath)) +} + +function Invoke-ExternalCommandCapture { + param( + [Parameter(Mandatory = $true)] + [string]$Command, + [string[]]$Arguments = @() + ) + + $commandInfo = Get-Command $Command -ErrorAction SilentlyContinue + if (-not $commandInfo) { + return [ordered]@{ + found = $false + exitCode = $null + output = '' + error = ('Command not found: {0}' -f $Command) + } + } + + $output = & $commandInfo.Source @Arguments 2>&1 | Out-String + $exitCode = $LASTEXITCODE + + return [ordered]@{ + found = $true + exitCode = $exitCode + output = $output.TrimEnd() + error = $null + } +} + +function Get-CommandInvocationText { + param( + [Parameter(Mandatory = $true)] + [object]$CommandItem + ) + + return ('{0} {1}' -f $CommandItem.command, (@($CommandItem.arguments) -join ' ')).Trim() +} + +function Add-GeneratedCommandArtifacts { + param( + [Parameter(Mandatory = $true)] + [object]$Artifacts, + [Parameter(Mandatory = $true)] + [object]$ObservedGaps, + [Parameter(Mandatory = $true)] + [object]$CommandItem, + [Parameter(Mandatory = $true)] + [string]$BundleRoot + ) + + $generatedOutputs = @(Get-ObjectPropertyValue -InputObject $CommandItem -Name 'generatedOutputs' -DefaultValue @()) + + foreach ($outputItem in $generatedOutputs) { + $resolvedSourcePath = Expand-EnvironmentPath -Path (Get-ObjectPropertyValue -InputObject $outputItem -Name 'sourcePath') + $destinationFolder = Get-ObjectPropertyValue -InputObject $outputItem -Name 'destinationFolder' -DefaultValue 'evidence/exports' + $outputFileName = Get-ObjectPropertyValue -InputObject $outputItem -Name 'fileName' + + if ([string]::IsNullOrWhiteSpace($outputFileName) -and -not [string]::IsNullOrWhiteSpace($resolvedSourcePath)) { + $outputFileName = Split-Path -Leaf $resolvedSourcePath + } + + $relativePath = Join-RelativePath -Left $destinationFolder -Right $outputFileName + $destinationPath = ConvertTo-PhysicalPath -Root $BundleRoot -RelativePath $relativePath + $family = Get-ObjectPropertyValue -InputObject $outputItem -Name 'family' -DefaultValue $CommandItem.family + $parseHints = @(Get-ObjectPropertyValue -InputObject $outputItem -Name 'parseHints' -DefaultValue @()) + $notes = Get-ObjectPropertyValue -InputObject $outputItem -Name 'notes' + + if ([string]::IsNullOrWhiteSpace($resolvedSourcePath) -or -not (Test-Path -LiteralPath $resolvedSourcePath -PathType Leaf)) { + $artifact = New-ArtifactRecord -Category 'export' -Family $family -RelativePath $relativePath -OriginPath $resolvedSourcePath -Status 'missing' -ParseHints $parseHints -Notes $notes + $Artifacts.Add($artifact) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'missing' -Origin $resolvedSourcePath -Reason $null + continue + } + + try { + Initialize-ParentDirectory -Path $destinationPath + Copy-Item -LiteralPath $resolvedSourcePath -Destination $destinationPath -Force + $sourceFile = Get-Item -LiteralPath $resolvedSourcePath -ErrorAction Stop + $artifact = New-ArtifactRecord -Category 'export' -Family $family -RelativePath $relativePath -OriginPath $resolvedSourcePath -Status 'collected' -ParseHints $parseHints -FilePath $destinationPath -Notes $notes -StartUtc $sourceFile.CreationTimeUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') -EndUtc $sourceFile.LastWriteTimeUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + catch { + $artifact = New-ArtifactRecord -Category 'export' -Family $family -RelativePath $relativePath -OriginPath $resolvedSourcePath -Status 'failed' -ParseHints $parseHints -Notes $_.Exception.Message + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $resolvedSourcePath -Reason $_.Exception.Message + } + + $Artifacts.Add($artifact) + } +} + +function New-MdmDiagnosticsCommandItem { + param( + [Parameter(Mandatory = $true)] + [string]$BundleId + ) + + $stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('CmtraceOpen-{0}' -f $BundleId) + Initialize-Directory -Path $stagingRoot + + $zipPath = Join-Path $stagingRoot 'MDMDiagReport.zip' + + return [pscustomobject]@{ + id = 'mdm-diagnostics-report' + family = 'diagnostic-command' + command = 'MdmDiagnosticsTool.exe' + arguments = @('-area', 'DeviceEnrollment;DeviceProvisioning;Autopilot', '-zip', $zipPath) + fileName = 'mdmdiagnosticstool.txt' + notes = 'Captures a fresh MDM diagnostics report during bundle collection.' + generatedOutputs = @( + [pscustomobject]@{ + family = 'mdm-diagnostics-report' + sourcePath = $zipPath + destinationFolder = 'evidence/exports' + fileName = 'MDMDiagReport.zip' + parseHints = @('zip', 'mdm') + notes = 'Fresh MDM diagnostics ZIP captured during bundle collection.' + } + ) + } +} + +function Get-DsRegStatusSummary { + $capture = Invoke-ExternalCommandCapture -Command 'dsregcmd.exe' -Arguments @('/status') + + $summary = [ordered]@{ + capture = $capture + azureAdJoined = $null + domainJoined = $null + enterpriseJoined = $null + tenantId = $null + tenantName = $null + deviceId = $null + } + + if (-not $capture.found -or $capture.exitCode -ne 0 -or [string]::IsNullOrWhiteSpace($capture.output)) { + return $summary + } + + foreach ($line in ($capture.output -split "`r?`n")) { + if ($line -match '^\s*AzureAdJoined\s*:\s*(.+?)\s*$') { + $summary.azureAdJoined = $Matches[1].Trim() + continue + } + + if ($line -match '^\s*DomainJoined\s*:\s*(.+?)\s*$') { + $summary.domainJoined = $Matches[1].Trim() + continue + } + + if ($line -match '^\s*EnterpriseJoined\s*:\s*(.+?)\s*$') { + $summary.enterpriseJoined = $Matches[1].Trim() + continue + } + + if ($line -match '^\s*TenantId\s*:\s*(.+?)\s*$') { + $summary.tenantId = $Matches[1].Trim() + continue + } + + if ($line -match '^\s*TenantName\s*:\s*(.+?)\s*$') { + $summary.tenantName = $Matches[1].Trim() + continue + } + + if ($line -match '^\s*DeviceId\s*:\s*(.+?)\s*$') { + $summary.deviceId = $Matches[1].Trim() + } + } + + return $summary +} + +function Get-LastLoggedOnUser { + try { + $properties = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -ErrorAction Stop + if ($properties.LastLoggedOnUser) { + return $properties.LastLoggedOnUser + } + } + catch { + } + + return $null +} + +function Get-DeviceContext { + $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem + $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem + $bios = Get-CimInstance -ClassName Win32_BIOS + $dsregStatus = Get-DsRegStatusSummary + + $primaryUser = $computerSystem.UserName + if (-not $primaryUser) { + $primaryUser = Get-LastLoggedOnUser + } + + return [ordered]@{ + device = [ordered]@{ + deviceName = $env:COMPUTERNAME + primaryUser = $primaryUser + serialNumber = $bios.SerialNumber + manufacturer = $computerSystem.Manufacturer + model = $computerSystem.Model + platform = 'Windows' + osVersion = ('{0} {1} (Build {2})' -f $operatingSystem.Caption, $operatingSystem.Version, $operatingSystem.BuildNumber) + tenant = if ($dsregStatus.tenantName) { $dsregStatus.tenantName } elseif ($dsregStatus.tenantId) { $dsregStatus.tenantId } else { $null } + azureAdJoined = $dsregStatus.azureAdJoined + domainJoined = $dsregStatus.domainJoined + enterpriseJoined = $dsregStatus.enterpriseJoined + deviceId = $dsregStatus.deviceId + } + dsregStatus = $dsregStatus + } +} + +function Test-EventChannelExists { + param( + [Parameter(Mandatory = $true)] + [string]$Channel + ) + + try { + $null = Get-WinEvent -ListLog $Channel -ErrorAction Stop + return $true + } + catch { + return $false + } +} + +function Get-RedactedUploadUrl { + param( + [AllowEmptyString()] + [string]$Url + ) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + $uriBuilder = New-Object System.UriBuilder($Url) + $uriBuilder.Query = '' + return $uriBuilder.Uri.AbsoluteUri.TrimEnd('?') +} + +function Resolve-BlobUploadUrl { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + [Parameter(Mandatory = $true)] + [string]$DefaultBlobName, + [AllowNull()] + [string]$RequestedBlobName + ) + + $uri = New-Object System.Uri($Url) + if ($uri.Scheme -ne 'https') { + throw 'SAS uploads require an https URL.' + } + + $effectiveBlobName = if ([string]::IsNullOrWhiteSpace($RequestedBlobName)) { + $DefaultBlobName + } + else { + $RequestedBlobName + } + + $uriBuilder = New-Object System.UriBuilder($uri) + $path = $uriBuilder.Path + + if ([string]::IsNullOrWhiteSpace($RequestedBlobName) -and $path -match '\.zip$') { + return [ordered]@{ + uploadUrl = $uri.AbsoluteUri + blobName = [System.IO.Path]::GetFileName($path) + redactedUrl = (Get-RedactedUploadUrl -Url $uri.AbsoluteUri) + } + } + + $escapedBlobName = ($effectiveBlobName -split '/') | ForEach-Object { [System.Uri]::EscapeDataString($_) } + $blobPath = [string]::Join('/', $escapedBlobName) + $trimmedPath = $path.TrimEnd('/') + $uriBuilder.Path = ('{0}/{1}' -f $trimmedPath, $blobPath) + + return [ordered]@{ + uploadUrl = $uriBuilder.Uri.AbsoluteUri + blobName = $effectiveBlobName + redactedUrl = (Get-RedactedUploadUrl -Url $uriBuilder.Uri.AbsoluteUri) + } +} + +function Invoke-BlobUpload { + param( + [Parameter(Mandatory = $true)] + [string]$ZipPath, + [Parameter(Mandatory = $true)] + [string]$UploadUrl + ) + + $headers = @{ + 'x-ms-blob-type' = 'BlockBlob' + 'x-ms-version' = '2021-12-02' + } + + $response = Invoke-WebRequest -Uri $UploadUrl -Method Put -InFile $ZipPath -Headers $headers -ContentType 'application/zip' -UseBasicParsing + + return [ordered]@{ + statusCode = $response.StatusCode + statusDescription = $response.StatusDescription + } +} + +function Add-ObservedGap { + param( + [Parameter(Mandatory = $true)] + [object]$ObservedGaps, + [Parameter(Mandatory = $true)] + [string]$Status, + [Parameter(Mandatory = $true)] + [string]$Origin, + [AllowNull()] + [string]$Reason + ) + + if ($Status -eq 'missing') { + $ObservedGaps.Add(('Missing expected artifact: {0}' -f $Origin)) + return + } + + if ($Status -eq 'failed') { + if ($Reason) { + $ObservedGaps.Add(('Collection failed for {0}: {1}' -f ($Origin, $Reason))) + } + else { + $ObservedGaps.Add(('Collection failed for {0}' -f $Origin)) + } + } +} + +Start-CollectorTranscript + +try { + Write-Step 'Loading collector profile' + $collectorProfile = Read-CollectorProfile -Path $CollectorProfilePath + + Write-Step 'Gathering device metadata' + $deviceContext = Get-DeviceContext + $bundleId = 'CMTRACE-{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmss'), (ConvertTo-SafeFileName -Value $deviceContext.device.deviceName) + $bundleRoot = Join-Path $OutputRoot $bundleId + $evidenceRoot = Join-Path $bundleRoot 'evidence' + + $logRoot = Join-Path $evidenceRoot 'logs' + $registryRoot = Join-Path $evidenceRoot 'registry' + $eventLogRoot = Join-Path $evidenceRoot 'event-logs' + $exportRoot = Join-Path $evidenceRoot 'exports' + $screenshotRoot = Join-Path $evidenceRoot 'screenshots' + $commandOutputRoot = Join-Path $evidenceRoot 'command-output' + + Write-Step 'Creating bundle structure' + foreach ($path in @($bundleRoot, $evidenceRoot, $logRoot, $registryRoot, $eventLogRoot, $exportRoot, $screenshotRoot, $commandOutputRoot)) { + Initialize-Directory -Path $path + } + + $commandItems = New-Object System.Collections.Generic.List[object] + foreach ($commandItem in @($collectorProfile.commands)) { + $commandItems.Add($commandItem) + } + + if (-not @($commandItems | Where-Object { $_.command -ieq 'MdmDiagnosticsTool.exe' })) { + $commandItems.Add((New-MdmDiagnosticsCommandItem -BundleId $bundleId)) + } + + $artifacts = New-Object System.Collections.Generic.List[object] + $observedGaps = New-Object System.Collections.Generic.List[string] + + Write-Step 'Collecting curated IME logs' + foreach ($logItem in @($collectorProfile.logs)) { + $matchedFiles = @(Get-ChildItem -Path $logItem.sourcePattern -File -ErrorAction SilentlyContinue | Sort-Object FullName) + + if ($matchedFiles.Count -eq 0) { + $relativePath = Join-RelativePath -Left $logItem.destinationFolder -Right (Split-Path -Leaf $logItem.sourcePattern) + $artifact = New-ArtifactRecord -Category 'log' -Family $logItem.family -RelativePath $relativePath -OriginPath $logItem.sourcePattern -Status 'missing' -ParseHints $logItem.parseHints -Notes $logItem.notes + $artifacts.Add($artifact) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'missing' -Origin $logItem.sourcePattern -Reason $null + continue + } + + foreach ($sourceFile in $matchedFiles) { + $relativePath = Join-RelativePath -Left $logItem.destinationFolder -Right $sourceFile.Name + $destinationPath = ConvertTo-PhysicalPath -Root $bundleRoot -RelativePath $relativePath + + try { + Initialize-ParentDirectory -Path $destinationPath + Copy-Item -LiteralPath $sourceFile.FullName -Destination $destinationPath -Force + $artifact = New-ArtifactRecord -Category 'log' -Family $logItem.family -RelativePath $relativePath -OriginPath $sourceFile.FullName -Status 'collected' -ParseHints $logItem.parseHints -FilePath $destinationPath -Notes $logItem.notes -StartUtc $sourceFile.CreationTimeUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') -EndUtc $sourceFile.LastWriteTimeUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + catch { + $artifact = New-ArtifactRecord -Category 'log' -Family $logItem.family -RelativePath $relativePath -OriginPath $sourceFile.FullName -Status 'failed' -ParseHints $logItem.parseHints -Notes (Protect-SecretText -Text $_.Exception.Message) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'failed' -Origin $sourceFile.FullName -Reason (Protect-SecretText -Text $_.Exception.Message) + } + + $artifacts.Add($artifact) + } + } + + Write-Step 'Exporting curated registry paths' + foreach ($registryItem in @($collectorProfile.registry)) { + $relativePath = Join-RelativePath -Left 'evidence/registry' -Right $registryItem.fileName + $destinationPath = ConvertTo-PhysicalPath -Root $bundleRoot -RelativePath $relativePath + + if (-not (Test-RegistryKeyExists -RegistryPath $registryItem.path)) { + $artifact = New-ArtifactRecord -Category 'registry' -Family $registryItem.family -RelativePath $relativePath -OriginPath $registryItem.path -Status 'missing' -ParseHints @('reg') -Notes $registryItem.notes + $artifacts.Add($artifact) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'missing' -Origin $registryItem.path -Reason $null + continue + } + + Initialize-ParentDirectory -Path $destinationPath + & reg.exe export $registryItem.path $destinationPath /y | Out-Null + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0 -and (Test-Path -LiteralPath $destinationPath)) { + $artifact = New-ArtifactRecord -Category 'registry' -Family $registryItem.family -RelativePath $relativePath -OriginPath $registryItem.path -Status 'collected' -ParseHints @('reg') -FilePath $destinationPath -Notes $registryItem.notes + } + else { + $notes = 'reg.exe export failed with exit code {0}.' -f $exitCode + $artifact = New-ArtifactRecord -Category 'registry' -Family $registryItem.family -RelativePath $relativePath -OriginPath $registryItem.path -Status 'failed' -ParseHints @('reg') -Notes $notes + Add-ObservedGap -ObservedGaps $observedGaps -Status 'failed' -Origin $registryItem.path -Reason $notes + } + + $artifacts.Add($artifact) + } + + Write-Step 'Exporting curated event channels' + foreach ($eventItem in @($collectorProfile.eventLogs)) { + $relativePath = Join-RelativePath -Left 'evidence/event-logs' -Right $eventItem.fileName + $destinationPath = ConvertTo-PhysicalPath -Root $bundleRoot -RelativePath $relativePath + + if (-not (Test-EventChannelExists -Channel $eventItem.channel)) { + $artifact = New-ArtifactRecord -Category 'event-log' -Family $eventItem.family -RelativePath $relativePath -OriginPath $eventItem.channel -Status 'missing' -ParseHints @('evtx') -Notes $eventItem.notes + $artifacts.Add($artifact) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'missing' -Origin $eventItem.channel -Reason $null + continue + } + + Initialize-ParentDirectory -Path $destinationPath + & wevtutil.exe epl $eventItem.channel $destinationPath /ow:true | Out-Null + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0 -and (Test-Path -LiteralPath $destinationPath)) { + $artifact = New-ArtifactRecord -Category 'event-log' -Family $eventItem.family -RelativePath $relativePath -OriginPath $eventItem.channel -Status 'collected' -ParseHints @('evtx') -FilePath $destinationPath -Notes $eventItem.notes + } + else { + $notes = 'wevtutil.exe epl failed with exit code {0}.' -f $exitCode + $artifact = New-ArtifactRecord -Category 'event-log' -Family $eventItem.family -RelativePath $relativePath -OriginPath $eventItem.channel -Status 'failed' -ParseHints @('evtx') -Notes $notes + Add-ObservedGap -ObservedGaps $observedGaps -Status 'failed' -Origin $eventItem.channel -Reason $notes + } + + $artifacts.Add($artifact) + } + + Write-Step 'Collecting exported file artifacts' + foreach ($exportItem in @($collectorProfile.exports)) { + $resolvedSourcePath = Expand-EnvironmentPath -Path $exportItem.sourcePath + $destinationFolder = if ([string]::IsNullOrWhiteSpace($exportItem.destinationFolder)) { 'evidence/exports' } else { $exportItem.destinationFolder } + $exportFileName = if ([string]::IsNullOrWhiteSpace($exportItem.fileName)) { Split-Path -Leaf $resolvedSourcePath } else { $exportItem.fileName } + $relativePath = Join-RelativePath -Left $destinationFolder -Right $exportFileName + $destinationPath = ConvertTo-PhysicalPath -Root $bundleRoot -RelativePath $relativePath + + if ([string]::IsNullOrWhiteSpace($resolvedSourcePath) -or -not (Test-Path -LiteralPath $resolvedSourcePath -PathType Leaf)) { + $artifact = New-ArtifactRecord -Category 'export' -Family $exportItem.family -RelativePath $relativePath -OriginPath $resolvedSourcePath -Status 'missing' -ParseHints $exportItem.parseHints -Notes $exportItem.notes + $artifacts.Add($artifact) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'missing' -Origin $resolvedSourcePath -Reason $null + continue + } + + try { + Initialize-ParentDirectory -Path $destinationPath + Copy-Item -LiteralPath $resolvedSourcePath -Destination $destinationPath -Force + $sourceFile = Get-Item -LiteralPath $resolvedSourcePath -ErrorAction Stop + $artifact = New-ArtifactRecord -Category 'export' -Family $exportItem.family -RelativePath $relativePath -OriginPath $resolvedSourcePath -Status 'collected' -ParseHints $exportItem.parseHints -FilePath $destinationPath -Notes $exportItem.notes -StartUtc $sourceFile.CreationTimeUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') -EndUtc $sourceFile.LastWriteTimeUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + catch { + $artifact = New-ArtifactRecord -Category 'export' -Family $exportItem.family -RelativePath $relativePath -OriginPath $resolvedSourcePath -Status 'failed' -ParseHints $exportItem.parseHints -Notes (Protect-SecretText -Text $_.Exception.Message) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'failed' -Origin $resolvedSourcePath -Reason (Protect-SecretText -Text $_.Exception.Message) + } + + $artifacts.Add($artifact) + } + + Write-Step 'Collecting command outputs' + foreach ($commandItem in $commandItems) { + $relativePath = Join-RelativePath -Left 'evidence/command-output' -Right $commandItem.fileName + $destinationPath = ConvertTo-PhysicalPath -Root $bundleRoot -RelativePath $relativePath + $commandInvocationText = Get-CommandInvocationText -CommandItem $commandItem + + if ($commandItem.id -eq 'dsregcmd-status') { + $capture = $deviceContext.dsregStatus.capture + } + else { + $capture = Invoke-ExternalCommandCapture -Command $commandItem.command -Arguments @($commandItem.arguments) + } + + if (-not $capture.found) { + $artifact = New-ArtifactRecord -Category 'command-output' -Family $commandItem.family -RelativePath $relativePath -OriginPath $commandInvocationText -Status 'missing' -ParseHints @('plain-text') -Notes $capture.error + $artifacts.Add($artifact) + Add-ObservedGap -ObservedGaps $observedGaps -Status 'missing' -Origin $commandItem.command -Reason $capture.error + continue + } + + $commandText = if ([string]::IsNullOrWhiteSpace($capture.output)) { '[no output returned]' } else { $capture.output } + Write-TextFile -Content $commandText -Path $destinationPath + + if ($capture.exitCode -eq 0) { + $artifact = New-ArtifactRecord -Category 'command-output' -Family $commandItem.family -RelativePath $relativePath -OriginPath $commandInvocationText -Status 'collected' -ParseHints @('plain-text') -FilePath $destinationPath -Notes $commandItem.notes + } + else { + $notes = '{0} exited with code {1}.' -f $commandItem.command, $capture.exitCode + $artifact = New-ArtifactRecord -Category 'command-output' -Family $commandItem.family -RelativePath $relativePath -OriginPath $commandInvocationText -Status 'failed' -ParseHints @('plain-text') -FilePath $destinationPath -Notes $notes + Add-ObservedGap -ObservedGaps $observedGaps -Status 'failed' -Origin $commandItem.command -Reason $notes + } + + $artifacts.Add($artifact) + Add-GeneratedCommandArtifacts -Artifacts $artifacts -ObservedGaps $observedGaps -CommandItem $commandItem -BundleRoot $bundleRoot + } + + $notesPath = Join-Path $bundleRoot 'notes.md' + $manifestPath = Join-Path $bundleRoot 'manifest.json' + + $statusCounts = [ordered]@{ + collected = @($artifacts | Where-Object { $_.status -eq 'collected' }).Count + missing = @($artifacts | Where-Object { $_.status -eq 'missing' }).Count + failed = @($artifacts | Where-Object { $_.status -eq 'failed' }).Count + skipped = @($artifacts | Where-Object { $_.status -eq 'skipped' }).Count + } + + $uploadRequested = (-not $LocalOnly) -and (-not [string]::IsNullOrWhiteSpace($SasUrl)) + $sanitizedBundleLabel = ConvertTo-SafeFileName -Value $BundleLabel + $zipFileName = ('{0}.zip' -f $bundleId) + $zipPath = Join-Path $OutputRoot $zipFileName + + $uploadInfo = [ordered]@{ + requested = $uploadRequested + destination = (Get-RedactedUploadUrl -Url $SasUrl) + blobName = $null + collectorRunLogPath = $script:CollectorRunLogPath + } + + if ($uploadRequested) { + $resolvedUpload = Resolve-BlobUploadUrl -Url $SasUrl -DefaultBlobName $zipFileName -RequestedBlobName $BlobName + $uploadInfo.destination = $resolvedUpload.redactedUrl + $uploadInfo.blobName = $resolvedUpload.blobName + } + + Write-Step 'Writing notes and manifest' + $manifestCreatedUtc = Get-UtcTimestamp + $manifestCaseReference = if ([string]::IsNullOrWhiteSpace($CaseReference)) { $bundleId } else { $CaseReference } + $operatorContactValue = if ([string]::IsNullOrWhiteSpace($OperatorContact)) { $null } else { $OperatorContact } + $analysisObservedGaps = if ($observedGaps.Count -gt 0) { @($observedGaps) } else { @('No collection gaps were recorded during bundle creation.') } + $observedGapSummary = [string]::Join('; ', @($analysisObservedGaps | ForEach-Object { [string]$_ })) + $artifactArray = @($artifacts.ToArray()) + $primaryEntryPoints = @( + 'evidence/logs', + 'evidence/registry', + 'evidence/event-logs', + 'evidence/exports', + 'evidence/screenshots', + 'evidence/command-output' + ) + $expectedEvidence = @( + [ordered]@{ + category = 'log' + relativePath = 'evidence/logs' + required = $true + reason = 'Primary troubleshooting timeline and parser input.' + }, + [ordered]@{ + category = 'registry' + relativePath = 'evidence/registry' + required = $false + reason = 'Useful for enrollment, policy, and IME state.' + }, + [ordered]@{ + category = 'event-log' + relativePath = 'evidence/event-logs' + required = $false + reason = 'Curated adjacent evidence for MDM, enrollment, and Autopilot.' + }, + [ordered]@{ + category = 'export' + relativePath = 'evidence/exports' + required = $false + reason = 'Exported supporting artifacts such as live Autopilot JSON state.' + }, + [ordered]@{ + category = 'command-output' + relativePath = 'evidence/command-output' + required = $false + reason = 'Point-in-time command output for device join and identity state.' + } + ) + + $notesContent = @" +# Investigation Notes + +## Case Summary + +- Case reference: $CaseReference +- Operator: $OperatorName +- Started: $(Get-UtcTimestamp) +- Device: $($deviceContext.device.deviceName) +- Scope: Curated Intune evidence collection generated by Invoke-CmtraceEvidenceCollection.ps1. +- Collector run log: $($script:CollectorRunLogPath) + +## Collection Notes + +| Time | Action | Result | +| --- | --- | --- | +| $(Get-UtcTimestamp) | Created evidence bundle structure | Bundle root: $bundleRoot | +| $(Get-UtcTimestamp) | Collected curated artifacts and exports | Collected=$($statusCounts.collected), Missing=$($statusCounts.missing), Failed=$($statusCounts.failed) | + +## Intake Notes + +- Lead artifact: evidence/logs +- Supporting exports: evidence/exports +- Known gaps: $observedGapSummary +- Upload requested: $uploadRequested +- Upload destination: $($uploadInfo.destination) +"@ + Write-TextFile -Content $notesContent.Trim() -Path $notesPath + + $manifest = [ordered]@{ + schemaVersion = '1.0' + bundle = [ordered]@{ + bundleId = $bundleId + bundleLabel = $sanitizedBundleLabel + createdUtc = $manifestCreatedUtc + caseReference = $manifestCaseReference + summary = 'Curated endpoint evidence bundle collected for Intune and adjacent Windows diagnostics.' + operator = [ordered]@{ + name = $OperatorName + team = $OperatorTeam + contact = $operatorContactValue + } + device = $deviceContext.device + } + collection = [ordered]@{ + method = 'intune-powershell-script' + collectorProfile = $collectorProfile.profileName + collectorVersion = $script:CollectorVersion + sourceRoot = $OutputRoot + collectedBy = $OperatorName + collectedUtc = $manifestCreatedUtc + chainOfCustodyNotes = 'Collected locally with built-in PowerShell and native Windows tools. Missing or failed artifacts are retained in this manifest.' + results = [ordered]@{ + artifactCounts = $statusCounts + zipFileName = $zipFileName + upload = $uploadInfo + } + } + intakeHints = [ordered]@{ + manifestPath = 'manifest.json' + notesPath = 'notes.md' + evidenceRoot = 'evidence' + primaryEntryPoints = $primaryEntryPoints + } + artifacts = $artifactArray + expectedEvidence = $expectedEvidence + analysis = [ordered]@{ + status = 'not-started' + priorityQuestions = @( + 'What failed, and when was it first observed?', + 'Which expected artifacts are missing or incomplete?', + 'Which collected artifact should be treated as the lead source?' + ) + observedGaps = $analysisObservedGaps + handoffSummary = 'Start with evidence/logs, evidence/exports, and dsregcmd-status.txt, then use the manifest to review missing or failed collections.' + } + } + + Write-JsonFile -InputObject $manifest -Path $manifestPath + + Write-Step 'Compressing evidence bundle' + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + Compress-Archive -LiteralPath $bundleRoot -DestinationPath $zipPath -CompressionLevel Optimal -Force + + $uploadStatus = [ordered]@{ + attempted = $uploadRequested + uploaded = $false + destination = $uploadInfo.destination + statusCode = $null + error = $null + } + + if ($uploadRequested) { + Write-Step 'Uploading zip to Azure Blob Storage' + try { + $uploadResult = Invoke-BlobUpload -ZipPath $zipPath -UploadUrl $resolvedUpload.uploadUrl + $uploadStatus.uploaded = $true + $uploadStatus.statusCode = $uploadResult.statusCode + } + catch { + $uploadStatus.error = Protect-SecretText -Text $_.Exception.Message + Write-Warning ('Upload failed: {0}' -f $uploadStatus.error) + } + } + else { + Write-Step 'Skipping upload because no SAS destination was provided' + } + + $result = [pscustomobject]@{ + BundleId = $bundleId + BundleRoot = $bundleRoot + ManifestPath = $manifestPath + NotesPath = $notesPath + ZipPath = $zipPath + CollectorLogPath = $script:CollectorRunLogPath + ArtifactCounts = $statusCounts + UploadStatus = $uploadStatus + } + + Write-Step 'Collection complete' + $result +} +catch { + $sanitizedMessage = Protect-SecretText -Text $_.Exception.Message + Write-Error ('Collector failed: {0}' -f $sanitizedMessage) + throw $sanitizedMessage +} +finally { + Stop-CollectorTranscript +} \ No newline at end of file diff --git a/references/collection/README.md b/references/collection/README.md new file mode 100644 index 000000000..c9fc9995b --- /dev/null +++ b/references/collection/README.md @@ -0,0 +1,155 @@ +# Evidence Collection + +This folder contains a dependency-light PowerShell collector for building a local evidence bundle that matches the tracked cmtrace-open evidence template as closely as practical. + +## Files + +- `Invoke-CmtraceEvidenceCollection.ps1`: collects curated logs, registry exports, event-log exports, and command output into a bundle, writes `manifest.json` and `notes.md`, compresses the bundle, and can optionally upload the zip to Azure Blob Storage with a SAS URL. +- `Invoke-CmtraceEvidenceBootstrap.ps1`: stages the collector and profile locally, accepts a direct SAS URL for upload, and registers a one-time `SYSTEM` scheduled task to run the collector outside the assignment process. +- `Detect-CmtraceEvidenceBootstrap.ps1`: Intune Remediations detection script that checks the bootstrap throttle state, removes only stale, invalid, or task-orphaned state, and exits `1` when remediation should restage the bootstrap. +- `Remediate-CmtraceEvidenceBootstrap.ps1`: Intune Remediations entrypoint that mirrors the bootstrap behavior in a self-contained script so the upload does not depend on a sibling file being present on the endpoint. +- `intune-evidence-profile.json`: curated collection profile consumed by the script. + +## Intended execution model + +- Windows endpoint execution under Intune or another local management runner. +- No Azure PowerShell modules. +- No external dependencies beyond built-in PowerShell cmdlets and native Windows tools such as `reg.exe`, `wevtutil.exe`, and `dsregcmd.exe`. + +## Pre-requirments + +- The detection, bootstrap, and remediation path is compatible with Windows PowerShell 5.1 and can run before PowerShell 7 is installed. +- The collector still runs through the resolved PowerShell 7.5.4 `pwsh.exe` path after bootstrap. +- `Invoke-CmtraceEvidenceBootstrap.ps1` enforces that collector prerequisite by downloading and installing the pinned PowerShell 7.5.4 x64 MSI when `pwsh.exe` is missing or older than required. + +## Examples + +Local-only collection: + +```powershell +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-CmtraceEvidenceCollection.ps1 +``` + +Local-only collection to a custom root: + +```powershell +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-CmtraceEvidenceCollection.ps1 -OutputRoot 'C:\ProgramData\CmtraceOpen\Evidence' -CaseReference 'INC-12345' +``` + +Upload to a blob SAS URL that already includes the target zip name: + +```powershell +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-CmtraceEvidenceCollection.ps1 -SasUrl 'https://account.blob.core.windows.net/evidence/cmtrace-case.zip?' +``` + +Upload to a container or virtual-folder SAS URL and let the script append a blob name: + +```powershell +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-CmtraceEvidenceCollection.ps1 -SasUrl 'https://account.blob.core.windows.net/evidence/intune?' -BlobName 'collections/cmtrace-case.zip' +``` + +Force local-only behavior even if a SAS URL is supplied: + +```powershell +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-CmtraceEvidenceCollection.ps1 -SasUrl 'https://account.blob.core.windows.net/evidence?' -LocalOnly +``` + +Bootstrap a one-time scheduled collection using explicit HTTPS payload URLs and a direct upload SAS URL: + +```powershell +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-CmtraceEvidenceBootstrap.ps1 -CollectorScriptUrl 'https://raw.githubusercontent.com////cmtrace-open/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1' -CollectorProfileUrl 'https://raw.githubusercontent.com////cmtrace-open/scripts/collection/intune-evidence-profile.json' -SasUrl 'https://account.blob.core.windows.net/evidence/container-or-blob?' +``` + +For Intune Remediations, upload `Detect-CmtraceEvidenceBootstrap.ps1` as the detection script and `Remediate-CmtraceEvidenceBootstrap.ps1` as the remediation script. + +## Output shape + +The script creates a bundle root like this: + +```text +CMTRACE-20260311-153000-DEVICE/ +├── manifest.json +├── notes.md +└── evidence/ + ├── logs/ + ├── registry/ + ├── event-logs/ + ├── exports/ + ├── screenshots/ + └── command-output/ +``` + +The resulting zip is created beside the bundle root. + +## Upload behavior + +- If `-SasUrl` is omitted, the script stays in local-only mode. +- Uploads use HTTPS with `Invoke-WebRequest` and `x-ms-blob-type: BlockBlob`. +- The script does not require storage account keys, Azure CLI, or Azure PowerShell. +- The manifest records the intended upload destination without exposing the SAS query string. +- If upload fails, the local bundle and zip still remain available and the script returns the upload error in its final output object. + +## Bootstrap behavior + +- `Invoke-CmtraceEvidenceBootstrap.ps1` is intended for assignment-side or other bootstrap execution where you do not want the full collector to run inline. +- `Detect-CmtraceEvidenceBootstrap.ps1` is the Intune Remediations detection-side companion. It returns `0` while the existing throttle window is still valid, and it returns `1` only after clearing stale, invalid, expired, or task-orphaned throttle state so remediation can run. +- `Remediate-CmtraceEvidenceBootstrap.ps1` intentionally mirrors the bootstrap logic in a self-contained file because Intune Remediations stores uploaded script content instead of a reference to the repo-side bootstrap path. +- In the Intune Remediations pairing, detection only cleans up stale or orphaned throttle state and signals remediation with exit `1`; remediation then uploads and runs the self-contained `Remediate-CmtraceEvidenceBootstrap.ps1` payload to restage the collector bootstrap flow. +- The detection, bootstrap, and remediation entrypoints are written to run under Windows PowerShell 5.1 before PowerShell 7 is present on the endpoint. +- Each bootstrap or remediation run downloads the collector and profile from HTTPS URLs into `C:\ProgramData\CmtraceOpen\Staging` and resolves run-scoped staged paths for that specific run. +- The staged collector remains generic. Runtime values such as the SAS URL, bundle metadata, and local-only mode are still passed at execution time through the scheduled task arguments rather than being baked into the staged collector file. +- The one-time `SYSTEM` scheduled task is registered with those exact staged collector and profile paths for that run. +- The bootstrap resolves the PowerShell 7.5.4 `pwsh.exe` path for the collector and enforces that prerequisite by staging and installing the pinned PowerShell 7.5.4 x64 MSI when `pwsh.exe` is missing or below the required version. +- Runtime resolution treats a missing or off-`PATH` `pwsh.exe` as a normal fallback case and continues to the pinned PowerShell 7.5.4 MSI install path instead of failing during command discovery. +- The bootstrap accepts a direct upload SAS URL and passes it to the collector scheduled task when not running in local-only mode. +- The bootstrap validates that the staged collector payload parses as PowerShell and that the staged profile parses as JSON before it registers the scheduled task. +- `CollectorProfileUrl` must return raw JSON content. Do not point it at an HTML landing page, portal download page, or any URL that wraps the JSON in another response format. +- If Intune or IME logs appear to show a space inserted into `intune-evidence-profile.json`, that is typically log line wrapping or copied-output formatting rather than a different filename on disk. +- The bootstrap registers a one-time `SYSTEM` scheduled task and writes state to `C:\ProgramData\CmtraceOpen\State\collection-bootstrap.json` so repeated execution can be throttled. +- Bootstrap and remediation status output now include a content-derived payload identifier, and the saved throttle state records the same identifier so operators can confirm which uploaded script content actually ran on a device. +- That payload identifier is especially useful with Intune Remediations because it helps catch stale remediation uploads where the device is still running older uploaded script content than expected. +- The remediation entrypoint self-relaunches into 64-bit PowerShell when needed before it starts the remediation transcript or registers the scheduled task. +- The remediation entrypoint starts a transcript under `C:\ProgramData\CmtraceOpen\Logs` for the remediation bootstrap run itself; that transcript does not cover the later collector scheduled task. +- If the log folder exists but the transcript file is missing, treat that as transcript startup failure rather than a successful transcript run; the remediation now emits compact status output for that condition. +- The remediation entrypoint keeps the same parameter flow and compact status-style output, but normal output now includes the transcript path and more troubleshooting context even without `-Verbose`. +- The bootstrap ships with placeholder URLs on `example.invalid`; pass real HTTPS payload URLs at execution time. +- Use commit-pinned raw GitHub URLs instead of `main` if you want deployment-time payload pinning. + +## Collection behavior + +- Missing or failed artifacts are recorded in `manifest.json` instead of aborting the whole run. +- The collector now starts a durable transcript under `C:\ProgramData\CmtraceOpen\Logs\Collection` and reports the resolved log path in normal status output, `notes.md`, and the final result object. +- Nested profile destinations such as `evidence/logs/panther` and `evidence/exports/autopilot` are created automatically before copy, export, or write operations. +- Current profile coverage includes curated IME logs; narrow Panther setup logs; MDM, IME, and Autopilot registry exports; curated event channels; targeted supporting files under `evidence/exports`; `dsregcmd /status`; and Delivery Optimization snapshots. +- Autopilot and enrollment-adjacent registry coverage includes these roots when present: + - `HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot` + - `HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings` + - `HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\ESPTrackingInfo\Diagnostics` + - `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\Win32Apps` + - `HKLM\SOFTWARE\Microsoft\Provisioning\NodeCache\CSP` + - `HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\SyncML\ODJApplied` +- The curated event channel set currently includes: + - `Microsoft-Windows-AAD/Operational` + - `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin` + - `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Operational` + - `Microsoft-Windows-DeliveryOptimization/Operational` + - `Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot` + - `Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService` + - `Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin` + - `Microsoft-Windows-Shell-Core/Operational` + - `Microsoft-Windows-Time-Service/Operational` + - `Microsoft-Windows-User Device Registration/Admin` +- Targeted file exports include `AutoPilotConfigurationFile.json`, JSON staged under `C:\Windows\ServiceState\Autopilot`, existing `C:\Users\Public\Documents\MDMDiagnostics` output, and `AutopilotDDSZTDFile.json` when present. +- The collector runs `MdmDiagnosticsTool.exe` during collection and harvests the generated `MDMDiagReport.zip` back into the same bundle so fresh diagnostics land beside any pre-existing `MDMDiagnostics` output. +- Delivery Optimization command capture currently includes `Get-DeliveryOptimizationStatus` and `Get-DeliveryOptimizationPerfSnap` snapshots. +- Enrollment `FirstSync` is not exported separately because the `Enrollments` export already covers that state, and `EstablishedCorrelations` is not duplicated because the Autopilot diagnostics export already includes it. +- The profile can be adjusted later without changing app code. + +## Operational notes + +- Run under a context that can read the targeted logs, registry paths, and event channels. Intune `SYSTEM` is the primary target. +- Missing Autopilot-only or scenario-specific artifacts are normal on devices that are not in an Autopilot flow, never staged the related JSON, did not produce local `MDMDiagnostics` output yet, or simply do not have the targeted Panther/setup traces. Those cases are recorded in `manifest.json` and do not fail the collection run. +- `Compress-Archive` uses the built-in ZIP implementation and is sufficient for typical evidence bundles. Very large bundles should still be sized with care. +- Use short-lived SAS URLs with write permissions scoped only to the target container or blob path. +- If you use the bootstrap flow, keep the upload SAS short-lived and do not commit live SAS values into repo-tracked bootstrap or profile files. +- If profile download validation fails, the bootstrap now reports the staged path, the source URL with query redacted, and a short payload preview so it is easier to spot non-JSON responses. diff --git a/references/collection/Remediate-CmtraceEvidenceBootstrap.ps1 b/references/collection/Remediate-CmtraceEvidenceBootstrap.ps1 new file mode 100644 index 000000000..9d60cc0a3 --- /dev/null +++ b/references/collection/Remediate-CmtraceEvidenceBootstrap.ps1 @@ -0,0 +1,1119 @@ +<# +Intune Remediations uploads the selected script content rather than a repo-relative file path. +This entrypoint intentionally mirrors Invoke-CmtraceEvidenceBootstrap.ps1 so it can be +uploaded directly while the original bootstrap remains the reusable repo-side engine. +Keep parameters and behavior aligned with Invoke-CmtraceEvidenceBootstrap.ps1. +#> + +[CmdletBinding()] +param( + [string]$StagingRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Staging'), + [string]$StateRoot = (Join-Path $env:ProgramData 'CmtraceOpen\State'), + [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), + [string]$TaskName = 'CmtraceOpen-EvidenceCollection-Once', + [int]$DelayMinutes = 2, + [int]$ThrottleHours = 24, + [version]$RequiredPowerShellVersion = [version]'7.5.4', + [string]$PowerShellMsiUrl = 'https://github.com/PowerShell/PowerShell/releases/download/v7.5.4/PowerShell-7.5.4-win-x64.msi', + [string]$CollectorProfileUrl = '', #fill out + [string]$CollectorScriptUrl = '', #fill out + [string]$SasUrl = '', #fill out + [string]$BundleLabel = 'intune-endpoint-evidence', + [string]$CaseReference = '', + [string]$BlobName = '', + [string]$OperatorName = 'SYSTEM', + [string]$OperatorTeam = 'Intune', + [string]$OperatorContact = '', + [switch]$LocalOnly, + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ForwardedArgumentList { + $argumentList = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $PSBoundParameters.GetEnumerator()) { + $argumentList.Add('-{0}' -f $entry.Key) + + if ($entry.Value -is [System.Management.Automation.SwitchParameter]) { + if (-not $entry.Value.IsPresent) { + $argumentList.RemoveAt($argumentList.Count - 1) + continue + } + + continue + } + + if (($entry.Value -is [System.Collections.IEnumerable]) -and (-not ($entry.Value -is [string]))) { + foreach ($item in $entry.Value) { + $argumentList.Add([string]$item) + } + + continue + } + + $argumentList.Add([string]$entry.Value) + } + + return $argumentList +} + +function Invoke-In64BitPowerShell { + if (-not [Environment]::Is64BitOperatingSystem) { + return + } + + if ([Environment]::Is64BitProcess) { + return + } + + if ([string]::IsNullOrWhiteSpace($PSCommandPath)) { + return + } + + $sysNativePowerShell = Join-Path $env:WINDIR 'SysNative\WindowsPowerShell\v1.0\powershell.exe' + if (-not (Test-Path -LiteralPath $sysNativePowerShell -PathType Leaf)) { + return + } + + # Re-enter through 64-bit PowerShell so transcript creation and Task Scheduler calls stay in the expected view. + & $sysNativePowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @(Get-ForwardedArgumentList) + exit $LASTEXITCODE +} + +Invoke-In64BitPowerShell + +$script:BootstrapTranscriptDirectory = Join-Path $env:ProgramData 'CmtraceOpen\Logs' +$script:BootstrapTranscriptPath = Join-Path $script:BootstrapTranscriptDirectory ('Remediate-CmtraceEvidenceBootstrap-{0}.log' -f (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')) +$script:BootstrapTranscriptStarted = $false +$script:BootstrapTranscriptFilePresent = $false +$script:BootstrapTranscriptStartError = $null +$script:BootstrapTranscriptDiagnostics = $null +$script:BootstrapStage = 'startup' +$script:ConvertFromJsonSupportsDepth = $null +$script:BootstrapPayloadPath = if ([string]::IsNullOrWhiteSpace($PSCommandPath)) { $MyInvocation.MyCommand.Path } else { $PSCommandPath } +$script:BootstrapPayloadFingerprint = $null + +function Protect-SecretText { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $Value + } + + return [System.Text.RegularExpressions.Regex]::Replace($Value, 'https?://[^\s''""<>]+', { + param($Match) + + $uri = $null + if (-not [System.Uri]::TryCreate($Match.Value, [System.UriKind]::Absolute, [ref]$uri)) { + return $Match.Value + } + + if ([string]::IsNullOrWhiteSpace($uri.Query)) { + return $Match.Value + } + + return ('{0} [query redacted]' -f $uri.GetLeftPart([System.UriPartial]::Path)) + }) +} + +function Format-StatusValue { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '[not set]' + } + + $normalizedValue = (Protect-SecretText -Value $Value) -replace '\r?\n', ' ' + $normalizedValue = ($normalizedValue -replace ';', ',').Trim() + + if ([string]::IsNullOrWhiteSpace($normalizedValue)) { + return '[not set]' + } + + return $normalizedValue +} + +function Write-Step { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Verbose $Message +} + +function Get-UtcTimestamp { + return (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +} + +function Get-ContentFingerprint { + param( + [AllowEmptyString()] + [string]$Path + ) + + $fingerprint = [ordered]@{ + Algorithm = 'sha256' + Hash = $null + Identifier = 'unavailable' + Path = $Path + } + + if ([string]::IsNullOrWhiteSpace($Path)) { + return [pscustomobject]$fingerprint + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return [pscustomobject]$fingerprint + } + + try { + $fileHash = Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop + $hashValue = $fileHash.Hash.ToLowerInvariant() + $fingerprint.Algorithm = $fileHash.Algorithm.ToLowerInvariant() + $fingerprint.Hash = $hashValue + $fingerprint.Identifier = '{0}:{1}' -f $fingerprint.Algorithm, $hashValue.Substring(0, 16) + } + catch { + $fingerprint.Identifier = 'unavailable' + } + + return [pscustomobject]$fingerprint +} + +$script:BootstrapPayloadFingerprint = Get-ContentFingerprint -Path $script:BootstrapPayloadPath + +function Write-StageStatus { + param( + [Parameter(Mandatory = $true)] + [string]$Stage, + [Parameter(Mandatory = $true)] + [string]$Status, + [hashtable]$Details + ) + + $parts = New-Object System.Collections.Generic.List[string] + $parts.Add('stage={0}' -f $Stage) + $parts.Add('status={0}' -f $Status) + $parts.Add('payloadId={0}' -f (Format-StatusValue -Value ([string]$script:BootstrapPayloadFingerprint.Identifier))) + + if ($null -ne $Details) { + foreach ($entry in ($Details.GetEnumerator() | Sort-Object Key)) { + $parts.Add(('{0}={1}' -f $entry.Key, (Format-StatusValue -Value ([string]$entry.Value)))) + } + } + + Write-Output ($parts -join '; ') +} + +function Get-ProcessArchitectureLabel { + if ([Environment]::Is64BitProcess) { + return 'x64' + } + + return 'x86' +} + +function Get-TranscriptStatusDetails { + param( + [AllowEmptyString()] + [string]$ErrorMessage + ) + + $details = [ordered]@{ + host = $Host.Name + processArch = Get-ProcessArchitectureLabel + transcript = $script:BootstrapTranscriptPath + transcriptDirExists = (Test-Path -LiteralPath $script:BootstrapTranscriptDirectory -PathType Container) + transcriptFileExists = (Test-Path -LiteralPath $script:BootstrapTranscriptPath -PathType Leaf) + } + + if (-not [string]::IsNullOrWhiteSpace($ErrorMessage)) { + $details.error = (Format-StatusValue -Value $ErrorMessage) + } + + return $details +} + +function Get-TranscriptStatus { + if (-not $script:BootstrapTranscriptStarted) { + return 'unavailable' + } + + if (-not $script:BootstrapTranscriptFilePresent) { + return 'file-missing' + } + + return 'enabled' +} + +function Initialize-BootstrapTranscript { + try { + [System.IO.Directory]::CreateDirectory($script:BootstrapTranscriptDirectory) | Out-Null + $startTranscriptParameters = @{ + LiteralPath = $script:BootstrapTranscriptPath + Force = $true + } + + $startTranscriptCommand = Get-Command -Name Start-Transcript -ErrorAction Stop + if ($startTranscriptCommand.Parameters.ContainsKey('UseMinimalHeader')) { + $startTranscriptParameters.UseMinimalHeader = $true + } + + Start-Transcript @startTranscriptParameters | Out-Null + $script:BootstrapTranscriptStarted = $true + $script:BootstrapTranscriptFilePresent = (Test-Path -LiteralPath $script:BootstrapTranscriptPath -PathType Leaf) + + if (-not $script:BootstrapTranscriptFilePresent) { + $script:BootstrapTranscriptStartError = 'Start-Transcript returned without creating the expected transcript file.' + } + } + catch { + $script:BootstrapTranscriptStartError = $_.Exception.Message + $script:BootstrapTranscriptFilePresent = (Test-Path -LiteralPath $script:BootstrapTranscriptPath -PathType Leaf) + } + + $script:BootstrapTranscriptDiagnostics = Get-TranscriptStatusDetails -ErrorMessage $script:BootstrapTranscriptStartError +} + +Initialize-BootstrapTranscript + +function Initialize-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Write-JsonFile { + param( + [Parameter(Mandatory = $true)] + [object]$InputObject, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $utf8Encoding = New-Object System.Text.UTF8Encoding($false) + $json = $InputObject | ConvertTo-Json -Depth 10 + [System.IO.File]::WriteAllText($Path, $json, $utf8Encoding) +} + +function Read-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $null + } + + return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-JsonCompat -Depth 10) +} + +function ConvertFrom-JsonCompat { + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyString()] + [string]$InputObject, + [int]$Depth = 10 + ) + + process { + if ($null -eq $script:ConvertFromJsonSupportsDepth) { + $convertFromJsonCommand = Get-Command -Name ConvertFrom-Json -ErrorAction Stop + $script:ConvertFromJsonSupportsDepth = $convertFromJsonCommand.Parameters.ContainsKey('Depth') + } + + $convertFromJsonParameters = @{ + ErrorAction = 'Stop' + } + + if ($script:ConvertFromJsonSupportsDepth) { + $convertFromJsonParameters.Depth = $Depth + } + + return ($InputObject | ConvertFrom-Json @convertFromJsonParameters) + } +} + +function Format-TaskArgument { + param( + [AllowEmptyString()] + [string]$Value + ) + + return '"{0}"' -f ($Value -replace '"', '\"') +} + +function Test-HttpsUrl { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $false + } + + $uri = $null + if (-not [System.Uri]::TryCreate($Value, [System.UriKind]::Absolute, [ref]$uri)) { + return $false + } + + return $uri.Scheme -eq 'https' +} + +function Test-PlaceholderUrl { + param( + [AllowEmptyString()] + [string]$Value + ) + + return $Value -like 'https://example.invalid/*' +} + +function Get-File { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-HttpsUrl -Value $Url)) { + throw ('Only HTTPS URLs are allowed: {0}' -f (Get-RedactedUrl -Value $Url)) + } + + Invoke-WebRequest -Uri $Url -OutFile $DestinationPath -UseBasicParsing +} + +function Get-CommandExecutablePath { + param( + [AllowEmptyString()] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { + return $null + } + + $command = Get-Command -Name $Name -CommandType Application -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + + foreach ($propertyName in @('Path', 'Source', 'Definition')) { + $property = $command.PSObject.Properties[$propertyName] + if ($null -eq $property) { + continue + } + + $propertyValue = [string]$property.Value + if ([string]::IsNullOrWhiteSpace($propertyValue)) { + continue + } + + return $propertyValue + } + + return $null +} + +function Get-PowerShellExecutableCandidates { + $candidatePaths = New-Object System.Collections.Generic.List[string] + $resolvedPwshPath = Get-CommandExecutablePath -Name 'pwsh.exe' + + foreach ($preferredPath in @( + (Join-Path ${env:ProgramFiles} 'PowerShell\7\pwsh.exe'), + $resolvedPwshPath, + (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe') + )) { + if ([string]::IsNullOrWhiteSpace($preferredPath)) { + continue + } + + if (-not $candidatePaths.Contains($preferredPath)) { + $candidatePaths.Add($preferredPath) + } + } + + return $candidatePaths +} + +function Get-PowerShellExecutableVersion { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + $versionOutput = & $Path -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>$null + if ($LASTEXITCODE -ne 0) { + return $null + } + + $versionText = [string]($versionOutput | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($versionText)) { + return $null + } + + $parsedVersion = $null + if ([version]::TryParse($versionText.Trim(), [ref]$parsedVersion)) { + return $parsedVersion + } + } + catch { + return $null + } + + return $null +} + +function Resolve-PowerShellExecutable { + param( + [Parameter(Mandatory = $true)] + [version]$MinimumVersion + ) + + $fallbackCandidate = $null + + foreach ($candidatePath in (Get-PowerShellExecutableCandidates)) { + if (-not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) { + continue + } + + $candidateVersion = Get-PowerShellExecutableVersion -Path $candidatePath + if ($null -eq $candidateVersion) { + continue + } + + $source = if ($candidatePath -eq (Join-Path ${env:ProgramFiles} 'PowerShell\7\pwsh.exe')) { + 'programfiles-x64' + } + elseif ($candidatePath -eq (Join-Path ${env:ProgramFiles(x86)} 'PowerShell\7\pwsh.exe')) { + 'programfiles-x86' + } + else { + 'command-path' + } + + $candidate = [ordered]@{ + Found = $true + Acceptable = ($candidateVersion -ge $MinimumVersion) + Path = $candidatePath + Version = $candidateVersion + Source = $source + } + + if ($candidate.Acceptable) { + return [pscustomobject]$candidate + } + + if ($null -eq $fallbackCandidate) { + $fallbackCandidate = [pscustomobject]$candidate + } + } + + if ($null -ne $fallbackCandidate) { + return $fallbackCandidate + } + + return [pscustomobject]@{ + Found = $false + Acceptable = $false + Path = $null + Version = $null + Source = 'not-found' + } +} + +function Install-PowerShellMsi { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + if (-not (Test-HttpsUrl -Value $Url)) { + throw "PowerShellMsiUrl must be an HTTPS URL: $Url" + } + + Write-Step 'Downloading PowerShell MSI payload' + Get-File -Url $Url -DestinationPath $DestinationPath + + Write-Step 'Installing PowerShell MSI payload' + $installerProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList ('/i "{0}" /qn /norestart ALLUSERS=1' -f $DestinationPath) -Wait -PassThru -WindowStyle Hidden + $restartRequired = $installerProcess.ExitCode -in @(3010, 1641) + $installSucceeded = $installerProcess.ExitCode -in @(0, 3010, 1641) + + return [pscustomobject]@{ + StagedMsiPath = $DestinationPath + InstallerExitCode = $installerProcess.ExitCode + RestartRequired = $restartRequired + InstallSucceeded = $installSucceeded + } +} + +function Initialize-PowerShellExecutable { + param( + [Parameter(Mandatory = $true)] + [version]$MinimumVersion, + [Parameter(Mandatory = $true)] + [string]$MsiUrl, + [Parameter(Mandatory = $true)] + [string]$MsiPath + ) + + $initialResolution = Resolve-PowerShellExecutable -MinimumVersion $MinimumVersion + $details = [ordered]@{ + requiredVersion = $MinimumVersion.ToString() + msiUrl = $MsiUrl + action = if ($initialResolution.Acceptable) { 'existing' } else { 'install-required' } + installAttempted = $false + initialPath = $initialResolution.Path + initialVersion = if ($null -ne $initialResolution.Version) { $initialResolution.Version.ToString() } else { $null } + initialSource = $initialResolution.Source + stagedMsiPath = $null + installerExitCode = $null + restartRequired = $false + finalPath = $initialResolution.Path + finalVersion = if ($null -ne $initialResolution.Version) { $initialResolution.Version.ToString() } else { $null } + finalSource = $initialResolution.Source + } + + if ($initialResolution.Acceptable) { + return [pscustomobject]@{ + ExecutablePath = $initialResolution.Path + Version = $initialResolution.Version + Details = [pscustomobject]$details + } + } + + $details.installAttempted = $true + $details.stagedMsiPath = $MsiPath + + $installResult = Install-PowerShellMsi -Url $MsiUrl -DestinationPath $MsiPath + $details.action = 'installed' + $details.installerExitCode = $installResult.InstallerExitCode + $details.restartRequired = $installResult.RestartRequired + $details.stagedMsiPath = $installResult.StagedMsiPath + + $finalResolution = Resolve-PowerShellExecutable -MinimumVersion $MinimumVersion + $details.finalPath = $finalResolution.Path + $details.finalVersion = if ($null -ne $finalResolution.Version) { $finalResolution.Version.ToString() } else { $null } + $details.finalSource = $finalResolution.Source + + if (-not $finalResolution.Acceptable) { + throw ('PowerShell {0} or later is required, but pwsh.exe could not be resolved after MSI install. Installer exit code: {1}. MSI path: {2}.' -f $MinimumVersion, $installResult.InstallerExitCode, $installResult.StagedMsiPath) + } + + if (-not $installResult.InstallSucceeded) { + throw ('PowerShell MSI install reported exit code {0} even though pwsh.exe resolved afterward. Treating this as a failure to avoid masking an incomplete installation.' -f $installResult.InstallerExitCode) + } + + return [pscustomobject]@{ + ExecutablePath = $finalResolution.Path + Version = $finalResolution.Version + Details = [pscustomobject]$details + } +} + +function Test-PowerShellFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $parseErrors = $null + $tokens = $null + [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors) | Out-Null + return ($parseErrors.Count -eq 0) +} + +function Test-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + Get-Content -LiteralPath $Path -Raw | ConvertFrom-JsonCompat -Depth 20 | Out-Null + return $true + } + catch { + return $false + } +} + +function Get-RedactedUrl { + param( + [AllowEmptyString()] + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '[not provided]' + } + + $uri = $null + if (-not [System.Uri]::TryCreate($Value, [System.UriKind]::Absolute, [ref]$uri)) { + return $Value + } + + if ([string]::IsNullOrWhiteSpace($uri.Query)) { + return $uri.AbsoluteUri + } + + return ('{0} [query redacted]' -f $uri.GetLeftPart([System.UriPartial]::Path)) +} + +function Get-FilePreview { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + $rawContent = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + } + catch { + return ('[preview unavailable: {0}]' -f $_.Exception.Message) + } + + if ([string]::IsNullOrWhiteSpace($rawContent)) { + return '[empty file]' + } + + $normalizedPreview = (($rawContent -replace [char]0xFEFF, '') -replace '\r?\n', ' ') + $normalizedPreview = ($normalizedPreview -replace '\s+', ' ').Trim() + + if ($normalizedPreview.Length -gt 200) { + return ('{0}...' -f $normalizedPreview.Substring(0, 200)) + } + + return $normalizedPreview +} + +function Get-JsonPayloadHint { + param( + [AllowEmptyString()] + [string]$Preview + ) + + if ([string]::IsNullOrWhiteSpace($Preview) -or $Preview -eq '[empty file]') { + return 'Payload is empty.' + } + + $trimmedPreview = $Preview.TrimStart() + if ($trimmedPreview.StartsWith('<')) { + return 'Payload preview suggests HTML or XML content rather than JSON.' + } + + if ((-not $trimmedPreview.StartsWith('{')) -and (-not $trimmedPreview.StartsWith('['))) { + return 'Payload preview suggests plain text or another non-JSON format.' + } + + return 'Payload could not be parsed as JSON.' +} + +function Assert-ValidJsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$SourceContext + ) + + try { + $rawContent = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + } + catch { + throw ('Downloaded profile payload could not be read. Staged path: {0}. Source: {1}. Error: {2}' -f $Path, (Get-RedactedUrl -Value $SourceContext), $_.Exception.Message) + } + + if ([string]::IsNullOrWhiteSpace($rawContent)) { + throw ('Downloaded profile payload is empty. Staged path: {0}. Source: {1}.' -f $Path, (Get-RedactedUrl -Value $SourceContext)) + } + + try { + $rawContent | ConvertFrom-JsonCompat -Depth 20 | Out-Null + } + catch { + $preview = Get-FilePreview -Path $Path + $payloadHint = Get-JsonPayloadHint -Preview $preview + throw ('Downloaded profile payload is not valid JSON. Staged path: {0}. Source: {1}. {2} Parse error: {3}. Payload preview: {4}' -f $Path, (Get-RedactedUrl -Value $SourceContext), $payloadHint, $_.Exception.Message, $preview) + } +} + +function Assert-StagedPayloads { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorPath, + [Parameter(Mandatory = $true)] + [string]$ProfilePath, + [Parameter(Mandatory = $true)] + [string]$ProfileSource + ) + + if (-not (Test-PowerShellFile -Path $CollectorPath)) { + throw "Downloaded collector payload is not valid PowerShell: $CollectorPath. Check CollectorScriptUrl." + } + + Assert-ValidJsonFile -Path $ProfilePath -SourceContext $ProfileSource +} + +function Get-Task { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue +} + +function Remove-TaskIfPresent { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $existingTask = Get-Task -Name $Name + if ($existingTask) { + Unregister-ScheduledTask -TaskName $Name -Confirm:$false + } +} + +function Assert-TaskRegistered { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Executable, + [Parameter(Mandatory = $true)] + [datetime]$TriggerTime, + [AllowEmptyString()] + [string]$Arguments + ) + + $registeredTask = Get-Task -Name $Name + if ($registeredTask) { + return $registeredTask + } + + $detailParts = New-Object System.Collections.Generic.List[string] + $detailParts.Add('taskName={0}' -f (Format-StatusValue -Value $Name)) + $detailParts.Add('execute={0}' -f (Format-StatusValue -Value $Executable)) + $detailParts.Add('processArch={0}' -f (Get-ProcessArchitectureLabel)) + $detailParts.Add('triggerUtc={0}' -f $TriggerTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) + + if (-not [string]::IsNullOrWhiteSpace($Arguments)) { + $detailParts.Add('arguments={0}' -f (Format-StatusValue -Value $Arguments)) + } + + throw ('Scheduled task was not visible after Register-ScheduledTask. {0}' -f ($detailParts -join '; ')) +} + +function Get-ShouldThrottle { + param( + [Parameter(Mandatory = $true)] + [string]$StatePath, + [Parameter(Mandatory = $true)] + [int]$WindowHours, + [switch]$IgnoreState + ) + + if ($IgnoreState) { + return $false + } + + $state = Read-JsonFile -Path $StatePath + if ($null -eq $state) { + return $false + } + + if ([string]::IsNullOrWhiteSpace([string]$state.registeredUtc)) { + return $false + } + + $registeredUtc = [datetime]::Parse([string]$state.registeredUtc).ToUniversalTime() + $expiresUtc = $registeredUtc.AddHours($WindowHours) + return $expiresUtc -gt (Get-Date).ToUniversalTime() +} + +function New-CollectorArgumentString { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorPath, + [Parameter(Mandatory = $true)] + [string]$ProfilePath, + [Parameter(Mandatory = $true)] + [string]$CollectorOutputRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorBundleLabel, + [AllowEmptyString()] + [string]$CollectorCaseReference, + [AllowEmptyString()] + [string]$CollectorBlobName, + [AllowEmptyString()] + [string]$CollectorOperatorName, + [AllowEmptyString()] + [string]$CollectorOperatorTeam, + [AllowEmptyString()] + [string]$CollectorOperatorContact, + [AllowEmptyString()] + [string]$ResolvedSasUrl, + [switch]$RunLocalOnly + ) + + $arguments = New-Object System.Collections.Generic.List[string] + $arguments.Add('-NoProfile') + $arguments.Add('-ExecutionPolicy') + $arguments.Add('Bypass') + $arguments.Add('-File') + $arguments.Add((Format-TaskArgument -Value $CollectorPath)) + $arguments.Add('-CollectorProfilePath') + $arguments.Add((Format-TaskArgument -Value $ProfilePath)) + $arguments.Add('-OutputRoot') + $arguments.Add((Format-TaskArgument -Value $CollectorOutputRoot)) + $arguments.Add('-BundleLabel') + $arguments.Add((Format-TaskArgument -Value $CollectorBundleLabel)) + $arguments.Add('-OperatorName') + $arguments.Add((Format-TaskArgument -Value $CollectorOperatorName)) + $arguments.Add('-OperatorTeam') + $arguments.Add((Format-TaskArgument -Value $CollectorOperatorTeam)) + + if (-not [string]::IsNullOrWhiteSpace($CollectorCaseReference)) { + $arguments.Add('-CaseReference') + $arguments.Add((Format-TaskArgument -Value $CollectorCaseReference)) + } + + if (-not [string]::IsNullOrWhiteSpace($CollectorBlobName)) { + $arguments.Add('-BlobName') + $arguments.Add((Format-TaskArgument -Value $CollectorBlobName)) + } + + if (-not [string]::IsNullOrWhiteSpace($CollectorOperatorContact)) { + $arguments.Add('-OperatorContact') + $arguments.Add((Format-TaskArgument -Value $CollectorOperatorContact)) + } + + if ($RunLocalOnly) { + $arguments.Add('-LocalOnly') + } + elseif (-not [string]::IsNullOrWhiteSpace($ResolvedSasUrl)) { + $arguments.Add('-SasUrl') + $arguments.Add((Format-TaskArgument -Value $ResolvedSasUrl)) + } + + return ($arguments -join ' ') +} + +function New-RunScopedStagedPath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + [Parameter(Mandatory = $true)] + [string]$LeafName, + [Parameter(Mandatory = $true)] + [string]$RunToken + ) + + $extension = [System.IO.Path]::GetExtension($LeafName) + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($LeafName) + + if ([string]::IsNullOrWhiteSpace($extension)) { + return (Join-Path $Root ('{0}-{1}' -f $baseName, $RunToken)) + } + + return (Join-Path $Root ('{0}-{1}{2}' -f $baseName, $RunToken, $extension)) +} + +$stagingRunId = '{0}-{1}' -f (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'), [guid]::NewGuid().ToString('N') +$statePath = Join-Path $StateRoot 'collection-bootstrap.json' +$stagedCollectorPath = New-RunScopedStagedPath -Root $StagingRoot -LeafName 'Invoke-CmtraceEvidenceCollection.ps1' -RunToken $stagingRunId +$stagedProfilePath = New-RunScopedStagedPath -Root $StagingRoot -LeafName 'intune-evidence-profile.json' -RunToken $stagingRunId +$stagedPowerShellMsiPath = Join-Path $StagingRoot ('PowerShell-{0}-win-x64.msi' -f $RequiredPowerShellVersion) + +try { + Write-StageStatus -Stage 'bootstrap' -Status 'starting' -Details @{ + delayMinutes = $DelayMinutes + localOnly = [bool]$LocalOnly + processArch = Get-ProcessArchitectureLabel + stagedCollector = $stagedCollectorPath + stagedProfile = $stagedProfilePath + stagingRunId = $stagingRunId + throttleHours = $ThrottleHours + transcript = $script:BootstrapTranscriptPath + transcriptStatus = Get-TranscriptStatus + } + + if ((Get-TranscriptStatus) -ne 'enabled') { + Write-StageStatus -Stage 'transcript' -Status (Get-TranscriptStatus) -Details $script:BootstrapTranscriptDiagnostics + } + + $script:BootstrapStage = 'prepare-directories' + Write-Step 'Preparing bootstrap directories' + Initialize-Directory -Path $StagingRoot + Initialize-Directory -Path $StateRoot + Initialize-Directory -Path $OutputRoot + + if (Test-PlaceholderUrl -Value $CollectorScriptUrl) { + throw 'CollectorScriptUrl still points to the example.invalid placeholder. Provide a reachable HTTPS URL for the collector payload.' + } + + if (Test-PlaceholderUrl -Value $CollectorProfileUrl) { + throw 'CollectorProfileUrl still points to the example.invalid placeholder. Provide a reachable HTTPS URL for the collector profile.' + } + + if ((-not $LocalOnly) -and [string]::IsNullOrWhiteSpace($SasUrl)) { + throw 'SasUrl is required unless you use -LocalOnly.' + } + + if ((-not $LocalOnly) -and (-not (Test-HttpsUrl -Value $SasUrl))) { + throw 'SasUrl must be an HTTPS URL.' + } + + if ((-not $LocalOnly) -and ($SasUrl -notmatch '\?')) { + throw 'SasUrl does not appear to contain a query string.' + } + + $script:BootstrapStage = 'throttle-check' + if (Get-ShouldThrottle -StatePath $statePath -WindowHours $ThrottleHours -IgnoreState:$Force) { + $existingTask = Get-Task -Name $TaskName + $status = if ($existingTask) { 'skipped-throttled-task-present' } else { 'skipped-throttled' } + Write-StageStatus -Stage 'bootstrap' -Status $status -Details @{ + state = $statePath + task = $TaskName + transcript = $script:BootstrapTranscriptPath + } + return + } + + $script:BootstrapStage = 'stage-payloads' + Write-Step 'Downloading staged collector payloads' + Get-File -Url $CollectorScriptUrl -DestinationPath $stagedCollectorPath + Get-File -Url $CollectorProfileUrl -DestinationPath $stagedProfilePath + + Write-Step 'Validating staged collector payloads' + Assert-StagedPayloads -CollectorPath $stagedCollectorPath -ProfilePath $stagedProfilePath -ProfileSource $CollectorProfileUrl + Write-StageStatus -Stage 'payloads' -Status 'ready' -Details @{ + collector = $stagedCollectorPath + profile = $stagedProfilePath + stagingRun = $stagingRunId + transcript = $script:BootstrapTranscriptPath + } + + $script:BootstrapStage = 'resolve-runtime' + Write-Step 'Resolving PowerShell runtime' + $powerShellResolution = Initialize-PowerShellExecutable -MinimumVersion $RequiredPowerShellVersion -MsiUrl $PowerShellMsiUrl -MsiPath $stagedPowerShellMsiPath + Write-StageStatus -Stage 'runtime' -Status 'ready' -Details @{ + executable = $powerShellResolution.ExecutablePath + transcript = $script:BootstrapTranscriptPath + version = $powerShellResolution.Version.ToString() + } + + $resolvedSasUrl = $SasUrl + + $caseReferenceValue = if ([string]::IsNullOrWhiteSpace($CaseReference)) { + 'bootstrap-{0}' -f (Get-Date -Format 'yyyyMMdd-HHmmss') + } + else { + $CaseReference + } + + $taskArguments = New-CollectorArgumentString -CollectorPath $stagedCollectorPath -ProfilePath $stagedProfilePath -CollectorOutputRoot $OutputRoot -CollectorBundleLabel $BundleLabel -CollectorCaseReference $caseReferenceValue -CollectorBlobName $BlobName -CollectorOperatorName $OperatorName -CollectorOperatorTeam $OperatorTeam -CollectorOperatorContact $OperatorContact -ResolvedSasUrl $resolvedSasUrl -RunLocalOnly:$LocalOnly + $powerShellExecutable = $powerShellResolution.ExecutablePath + + $script:BootstrapStage = 'register-task' + Write-Step 'Registering one-time SYSTEM scheduled task' + if ($Force) { + Remove-TaskIfPresent -Name $TaskName + } + elseif (Get-Task -Name $TaskName) { + Remove-TaskIfPresent -Name $TaskName + } + + $triggerTime = (Get-Date).AddMinutes($DelayMinutes) + $taskAction = New-ScheduledTaskAction -Execute $powerShellExecutable -Argument $taskArguments + $taskTrigger = New-ScheduledTaskTrigger -Once -At $triggerTime + $taskSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 8) -StartWhenAvailable + + Register-ScheduledTask -TaskName $TaskName -Action $taskAction -Trigger $taskTrigger -User 'SYSTEM' -RunLevel Highest -Settings $taskSettings -Force | Out-Null + $registeredTask = Assert-TaskRegistered -Name $TaskName -Executable $powerShellExecutable -Arguments $taskArguments -TriggerTime $triggerTime + + $state = [ordered]@{ + registeredUtc = Get-UtcTimestamp + triggerUtc = $triggerTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + taskName = $TaskName + remediationPayloadId = $script:BootstrapPayloadFingerprint.Identifier + remediationPayloadAlgorithm = $script:BootstrapPayloadFingerprint.Algorithm + remediationPayloadHash = $script:BootstrapPayloadFingerprint.Hash + remediationPayloadPath = $script:BootstrapPayloadFingerprint.Path + stagingRoot = $StagingRoot + stagingRunId = $stagingRunId + stagedCollectorPath = $stagedCollectorPath + stagedProfilePath = $stagedProfilePath + outputRoot = $OutputRoot + collectorScriptUrl = $CollectorScriptUrl + collectorProfileUrl = $CollectorProfileUrl + powerShell = $powerShellResolution.Details + powerShellExecutable = $powerShellExecutable + sasUrlConfigured = (-not [string]::IsNullOrWhiteSpace($SasUrl)) + localOnly = [bool]$LocalOnly + caseReference = $caseReferenceValue + transcriptPath = $script:BootstrapTranscriptPath + } + + Write-JsonFile -InputObject $state -Path $statePath + + $script:BootstrapStage = 'completed' + Write-Step 'Bootstrap complete' + Write-StageStatus -Stage 'bootstrap' -Status 'scheduled' -Details @{ + collector = $stagedCollectorPath + outputRoot = $OutputRoot + profile = $stagedProfilePath + state = $statePath + task = $TaskName + taskPath = [string]$registeredTask.TaskPath + transcript = $script:BootstrapTranscriptPath + triggerUtc = $triggerTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } +} +catch { + $redactedMessage = Format-StatusValue -Value $_.Exception.Message + Write-StageStatus -Stage $script:BootstrapStage -Status 'failed' -Details @{ + collector = $stagedCollectorPath + message = $redactedMessage + profile = $stagedProfilePath + transcript = $script:BootstrapTranscriptPath + } + throw +} +finally { + if ($script:BootstrapTranscriptStarted) { + try { + Stop-Transcript | Out-Null + } + catch { + Write-StageStatus -Stage 'transcript' -Status 'stop-failed' -Details @{ + message = $_.Exception.Message + transcript = $script:BootstrapTranscriptPath + } + } + } +} \ No newline at end of file diff --git a/references/collection/intune-evidence-profile.json b/references/collection/intune-evidence-profile.json new file mode 100644 index 000000000..25a541955 --- /dev/null +++ b/references/collection/intune-evidence-profile.json @@ -0,0 +1,267 @@ +{ + "profileName": "intune-windows-endpoint-v1", + "profileVersion": "1.3.0", + "logs": [ + { + "id": "ime-logs", + "family": "intune-ime", + "sourcePattern": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\*.log", + "destinationFolder": "evidence/logs", + "parseHints": [ + "cmtrace", + "intune" + ], + "notes": "Collects IME text logs and rotated variants when present." + }, + { + "id": "panther-setupact", + "family": "panther-setup", + "sourcePattern": "C:\\Windows\\Panther\\setupact.log", + "destinationFolder": "evidence/logs/panther", + "parseHints": [ + "cmtrace", + "panther" + ], + "notes": "Focused Panther activity log for OOBE, setup, and Autopilot-adjacent troubleshooting." + }, + { + "id": "panther-setuperr", + "family": "panther-setup", + "sourcePattern": "C:\\Windows\\Panther\\setuperr.log", + "destinationFolder": "evidence/logs/panther", + "parseHints": [ + "cmtrace", + "panther" + ], + "notes": "Focused Panther error log to capture setup failures without broader Panther sweep." + }, + { + "id": "autopilot-service-state-json", + "family": "autopilot-profile-json", + "sourcePattern": "C:\\Windows\\ServiceState\\Autopilot\\*.json", + "destinationFolder": "evidence/exports/autopilot", + "parseHints": [ + "json", + "autopilot" + ], + "notes": "Collects known high-value Autopilot JSON artifacts staged under ServiceState." + }, + { + "id": "mdm-diagnostics-public-documents", + "family": "mdm-diagnostics-output", + "sourcePattern": "C:\\Users\\Public\\Documents\\MDMDiagnostics\\*", + "destinationFolder": "evidence/exports/mdm-diagnostics", + "parseHints": [ + "mdm", + "diagnostics" + ], + "notes": "Captures MDM diagnostics outputs generated by MdmDiagnosticsTool in the public documents path." + } + ], + "registry": [ + { + "id": "mdm-enrollments", + "family": "mdm-enrollment", + "path": "HKLM\\SOFTWARE\\Microsoft\\Enrollments", + "fileName": "mdm-enrollments.reg", + "notes": "Enrollment state and tenant-scoped enrollment metadata." + }, + { + "id": "omadm-accounts", + "family": "omadm-account-state", + "path": "HKLM\\SOFTWARE\\Microsoft\\Provisioning\\OMADM\\Accounts", + "fileName": "omadm-accounts.reg", + "notes": "OMA-DM account configuration and MDM account identifiers." + }, + { + "id": "autopilot-diagnostics", + "family": "autopilot-diagnostics", + "path": "HKLM\\SOFTWARE\\Microsoft\\Provisioning\\Diagnostics\\Autopilot", + "fileName": "autopilot-diagnostics.reg", + "notes": "Autopilot deployment diagnostics root referenced by Get-AutopilotDiagnostics.ps1." + }, + { + "id": "autopilot-settings", + "family": "autopilot-settings", + "path": "HKLM\\SOFTWARE\\Microsoft\\Provisioning\\AutopilotSettings", + "fileName": "autopilot-settings.reg", + "notes": "Autopilot profile and device preparation settings referenced by Get-AutopilotDiagnostics.ps1." + }, + { + "id": "policymanager-device", + "family": "policymanager-device", + "path": "HKLM\\SOFTWARE\\Microsoft\\PolicyManager\\Current\\Device", + "fileName": "policymanager-device.reg", + "notes": "Current device PolicyManager state relevant to MDM troubleshooting." + }, + { + "id": "autopilot-esp-diagnostics", + "family": "autopilot-esp-diagnostics", + "path": "HKLM\\SOFTWARE\\Microsoft\\Windows\\Autopilot\\EnrollmentStatusTracking\\ESPTrackingInfo\\Diagnostics", + "fileName": "autopilot-esp-diagnostics.reg", + "notes": "Enrollment Status Page tracking diagnostics used by Autopilot troubleshooting." + }, + { + "id": "ime-state", + "family": "intune-management-extension-state", + "path": "HKLM\\SOFTWARE\\Microsoft\\IntuneManagementExtension", + "fileName": "intune-management-extension.reg", + "notes": "IME service and local Intune management extension state." + }, + { + "id": "ime-win32apps", + "family": "intune-management-extension-win32apps", + "path": "HKLM\\SOFTWARE\\Microsoft\\IntuneManagementExtension\\Win32Apps", + "fileName": "intune-management-extension-win32apps.reg", + "notes": "Win32 app and Autopilot Device Preparation workload state, including provisioning progress and enforcement metadata." + }, + { + "id": "provisioning-nodecache-csp", + "family": "provisioning-nodecache-csp", + "path": "HKLM\\SOFTWARE\\Microsoft\\Provisioning\\NodeCache\\CSP", + "fileName": "provisioning-nodecache-csp.reg", + "notes": "Provisioning CSP node cache that helps reconstruct OMA-DM and enrollment processing state." + }, + { + "id": "omadm-syncml-odjapplied", + "family": "omadm-syncml-odj-state", + "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." + } + ], + "eventLogs": [ + { + "id": "device-management-admin", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + "fileName": "device-management-admin.evtx", + "notes": "Primary MDM admin channel for enrollment and policy issues." + }, + { + "id": "device-management-operational", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Operational", + "fileName": "device-management-operational.evtx", + "notes": "Operational MDM events that complement the admin channel." + }, + { + "id": "autopilot", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "fileName": "autopilot.evtx", + "notes": "Autopilot deployment diagnostics when available on the device." + }, + { + "id": "aad-operational", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-AAD/Operational", + "fileName": "aad-operational.evtx", + "notes": "Entra ID device registration, token, and SSO activity adjacent to join and enrollment issues." + }, + { + "id": "delivery-optimization-operational", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-DeliveryOptimization/Operational", + "fileName": "delivery-optimization-operational.evtx", + "notes": "Delivery Optimization transfer and peering events relevant to app and content download troubleshooting." + }, + { + "id": "management-service", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + "fileName": "management-service.evtx", + "notes": "Modern deployment management service events adjacent to Autopilot and provisioning issues." + }, + { + "id": "provisioning-diagnostics-admin", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin", + "fileName": "provisioning-diagnostics-admin.evtx", + "notes": "Provisioning diagnostics events relevant to setup and deployment troubleshooting." + }, + { + "id": "shell-core-operational", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-Shell-Core/Operational", + "fileName": "shell-core-operational.evtx", + "notes": "Shell and sign-in experience events that can help explain provisioning and user-session behavior." + }, + { + "id": "time-service-operational", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-Time-Service/Operational", + "fileName": "time-service-operational.evtx", + "notes": "Time synchronization events that can affect join, enrollment, and token-related workflows." + }, + { + "id": "user-device-registration-admin", + "family": "curated-channel-export", + "channel": "Microsoft-Windows-User Device Registration/Admin", + "fileName": "user-device-registration-admin.evtx", + "notes": "Azure AD and workplace join diagnostics adjacent to Intune issues." + } + ], + "exports": [ + { + "id": "autopilot-configuration-file", + "family": "autopilot-profile-json", + "sourcePath": "%WINDIR%\\Provisioning\\AutoPilot\\AutoPilotConfigurationFile.json", + "destinationFolder": "evidence/exports", + "fileName": "AutoPilotConfigurationFile.json", + "parseHints": [ + "json", + "autopilot" + ], + "notes": "Classic Autopilot configuration file retained for older profiles and existing-device scenarios." + }, + { + "id": "autopilot-dds-ztd-file", + "family": "autopilot-profile-json", + "sourcePath": "%WINDIR%\\Provisioning\\Diagnostics\\AutopilotDDSZTDFile.json", + "destinationFolder": "evidence/exports", + "fileName": "AutopilotDDSZTDFile.json", + "parseHints": [ + "json", + "autopilot" + ], + "notes": "Autopilot DDS profile JSON used during OOBE and provisioning diagnostics when present on the live device." + } + ], + "commands": [ + { + "id": "dsregcmd-status", + "family": "diagnostic-command", + "command": "dsregcmd.exe", + "arguments": [ + "/status" + ], + "fileName": "dsregcmd-status.txt", + "notes": "Captures current join, tenant, and registration state." + }, + { + "id": "delivery-optimization-status", + "family": "delivery-optimization-command", + "command": "powershell.exe", + "arguments": [ + "-NoProfile", + "-Command", + "Get-DeliveryOptimizationStatus | Format-List *" + ], + "fileName": "delivery-optimization-status.txt", + "notes": "Captures current Delivery Optimization session and peer status using the built-in PowerShell cmdlet." + }, + { + "id": "delivery-optimization-perf-snap", + "family": "delivery-optimization-command", + "command": "powershell.exe", + "arguments": [ + "-NoProfile", + "-Command", + "Get-DeliveryOptimizationPerfSnap | Format-List *" + ], + "fileName": "delivery-optimization-perf-snap.txt", + "notes": "Captures a Delivery Optimization performance snapshot for current bandwidth and cache usage evidence." + } + ] +} \ No newline at end of file diff --git a/scripts/Launch-CMTraceOpen.ps1 b/scripts/Launch-CMTraceOpen.ps1 index 47478c304..e17a04dfd 100644 --- a/scripts/Launch-CMTraceOpen.ps1 +++ b/scripts/Launch-CMTraceOpen.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet('Dev', 'Build', 'BuildAndRun')] + [ValidateSet('Dev', 'Build', 'BuildExeOnly', 'BuildAndRun')] [string]$Mode = 'Dev', [switch]$InstallDependencies, [string]$OpenPath @@ -18,6 +18,43 @@ function Write-Step { Write-Host "==> $Message" -ForegroundColor Cyan } +function Add-PathEntryIfExists { + param( + [Parameter(Mandatory = $true)] + [string]$PathEntry + ) + + if (-not (Test-Path $PathEntry)) { + return + } + + $pathSegments = @($env:Path -split ';') | Where-Object { $_ } + if ($pathSegments -contains $PathEntry) { + return + } + + $env:Path = "$PathEntry;$env:Path" +} + +function Add-RustToolchainToPath { + $cargoBin = Join-Path $env:USERPROFILE '.cargo\bin' + Add-PathEntryIfExists -PathEntry $cargoBin +} + +function Assert-CommandAvailable { + param( + [Parameter(Mandatory = $true)] + [string]$CommandName, + + [Parameter(Mandatory = $true)] + [string]$ErrorMessage + ) + + if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) { + throw $ErrorMessage + } +} + function Resolve-VsWherePath { $candidates = @(( (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'), @@ -74,7 +111,7 @@ function Invoke-CheckedCommand { function Get-ModeConfiguration { param( [Parameter(Mandatory = $true)] - [ValidateSet('Dev', 'Build', 'BuildAndRun')] + [ValidateSet('Dev', 'Build', 'BuildExeOnly', 'BuildAndRun')] [string]$Mode ) @@ -91,6 +128,12 @@ function Get-ModeConfiguration { RequiresBuiltArtifact = $false } } + 'BuildExeOnly' { + return @{ + NpmScript = 'app:build:exe-only' + RequiresBuiltArtifact = $false + } + } 'BuildAndRun' { return @{ NpmScript = 'app:build:release' @@ -126,10 +169,16 @@ $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $appRoot = Split-Path -Parent $scriptRoot $nodeModulesPath = Join-Path $appRoot 'node_modules' +Write-Step 'Ensuring Rust toolchain is available on PATH' +Add-RustToolchainToPath + Write-Step 'Entering Visual Studio Developer PowerShell' $vsInstallPath = Enable-VsDeveloperPowerShell Write-Host "Using Visual Studio at $vsInstallPath" -ForegroundColor DarkGray +Add-RustToolchainToPath +Assert-CommandAvailable -CommandName 'cargo.exe' -ErrorMessage 'Could not find cargo.exe on PATH. Install Rust via rustup or run scripts/Install-CMTraceOpenBuildPrereqs.ps1, then open a new terminal and retry.' + Set-Location $appRoot if ($InstallDependencies -or -not (Test-Path $nodeModulesPath)) { diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..79157299d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84" + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6aef77de7..161275924 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +44,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -47,12 +65,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -202,6 +264,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -272,6 +340,19 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cargo_metadata" version = "0.19.2" @@ -401,8 +482,10 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", ] [[package]] @@ -427,9 +510,11 @@ dependencies = [ "chrono", "criterion", "encoding_rs", + "evtx", "log", "notify", "once_cell", + "rayon", "regex", "serde", "serde_json", @@ -440,9 +525,17 @@ dependencies = [ "tauri-plugin-fs", "tempfile", "tokio", + "ureq", + "windows 0.58.0", "winreg 0.52.0", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -453,6 +546,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -742,6 +848,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -902,6 +1021,76 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -938,12 +1127,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + [[package]] name = "error-code" version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "evtx" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8273ed69ad5086ec987e16e883ac35589a1cf0f2b000d965a693fc2a937a24" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "byteorder", + "chrono", + "clap", + "crc32fast", + "dialoguer", + "encoding", + "hashbrown 0.14.5", + "indoc", + "log", + "quick-xml 0.36.2", + "rayon", + "serde", + "serde_json", + "simplelog", + "skeptic", + "thiserror 1.0.69", + "winstructs", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1506,6 +1731,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1835,6 +2070,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -1900,6 +2144,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -2327,6 +2577,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2358,6 +2619,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2488,6 +2758,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -2961,6 +3237,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -2973,6 +3260,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3255,6 +3551,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3283,6 +3593,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3461,6 +3806,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -3580,6 +3926,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3592,6 +3944,17 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -3604,6 +3967,21 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata 0.14.2", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + [[package]] name = "slab" version = "0.4.12" @@ -3735,6 +4113,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3833,7 +4217,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3904,7 +4288,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4064,7 +4448,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4089,7 +4473,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4101,7 +4485,7 @@ checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", - "cargo_metadata", + "cargo_metadata 0.19.2", "ctor", "dunce", "glob", @@ -4166,6 +4550,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4228,7 +4621,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4559,6 +4954,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4571,12 +4972,40 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4614,6 +5043,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" @@ -4949,6 +5384,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4957,10 +5410,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4981,7 +5434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5037,6 +5490,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5059,14 +5522,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -5078,8 +5554,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -5096,6 +5572,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5107,6 +5594,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5140,6 +5638,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5158,6 +5665,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5529,6 +6046,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winstructs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc7406cd936173d9cc3a4fd5dc5b295bc59612439d72038e3d7ac4e5dd42de9" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "chrono", + "log", + "num-derive", + "num-traits", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5679,7 +6213,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5787,6 +6321,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4d8253799..f7fc55fed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,9 +28,13 @@ encoding_rs = "0.8" log = "0.4" notify = "7" tokio = { version = "1", features = ["fs", "io-util", "sync", "rt"] } +rayon = "1.10" +evtx = "0.8" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" +windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_EventLog", "Win32_Security"] } +ureq = "2" [dev-dependencies] criterion = "0.5" diff --git a/src-tauri/src/commands/dsregcmd.rs b/src-tauri/src/commands/dsregcmd.rs index 90835af2a..36f037f09 100644 --- a/src-tauri/src/commands/dsregcmd.rs +++ b/src-tauri/src/commands/dsregcmd.rs @@ -1,4 +1,6 @@ -use crate::dsregcmd::{analyze_text, registry, DsregcmdAnalysisResult}; +use crate::dsregcmd::{analyze_text, registry, rules, DsregcmdAnalysisResult}; +#[cfg(target_os = "windows")] +use crate::dsregcmd::connectivity; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -55,9 +57,21 @@ pub fn analyze_dsregcmd( 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)); + let bp = Path::new(bundle_path); + result.policy_evidence = registry::load_whfb_policy_evidence(bp); + result.os_version = registry::load_os_version_evidence(bp); + result.proxy_evidence = registry::load_proxy_evidence(bp); + result.enrollment_evidence = registry::load_enrollment_evidence(bp); + result.active_evidence = load_active_evidence_from_bundle(bp); + result.event_log_analysis = load_event_log_from_bundle(bp); } + // Run extended diagnostics (Phase 2, 3, 4) after all evidence is loaded + let mut extended = rules::build_extended_diagnostics(&result); + extended.append(&mut rules::build_active_diagnostics_rules(&result)); + extended.append(&mut rules::build_event_log_diagnostics(&result)); + result.diagnostics.append(&mut extended); + eprintln!( "event=dsregcmd_analysis_complete diagnostics_count={} join_type={:?}", result.diagnostics.len(), @@ -67,6 +81,47 @@ pub fn analyze_dsregcmd( Ok(result) } +fn load_active_evidence_from_bundle( + bundle_path: &Path, +) -> Option { + let connectivity_dir = bundle_path.join("evidence").join("connectivity"); + + let tests_path = connectivity_dir.join("endpoint-tests.json"); + let scp_path = connectivity_dir.join("scp-query.json"); + + let connectivity_tests: Vec = + std::fs::read_to_string(&tests_path) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + + let scp_query: Option = + std::fs::read_to_string(&scp_path) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()); + + if connectivity_tests.is_empty() && scp_query.is_none() { + return None; + } + + Some(crate::dsregcmd::DsregcmdActiveEvidence { + connectivity_tests, + scp_query, + }) +} + +fn load_event_log_from_bundle( + bundle_path: &Path, +) -> Option { + let path = bundle_path + .join("evidence") + .join("event-logs") + .join("dsregcmd-events.json"); + std::fs::read_to_string(&path) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()) +} + #[tauri::command] pub fn capture_dsregcmd() -> Result { capture_dsregcmd_impl() @@ -223,6 +278,30 @@ const LIVE_CAPTURE_REGISTRY_EXPORTS: &[RegistryExportSpec] = &[ key_path: r"HKLM\Software\Microsoft\Policies", file_name: "hklm-microsoft-policies.reg", }, + RegistryExportSpec { + key_path: r"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo", + file_name: "cdj-joininfo.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\CDJ\AAD", + file_name: "cdj-aad.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion", + file_name: "os-version.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\SYSTEM\CurrentControlSet\Services\WinHttpAutoProxySvc\Parameters\Connections", + file_name: "proxy-connections.reg", + }, + RegistryExportSpec { + key_path: r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings", + file_name: "proxy-internet-settings.reg", + }, + RegistryExportSpec { + key_path: r"HKLM\SOFTWARE\Microsoft\Enrollments", + file_name: "enrollments.reg", + }, ]; #[cfg(target_os = "windows")] @@ -280,6 +359,31 @@ fn stage_live_capture_bundle(stdout: &str) -> Result export_live_registry_evidence(&evidence_registry); + // Phase 3: Active diagnostics (connectivity + SCP) + let evidence_connectivity = bundle_path.join("evidence").join("connectivity"); + if fs::create_dir_all(&evidence_connectivity).is_ok() { + let active_evidence = connectivity::run_active_diagnostics(); + if let Ok(json) = serde_json::to_string_pretty(&active_evidence.connectivity_tests) { + let _ = fs::write(evidence_connectivity.join("endpoint-tests.json"), json); + } + if let Some(ref scp) = active_evidence.scp_query { + if let Ok(json) = serde_json::to_string_pretty(scp) { + let _ = fs::write(evidence_connectivity.join("scp-query.json"), json); + } + } + } + + // Phase 4: Event log collection + let event_log_analysis = crate::dsregcmd::event_logs::collect_dsregcmd_event_logs(); + if let Some(ref analysis) = event_log_analysis { + let evidence_event_logs = bundle_path.join("evidence").join("event-logs"); + if fs::create_dir_all(&evidence_event_logs).is_ok() { + if let Ok(json) = serde_json::to_string_pretty(analysis) { + let _ = fs::write(evidence_event_logs.join("dsregcmd-events.json"), json); + } + } + } + Ok(LiveCaptureBundle { bundle_path, evidence_file_path, diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 14a906cc5..3e8a31439 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -7,8 +7,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::State; +use crate::dsregcmd::registry::{inspect_registry_snapshot_file, RegistrySnapshotSummary}; use crate::intune::models::{EvidenceBundleArtifactCounts, EvidenceBundleMetadata}; -use crate::models::log_entry::ParseResult; +use crate::models::log_entry::{ + AggregateParseResult, AggregateParsedFileResult, LogEntry, ParseQuality, ParseResult, + ParserKind, ParserSelectionInfo, ParserSpecialization, +}; use crate::parser; use crate::state::app_state::{AppState, OpenFile}; @@ -119,6 +123,121 @@ pub struct FolderListingResult { pub bundle_metadata: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceArtifactTimeCoverage { + pub start_utc: Option, + pub end_utc: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum EvidenceArtifactIntakeKind { + Log, + RegistrySnapshot, + EventLogExport, + CommandOutput, + Screenshot, + Export, + Unknown, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum EvidenceArtifactIntakeStatus { + Recognized, + Generic, + Unsupported, + Missing, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceArtifactIntake { + pub kind: EvidenceArtifactIntakeKind, + pub status: EvidenceArtifactIntakeStatus, + pub recognized_as: Option, + pub summary: String, + pub parser_selection: Option, + pub parse_diagnostics: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceArtifactParseDiagnostics { + pub total_lines: u32, + pub entry_count: u32, + pub parse_errors: u32, + pub clean_parse: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceArtifactRecord { + pub artifact_id: Option, + pub category: String, + pub family: Option, + pub relative_path: String, + pub absolute_path: Option, + pub origin_path: Option, + pub collected_utc: Option, + pub status: String, + #[serde(default)] + pub parse_hints: Vec, + pub notes: Option, + pub time_coverage: Option, + pub sha256: Option, + pub exists_on_disk: bool, + pub intake: EvidenceArtifactIntake, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExpectedEvidenceRecord { + pub category: String, + pub relative_path: String, + pub required: bool, + pub reason: Option, + pub available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceBundleDetails { + pub bundle_root_path: String, + pub metadata: EvidenceBundleMetadata, + pub manifest_content: String, + pub notes_content: Option, + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub expected_evidence: Vec, + #[serde(default)] + pub observed_gaps: Vec, + #[serde(default)] + pub priority_questions: Vec, + pub handoff_summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceEventLogExportPreview { + pub channel: Option, + pub file_size_bytes: Option, + pub modified_unix_ms: Option, + pub export_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceArtifactPreview { + pub path: String, + pub intake_kind: EvidenceArtifactIntakeKind, + pub summary: String, + pub registry_snapshot: Option, + pub event_log_export: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KnownSourceMetadata { @@ -156,6 +275,76 @@ pub fn open_log_file(path: String, state: State<'_, AppState>) -> Result, +) -> Result { + let listing = list_log_folder(path.clone())?; + let file_entries: Vec<&FolderEntry> = listing.entries.iter().filter(|entry| !entry.is_dir).collect(); + + let mut aggregate_entries: Vec = Vec::new(); + let mut aggregate_files = Vec::with_capacity(file_entries.len()); + let mut open_file_states = Vec::with_capacity(file_entries.len()); + let mut total_lines = 0u32; + let mut parse_errors = 0u32; + + for entry in file_entries { + let (result, parser_selection) = parser::parse_file(&entry.path)?; + + total_lines = total_lines.saturating_add(result.total_lines); + parse_errors = parse_errors.saturating_add(result.parse_errors); + aggregate_entries.extend(result.entries); + aggregate_files.push(AggregateParsedFileResult { + file_path: result.file_path.clone(), + total_lines: result.total_lines, + parse_errors: result.parse_errors, + file_size: result.file_size, + byte_offset: result.byte_offset, + }); + open_file_states.push(( + PathBuf::from(&result.file_path), + parser_selection, + result.byte_offset, + )); + } + + let file_order: std::collections::HashMap = aggregate_files + .iter() + .enumerate() + .map(|(index, file)| (file.file_path.clone(), index)) + .collect(); + + aggregate_entries.sort_by(|left, right| compare_aggregate_entries(left, right, &file_order)); + + for (index, entry) in aggregate_entries.iter_mut().enumerate() { + entry.id = index as u64; + } + + let mut open_files = state.open_files.lock().map_err(|e| e.to_string())?; + for (path_buf, parser_selection, byte_offset) in open_file_states { + open_files.insert( + path_buf.clone(), + OpenFile { + path: path_buf, + entries: vec![], + parser_selection, + byte_offset, + }, + ); + } + + Ok(AggregateParseResult { + entries: aggregate_entries, + total_lines, + parse_errors, + folder_path: path, + files: aggregate_files, + }) +} + #[tauri::command] pub fn inspect_path_kind(path: String) -> Result { let requested_path = PathBuf::from(&path); @@ -177,8 +366,7 @@ pub fn inspect_path_kind(path: String) -> Result { #[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)) + fs::write(&path, contents).map_err(|error| format!("failed to write file {}: {}", path, error)) } /// Returns the file path passed as a CLI argument at startup via OS file association. @@ -219,6 +407,25 @@ fn compare_folder_entries(left: &FolderEntry, right: &FolderEntry) -> Ordering { } } +fn compare_aggregate_entries( + left: &LogEntry, + right: &LogEntry, + file_order: &std::collections::HashMap, +) -> Ordering { + match (left.timestamp, right.timestamp) { + (Some(left_ts), Some(right_ts)) if left_ts != right_ts => left_ts.cmp(&right_ts), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + _ => file_order + .get(&left.file_path) + .copied() + .unwrap_or(usize::MAX) + .cmp(&file_order.get(&right.file_path).copied().unwrap_or(usize::MAX)) + .then_with(|| left.line_number.cmp(&right.line_number)) + .then_with(|| left.message.cmp(&right.message)), + } +} + fn bundle_entry_rank(entry: &FolderEntry) -> usize { match entry.name.to_ascii_lowercase().as_str() { "manifest.json" => 0, @@ -314,8 +521,456 @@ fn detect_evidence_bundle_metadata(path: &Path) -> Option Result { + if !path.exists() { + return Err(format!("bundle path does not exist: {}", path.display())); + } + + if !path.is_dir() { + return Err(format!("bundle path is not a folder: {}", path.display())); + } + + let manifest_path = path.join("manifest.json"); + if !manifest_path.is_file() { + return Err(format!( + "manifest.json was not found under {}", + path.display() + )); + } + + let manifest_content = fs::read_to_string(&manifest_path) + .map_err(|error| format!("failed to read {}: {}", manifest_path.display(), error))?; + let manifest = serde_json::from_str::(&manifest_content) + .map_err(|error| format!("failed to parse {}: {}", manifest_path.display(), error))?; + let metadata = detect_evidence_bundle_metadata(path) + .ok_or_else(|| format!("{} is not a recognized evidence bundle", path.display()))?; + + let artifacts = json_value_at(&manifest, &["artifacts"]) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| parse_evidence_artifact_record(path, item)) + .collect::>() + }) + .unwrap_or_default(); + + let expected_evidence = json_value_at(&manifest, &["expectedEvidence"]) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| parse_expected_evidence_record(path, &artifacts, item)) + .collect::>() + }) + .unwrap_or_default(); + + let notes_content = metadata + .notes_path + .as_ref() + .and_then(|notes_path| fs::read_to_string(notes_path).ok()); + + Ok(EvidenceBundleDetails { + bundle_root_path: normalize_path_string(path), + metadata, + manifest_content, + notes_content, + artifacts, + expected_evidence, + observed_gaps: json_string_array_at(&manifest, &["analysis", "observedGaps"]), + priority_questions: json_string_array_at(&manifest, &["analysis", "priorityQuestions"]), + handoff_summary: json_string_at(&manifest, &["analysis", "handoffSummary"]), + }) +} + +fn parse_evidence_artifact_record( + bundle_root: &Path, + value: &Value, +) -> Option { + let relative_path = json_string_at(value, &["relativePath"])?; + let absolute_path = resolve_bundle_hint_path(bundle_root, Some(relative_path.as_str())); + let exists_on_disk = absolute_path.as_ref().is_some_and(|path| path.exists()); + let category = json_string_at(value, &["category"]).unwrap_or_else(|| "unknown".to_string()); + let family = json_string_at(value, &["family"]); + let parse_hints = json_string_array_at(value, &["parseHints"]); + let intake = detect_artifact_intake( + &category, + family.as_deref(), + &relative_path, + absolute_path.as_deref(), + exists_on_disk, + &parse_hints, + ); + + Some(EvidenceArtifactRecord { + artifact_id: json_string_at(value, &["artifactId"]), + category, + family, + relative_path, + absolute_path: absolute_path.map(|value| value.to_string_lossy().to_string()), + origin_path: json_string_at(value, &["originPath"]), + collected_utc: json_string_at(value, &["collectedUtc"]), + status: json_string_at(value, &["status"]) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_else(|| "unknown".to_string()), + parse_hints, + notes: json_string_at(value, &["notes"]), + time_coverage: parse_artifact_time_coverage(value), + sha256: json_string_at(value, &["hashes", "sha256"]), + exists_on_disk, + intake, + }) +} + +fn detect_artifact_intake( + category: &str, + family: Option<&str>, + relative_path: &str, + absolute_path: Option<&Path>, + exists_on_disk: bool, + parse_hints: &[String], +) -> EvidenceArtifactIntake { + if !exists_on_disk { + return EvidenceArtifactIntake { + kind: classify_artifact_intake_kind(category), + status: EvidenceArtifactIntakeStatus::Missing, + recognized_as: None, + summary: "Artifact is not available on disk in this bundle.".to_string(), + parser_selection: None, + parse_diagnostics: None, + }; + } + + match classify_artifact_intake_kind(category) { + EvidenceArtifactIntakeKind::Log => detect_log_artifact_intake(relative_path, absolute_path), + EvidenceArtifactIntakeKind::RegistrySnapshot => EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::RegistrySnapshot, + status: EvidenceArtifactIntakeStatus::Recognized, + recognized_as: Some("Registry snapshot".to_string()), + summary: "Captured as structured registry evidence for offline inspection.".to_string(), + parser_selection: None, + parse_diagnostics: None, + }, + EvidenceArtifactIntakeKind::EventLogExport => EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::EventLogExport, + status: EvidenceArtifactIntakeStatus::Recognized, + recognized_as: Some("Curated event evidence".to_string()), + summary: "Captured as event-log evidence for correlation outside the log parser." + .to_string(), + parser_selection: None, + parse_diagnostics: None, + }, + EvidenceArtifactIntakeKind::CommandOutput => { + detect_command_output_artifact_intake(relative_path, family, parse_hints) + } + EvidenceArtifactIntakeKind::Screenshot => EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Screenshot, + status: EvidenceArtifactIntakeStatus::Recognized, + recognized_as: Some("Screenshot capture".to_string()), + summary: "Captured as visual supporting evidence.".to_string(), + parser_selection: None, + parse_diagnostics: None, + }, + EvidenceArtifactIntakeKind::Export => EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Export, + status: EvidenceArtifactIntakeStatus::Recognized, + recognized_as: Some("Exported evidence".to_string()), + summary: "Captured as exported supporting evidence.".to_string(), + parser_selection: None, + parse_diagnostics: None, + }, + EvidenceArtifactIntakeKind::Unknown => EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Unknown, + status: EvidenceArtifactIntakeStatus::Unsupported, + recognized_as: None, + summary: "Captured artifact category is not yet classified by the app.".to_string(), + parser_selection: None, + parse_diagnostics: None, + }, + } +} + +fn classify_artifact_intake_kind(category: &str) -> EvidenceArtifactIntakeKind { + match category.to_ascii_lowercase().as_str() { + "logs" => EvidenceArtifactIntakeKind::Log, + "registry" => EvidenceArtifactIntakeKind::RegistrySnapshot, + "event-log" | "event-logs" => EvidenceArtifactIntakeKind::EventLogExport, + "command-output" => EvidenceArtifactIntakeKind::CommandOutput, + "screenshots" => EvidenceArtifactIntakeKind::Screenshot, + "exports" => EvidenceArtifactIntakeKind::Export, + _ => EvidenceArtifactIntakeKind::Unknown, + } +} + +fn inspect_evidence_artifact_preview( + path: &Path, + intake_kind: EvidenceArtifactIntakeKind, + origin_path: Option, +) -> Result { + if !path.exists() { + return Err(format!("artifact path does not exist: {}", path.display())); + } + + if !path.is_file() { + return Err(format!("artifact path is not a file: {}", path.display())); + } + + match intake_kind { + EvidenceArtifactIntakeKind::RegistrySnapshot => { + let registry_snapshot = inspect_registry_snapshot_file(path) + .ok_or_else(|| format!("failed to inspect registry snapshot {}", path.display()))?; + let summary = format!( + "Parsed {} registry key{} and {} value{} from this exported snapshot.", + registry_snapshot.key_count, + if registry_snapshot.key_count == 1 { "" } else { "s" }, + registry_snapshot.value_count, + if registry_snapshot.value_count == 1 { "" } else { "s" } + ); + + Ok(EvidenceArtifactPreview { + path: normalize_path_string(path), + intake_kind, + summary, + registry_snapshot: Some(registry_snapshot), + event_log_export: None, + }) + } + EvidenceArtifactIntakeKind::EventLogExport => { + let metadata = fs::metadata(path) + .map_err(|error| format!("failed to read {} metadata: {}", path.display(), error))?; + let export_format = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .unwrap_or_else(|| "unknown".to_string()); + let channel_summary = origin_path + .as_deref() + .map(|channel| format!("Captured from {}.", channel)) + .unwrap_or_else(|| "Captured as a curated event-log export.".to_string()); + + Ok(EvidenceArtifactPreview { + path: normalize_path_string(path), + intake_kind, + summary: format!( + "{} Review stays bundle-first here; full event extraction is a later Push 2 follow-on.", + channel_summary + ), + registry_snapshot: None, + event_log_export: Some(EvidenceEventLogExportPreview { + channel: origin_path, + file_size_bytes: Some(metadata.len()), + modified_unix_ms: metadata_modified_unix_ms(&metadata), + export_format, + }), + }) + } + _ => Err("artifact preview is currently supported for registry snapshots and event-log exports only".to_string()), + } +} + +fn detect_log_artifact_intake( + relative_path: &str, + absolute_path: Option<&Path>, +) -> EvidenceArtifactIntake { + let Some(absolute_path) = absolute_path else { + return EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Log, + status: EvidenceArtifactIntakeStatus::Missing, + recognized_as: None, + summary: "Artifact is not available on disk in this bundle.".to_string(), + parser_selection: None, + parse_diagnostics: None, + }; + }; + + if !is_text_like_artifact_path(absolute_path) { + return EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Log, + status: EvidenceArtifactIntakeStatus::Unsupported, + recognized_as: Some("Non-text log artifact".to_string()), + summary: + "This log artifact is not a text log that the current parser pipeline can inspect." + .to_string(), + parser_selection: None, + parse_diagnostics: None, + }; + } + + let content = match fs::read_to_string(absolute_path) { + Ok(content) => content, + Err(_) => { + return EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Log, + status: EvidenceArtifactIntakeStatus::Unsupported, + recognized_as: Some("Unreadable text log".to_string()), + summary: "The artifact could not be read as UTF-8 text for intake classification." + .to_string(), + parser_selection: None, + parse_diagnostics: None, + }; + } + }; + + let resolved_parser = parser::detect::detect_parser(relative_path, &content); + let parsed_chunk = + parser::parse_content_with_selection(&content, relative_path, &resolved_parser); + let parser_selection = resolved_parser.to_info(); + let recognized_as = Some(describe_parser_selection(&parser_selection)); + let status = if parser_selection.parse_quality == ParseQuality::TextFallback { + EvidenceArtifactIntakeStatus::Generic + } else { + EvidenceArtifactIntakeStatus::Recognized + }; + let entry_count = u32::try_from(parsed_chunk.entries.len()).unwrap_or(u32::MAX); + let parse_diagnostics = EvidenceArtifactParseDiagnostics { + total_lines: parsed_chunk.total_lines, + entry_count, + parse_errors: parsed_chunk.parse_errors, + clean_parse: parsed_chunk.parse_errors == 0, + }; + let summary = if status == EvidenceArtifactIntakeStatus::Recognized { + if parse_diagnostics.clean_parse { + format!( + "Recognized as {} and parsed cleanly across {} line{}.", + recognized_as.as_deref().unwrap_or("a known log source"), + parse_diagnostics.total_lines, + if parse_diagnostics.total_lines == 1 { + "" + } else { + "s" + } + ) + } else { + format!( + "Recognized as {} with {} parse issue{} across {} line{}.", + recognized_as.as_deref().unwrap_or("a known log source"), + parse_diagnostics.parse_errors, + if parse_diagnostics.parse_errors == 1 { + "" + } else { + "s" + }, + parse_diagnostics.total_lines, + if parse_diagnostics.total_lines == 1 { + "" + } else { + "s" + } + ) + } + } else { + "Read as text, but only generic text fallback was recognized for this artifact.".to_string() + }; + + EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::Log, + status, + recognized_as, + summary, + parser_selection: Some(parser_selection), + parse_diagnostics: Some(parse_diagnostics), + } +} + +fn detect_command_output_artifact_intake( + relative_path: &str, + family: Option<&str>, + parse_hints: &[String], +) -> EvidenceArtifactIntake { + let recognized_as = + if text_matches_any(relative_path, &["dsregcmd", "entra", "azuread", "join"]) + || family.is_some_and(|value| { + text_matches_any(value, &["dsregcmd", "entra", "azuread", "join"]) + }) + || parse_hints + .iter() + .any(|value| text_matches_any(value, &["dsregcmd", "entra", "azuread", "join"])) + { + Some("dsregcmd command output".to_string()) + } else { + family + .filter(|value| !value.trim().is_empty()) + .map(|value| format!("{} command output", value.trim())) + .or_else(|| Some("Command output".to_string())) + }; + + EvidenceArtifactIntake { + kind: EvidenceArtifactIntakeKind::CommandOutput, + status: EvidenceArtifactIntakeStatus::Recognized, + recognized_as, + summary: "Captured as command-output evidence for read-only review.".to_string(), + parser_selection: None, + parse_diagnostics: None, + } +} + +fn describe_parser_selection(parser_selection: &ParserSelectionInfo) -> String { + match parser_selection.specialization { + Some(ParserSpecialization::Ime) => "Intune IME log".to_string(), + None => match parser_selection.parser { + ParserKind::Ccm => "CCM-style log".to_string(), + ParserKind::Simple => "Simple format log".to_string(), + ParserKind::Timestamped => "Generic timestamped log".to_string(), + ParserKind::Plain => "Plain text log".to_string(), + ParserKind::Panther => "Windows Panther log".to_string(), + ParserKind::Cbs => "CBS servicing log".to_string(), + ParserKind::Dism => "DISM servicing log".to_string(), + ParserKind::ReportingEvents => "Windows Update reporting log".to_string(), + }, + } +} + +fn text_matches_any(value: &str, terms: &[&str]) -> bool { + let normalized = value.to_ascii_lowercase(); + terms.iter().any(|term| normalized.contains(term)) +} + +fn is_text_like_artifact_path(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()), + Some(extension) if extension == "log" || extension == "lo_" || extension == "txt" + ) +} + +fn parse_artifact_time_coverage(value: &Value) -> Option { + let time_coverage = json_value_at(value, &["timeCoverage"])?; + let start_utc = json_string_at(time_coverage, &["startUtc"]); + let end_utc = json_string_at(time_coverage, &["endUtc"]); + + if start_utc.is_none() && end_utc.is_none() { + return None; + } + + Some(EvidenceArtifactTimeCoverage { start_utc, end_utc }) +} + +fn parse_expected_evidence_record( + bundle_root: &Path, + artifacts: &[EvidenceArtifactRecord], + value: &Value, +) -> Option { + let category = json_string_at(value, &["category"])?; + let relative_path = json_string_at(value, &["relativePath"])?; + let candidate_path = resolve_bundle_hint_path(bundle_root, Some(relative_path.as_str())); + let available = artifacts + .iter() + .any(|artifact| artifact.relative_path == relative_path && artifact.status == "collected") + || candidate_path.as_ref().is_some_and(|path| path.exists()); + + Some(ExpectedEvidenceRecord { + category, + relative_path, + required: json_bool_at(value, &["required"]).unwrap_or(false), + reason: json_string_at(value, &["reason"]), + available, + }) +} + fn resolve_bundle_primary_entry_points(bundle_root: &Path, manifest: &Value) -> Vec { - let manifest_entry_points = json_string_array_at(manifest, &["intakeHints", "primaryEntryPoints"]); + let manifest_entry_points = + json_string_array_at(manifest, &["intakeHints", "primaryEntryPoints"]); let entry_points = if manifest_entry_points.is_empty() { DEFAULT_BUNDLE_PRIMARY_ENTRY_POINTS .iter() @@ -363,6 +1018,10 @@ fn json_u64_at(value: &Value, path: &[&str]) -> Option { json_value_at(value, path).and_then(Value::as_u64) } +fn json_bool_at(value: &Value, path: &[&str]) -> Option { + json_value_at(value, path).and_then(Value::as_bool) +} + fn json_string_array_at(value: &Value, path: &[&str]) -> Vec { json_value_at(value, path) .and_then(Value::as_array) @@ -464,6 +1123,20 @@ pub fn list_log_folder(path: String) -> Result { }) } +#[tauri::command] +pub fn inspect_evidence_bundle(path: String) -> Result { + inspect_evidence_bundle_details(Path::new(&path)) +} + +#[tauri::command] +pub fn inspect_evidence_artifact( + path: String, + intake_kind: EvidenceArtifactIntakeKind, + origin_path: Option, +) -> Result { + inspect_evidence_artifact_preview(Path::new(&path), intake_kind, origin_path) +} + #[cfg(target_os = "windows")] #[allow(clippy::too_many_arguments)] fn windows_known_source( @@ -718,45 +1391,59 @@ pub fn get_known_log_sources() -> Result, String> { #[cfg(test)] mod tests { - use super::list_log_folder; - use std::fs; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn list_log_folder_marks_evidence_bundle_and_exposes_primary_entry_points() { - let bundle_dir = create_temp_dir("file-ops-bundle"); - fs::create_dir_all(bundle_dir.join("evidence").join("logs")).expect("create logs dir"); - fs::create_dir_all(bundle_dir.join("evidence").join("registry")) - .expect("create registry dir"); - fs::write(bundle_dir.join("notes.md"), "notes").expect("write notes"); - fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()).expect("write manifest"); - - let result = list_log_folder(bundle_dir.to_string_lossy().to_string()).expect("list folder"); - let bundle_metadata = result.bundle_metadata.expect("bundle metadata"); - - assert_eq!(bundle_metadata.bundle_id.as_deref(), Some("CMTRACE-123")); - assert_eq!(result.entries.first().map(|entry| entry.name.as_str()), Some("manifest.json")); - assert!(bundle_metadata - .available_primary_entry_points - .iter() - .any(|path| path.ends_with("evidence\\logs") || path.ends_with("evidence/logs"))); - assert!(bundle_metadata - .available_primary_entry_points - .iter() - .any(|path| path.ends_with("evidence\\registry") || path.ends_with("evidence/registry"))); - - fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); - } + use super::{ + inspect_evidence_artifact, inspect_evidence_bundle, list_log_folder, + EvidenceArtifactIntakeKind, + }; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn list_log_folder_marks_evidence_bundle_and_exposes_primary_entry_points() { + let bundle_dir = create_temp_dir("file-ops-bundle"); + fs::create_dir_all(bundle_dir.join("evidence").join("logs")).expect("create logs dir"); + fs::create_dir_all(bundle_dir.join("evidence").join("registry")) + .expect("create registry dir"); + fs::write(bundle_dir.join("notes.md"), "notes").expect("write notes"); + fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()) + .expect("write manifest"); + + let result = + list_log_folder(bundle_dir.to_string_lossy().to_string()).expect("list folder"); + let bundle_metadata = result.bundle_metadata.expect("bundle metadata"); + + assert_eq!(bundle_metadata.bundle_id.as_deref(), Some("CMTRACE-123")); + assert_eq!( + result.entries.first().map(|entry| entry.name.as_str()), + Some("manifest.json") + ); + assert!(bundle_metadata + .available_primary_entry_points + .iter() + .any(|path| path.ends_with("evidence\\logs") || path.ends_with("evidence/logs"))); + assert!(bundle_metadata + .available_primary_entry_points + .iter() + .any( + |path| path.ends_with("evidence\\registry") || path.ends_with("evidence/registry") + )); + + fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); + } #[test] fn list_log_folder_bundle_metadata_filters_missing_manifest_entry_points() { let bundle_dir = create_temp_dir("file-ops-bundle-missing"); fs::create_dir_all(bundle_dir.join("evidence").join("logs")).expect("create logs dir"); - fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest_with_missing_entry()) - .expect("write manifest"); + fs::write( + bundle_dir.join("manifest.json"), + sample_bundle_manifest_with_missing_entry(), + ) + .expect("write manifest"); - let result = list_log_folder(bundle_dir.to_string_lossy().to_string()).expect("list folder"); + let result = + list_log_folder(bundle_dir.to_string_lossy().to_string()).expect("list folder"); let bundle_metadata = result.bundle_metadata.expect("bundle metadata"); assert_eq!(bundle_metadata.primary_entry_points.len(), 2); @@ -772,23 +1459,159 @@ mod tests { assert!(bundle_metadata .available_primary_entry_points .iter() - .all(|path| !path.ends_with("evidence\\missing") && !path.ends_with("evidence/missing"))); + .all( + |path| !path.ends_with("evidence\\missing") && !path.ends_with("evidence/missing") + )); fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); } - fn create_temp_dir(prefix: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time before unix epoch") - .as_nanos(); - let path = std::env::temp_dir().join(format!("{}-{}", prefix, unique)); - fs::create_dir_all(&path).expect("create temp dir"); - path - } + #[test] + fn inspect_evidence_bundle_returns_inventory_and_notes_preview() { + let bundle_dir = create_temp_dir("file-ops-bundle-details"); + fs::create_dir_all(bundle_dir.join("evidence").join("logs")).expect("create logs dir"); + fs::create_dir_all(bundle_dir.join("evidence").join("registry")) + .expect("create registry dir"); + fs::write( + bundle_dir.join("evidence").join("logs").join("IntuneManagementExtension.log"), + "", + ) + .expect("write log"); + fs::write(bundle_dir.join("notes.md"), "bundle notes").expect("write notes"); + fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()) + .expect("write manifest"); - fn sample_bundle_manifest() -> &'static str { - r#"{ + let result = inspect_evidence_bundle(bundle_dir.to_string_lossy().to_string()) + .expect("inspect bundle"); + let log_artifact = result + .artifacts + .iter() + .find(|artifact| artifact.category == "logs") + .expect("log artifact"); + let registry_artifact = result + .artifacts + .iter() + .find(|artifact| artifact.category == "registry") + .expect("registry artifact"); + + assert_eq!( + result.bundle_root_path, + bundle_dir.to_string_lossy().to_string() + ); + assert_eq!(result.notes_content.as_deref(), Some("bundle notes")); + assert_eq!(result.artifacts.len(), 2); + assert!(result + .artifacts + .iter() + .any(|artifact| artifact.exists_on_disk)); + assert_eq!( + log_artifact.intake.kind, + super::EvidenceArtifactIntakeKind::Log + ); + assert_eq!( + log_artifact.intake.status, + super::EvidenceArtifactIntakeStatus::Recognized + ); + assert_eq!( + log_artifact.intake.recognized_as.as_deref(), + Some("Intune IME log") + ); + assert!(log_artifact.intake.parser_selection.is_some()); + assert_eq!( + log_artifact + .intake + .parse_diagnostics + .as_ref() + .map(|diagnostics| diagnostics.parse_errors), + Some(0) + ); + assert_eq!( + registry_artifact.intake.kind, + super::EvidenceArtifactIntakeKind::RegistrySnapshot + ); + assert_eq!( + registry_artifact.intake.status, + super::EvidenceArtifactIntakeStatus::Missing + ); + assert_eq!(result.expected_evidence.len(), 2); + assert!(result.expected_evidence.iter().any(|entry| entry.available)); + assert!(result + .observed_gaps + .iter() + .any(|gap| gap.contains("registry"))); + assert!(result + .priority_questions + .iter() + .any(|question| question.contains("policy"))); + + fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); + } + + #[test] + fn inspect_evidence_artifact_previews_registry_and_event_exports() { + let bundle_dir = create_temp_dir("file-ops-artifact-preview"); + let registry_dir = bundle_dir.join("evidence").join("registry"); + let event_dir = bundle_dir.join("evidence").join("event-logs"); + fs::create_dir_all(®istry_dir).expect("create registry dir"); + fs::create_dir_all(&event_dir).expect("create event dir"); + + let registry_path = registry_dir.join("policymanager-device.reg"); + fs::write( + ®istry_path, + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\Current\Device\PassportForWork\Policies] +"UsePassportForWork"=dword:00000001 +"TenantName"="Contoso" +"#, + ) + .expect("write registry export"); + + let event_path = event_dir.join("device-management-admin.evtx"); + fs::write(&event_path, b"EVTX").expect("write event log export"); + + let registry_preview = inspect_evidence_artifact( + registry_path.to_string_lossy().to_string(), + EvidenceArtifactIntakeKind::RegistrySnapshot, + Some("HKLM\\SOFTWARE\\Microsoft\\PolicyManager".to_string()), + ) + .expect("inspect registry artifact"); + let event_preview = inspect_evidence_artifact( + event_path.to_string_lossy().to_string(), + EvidenceArtifactIntakeKind::EventLogExport, + Some( + "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin" + .to_string(), + ), + ) + .expect("inspect event artifact"); + + assert!(registry_preview.registry_snapshot.is_some()); + assert!(registry_preview.summary.contains("registry key")); + assert!(event_preview.event_log_export.is_some()); + assert_eq!( + event_preview + .event_log_export + .as_ref() + .and_then(|preview| preview.channel.as_deref()), + Some("Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin") + ); + + fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); + } + + fn create_temp_dir(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("{}-{}", prefix, unique)); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn sample_bundle_manifest() -> &'static str { + r#"{ "bundle": { "bundleId": "CMTRACE-123", "bundleLabel": "intune-endpoint-evidence", @@ -816,6 +1639,60 @@ mod tests { } } }, + "artifacts": [ + { + "artifactId": "ime-log", + "category": "logs", + "family": "intune-ime", + "relativePath": "evidence/logs/IntuneManagementExtension.log", + "originPath": "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log", + "collectedUtc": "2026-03-12T16:00:54Z", + "status": "collected", + "parseHints": ["intune-ime", "cmtrace"], + "timeCoverage": { + "startUtc": "2026-03-12T15:00:00Z", + "endUtc": "2026-03-12T16:00:00Z" + }, + "hashes": { + "sha256": "abc123" + }, + "notes": "Primary IME log" + }, + { + "artifactId": "device-registry", + "category": "registry", + "family": "enrollment", + "relativePath": "evidence/registry/device.reg", + "originPath": "HKLM\\Software\\Microsoft", + "collectedUtc": "2026-03-12T16:01:12Z", + "status": "missing", + "parseHints": ["reg-export"], + "notes": "Registry export missing on device" + } + ], + "expectedEvidence": [ + { + "category": "logs", + "relativePath": "evidence/logs/IntuneManagementExtension.log", + "required": true, + "reason": "Primary Intune IME execution trace" + }, + { + "category": "registry", + "relativePath": "evidence/registry/device.reg", + "required": true, + "reason": "Enrollment registry state" + } + ], + "analysis": { + "observedGaps": [ + "Expected registry export was not collected." + ], + "priorityQuestions": [ + "Did policy evaluation fail before IME content download?" + ], + "handoffSummary": "Start with the IME log, then confirm registry enrollment state." + }, "intakeHints": { "notesPath": "notes.md", "evidenceRoot": "evidence", @@ -829,10 +1706,10 @@ mod tests { ] } }"# - } + } - fn sample_bundle_manifest_with_missing_entry() -> &'static str { - r#"{ + fn sample_bundle_manifest_with_missing_entry() -> &'static str { + r#"{ "bundle": { "bundleId": "CMTRACE-456", "bundleLabel": "intune-endpoint-evidence", @@ -859,5 +1736,5 @@ mod tests { ] } }"# - } + } } diff --git a/src-tauri/src/commands/intune.rs b/src-tauri/src/commands/intune.rs index 6d339b96f..d7903f365 100644 --- a/src-tauri/src/commands/intune.rs +++ b/src-tauri/src/commands/intune.rs @@ -1,21 +1,29 @@ use std::collections::{HashMap, HashSet}; +use std::fmt::Write as FmtWrite; use std::fs; +use std::io::Write as IoWrite; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Instant; +use rayon::prelude::*; +use serde::Serialize; use serde_json::Value; -use tauri::async_runtime; +use tauri::{async_runtime, AppHandle, Emitter}; use crate::error_db::lookup::lookup_error_code; use crate::intune::download_stats; use crate::intune::event_tracker; +use crate::intune::evtx_parser; +use crate::intune::guid_registry::GuidRegistry; use crate::intune::ime_parser; use crate::intune::models::{ - DownloadStat, EvidenceBundleArtifactCounts, EvidenceBundleMetadata, IntuneAnalysisResult, - IntuneDiagnosticInsight, IntuneDiagnosticSeverity, IntuneDiagnosticsConfidence, - IntuneDiagnosticsConfidenceLevel, IntuneDiagnosticsCoverage, IntuneDiagnosticsFileCoverage, - IntuneDominantSource, IntuneEvent, IntuneEventType, IntuneRepeatedFailureGroup, - IntuneStatus, IntuneSummary, IntuneTimestampBounds, + DownloadStat, EventLogAnalysis, EvidenceBundleArtifactCounts, EvidenceBundleMetadata, + IntuneAnalysisResult, IntuneDiagnosticCategory, IntuneDiagnosticInsight, + IntuneDiagnosticSeverity, IntuneDiagnosticsConfidence, IntuneDiagnosticsConfidenceLevel, + IntuneDiagnosticsCoverage, IntuneDiagnosticsFileCoverage, IntuneDominantSource, IntuneEvent, + IntuneEventType, IntuneRemediationPriority, IntuneRepeatedFailureGroup, IntuneStatus, + IntuneSummary, IntuneTimestampBounds, }; use crate::intune::timeline; @@ -42,21 +50,57 @@ const DEFAULT_BUNDLE_PRIMARY_ENTRY_POINTS: &[&str] = &[ "evidence/command-output", ]; +const INTUNE_ANALYSIS_PROGRESS_EVENT: &str = "intune-analysis-progress"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct IntuneAnalysisProgressPayload { + request_id: String, + stage: &'static str, + message: String, + detail: Option, + current_file: Option, + completed_files: usize, + total_files: Option, +} + /// Analyze Intune Management Extension logs and return structured results. /// /// Supports either: /// - A single IME log file path /// - A directory containing IME logs (aggregated) #[tauri::command] -pub async fn analyze_intune_logs(path: String) -> Result { - async_runtime::spawn_blocking(move || analyze_intune_logs_blocking(path)) - .await - .map_err(|error| format!("Intune analysis task failed: {}", error))? +pub async fn analyze_intune_logs( + path: String, + request_id: String, + include_live_event_logs: bool, + app: AppHandle, +) -> Result { + async_runtime::spawn_blocking(move || { + analyze_intune_logs_blocking(path, request_id, include_live_event_logs, app) + }) + .await + .map_err(|error| format!("Intune analysis task failed: {}", error))? } -fn analyze_intune_logs_blocking(path: String) -> Result { +fn analyze_intune_logs_blocking( + path: String, + request_id: String, + include_live_event_logs: bool, + app: AppHandle, +) -> Result { let analysis_started = Instant::now(); eprintln!("event=intune_analysis_start path=\"{}\"", path); + emit_analysis_progress( + &app, + &request_id, + "resolving", + "Resolving Intune source...".to_string(), + Some(path.clone()), + None, + 0, + None, + ); let input_path = Path::new(&path); let resolved_input = resolve_intune_input(input_path)?; @@ -67,77 +111,159 @@ fn analyze_intune_logs_blocking(path: String) -> Result = source_paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); + let completed_files = AtomicUsize::new(0); + let mut processed_files: Vec = source_paths + .par_iter() + .enumerate() + .map(|(index, source_path)| { + analyze_intune_source_file( + source_path, + index, + total_files, + &request_id, + &app, + &completed_files, + ) + }) + .collect::, _>>()?; + + processed_files.sort_by_key(|file| file.index); + + let completed_files = completed_files.load(Ordering::Relaxed); + + // Build global GUID→name registry from all files + let mut guid_registry = GuidRegistry::new(); + for processed_file in &processed_files { + guid_registry.merge(&processed_file.guid_registry); + } + let mut all_events = Vec::new(); let mut all_downloads = Vec::new(); let mut coverage = Vec::new(); - for source_path in &source_paths { - let file_started = Instant::now(); - let source_file = source_path.to_string_lossy().to_string(); - eprintln!("event=intune_analysis_file_start file=\"{}\"", source_file); - let content = fs::read_to_string(source_path) - .map_err(|e| format!("Failed to read file '{}': {}", source_file, e))?; - - let lines = ime_parser::parse_ime_content(&content); - let rotation = detect_rotation_metadata(source_path); - if lines.is_empty() { - coverage.push(CoverageAccumulator { - coverage: IntuneDiagnosticsFileCoverage { - file_path: source_file.clone(), - event_count: 0, - download_count: 0, - timestamp_bounds: None, - is_rotated_segment: false, - rotation_group: None, - }, - rotation_candidate: rotation.rotation_group, - is_explicit_rotated_segment: rotation.is_rotated_segment, - }); - eprintln!( - "event=intune_analysis_file_complete file=\"{}\" line_count=0 event_count=0 download_count=0 elapsed_ms={}", - source_file, - file_started.elapsed().as_millis() - ); - continue; + for processed_file in processed_files { + all_events.extend(processed_file.events); + all_downloads.extend(processed_file.downloads); + coverage.push(processed_file.coverage); + } + + // Enrich event and download names using the global GUID registry + let mut diag_buffer = String::new(); + let mut enriched_events = 0u32; + let mut enriched_downloads = 0u32; + let mut missed_events = 0u32; + let mut missed_downloads = 0u32; + if !guid_registry.is_empty() { + let _ = writeln!(diag_buffer, "event=guid_registry_global entries={}", guid_registry.len()); + for (guid, entry) in guid_registry.iter() { + let _ = writeln!(diag_buffer, " guid={} name=\"{}\" source={:?}", guid, entry.name, entry.source); } - let file_events = event_tracker::extract_events(&lines, &source_file); - let file_downloads = download_stats::extract_downloads(&lines, &source_file); - let file_timestamp_bounds = build_timestamp_bounds(&file_events, &file_downloads); - - coverage.push(CoverageAccumulator { - coverage: IntuneDiagnosticsFileCoverage { - file_path: source_file.clone(), - event_count: file_events.len() as u32, - download_count: file_downloads.len() as u32, - timestamp_bounds: file_timestamp_bounds, - is_rotated_segment: false, - rotation_group: None, - }, - rotation_candidate: rotation.rotation_group, - is_explicit_rotated_segment: rotation.is_rotated_segment, - }); + for event in &mut all_events { + if let Some(guid) = &event.guid { + if let Some(enriched) = guid_registry.enrich_event_name(&event.name, guid) { + let _ = writeln!(diag_buffer, "event=guid_enriched_event old=\"{}\" new=\"{}\" guid={}", event.name, enriched, guid); + event.name = enriched; + enriched_events += 1; + } else if event.name.ends_with(')') && event.name.contains('(') { + let _ = writeln!(diag_buffer, "event=guid_enrich_miss name=\"{}\" guid={} registry_has={}", event.name, guid, guid_registry.resolve(guid).unwrap_or("NOT_FOUND")); + missed_events += 1; + } + } else if event.name.ends_with(')') && event.name.contains('(') { + let _ = writeln!(diag_buffer, "event=guid_enrich_skip_no_guid name=\"{}\"", event.name); + missed_events += 1; + } + } + for dl in &mut all_downloads { + if let Some(resolved) = guid_registry.resolve_fallback_name(&dl.name, &dl.content_id) + { + let _ = writeln!(diag_buffer, "event=guid_enriched_download old=\"{}\" new=\"{}\" guid={}", dl.name, resolved, dl.content_id); + dl.name = resolved; + enriched_downloads += 1; + } else if dl.name.starts_with("Download (") || dl.name.starts_with("Download:") { + let _ = writeln!(diag_buffer, "event=guid_enrich_miss_download name=\"{}\" guid={} registry_has={}", dl.name, dl.content_id, guid_registry.resolve(&dl.content_id).unwrap_or("NOT_FOUND")); + missed_downloads += 1; + } + } + } + // Append pipeline summary and write diag file (verbose detail to file only, summary to stderr) + { + let _ = writeln!(diag_buffer, "event=pipeline_summary event_count={} download_count={} guid_registry_entries={}", all_events.len(), all_downloads.len(), guid_registry.len()); + for (i, dl) in all_downloads.iter().enumerate() { + let _ = writeln!(diag_buffer, " download[{}] content_id={} name=\"{}\" success={} size={}", i, dl.content_id, dl.name, dl.success, dl.size_bytes); + } eprintln!( - "event=intune_analysis_file_complete file=\"{}\" line_count={} event_count={} download_count={} elapsed_ms={}", - source_file, - lines.len(), - file_events.len(), - file_downloads.len(), - file_started.elapsed().as_millis() + "event=guid_enrichment_summary registry={} enriched_events={} missed_events={} enriched_downloads={} missed_downloads={} total_downloads={}", + guid_registry.len(), enriched_events, missed_events, enriched_downloads, missed_downloads, all_downloads.len() ); + let diag_path = std::env::temp_dir().join("cmtrace-guid-diag.log"); + if let Ok(mut f) = fs::File::create(&diag_path) { + let _ = f.write_all(diag_buffer.as_bytes()); + eprintln!("event=guid_diag_written path=\"{}\"", diag_path.display()); + } + } - all_events.extend(file_events); - all_downloads.extend(file_downloads); + // Fallback: synthesize DownloadStat records from ContentDownload events + // when the regex-based download_stats extractor found nothing. + if all_downloads.is_empty() { + all_downloads = synthesize_downloads_from_events(&all_events); + if !all_downloads.is_empty() { + eprintln!( + "event=download_synthesized_from_events count={}", + all_downloads.len() + ); + } } + emit_analysis_progress( + &app, + &request_id, + "finalizing", + "Building Intune diagnostics view...".to_string(), + Some(if total_files == 0 { + path.clone() + } else { + format!("{} file(s) scanned", total_files) + }), + None, + completed_files, + Some(total_files), + ); + if all_events.is_empty() { + // Parse event logs even when no IME events were found + let mut event_log_analysis = load_event_log_analysis( + Path::new(&path), + &evidence_bundle, + include_live_event_logs, + &app, + &request_id, + completed_files, + total_files, + ); + let download_summary = summarize_download_signals(&[], &all_downloads); let summary = IntuneSummary { total_events: 0, @@ -156,22 +282,41 @@ fn analyze_intune_logs_blocking(path: String) -> Result Result Result Result, + include_live_event_logs: bool, + app: &AppHandle, + request_id: &str, + completed_files: usize, + total_files: usize, +) -> Option { + if evidence_bundle.is_some() { + emit_analysis_progress( + app, + request_id, + "parsing-event-logs", + "Parsing Windows Event Logs...".to_string(), + None, + None, + completed_files, + Some(total_files), + ); + return evtx_parser::parse_bundle_event_logs(input_path, evidence_bundle); + } + + if include_live_event_logs { + emit_analysis_progress( + app, + request_id, + "parsing-event-logs", + "Querying live Windows Event Logs...".to_string(), + None, + None, + completed_files, + Some(total_files), + ); + return evtx_parser::parse_live_event_logs(); + } + + None +} + +#[expect(clippy::too_many_arguments, reason = "progress event keeps all fields explicit")] +fn emit_analysis_progress( + app: &AppHandle, + request_id: &str, + stage: &'static str, + message: String, + detail: Option, + current_file: Option, + completed_files: usize, + total_files: Option, +) { + let payload = IntuneAnalysisProgressPayload { + request_id: request_id.to_string(), + stage, + message, + detail, + current_file, + completed_files, + total_files, + }; + + if let Err(error) = app.emit(INTUNE_ANALYSIS_PROGRESS_EVENT, payload) { + log::warn!("Failed to emit Intune analysis progress: {}", error); + } +} + +fn format_progress_detail(completed_files: usize, total_files: usize, source_file: &str) -> String { + format!( + "{} of {} complete | {}", + completed_files, total_files, source_file + ) +} + +fn display_file_name(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .unwrap_or_else(|| path.to_string()) +} + #[derive(Debug, Clone)] struct ResolvedIntuneInput { source_paths: Vec, @@ -254,6 +513,15 @@ struct RotationMetadata { rotation_group: Option, } +#[derive(Debug)] +struct ProcessedIntuneFile { + index: usize, + events: Vec, + downloads: Vec, + coverage: CoverageAccumulator, + guid_registry: GuidRegistry, +} + #[derive(Debug, Clone)] struct TimestampCandidate { parsed: chrono::NaiveDateTime, @@ -325,8 +593,13 @@ fn finalize_coverage( } } - let files: Vec = coverage.into_iter().map(|file| file.coverage).collect(); - let timestamp_bounds = merge_timestamp_bounds(files.iter().filter_map(|file| file.timestamp_bounds.as_ref())); + let files: Vec = + coverage.into_iter().map(|file| file.coverage).collect(); + let timestamp_bounds = merge_timestamp_bounds( + files + .iter() + .filter_map(|file| file.timestamp_bounds.as_ref()), + ); let has_rotated_logs = files.iter().any(|file| file.rotation_group.is_some()); let dominant_source = build_dominant_source(&files, events, downloads); @@ -338,6 +611,118 @@ fn finalize_coverage( } } +fn analyze_intune_source_file( + source_path: &Path, + index: usize, + total_files: usize, + request_id: &str, + app: &AppHandle, + completed_files: &AtomicUsize, +) -> Result { + let file_started = Instant::now(); + let source_file = source_path.to_string_lossy().to_string(); + eprintln!("event=intune_analysis_file_start file=\"{}\"", source_file); + emit_analysis_progress( + app, + request_id, + "reading-file", + format!( + "Reading {} ({}/{})", + display_file_name(&source_file), + index + 1, + total_files + ), + Some(format_progress_detail(index, total_files, &source_file)), + Some(source_file.clone()), + completed_files.load(Ordering::Relaxed), + Some(total_files), + ); + + let content = fs::read_to_string(source_path) + .map_err(|error| format!("Failed to read file '{}': {}", source_file, error))?; + + let lines = ime_parser::parse_ime_content(&content); + let rotation = detect_rotation_metadata(source_path); + + let mut file_guid_registry = GuidRegistry::new(); + + let (file_events, file_downloads, file_timestamp_bounds, line_count): ( + Vec, + Vec, + Option, + usize, + ) = if lines.is_empty() { + (Vec::new(), Vec::new(), None, 0usize) + } else { + file_guid_registry.ingest_lines(&lines); + eprintln!( + "event=guid_registry_file file=\"{}\" entries={}", + source_file, + file_guid_registry.len() + ); + for (guid, entry) in file_guid_registry.iter() { + eprintln!(" guid={} name=\"{}\" source={:?}", guid, entry.name, entry.source); + } + let file_events = event_tracker::extract_events(&lines, &source_file); + let file_downloads = download_stats::extract_downloads(&lines, &source_file); + let file_timestamp_bounds = build_timestamp_bounds(&file_events, &file_downloads); + + ( + file_events, + file_downloads, + file_timestamp_bounds, + lines.len(), + ) + }; + + let coverage = CoverageAccumulator { + coverage: IntuneDiagnosticsFileCoverage { + file_path: source_file.clone(), + event_count: file_events.len() as u32, + download_count: file_downloads.len() as u32, + timestamp_bounds: file_timestamp_bounds, + is_rotated_segment: false, + rotation_group: None, + }, + rotation_candidate: rotation.rotation_group, + is_explicit_rotated_segment: rotation.is_rotated_segment, + }; + + eprintln!( + "event=intune_analysis_file_complete file=\"{}\" line_count={} event_count={} download_count={} elapsed_ms={}", + source_file, + line_count, + file_events.len(), + file_downloads.len(), + file_started.elapsed().as_millis() + ); + + let completed = completed_files.fetch_add(1, Ordering::Relaxed) + 1; + emit_analysis_progress( + app, + request_id, + "completed-file", + format!( + "Indexed {} ({}/{})", + display_file_name(&source_file), + completed, + total_files + ), + Some(format_progress_detail(completed, total_files, &source_file)), + Some(source_file.clone()), + completed, + Some(total_files), + ); + + Ok(ProcessedIntuneFile { + index, + events: file_events, + downloads: file_downloads, + coverage, + guid_registry: file_guid_registry, + }) +} + fn count_events_by_source(events: &[IntuneEvent]) -> HashMap { let mut counts = HashMap::new(); @@ -545,32 +930,30 @@ fn is_rotation_suffix(value: &str) -> bool { fn build_repeated_failures(events: &[IntuneEvent]) -> Vec { let mut groups: HashMap = HashMap::new(); - for event in events.iter().filter(|event| { - matches!(event.status, IntuneStatus::Failed | IntuneStatus::Timeout) - }) { + for event in events + .iter() + .filter(|event| matches!(event.status, IntuneStatus::Failed | IntuneStatus::Timeout)) + { let reason = normalize_failure_reason(event); let subject_key = event .guid .clone() .unwrap_or_else(|| normalize_group_label(&event.name)); - let key = format!( - "{:?}|{}|{}", - event.event_type, - subject_key, - reason.key - ); - - let entry = groups.entry(key).or_insert_with(|| RepeatedFailureAccumulator { - name: event.name.clone(), - event_type: event.event_type, - error_code: event.error_code.clone(), - occurrences: 0, - source_files: HashSet::new(), - sample_event_ids: Vec::new(), - earliest: None, - latest: None, - reason_display: reason.display.clone(), - }); + let key = format!("{:?}|{}|{}", event.event_type, subject_key, reason.key); + + let entry = groups + .entry(key) + .or_insert_with(|| RepeatedFailureAccumulator { + name: event.name.clone(), + event_type: event.event_type, + error_code: event.error_code.clone(), + occurrences: 0, + source_files: HashSet::new(), + sample_event_ids: Vec::new(), + earliest: None, + latest: None, + reason_display: reason.display.clone(), + }); entry.occurrences += 1; entry.source_files.insert(event.source_file.clone()); @@ -660,7 +1043,10 @@ fn normalize_failure_reason(event: &IntuneEvent) -> FailureReason { ("file not found", "file not found"), ("execution policy", "execution policy blocked execution"), ("digitally signed", "script signing blocked execution"), - ("running scripts is disabled", "script execution is disabled"), + ( + "running scripts is disabled", + "script execution is disabled", + ), ("timed out", "timed out"), ("timeout", "timed out"), ("stalled", "stalled"), @@ -742,6 +1128,7 @@ fn build_diagnostics_confidence( coverage: &IntuneDiagnosticsCoverage, repeated_failures: &[IntuneRepeatedFailureGroup], events: &[IntuneEvent], + event_log_analysis: &Option, ) -> IntuneDiagnosticsConfidence { if summary.total_events == 0 && summary.total_downloads == 0 { return IntuneDiagnosticsConfidence { @@ -811,7 +1198,9 @@ fn build_diagnostics_confidence( if coverage.timestamp_bounds.is_some() { score += 0.1; - reasons.push("Parsed timestamps were available for the overall diagnostics window.".to_string()); + reasons.push( + "Parsed timestamps were available for the overall diagnostics window.".to_string(), + ); } if !repeated_failures.is_empty() { @@ -824,7 +1213,10 @@ fn build_diagnostics_confidence( if coverage.has_rotated_logs { score += 0.05; - reasons.push("Rotated log segments were available, which improves continuity across retries.".to_string()); + reasons.push( + "Rotated log segments were available, which improves continuity across retries." + .to_string(), + ); } if contributing_files <= 1 { @@ -841,22 +1233,32 @@ fn build_diagnostics_confidence( if summary.total_events == 0 && summary.total_downloads > 0 { score -= 0.2; - reasons.push("Only download statistics were available; no correlated Intune events were extracted.".to_string()); + reasons.push( + "Only download statistics were available; no correlated Intune events were extracted." + .to_string(), + ); } - if summary.in_progress + summary.pending > summary.failed + summary.succeeded && summary.total_events > 0 { + if summary.in_progress + summary.pending > summary.failed + summary.succeeded + && summary.total_events > 0 + { score -= 0.1; reasons.push("Most observed work is still pending or in progress, so the failure picture may be incomplete.".to_string()); } if has_app_or_download_failures(events) && !has_source_kind(&coverage.files, "appworkload") { score -= 0.15; - reasons.push("AppWorkload evidence was not available for app or download failures.".to_string()); + reasons.push( + "AppWorkload evidence was not available for app or download failures.".to_string(), + ); } if has_policy_failures(events) && !has_source_kind(&coverage.files, "appactionprocessor") { score -= 0.15; - reasons.push("AppActionProcessor evidence was not available for applicability or policy failures.".to_string()); + reasons.push( + "AppActionProcessor evidence was not available for applicability or policy failures." + .to_string(), + ); } if has_script_failures(events) @@ -867,6 +1269,32 @@ fn build_diagnostics_confidence( reasons.push("AgentExecutor or HealthScripts evidence was not available for script-related failures.".to_string()); } + // Event log evidence boosts + if let Some(ref ela) = event_log_analysis { + if ela.error_entry_count + ela.warning_entry_count > 0 { + score += 0.15; + reasons.push(format!( + "Windows Event Log evidence available with {} error/warning entries across {} channel(s).", + ela.error_entry_count + ela.warning_entry_count, + ela.channel_summaries.len() + )); + } + if !ela.correlation_links.is_empty() { + let linked_ime_count = ela + .correlation_links + .iter() + .filter(|l| l.linked_intune_event_id.is_some()) + .count(); + if linked_ime_count > 0 { + score += 0.10; + reasons.push(format!( + "Event log entries correlated with {} IME event(s).", + linked_ime_count + )); + } + } + } + score = score.clamp(0.0, 1.0); let level = if score >= 0.75 { IntuneDiagnosticsConfidenceLevel::High @@ -899,7 +1327,8 @@ fn distinct_source_kinds(files: &[IntuneDiagnosticsFileCoverage]) -> usize { fn has_source_kind(files: &[IntuneDiagnosticsFileCoverage], kind: &str) -> bool { files.iter().any(|file| { - (file.event_count > 0 || file.download_count > 0) && source_kind_key(&file.file_path) == kind + (file.event_count > 0 || file.download_count > 0) + && source_kind_key(&file.file_path) == kind }) } @@ -929,7 +1358,9 @@ fn has_app_or_download_failures(events: &[IntuneEvent]) -> bool { matches!(event.status, IntuneStatus::Failed | IntuneStatus::Timeout) && matches!( event.event_type, - IntuneEventType::Win32App | IntuneEventType::WinGetApp | IntuneEventType::ContentDownload + IntuneEventType::Win32App + | IntuneEventType::WinGetApp + | IntuneEventType::ContentDownload ) }) } @@ -937,7 +1368,10 @@ fn has_app_or_download_failures(events: &[IntuneEvent]) -> bool { fn has_policy_failures(events: &[IntuneEvent]) -> bool { events.iter().any(|event| { event.event_type == IntuneEventType::PolicyEvaluation - && matches!(event.status, IntuneStatus::Failed | IntuneStatus::Timeout | IntuneStatus::Pending) + && matches!( + event.status, + IntuneStatus::Failed | IntuneStatus::Timeout | IntuneStatus::Pending + ) }) } @@ -954,7 +1388,10 @@ fn has_script_failures(events: &[IntuneEvent]) -> bool { fn describe_path_access_error(path: &Path, error: &std::io::Error) -> String { match error.kind() { std::io::ErrorKind::NotFound => { - format!("The selected Intune source was not found: '{}'", path.display()) + format!( + "The selected Intune source was not found: '{}'", + path.display() + ) } std::io::ErrorKind::PermissionDenied => format!( "The selected Intune source could not be accessed because permission was denied: '{}'", @@ -1016,7 +1453,8 @@ fn collect_input_paths(path: &Path) -> Result, String> { } fn collect_directory_log_paths(path: &Path) -> Result, String> { - let entries = fs::read_dir(path).map_err(|error| describe_directory_read_error(path, &error))?; + let entries = + fs::read_dir(path).map_err(|error| describe_directory_read_error(path, &error))?; let mut files: Vec = entries .filter_map(|entry| entry.ok()) @@ -1268,7 +1706,8 @@ fn prioritize_ime_log_paths(candidates: Vec) -> Vec { } fn resolve_bundle_primary_entry_points(bundle_root: &Path, manifest: &Value) -> Vec { - let manifest_entry_points = json_string_array_at(manifest, &["intakeHints", "primaryEntryPoints"]); + let manifest_entry_points = + json_string_array_at(manifest, &["intakeHints", "primaryEntryPoints"]); let entry_points = if manifest_entry_points.is_empty() { DEFAULT_BUNDLE_PRIMARY_ENTRY_POINTS .iter() @@ -1343,16 +1782,15 @@ fn is_ime_related_log_file(path: &Path) -> bool { path.file_name() .map(|name| { let name = name.to_string_lossy().to_ascii_lowercase(); - IME_LOG_PATTERNS.iter().any(|pattern| name.contains(pattern)) + IME_LOG_PATTERNS + .iter() + .any(|pattern| name.contains(pattern)) }) .unwrap_or(false) } /// Build summary statistics from events and downloads. -fn build_summary( - events: &[IntuneEvent], - downloads: &[DownloadStat], -) -> IntuneSummary { +fn build_summary(events: &[IntuneEvent], downloads: &[DownloadStat]) -> IntuneSummary { let summary_events: Vec<&IntuneEvent> = events .iter() .filter(|event| is_summary_signal_event(event)) @@ -1432,11 +1870,58 @@ fn is_summary_signal_event(event: &IntuneEvent) -> bool { | IntuneEventType::SyncSession => true, IntuneEventType::Other => matches!( event.status, - IntuneStatus::Failed | IntuneStatus::Timeout | IntuneStatus::Pending | IntuneStatus::InProgress + IntuneStatus::Failed + | IntuneStatus::Timeout + | IntuneStatus::Pending + | IntuneStatus::InProgress ), } } +/// Synthesize `DownloadStat` records from ContentDownload events when +/// the regex-based `download_stats` extractor found nothing (i.e. the log +/// format didn't match `DOWNLOAD_RE`). Groups events by GUID and picks the +/// latest status per GUID as the outcome. +fn synthesize_downloads_from_events(events: &[IntuneEvent]) -> Vec { + let mut by_guid: HashMap> = HashMap::new(); + for event in events { + if event.event_type != IntuneEventType::ContentDownload { + continue; + } + let key = event + .guid + .clone() + .unwrap_or_else(|| event.name.clone()); + by_guid.entry(key).or_default().push(event); + } + + let mut downloads = Vec::new(); + for (content_id, group) in &by_guid { + // Use the last event's status as the outcome + let last = group.iter().max_by_key(|e| e.id).unwrap(); + let success = last.status == IntuneStatus::Success; + let name = last.name.clone(); + let timestamp = last + .start_time + .clone() + .or_else(|| last.end_time.clone()); + + downloads.push(DownloadStat { + content_id: content_id.clone(), + name, + size_bytes: 0, + speed_bps: 0.0, + do_percentage: 0.0, + duration_secs: last.duration_secs.unwrap_or(0.0), + success, + timestamp, + }); + } + + downloads.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + downloads +} + fn summarize_download_signals( events: &[IntuneEvent], downloads: &[DownloadStat], @@ -1517,7 +2002,9 @@ fn download_signal_key_for_event(event: &IntuneEvent) -> Option { } fn download_signal_key_for_stat(download: &DownloadStat) -> String { - if !download.content_id.trim().is_empty() && !download.content_id.eq_ignore_ascii_case("unknown") { + if !download.content_id.trim().is_empty() + && !download.content_id.eq_ignore_ascii_case("unknown") + { return format!("guid:{}", download.content_id.to_ascii_lowercase()); } @@ -1529,7 +2016,11 @@ fn download_signal_key_for_stat(download: &DownloadStat) -> String { format!( "timestamp:{}|result:{}", download.timestamp.as_deref().unwrap_or("unknown"), - if download.success { "success" } else { "failed" } + if download.success { + "success" + } else { + "failed" + } ) } @@ -1550,7 +2041,9 @@ fn upsert_download_signal( ) { let candidate_timestamp = timestamp.map(|value| value.to_string()); let should_replace = match signals.get(&key) { - Some(existing) => should_replace_download_signal(existing, state, candidate_timestamp.as_deref()), + Some(existing) => { + should_replace_download_signal(existing, state, candidate_timestamp.as_deref()) + } None => true, }; @@ -1573,14 +2066,21 @@ fn should_replace_download_signal( match compare_optional_timestamps(candidate_timestamp, existing.timestamp.as_deref()) { std::cmp::Ordering::Greater => true, std::cmp::Ordering::Less => false, - std::cmp::Ordering::Equal => download_signal_rank(candidate_state) >= download_signal_rank(existing.state), + std::cmp::Ordering::Equal => { + download_signal_rank(candidate_state) >= download_signal_rank(existing.state) + } } } fn compare_optional_timestamps(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering { match (left, right) { - (Some(left), Some(right)) => match (timeline::parse_timestamp(left), timeline::parse_timestamp(right)) { - (Some(left_time), Some(right_time)) => left_time.cmp(&right_time).then_with(|| left.cmp(right)), + (Some(left), Some(right)) => match ( + timeline::parse_timestamp(left), + timeline::parse_timestamp(right), + ) { + (Some(left_time), Some(right_time)) => { + left_time.cmp(&right_time).then_with(|| left.cmp(right)) + } _ => left.cmp(right), }, (Some(_), None) => std::cmp::Ordering::Greater, @@ -1614,8 +2114,10 @@ fn build_diagnostics( let install_failures: Vec<&IntuneEvent> = events .iter() .filter(|event| { - matches!(event.event_type, IntuneEventType::Win32App | IntuneEventType::WinGetApp) - && matches!(event.status, IntuneStatus::Failed | IntuneStatus::Timeout) + matches!( + event.event_type, + IntuneEventType::Win32App | IntuneEventType::WinGetApp + ) && matches!(event.status, IntuneStatus::Failed | IntuneStatus::Timeout) && contains_any( &event.detail, &[ @@ -1663,13 +2165,20 @@ fn build_diagnostics( if let Some(stall) = stalled_download_evidence(&failed_download_events) { evidence.push(stall); } - evidence.extend(repeated_group_evidence(&failed_download_events, 2, "Repeated failed download pattern")); + evidence.extend(repeated_group_evidence( + &failed_download_events, + 2, + "Repeated failed download pattern", + )); insights.push(IntuneDiagnosticInsight { id: "download-failures".to_string(), severity: IntuneDiagnosticSeverity::Error, + category: IntuneDiagnosticCategory::Download, + remediation_priority: IntuneRemediationPriority::Immediate, title: download_case.title.to_string(), summary: download_case.summary.to_string(), + likely_cause: Some(download_case.likely_cause.to_string()), evidence, next_checks: vec![ "Review AppWorkload download, staging, and hash-validation lines for the affected content IDs.".to_string(), @@ -1681,6 +2190,13 @@ fn build_diagnostics( .into_iter() .map(|item| item.to_string()) .collect(), + focus_areas: vec![ + "AppWorkload download and staging transitions".to_string(), + "Delivery Optimization, proxy, and content reachability".to_string(), + "IME cache health and package revision consistency".to_string(), + ], + affected_source_files: related_source_files(&failed_download_events, 4), + related_error_codes: related_error_codes(&failed_download_events, 3), }); } @@ -1702,8 +2218,16 @@ fn build_diagnostics( insights.push(IntuneDiagnosticInsight { id: "install-enforcement-failures".to_string(), severity: IntuneDiagnosticSeverity::Error, + category: IntuneDiagnosticCategory::Install, + remediation_priority: IntuneRemediationPriority::High, title: "App install or enforcement failures detected".to_string(), summary: "The workload progressed past content acquisition but failed during installer launch, enforcement, or completion tracking.".to_string(), + likely_cause: Some( + install_hint + .as_ref() + .map(|hint| format!("Installer enforcement is failing with {} ({}).", hint.code, hint.description)) + .unwrap_or_else(|| "Installer launch, execution, or detection handoff is failing after content acquisition completed.".to_string()), + ), evidence, next_checks: vec![ "Inspect AppWorkload install and enforcement rows near the failure for the last successful phase before the installer returned control.".to_string(), @@ -1711,6 +2235,13 @@ fn build_diagnostics( "Correlate the failure with AgentExecutor or remediation activity if the deployment depends on prerequisite scripts.".to_string(), ], suggested_fixes: install_failure_suggested_fixes(install_hint), + focus_areas: vec![ + "Installer command line and return-code mapping".to_string(), + "Detection-rule accuracy after install".to_string(), + "Prerequisite scripts and execution context".to_string(), + ], + affected_source_files: related_source_files(&install_failures, 4), + related_error_codes: related_error_codes(&install_failures, 3), }); } @@ -1721,7 +2252,11 @@ fn build_diagnostics( timed_out_events.len() )]; evidence.extend(top_event_labels(&timed_out_events, 2)); - evidence.extend(repeated_group_evidence(&timed_out_events, 2, "Timeout loop")); + evidence.extend(repeated_group_evidence( + &timed_out_events, + 2, + "Timeout loop", + )); let (title, summary) = if timeout_loops.is_empty() { ( @@ -1752,8 +2287,19 @@ fn build_diagnostics( insights.push(IntuneDiagnosticInsight { id: "operation-timeouts".to_string(), severity: IntuneDiagnosticSeverity::Error, + category: IntuneDiagnosticCategory::Timeout, + remediation_priority: if timeout_loops.is_empty() { + IntuneRemediationPriority::High + } else { + IntuneRemediationPriority::Immediate + }, title: title.to_string(), summary: summary.to_string(), + likely_cause: Some(if timeout_loops.is_empty() { + "The operation is running long enough to hit IME timeout thresholds without a definitive completion signal.".to_string() + } else { + "The same timeout path is repeating across retries, which means the blocking condition is persisting between attempts.".to_string() + }), evidence, next_checks: vec![ "Inspect the matching event rows around the timeout for the last successful phase before the stall.".to_string(), @@ -1761,6 +2307,13 @@ fn build_diagnostics( "Look for repeated retries or follow-on failure codes in AppWorkload, AgentExecutor, or HealthScripts logs.".to_string(), ], suggested_fixes, + focus_areas: vec![ + "Last successful phase before the stall".to_string(), + "Installer or script wait conditions".to_string(), + "External dependencies that never become ready".to_string(), + ], + affected_source_files: related_source_files(&timed_out_events, 4), + related_error_codes: related_error_codes(&timed_out_events, 3), }); } @@ -1773,7 +2326,11 @@ fn build_diagnostics( )]; evidence.extend(top_event_labels(&script_failures, 3)); evidence.extend(top_event_detail_matches(&script_failures, 2)); - evidence.extend(repeated_group_evidence(&script_failures, 2, "Recurring script failure")); + evidence.extend(repeated_group_evidence( + &script_failures, + 2, + "Recurring script failure", + )); evidence.extend(script_scope_evidence(&script_failures)); if let Some(error_hint) = &script_hint { evidence.push(format!( @@ -1785,8 +2342,11 @@ fn build_diagnostics( insights.push(IntuneDiagnosticInsight { id: "script-failures".to_string(), severity: IntuneDiagnosticSeverity::Error, + category: IntuneDiagnosticCategory::Script, + remediation_priority: IntuneRemediationPriority::High, title: script_case.title.to_string(), summary: script_case.summary.to_string(), + likely_cause: Some(script_case.likely_cause.to_string()), evidence, next_checks: vec![ "Review AgentExecutor and HealthScripts entries for stdout, stderr, and explicit exit-code lines around the affected script.".to_string(), @@ -1794,6 +2354,13 @@ fn build_diagnostics( "Validate script prerequisites such as execution context, file paths, network access, and required modules or commands.".to_string(), ], suggested_fixes: script_failure_suggested_fixes(&script_failures, script_hint), + focus_areas: vec![ + "AgentExecutor and HealthScripts output around failure".to_string(), + "Execution context, paths, and dependency availability".to_string(), + "Detection vs remediation script separation".to_string(), + ], + affected_source_files: related_source_files(&script_failures, 4), + related_error_codes: related_error_codes(&script_failures, 3), }); } @@ -1805,7 +2372,11 @@ fn build_diagnostics( )]; evidence.extend(top_event_labels(&policy_events, 2)); evidence.extend(top_event_detail_matches(&policy_events, 2)); - evidence.extend(repeated_group_evidence(&policy_events, 2, "Repeated policy block")); + evidence.extend(repeated_group_evidence( + &policy_events, + 2, + "Repeated policy block", + )); if let Some(reason) = applicability_reason_evidence(&policy_events) { evidence.push(reason); } @@ -1813,8 +2384,11 @@ fn build_diagnostics( insights.push(IntuneDiagnosticInsight { id: "policy-applicability".to_string(), severity: IntuneDiagnosticSeverity::Warning, + category: IntuneDiagnosticCategory::Policy, + remediation_priority: IntuneRemediationPriority::Medium, title: policy_case.title.to_string(), summary: policy_case.summary.to_string(), + likely_cause: Some(policy_case.likely_cause.to_string()), evidence, next_checks: vec![ "Review AppActionProcessor requirement-rule, detection-rule, and applicability lines for the affected app GUIDs.".to_string(), @@ -1826,6 +2400,13 @@ fn build_diagnostics( .into_iter() .map(|item| item.to_string()) .collect(), + focus_areas: vec![ + "AppActionProcessor applicability and requirement evaluation".to_string(), + "Assignment targeting and deployment intent".to_string(), + "Detection-rule and applicability-rule truthfulness".to_string(), + ], + affected_source_files: related_source_files(&policy_events, 4), + related_error_codes: related_error_codes(&policy_events, 3), }); } @@ -1834,8 +2415,11 @@ fn build_diagnostics( insights.push(IntuneDiagnosticInsight { id: "work-in-progress".to_string(), severity: IntuneDiagnosticSeverity::Info, + category: IntuneDiagnosticCategory::State, + remediation_priority: IntuneRemediationPriority::Monitor, title: "Workload still in progress".to_string(), summary: "The current IME snapshot shows pending or in-progress work without a dominant failure pattern yet.".to_string(), + likely_cause: Some("The device is still moving through the current IME cycle, so a stable failure signature has not formed yet.".to_string()), evidence: vec![ format!("{} event(s) are still in progress.", summary.in_progress), format!("{} event(s) are still pending.", summary.pending), @@ -1847,13 +2431,22 @@ fn build_diagnostics( suggested_fixes: vec![ "Allow the current IME cycle to finish before changing the deployment unless a repeated stall pattern appears.".to_string(), ], + focus_areas: vec![ + "Most recent active timeline items".to_string(), + "Whether progress converts into success or a stable failure".to_string(), + ], + affected_source_files: Vec::new(), + related_error_codes: Vec::new(), }); } else if summary.total_events > 0 { insights.push(IntuneDiagnosticInsight { id: "no-dominant-blocker".to_string(), severity: IntuneDiagnosticSeverity::Info, + category: IntuneDiagnosticCategory::General, + remediation_priority: IntuneRemediationPriority::Monitor, title: "No dominant blocker detected".to_string(), summary: "The analyzed IME logs do not show a strong failure cluster in downloads, scripts, policy evaluation, or timeouts.".to_string(), + likely_cause: Some("The current evidence set is not clustered around a single dominant failure path, so more correlation is needed before changing packaging or targeting.".to_string()), evidence: vec![ format!("{} event(s) succeeded.", summary.succeeded), format!("{} total event(s) were analyzed.", summary.total_events), @@ -1865,6 +2458,12 @@ fn build_diagnostics( suggested_fixes: vec![ "Do not change packaging or targeting yet; gather one failing sample with adjacent logs before tuning heuristics further.".to_string(), ], + focus_areas: vec![ + "Last non-success timeline event".to_string(), + "Correlation with portal assignment state and device conditions".to_string(), + ], + affected_source_files: Vec::new(), + related_error_codes: Vec::new(), }); } } @@ -1872,20 +2471,43 @@ fn build_diagnostics( insights } -fn top_failed_download_labels(downloads: &[DownloadStat], limit: usize) -> Vec { +fn related_source_files(events: &[&IntuneEvent], limit: usize) -> Vec { + let mut files = Vec::new(); + + for event in events { + if files.contains(&event.source_file) { + continue; + } + + files.push(event.source_file.clone()); + if files.len() >= limit { + break; + } + } + + files +} + +fn related_error_codes(events: &[&IntuneEvent], limit: usize) -> Vec { let mut labels = Vec::new(); - for download in downloads.iter().filter(|download| !download.success) { - let label = if download.name.trim().is_empty() { - format!("Affected content ID: {}", download.content_id) + for event in events { + let Some(error_code) = &event.error_code else { + continue; + }; + + let lookup = lookup_error_code(error_code); + let label = if lookup.found { + format!("{} ({})", lookup.code_hex, lookup.description) } else { - format!("Affected content: {}", download.name) + format!("{} ({})", error_code, lookup.description) }; - if !labels.contains(&label) { - labels.push(label); + if labels.contains(&label) { + continue; } + labels.push(label); if labels.len() >= limit { break; } @@ -1894,6 +2516,36 @@ fn top_failed_download_labels(downloads: &[DownloadStat], limit: usize) -> Vec Vec { + let mut label_counts: std::collections::HashMap = + std::collections::HashMap::new(); + + for download in downloads.iter().filter(|download| !download.success) { + let label = if download.name.trim().is_empty() { + format!("Affected content ID: {}", download.content_id) + } else { + format!("Affected content: {}", download.name) + }; + + *label_counts.entry(label).or_insert(0) += 1; + } + + let mut sorted_counts: Vec<(String, usize)> = label_counts.into_iter().collect(); + sorted_counts.sort_by(|a, b| b.1.cmp(&a.1)); + + sorted_counts + .into_iter() + .take(limit) + .map(|(label, count)| { + if count > 1 { + format!("{} ({} times)", label, count) + } else { + label + } + }) + .collect() +} + #[derive(Clone)] struct ErrorHint { code: String, @@ -1903,6 +2555,7 @@ struct ErrorHint { struct DownloadFailureCase { title: &'static str, summary: &'static str, + likely_cause: &'static str, suggested_fixes: Vec<&'static str>, } @@ -1914,12 +2567,19 @@ fn classify_download_failure_case( event.status == IntuneStatus::Timeout || contains_any( &event.detail, - &["stalled", "not progressing", "no progress", "timed out", "timeout"], + &[ + "stalled", + "not progressing", + "no progress", + "timed out", + "timeout", + ], ) }) { return DownloadFailureCase { title: "Content download stalled or timed out", summary: "The device started content acquisition, but AppWorkload shows the same payload stopping without forward progress before install-ready staging completed.", + likely_cause: "Content transfer is starting but losing forward progress before staging completes.", suggested_fixes: vec![ "Check for content-transfer stalls, Delivery Optimization blockage, or proxy/VPN interference before forcing another retry.", "If the same content repeatedly stalls, clear stale IME cache state on the test device and retry with fresh logs.", @@ -1935,6 +2595,7 @@ fn classify_download_failure_case( return DownloadFailureCase { title: "Content hash or staging validation failed", summary: "The device downloaded content, but staging or hash verification indicates the package may be incomplete, stale, or mismatched.", + likely_cause: "The downloaded payload does not match the content revision expected during staging or validation.", suggested_fixes: vec![ "Re-upload or redistribute the app content in Intune so the device receives a clean package revision.", "Verify that the package contents and detection logic still match the deployed app version.", @@ -1943,13 +2604,16 @@ fn classify_download_failure_case( }; } - if events - .iter() - .any(|event| contains_any(&event.detail, &["staging", "content cached", "cache location"])) - { + if events.iter().any(|event| { + contains_any( + &event.detail, + &["staging", "content cached", "cache location"], + ) + }) { return DownloadFailureCase { title: "Content staging failed after download", summary: "The workload reached caching or staging, but the local handoff into install-ready content did not complete successfully.", + likely_cause: "The package transfer finished, but local cache handoff or disk-backed staging is failing.", suggested_fixes: vec![ "Validate local disk space and permissions on the IME content cache path.", "Retry with a fresh content download if cached payloads appear stale or partially written.", @@ -1962,6 +2626,7 @@ fn classify_download_failure_case( return DownloadFailureCase { title: "Content download is retrying without completing", summary: "The same content is cycling through retry attempts, which points to a persistent transfer or staging blocker instead of a one-off transient miss.", + likely_cause: "The retry loop is masking a persistent download or cache blocker that is not changing between attempts.", suggested_fixes: vec![ "Review the first failed download attempt for the real cause instead of focusing only on the later retry lines.", "Validate network path, Delivery Optimization policy, and local cache health before forcing additional sync cycles.", @@ -1970,10 +2635,14 @@ fn classify_download_failure_case( }; } - if downloads.iter().any(|download| !download.success && download.do_percentage == 0.0) { + if downloads + .iter() + .any(|download| !download.success && download.do_percentage == 0.0) + { return DownloadFailureCase { title: "Content retrieval failed before local staging", summary: "The workload is failing during content acquisition rather than install, and the logs do not show healthy Delivery Optimization contribution.", + likely_cause: "The device is failing before content ever reaches a healthy local cache or staging state.", suggested_fixes: vec![ "Validate proxy, VPN, firewall, and Delivery Optimization reachability for the content source.", "Test the same deployment on a network path without restrictive content filtering.", @@ -1985,6 +2654,7 @@ fn classify_download_failure_case( DownloadFailureCase { title: "Content download failures detected", summary: "App content did not download cleanly, so enforcement may never reach install or detection stages.", + likely_cause: "Content acquisition is failing early enough that install and detection phases cannot start reliably.", suggested_fixes: vec![ "Confirm the app payload is still available and matches the expected content in Intune.", "Check device network reachability to Microsoft content endpoints and any proxy path in between.", @@ -2019,6 +2689,7 @@ fn best_error_hint(events: &[&IntuneEvent]) -> Option { struct ScriptFailureCase { title: &'static str, summary: &'static str, + likely_cause: &'static str, } fn classify_script_failure_case(events: &[&IntuneEvent]) -> ScriptFailureCase { @@ -2035,6 +2706,7 @@ fn classify_script_failure_case(events: &[&IntuneEvent]) -> ScriptFailureCase { return ScriptFailureCase { title: "Script execution policy or signing blocked execution", summary: "The script did not fail inside its own logic; PowerShell policy or signing requirements blocked it before it could run normally.", + likely_cause: "PowerShell policy or signature requirements are preventing script startup.", }; } @@ -2047,6 +2719,7 @@ fn classify_script_failure_case(events: &[&IntuneEvent]) -> ScriptFailureCase { return ScriptFailureCase { title: "Script execution failed due to permissions or access", summary: "The script path is being reached, but the execution context does not have access to one or more required resources.", + likely_cause: "The IME execution context cannot reach or modify one of the resources the script expects.", }; } @@ -2065,15 +2738,18 @@ fn classify_script_failure_case(events: &[&IntuneEvent]) -> ScriptFailureCase { return ScriptFailureCase { title: "Script dependency or path resolution failed", summary: "The script is calling a path, command, or module that is not available in the IME execution context on the device.", + likely_cause: "One or more script dependencies are missing or resolved differently under IME.", }; } - if events.iter().any(|event| { - contains_any(&event.detail, &["parsererror", "syntax error", "exception"]) - }) { + if events + .iter() + .any(|event| contains_any(&event.detail, &["parsererror", "syntax error", "exception"])) + { return ScriptFailureCase { title: "Script syntax or runtime errors detected", summary: "The script started but then failed because of a parser, command, or runtime error rather than a packaging or download issue.", + likely_cause: "The script is running but failing inside its own logic or command flow.", }; } @@ -2081,11 +2757,13 @@ fn classify_script_failure_case(events: &[&IntuneEvent]) -> ScriptFailureCase { ScriptFailureCase { title: "Script execution failures detected", summary: "Detection or remediation logic returned a non-zero outcome or never completed, which can block compliance or app enforcement.", + likely_cause: "Detection or remediation logic is failing consistently enough to block downstream enforcement decisions.", } } else { ScriptFailureCase { title: "Recurring script or remediation failures detected", summary: "The same detection or remediation path is failing across multiple attempts, which points to a persistent script issue instead of a one-time transient failure.", + likely_cause: "The same script path is re-entering failure with no device-state change between attempts.", } } } @@ -2093,6 +2771,7 @@ fn classify_script_failure_case(events: &[&IntuneEvent]) -> ScriptFailureCase { struct PolicyFailureCase { title: &'static str, summary: &'static str, + likely_cause: &'static str, suggested_fixes: Vec<&'static str>, } @@ -2104,6 +2783,7 @@ fn classify_policy_failure_case(events: &[&IntuneEvent]) -> PolicyFailureCase { return PolicyFailureCase { title: "Applicability blocked enforcement", summary: "AppActionProcessor shows the deployment was evaluated, but the app was rejected as not applicable before enforcement could continue.", + likely_cause: "Applicability logic is determining the target is not eligible, so enforcement never starts.", suggested_fixes: vec![ "Review assignment targeting and applicability conditions to confirm the device should actually qualify.", "If the device should be included, correct the applicability logic instead of forcing repeated retries.", @@ -2119,6 +2799,7 @@ fn classify_policy_failure_case(events: &[&IntuneEvent]) -> PolicyFailureCase { return PolicyFailureCase { title: "Requirement rules blocked enforcement", summary: "The assignment reached policy evaluation, but a requirement-rule decision prevented the app from entering the enforcement path.", + likely_cause: "Requirement-rule evaluation is filtering the device out before the install workflow begins.", suggested_fixes: vec![ "Validate every requirement-rule input on the affected device, especially OS version, architecture, and custom script results.", "Re-test the rule with the same device context that IME uses instead of assuming portal targeting is enough.", @@ -2127,13 +2808,16 @@ fn classify_policy_failure_case(events: &[&IntuneEvent]) -> PolicyFailureCase { }; } - if events - .iter() - .any(|event| contains_any(&event.detail, &["detection rule", "detected", "already installed"])) - { + if events.iter().any(|event| { + contains_any( + &event.detail, + &["detection rule", "detected", "already installed"], + ) + }) { return PolicyFailureCase { title: "Detection-state evidence blocked enforcement", summary: "AppActionProcessor indicates the deployment was evaluated, but detection-state logic made IME treat the app as already present or otherwise not needing enforcement.", + likely_cause: "Detection-state evidence is convincing IME that enforcement is unnecessary or already satisfied.", suggested_fixes: vec![ "Verify that the detection rule is not falsely reporting success on the affected device.", "Compare detection-rule logic with the actual install footprint created by the package.", @@ -2145,6 +2829,7 @@ fn classify_policy_failure_case(events: &[&IntuneEvent]) -> PolicyFailureCase { PolicyFailureCase { title: "Policy applicability needs review", summary: "Assignment or applicability evaluation may be preventing enforcement even when content and scripts are available.", + likely_cause: "The device is reaching policy evaluation, but assignment or applicability state is not lining up with the expected outcome.", suggested_fixes: vec![ "Review assignment targeting, intent, and any deadlines or retry windows for the affected policy.", "Validate that prerequisite policies or dependent apps are not blocking the enforcement path.", @@ -2203,7 +2888,11 @@ fn script_failure_suggested_fixes( if events.iter().any(|event| { contains_any( &event.detail, - &["execution policy", "digitally signed", "running scripts is disabled"], + &[ + "execution policy", + "digitally signed", + "running scripts is disabled", + ], ) }) { fixes.push( @@ -2214,7 +2903,13 @@ fn script_failure_suggested_fixes( if events.iter().any(|event| { contains_any( &event.detail, - &["cannot find path", "path not found", "file not found", "module", "not recognized"], + &[ + "cannot find path", + "path not found", + "file not found", + "module", + "not recognized", + ], ) }) { fixes.push( @@ -2222,7 +2917,9 @@ fn script_failure_suggested_fixes( ); } - if events.iter().any(|event| event.status == IntuneStatus::Timeout) + if events + .iter() + .any(|event| event.status == IntuneStatus::Timeout) || !repeated_failure_groups(events, 2).is_empty() { fixes.push( @@ -2250,7 +2947,9 @@ fn script_failure_suggested_fixes( } fn top_event_detail_matches(events: &[&IntuneEvent], limit: usize) -> Vec { - let mut labels = Vec::new(); + let mut evidence_counts: std::collections::HashMap = + std::collections::HashMap::new(); + let name_re = regex::Regex::new(r#"(?i)\"(?:ApplicationName|Name)\"\s*:\s*\"([^\",\}]+)"#).ok(); for event in events { let snippet = event.detail.trim(); @@ -2258,29 +2957,52 @@ fn top_event_detail_matches(events: &[&IntuneEvent], limit: usize) -> Vec 120 { - format!("{}...", &snippet[..120]) + let extracted_name = name_re + .as_ref() + .and_then(|re| re.captures(snippet).map(|caps| caps[1].trim().to_string())); + + let evidence = if let Some(name) = extracted_name { + if event.event_type == IntuneEventType::PowerShellScript + || event.event_type == IntuneEventType::Remediation + { + format!("Failing script: {}", name) + } else if event.event_type == IntuneEventType::PolicyEvaluation { + format!("Affected policy: {}", name) + } else { + format!("Failing app: {}", name) + } } else { - snippet.to_string() + format!("Observed detail: {}", snippet) }; - let evidence = format!("Observed detail: {}", shortened); - - if !labels.contains(&evidence) { - labels.push(evidence); - } - if labels.len() >= limit { - break; - } + *evidence_counts.entry(evidence).or_insert(0) += 1; } - labels + let mut sorted_counts: Vec<(String, usize)> = evidence_counts.into_iter().collect(); + sorted_counts.sort_by(|a, b| b.1.cmp(&a.1)); + + sorted_counts + .into_iter() + .take(limit) + .map(|(evidence, count)| { + if count > 1 { + format!("{} ({} times)", evidence, count) + } else { + evidence + } + }) + .collect() } fn repeated_retry_evidence(events: &[&IntuneEvent]) -> Option { let retry_count = events .iter() - .filter(|event| contains_any(&event.detail, &["retry", "retrying", "reattempt", "will retry"])) + .filter(|event| { + contains_any( + &event.detail, + &["retry", "retrying", "reattempt", "will retry"], + ) + }) .count(); if retry_count > 0 { @@ -2300,7 +3022,13 @@ fn stalled_download_evidence(events: &[&IntuneEvent]) -> Option { event.status == IntuneStatus::Timeout || contains_any( &event.detail, - &["stalled", "not progressing", "no progress", "timed out", "timeout"], + &[ + "stalled", + "not progressing", + "no progress", + "timed out", + "timeout", + ], ) }) .count(); @@ -2330,14 +3058,17 @@ fn applicability_reason_evidence(events: &[&IntuneEvent]) -> Option { .any(|event| contains_any(&event.detail, &["requirement rule", "requirements"])) { return Some( - "Requirement-rule evidence appears in the policy-evaluation flow for the affected app.".to_string(), + "Requirement-rule evidence appears in the policy-evaluation flow for the affected app." + .to_string(), ); } - if events - .iter() - .any(|event| contains_any(&event.detail, &["detection rule", "already installed", "detected"])) - { + if events.iter().any(|event| { + contains_any( + &event.detail, + &["detection rule", "already installed", "detected"], + ) + }) { return Some( "Detection-rule evidence appears to be short-circuiting enforcement for the affected app.".to_string(), ); @@ -2380,7 +3111,12 @@ fn repeated_group_evidence( ) -> Vec { repeated_failure_groups(events, minimum_occurrences) .into_iter() - .map(|group| format!("{}: {} ({} occurrence(s)).", prefix, group.label, group.occurrences)) + .map(|group| { + format!( + "{}: {} ({} occurrence(s)).", + prefix, group.label, group.occurrences + ) + }) .collect() } @@ -2453,11 +3189,14 @@ fn normalize_group_label(value: &str) -> String { fn contains_any(value: &str, terms: &[&str]) -> bool { let normalized = value.to_ascii_lowercase(); - terms.iter().any(|term| normalized.contains(&term.to_ascii_lowercase())) + terms + .iter() + .any(|term| normalized.contains(&term.to_ascii_lowercase())) } fn top_event_labels(events: &[&IntuneEvent], limit: usize) -> Vec { - let mut labels = Vec::new(); + let mut label_counts: std::collections::HashMap = + std::collections::HashMap::new(); for event in events { let mut label = event.name.clone(); @@ -2466,24 +3205,31 @@ fn top_event_labels(events: &[&IntuneEvent], limit: usize) -> Vec { } let evidence = format!("Affected event: {}", label); - if !labels.contains(&evidence) { - labels.push(evidence); - } - - if labels.len() >= limit { - break; - } + *label_counts.entry(evidence).or_insert(0) += 1; } - labels + let mut sorted_counts: Vec<(String, usize)> = label_counts.into_iter().collect(); + sorted_counts.sort_by(|a, b| b.1.cmp(&a.1)); + + sorted_counts + .into_iter() + .take(limit) + .map(|(label, count)| { + if count > 1 { + format!("{} ({} times)", label, count) + } else { + label + } + }) + .collect() } #[cfg(test)] mod tests { use super::{ - build_diagnostics, build_diagnostics_confidence, build_repeated_failures, - build_summary, build_timestamp_bounds, collect_input_paths, finalize_coverage, - resolve_intune_input, CoverageAccumulator, + build_diagnostics, build_diagnostics_confidence, build_repeated_failures, build_summary, + build_timestamp_bounds, collect_input_paths, finalize_coverage, resolve_intune_input, + CoverageAccumulator, }; use crate::intune::models::{ DownloadStat, IntuneDiagnosticSeverity, IntuneDiagnosticsConfidenceLevel, @@ -2499,8 +3245,7 @@ mod tests { fs::write(test_dir.join("IntuneManagementExtension.log"), "primary") .expect("write primary log"); - fs::write(test_dir.join("AppWorkload.log"), "sidecar") - .expect("write app workload log"); + fs::write(test_dir.join("AppWorkload.log"), "sidecar").expect("write app workload log"); fs::write(test_dir.join("AppActionProcessor.log"), "app actions") .expect("write app action processor log"); fs::write(test_dir.join("AgentExecutor.log"), "executor") @@ -2511,24 +3256,21 @@ mod tests { .expect("write client health log"); fs::write(test_dir.join("ClientCertCheck.log"), "client cert") .expect("write client cert check log"); - fs::write( - test_dir.join("DeviceHealthMonitoring.log"), - "device health", - ) - .expect("write device health monitoring log"); - fs::write(test_dir.join("Sensor.log"), "sensor") - .expect("write sensor log"); + fs::write(test_dir.join("DeviceHealthMonitoring.log"), "device health") + .expect("write device health monitoring log"); + fs::write(test_dir.join("Sensor.log"), "sensor").expect("write sensor log"); fs::write(test_dir.join("Win32AppInventory.log"), "inventory") .expect("write win32 app inventory log"); - fs::write(test_dir.join("ImeUI.log"), "ui") - .expect("write ime ui log"); - fs::write(test_dir.join("random.log"), "other") - .expect("write unrelated log"); + fs::write(test_dir.join("ImeUI.log"), "ui").expect("write ime ui log"); + fs::write(test_dir.join("random.log"), "other").expect("write unrelated log"); let collected = collect_input_paths(&test_dir).expect("collect input paths"); let file_names: Vec = collected .iter() - .filter_map(|path| path.file_name().map(|name| name.to_string_lossy().into_owned())) + .filter_map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) .collect(); assert_eq!( @@ -2551,57 +3293,63 @@ mod tests { fs::remove_dir_all(&test_dir).expect("remove temp dir"); } - #[test] - fn collect_input_paths_reads_bundle_logs_from_manifest_guided_entry_points() { - let bundle_dir = create_temp_dir("intune-bundle"); - let logs_dir = bundle_dir.join("evidence").join("logs"); - fs::create_dir_all(&logs_dir).expect("create logs dir"); - fs::write(logs_dir.join("IntuneManagementExtension.log"), "primary") - .expect("write primary log"); - fs::write(logs_dir.join("AppWorkload.log"), "sidecar").expect("write sidecar"); - fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()).expect("write manifest"); - - let collected = collect_input_paths(&bundle_dir).expect("collect input paths"); - let file_names: Vec = collected - .iter() - .filter_map(|path| path.file_name().map(|name| name.to_string_lossy().into_owned())) - .collect(); - - assert_eq!( - file_names, - vec![ - "IntuneManagementExtension.log".to_string(), - "AppWorkload.log".to_string(), - ] - ); + #[test] + fn collect_input_paths_reads_bundle_logs_from_manifest_guided_entry_points() { + let bundle_dir = create_temp_dir("intune-bundle"); + let logs_dir = bundle_dir.join("evidence").join("logs"); + fs::create_dir_all(&logs_dir).expect("create logs dir"); + fs::write(logs_dir.join("IntuneManagementExtension.log"), "primary") + .expect("write primary log"); + fs::write(logs_dir.join("AppWorkload.log"), "sidecar").expect("write sidecar"); + fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()) + .expect("write manifest"); - fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); - } + let collected = collect_input_paths(&bundle_dir).expect("collect input paths"); + let file_names: Vec = collected + .iter() + .filter_map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .collect(); - #[test] - fn resolve_intune_input_retains_bundle_metadata_and_allows_sparse_bundle() { - let bundle_dir = create_temp_dir("intune-sparse-bundle"); - fs::create_dir_all(bundle_dir.join("evidence").join("logs")).expect("create sparse logs dir"); - fs::write(bundle_dir.join("notes.md"), "notes").expect("write notes"); - fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()).expect("write manifest"); - - let resolved = resolve_intune_input(&bundle_dir).expect("resolve bundle input"); - - assert!(resolved.source_paths.is_empty()); - let bundle = resolved.evidence_bundle.expect("bundle metadata"); - assert_eq!(bundle.bundle_id.as_deref(), Some("CMTRACE-123")); - assert_eq!(bundle.device_name.as_deref(), Some("GELL-VM-5879648")); - assert_eq!(bundle.available_primary_entry_points.len(), 1); - assert!(bundle - .available_primary_entry_points - .iter() - .any(|path| path.ends_with("evidence\\logs") || path.ends_with("evidence/logs"))); - - fs::remove_dir_all(&bundle_dir).expect("remove temp sparse bundle dir"); - } + assert_eq!( + file_names, + vec![ + "IntuneManagementExtension.log".to_string(), + "AppWorkload.log".to_string(), + ] + ); + + fs::remove_dir_all(&bundle_dir).expect("remove temp bundle dir"); + } + + #[test] + fn resolve_intune_input_retains_bundle_metadata_and_allows_sparse_bundle() { + let bundle_dir = create_temp_dir("intune-sparse-bundle"); + fs::create_dir_all(bundle_dir.join("evidence").join("logs")) + .expect("create sparse logs dir"); + fs::write(bundle_dir.join("notes.md"), "notes").expect("write notes"); + fs::write(bundle_dir.join("manifest.json"), sample_bundle_manifest()) + .expect("write manifest"); + + let resolved = resolve_intune_input(&bundle_dir).expect("resolve bundle input"); + + assert!(resolved.source_paths.is_empty()); + let bundle = resolved.evidence_bundle.expect("bundle metadata"); + assert_eq!(bundle.bundle_id.as_deref(), Some("CMTRACE-123")); + assert_eq!(bundle.device_name.as_deref(), Some("GELL-VM-5879648")); + assert_eq!(bundle.available_primary_entry_points.len(), 1); + assert!(bundle + .available_primary_entry_points + .iter() + .any(|path| path.ends_with("evidence\\logs") || path.ends_with("evidence/logs"))); + + fs::remove_dir_all(&bundle_dir).expect("remove temp sparse bundle dir"); + } - fn sample_bundle_manifest() -> &'static str { - r#"{ + fn sample_bundle_manifest() -> &'static str { + r#"{ "bundle": { "bundleId": "CMTRACE-123", "bundleLabel": "intune-endpoint-evidence", @@ -2654,7 +3402,7 @@ mod tests { } ] }"# - } + } fn create_temp_dir(prefix: &str) -> PathBuf { let unique = SystemTime::now() @@ -2759,7 +3507,10 @@ mod tests { assert_eq!(diagnostics.len(), 4); assert_eq!(diagnostics[0].id, "download-failures"); assert_eq!(diagnostics[0].severity, IntuneDiagnosticSeverity::Error); - assert_eq!(diagnostics[0].title, "Content hash or staging validation failed"); + assert_eq!( + diagnostics[0].title, + "Content hash or staging validation failed" + ); assert!(diagnostics[0] .evidence .iter() @@ -2772,7 +3523,9 @@ mod tests { assert!(diagnostics .iter() .any(|item| item.id == "install-enforcement-failures")); - assert!(diagnostics.iter().any(|item| item.id == "policy-applicability")); + assert!(diagnostics + .iter() + .any(|item| item.id == "policy-applicability")); let install = diagnostics .iter() @@ -2826,7 +3579,10 @@ mod tests { assert_eq!(repeated[0].occurrences, 2); assert_eq!(repeated[0].source_files.len(), 2); assert!(repeated[0].name.contains("Contoso App Install")); - assert!(repeated[0].name.contains("Access is denied") || repeated[0].name.contains("0x80070005")); + assert!( + repeated[0].name.contains("Access is denied") + || repeated[0].name.contains("0x80070005") + ); } #[test] @@ -2885,9 +3641,18 @@ mod tests { let finalized = finalize_coverage(coverage, &events, &downloads); assert!(finalized.has_rotated_logs); - assert_eq!(finalized.files[0].rotation_group.as_deref(), Some("appworkload")); + assert_eq!( + finalized.files[0].rotation_group.as_deref(), + Some("appworkload") + ); assert!(finalized.files[1].is_rotated_segment); - assert_eq!(finalized.dominant_source.as_ref().map(|item| item.file_path.as_str()), Some("C:/Logs/AppWorkload.log")); + assert_eq!( + finalized + .dominant_source + .as_ref() + .map(|item| item.file_path.as_str()), + Some("C:/Logs/AppWorkload.log") + ); } #[test] @@ -2969,7 +3734,7 @@ mod tests { }, ]; - let confidence = build_diagnostics_confidence(&summary, &coverage, &[], &events); + let confidence = build_diagnostics_confidence(&summary, &coverage, &[], &events, &None); assert_eq!(confidence.level, IntuneDiagnosticsConfidenceLevel::Low); assert!(confidence .reasons @@ -2982,7 +3747,7 @@ mod tests { let events = vec![IntuneEvent { id: 1, event_type: IntuneEventType::ContentDownload, - name: "AppWorkload Download Stall (abcd1234...)".to_string(), + name: "AppWorkload Download Stall (a1b2c3d4-e5f6-7890-abcd-ef1234567890)".to_string(), guid: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()), status: IntuneStatus::Timeout, start_time: Some("01-15-2024 10:00:00.000".to_string()), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 40167f26d..08b86f74b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod file_ops; pub mod filter; pub mod intune; pub mod parsing; +pub mod system_preferences; diff --git a/src-tauri/src/commands/system_preferences.rs b/src-tauri/src/commands/system_preferences.rs new file mode 100644 index 000000000..9edcfb16f --- /dev/null +++ b/src-tauri/src/commands/system_preferences.rs @@ -0,0 +1,64 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemDateTimePreferences { + pub date_pattern: String, + pub time_pattern: String, + pub am_designator: Option, + pub pm_designator: Option, +} + +#[tauri::command] +pub fn get_system_date_time_preferences() -> Result { + #[cfg(target_os = "windows")] + { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey("Control Panel\\International") + .map_err(|error| { + format!( + "failed to open Windows international settings: {}", + error + ) + })?; + + let date_pattern: String = key + .get_value("sShortDate") + .map_err(|error| format!("failed to read Windows short date format: {}", error))?; + + let time_pattern: String = key + .get_value("sTimeFormat") + .or_else(|_| key.get_value("sShortTime")) + .map_err(|error| format!("failed to read Windows time format: {}", error))?; + + let am_designator = key + .get_value::("s1159") + .ok() + .filter(|value| !value.trim().is_empty()); + + let pm_designator = key + .get_value::("s2359") + .ok() + .filter(|value| !value.trim().is_empty()); + + Ok(SystemDateTimePreferences { + date_pattern, + time_pattern, + am_designator, + pm_designator, + }) + } + + #[cfg(not(target_os = "windows"))] + { + Ok(SystemDateTimePreferences { + date_pattern: "yyyy-MM-dd".to_string(), + time_pattern: "HH:mm:ss".to_string(), + am_designator: Some("AM".to_string()), + pm_designator: Some("PM".to_string()), + }) + } +} \ No newline at end of file diff --git a/src-tauri/src/dsregcmd/connectivity.rs b/src-tauri/src/dsregcmd/connectivity.rs new file mode 100644 index 000000000..45150ba0f --- /dev/null +++ b/src-tauri/src/dsregcmd/connectivity.rs @@ -0,0 +1,178 @@ +use crate::dsregcmd::models::DsregcmdActiveEvidence; +#[cfg(target_os = "windows")] +use crate::dsregcmd::models::{DsregcmdConnectivityResult, DsregcmdScpQueryResult}; + +#[cfg(target_os = "windows")] +const TEST_ENDPOINTS: &[&str] = &[ + "https://enterpriseregistration.windows.net", + "https://login.microsoftonline.com", + "https://device.login.microsoftonline.com", + "https://autologon.microsoftazuread-sso.com", +]; + +#[cfg(target_os = "windows")] +const ENDPOINT_TIMEOUT_SECS: u64 = 10; + +#[cfg(target_os = "windows")] +pub fn test_endpoint_connectivity() -> Vec { + let mut results = Vec::new(); + + for endpoint in TEST_ENDPOINTS { + let start = std::time::Instant::now(); + let timestamp = chrono::Utc::now().to_rfc3339(); + + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(ENDPOINT_TIMEOUT_SECS)) + .timeout_read(std::time::Duration::from_secs(ENDPOINT_TIMEOUT_SECS)) + .build(); + + match agent.head(endpoint).call() { + Ok(response) => { + let latency = start.elapsed().as_millis() as u64; + results.push(DsregcmdConnectivityResult { + endpoint: endpoint.to_string(), + reachable: true, + status_code: Some(response.status()), + latency_ms: Some(latency), + error_message: None, + timestamp, + }); + } + Err(ureq::Error::Status(code, _response)) => { + let latency = start.elapsed().as_millis() as u64; + // Non-2xx status but endpoint was reachable + results.push(DsregcmdConnectivityResult { + endpoint: endpoint.to_string(), + reachable: true, + status_code: Some(code), + latency_ms: Some(latency), + error_message: None, + timestamp, + }); + } + Err(ureq::Error::Transport(transport)) => { + let latency = start.elapsed().as_millis() as u64; + results.push(DsregcmdConnectivityResult { + endpoint: endpoint.to_string(), + reachable: false, + status_code: None, + latency_ms: Some(latency), + error_message: Some(transport.to_string()), + timestamp, + }); + } + } + } + + results +} + +#[cfg(target_os = "windows")] +pub fn query_scp() -> DsregcmdScpQueryResult { + let mut result = DsregcmdScpQueryResult::default(); + + // Try to find a domain controller via nltest + let dc_output = std::process::Command::new("nltest") + .arg("/dsgetdc:") + .output(); + + match dc_output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("DC:") { + result.domain_controller = + Some(trimmed.trim_start_matches("DC:").trim().trim_start_matches("\\\\").to_string()); + break; + } + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let exit_code = output.status.code().unwrap_or_default(); + result.error = Some(format!( + "nltest /dsgetdc: failed (exit code {}): {}", + exit_code, + if stderr.is_empty() { "(no stderr)" } else { &stderr } + )); + return result; + } + Err(e) => { + result.error = Some(format!("nltest not available: {e}")); + return result; + } + } + + // Query SCP via PowerShell + let ps_script = r#" +try { + $scp = [ADSI]"LDAP://CN=62a0ff2e-97b9-4513-943f-0d221bd30080,CN=Device Registration Configuration,CN=Services,CN=Configuration,$((Get-ADForest).Name)" + if ($scp.keywords) { + $scp.keywords | ForEach-Object { Write-Output $_ } + } else { + Write-Output "SCP_NOT_FOUND" + } +} catch { + Write-Output "SCP_ERROR: $_" +} +"#; + + let ps_output = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", ps_script]) + .output(); + + match ps_output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); + + if lines.iter().any(|l| l.contains("SCP_NOT_FOUND")) { + result.error = Some("SCP object exists but has no keywords.".to_string()); + return result; + } + + if let Some(error_line) = lines.iter().find(|l| l.starts_with("SCP_ERROR:")) { + result.error = Some(error_line.to_string()); + return result; + } + + result.scp_found = true; + result.keywords = lines.iter().map(|l| l.to_string()).collect(); + + for keyword in &result.keywords { + if let Some(domain) = keyword.strip_prefix("azureADName:") { + result.tenant_domain = Some(domain.trim().to_string()); + } + if let Some(id) = keyword.strip_prefix("azureADId:") { + result.azuread_id = Some(id.trim().to_string()); + } + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + result.error = Some(format!("PowerShell SCP query failed: {stderr}")); + } + Err(e) => { + result.error = Some(format!("PowerShell not available: {e}")); + } + } + + result +} + +#[cfg(target_os = "windows")] +pub fn run_active_diagnostics() -> DsregcmdActiveEvidence { + let connectivity_tests = test_endpoint_connectivity(); + let scp_query = Some(query_scp()); + + DsregcmdActiveEvidence { + connectivity_tests, + scp_query, + } +} + +#[cfg(not(target_os = "windows"))] +pub fn run_active_diagnostics() -> DsregcmdActiveEvidence { + DsregcmdActiveEvidence::default() +} diff --git a/src-tauri/src/dsregcmd/event_logs.rs b/src-tauri/src/dsregcmd/event_logs.rs new file mode 100644 index 000000000..81ba4acb8 --- /dev/null +++ b/src-tauri/src/dsregcmd/event_logs.rs @@ -0,0 +1,137 @@ +use crate::intune::models::EventLogAnalysis; + +#[cfg(target_os = "windows")] +const DSREGCMD_EVENT_CHANNELS: &[&str] = &[ + "Microsoft-Windows-AAD/Operational", + "Microsoft-Windows-User Device Registration/Admin", + "Microsoft-Windows-Crypto-DPAPI/Operational", + "Microsoft-Windows-Kerberos/Operational", + "System", +]; + +#[cfg(target_os = "windows")] +const MAX_ENTRIES_PER_CHANNEL: usize = 200; + +#[cfg(target_os = "windows")] +pub fn collect_dsregcmd_event_logs() -> Option { + use crate::intune::eventlog_win32; + use crate::intune::evtx_parser; + use crate::intune::models::{ + EventLogAnalysisSource, EventLogLiveQueryChannelResult, EventLogLiveQueryMetadata, + EventLogLiveQueryStatus, + }; + + let mut all_entries = Vec::new(); + let mut channel_results = Vec::new(); + let mut entry_id: u64 = 0; + + for channel_path in DSREGCMD_EVENT_CHANNELS { + match eventlog_win32::query_live_channel(channel_path, MAX_ENTRIES_PER_CHANNEL) { + Ok(query_result) => { + let mut channel_entry_count = 0u32; + + for record in &query_result.records { + if let Some(entry) = evtx_parser::parse_live_event_record( + &record.xml, + &record.source_file, + record.rendered_message.clone(), + entry_id, + channel_path, + ) { + all_entries.push(entry); + entry_id += 1; + channel_entry_count += 1; + } + } + + let status = if channel_entry_count > 0 { + EventLogLiveQueryStatus::Success + } else { + EventLogLiveQueryStatus::Empty + }; + + channel_results.push(EventLogLiveQueryChannelResult { + channel: crate::intune::models::EventLogChannel::from_channel_string( + channel_path, + ), + channel_display: crate::intune::models::EventLogChannel::from_channel_string( + channel_path, + ) + .display_name() + .to_string(), + channel_path: channel_path.to_string(), + source_file: query_result.source_file.clone(), + status, + entry_count: channel_entry_count, + error_message: None, + }); + } + Err(error) => { + eprintln!( + "event=dsregcmd_event_log_query_failed channel={} error={}", + channel_path, error + ); + + channel_results.push(EventLogLiveQueryChannelResult { + channel: crate::intune::models::EventLogChannel::from_channel_string( + channel_path, + ), + channel_display: crate::intune::models::EventLogChannel::from_channel_string( + channel_path, + ) + .display_name() + .to_string(), + channel_path: channel_path.to_string(), + source_file: String::new(), + status: EventLogLiveQueryStatus::Failed, + entry_count: 0, + error_message: Some(error), + }); + } + } + } + + let attempted = u32::try_from(channel_results.len()).unwrap_or(u32::MAX); + let successful = u32::try_from( + channel_results + .iter() + .filter(|r| !matches!(r.status, EventLogLiveQueryStatus::Failed)) + .count(), + ) + .unwrap_or(0); + let with_results = u32::try_from( + channel_results + .iter() + .filter(|r| matches!(r.status, EventLogLiveQueryStatus::Success)) + .count(), + ) + .unwrap_or(0); + let failed = u32::try_from( + channel_results + .iter() + .filter(|r| matches!(r.status, EventLogLiveQueryStatus::Failed)) + .count(), + ) + .unwrap_or(0); + + let live_query = Some(EventLogLiveQueryMetadata { + attempted_channel_count: attempted, + successful_channel_count: successful, + channels_with_results_count: with_results, + failed_channel_count: failed, + per_channel_entry_limit: u32::try_from(MAX_ENTRIES_PER_CHANNEL).unwrap_or(u32::MAX), + channels: channel_results, + }); + + evtx_parser::build_event_log_analysis( + all_entries, + u32::try_from(DSREGCMD_EVENT_CHANNELS.len()).unwrap_or(0), + EventLogAnalysisSource::Live, + live_query, + ) +} + +#[cfg(not(target_os = "windows"))] +pub fn collect_dsregcmd_event_logs() -> Option { + None +} diff --git a/src-tauri/src/dsregcmd/mod.rs b/src-tauri/src/dsregcmd/mod.rs index aa1acdc98..4e5dc2387 100644 --- a/src-tauri/src/dsregcmd/mod.rs +++ b/src-tauri/src/dsregcmd/mod.rs @@ -1,12 +1,16 @@ +pub mod connectivity; +pub mod event_logs; pub mod models; pub mod parser; pub mod registry; pub mod rules; pub use models::{ - DsregcmdAnalysisResult, DsregcmdDerived, DsregcmdDiagnosticInsight, - DsregcmdEvidenceSource, DsregcmdFacts, DsregcmdJoinType, DsregcmdPolicyEvidenceValue, - DsregcmdWhfbPolicyEvidence, + DsregcmdActiveEvidence, DsregcmdAnalysisResult, DsregcmdConnectivityResult, + DsregcmdDerived, DsregcmdDiagnosticInsight, DsregcmdEnrollmentEntry, + DsregcmdEnrollmentEvidence, DsregcmdEvidenceSource, DsregcmdFacts, DsregcmdJoinType, + DsregcmdOsVersionEvidence, DsregcmdPolicyEvidenceValue, DsregcmdProxyEvidence, + DsregcmdScpQueryResult, DsregcmdWhfbPolicyEvidence, }; pub fn analyze_text(input: &str) -> Result { diff --git a/src-tauri/src/dsregcmd/models.rs b/src-tauri/src/dsregcmd/models.rs index fee743db2..ce1b4425a 100644 --- a/src-tauri/src/dsregcmd/models.rs +++ b/src-tauri/src/dsregcmd/models.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::intune::models::IntuneDiagnosticSeverity; +use crate::intune::models::{EventLogAnalysis, IntuneDiagnosticSeverity}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum DsregcmdJoinType { @@ -269,6 +269,72 @@ pub struct DsregcmdDiagnosticInsight { pub suggested_fixes: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdOsVersionEvidence { + pub current_build: Option, + pub display_version: Option, + pub product_name: Option, + pub ubr: Option, + pub edition_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdProxyEvidence { + pub proxy_enabled: Option, + pub proxy_server: Option, + pub proxy_override: Option, + pub auto_config_url: Option, + pub wpad_detected: bool, + pub winhttp_proxy: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdEnrollmentEntry { + pub upn: Option, + pub provider_id: Option, + pub enrollment_state: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdEnrollmentEvidence { + pub enrollment_count: u32, + #[serde(default)] + pub enrollments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdConnectivityResult { + pub endpoint: String, + pub reachable: bool, + pub status_code: Option, + pub latency_ms: Option, + pub error_message: Option, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdScpQueryResult { + pub scp_found: bool, + pub tenant_domain: Option, + pub azuread_id: Option, + pub keywords: Vec, + pub domain_controller: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DsregcmdActiveEvidence { + pub connectivity_tests: Vec, + pub scp_query: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct DsregcmdAnalysisResult { @@ -278,6 +344,16 @@ pub struct DsregcmdAnalysisResult { pub diagnostics: Vec, #[serde(default)] pub policy_evidence: DsregcmdWhfbPolicyEvidence, + #[serde(default)] + pub os_version: Option, + #[serde(default)] + pub proxy_evidence: Option, + #[serde(default)] + pub enrollment_evidence: Option, + #[serde(default)] + pub active_evidence: Option, + #[serde(default)] + pub event_log_analysis: Option, } #[cfg(test)] diff --git a/src-tauri/src/dsregcmd/registry.rs b/src-tauri/src/dsregcmd/registry.rs index 63e20088e..5db092281 100644 --- a/src-tauri/src/dsregcmd/registry.rs +++ b/src-tauri/src/dsregcmd/registry.rs @@ -2,8 +2,12 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; + use crate::dsregcmd::models::{ - DsregcmdEvidenceSource, DsregcmdPolicyEvidenceValue, DsregcmdWhfbPolicyEvidence, + DsregcmdEnrollmentEntry, DsregcmdEnrollmentEvidence, DsregcmdEvidenceSource, + DsregcmdOsVersionEvidence, DsregcmdPolicyEvidenceValue, DsregcmdProxyEvidence, + DsregcmdWhfbPolicyEvidence, }; const REGISTRY_FOLDER: [&str; 2] = ["evidence", "registry"]; @@ -13,6 +17,10 @@ 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"; +const OS_VERSION_FILE: &str = "os-version.reg"; +const PROXY_INTERNET_SETTINGS_FILE: &str = "proxy-internet-settings.reg"; +const PROXY_CONNECTIONS_FILE: &str = "proxy-connections.reg"; +const ENROLLMENTS_FILE: &str = "enrollments.reg"; #[derive(Debug, Clone, PartialEq, Eq)] enum RegistryValue { @@ -20,8 +28,41 @@ enum RegistryValue { String(String), } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegistrySnapshotValuePreview { + pub name: String, + pub value_type: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegistrySnapshotKeyPreview { + pub path: String, + pub value_count: u32, + pub values: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegistrySnapshotSummary { + pub key_count: u32, + pub value_count: u32, + pub keys: Vec, +} + type RegistryKeyMap = HashMap>; +pub fn inspect_registry_snapshot_file(path: &Path) -> Option { + let content = fs::read_to_string(path) + .ok() + .or_else(|| fs::read(path).ok().and_then(|bytes| decode_reg_content(&bytes)))?; + let registry = parse_reg_snapshot(&content); + + Some(build_registry_snapshot_summary(®istry)) +} + pub fn load_whfb_policy_evidence(bundle_path: &Path) -> DsregcmdWhfbPolicyEvidence { let mut evidence = DsregcmdWhfbPolicyEvidence::default(); @@ -91,6 +132,138 @@ pub fn load_whfb_policy_evidence(bundle_path: &Path) -> DsregcmdWhfbPolicyEviden evidence } +pub fn load_os_version_evidence(bundle_path: &Path) -> Option { + let path = registry_file_path(bundle_path, OS_VERSION_FILE); + let mut artifact_paths = Vec::new(); + let registry = load_registry_map(&path, &mut artifact_paths); + if artifact_paths.is_empty() { + return None; + } + + let mut evidence = DsregcmdOsVersionEvidence::default(); + for (key_path, values) in ®istry { + let lower = key_path.to_ascii_lowercase(); + if !lower.contains("\\windows nt\\currentversion") { + continue; + } + evidence.current_build = extract_string_value(values, "CurrentBuild") + .or_else(|| extract_string_value(values, "CurrentBuildNumber")); + evidence.display_version = extract_string_value(values, "DisplayVersion"); + evidence.product_name = extract_string_value(values, "ProductName"); + evidence.ubr = extract_dword_value(values, "UBR"); + evidence.edition_id = extract_string_value(values, "EditionID"); + break; + } + + Some(evidence) +} + +pub fn load_proxy_evidence(bundle_path: &Path) -> Option { + let ie_path = registry_file_path(bundle_path, PROXY_INTERNET_SETTINGS_FILE); + let conn_path = registry_file_path(bundle_path, PROXY_CONNECTIONS_FILE); + + let mut artifact_paths = Vec::new(); + let ie_registry = load_registry_map(&ie_path, &mut artifact_paths); + let conn_registry = load_registry_map(&conn_path, &mut artifact_paths); + + if artifact_paths.is_empty() { + return None; + } + + let mut evidence = DsregcmdProxyEvidence::default(); + + for (key_path, values) in &ie_registry { + let lower = key_path.to_ascii_lowercase(); + if !lower.contains("\\internet settings") { + continue; + } + evidence.proxy_enabled = extract_dword_value(values, "ProxyEnable").map(|v| v != 0); + evidence.proxy_server = extract_string_value(values, "ProxyServer"); + evidence.proxy_override = extract_string_value(values, "ProxyOverride"); + evidence.auto_config_url = extract_string_value(values, "AutoConfigURL"); + break; + } + + if let Some(ref url) = evidence.auto_config_url { + evidence.wpad_detected = url.to_ascii_lowercase().contains("wpad"); + } + + // Check for WinHTTP proxy in connections registry + for (key_path, values) in &conn_registry { + let lower = key_path.to_ascii_lowercase(); + if lower.contains("\\internet settings\\connections") { + evidence.winhttp_proxy = + extract_string_value(values, "WinHttpSettings") + .or_else(|| extract_string_value(values, "DefaultConnectionSettings")); + break; + } + } + + Some(evidence) +} + +pub fn load_enrollment_evidence(bundle_path: &Path) -> Option { + let path = registry_file_path(bundle_path, ENROLLMENTS_FILE); + let mut artifact_paths = Vec::new(); + let registry = load_registry_map(&path, &mut artifact_paths); + if artifact_paths.is_empty() { + return None; + } + + let mut enrollments = Vec::new(); + + for (key_path, values) in ®istry { + let lower = key_path.to_ascii_lowercase(); + // Each enrollment is under a GUID subkey: ...\Enrollments\{GUID} + if !lower.contains("\\enrollments\\{") { + continue; + } + // Skip deeper subkeys (e.g., ...\{GUID}\FirstSync) + let after_guid = key_path + .rfind('}') + .map(|pos| &key_path[pos + 1..]) + .unwrap_or(""); + if after_guid.contains('\\') { + continue; + } + + enrollments.push(DsregcmdEnrollmentEntry { + upn: extract_string_value(values, "UPN"), + provider_id: extract_string_value(values, "ProviderID"), + enrollment_state: extract_dword_value(values, "EnrollmentState"), + }); + } + + let enrollment_count = u32::try_from(enrollments.len()).unwrap_or(u32::MAX); + + Some(DsregcmdEnrollmentEvidence { + enrollment_count, + enrollments, + }) +} + +fn extract_string_value( + values: &HashMap, + value_name: &str, +) -> Option { + let key = value_name.to_ascii_lowercase(); + values.get(&key).map(|v| match v { + RegistryValue::String(s) => s.clone(), + RegistryValue::Dword(d) => d.to_string(), + }) +} + +fn extract_dword_value( + values: &HashMap, + value_name: &str, +) -> Option { + let key = value_name.to_ascii_lowercase(); + values.get(&key).and_then(|v| match v { + RegistryValue::Dword(d) => Some(*d), + RegistryValue::String(s) => s.trim().parse().ok(), + }) +} + fn annotate_missing_policy_evidence(evidence: &mut DsregcmdWhfbPolicyEvidence) { let has_artifacts = !evidence.artifact_paths.is_empty(); if !has_artifacts { @@ -156,6 +329,59 @@ fn decode_reg_content(bytes: &[u8]) -> Option { String::from_utf8(bytes.to_vec()).ok() } +fn build_registry_snapshot_summary(registry: &RegistryKeyMap) -> RegistrySnapshotSummary { + let mut keys = registry.iter().collect::>(); + keys.sort_by(|left, right| left.0.cmp(right.0)); + + let key_count = u32::try_from(keys.len()).unwrap_or(u32::MAX); + let value_count = u32::try_from( + registry + .values() + .map(|values| values.len()) + .sum::(), + ) + .unwrap_or(u32::MAX); + + let keys = keys + .into_iter() + .take(6) + .map(|(path, values)| { + let mut values = values.iter().collect::>(); + values.sort_by(|left, right| left.0.cmp(right.0)); + + RegistrySnapshotKeyPreview { + path: path.clone(), + value_count: u32::try_from(values.len()).unwrap_or(u32::MAX), + values: values + .into_iter() + .take(8) + .map(|(name, value)| { + let (value_type, rendered_value) = render_registry_value(value); + RegistrySnapshotValuePreview { + name: name.clone(), + value_type, + value: rendered_value, + } + }) + .collect(), + } + }) + .collect(); + + RegistrySnapshotSummary { + key_count, + value_count, + keys, + } +} + +fn render_registry_value(value: &RegistryValue) -> (String, String) { + match value { + RegistryValue::Dword(raw) => ("dword".to_string(), format!("0x{raw:08X} ({raw})")), + RegistryValue::String(raw) => ("string".to_string(), raw.clone()), + } +} + fn current_policy_value(registry: &RegistryKeyMap, value_name: &str) -> Option { policy_value_from_keys(registry, value_name, |path| { let normalized = path.to_ascii_lowercase(); @@ -369,7 +595,11 @@ fn parse_registry_bool(value: &RegistryValue) -> Option { #[cfg(test)] mod tests { - use super::{decode_reg_content, load_whfb_policy_evidence, parse_reg_snapshot}; + use super::{ + decode_reg_content, inspect_registry_snapshot_file, load_enrollment_evidence, + load_os_version_evidence, load_proxy_evidence, load_whfb_policy_evidence, + parse_reg_snapshot, + }; use crate::dsregcmd::models::DsregcmdEvidenceSource; #[test] @@ -450,6 +680,32 @@ mod tests { assert_eq!(decoded, "Windows"); } + #[test] + fn inspects_registry_snapshot_file_summary() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let path = temp_dir.path().join("snapshot.reg"); + std::fs::write( + &path, + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\Current\Device\PassportForWork\Policies] +"UsePassportForWork"=dword:00000001 +"TenantName"="Contoso" +"#, + ) + .expect("write registry snapshot"); + + let summary = inspect_registry_snapshot_file(&path).expect("registry summary"); + assert_eq!(summary.key_count, 1); + assert_eq!(summary.value_count, 2); + assert!(summary + .keys + .first() + .expect("key preview") + .path + .contains("PassportForWork")); + } + #[test] fn falls_back_to_windows_policy_hive_values() { let temp_dir = tempfile::tempdir().expect("create temp dir"); @@ -473,4 +729,95 @@ mod tests { ); assert_eq!(evidence.post_logon_enabled.display_value, Some(true)); } + + #[test] + fn loads_os_version_evidence_from_bundle() { + 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("os-version.reg"), + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion] +"CurrentBuild"="22631" +"DisplayVersion"="23H2" +"ProductName"="Windows 11 Enterprise" +"UBR"=dword:00000FA0 +"EditionID"="Enterprise" +"#, + ) + .expect("write os version sample"); + + let evidence = load_os_version_evidence(temp_dir.path()).expect("os version evidence"); + assert_eq!(evidence.current_build.as_deref(), Some("22631")); + assert_eq!(evidence.display_version.as_deref(), Some("23H2")); + assert_eq!(evidence.product_name.as_deref(), Some("Windows 11 Enterprise")); + assert_eq!(evidence.ubr, Some(4000)); + assert_eq!(evidence.edition_id.as_deref(), Some("Enterprise")); + } + + #[test] + fn returns_none_when_os_version_file_missing() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + assert!(load_os_version_evidence(temp_dir.path()).is_none()); + } + + #[test] + fn loads_proxy_evidence_from_bundle() { + 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("proxy-internet-settings.reg"), + r#"Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings] +"ProxyEnable"=dword:00000001 +"ProxyServer"="http://proxy.contoso.com:8080" +"ProxyOverride"="*.contoso.com;localhost" +"AutoConfigURL"="http://wpad.contoso.com/wpad.dat" +"#, + ) + .expect("write proxy sample"); + + let evidence = load_proxy_evidence(temp_dir.path()).expect("proxy evidence"); + assert_eq!(evidence.proxy_enabled, Some(true)); + assert_eq!(evidence.proxy_server.as_deref(), Some("http://proxy.contoso.com:8080")); + assert!(evidence.wpad_detected); + } + + #[test] + fn loads_enrollment_evidence_from_bundle() { + 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("enrollments.reg"), + r#"Windows Registry Editor Version 5.00 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Enrollments\{11111111-2222-3333-4444-555555555555}] +"UPN"="user@contoso.com" +"ProviderID"="MS DM Server" +"EnrollmentState"=dword:00000001 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Enrollments\{11111111-2222-3333-4444-555555555555}\FirstSync] +"SyncComplete"=dword:00000001 + +[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Enrollments\{22222222-3333-4444-5555-666666666666}] +"UPN"="admin@contoso.com" +"ProviderID"="MS DM Server" +"EnrollmentState"=dword:00000001 +"#, + ) + .expect("write enrollments sample"); + + let evidence = load_enrollment_evidence(temp_dir.path()).expect("enrollment evidence"); + assert_eq!(evidence.enrollment_count, 2); + assert_eq!(evidence.enrollments.len(), 2); + assert!(evidence.enrollments.iter().any(|e| e.upn.as_deref() == Some("user@contoso.com"))); + } } \ No newline at end of file diff --git a/src-tauri/src/dsregcmd/rules.rs b/src-tauri/src/dsregcmd/rules.rs index 438b8445c..03e2c79e9 100644 --- a/src-tauri/src/dsregcmd/rules.rs +++ b/src-tauri/src/dsregcmd/rules.rs @@ -31,6 +31,11 @@ pub fn analyze_facts(facts: DsregcmdFacts, raw_input: &str) -> DsregcmdAnalysisR derived, diagnostics, policy_evidence: Default::default(), + os_version: None, + proxy_evidence: None, + enrollment_evidence: None, + active_evidence: None, + event_log_analysis: None, } } @@ -1523,6 +1528,414 @@ fn build_diagnostics( diagnostics } +/// Extended diagnostics based on registry evidence collected in Phase 2. +pub fn build_extended_diagnostics( + result: &DsregcmdAnalysisResult, +) -> Vec { + let mut diagnostics = Vec::new(); + + if let Some(os) = result.os_version.as_ref() { + let build_num = os + .current_build + .as_deref() + .and_then(|b| b.parse::().ok()); + + let cloud_trust_configured = result + .policy_evidence + .use_cloud_trust_for_on_prem_auth + .display_value + == Some(true); + + if let Some(build) = build_num { + if build < 22000 && cloud_trust_configured { + diagnostics.push(issue( + "os-build-below-cloud-trust", + IntuneDiagnosticSeverity::Warning, + "configuration", + "OS build is below the minimum required for cloud trust", + "Cloud trust for on-premises authentication requires Windows 11 (build 22000+), but this device is running an older build.", + vec![ + format!("CurrentBuild: {build}"), + "UseCloudTrustForOnPremAuth: YES".to_string(), + ], + vec!["Upgrade the OS to Windows 11 or later to use cloud trust.".to_string()], + vec!["Upgrade the device OS or switch to certificate-based on-premises authentication.".to_string()], + )); + } + + if build < 19041 { + diagnostics.push(issue( + "os-build-outdated", + IntuneDiagnosticSeverity::Info, + "configuration", + "OS build is older than Windows 10 version 2004", + "This device is running a build prior to 19041 (Windows 10 2004). Some modern device registration features may not be available.", + vec![format!("CurrentBuild: {build}")], + vec!["Confirm the OS version is still in support and meets tenant requirements.".to_string()], + Vec::new(), + )); + } + } + } + + if let Some(proxy) = result.proxy_evidence.as_ref() { + let proxy_detected = proxy.proxy_enabled == Some(true) + || proxy.proxy_server.is_some() + || proxy.auto_config_url.is_some(); + + if proxy_detected { + diagnostics.push(issue( + "proxy-configured", + IntuneDiagnosticSeverity::Info, + "network", + "Proxy configuration detected", + "The device has proxy settings configured, which can affect connectivity to Entra ID endpoints.", + vec![ + format!("ProxyEnabled: {}", proxy.proxy_enabled.map(|v| if v { "YES" } else { "NO" }).unwrap_or("(not set)")), + format!("ProxyServer: {}", proxy.proxy_server.as_deref().unwrap_or("(not set)")), + format!("AutoConfigURL: {}", proxy.auto_config_url.as_deref().unwrap_or("(not set)")), + ], + vec!["Confirm the proxy allows traffic to required Microsoft Entra endpoints.".to_string()], + Vec::new(), + )); + } + + let has_join_failures = result + .diagnostics + .iter() + .any(|d| d.severity == IntuneDiagnosticSeverity::Error); + + if proxy.wpad_detected && has_join_failures { + diagnostics.push(issue( + "proxy-wpad-with-join-failure", + IntuneDiagnosticSeverity::Warning, + "network", + "WPAD proxy detected alongside join failures", + "The device is using WPAD proxy auto-discovery, which can cause intermittent connectivity failures during device registration if the SYSTEM context cannot resolve the WPAD endpoint.", + vec![ + format!("AutoConfigURL: {}", proxy.auto_config_url.as_deref().unwrap_or("(wpad)")), + ], + vec![ + "Verify the SYSTEM context can resolve the WPAD endpoint.".to_string(), + "Consider configuring explicit WinHTTP proxy for the machine context.".to_string(), + ], + vec!["Configure an explicit proxy or ensure WPAD resolves from the machine context.".to_string()], + )); + } + } + + if let Some(enrollment) = result.enrollment_evidence.as_ref() { + let is_joined = result.facts.join_state.azure_ad_joined == Some(true); + + if enrollment.enrollment_count == 0 && is_joined { + diagnostics.push(issue( + "enrollment-missing-on-joined", + IntuneDiagnosticSeverity::Warning, + "configuration", + "No MDM enrollment found on a joined device", + "The device is Entra ID joined but has no enrollment entries in the registry, which may indicate MDM enrollment has not completed or was removed.", + vec![format!("EnrollmentCount: {}", enrollment.enrollment_count)], + vec![ + "Check whether automatic MDM enrollment is configured for this tenant and user scope.".to_string(), + "Verify enrollment status in the Intune portal for this device.".to_string(), + ], + vec!["Trigger MDM enrollment or re-register the device.".to_string()], + )); + } + + if enrollment.enrollment_count > 1 { + diagnostics.push(issue( + "multiple-enrollments", + IntuneDiagnosticSeverity::Info, + "configuration", + "Multiple MDM enrollment entries detected", + "The device has more than one enrollment entry in the registry, which can indicate dual management or stale enrollment records.", + vec![format!("EnrollmentCount: {}", enrollment.enrollment_count)], + vec!["Review enrollment entries and confirm only the expected MDM enrollment is active.".to_string()], + Vec::new(), + )); + } + } + + diagnostics +} + +/// Phase 3: Rules for active diagnostics (connectivity + SCP). +pub fn build_active_diagnostics_rules( + result: &DsregcmdAnalysisResult, +) -> Vec { + let mut diagnostics = Vec::new(); + + let active = match result.active_evidence.as_ref() { + Some(a) => a, + None => return diagnostics, + }; + + let has_join_failures = result + .diagnostics + .iter() + .any(|d| d.severity == IntuneDiagnosticSeverity::Error); + + for test in &active.connectivity_tests { + if !test.reachable { + let endpoint_lower = test.endpoint.to_ascii_lowercase(); + + if endpoint_lower.contains("enterpriseregistration") && has_join_failures { + diagnostics.push(issue( + "endpoint-unreachable-drs", + IntuneDiagnosticSeverity::Error, + "connectivity", + "DRS endpoint is unreachable and join is failing", + "The device cannot reach enterpriseregistration.windows.net, which is required for device registration.", + vec![ + format!("Endpoint: {}", test.endpoint), + format!("Error: {}", test.error_message.as_deref().unwrap_or("(unknown)")), + ], + vec!["Restore network access to the DRS endpoint before retrying join.".to_string()], + vec!["Fix firewall, proxy, or DNS rules blocking the DRS endpoint.".to_string()], + )); + } + + if endpoint_lower.contains("login.microsoftonline.com") + && !endpoint_lower.contains("device.login") + { + diagnostics.push(issue( + "endpoint-unreachable-login", + IntuneDiagnosticSeverity::Error, + "connectivity", + "Microsoft Entra login endpoint is unreachable", + "The device cannot reach login.microsoftonline.com, which is required for authentication.", + vec![ + format!("Endpoint: {}", test.endpoint), + format!("Error: {}", test.error_message.as_deref().unwrap_or("(unknown)")), + ], + vec!["Restore connectivity to login.microsoftonline.com.".to_string()], + vec!["Fix network path to the Microsoft Entra login endpoint.".to_string()], + )); + } + + if endpoint_lower.contains("device.login") { + diagnostics.push(issue( + "endpoint-unreachable-device-login", + IntuneDiagnosticSeverity::Error, + "connectivity", + "Device login endpoint is unreachable", + "The device cannot reach device.login.microsoftonline.com, which is needed for device code flows and conditional access.", + vec![ + format!("Endpoint: {}", test.endpoint), + format!("Error: {}", test.error_message.as_deref().unwrap_or("(unknown)")), + ], + vec!["Restore connectivity to device.login.microsoftonline.com.".to_string()], + vec!["Fix network path to the device login endpoint.".to_string()], + )); + } + + let is_hybrid = result.derived.join_type == DsregcmdJoinType::HybridEntraIdJoined + || result.facts.join_state.domain_joined == Some(true); + + if endpoint_lower.contains("autologon") && is_hybrid { + diagnostics.push(issue( + "seamless-sso-unreachable", + IntuneDiagnosticSeverity::Warning, + "connectivity", + "Seamless SSO endpoint is unreachable on a hybrid device", + "The device cannot reach autologon.microsoftazuread-sso.com, which is used for seamless SSO on hybrid-joined devices.", + vec![ + format!("Endpoint: {}", test.endpoint), + format!("Error: {}", test.error_message.as_deref().unwrap_or("(unknown)")), + ], + vec!["Check whether seamless SSO is configured and the endpoint is reachable.".to_string()], + Vec::new(), + )); + } + } + + if let Some(latency) = test.latency_ms { + if latency > 2000 { + diagnostics.push(issue( + "endpoint-high-latency", + IntuneDiagnosticSeverity::Warning, + "connectivity", + "High latency detected to an authentication endpoint", + &format!( + "The connectivity test to {} completed but took {}ms, which exceeds the 2000ms threshold and may cause timeouts during registration.", + test.endpoint, latency + ), + vec![ + format!("Endpoint: {}", test.endpoint), + format!("Latency: {}ms", latency), + ], + vec!["Investigate network path quality and proxy overhead for this endpoint.".to_string()], + Vec::new(), + )); + } + } + } + + if let Some(scp) = active.scp_query.as_ref() { + let is_domain_joined = result.facts.join_state.domain_joined == Some(true); + + if !scp.scp_found && is_domain_joined { + diagnostics.push(issue( + "scp-not-found", + IntuneDiagnosticSeverity::Error, + "configuration", + "Service Connection Point not found in Active Directory", + "The device is domain-joined but no SCP was found for hybrid join configuration. This prevents automatic tenant discovery.", + vec![ + format!("SCP found: NO"), + format!("Error: {}", scp.error.as_deref().unwrap_or("(none)")), + ], + vec![ + "Verify the SCP is configured in Active Directory for this forest.".to_string(), + "Check Azure AD Connect SCP configuration.".to_string(), + ], + vec!["Configure the SCP in Active Directory using Azure AD Connect or manually.".to_string()], + )); + } + + if scp.scp_found { + let dsregcmd_tenant = result.facts.tenant_details.domain_name.as_deref(); + let scp_tenant = scp.tenant_domain.as_deref(); + + if let (Some(dsregcmd_domain), Some(scp_domain)) = (dsregcmd_tenant, scp_tenant) { + if !dsregcmd_domain.eq_ignore_ascii_case(scp_domain) { + diagnostics.push(issue( + "scp-tenant-mismatch-active", + IntuneDiagnosticSeverity::Error, + "configuration", + "SCP tenant domain does not match dsregcmd tenant", + "The SCP in Active Directory points to a different tenant than what dsregcmd reports. This can cause hybrid join to target the wrong tenant.", + vec![ + format!("SCP tenant: {scp_domain}"), + format!("dsregcmd tenant: {dsregcmd_domain}"), + ], + vec!["Update the SCP to point to the correct verified tenant domain.".to_string()], + vec!["Correct the SCP tenant targeting in Active Directory.".to_string()], + )); + } + } + } + } + + diagnostics +} + +/// Phase 4: Rules based on event log evidence. +pub fn build_event_log_diagnostics( + result: &DsregcmdAnalysisResult, +) -> Vec { + let mut diagnostics = Vec::new(); + + let event_log = match result.event_log_analysis.as_ref() { + Some(a) => a, + None => return diagnostics, + }; + + let has_join_failures = result + .diagnostics + .iter() + .any(|d| { + d.severity == IntuneDiagnosticSeverity::Error + && (d.category == "authentication" + || d.category == "join" + || d.category == "configuration") + }); + + let has_tpm_errors = result + .diagnostics + .iter() + .any(|d| d.id.starts_with("tpm-")); + + // Check for time sync issues near join failures + let time_sync_errors = event_log + .entries + .iter() + .any(|e| { + let channel_display = e.channel_display.to_ascii_lowercase(); + let message_lower = e.message.to_ascii_lowercase(); + (channel_display.contains("time service") || channel_display.contains("system")) + && (message_lower.contains("time skew") + || message_lower.contains("time synchronization") + || message_lower.contains("clock")) + }); + + if time_sync_errors && has_join_failures { + diagnostics.push(issue( + "event-log-time-skew", + IntuneDiagnosticSeverity::Warning, + "configuration", + "Event logs show time synchronization issues near join failures", + "The Windows event logs contain time sync or clock skew events that may correlate with authentication or join failures.", + vec!["Time sync events detected in System or Time Service logs.".to_string()], + vec![ + "Check device clock accuracy and NTP configuration.".to_string(), + "Review whether time skew is causing certificate validation or token failures.".to_string(), + ], + vec!["Fix time synchronization before retrying device registration.".to_string()], + )); + } + + // Check for DPAPI failures near TPM errors + let dpapi_failures = event_log + .entries + .iter() + .any(|e| { + let channel_display = e.channel_display.to_ascii_lowercase(); + let message_lower = e.message.to_ascii_lowercase(); + channel_display.contains("dpapi") + && (message_lower.contains("failed") + || message_lower.contains("error") + || message_lower.contains("cannot")) + }); + + if dpapi_failures && has_tpm_errors { + diagnostics.push(issue( + "event-log-dpapi-failure", + IntuneDiagnosticSeverity::Warning, + "configuration", + "DPAPI key failures detected alongside TPM errors", + "The DPAPI operational log shows key protection failures that may be related to the TPM issues observed in the dsregcmd output.", + vec!["DPAPI operational log errors present alongside TPM diagnostics.".to_string()], + vec![ + "Check TPM health and whether DPAPI key material is protected by the TPM.".to_string(), + "Review whether clearing the TPM or re-provisioning keys would resolve the issue.".to_string(), + ], + Vec::new(), + )); + } + + // Check for AAD operational errors + let aad_errors = event_log + .entries + .iter() + .filter(|e| { + let channel_display = e.channel_display.to_ascii_lowercase(); + channel_display.contains("aad") + && e.severity.is_error_or_warning() + && !matches!(e.severity, crate::intune::models::EventLogSeverity::Warning) + }) + .count(); + + if aad_errors > 0 { + diagnostics.push(issue( + "event-log-aad-errors", + IntuneDiagnosticSeverity::Info, + "authentication", + "AAD operational event log errors present", + &format!( + "The AAD Operational event log contains {} error(s) that may provide additional context for authentication or join issues.", + aad_errors + ), + vec![format!("AAD Operational error count: {aad_errors}")], + vec!["Review the AAD Operational event log entries for additional diagnostic detail.".to_string()], + Vec::new(), + )); + } + + diagnostics +} + fn derive_join_type(facts: &DsregcmdFacts) -> DsregcmdJoinType { match ( facts.join_state.azure_ad_joined, diff --git a/src-tauri/src/intune/download_stats.rs b/src-tauri/src/intune/download_stats.rs index 7a3c37b78..7587f9152 100644 --- a/src-tauri/src/intune/download_stats.rs +++ b/src-tauri/src/intune/download_stats.rs @@ -4,6 +4,9 @@ use std::path::Path; use once_cell::sync::Lazy; use regex::Regex; +use super::guid_registry::{ + extract_json_field, setup_file_name, APP_ID_JSON_RE, APP_NAME_JSON_RE, SETUP_FILE_JSON_RE, +}; use super::ime_parser::ImeLine; use super::models::DownloadStat; @@ -13,19 +16,16 @@ static DOWNLOAD_RE: Lazy = Lazy::new(|| { ) .unwrap() }); -static DOWNLOAD_IGNORE_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)adding\s+new\s+state\s+transition\s*-\s*from:"#).unwrap() -}); +static DOWNLOAD_IGNORE_RE: Lazy = + Lazy::new(|| Regex::new(r#"(?i)adding\s+new\s+state\s+transition\s*-\s*from:"#).unwrap()); static SIZE_RE: Lazy = Lazy::new(|| { Regex::new(r#"(?i)(?:content\s+)?size[:\s]+([\d.]+)\s*(bytes|kb|mb|gb)"#).unwrap() }); static SPEED_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)(?:speed|rate)[:\s]+([\d.]+)\s*(bytes?/s|kb/s|mb/s|bps|kbps|mbps)"#) - .unwrap() -}); -static DO_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)(?:delivery\s+optimization|DO)[:\s]+([\d.]+)\s*%"#).unwrap() + Regex::new(r#"(?i)(?:speed|rate)[:\s]+([\d.]+)\s*(bytes?/s|kb/s|mb/s|bps|kbps|mbps)"#).unwrap() }); +static DO_RE: Lazy = + Lazy::new(|| Regex::new(r#"(?i)(?:delivery\s+optimization|DO)[:\s]+([\d.]+)\s*%"#).unwrap()); static CONTENT_ID_RE: Lazy = Lazy::new(|| { Regex::new(r#"(?i)(?:content|app|application)\s*(?:id)?[:\s]+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"#).unwrap() }); @@ -48,29 +48,27 @@ static DOWNLOAD_START_RE: Lazy = Lazy::new(|| { .unwrap() }); static DOWNLOAD_PROGRESS_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)(?:bytes\s+downloaded|downloading|download\s+progress|delivery\s+optimization)"#) - .unwrap() + Regex::new( + r#"(?i)(?:bytes\s+downloaded|downloading|download\s+progress|delivery\s+optimization)"#, + ) + .unwrap() }); static DOWNLOAD_STALL_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)(?:stalled|not\s+progressing|no\s+progress|timed?\s*out|timeout|retry\s+exhausted)"#) - .unwrap() + Regex::new( + r#"(?i)(?:stalled|not\s+progressing|no\s+progress|timed?\s*out|timeout|retry\s+exhausted)"#, + ) + .unwrap() }); static APPWORKLOAD_RETRY_RE: Lazy = Lazy::new(|| { Regex::new(r#"(?i)(?:(?-u:\b)retrying(?-u:\b)|(?-u:\b)reattempt(?:ing)?(?-u:\b)|will\s+retry|retry\s+exhausted|failed[^\r\n]{0,80}retry)"#).unwrap() }); static DURATION_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)(?:duration|took|elapsed)[:\s]+([\d.]+)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?)"#) - .unwrap() -}); -static APP_ID_JSON_RE: Lazy = Lazy::new(|| { - Regex::new(r#"\"AppId\"\s*:\s*\"([0-9a-fA-F-]{36})\""#).unwrap() -}); -static APP_NAME_JSON_RE: Lazy = Lazy::new(|| { - Regex::new(r#"\"ApplicationName\"\s*:\s*\"([^\"]+)\""#).unwrap() -}); -static SETUP_FILE_JSON_RE: Lazy = Lazy::new(|| { - Regex::new(r#"\"SetUpFilePath\"\s*:\s*\"([^\"]+)\""#).unwrap() + Regex::new( + r#"(?i)(?:duration|took|elapsed)[:\s]+([\d.]+)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?)"#, + ) + .unwrap() }); +// APP_ID_JSON_RE, APP_NAME_JSON_RE, SETUP_FILE_JSON_RE imported from guid_registry pub fn extract_downloads(lines: &[ImeLine], source_file: &str) -> Vec { let source_kind = classify_download_source(source_file); @@ -83,6 +81,8 @@ pub fn extract_downloads(lines: &[ImeLine], source_file: &str) -> Vec Vec Vec Vec Vec Vec Vec, display_name: Option, start_time: Option) -> Self { + fn new( + content_id: Option, + display_name: Option, + start_time: Option, + ) -> Self { Self { content_id, display_name, @@ -308,21 +311,13 @@ fn extract_display_name(msg: &str) -> Option { if let Some(value) = extract_json_field(msg, "\"ApplicationName\":\"", "\"") { return Some(value.to_string()); } - if let Some(value) = extract_json_field( - msg, - "\\\"ApplicationName\\\":\\\"", - "\\\"", - ) { + if let Some(value) = extract_json_field(msg, "\\\"ApplicationName\\\":\\\"", "\\\"") { return Some(value.to_string()); } if let Some(value) = extract_json_field(msg, "\"SetUpFilePath\":\"", "\"") { return Some(setup_file_name(value)); } - if let Some(value) = extract_json_field( - msg, - "\\\"SetUpFilePath\\\":\\\"", - "\\\"", - ) { + if let Some(value) = extract_json_field(msg, "\\\"SetUpFilePath\\\":\\\"", "\\\"") { return Some(setup_file_name(value)); } @@ -338,20 +333,7 @@ fn extract_display_name(msg: &str) -> Option { }) } -fn extract_json_field<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { - let start = msg.find(prefix)? + prefix.len(); - let remainder = msg.get(start..)?; - let end = remainder.find(suffix)?; - remainder.get(..end) -} - -fn setup_file_name(path: &str) -> String { - Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(path) - .to_string() -} +// extract_json_field and setup_file_name imported from guid_registry fn apply_download_analysis( download: &mut PartialDownload, @@ -476,11 +458,7 @@ fn finalize_download( } fn short_id(id: &str) -> String { - if id.len() > 8 && id.contains('-') { - format!("Download ({id}...)", id = &id[..8]) - } else { - format!("Download: {id}") - } + format!("Download ({id})") } #[cfg(test)] @@ -493,12 +471,14 @@ mod tests { ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), + timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, }, ImeLine { line_number: 2, timestamp: Some("01-15-2024 10:00:05.000".to_string()), + timestamp_utc: None, message: "Download completed successfully. Content size: 5242880 bytes, speed: 1048576 Bps, Delivery Optimization: 75.5%".to_string(), component: None, }, @@ -516,12 +496,14 @@ mod tests { ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), + timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, }, ImeLine { line_number: 2, timestamp: Some("01-15-2024 10:00:30.000".to_string()), + timestamp_utc: None, message: "Content download stalled with no progress for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, }, @@ -537,7 +519,9 @@ mod tests { let lines = vec![ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), - message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), + timestamp_utc: None, + message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890" + .to_string(), component: None, }]; @@ -551,12 +535,14 @@ mod tests { ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), + timestamp_utc: None, message: "Starting content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, }, ImeLine { line_number: 2, timestamp: Some("01-15-2024 10:00:05.000".to_string()), + timestamp_utc: None, message: "Download failed, retrying content download for app id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), component: None, }, @@ -572,6 +558,7 @@ mod tests { let lines = vec![ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), + timestamp_utc: None, message: "Adding new state transition - From: Install In Progress To: Download In Progress With Event: Download Started.".to_string(), component: None, }]; @@ -585,6 +572,7 @@ mod tests { let lines = vec![ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), + timestamp_utc: None, message: r#"RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"MaxRetries\":3,\"RetryIntervalInMinutes\":5,\"DownloadStartTimeUTC\":\"\\/Date(-62135578800000)\\/\"}"#.to_string(), component: None, }]; @@ -599,12 +587,14 @@ mod tests { ImeLine { line_number: 1, timestamp: Some("01-15-2024 10:00:00.000".to_string()), + timestamp_utc: None, message: r#"Starting content download RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Contoso App\"}"#.to_string(), component: None, }, ImeLine { line_number: 2, timestamp: Some("01-15-2024 10:00:05.000".to_string()), + timestamp_utc: None, message: r#"Download completed successfully RequestPayload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Contoso App\"}"#.to_string(), component: None, }, @@ -612,7 +602,10 @@ mod tests { let downloads = extract_downloads(&lines, "C:/Logs/AppWorkload.log"); assert_eq!(downloads.len(), 1); - assert_eq!(downloads[0].content_id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + assert_eq!( + downloads[0].content_id, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ); assert_eq!(downloads[0].name, "Contoso App"); } @@ -624,6 +617,9 @@ mod tests { extract_content_id(message).as_deref(), Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890") ); - assert_eq!(extract_display_name(message).as_deref(), Some("Contoso App")); + assert_eq!( + extract_display_name(message).as_deref(), + Some("Contoso App") + ); } } diff --git a/src-tauri/src/intune/event_tracker.rs b/src-tauri/src/intune/event_tracker.rs index 58e41f24f..5441a6437 100644 --- a/src-tauri/src/intune/event_tracker.rs +++ b/src-tauri/src/intune/event_tracker.rs @@ -45,8 +45,7 @@ static APPWORKLOAD_INSTALL_RE: Lazy = Lazy::new(|| { .unwrap() }); static APPWORKLOAD_RETRY_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)(?:retry|retrying|reattempt|will\s+retry|attempt\s+\d+\s+of\s+\d+)"#) - .unwrap() + Regex::new(r#"(?i)(?:retry|retrying|reattempt|will\s+retry|attempt\s+\d+\s+of\s+\d+)"#).unwrap() }); static APPWORKLOAD_STALL_RE: Lazy = Lazy::new(|| { Regex::new( @@ -148,8 +147,7 @@ static ERROR_CODE_RE: Lazy = Lazy::new(|| { .unwrap() }); static EXIT_CODE_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)exit\s*code(?:\s+of\s+the\s+script)?\s*(?:is|[=:])\s*(-?\d+)"#) - .unwrap() + Regex::new(r#"(?i)exit\s*code(?:\s+of\s+the\s+script)?\s*(?:is|[=:])\s*(-?\d+)"#).unwrap() }); static PENDING_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)(?:pending|queued|waiting|scheduled|requesting)"#).unwrap()); @@ -303,11 +301,7 @@ pub fn extract_events(lines: &[ImeLine], source_file: &str) -> Vec let guid = extract_guid(&line.message); let status = determine_status(&line.message, source_kind); let name = build_event_name(&event_type, &guid, &line.message, source_kind); - let detail = if line.message.len() > 300 { - format!("{}...", &line.message[..300]) - } else { - line.message.clone() - }; + let detail = line.message.clone(); events.push(IntuneEvent { id: next_id, @@ -315,7 +309,10 @@ pub fn extract_events(lines: &[ImeLine], source_file: &str) -> Vec name, guid, status, - start_time: line.timestamp.clone(), + start_time: line + .timestamp_utc + .clone() + .or_else(|| line.timestamp.clone()), end_time: None, duration_secs: None, error_code: extract_error_code(&line.message), @@ -376,7 +373,10 @@ fn extract_appworkload_event( name, guid, status, - start_time: line.timestamp.clone(), + start_time: line + .timestamp_utc + .clone() + .or_else(|| line.timestamp.clone()), end_time: None, duration_secs: None, error_code: extract_error_code(msg), @@ -408,7 +408,7 @@ fn build_appworkload_name( ) -> String { if *event_type == IntuneEventType::WinGetApp { return match guid.as_deref().map(short_guid) { - Some(short) => format!("AppWorkload WinGet ({short}...)"), + Some(short) => format!("AppWorkload WinGet ({short})"), None => "AppWorkload WinGet".to_string(), }; } @@ -430,21 +430,13 @@ fn build_appworkload_name( }; match guid.as_deref().map(short_guid) { - Some(short) => format!("AppWorkload {phase} ({short}...)"), + Some(short) => format!("AppWorkload {phase} ({short})"), None => format!("AppWorkload {phase}"), } } fn build_detail(msg: &str) -> String { - if msg.len() > 300 { - let mut end = 300; - while end > 0 && !msg.is_char_boundary(end) { - end -= 1; - } - format!("{}...", &msg[..end]) - } else { - msg.to_string() - } + msg.to_string() } fn classify_source_kind(source_file: &str) -> ImeSourceKind { @@ -621,8 +613,7 @@ fn is_appworkload_event_candidate(msg: &str) -> bool { "cangenerate", "isappreportable", ], - ) - { + ) { return false; } @@ -669,8 +660,7 @@ fn is_healthscripts_event_candidate(msg: &str) -> bool { "job is queued and will be scheduled", "completed user session", ], - ) - { + ) { return false; } @@ -694,7 +684,10 @@ fn is_healthscripts_event_candidate(msg: &str) -> bool { fn is_client_health_event_candidate(msg: &str) -> bool { CLIENT_HEALTH_HEARTBEAT_SUCCESS_RE.is_match(msg) || CLIENT_HEALTH_HEARTBEAT_FAILURE_RE.is_match(msg) - || matches!(parse_client_health_summary(msg), Some((_, IntuneStatus::Failed, _))) + || matches!( + parse_client_health_summary(msg), + Some((_, IntuneStatus::Failed, _)) + ) } fn is_client_cert_check_event_candidate(msg: &str) -> bool { @@ -846,9 +839,9 @@ fn build_event_name( if let Some(guid) = guid { let short = short_guid(guid); - format!("{label} ({short}...)") + format!("{label} ({short})") } else { - format!("{label}: {}", msg.chars().take(50).collect::()) + format!("{label}: {msg}") } } @@ -878,7 +871,7 @@ fn build_source_specific_name( return None; }; Some(match short_guid { - Some(short) => format!("AppWorkload {phase} ({short}...)") , + Some(short) => format!("AppWorkload {phase} ({short})"), None => format!("AppWorkload {phase}"), }) } @@ -895,12 +888,14 @@ fn build_source_specific_name( return None; }; Some(match short_guid { - Some(short) => format!("AppActionProcessor {area} ({short}...)") , + Some(short) => format!("AppActionProcessor {area} ({short})"), None => format!("AppActionProcessor {area}"), }) } ImeSourceKind::AgentExecutor => { - let area = if REMEDIATION_SCRIPT_RE.is_match(msg) || *event_type == IntuneEventType::Remediation { + let area = if REMEDIATION_SCRIPT_RE.is_match(msg) + || *event_type == IntuneEventType::Remediation + { "Remediation Script" } else if DETECTION_SCRIPT_RE.is_match(msg) { "Detection Script" @@ -910,7 +905,7 @@ fn build_source_specific_name( "PowerShell Script" }; Some(match short_guid { - Some(short) => format!("AgentExecutor {area} ({short}...)") , + Some(short) => format!("AgentExecutor {area} ({short})"), None => format!("AgentExecutor {area}"), }) } @@ -925,7 +920,7 @@ fn build_source_specific_name( "Schedule" }; Some(match short_guid { - Some(short) => format!("HealthScripts {area} ({short}...)") , + Some(short) => format!("HealthScripts {area} ({short})"), None => format!("HealthScripts {area}"), }) } @@ -963,14 +958,14 @@ fn build_source_specific_name( None } } - ImeSourceKind::DeviceHealthMonitoring => parse_device_health_app_crash(msg).map( - |(app_name, exception_code)| { + ImeSourceKind::DeviceHealthMonitoring => { + parse_device_health_app_crash(msg).map(|(app_name, exception_code)| { format!( "DeviceHealthMonitoring App Crash: {} ({})", app_name, exception_code ) - }, - ), + }) + } ImeSourceKind::Sensor => { if SENSOR_MEMORY_FAILURE_RE.is_match(msg) { Some("Sensor Hardware Readiness Memory Failure".to_string()) @@ -1030,11 +1025,7 @@ fn parse_win32_app_inventory_delta(msg: &str) -> Option<(u32, u32, u32)> { } fn short_guid(value: &str) -> &str { - if value.len() > 8 { - &value[..8] - } else { - value - } + value } fn contains_case_insensitive(value: &str, needle: &str) -> bool { @@ -1089,7 +1080,10 @@ fn pair_events(events: &mut Vec) { continue; } - let Some(start_index) = open_events.get_mut(&identity_key).and_then(|indices| indices.pop()) else { + let Some(start_index) = open_events + .get_mut(&identity_key) + .and_then(|indices| indices.pop()) + else { continue; }; if consumed_end_indices.contains(&index) { @@ -1103,7 +1097,10 @@ fn pair_events(events: &mut Vec) { .clone() .or_else(|| events[start_index].error_code.clone()); - if let (Some(start), Some(end)) = (&events[start_index].start_time, &events[start_index].end_time) { + if let (Some(start), Some(end)) = ( + &events[start_index].start_time, + &events[start_index].end_time, + ) { events[start_index].duration_secs = estimate_duration(start, end); } @@ -1177,6 +1174,12 @@ fn normalize_identity_fragment(value: &str) -> String { } fn estimate_duration(start: &str, end: &str) -> Option { + if let (Some(start_dt), Some(end_dt)) = + (parse_event_timestamp(start), parse_event_timestamp(end)) + { + return Some((end_dt - start_dt).num_milliseconds().abs() as f64 / 1000.0); + } + let parse_seconds = |ts: &str| -> Option { let time_part = ts.split_whitespace().last()?; let parts: Vec<&str> = time_part.split(':').collect(); @@ -1200,6 +1203,18 @@ fn estimate_duration(start: &str, end: &str) -> Option { } } +fn parse_event_timestamp(value: &str) -> Option> { + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(value) { + return Some(parsed.with_timezone(&chrono::Utc)); + } + + let naive = chrono::NaiveDateTime::parse_from_str(value, "%m-%d-%Y %H:%M:%S%.f").ok()?; + Some(chrono::DateTime::::from_naive_utc_and_offset( + naive, + chrono::Utc, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -1208,11 +1223,40 @@ mod tests { ImeLine { line_number, timestamp: Some(timestamp.to_string()), + timestamp_utc: Some(timestamp.to_string()), message: message.to_string(), component: None, } } + #[test] + fn extract_events_prefers_utc_normalized_timestamp() { + let events = extract_events( + &[ImeLine { + line_number: 1, + timestamp: Some("03-12-2026 11:16:42.332".to_string()), + timestamp_utc: Some("2026-03-12T11:16:42.332Z".to_string()), + message: "Assignment evaluation failed for app with id: a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), + component: None, + }], + "C:/Logs/AppActionProcessor.log", + ); + + assert_eq!(events.len(), 1); + assert_eq!( + events[0].start_time.as_deref(), + Some("2026-03-12T11:16:42.332Z") + ); + } + + #[test] + fn estimate_duration_supports_iso_timestamps() { + assert_eq!( + estimate_duration("2026-03-12T11:16:42.332Z", "2026-03-12T11:16:47.332Z"), + Some(5.0) + ); + } + #[test] fn appworkload_extracts_stalled_download_events() { let events = extract_events( @@ -1442,7 +1486,10 @@ mod tests { assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, IntuneEventType::ContentDownload); assert_eq!(events[0].status, IntuneStatus::Success); - assert_eq!(events[0].guid.as_deref(), Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); + assert_eq!( + events[0].guid.as_deref(), + Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + ); assert_eq!(events[0].duration_secs, Some(5.0)); } } diff --git a/src-tauri/src/intune/eventlog_win32.rs b/src-tauri/src/intune/eventlog_win32.rs new file mode 100644 index 000000000..e48ae3350 --- /dev/null +++ b/src-tauri/src/intune/eventlog_win32.rs @@ -0,0 +1,281 @@ +#[cfg(target_os = "windows")] +mod windows_impl { + use std::collections::HashMap; + use std::ffi::c_void; + + use once_cell::sync::Lazy; + use regex::Regex; + use windows::core::{Error, HSTRING, PCWSTR}; + use windows::Win32::System::EventLog::{ + EVT_HANDLE, EvtClose, EvtFormatMessage, EvtFormatMessageEvent, EvtNext, + EvtOpenPublisherMetadata, EvtQuery, EvtQueryChannelPath, EvtQueryReverseDirection, + EvtRender, EvtRenderEventXml, + }; + + static PROVIDER_RE: Lazy = Lazy::new(|| { + Regex::new(r#"]*Name=['\"]([^'\"]+)['\"]"#) + .expect("provider regex must compile") + }); + + #[derive(Debug, Clone)] + pub struct LiveEventRecord { + pub xml: String, + pub rendered_message: Option, + pub source_file: String, + } + + #[derive(Debug, Clone)] + pub struct LiveChannelQueryResult { + pub channel_path: String, + pub source_file: String, + pub records: Vec, + } + + #[derive(Debug)] + struct OwnedEvtHandle(EVT_HANDLE); + + impl OwnedEvtHandle { + fn new(handle: EVT_HANDLE) -> Self { + Self(handle) + } + + fn raw(&self) -> EVT_HANDLE { + self.0 + } + } + + impl Drop for OwnedEvtHandle { + fn drop(&mut self) { + if !self.0.is_invalid() { + unsafe { + let _ = EvtClose(self.0); + } + } + } + } + + pub fn query_live_channel( + channel: &str, + entry_limit: usize, + ) -> Result { + let channel_string = HSTRING::from(channel); + let query_string = HSTRING::from("*"); + let source_file = format!("live-event-log/{}.evtx", sanitize_channel_name(channel)); + let query = unsafe { + EvtQuery( + None, + &channel_string, + &query_string, + EvtQueryChannelPath.0 | EvtQueryReverseDirection.0, + ) + } + .map_err(format_windows_error)?; + let query = OwnedEvtHandle::new(query); + + let mut records = Vec::new(); + let mut publisher_metadata = HashMap::>::new(); + + while records.len() < entry_limit { + let mut raw_handles = [0isize; 16]; + let mut returned = 0u32; + + match unsafe { EvtNext(query.raw(), &mut raw_handles, 0, 0, &mut returned) } { + Ok(()) => {} + Err(error) => { + if is_no_more_items(&error) { + break; + } + + return Err(format_windows_error(error)); + } + } + + if returned == 0 { + break; + } + + for raw_handle in raw_handles.into_iter().take(returned as usize) { + if records.len() >= entry_limit { + break; + } + + let event_handle = OwnedEvtHandle::new(EVT_HANDLE(raw_handle)); + let xml = render_event_xml(event_handle.raw()).map_err(format_windows_error)?; + let provider_name = extract_provider_name(&xml); + let rendered_message = provider_name.as_deref().and_then(|provider| { + format_event_message( + event_handle.raw(), + provider, + &mut publisher_metadata, + ) + .ok() + .flatten() + }); + + records.push(LiveEventRecord { + xml, + rendered_message, + source_file: source_file.clone(), + }); + } + } + + Ok(LiveChannelQueryResult { + channel_path: channel.to_string(), + source_file, + records, + }) + } + + fn render_event_xml(event_handle: EVT_HANDLE) -> Result { + let mut buffer_used = 0u32; + let mut property_count = 0u32; + let mut buffer = vec![0u16; 4096]; + + loop { + match unsafe { + EvtRender( + None, + event_handle, + EvtRenderEventXml.0, + (buffer.len() * std::mem::size_of::()) as u32, + Some(buffer.as_mut_ptr() as *mut c_void), + &mut buffer_used, + &mut property_count, + ) + } { + Ok(()) => { + let utf16_len = (buffer_used as usize / std::mem::size_of::()) + .saturating_sub(1); + return Ok(String::from_utf16_lossy(&buffer[..utf16_len])); + } + Err(error) if is_insufficient_buffer(&error) => { + let next_len = (buffer_used as usize / std::mem::size_of::()) + .max(buffer.len() * 2); + buffer.resize(next_len, 0); + } + Err(error) => return Err(error), + } + } + } + + fn format_event_message( + event_handle: EVT_HANDLE, + provider_name: &str, + cache: &mut HashMap>, + ) -> Result, Error> { + if !cache.contains_key(provider_name) { + let provider = HSTRING::from(provider_name); + let metadata = unsafe { + EvtOpenPublisherMetadata(EVT_HANDLE::default(), &provider, PCWSTR::null(), 0, 0) + } + .ok() + .map(OwnedEvtHandle::new); + cache.insert(provider_name.to_string(), metadata); + } + + let Some(Some(metadata)) = cache.get(provider_name) else { + return Ok(None); + }; + + let mut buffer_used = 0u32; + let mut buffer = vec![0u16; 2048]; + + loop { + match unsafe { + EvtFormatMessage( + metadata.raw(), + event_handle, + 0, + None, + EvtFormatMessageEvent.0, + Some(buffer.as_mut_slice()), + &mut buffer_used, + ) + } { + Ok(()) => { + let utf16_len = buffer_used.saturating_sub(1) as usize; + let rendered = String::from_utf16_lossy(&buffer[..utf16_len]) + .trim() + .to_string(); + return Ok((!rendered.is_empty()).then_some(rendered)); + } + Err(error) if is_insufficient_buffer(&error) => { + buffer.resize(buffer_used.max(buffer.len() as u32 * 2) as usize, 0); + } + Err(error) if is_not_found(&error) || is_message_not_found(&error) => { + return Ok(None); + } + Err(error) => return Err(error), + } + } + } + + fn extract_provider_name(xml: &str) -> Option { + PROVIDER_RE + .captures(xml) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) + } + + fn sanitize_channel_name(channel: &str) -> String { + channel + .chars() + .map(|value| match value { + '/' | '\\' | ':' | ' ' => '-', + other => other, + }) + .collect() + } + + fn format_windows_error(error: Error) -> String { + let message = error.message(); + if message.trim().is_empty() { + format!("Windows Event Log API error 0x{:08x}", error.code().0 as u32) + } else { + message.trim().to_string() + } + } + + fn is_insufficient_buffer(error: &Error) -> bool { + error.code().0 as u32 == 122 + } + + fn is_no_more_items(error: &Error) -> bool { + error.code().0 as u32 == 259 + } + + fn is_not_found(error: &Error) -> bool { + error.code().0 as u32 == 1168 + } + + fn is_message_not_found(error: &Error) -> bool { + error.code().0 as u32 == 15027 + } +} + +#[cfg(target_os = "windows")] +pub use windows_impl::{query_live_channel, LiveChannelQueryResult, LiveEventRecord}; + +#[cfg(not(target_os = "windows"))] +#[derive(Debug, Clone)] +pub struct LiveEventRecord { + pub xml: String, + pub rendered_message: Option, + pub source_file: String, +} + +#[cfg(not(target_os = "windows"))] +#[derive(Debug, Clone)] +pub struct LiveChannelQueryResult { + pub channel_path: String, + pub source_file: String, + pub records: Vec, +} + +#[cfg(not(target_os = "windows"))] +pub fn query_live_channel( + _channel: &str, + _entry_limit: usize, +) -> Result { + Err("Live Windows Event Log queries are only supported on Windows".to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/intune/evtx_parser.rs b/src-tauri/src/intune/evtx_parser.rs new file mode 100644 index 000000000..599fe90c8 --- /dev/null +++ b/src-tauri/src/intune/evtx_parser.rs @@ -0,0 +1,1134 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use evtx::EvtxParser; +#[cfg(target_os = "windows")] +use once_cell::sync::Lazy; +#[cfg(target_os = "windows")] +use regex::Regex; +use serde_json::Value; + +#[cfg(target_os = "windows")] +use crate::intune::eventlog_win32; +use crate::intune::models::{ + EvidenceBundleMetadata, EventLogAnalysis, EventLogAnalysisSource, EventLogChannel, + EventLogChannelSummary, EventLogCorrelationKind, EventLogCorrelationLink, EventLogEntry, + EventLogLiveQueryMetadata, EventLogSeverity, IntuneDiagnosticInsight, IntuneEvent, + IntuneEventType, IntuneStatus, IntuneTimestampBounds, +}; +#[cfg(target_os = "windows")] +use crate::intune::models::{EventLogLiveQueryChannelResult, EventLogLiveQueryStatus}; + +/// Maximum entries to parse from a single .evtx file to prevent memory issues. +const MAX_ENTRIES_PER_FILE: usize = 50_000; +#[cfg(target_os = "windows")] +const MAX_LIVE_ENTRIES_PER_CHANNEL: usize = 200; + +#[cfg(target_os = "windows")] +const LIVE_EVENT_CHANNELS: &[&str] = &[ + "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin", + "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Operational", + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "Microsoft-Windows-AAD/Operational", + "Microsoft-Windows-DeliveryOptimization/Operational", + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + "Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin", + "Microsoft-Windows-Shell-Core/Operational", + "Microsoft-Windows-Time-Service/Operational", + "Microsoft-Windows-User Device Registration/Admin", +]; + +#[cfg(target_os = "windows")] +static PROVIDER_RE: Lazy = Lazy::new(|| { + Regex::new(r#"]*Name=['\"]([^'\"]+)['\"]"#) + .expect("provider regex must compile") +}); +#[cfg(target_os = "windows")] +static CHANNEL_RE: Lazy = Lazy::new(|| { + Regex::new(r"(.*?)").expect("channel regex must compile") +}); +#[cfg(target_os = "windows")] +static EVENT_ID_RE: Lazy = Lazy::new(|| { + Regex::new(r"]*)?>(\d+)").expect("event id regex must compile") +}); +#[cfg(target_os = "windows")] +static LEVEL_RE: Lazy = Lazy::new(|| { + Regex::new(r"(\d+)").expect("level regex must compile") +}); +#[cfg(target_os = "windows")] +static TIME_RE: Lazy = Lazy::new(|| { + Regex::new(r#"]*SystemTime=['\"]([^'\"]+)['\"]"#) + .expect("time regex must compile") +}); +#[cfg(target_os = "windows")] +static COMPUTER_RE: Lazy = Lazy::new(|| { + Regex::new(r"(.*?)").expect("computer regex must compile") +}); +#[cfg(target_os = "windows")] +static ACTIVITY_RE: Lazy = Lazy::new(|| { + Regex::new(r#"]*ActivityID=['\"]([^'\"]+)['\"]"#) + .expect("activity regex must compile") +}); +#[cfg(target_os = "windows")] +static MESSAGE_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?s)(.*?)").expect("message regex must compile") +}); + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +/// Finds .evtx files in an evidence bundle's event-logs directory. +pub fn discover_evtx_files( + bundle_root: &Path, + evidence_bundle: &Option, +) -> Vec { + // Strategy 1: conventional evidence/event-logs/ path + let event_logs_dir = bundle_root.join("evidence").join("event-logs"); + if event_logs_dir.is_dir() { + return enumerate_evtx_in_dir(&event_logs_dir); + } + + // Strategy 2: try evidence_root from bundle metadata + if let Some(ref bundle) = evidence_bundle { + if let Some(ref root) = bundle.evidence_root { + let alt_dir = Path::new(root).join("event-logs"); + if alt_dir.is_dir() { + return enumerate_evtx_in_dir(&alt_dir); + } + } + } + + Vec::new() +} + +fn enumerate_evtx_in_dir(dir: &Path) -> Vec { + fs::read_dir(dir) + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(|e| e.ok()).map(|e| e.path())) + .filter(|p| { + p.extension() + .map(|ext| ext.eq_ignore_ascii_case("evtx")) + .unwrap_or(false) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Single-file EVTX parser +// --------------------------------------------------------------------------- + +/// Parses a single .evtx file into `EventLogEntry` records. +/// Skips corrupt/malformed records rather than failing the whole file. +pub fn parse_evtx_file(path: &Path, id_offset: u64) -> Result, String> { + let mut parser = EvtxParser::from_path(path) + .map_err(|e| format!("Failed to open EVTX file {}: {}", path.display(), e))?; + + let source_file = path.to_string_lossy().to_string(); + let mut entries = Vec::new(); + let mut current_id = id_offset; + + for record_result in parser.records_json() { + if entries.len() >= MAX_ENTRIES_PER_FILE { + eprintln!( + "event=evtx_entry_cap_reached file=\"{}\" cap={}", + source_file, MAX_ENTRIES_PER_FILE + ); + break; + } + + let record = match record_result { + Ok(r) => r, + Err(e) => { + eprintln!( + "event=evtx_record_skip file=\"{}\" error=\"{}\"", + source_file, e + ); + continue; + } + }; + + let json: Value = match serde_json::from_str(&record.data) { + Ok(v) => v, + Err(_) => continue, + }; + + let system = &json["Event"]["System"]; + let event_data = &json["Event"]["EventData"]; + let user_data = &json["Event"]["UserData"]; + + let channel_raw = system["Channel"].as_str().unwrap_or("").to_string(); + let channel = EventLogChannel::from_channel_string(&channel_raw); + let channel_display = channel.display_name().to_string(); + + let provider = system["Provider"]["#attributes"]["Name"] + .as_str() + .unwrap_or("") + .to_string(); + + let event_id = extract_event_id(system); + + let level = system["Level"].as_u64().unwrap_or(0) as u8; + let severity = EventLogSeverity::from_level(level); + + let timestamp = system["TimeCreated"]["#attributes"]["SystemTime"] + .as_str() + .unwrap_or("") + .to_string(); + + let computer = system["Computer"].as_str().map(|s| s.to_string()); + + let correlation_activity_id = system["Correlation"]["#attributes"]["ActivityID"] + .as_str() + .map(|s| s.to_string()); + + let message = extract_message(event_data, user_data); + + entries.push(EventLogEntry { + id: current_id, + channel, + channel_display, + provider, + event_id, + severity, + timestamp, + computer, + message, + correlation_activity_id, + source_file: source_file.clone(), + }); + + current_id += 1; + } + + Ok(entries) +} + +/// Extract EventID which can appear as `{"#text": N}` or just `N`. +fn extract_event_id(system: &Value) -> u32 { + if let Some(id) = system["EventID"].as_u64() { + return id as u32; + } + if let Some(id) = system["EventID"]["#text"].as_u64() { + return id as u32; + } + if let Some(s) = system["EventID"]["#text"].as_str() { + return s.parse().unwrap_or(0); + } + 0 +} + +/// Build a human-readable message by concatenating EventData or UserData fields. +fn extract_message(event_data: &Value, user_data: &Value) -> String { + // Try EventData first (most common) + if let Some(obj) = event_data.as_object() { + let parts: Vec = obj + .iter() + .filter(|(k, _)| *k != "#attributes") + .filter_map(|(k, v)| { + let val = match v { + Value::String(s) => s.clone(), + Value::Null => return None, + other => other.to_string(), + }; + if val.is_empty() { + None + } else { + Some(format!("{}: {}", k, val)) + } + }) + .collect(); + if !parts.is_empty() { + return parts.join("; "); + } + } + + // Fall back to UserData + if let Some(obj) = user_data.as_object() { + let parts: Vec = obj + .values() + .filter_map(|v| { + if let Some(inner) = v.as_object() { + let sub_parts: Vec = inner + .iter() + .filter(|(k, _)| *k != "#attributes" && *k != "xmlns") + .filter_map(|(k, v)| { + let val = match v { + Value::String(s) if !s.is_empty() => s.clone(), + Value::Null => return None, + Value::String(_) => return None, + other => other.to_string(), + }; + Some(format!("{}: {}", k, val)) + }) + .collect(); + if sub_parts.is_empty() { + None + } else { + Some(sub_parts.join("; ")) + } + } else { + None + } + }) + .collect(); + if !parts.is_empty() { + return parts.join(" | "); + } + } + + String::new() +} + +// --------------------------------------------------------------------------- +// Bundle orchestrator +// --------------------------------------------------------------------------- + +/// Parses all .evtx files in an evidence bundle and builds the analysis container. +/// Returns `None` if no .evtx files are found or all are empty. +/// `correlation_links` is left empty — call `build_event_log_correlations` afterwards. +pub fn parse_bundle_event_logs( + bundle_root: &Path, + evidence_bundle: &Option, +) -> Option { + let evtx_files = discover_evtx_files(bundle_root, evidence_bundle); + if evtx_files.is_empty() { + return None; + } + + let mut all_entries: Vec = Vec::new(); + let mut id_offset: u64 = 0; + let mut parsed_file_count: u32 = 0; + + for evtx_path in &evtx_files { + match parse_evtx_file(evtx_path, id_offset) { + Ok(entries) => { + id_offset += entries.len() as u64; + parsed_file_count += 1; + all_entries.extend(entries); + } + Err(e) => { + eprintln!( + "event=evtx_file_error file=\"{}\" error=\"{}\"", + evtx_path.display(), + e + ); + } + } + } + + build_event_log_analysis( + all_entries, + parsed_file_count, + EventLogAnalysisSource::Bundle, + None, + ) +} + +pub fn parse_live_event_logs() -> Option { + #[cfg(target_os = "windows")] + { + let mut all_entries = Vec::new(); + let mut id_offset = 0u64; + let mut parsed_file_count = 0u32; + let mut live_channels = Vec::with_capacity(LIVE_EVENT_CHANNELS.len()); + + for channel in LIVE_EVENT_CHANNELS { + match eventlog_win32::query_live_channel(channel, MAX_LIVE_ENTRIES_PER_CHANNEL) { + Ok(result) => { + let channel_enum = EventLogChannel::from_channel_string(&result.channel_path); + let entry_count = result.records.len() as u32; + let status = if entry_count == 0 { + EventLogLiveQueryStatus::Empty + } else { + EventLogLiveQueryStatus::Success + }; + + live_channels.push(EventLogLiveQueryChannelResult { + channel: channel_enum.clone(), + channel_display: channel_enum.display_name().to_string(), + channel_path: result.channel_path.clone(), + source_file: result.source_file.clone(), + status, + entry_count, + error_message: None, + }); + + for record in result.records { + if let Some(entry) = parse_live_event_record( + &record.xml, + &record.source_file, + record.rendered_message, + id_offset, + &result.channel_path, + ) { + all_entries.push(entry); + id_offset += 1; + } + } + + if entry_count > 0 { + parsed_file_count += 1; + } + } + Err(error) => { + eprintln!( + "event=live_event_log_query_failed channel=\"{}\" error=\"{}\"", + channel, error + ); + + let channel_enum = EventLogChannel::from_channel_string(channel); + live_channels.push(EventLogLiveQueryChannelResult { + channel: channel_enum.clone(), + channel_display: channel_enum.display_name().to_string(), + channel_path: channel.to_string(), + source_file: format!( + "live-event-log/{}.evtx", + sanitize_channel_name(channel) + ), + status: EventLogLiveQueryStatus::Failed, + entry_count: 0, + error_message: Some(error), + }); + } + } + } + + build_event_log_analysis( + all_entries, + parsed_file_count, + EventLogAnalysisSource::Live, + Some(build_live_query_metadata(live_channels)), + ) + } + + #[cfg(not(target_os = "windows"))] + { + None + } +} + +pub(crate) fn build_event_log_analysis( + mut all_entries: Vec, + parsed_file_count: u32, + source_kind: EventLogAnalysisSource, + live_query: Option, +) -> Option { + if all_entries.is_empty() && !matches!(source_kind, EventLogAnalysisSource::Live) { + return None; + } + + all_entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + for (i, entry) in all_entries.iter_mut().enumerate() { + entry.id = i as u64; + } + + let channel_summaries = build_channel_summaries(&all_entries); + + let total_entry_count = all_entries.len() as u32; + let error_entry_count = all_entries + .iter() + .filter(|e| matches!(e.severity, EventLogSeverity::Error | EventLogSeverity::Critical)) + .count() as u32; + let warning_entry_count = all_entries + .iter() + .filter(|e| matches!(e.severity, EventLogSeverity::Warning)) + .count() as u32; + + let timestamp_bounds = if all_entries.is_empty() { + None + } else { + Some(IntuneTimestampBounds { + first_timestamp: all_entries.first().map(|e| e.timestamp.clone()), + last_timestamp: all_entries.last().map(|e| e.timestamp.clone()), + }) + }; + + Some(EventLogAnalysis { + source_kind, + entries: all_entries, + channel_summaries, + correlation_links: Vec::new(), + parsed_file_count, + total_entry_count, + error_entry_count, + warning_entry_count, + timestamp_bounds, + live_query, + }) +} + +#[cfg(target_os = "windows")] +pub(crate) fn parse_live_event_record( + xml: &str, + source_file: &str, + rendered_message: Option, + id: u64, + fallback_channel: &str, +) -> Option { + let channel_raw = extract_regex_value(xml, &CHANNEL_RE) + .unwrap_or_else(|| fallback_channel.to_string()); + let channel = EventLogChannel::from_channel_string(&channel_raw); + let timestamp = extract_regex_value(xml, &TIME_RE)?; + + let provider = extract_regex_value(xml, &PROVIDER_RE).unwrap_or_default(); + let event_id = extract_regex_value(xml, &EVENT_ID_RE) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let level = extract_regex_value(xml, &LEVEL_RE) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let computer = extract_regex_value(xml, &COMPUTER_RE); + let correlation_activity_id = extract_regex_value(xml, &ACTIVITY_RE); + let message = rendered_message + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + extract_regex_value(xml, &MESSAGE_RE) + .map(|value| decode_xml_text(&value)) + .unwrap_or_default() + }); + + Some(EventLogEntry { + id, + channel: channel.clone(), + channel_display: channel.display_name().to_string(), + provider, + event_id, + severity: EventLogSeverity::from_level(level), + timestamp, + computer: computer.map(|value| decode_xml_text(&value)), + message, + correlation_activity_id, + source_file: source_file.to_string(), + }) +} + +#[cfg(target_os = "windows")] +fn extract_regex_value(text: &str, regex: &Regex) -> Option { + regex + .captures(text) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) +} + +#[cfg(target_os = "windows")] +fn sanitize_channel_name(channel: &str) -> String { + channel + .chars() + .map(|value| match value { + '/' | '\\' | ':' | ' ' => '-', + other => other, + }) + .collect() +} + +#[cfg(target_os = "windows")] +fn decode_xml_text(value: &str) -> String { + value + .replace(" ", "\r") + .replace(" ", "\n") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +#[cfg(target_os = "windows")] +fn build_live_query_metadata( + channels: Vec, +) -> EventLogLiveQueryMetadata { + let attempted_channel_count = channels.len() as u32; + let successful_channel_count = channels + .iter() + .filter(|channel| { + matches!( + channel.status, + EventLogLiveQueryStatus::Success | EventLogLiveQueryStatus::Empty + ) + }) + .count() as u32; + let channels_with_results_count = channels + .iter() + .filter(|channel| channel.entry_count > 0) + .count() as u32; + let failed_channel_count = channels + .iter() + .filter(|channel| matches!(channel.status, EventLogLiveQueryStatus::Failed)) + .count() as u32; + + EventLogLiveQueryMetadata { + attempted_channel_count, + successful_channel_count, + channels_with_results_count, + failed_channel_count, + per_channel_entry_limit: MAX_LIVE_ENTRIES_PER_CHANNEL as u32, + channels, + } +} + +fn build_channel_summaries(entries: &[EventLogEntry]) -> Vec { + struct Acc { + channel: EventLogChannel, + channel_display: String, + entry_count: u32, + error_count: u32, + warning_count: u32, + first_ts: Option, + last_ts: Option, + source_file: String, + } + + let mut map: HashMap = HashMap::new(); + + for entry in entries { + let key = entry.channel_display.clone(); + let acc = map.entry(key).or_insert_with(|| Acc { + channel: entry.channel.clone(), + channel_display: entry.channel_display.clone(), + entry_count: 0, + error_count: 0, + warning_count: 0, + first_ts: None, + last_ts: None, + source_file: entry.source_file.clone(), + }); + + acc.entry_count += 1; + if matches!( + entry.severity, + EventLogSeverity::Error | EventLogSeverity::Critical + ) { + acc.error_count += 1; + } + if matches!(entry.severity, EventLogSeverity::Warning) { + acc.warning_count += 1; + } + + if acc.first_ts.is_none() || entry.timestamp < *acc.first_ts.as_ref().unwrap() { + acc.first_ts = Some(entry.timestamp.clone()); + } + if acc.last_ts.is_none() || entry.timestamp > *acc.last_ts.as_ref().unwrap() { + acc.last_ts = Some(entry.timestamp.clone()); + } + } + + let mut summaries: Vec = map + .into_values() + .map(|a| EventLogChannelSummary { + channel: a.channel, + channel_display: a.channel_display, + entry_count: a.entry_count, + error_count: a.error_count, + warning_count: a.warning_count, + timestamp_bounds: Some(IntuneTimestampBounds { + first_timestamp: a.first_ts, + last_timestamp: a.last_ts, + }), + source_file: a.source_file, + }) + .collect(); + + // Sort by error count desc, then entry count desc for stable UI ordering + summaries.sort_by(|a, b| { + b.error_count + .cmp(&a.error_count) + .then(b.entry_count.cmp(&a.entry_count)) + }); + + summaries +} + +// --------------------------------------------------------------------------- +// Correlation engine +// --------------------------------------------------------------------------- + +/// Time window in seconds for channel-based correlation (Strategy 1). +const TIME_WINDOW_CHANNEL_SECS: f64 = 120.0; + +/// Time window in seconds for enrollment context correlation (Strategy 3). +const TIME_WINDOW_ENROLLMENT_SECS: f64 = 300.0; + +/// Builds deterministic correlation links between event log entries and +/// IME-derived Intune events + diagnostics. +pub fn build_event_log_correlations( + ime_events: &[IntuneEvent], + event_log_entries: &[EventLogEntry], + diagnostics: &[IntuneDiagnosticInsight], +) -> Vec { + if event_log_entries.is_empty() { + return Vec::new(); + } + + let mut links: Vec = Vec::new(); + let mut seen: HashSet<(u64, Option)> = HashSet::new(); + + // Strategy 1: TimeWindowChannelMatch + correlate_by_time_window_channel(ime_events, event_log_entries, &mut links, &mut seen); + + // Strategy 2: ErrorCodeMatch + correlate_by_error_code(ime_events, event_log_entries, &mut links, &mut seen); + + // Strategy 3: EnrollmentContextMatch + correlate_by_enrollment_context(ime_events, event_log_entries, &mut links, &mut seen); + + // Diagnostic-level linking + correlate_diagnostics(event_log_entries, diagnostics, &mut links, &mut seen); + + links +} + +/// Strategy 1: For each failed/timed-out IME event, find event log entries +/// within a time window from contextually relevant channels. +fn correlate_by_time_window_channel( + ime_events: &[IntuneEvent], + entries: &[EventLogEntry], + links: &mut Vec, + seen: &mut HashSet<(u64, Option)>, +) { + for ime in ime_events { + if !matches!(ime.status, IntuneStatus::Failed | IntuneStatus::Timeout) { + continue; + } + + let relevant_channels = channels_for_event_type(&ime.event_type); + if relevant_channels.is_empty() { + continue; + } + + let ime_ts = best_timestamp_for_ime(ime); + let ime_ndt = match parse_timestamp_loose(&ime_ts) { + Some(t) => t, + None => continue, + }; + + for entry in entries { + if !entry.severity.is_error_or_warning() { + continue; + } + if !relevant_channels.contains(&entry.channel) { + continue; + } + + let entry_ndt = match parse_timestamp_loose(&entry.timestamp) { + Some(t) => t, + None => continue, + }; + + let delta = (ime_ndt - entry_ndt).num_seconds().unsigned_abs() as f64; + if delta > TIME_WINDOW_CHANNEL_SECS { + continue; + } + + let pair = (entry.id, Some(ime.id)); + if seen.contains(&pair) { + continue; + } + seen.insert(pair); + + links.push(EventLogCorrelationLink { + event_log_entry_id: entry.id, + linked_intune_event_id: Some(ime.id), + linked_diagnostic_id: None, + correlation_kind: EventLogCorrelationKind::TimeWindowChannelMatch, + time_delta_secs: Some(delta), + }); + } + } +} + +/// Strategy 2: For each IME event with an error code, find event log entries +/// whose message contains that error code. +fn correlate_by_error_code( + ime_events: &[IntuneEvent], + entries: &[EventLogEntry], + links: &mut Vec, + seen: &mut HashSet<(u64, Option)>, +) { + for ime in ime_events { + let error_code = match &ime.error_code { + Some(code) if !code.is_empty() => code.to_ascii_lowercase(), + _ => continue, + }; + + let ime_ts = best_timestamp_for_ime(ime); + let ime_ndt = parse_timestamp_loose(&ime_ts); + + for entry in entries { + if !entry.message.to_ascii_lowercase().contains(&error_code) { + continue; + } + + let pair = (entry.id, Some(ime.id)); + if seen.contains(&pair) { + continue; + } + seen.insert(pair); + + let delta = ime_ndt.and_then(|i| { + parse_timestamp_loose(&entry.timestamp) + .map(|e| (i - e).num_seconds().unsigned_abs() as f64) + }); + + links.push(EventLogCorrelationLink { + event_log_entry_id: entry.id, + linked_intune_event_id: Some(ime.id), + linked_diagnostic_id: None, + correlation_kind: EventLogCorrelationKind::ErrorCodeMatch, + time_delta_secs: delta, + }); + } + } +} + +/// Strategy 3: For ESP/SyncSession IME events, match against +/// AAD/Operational and User Device Registration channels. +fn correlate_by_enrollment_context( + ime_events: &[IntuneEvent], + entries: &[EventLogEntry], + links: &mut Vec, + seen: &mut HashSet<(u64, Option)>, +) { + let enrollment_channels = [ + EventLogChannel::AadOperational, + EventLogChannel::UserDeviceRegistrationAdmin, + ]; + + for ime in ime_events { + if !matches!( + ime.event_type, + IntuneEventType::Esp | IntuneEventType::SyncSession + ) { + continue; + } + + let ime_ts = best_timestamp_for_ime(ime); + let ime_ndt = match parse_timestamp_loose(&ime_ts) { + Some(t) => t, + None => continue, + }; + + for entry in entries { + if !entry.severity.is_error_or_warning() { + continue; + } + if !enrollment_channels.contains(&entry.channel) { + continue; + } + + let entry_ndt = match parse_timestamp_loose(&entry.timestamp) { + Some(t) => t, + None => continue, + }; + + let delta = (ime_ndt - entry_ndt).num_seconds().unsigned_abs() as f64; + if delta > TIME_WINDOW_ENROLLMENT_SECS { + continue; + } + + let pair = (entry.id, Some(ime.id)); + if seen.contains(&pair) { + continue; + } + seen.insert(pair); + + links.push(EventLogCorrelationLink { + event_log_entry_id: entry.id, + linked_intune_event_id: Some(ime.id), + linked_diagnostic_id: None, + correlation_kind: EventLogCorrelationKind::EnrollmentContextMatch, + time_delta_secs: Some(delta), + }); + } + } +} + +/// Diagnostic-level linking: for each diagnostic, check if its error codes +/// appear in event log entries that were already linked. +fn correlate_diagnostics( + entries: &[EventLogEntry], + diagnostics: &[IntuneDiagnosticInsight], + links: &mut Vec, + seen: &mut HashSet<(u64, Option)>, +) { + // Build a set of entry IDs already linked by event-level strategies + let linked_entry_ids: HashSet = links.iter().map(|l| l.event_log_entry_id).collect(); + + for diag in diagnostics { + if diag.related_error_codes.is_empty() { + continue; + } + + let lower_codes: Vec = diag + .related_error_codes + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect(); + + for entry in entries { + // Only link entries that were already connected via event-level strategies + // or that have error/warning severity + if !linked_entry_ids.contains(&entry.id) && !entry.severity.is_error_or_warning() { + continue; + } + + let lower_msg = entry.message.to_ascii_lowercase(); + let has_match = lower_codes.iter().any(|code| lower_msg.contains(code)); + if !has_match { + continue; + } + + let pair = (entry.id, None); + if seen.contains(&pair) { + continue; + } + seen.insert(pair); + + links.push(EventLogCorrelationLink { + event_log_entry_id: entry.id, + linked_intune_event_id: None, + linked_diagnostic_id: Some(diag.id.clone()), + correlation_kind: EventLogCorrelationKind::ErrorCodeMatch, + time_delta_secs: None, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Returns the set of event log channels contextually relevant for a given IME event type. +fn channels_for_event_type(event_type: &IntuneEventType) -> Vec { + match event_type { + IntuneEventType::Win32App + | IntuneEventType::WinGetApp + | IntuneEventType::ContentDownload => vec![ + EventLogChannel::DeviceManagementAdmin, + EventLogChannel::DeviceManagementOperational, + EventLogChannel::DeliveryOptimizationOperational, + ], + IntuneEventType::PolicyEvaluation => vec![ + EventLogChannel::DeviceManagementAdmin, + EventLogChannel::DeviceManagementOperational, + ], + IntuneEventType::Esp => vec![ + EventLogChannel::Autopilot, + EventLogChannel::DeviceManagementAdmin, + EventLogChannel::ManagementService, + ], + IntuneEventType::SyncSession => vec![ + EventLogChannel::DeviceManagementAdmin, + EventLogChannel::DeviceManagementOperational, + ], + IntuneEventType::PowerShellScript | IntuneEventType::Remediation => vec![ + EventLogChannel::DeviceManagementAdmin, + EventLogChannel::DeviceManagementOperational, + ], + IntuneEventType::Other => Vec::new(), + } +} + +/// Pick the best timestamp to use from an IME event for time-based correlation. +fn best_timestamp_for_ime(ime: &IntuneEvent) -> String { + // Prefer end_time (closer to the failure moment), fall back to start_time + ime.end_time + .as_deref() + .or(ime.start_time.as_deref()) + .unwrap_or("") + .to_string() +} + +/// Parse a timestamp string loosely, supporting both ISO 8601 (EVTX) and +/// common IME log timestamp formats. +fn parse_timestamp_loose(ts: &str) -> Option { + if ts.is_empty() { + return None; + } + + // ISO 8601 with Z suffix (EVTX format) + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) { + return Some(dt.naive_utc()); + } + + // ISO 8601 without timezone + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.f") { + return Some(dt); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S") { + return Some(dt); + } + + // IME format: MM/DD/YYYY HH:MM:SS (12h with AM/PM) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%m/%d/%Y %I:%M:%S %p") { + return Some(dt); + } + + // IME format: MM-DD-YYYY HH:MM:SS.fff + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%m-%d-%Y %H:%M:%S%.f") { + return Some(dt); + } + + // IME format: MM/DD/YYYY HH:MM:SS.fff + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%m/%d/%Y %H:%M:%S%.f") { + return Some(dt); + } + + None +} + +/// Build corroboration evidence strings from correlation links for appending +/// to diagnostic insights. +pub fn build_corroboration_evidence( + entries: &[EventLogEntry], + correlation_links: &[EventLogCorrelationLink], + diagnostic_id: &str, +) -> Vec { + let mut evidence = Vec::new(); + + // Find entry-level links connected to this diagnostic's IME events + // plus direct diagnostic-level links + let relevant_entry_ids: Vec = correlation_links + .iter() + .filter(|l| l.linked_diagnostic_id.as_deref() == Some(diagnostic_id)) + .map(|l| l.event_log_entry_id) + .collect(); + + let entry_map: HashMap = + entries.iter().map(|e| (e.id, e)).collect(); + + for entry_id in relevant_entry_ids.iter().take(3) { + if let Some(entry) = entry_map.get(entry_id) { + let truncated_msg = if entry.message.len() > 80 { + format!("{}...", &entry.message[..80]) + } else { + entry.message.clone() + }; + evidence.push(format!( + "Windows Event Log: {} Event ID {} ({:?}) at {} \u{2014} {}", + entry.channel_display, entry.event_id, entry.severity, entry.timestamp, truncated_msg + )); + } + } + + evidence +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_from_string_maps_known_channels() { + assert_eq!( + EventLogChannel::from_channel_string( + "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin" + ), + EventLogChannel::DeviceManagementAdmin + ); + assert_eq!( + EventLogChannel::from_channel_string( + "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Operational" + ), + EventLogChannel::DeviceManagementOperational + ); + assert_eq!( + EventLogChannel::from_channel_string( + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot" + ), + EventLogChannel::Autopilot + ); + assert_eq!( + EventLogChannel::from_channel_string("Microsoft-Windows-AAD/Operational"), + EventLogChannel::AadOperational + ); + assert_eq!( + EventLogChannel::from_channel_string( + "Microsoft-Windows-DeliveryOptimization/Operational" + ), + EventLogChannel::DeliveryOptimizationOperational + ); + assert_eq!( + EventLogChannel::from_channel_string( + "Microsoft-Windows-User Device Registration/Admin" + ), + EventLogChannel::UserDeviceRegistrationAdmin + ); + } + + #[test] + fn channel_from_string_falls_back_to_other() { + let ch = EventLogChannel::from_channel_string("SomeCustom/Channel"); + assert!(matches!(ch, EventLogChannel::Other(ref s) if s == "SomeCustom/Channel")); + } + + #[test] + fn severity_from_level_maps_correctly() { + assert_eq!(EventLogSeverity::from_level(1), EventLogSeverity::Critical); + assert_eq!(EventLogSeverity::from_level(2), EventLogSeverity::Error); + assert_eq!(EventLogSeverity::from_level(3), EventLogSeverity::Warning); + assert_eq!( + EventLogSeverity::from_level(4), + EventLogSeverity::Information + ); + assert_eq!(EventLogSeverity::from_level(5), EventLogSeverity::Verbose); + assert_eq!(EventLogSeverity::from_level(99), EventLogSeverity::Unknown); + } + + #[test] + fn parse_timestamp_loose_handles_formats() { + // ISO 8601 with Z + assert!(parse_timestamp_loose("2026-03-12T16:01:23.456Z").is_some()); + // ISO 8601 without TZ + assert!(parse_timestamp_loose("2026-03-12T16:01:23.456").is_some()); + // IME 12h format + assert!(parse_timestamp_loose("03/12/2026 04:01:23 PM").is_some()); + // IME dash format + assert!(parse_timestamp_loose("03-12-2026 16:01:23.456").is_some()); + // Empty + assert!(parse_timestamp_loose("").is_none()); + // Garbage + assert!(parse_timestamp_loose("not-a-timestamp").is_none()); + } + + #[test] + fn channels_for_event_type_returns_relevant_channels() { + let channels = channels_for_event_type(&IntuneEventType::Win32App); + assert!(channels.contains(&EventLogChannel::DeviceManagementAdmin)); + assert!(channels.contains(&EventLogChannel::DeliveryOptimizationOperational)); + assert!(!channels.contains(&EventLogChannel::Autopilot)); + + let esp_channels = channels_for_event_type(&IntuneEventType::Esp); + assert!(esp_channels.contains(&EventLogChannel::Autopilot)); + } + + #[test] + fn build_event_log_correlations_returns_empty_for_no_entries() { + let links = build_event_log_correlations(&[], &[], &[]); + assert!(links.is_empty()); + } + + #[cfg(target_os = "windows")] + #[test] + fn parse_live_event_record_extracts_rendered_xml_fields() { + let xml = r#"8132Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/AdminCONTOSO-01Enrollment failed & needs attention"#; + + let entry = parse_live_event_record( + xml, + "live-event-log/test.evtx", + None, + 7, + "fallback", + ) + .expect("live entry"); + + assert_eq!(entry.id, 7); + assert_eq!(entry.event_id, 813); + assert_eq!(entry.severity, EventLogSeverity::Error); + assert_eq!(entry.provider, "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider"); + assert_eq!(entry.timestamp, "2026-03-12T16:01:23.456Z"); + assert_eq!(entry.message, "Enrollment failed & needs attention"); + } +} diff --git a/src-tauri/src/intune/guid_registry.rs b/src-tauri/src/intune/guid_registry.rs new file mode 100644 index 000000000..622a73917 --- /dev/null +++ b/src-tauri/src/intune/guid_registry.rs @@ -0,0 +1,689 @@ +use std::collections::HashMap; + +use once_cell::sync::Lazy; +use regex::Regex; + +use super::ime_parser::ImeLine; + +// ── Shared regexes (also used by download_stats.rs) ───────────────────────── + +pub(crate) static APP_ID_JSON_RE: Lazy = + Lazy::new(|| Regex::new(r#"\"AppId\"\s*:\s*\"([0-9a-fA-F-]{36})\""#).unwrap()); +pub(crate) static APP_NAME_JSON_RE: Lazy = Lazy::new(|| { + Regex::new(r#"(?i)\"(?:ApplicationName|Name)\"\s*:\s*\"([^\",\}]+)"#).unwrap() +}); +pub(crate) static SETUP_FILE_JSON_RE: Lazy = + Lazy::new(|| Regex::new(r#"\"SetUpFilePath\"\s*:\s*\"([^\"]+)\""#).unwrap()); + +/// Generic GUID pattern for secondary extraction. +static GUID_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"#, + ) + .unwrap() +}); + +// ── Shared helpers ─────────────────────────────────────────────────────────── + +/// Fast prefix/suffix JSON field extraction without regex overhead. +pub(crate) fn extract_json_field<'a>(msg: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { + let start = msg.find(prefix)? + prefix.len(); + let remainder = msg.get(start..)?; + let end = remainder.find(suffix)?; + remainder.get(..end) +} + +/// Extract just the filename from a SetUpFilePath value. +/// Handles Windows-style backslash paths on all platforms. +pub(crate) fn setup_file_name(path: &str) -> String { + // Split on both forward and backslash to handle Windows paths on Linux CI + path.rsplit(['\\', '/']) + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(path) + .to_string() +} + +// ── GUID registry types ───────────────────────────────────────────────────── + +/// Indicates where a GUID→name association was found, ranked by confidence. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum GuidNameSource { + /// `"SetUpFilePath"` — lowest confidence (just a filename) + SetUpFilePath = 0, + /// `"Name"` JSON field + NameField = 1, + /// `"ApplicationName"` JSON field — highest confidence + ApplicationName = 2, +} + +/// A resolved identity for a GUID observed in IME logs. +#[derive(Debug, Clone)] +pub struct GuidEntry { + /// Human-readable display name. + pub name: String, + /// Source of the name — used for confidence ranking during merges. + pub source: GuidNameSource, +} + +/// A global GUID→name registry built by scanning IME log lines. +/// +/// Any module that needs to translate a GUID into an application/script/policy +/// name can use this registry. It is built per-file during parallel analysis +/// and then merged into a single global instance. +#[derive(Debug, Clone, Default)] +pub struct GuidRegistry { + entries: HashMap, +} + +impl GuidRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Scan all lines from a single log file, accumulating GUID→name pairs. + pub fn ingest_lines(&mut self, lines: &[ImeLine]) { + for line in lines { + self.ingest_message(&line.message); + } + } + + /// Extract GUID→name pairs from a single message string. + fn ingest_message(&mut self, msg: &str) { + // Multi-pair path: extract all "Id"+"Name" pairs from JSON arrays + // e.g. Get policies = [{"Id":"guid1","Name":"name1"},{"Id":"guid2","Name":"name2"}] + for (guid, name, source) in extract_all_id_name_pairs(msg) { + self.insert_if_dominated(guid, name, source); + } + + // Single-GUID path: handles AppId, ApplicationName, SetUpFilePath + if let Some(guid) = extract_app_id(msg) { + if let Some((name, source)) = extract_app_name_with_source(msg) { + self.insert_if_dominated(guid, name, source); + } + } + } + + /// Insert an entry if no higher-confidence entry already exists for this GUID. + fn insert_if_dominated(&mut self, guid: String, name: String, source: GuidNameSource) { + let dominated = self + .entries + .get(&guid) + .map_or(true, |existing| source > existing.source); + if dominated { + self.entries.insert(guid, GuidEntry { name, source }); + } + } + + /// Merge another registry into this one. + /// Keeps the higher-confidence entry when the same GUID appears in both. + pub fn merge(&mut self, other: &GuidRegistry) { + for (guid, entry) in &other.entries { + self.insert_if_dominated(guid.clone(), entry.name.clone(), entry.source.clone()); + } + } + + /// Look up the display name for a GUID. + pub fn resolve(&self, guid: &str) -> Option<&str> { + self.entries.get(guid).map(|entry| entry.name.as_str()) + } + + /// If `current_name` looks like a short-id fallback (e.g. "Download (a1b2c3d4...)"), + /// return the resolved name for the GUID. Otherwise return `None`. + pub fn resolve_fallback_name(&self, current_name: &str, guid: &str) -> Option { + if is_fallback_name(current_name) { + self.resolve(guid).map(|name| name.to_string()) + } else { + None + } + } + + /// Enrich an event name that ends with a short-GUID suffix like `(00591936...)`. + /// + /// For example: + /// - `"AppWorkload Download Retry (00591936...)"` → `"AppWorkload Download Retry — Contoso App"` + /// - `"Win32 App (a1b2c3d4...)"` → `"Win32 App — Contoso App"` + /// + /// Returns `None` if the name doesn't match the pattern or the GUID is unknown. + pub fn enrich_event_name(&self, current_name: &str, guid: &str) -> Option { + let resolved = self.resolve(guid)?; + // Strip the trailing "(shortguid...)" suffix and replace with the resolved name + strip_short_guid_suffix(current_name) + .map(|prefix| format!("{prefix}{resolved}")) + } + + /// Number of entries in the registry. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns `true` if the registry contains no entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Iterate over all `(guid, entry)` pairs in the registry. + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } +} + +// ── Private extraction helpers ─────────────────────────────────────────────── + +/// Extract all `"Id"` + `"Name"` pairs from a message that may contain a JSON array. +/// +/// Handles lines like: +/// ```text +/// Get policies = [{"Id":"guid1","Name":"name1","Version":1},{"Id":"guid2","Name":"name2"}] +/// ``` +/// +/// Returns one `(guid, name, NameField)` tuple per valid pair found. +fn extract_all_id_name_pairs(msg: &str) -> Vec<(String, String, GuidNameSource)> { + // Try direct JSON, then escaped JSON + for &(id_pre, id_suf, name_pre, name_suf) in &[ + ("\"Id\":\"", "\"", "\"Name\":\"", "\""), + ("\\\"Id\\\":\\\"", "\\\"", "\\\"Name\\\":\\\"", "\\\""), + ] { + let ids = extract_all_field_values(msg, id_pre, id_suf); + if ids.is_empty() { + continue; + } + let names = extract_all_field_values(msg, name_pre, name_suf); + if names.is_empty() { + continue; + } + + let mut pairs = Vec::new(); + for (id_val, name_val) in ids.into_iter().zip(names.into_iter()) { + if id_val.len() == 36 && GUID_RE.is_match(&id_val) { + pairs.push((id_val, name_val, GuidNameSource::NameField)); + } + } + if !pairs.is_empty() { + return pairs; + } + } + + Vec::new() +} + +/// Find all occurrences of a `prefix…suffix` delimited field in `msg`. +fn extract_all_field_values(msg: &str, prefix: &str, suffix: &str) -> Vec { + let mut results = Vec::new(); + let mut search_from = 0; + while let Some(pos) = msg[search_from..].find(prefix) { + let start = search_from + pos + prefix.len(); + let Some(remainder) = msg.get(start..) else { + break; + }; + let Some(end) = remainder.find(suffix) else { + break; + }; + if let Some(value) = remainder.get(..end) { + results.push(value.to_string()); + } + search_from = start + end + suffix.len(); + } + results +} + +/// Extract a GUID from a log message via JSON identity fields. +/// +/// Checks (in order): `"AppId"`, `"Id"`, then falls back to a generic +/// GUID regex when a name field is also present on the same line. +fn extract_app_id(msg: &str) -> Option { + // Try "AppId" — direct and escaped JSON + if let Some(value) = extract_json_field(msg, "\"AppId\":\"", "\"") { + return Some(value.to_string()); + } + if let Some(value) = extract_json_field(msg, "\\\"AppId\\\":\\\"", "\\\"") { + return Some(value.to_string()); + } + // Try "Id" — appears in policy payloads like Get policies = [{"Id":"","Name":"..."}] + if let Some(value) = extract_guid_from_id_field(msg, "\"Id\":\"", "\"") { + return Some(value); + } + if let Some(value) = extract_guid_from_id_field(msg, "\\\"Id\\\":\\\"", "\\\"") { + return Some(value); + } + // Try regex for "AppId" specifically + APP_ID_JSON_RE + .captures(msg) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .or_else(|| { + // Only fall back to generic GUID if a name field is present + // (avoids polluting registry with context-free GUIDs) + if has_name_field(msg) { + GUID_RE + .captures(msg) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + } else { + None + } + }) +} + +/// Extract a GUID from an `"Id"` field, validating it looks like a UUID. +/// This is more conservative than `extract_json_field` alone because `"Id"` +/// is a very generic key — we only accept values that are 36-char UUIDs. +fn extract_guid_from_id_field(msg: &str, prefix: &str, suffix: &str) -> Option { + let value = extract_json_field(msg, prefix, suffix)?; + if value.len() == 36 && GUID_RE.is_match(value) { + Some(value.to_string()) + } else { + None + } +} + +/// Returns `true` if the message contains any name-bearing JSON field. +fn has_name_field(msg: &str) -> bool { + msg.contains("ApplicationName") + || msg.contains("\"Name\"") + || msg.contains("\\\"Name\\\"") + || msg.contains("SetUpFilePath") +} + +/// Extract a display name along with its confidence source. +fn extract_app_name_with_source(msg: &str) -> Option<(String, GuidNameSource)> { + // ApplicationName (highest confidence) + if let Some(value) = extract_json_field(msg, "\"ApplicationName\":\"", "\"") { + return Some((value.to_string(), GuidNameSource::ApplicationName)); + } + if let Some(value) = extract_json_field(msg, "\\\"ApplicationName\\\":\\\"", "\\\"") { + return Some((value.to_string(), GuidNameSource::ApplicationName)); + } + + // Generic "Name" field — direct and escaped JSON + if let Some(value) = extract_json_field(msg, "\"Name\":\"", "\"") { + return Some((value.to_string(), GuidNameSource::NameField)); + } + if let Some(value) = extract_json_field(msg, "\\\"Name\\\":\\\"", "\\\"") { + return Some((value.to_string(), GuidNameSource::NameField)); + } + + // Regex fallback for ApplicationName/Name (handles edge cases) + if let Some(caps) = APP_NAME_JSON_RE.captures(msg) { + if let Some(m) = caps.get(1) { + let name = m.as_str().to_string(); + let source = if msg.contains("ApplicationName") { + GuidNameSource::ApplicationName + } else { + GuidNameSource::NameField + }; + return Some((name, source)); + } + } + + // SetUpFilePath (lowest confidence) + if let Some(value) = extract_json_field(msg, "\"SetUpFilePath\":\"", "\"") { + return Some((setup_file_name(value), GuidNameSource::SetUpFilePath)); + } + if let Some(value) = extract_json_field(msg, "\\\"SetUpFilePath\\\":\\\"", "\\\"") { + return Some((setup_file_name(value), GuidNameSource::SetUpFilePath)); + } + SETUP_FILE_JSON_RE + .captures(msg) + .and_then(|c| c.get(1)) + .map(|m| (setup_file_name(m.as_str()), GuidNameSource::SetUpFilePath)) +} + +/// Detect whether a name is a fallback like "Download (guid)" or "Download: id". +fn is_fallback_name(name: &str) -> bool { + name.starts_with("Download (") || name.starts_with("Download:") +} + +/// If `name` ends with a parenthesised GUID (full or short), strip that suffix +/// and return the prefix with a ` — ` separator ready for the resolved name. +/// +/// Examples: +/// - `"AppWorkload Download Retry (00591936-3d7f-4c79-bd9e-550b09c2e8d9)"` → `Some("AppWorkload Download Retry — ")` +/// - `"Win32 App (a1b2c3d4-e5f6-7890-abcd-ef1234567890)"` → `Some("Win32 App — ")` +/// - `"AppWorkload Download Retry (00591936...)"` → `Some("AppWorkload Download Retry — ")` (legacy short format) +/// - `"Contoso App"` → `None` +fn strip_short_guid_suffix(name: &str) -> Option { + let trimmed = name.trim_end(); + if !trimmed.ends_with(')') { + return None; + } + let paren_open = trimmed.rfind('(')?; + let inner = &trimmed[paren_open + 1..trimmed.len() - 1]; // content between ( and ) + if inner.is_empty() { + return None; + } + // Accept full GUID: hex + dashes, 36 chars + let is_full_guid = inner.len() == 36 + && inner + .chars() + .all(|c| c.is_ascii_hexdigit() || c == '-'); + // Accept legacy short format: hex chars followed by "..." + let is_short_guid = inner.ends_with("...") + && inner[..inner.len() - 3] + .chars() + .all(|c| c.is_ascii_hexdigit()) + && inner.len() > 3; + if !is_full_guid && !is_short_guid { + return None; + } + let prefix = trimmed[..paren_open].trim_end(); + Some(format!("{prefix} — ")) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn line(msg: &str) -> ImeLine { + ImeLine { + line_number: 1, + timestamp: None, + timestamp_utc: None, + message: msg.to_string(), + component: None, + } + } + + #[test] + fn ingest_direct_json() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Processing app: {"AppId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","ApplicationName":"Contoso App"}"#, + )]); + assert_eq!( + reg.resolve("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + Some("Contoso App") + ); + } + + #[test] + fn ingest_escaped_json() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Payload: {\"AppId\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"ApplicationName\":\"Remote Desktop\"}"#, + )]); + assert_eq!( + reg.resolve("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + Some("Remote Desktop") + ); + } + + #[test] + fn higher_confidence_wins_on_merge() { + let mut a = GuidRegistry::new(); + a.entries.insert( + "guid-1".to_string(), + GuidEntry { + name: "setup.exe".to_string(), + source: GuidNameSource::SetUpFilePath, + }, + ); + + let mut b = GuidRegistry::new(); + b.entries.insert( + "guid-1".to_string(), + GuidEntry { + name: "Contoso App".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + a.merge(&b); + assert_eq!(a.resolve("guid-1"), Some("Contoso App")); + } + + #[test] + fn lower_confidence_does_not_overwrite() { + let mut a = GuidRegistry::new(); + a.entries.insert( + "guid-1".to_string(), + GuidEntry { + name: "Contoso App".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + let mut b = GuidRegistry::new(); + b.entries.insert( + "guid-1".to_string(), + GuidEntry { + name: "setup.exe".to_string(), + source: GuidNameSource::SetUpFilePath, + }, + ); + + a.merge(&b); + assert_eq!(a.resolve("guid-1"), Some("Contoso App")); + } + + #[test] + fn resolve_fallback_name_replaces_short_id() { + let mut reg = GuidRegistry::new(); + reg.entries.insert( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), + GuidEntry { + name: "Contoso App".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + assert_eq!( + reg.resolve_fallback_name( + "Download (a1b2c3d4...)", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + Some("Contoso App".to_string()) + ); + } + + #[test] + fn resolve_fallback_name_preserves_real_name() { + let mut reg = GuidRegistry::new(); + reg.entries.insert( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), + GuidEntry { + name: "Other App".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + assert_eq!( + reg.resolve_fallback_name( + "Contoso App", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + None + ); + } + + #[test] + fn empty_registry() { + let reg = GuidRegistry::new(); + assert!(reg.is_empty()); + assert_eq!(reg.len(), 0); + assert_eq!(reg.resolve("anything"), None); + } + + #[test] + fn setup_file_path_extraction() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Download started: {"AppId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","SetUpFilePath":"C:\\Cache\\MyInstaller.exe"}"#, + )]); + assert_eq!( + reg.resolve("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + Some("MyInstaller.exe") + ); + } + + #[test] + fn policy_payload_id_and_name_extracted() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Get policies = [{"Id":"00591936-3d7f-4c79-bd9e-550b09c2e8d9","Name":"Update for Remote Desktop Manager 2026.1.12.0","Version":1}]"#, + )]); + assert_eq!( + reg.resolve("00591936-3d7f-4c79-bd9e-550b09c2e8d9"), + Some("Update for Remote Desktop Manager 2026.1.12.0") + ); + } + + #[test] + fn escaped_policy_payload_id_and_name_extracted() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Get policies = [{\"Id\":\"00591936-3d7f-4c79-bd9e-550b09c2e8d9\",\"Name\":\"Update for Remote Desktop Manager 2026.1.12.0\",\"Version\":1}]"#, + )]); + assert_eq!( + reg.resolve("00591936-3d7f-4c79-bd9e-550b09c2e8d9"), + Some("Update for Remote Desktop Manager 2026.1.12.0") + ); + } + + #[test] + fn multi_entry_policy_array_extracts_all_guids() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Get policies = [{"Id":"00591936-3d7f-4c79-bd9e-550b09c2e8d9","Name":"Update for Remote Desktop Manager 2026.1.12.0","Version":1},{"Id":"bf98868f-45ed-49bd-b0b9-1e0b14b1dd9d","Name":"7-Zip 24.09","Version":3}]"#, + )]); + assert_eq!( + reg.resolve("00591936-3d7f-4c79-bd9e-550b09c2e8d9"), + Some("Update for Remote Desktop Manager 2026.1.12.0") + ); + assert_eq!( + reg.resolve("bf98868f-45ed-49bd-b0b9-1e0b14b1dd9d"), + Some("7-Zip 24.09") + ); + assert_eq!(reg.len(), 2); + } + + #[test] + fn multi_entry_escaped_policy_array_extracts_all_guids() { + let mut reg = GuidRegistry::new(); + reg.ingest_lines(&[line( + r#"Get policies = [{\"Id\":\"00591936-3d7f-4c79-bd9e-550b09c2e8d9\",\"Name\":\"Update for RDM\",\"Version\":1},{\"Id\":\"bf98868f-45ed-49bd-b0b9-1e0b14b1dd9d\",\"Name\":\"7-Zip\",\"Version\":3}]"#, + )]); + assert_eq!(reg.resolve("00591936-3d7f-4c79-bd9e-550b09c2e8d9"), Some("Update for RDM")); + assert_eq!(reg.resolve("bf98868f-45ed-49bd-b0b9-1e0b14b1dd9d"), Some("7-Zip")); + } + + #[test] + fn enrich_event_name_replaces_full_guid_suffix() { + let mut reg = GuidRegistry::new(); + reg.entries.insert( + "00591936-aaaa-bbbb-cccc-ddddeeeeeeee".to_string(), + GuidEntry { + name: "Remote Desktop Manager".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + assert_eq!( + reg.enrich_event_name( + "AppWorkload Download Retry (00591936-aaaa-bbbb-cccc-ddddeeeeeeee)", + "00591936-aaaa-bbbb-cccc-ddddeeeeeeee" + ), + Some("AppWorkload Download Retry — Remote Desktop Manager".to_string()) + ); + } + + #[test] + fn enrich_event_name_replaces_legacy_short_guid_suffix() { + let mut reg = GuidRegistry::new(); + reg.entries.insert( + "00591936-aaaa-bbbb-cccc-ddddeeeeeeee".to_string(), + GuidEntry { + name: "Remote Desktop Manager".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + assert_eq!( + reg.enrich_event_name( + "AppWorkload Download Retry (00591936...)", + "00591936-aaaa-bbbb-cccc-ddddeeeeeeee" + ), + Some("AppWorkload Download Retry — Remote Desktop Manager".to_string()) + ); + } + + #[test] + fn enrich_event_name_works_for_win32_app() { + let mut reg = GuidRegistry::new(); + reg.entries.insert( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), + GuidEntry { + name: "Contoso App".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + assert_eq!( + reg.enrich_event_name( + "Win32 App (a1b2c3d4-e5f6-7890-abcd-ef1234567890)", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + Some("Win32 App — Contoso App".to_string()) + ); + } + + #[test] + fn enrich_event_name_returns_none_for_real_name() { + let mut reg = GuidRegistry::new(); + reg.entries.insert( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string(), + GuidEntry { + name: "Other".to_string(), + source: GuidNameSource::ApplicationName, + }, + ); + + assert_eq!( + reg.enrich_event_name( + "ClientHealth Heartbeat Failed", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + None + ); + } + + #[test] + fn enrich_event_name_returns_none_for_unknown_guid() { + let reg = GuidRegistry::new(); + assert_eq!( + reg.enrich_event_name( + "AppWorkload Download (00591936-aaaa-bbbb-cccc-ddddeeeeeeee)", + "00591936-aaaa-bbbb-cccc-ddddeeeeeeee" + ), + None + ); + } + + #[test] + fn strip_guid_suffix_unit() { + // Full GUID format + assert_eq!( + strip_short_guid_suffix("AppWorkload Download Retry (00591936-aaaa-bbbb-cccc-ddddeeeeeeee)"), + Some("AppWorkload Download Retry — ".to_string()) + ); + assert_eq!( + strip_short_guid_suffix("Win32 App (a1b2c3d4-e5f6-7890-abcd-ef1234567890)"), + Some("Win32 App — ".to_string()) + ); + // Legacy short format + assert_eq!( + strip_short_guid_suffix("AppWorkload Download Retry (00591936...)"), + Some("AppWorkload Download Retry — ".to_string()) + ); + assert_eq!( + strip_short_guid_suffix("Win32 App (a1b2c3d4...)"), + Some("Win32 App — ".to_string()) + ); + // Non-matching + assert_eq!(strip_short_guid_suffix("ClientHealth Heartbeat Failed"), None); + assert_eq!(strip_short_guid_suffix("Some Name (not-hex...)"), None); + assert_eq!(strip_short_guid_suffix("Some Name (not a guid)"), None); + } +} diff --git a/src-tauri/src/intune/ime_parser.rs b/src-tauri/src/intune/ime_parser.rs index 5cbcc4450..a0a0a3dba 100644 --- a/src-tauri/src/intune/ime_parser.rs +++ b/src-tauri/src/intune/ime_parser.rs @@ -1,3 +1,4 @@ +use chrono::{FixedOffset, Local, LocalResult, TimeZone, Utc}; use once_cell::sync::Lazy; use regex::Regex; @@ -10,6 +11,7 @@ use crate::parser::severity::detect_severity_from_text; pub struct ImeLine { pub line_number: u32, pub timestamp: Option, + pub timestamp_utc: Option, pub message: String, pub component: Option, } @@ -52,11 +54,7 @@ struct ParsedImeAttrs<'a> { /// Parse IME log content into structured logical records. pub fn parse_ime_content(content: &str) -> Vec { let parsed = parse_ime_records(content); - parsed - .entries - .into_iter() - .map(|entry| entry.line) - .collect() + parsed.entries.into_iter().map(|entry| entry.line).collect() } /// Parse IME log content into shared log entries while preserving logical records. @@ -136,13 +134,20 @@ fn parse_ime_records(content: &str) -> ParsedImeChunk { ); if matched_any { - ParsedImeChunk { entries, parse_errors } + ParsedImeChunk { + entries, + parse_errors, + } } else { parse_fallback_lines(content) } } -fn parse_record(caps: ®ex::Captures<'_>, offset: usize, line_starts: &[usize]) -> Option { +fn parse_record( + caps: ®ex::Captures<'_>, + offset: usize, + line_starts: &[usize], +) -> Option { let message = caps.name("msg")?.as_str().to_string(); let attrs = parse_attributes(caps.name("attrs")?.as_str()); let component = attrs @@ -156,13 +161,14 @@ fn parse_record(caps: ®ex::Captures<'_>, offset: usize, line_starts: &[usize] let thread = attrs.thread; let thread_display = thread.map(format_thread_display); let severity = severity_from_type_field(attrs.type_value, &message); - let (timestamp_millis, timestamp_display, timezone_offset, line_timestamp) = + let (timestamp_millis, timestamp_display, timezone_offset, line_timestamp, line_timestamp_utc) = parse_timestamp_fields(attrs.date, attrs.time); Some(ParsedImeRecord { line: ImeLine { line_number: line_number_for_offset(line_starts, offset), timestamp: line_timestamp, + timestamp_utc: line_timestamp_utc, message, component, }, @@ -188,7 +194,8 @@ fn parse_attributes(attrs: &str) -> ParsedImeAttrs<'_> { } let key_start = index; - while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') { + while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { index += 1; } @@ -224,33 +231,97 @@ fn parse_attributes(attrs: &str) -> ParsedImeAttrs<'_> { parsed } +#[expect(clippy::type_complexity, reason = "tuple return avoids extra struct for internal parsing")] fn parse_timestamp_fields( date: Option<&str>, time: Option<&str>, -) -> (Option, Option, Option, Option) { +) -> ( + Option, + Option, + Option, + Option, + Option, +) { let Some(date) = date else { - return (None, None, None, None); + return (None, None, None, None, None); }; let Some(time) = time else { - return (None, None, None, None); + return (None, None, None, None, None); }; let Some((month, day, year)) = parse_date(date) else { - return (None, None, None, None); + return (None, None, None, None, None); }; let Some((hour, minute, second, millis, timezone_offset)) = parse_time(time) else { - return (None, None, None, None); + return (None, None, None, None, None); }; let (timestamp_millis, timestamp_display) = build_timestamp(month, day, year, hour, minute, second, millis); let line_timestamp = timestamp_display.clone(); + let line_timestamp_utc = build_utc_timestamp( + month, + day, + year, + hour, + minute, + second, + millis, + timezone_offset, + ); ( timestamp_millis, timestamp_display, timezone_offset, line_timestamp, + line_timestamp_utc, + ) +} + +#[expect(clippy::too_many_arguments, reason = "timestamp construction keeps calendar fields explicit")] +fn build_utc_timestamp( + month: u32, + day: u32, + year: i32, + hour: u32, + minute: u32, + second: u32, + millis: u32, + timezone_offset: Option, +) -> Option { + let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)? + .and_hms_milli_opt(hour, minute, second, millis)?; + + let utc_value = if let Some(offset_minutes) = timezone_offset { + let offset = FixedOffset::east_opt(offset_minutes.checked_mul(60)?)?; + offset + .from_local_datetime(&naive) + .single()? + .with_timezone(&Utc) + } else { + match Local.from_local_datetime(&naive) { + LocalResult::Single(local_value) => local_value.with_timezone(&Utc), + LocalResult::Ambiguous(local_value, _) => local_value.with_timezone(&Utc), + LocalResult::None => return None, + } + }; + + Some(utc_value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) +} + +fn parse_fallback_timestamp_utc(value: &str) -> Option { + let naive = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f").ok()?; + let local_value = match Local.from_local_datetime(&naive) { + LocalResult::Single(local_value) => local_value, + LocalResult::Ambiguous(local_value, _) => local_value, + LocalResult::None => return None, + }; + + Some( + local_value + .with_timezone(&Utc) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), ) } @@ -274,7 +345,9 @@ fn parse_time(time: &str) -> Option<(u32, u32, u32, u32, Option)> { let (minute, second_and_fraction) = remainder.split_once(':')?; let (second, fraction) = second_and_fraction .split_once('.') - .map_or((second_and_fraction, ""), |(seconds, fraction)| (seconds, fraction)); + .map_or((second_and_fraction, ""), |(seconds, fraction)| { + (seconds, fraction) + }); let millis = if fraction.is_empty() { 0 } else { @@ -426,6 +499,7 @@ fn push_unmatched_segment( line: ImeLine { line_number: line_number_for_offset(line_starts, base_offset + local_offset), timestamp: None, + timestamp_utc: None, message: trimmed.to_string(), component: None, }, @@ -455,6 +529,7 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { if let Some(caps) = SIMPLE_TS_RE.captures(trimmed) { let timestamp = caps.name("ts").map(|value| value.as_str().to_string()); + let timestamp_utc = timestamp.as_deref().and_then(parse_fallback_timestamp_utc); let message = caps .name("msg") .map(|value| value.as_str().to_string()) @@ -463,6 +538,7 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { line: ImeLine { line_number: (index + 1) as u32, timestamp: timestamp.clone(), + timestamp_utc, message: message.clone(), component: None, }, @@ -480,6 +556,7 @@ fn parse_fallback_lines(content: &str) -> ParsedImeChunk { line: ImeLine { line_number: (index + 1) as u32, timestamp: None, + timestamp_utc: None, message: trimmed.to_string(), component: None, }, @@ -515,12 +592,14 @@ mod tests { ); let lines = parse_ime_content(content); + let expected_utc = parse_fallback_timestamp_utc("2026-03-12 11:16:37.309"); assert_eq!(lines.len(), 3); assert_eq!(lines[0].line_number, 1); assert_eq!(lines[1].line_number, 2); assert_eq!(lines[2].line_number, 4); assert_eq!(lines[1].message, "Second line one\nSecond line two"); + assert_eq!(lines[0].timestamp_utc, expected_utc); } #[test] @@ -545,9 +624,7 @@ mod tests { #[test] fn test_parse_ime_entries_parses_attributes_without_regex_map_overhead() { - let content = concat!( - "" - ); + let content = ""; let (entries, parse_errors) = parse_ime_entries(content, "HealthScripts.log"); @@ -562,4 +639,17 @@ mod tests { Some("03-12-2026 11:16:42.332") ); } + + #[test] + fn test_parse_ime_content_normalizes_timezone_offset_to_utc() { + let content = ""; + + let lines = parse_ime_content(content); + + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0].timestamp_utc.as_deref(), + Some("2016-09-02T09:06:34.590Z") + ); + } } diff --git a/src-tauri/src/intune/mod.rs b/src-tauri/src/intune/mod.rs index 01d5eef48..ee3868179 100644 --- a/src-tauri/src/intune/mod.rs +++ b/src-tauri/src/intune/mod.rs @@ -1,5 +1,8 @@ pub mod download_stats; pub mod event_tracker; +pub mod eventlog_win32; +pub mod evtx_parser; +pub mod guid_registry; pub mod ime_parser; pub mod models; pub mod timeline; diff --git a/src-tauri/src/intune/models.rs b/src-tauri/src/intune/models.rs index 8a394d0aa..124790b08 100644 --- a/src-tauri/src/intune/models.rs +++ b/src-tauri/src/intune/models.rs @@ -45,18 +45,48 @@ pub enum IntuneDiagnosticSeverity { Error, } +/// Diagnostic category used to group related remediation guidance. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum IntuneDiagnosticCategory { + Download, + Install, + Timeout, + Script, + Policy, + State, + General, +} + +/// Priority used to order remediation guidance. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum IntuneRemediationPriority { + Monitor, + Medium, + High, + Immediate, +} + /// Deterministic diagnostic guidance derived from Intune analysis results. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IntuneDiagnosticInsight { pub id: String, pub severity: IntuneDiagnosticSeverity, + pub category: IntuneDiagnosticCategory, + pub remediation_priority: IntuneRemediationPriority, pub title: String, pub summary: String, + pub likely_cause: Option, pub evidence: Vec, pub next_checks: Vec, #[serde(default)] pub suggested_fixes: Vec, + #[serde(default)] + pub focus_areas: Vec, + #[serde(default)] + pub affected_source_files: Vec, + #[serde(default)] + pub related_error_codes: Vec, } /// Type of Intune event detected from log analysis. @@ -214,6 +244,252 @@ pub struct IntuneRepeatedFailureGroup { pub sample_event_ids: Vec, } +// --------------------------------------------------------------------------- +// Windows Event Log (EVTX) models +// --------------------------------------------------------------------------- + +/// Severity level from a Windows Event Log record (`System.Level`). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum EventLogSeverity { + Critical, + Error, + Warning, + Information, + Verbose, + Unknown, +} + +impl EventLogSeverity { + /// Map the numeric `Level` value from an EVTX record to a severity. + pub fn from_level(level: u8) -> Self { + match level { + 1 => Self::Critical, + 2 => Self::Error, + 3 => Self::Warning, + 4 => Self::Information, + 5 => Self::Verbose, + _ => Self::Unknown, + } + } + + pub fn is_error_or_warning(&self) -> bool { + matches!(self, Self::Critical | Self::Error | Self::Warning) + } +} + +/// Typed Windows Event Log channel corresponding to the curated channels +/// collected by the evidence profile. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum EventLogChannel { + DeviceManagementAdmin, + DeviceManagementOperational, + Autopilot, + AadOperational, + DeliveryOptimizationOperational, + ManagementService, + ProvisioningDiagnosticsAdmin, + ShellCoreOperational, + TimeServiceOperational, + UserDeviceRegistrationAdmin, + CryptoDpapiOperational, + KerberosOperational, + SystemLog, + Other(String), +} + +impl EventLogChannel { + /// Map the full channel name string from an EVTX record to a typed variant. + pub fn from_channel_string(raw: &str) -> Self { + let lower = raw.to_ascii_lowercase(); + if lower.contains("devicemanagement") + && lower.contains("/admin") + && !lower.contains("/operational") + { + Self::DeviceManagementAdmin + } else if lower.contains("devicemanagement") && lower.contains("/operational") { + Self::DeviceManagementOperational + } else if lower.contains("moderndeployment") && lower.contains("autopilot") { + Self::Autopilot + } else if lower.contains("-aad/operational") { + Self::AadOperational + } else if lower.contains("deliveryoptimization") && lower.contains("/operational") { + Self::DeliveryOptimizationOperational + } else if lower.contains("moderndeployment") && lower.contains("managementservice") { + Self::ManagementService + } else if lower.contains("provisioning-diagnostics") && lower.contains("/admin") { + Self::ProvisioningDiagnosticsAdmin + } else if lower.contains("shell-core") && lower.contains("/operational") { + Self::ShellCoreOperational + } else if lower.contains("time-service") && lower.contains("/operational") { + Self::TimeServiceOperational + } else if lower.contains("user device registration") && lower.contains("/admin") { + Self::UserDeviceRegistrationAdmin + } else if lower.contains("crypto-dpapi") && lower.contains("/operational") { + Self::CryptoDpapiOperational + } else if lower.contains("kerberos") && lower.contains("/operational") { + Self::KerberosOperational + } else if lower == "system" || lower.contains("system log") { + Self::SystemLog + } else if raw.is_empty() { + Self::Other("Unknown".to_string()) + } else { + Self::Other(raw.to_string()) + } + } + + /// Short display name for UI badges and labels. + pub fn display_name(&self) -> &str { + match self { + Self::DeviceManagementAdmin => "MDM Admin", + Self::DeviceManagementOperational => "MDM Operational", + Self::Autopilot => "Autopilot", + Self::AadOperational => "AAD Operational", + Self::DeliveryOptimizationOperational => "DO Operational", + Self::ManagementService => "Management Service", + Self::ProvisioningDiagnosticsAdmin => "Provisioning Admin", + Self::ShellCoreOperational => "Shell Core", + Self::TimeServiceOperational => "Time Service", + Self::UserDeviceRegistrationAdmin => "User Device Reg", + Self::CryptoDpapiOperational => "Crypto DPAPI", + Self::KerberosOperational => "Kerberos", + Self::SystemLog => "System", + Self::Other(name) => name.as_str(), + } + } +} + +/// A single parsed record from a Windows Event Log (.evtx) file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventLogEntry { + /// Sequential ID across all parsed entries (chronological after sorting). + pub id: u64, + /// Typed channel enum. + pub channel: EventLogChannel, + /// Human-readable channel display name. + pub channel_display: String, + /// Provider name from `System.Provider`. + pub provider: String, + /// Windows Event ID. + pub event_id: u32, + /// Severity mapped from `System.Level`. + pub severity: EventLogSeverity, + /// ISO 8601 UTC timestamp. + pub timestamp: String, + /// Computer name from `System.Computer`. + pub computer: Option, + /// Extracted message from EventData/UserData. + pub message: String, + /// Correlation activity ID from `System.Correlation`. + pub correlation_activity_id: Option, + /// Path to the .evtx source file. + pub source_file: String, +} + +/// Per-channel summary rollup for the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventLogChannelSummary { + pub channel: EventLogChannel, + pub channel_display: String, + pub entry_count: u32, + pub error_count: u32, + pub warning_count: u32, + pub timestamp_bounds: Option, + pub source_file: String, +} + +/// The kind of deterministic correlation between an event log entry +/// and an IME-derived Intune event or diagnostic. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum EventLogCorrelationKind { + /// Temporal proximity within a contextually relevant channel. + TimeWindowChannelMatch, + /// Error code from an IME event appears in the event log message. + ErrorCodeMatch, + /// Enrollment/registration activity near ESP or sync events. + EnrollmentContextMatch, +} + +/// A deterministic link between an event log entry and an IME event or diagnostic. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventLogCorrelationLink { + /// Points to `EventLogEntry.id`. + pub event_log_entry_id: u64, + /// Points to `IntuneEvent.id` when this is an event-level link. + pub linked_intune_event_id: Option, + /// Points to `IntuneDiagnosticInsight.id` when this is a diagnostic-level link. + pub linked_diagnostic_id: Option, + /// Which correlation strategy produced this link. + pub correlation_kind: EventLogCorrelationKind, + /// Time gap in seconds between the linked items (for display/sorting). + pub time_delta_secs: Option, +} + +/// Source category for the current event log analysis payload. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum EventLogAnalysisSource { + #[default] + Bundle, + Live, +} + +/// Per-channel outcome status for a live Windows Event Log query. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum EventLogLiveQueryStatus { + #[default] + Success, + Empty, + Failed, +} + +/// Per-channel metadata retained for a live Windows Event Log query attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventLogLiveQueryChannelResult { + pub channel: EventLogChannel, + pub channel_display: String, + pub channel_path: String, + pub source_file: String, + pub status: EventLogLiveQueryStatus, + pub entry_count: u32, + pub error_message: Option, +} + +/// Live Windows Event Log query summary for UI status and empty-state behavior. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct EventLogLiveQueryMetadata { + pub attempted_channel_count: u32, + pub successful_channel_count: u32, + pub channels_with_results_count: u32, + pub failed_channel_count: u32, + pub per_channel_entry_limit: u32, + #[serde(default)] + pub channels: Vec, +} + +/// Top-level container for all parsed and correlated Windows Event Log data. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct EventLogAnalysis { + pub source_kind: EventLogAnalysisSource, + pub entries: Vec, + pub channel_summaries: Vec, + pub correlation_links: Vec, + pub parsed_file_count: u32, + pub total_entry_count: u32, + pub error_entry_count: u32, + pub warning_entry_count: u32, + pub timestamp_bounds: Option, + pub live_query: Option, +} + +// --------------------------------------------------------------------------- +// Intune analysis result +// --------------------------------------------------------------------------- + /// Complete result of Intune log analysis. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -244,6 +520,9 @@ pub struct IntuneAnalysisResult { /// Bundle metadata retained when the analyzed path is an evidence bundle root. #[serde(default)] pub evidence_bundle: Option, + /// Parsed and correlated Windows Event Log data from evidence bundle .evtx files. + #[serde(default)] + pub event_log_analysis: Option, } impl Serialize for IntuneAnalysisResult { @@ -251,7 +530,7 @@ impl Serialize for IntuneAnalysisResult { where S: Serializer, { - let mut state = serializer.serialize_struct("IntuneAnalysisResult", 10)?; + let mut state = serializer.serialize_struct("IntuneAnalysisResult", 11)?; state.serialize_field("events", &self.events)?; state.serialize_field("downloads", &self.downloads)?; state.serialize_field("summary", &self.summary)?; @@ -262,6 +541,7 @@ impl Serialize for IntuneAnalysisResult { state.serialize_field("diagnosticsConfidence", &self.diagnostics_confidence)?; state.serialize_field("repeatedFailures", &self.repeated_failures)?; state.serialize_field("evidenceBundle", &self.evidence_bundle)?; + state.serialize_field("eventLogAnalysis", &self.event_log_analysis)?; state.end() } } @@ -322,6 +602,7 @@ mod tests { diagnostics_coverage: IntuneDiagnosticsCoverage::default(), diagnostics_confidence: IntuneDiagnosticsConfidence::default(), repeated_failures: Vec::new(), + event_log_analysis: None, evidence_bundle: Some(EvidenceBundleMetadata { manifest_path: "bundle-root/manifest.json".to_string(), notes_path: Some("bundle-root/notes.md".to_string()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03e185457..db4c3466d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,11 +44,15 @@ pub fn run() { commands::file_association::associate_log_files_with_app, commands::file_association::set_file_association_prompt_suppressed, commands::file_ops::open_log_file, + commands::file_ops::open_log_folder_aggregate, commands::file_ops::list_log_folder, + commands::file_ops::inspect_evidence_bundle, + commands::file_ops::inspect_evidence_artifact, commands::file_ops::get_known_log_sources, commands::file_ops::inspect_path_kind, commands::file_ops::write_text_output_file, commands::file_ops::get_initial_file_path, + commands::system_preferences::get_system_date_time_preferences, commands::parsing::start_tail, commands::parsing::stop_tail, commands::parsing::pause_tail, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index a37fa2305..26e15f949 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -16,7 +16,7 @@ pub const MENU_ID_TOOLS_ERROR_LOOKUP: &str = "tools.error_lookup"; pub const MENU_ID_WINDOW_TOGGLE_DETAILS: &str = "window.toggle.details"; pub const MENU_ID_WINDOW_TOGGLE_INFO: &str = "window.toggle.info"; - +pub const MENU_ID_WINDOW_ACCESSIBILITY_SETTINGS: &str = "window.accessibility.settings"; pub const MENU_ID_HELP_ABOUT: &str = "help.about"; pub const MENU_ID_PRESET_WINDOWS_IME: &str = "preset.windows.ime"; @@ -83,7 +83,13 @@ pub fn build_app_menu(app: &AppHandle) -> tauri::Result> true, None::<&str>, )?; - + let accessibility_settings = MenuItem::with_id( + app, + MENU_ID_WINDOW_ACCESSIBILITY_SETTINGS, + "Accessibility Settings...", + true, + None::<&str>, + )?; let about = MenuItem::with_id(app, MENU_ID_HELP_ABOUT, "About CMTrace Open", true, None::<&str>)?; let file_menu = Submenu::with_items( @@ -94,7 +100,16 @@ pub fn build_app_menu(app: &AppHandle) -> tauri::Result> )?; let edit_menu = Submenu::with_items(app, "Edit", true, &[&find, &filter])?; let tools_menu = Submenu::with_items(app, "Tools", true, &[&error_lookup])?; - let window_menu = Submenu::with_items(app, "Window", true, &[&toggle_details, &toggle_info])?; + let window_menu = Submenu::with_items( + app, + "Window", + true, + &[ + &toggle_details, + &toggle_info, + &accessibility_settings, + ], + )?; let help_menu = Submenu::with_items(app, "Help", true, &[&about])?; Menu::with_items(app, &[&file_menu, &edit_menu, &tools_menu, &window_menu, &help_menu]) @@ -226,6 +241,15 @@ fn payload_for_menu_id(menu_id: &str) -> Option { preset_id: None, platform: None, }, + MENU_ID_WINDOW_ACCESSIBILITY_SETTINGS => AppMenuActionPayload { + version: 1, + menu_id: MENU_ID_WINDOW_ACCESSIBILITY_SETTINGS, + action: "show_accessibility_settings", + category: "window", + trigger: "menu", + preset_id: None, + platform: None, + }, MENU_ID_PRESET_WINDOWS_IME => AppMenuActionPayload { version: 1, menu_id: MENU_ID_PRESET_WINDOWS_IME, diff --git a/src-tauri/src/models/log_entry.rs b/src-tauri/src/models/log_entry.rs index 5679e9e2f..21f1079a3 100644 --- a/src-tauri/src/models/log_entry.rs +++ b/src-tauri/src/models/log_entry.rs @@ -89,7 +89,7 @@ pub enum ParserSpecialization { } /// Rich parser selection metadata returned to the frontend. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ParserSelectionInfo { pub parser: ParserKind, @@ -148,3 +148,25 @@ pub struct ParseResult { /// Byte offset where parsing ended — used as the starting point for tailing pub byte_offset: u64, } + +/// Per-file parse metadata for an aggregated folder open. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregateParsedFileResult { + pub file_path: String, + pub total_lines: u32, + pub parse_errors: u32, + pub file_size: u64, + pub byte_offset: u64, +} + +/// Result of parsing every file in a folder into one combined view. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregateParseResult { + pub entries: Vec, + pub total_lines: u32, + pub parse_errors: u32, + pub folder_path: String, + pub files: Vec, +} diff --git a/src-tauri/src/parser/cbs.rs b/src-tauri/src/parser/cbs.rs index 50749596f..bbdf5519a 100644 --- a/src-tauri/src/parser/cbs.rs +++ b/src-tauri/src/parser/cbs.rs @@ -4,9 +4,8 @@ use regex::Regex; use super::severity::detect_severity_from_text; use crate::models::log_entry::{LogEntry, LogFormat, Severity}; -static CBS_PREFIX_RE: Lazy = Lazy::new(|| { - Regex::new(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},").unwrap() -}); +static CBS_PREFIX_RE: Lazy = + Lazy::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},").unwrap()); static CBS_HEADER_RE: Lazy = Lazy::new(|| { Regex::new( @@ -15,6 +14,13 @@ static CBS_HEADER_RE: Lazy = Lazy::new(|| { .unwrap() }); +static CBS_RELAXED_HEADER_RE: Lazy = Lazy::new(|| { + Regex::new( + r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2}),\s+([A-Za-z][A-Za-z0-9_-]{1,31})\s+([A-Za-z][A-Za-z0-9_.-]{1,31})\s+(.*)$", + ) + .unwrap() +}); + struct PendingEntry { entry: LogEntry, start_line: u32, @@ -86,8 +92,26 @@ pub fn parse_lines(lines: &[&str], file_path: &str) -> (Vec, u32) { } fn parse_header(line: &str, file_path: &str) -> Option { - let caps = CBS_HEADER_RE.captures(line)?; + if let Some(caps) = CBS_HEADER_RE.captures(line) { + return build_entry_from_caps(&caps, file_path); + } + + let caps = CBS_RELAXED_HEADER_RE.captures(line)?; + let component = caps.get(8)?.as_str().to_ascii_uppercase(); + let message = caps.get(9).map(|m| m.as_str()).unwrap_or(""); + + if !matches!( + component.as_str(), + "CBS" | "CSI" | "TI" | "SR" | "SFC" | "RIBS" | "OC" | "POQ" | "SQM" | "DWLD" + ) && !message.starts_with("[SR]") + { + return None; + } + + build_entry_from_caps(&caps, file_path) +} +fn build_entry_from_caps(caps: ®ex::Captures<'_>, file_path: &str) -> Option { let year: i32 = caps.get(1)?.as_str().parse().ok()?; let month: u32 = caps.get(2)?.as_str().parse().ok()?; let day: u32 = caps.get(3)?.as_str().parse().ok()?; @@ -96,7 +120,12 @@ fn parse_header(line: &str, file_path: &str) -> Option { let second: u32 = caps.get(6)?.as_str().parse().ok()?; let level = caps.get(7)?.as_str(); let component = caps.get(8)?.as_str().to_string(); - let message = caps.get(9).map(|m| m.as_str()).unwrap_or("").trim_end().to_string(); + let message = caps + .get(9) + .map(|m| m.as_str()) + .unwrap_or("") + .trim_end() + .to_string(); let timestamp = chrono::NaiveDate::from_ymd_opt(year, month, day) .and_then(|date| date.and_hms_opt(hour, minute, second)) @@ -131,7 +160,11 @@ fn severity_from_level(level: &str, message: &str) -> Severity { } } -fn flush_pending(entries: &mut Vec, pending: &mut Option, next_id: &mut u64) { +fn flush_pending( + entries: &mut Vec, + pending: &mut Option, + next_id: &mut u64, +) { if let Some(mut pending_entry) = pending.take() { pending_entry.entry.id = *next_id; pending_entry.entry.line_number = pending_entry.start_line; @@ -194,7 +227,7 @@ mod tests { } #[test] - fn test_parse_lines_keeps_fallback_for_malformed_segments() { + fn test_parse_lines_salvages_structural_segments_with_unexpected_levels() { let lines = [ "orphan preamble", "2024-01-15 08:00:00, Info CBS Exec: Processing package", @@ -205,15 +238,16 @@ mod tests { let (entries, parse_errors) = parse_lines(&lines, "C:/Windows/Logs/CBS/CBS.log"); - assert_eq!(parse_errors, 2); + assert_eq!(parse_errors, 1); assert_eq!(entries.len(), 4); assert_eq!(entries[0].message, "orphan preamble"); - assert_eq!(entries[1].message, "Exec: Processing package\nContinuation detail"); assert_eq!( - entries[2].message, - "2024-01-15 08:00:01, UnexpectedLevel CBS malformed header" + entries[1].message, + "Exec: Processing package\nContinuation detail" ); + assert_eq!(entries[2].message, "malformed header"); + assert_eq!(entries[2].component.as_deref(), Some("CBS")); assert_eq!(entries[3].severity, Severity::Warning); assert_eq!(entries[3].component.as_deref(), Some("CSI")); } -} \ No newline at end of file +} diff --git a/src-tauri/src/parser/dism.rs b/src-tauri/src/parser/dism.rs index 791476449..a094cf7bc 100644 --- a/src-tauri/src/parser/dism.rs +++ b/src-tauri/src/parser/dism.rs @@ -4,9 +4,8 @@ use regex::Regex; use super::severity::detect_severity_from_text; use crate::models::log_entry::{LogEntry, LogFormat, Severity}; -static DISM_PREFIX_RE: Lazy = Lazy::new(|| { - Regex::new(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},").unwrap() -}); +static DISM_PREFIX_RE: Lazy = + Lazy::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},").unwrap()); static DISM_HEADER_RE: Lazy = Lazy::new(|| { Regex::new( @@ -15,6 +14,13 @@ static DISM_HEADER_RE: Lazy = Lazy::new(|| { .unwrap() }); +static DISM_RELAXED_HEADER_RE: Lazy = Lazy::new(|| { + Regex::new( + r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2}),\s+([A-Za-z][A-Za-z0-9_-]{1,31})\s+([A-Za-z][A-Za-z0-9_.-]{1,31})\s+(.*)$", + ) + .unwrap() +}); + struct PendingEntry { entry: LogEntry, start_line: u32, @@ -79,7 +85,20 @@ pub fn parse_lines(lines: &[&str], file_path: &str) -> (Vec, u32) { } fn parse_header(line: &str, file_path: &str) -> Option { - let caps = DISM_HEADER_RE.captures(line)?; + if let Some(caps) = DISM_HEADER_RE.captures(line) { + return build_entry_from_caps(&caps, file_path); + } + + let caps = DISM_RELAXED_HEADER_RE.captures(line)?; + let component = caps.get(8)?.as_str().to_ascii_uppercase(); + if !component.starts_with("DISM") { + return None; + } + + build_entry_from_caps(&caps, file_path) +} + +fn build_entry_from_caps(caps: ®ex::Captures<'_>, file_path: &str) -> Option { let component = caps.get(8)?.as_str().to_string(); if !component.to_ascii_uppercase().starts_with("DISM") { return None; @@ -92,7 +111,12 @@ fn parse_header(line: &str, file_path: &str) -> Option { let minute: u32 = caps.get(5)?.as_str().parse().ok()?; let second: u32 = caps.get(6)?.as_str().parse().ok()?; let level = caps.get(7)?.as_str(); - let message = caps.get(9).map(|m| m.as_str()).unwrap_or("").trim_end().to_string(); + let message = caps + .get(9) + .map(|m| m.as_str()) + .unwrap_or("") + .trim_end() + .to_string(); let timestamp = chrono::NaiveDate::from_ymd_opt(year, month, day) .and_then(|date| date.and_hms_opt(hour, minute, second)) @@ -127,7 +151,11 @@ fn severity_from_level(level: &str, message: &str) -> Severity { } } -fn flush_pending(entries: &mut Vec, pending: &mut Option, next_id: &mut u64) { +fn flush_pending( + entries: &mut Vec, + pending: &mut Option, + next_id: &mut u64, +) { if let Some(mut pending_entry) = pending.take() { pending_entry.entry.id = *next_id; pending_entry.entry.line_number = pending_entry.start_line; @@ -189,7 +217,7 @@ mod tests { } #[test] - fn test_parse_lines_keeps_fallback_for_malformed_segments() { + fn test_parse_lines_salvages_structural_segments_with_unexpected_levels() { let lines = [ "orphan preamble", "2024-01-15 08:00:00, Info DISM DISM Package Manager: Processing package", @@ -200,18 +228,16 @@ mod tests { let (entries, parse_errors) = parse_lines(&lines, "C:/Windows/Logs/DISM/dism.log"); - assert_eq!(parse_errors, 2); + assert_eq!(parse_errors, 1); assert_eq!(entries.len(), 4); assert_eq!(entries[0].message, "orphan preamble"); assert_eq!( entries[1].message, "DISM Package Manager: Processing package\nContinuation detail" ); - assert_eq!( - entries[2].message, - "2024-01-15 08:00:01, UnexpectedLevel DISM malformed header" - ); + assert_eq!(entries[2].message, "malformed header"); + assert_eq!(entries[2].component.as_deref(), Some("DISM")); assert_eq!(entries[3].severity, Severity::Error); assert_eq!(entries[3].component.as_deref(), Some("DISM")); } -} \ No newline at end of file +} diff --git a/src-tauri/src/parser/panther.rs b/src-tauri/src/parser/panther.rs index c9404f17f..802d69fb3 100644 --- a/src-tauri/src/parser/panther.rs +++ b/src-tauri/src/parser/panther.rs @@ -15,6 +15,13 @@ static PANTHER_HEADER_RE: Lazy = Lazy::new(|| { .unwrap() }); +static PANTHER_RELAXED_HEADER_RE: Lazy = Lazy::new(|| { + Regex::new( + r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2}),\s+([A-Za-z][A-Za-z0-9_-]{1,31})\s+(?:(\[0x[0-9A-Fa-f]+\])\s+)?(?:([A-Z][A-Z0-9_.-]{1,31})\s+)?(.*)$", + ) + .unwrap() +}); + struct PendingEntry { entry: LogEntry, start_line: u32, @@ -73,7 +80,15 @@ pub fn parse_lines(lines: &[&str], file_path: &str) -> (Vec, u32) { } fn parse_header(line: &str, file_path: &str) -> Option { - let caps = PANTHER_HEADER_RE.captures(line)?; + if let Some(caps) = PANTHER_HEADER_RE.captures(line) { + return build_entry_from_caps(&caps, file_path); + } + + let caps = PANTHER_RELAXED_HEADER_RE.captures(line)?; + build_entry_from_caps(&caps, file_path) +} + +fn build_entry_from_caps(caps: ®ex::Captures<'_>, file_path: &str) -> Option { let year: i32 = caps.get(1)?.as_str().parse().ok()?; let month: u32 = caps.get(2)?.as_str().parse().ok()?; @@ -182,7 +197,7 @@ mod tests { } #[test] - fn test_parse_lines_keeps_fallback_for_malformed_segments() { + fn test_parse_lines_salvages_structural_segments_with_unexpected_levels() { let lines = [ "orphan preamble", "2024-01-15 08:00:00, Info SP Setup started", @@ -193,11 +208,12 @@ mod tests { let (entries, parse_errors) = parse_lines(&lines, "C:/Windows/Panther/setuperr.log"); - assert_eq!(parse_errors, 2); + assert_eq!(parse_errors, 1); assert_eq!(entries.len(), 4); assert_eq!(entries[0].message, "orphan preamble"); assert_eq!(entries[1].message, "Setup started\ncontinuation detail"); - assert_eq!(entries[2].message, "2024-01-15 08:00:01, UnexpectedLevel SP malformed header"); + assert_eq!(entries[2].message, "malformed header"); + assert_eq!(entries[2].component.as_deref(), Some("SP")); assert_eq!(entries[3].severity, Severity::Error); assert_eq!(entries[3].component.as_deref(), Some("SP")); } diff --git a/src-tauri/src/watcher/tail.rs b/src-tauri/src/watcher/tail.rs index 0fcb5f252..12f160cd2 100644 --- a/src-tauri/src/watcher/tail.rs +++ b/src-tauri/src/watcher/tail.rs @@ -535,10 +535,9 @@ mod tests { .append(true) .open(&path) .expect("should reopen temp file"); - write!( + writeln!( file, - "{}", - "]LOG]!>\n" + "]LOG]!>" ) .expect("should append IME record terminator"); drop(file); diff --git a/src-tauri/tests/parser_regression_corpus.rs b/src-tauri/tests/parser_regression_corpus.rs index 68620a5b5..59a256b16 100644 --- a/src-tauri/tests/parser_regression_corpus.rs +++ b/src-tauri/tests/parser_regression_corpus.rs @@ -180,14 +180,12 @@ fn panther_mixed_fixture_preserves_fallback_segments() { "Timestamped", ); assert_eq!(parsed.total_lines, 5); - assert_eq!(parsed.parse_errors, 2); + assert_eq!(parsed.parse_errors, 1); assert_eq!(parsed.entries.len(), 4); assert_eq!(parsed.entries[0].message, "orphan preamble"); assert_eq!(parsed.entries[1].message, "Setup started\ncontinuation detail"); - assert_eq!( - parsed.entries[2].message, - "2024-01-15 08:00:01, UnexpectedLevel SP malformed header" - ); + assert_eq!(parsed.entries[2].message, "malformed header"); + assert_eq!(parsed.entries[2].component.as_deref(), Some("SP")); assert_eq!(parsed.entries[3].component.as_deref(), Some("SP")); assert_eq!(parsed.entries[3].severity, "Error"); } @@ -249,14 +247,12 @@ fn cbs_mixed_fixture_preserves_fallback_segments() { "Timestamped", ); assert_eq!(parsed.total_lines, 5); - assert_eq!(parsed.parse_errors, 2); + assert_eq!(parsed.parse_errors, 1); assert_eq!(parsed.entries.len(), 4); assert_eq!(parsed.entries[0].message, "orphan preamble"); assert_eq!(parsed.entries[1].message, "Exec: Processing package\nContinuation detail"); - assert_eq!( - parsed.entries[2].message, - "2024-01-15 08:00:01, UnexpectedLevel CBS malformed header" - ); + assert_eq!(parsed.entries[2].message, "malformed header"); + assert_eq!(parsed.entries[2].component.as_deref(), Some("CBS")); assert_eq!(parsed.entries[3].component.as_deref(), Some("CSI")); assert_eq!(parsed.entries[3].severity, "Warning"); } @@ -317,17 +313,15 @@ fn dism_mixed_fixture_preserves_fallback_segments() { "Timestamped", ); assert_eq!(parsed.total_lines, 5); - assert_eq!(parsed.parse_errors, 2); + assert_eq!(parsed.parse_errors, 1); assert_eq!(parsed.entries.len(), 4); assert_eq!(parsed.entries[0].message, "orphan preamble"); assert_eq!( parsed.entries[1].message, "DISM Package Manager: Processing package\nContinuation detail" ); - assert_eq!( - parsed.entries[2].message, - "2024-01-15 08:00:01, UnexpectedLevel DISM malformed header" - ); + assert_eq!(parsed.entries[2].message, "malformed header"); + assert_eq!(parsed.entries[2].component.as_deref(), Some("DISM")); assert_eq!(parsed.entries[3].component.as_deref(), Some("DISM")); assert_eq!(parsed.entries[3].severity, "Error"); } diff --git a/src/components/dialogs/AccessibilityDialog.tsx b/src/components/dialogs/AccessibilityDialog.tsx new file mode 100644 index 000000000..42bc39fdb --- /dev/null +++ b/src/components/dialogs/AccessibilityDialog.tsx @@ -0,0 +1,308 @@ +import { useEffect, useRef } from "react"; +import { + DEFAULT_LOG_DETAILS_FONT_SIZE, + DEFAULT_LOG_LIST_FONT_SIZE, + MAX_LOG_DETAILS_FONT_SIZE, + MAX_LOG_LIST_FONT_SIZE, + MIN_LOG_DETAILS_FONT_SIZE, + MIN_LOG_LIST_FONT_SIZE, + LOG_MONOSPACE_FONT_FAMILY, +} from "../../lib/log-accessibility"; +import { + type LogSeverityPaletteMode, + getLogSeverityPalette, +} from "../../lib/constants"; +import { useUiStore } from "../../stores/ui-store"; + +interface AccessibilityDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const paletteOptions: Array<{ + value: LogSeverityPaletteMode; + label: string; + description: string; +}> = [ + { + value: "classic", + label: "Classic CMTrace", + description: "Preserve CMTrace's original severity colors.", + }, + { + value: "accessible", + label: "Accessible", + description: "Use softer backgrounds and higher-contrast text.", + }, +]; + +export function AccessibilityDialog({ isOpen, onClose }: AccessibilityDialogProps) { + const logListFontSize = useUiStore((state) => state.logListFontSize); + const logDetailsFontSize = useUiStore((state) => state.logDetailsFontSize); + const logSeverityPaletteMode = useUiStore( + (state) => state.logSeverityPaletteMode + ); + const setLogListFontSize = useUiStore((state) => state.setLogListFontSize); + const setLogDetailsFontSize = useUiStore( + (state) => state.setLogDetailsFontSize + ); + const setLogSeverityPaletteMode = useUiStore( + (state) => state.setLogSeverityPaletteMode + ); + const resetLogAccessibilityPreferences = useUiStore( + (state) => state.resetLogAccessibilityPreferences + ); + const dialogRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + useEffect(() => { + if (isOpen) { + if (document.activeElement instanceof HTMLElement) { + previouslyFocusedElementRef.current = document.activeElement; + } else { + previouslyFocusedElementRef.current = null; + } + const dialogNode = dialogRef.current; + if (dialogNode) { + dialogNode.focus(); + } + } else { + if (previouslyFocusedElementRef.current) { + previouslyFocusedElementRef.current.focus(); + } + } + }, [isOpen]); + + if (!isOpen) { + return null; + } + + const palette = getLogSeverityPalette(logSeverityPaletteMode); + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
{ + if (event.key !== "Tab") { + return; + } + const dialogNode = dialogRef.current; + if (!dialogNode) { + return; + } + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'textarea:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ]; + const focusableElements = Array.from( + dialogNode.querySelectorAll(focusableSelectors.join(",")) + ).filter( + (el) => + !el.hasAttribute("disabled") && + el.getAttribute("aria-hidden") !== "true" + ); + if (focusableElements.length === 0) { + event.preventDefault(); + dialogNode.focus(); + return; + } + const firstElement = focusableElements[0]; + const lastElement = + focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; + + if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } else if (event.shiftKey && activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + }} + style={{ + backgroundColor: "#f0f0f0", + border: "1px solid #999", + borderRadius: "4px", + padding: "16px", + minWidth: "520px", + maxWidth: "640px", + boxShadow: "0 4px 12px rgba(0,0,0,0.3)", + }} + > +
+ Accessibility Settings +
+
+ Adjust log-reading text sizes independently and choose whether severity rows use classic CMTrace colors or a more accessible palette. +
+ +
+
+ Application text size +
+
+ Controls text size across log lists, Intune workspace, timelines, and evidence surfaces. +
+
+ setLogListFontSize(Number(event.target.value))} + style={{ flex: 1 }} + aria-label={`Application text size: ${logListFontSize} pixels`} + /> +
+ {logListFontSize}px +
+
+
+ Quick actions: Ctrl/Cmd + =, Ctrl/Cmd + -, Ctrl/Cmd + 0 +
+
+ +
+
+ Details pane text size +
+
+ setLogDetailsFontSize(Number(event.target.value))} + style={{ flex: 1 }} + /> +
+ {logDetailsFontSize}px +
+
+
+ +
+
+ Severity colors +
+
+ {paletteOptions.map((option) => ( + + ))} +
+
+ +
+
Preview
+
+ {``} +
+
+ The details pane preview uses its own independent reading size. +
+
+ +
+
+ Defaults: list {DEFAULT_LOG_LIST_FONT_SIZE}px, details {DEFAULT_LOG_DETAILS_FONT_SIZE}px +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/dialogs/EvidenceBundleDialog.tsx b/src/components/dialogs/EvidenceBundleDialog.tsx new file mode 100644 index 000000000..7c561eb87 --- /dev/null +++ b/src/components/dialogs/EvidenceBundleDialog.tsx @@ -0,0 +1,1166 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { inspectEvidenceArtifact, inspectEvidenceBundle } from "../../lib/commands"; +import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useIntuneStore } from "../../stores/intune-store"; +import { useLogStore } from "../../stores/log-store"; +import { isIntuneWorkspace, useUiStore } from "../../stores/ui-store"; +import { formatDisplayDateTime } from "../../lib/date-time-format"; +import { useAppActions } from "../layout/Toolbar"; +import type { + EvidenceArtifactRecord, + EvidenceArtifactPreview, + EvidenceBundleDetails, +} from "../../types/evidence"; +import type { ParseQuality } from "../../types/log"; +import type { WorkspaceId } from "../../stores/ui-store"; + +interface EvidenceBundleDialogProps { + isOpen: boolean; + onClose: () => void; +} + +type EvidenceBundleTab = "summary" | "inventory" | "notes" | "manifest"; + +interface ArtifactNavigationState { + canOpen: boolean; + reason: string; + actionLabel: string | null; +} + +const TEXT_LIKE_EXTENSIONS = new Set([".log", ".lo_", ".txt"]); + +function getBaseName(path: string | null): string { + if (!path) { + return ""; + } + + return path.split(/[\\/]/).pop() ?? path; +} + +function getDirectoryName(path: string | null): string | null { + if (!path) { + return null; + } + + const normalized = path.replace(/\\/g, "/"); + const lastSeparator = normalized.lastIndexOf("/"); + if (lastSeparator <= 0) { + return null; + } + + return path.slice(0, lastSeparator); +} + +function formatUtcDateTime(value: string | null): string { + if (!value) { + return "Not reported"; + } + + return formatDisplayDateTime(value) ?? value; +} + +function formatCategoryLabel(category: string): string { + return category + .split(/[-_]/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatArtifactStatusTone(status: EvidenceArtifactRecord["status"]) { + switch (status) { + case "collected": + return { backgroundColor: "#dcfce7", color: "#166534" }; + case "missing": + return { backgroundColor: "#fef3c7", color: "#92400e" }; + case "failed": + return { backgroundColor: "#fee2e2", color: "#991b1b" }; + case "skipped": + return { backgroundColor: "#e0f2fe", color: "#0f766e" }; + default: + return { backgroundColor: "#e5e7eb", color: "#374151" }; + } +} + +function formatIntakeStatusTone(status: EvidenceArtifactRecord["intake"]["status"]) { + switch (status) { + case "recognized": + return { backgroundColor: "#dbeafe", color: "#1d4ed8" }; + case "generic": + return { backgroundColor: "#fef3c7", color: "#92400e" }; + case "unsupported": + return { backgroundColor: "#fee2e2", color: "#991b1b" }; + case "missing": + return { backgroundColor: "#e5e7eb", color: "#374151" }; + default: + return { backgroundColor: "#e5e7eb", color: "#374151" }; + } +} + +function formatIntakeStatusLabel(status: EvidenceArtifactRecord["intake"]["status"]) { + switch (status) { + case "recognized": + return "Recognized"; + case "generic": + return "Generic text"; + case "unsupported": + return "Unsupported"; + case "missing": + return "Missing on disk"; + default: + return status; + } +} + +function formatParseQualityLabel(value: ParseQuality | null | undefined) { + switch (value) { + case "structured": + return "Structured"; + case "semiStructured": + return "Semi-structured"; + case "textFallback": + return "Text fallback"; + default: + return null; + } +} + +function formatParseDiagnosticsSummary(artifact: EvidenceArtifactRecord): string | null { + const diagnostics = artifact.intake.parseDiagnostics; + if (!diagnostics) { + return null; + } + + const lineLabel = diagnostics.totalLines === 1 ? "line" : "lines"; + const entryLabel = diagnostics.entryCount === 1 ? "entry" : "entries"; + const errorLabel = diagnostics.parseErrors === 1 ? "issue" : "issues"; + + if (diagnostics.cleanParse) { + return `${diagnostics.entryCount} ${entryLabel} from ${diagnostics.totalLines} ${lineLabel} with no parse issues`; + } + + return `${diagnostics.entryCount} ${entryLabel} from ${diagnostics.totalLines} ${lineLabel} with ${diagnostics.parseErrors} parse ${errorLabel}`; +} + +function formatFileSize(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) { + return "Not reported"; + } + + if (value < 1024) { + return `${value} B`; + } + + if (value < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB`; + } + + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +function getFileExtension(path: string): string { + const fileName = getBaseName(path); + const lastDot = fileName.lastIndexOf("."); + if (lastDot < 0) { + return ""; + } + + return fileName.slice(lastDot).toLowerCase(); +} + +function isTextLikeArtifactPath(path: string | null): boolean { + if (!path) { + return false; + } + + return TEXT_LIKE_EXTENSIONS.has(getFileExtension(path)); +} + +function includesAny(value: string | null | undefined, terms: string[]): boolean { + if (!value) { + return false; + } + + const normalized = value.toLowerCase(); + return terms.some((term) => normalized.includes(term)); +} + +function artifactMatchesTerms(artifact: EvidenceArtifactRecord, terms: string[]): boolean { + if (includesAny(artifact.family, terms)) { + return true; + } + + if (includesAny(artifact.relativePath, terms)) { + return true; + } + + if (includesAny(artifact.originPath, terms)) { + return true; + } + + if (includesAny(artifact.notes, terms)) { + return true; + } + + return artifact.parseHints.some((hint) => includesAny(hint, terms)); +} + +function canPreviewAdjacentEvidence(artifact: EvidenceArtifactRecord): boolean { + return ( + artifact.existsOnDisk && + artifact.absolutePath != null && + (artifact.intake.kind === "registrySnapshot" || + artifact.intake.kind === "eventLogExport") + ); +} + +function getArtifactActionLabel( + artifact: EvidenceArtifactRecord, + navigation: ArtifactNavigationState +): string { + if (navigation.canOpen) { + return navigation.actionLabel ?? navigation.reason; + } + + if (canPreviewAdjacentEvidence(artifact)) { + return artifact.intake.kind === "registrySnapshot" + ? "Review registry snapshot" + : "Review event export"; + } + + return navigation.reason; +} + +function getArtifactNavigationState( + artifact: EvidenceArtifactRecord, + activeView: WorkspaceId +): ArtifactNavigationState { + if (artifact.status !== "collected") { + return { + canOpen: false, + reason: "Only collected artifacts can be opened.", + actionLabel: null, + }; + } + + if (!artifact.existsOnDisk || !artifact.absolutePath) { + return { + canOpen: false, + reason: "This artifact is not available on disk.", + actionLabel: null, + }; + } + + if (activeView === "log") { + if (artifact.category !== "logs" || !isTextLikeArtifactPath(artifact.absolutePath)) { + return { + canOpen: false, + reason: "The log workspace currently opens collected text log artifacts only.", + actionLabel: null, + }; + } + + return { + canOpen: true, + reason: "Open this artifact in the log workspace.", + actionLabel: "Open in log workspace", + }; + } + + if (isIntuneWorkspace(activeView)) { + if (artifact.category !== "logs" || !isTextLikeArtifactPath(artifact.absolutePath)) { + return { + canOpen: false, + reason: "The Intune workspace currently opens IME-style text log artifacts only.", + actionLabel: null, + }; + } + + if (!artifactMatchesTerms(artifact, ["intune", "ime", "appworkload", "agentexecutor", "healthscripts", "appactionprocessor"])) { + return { + canOpen: false, + reason: "This artifact does not look like an Intune IME log source.", + actionLabel: null, + }; + } + + return { + canOpen: true, + reason: + activeView === "new-intune" + ? "Open this artifact in New Intune Workspace." + : "Open this artifact in the Intune workspace.", + actionLabel: + activeView === "new-intune" + ? "Open in New Intune Workspace" + : "Open in Intune workspace", + }; + } + + if (!isTextLikeArtifactPath(artifact.absolutePath)) { + return { + canOpen: false, + reason: "The dsregcmd workspace currently opens dsregcmd text captures only.", + actionLabel: null, + }; + } + + if (!artifactMatchesTerms(artifact, ["dsregcmd", "entra", "azuread", "join"])) { + return { + canOpen: false, + reason: "This artifact does not look like a dsregcmd capture.", + actionLabel: null, + }; + } + + return { + canOpen: true, + reason: "Open this artifact in the dsregcmd workspace.", + actionLabel: "Open in dsregcmd workspace", + }; +} + +function MetadataRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PreviewPane({ content }: { content: string | null }) { + if (!content) { + return ( +
+ No content was available for this file. +
+ ); + } + + return ( +
+      {content}
+    
+ ); +} + +export function EvidenceBundleDialog({ isOpen, onClose }: EvidenceBundleDialogProps) { + const activeView = useUiStore((state) => state.activeView); + const logBundleMetadata = useLogStore((state) => state.bundleMetadata); + const logSourceEntries = useLogStore((state) => state.sourceEntries); + const logSelectedSourceFilePath = useLogStore((state) => state.selectedSourceFilePath); + const logActiveSource = useLogStore((state) => state.activeSource); + const intuneEvidenceBundle = useIntuneStore((state) => state.evidenceBundle); + const intuneSourceContext = useIntuneStore((state) => state.sourceContext); + const dsregcmdSourceContext = useDsregcmdStore((state) => state.sourceContext); + const { openPathForActiveWorkspace } = useAppActions(); + const dialogRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + const [activeTab, setActiveTab] = useState("summary"); + const [details, setDetails] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [artifactActionMessage, setArtifactActionMessage] = useState(null); + const [selectedArtifact, setSelectedArtifact] = useState(null); + const [artifactPreview, setArtifactPreview] = useState(null); + const [isArtifactPreviewLoading, setIsArtifactPreviewLoading] = useState(false); + + const bundleRootPath = useMemo(() => { + if (activeView === "log") { + if (!logBundleMetadata) { + return null; + } + + if (logActiveSource?.kind === "folder") { + return logActiveSource.path; + } + + if (logActiveSource?.kind === "known" && logActiveSource.pathKind === "folder") { + return logActiveSource.defaultPath; + } + + return getDirectoryName(logBundleMetadata.manifestPath); + } + + if (isIntuneWorkspace(activeView)) { + return intuneEvidenceBundle ? getDirectoryName(intuneEvidenceBundle.manifestPath) : null; + } + + return dsregcmdSourceContext.bundlePath; + }, [ + activeView, + dsregcmdSourceContext.bundlePath, + intuneEvidenceBundle, + logActiveSource, + logBundleMetadata, + ]); + + const selectedSourceFilePath = + activeView === "log" + ? logSelectedSourceFilePath + : isIntuneWorkspace(activeView) + ? intuneSourceContext.analyzedPath + : null; + const sourceEntries = activeView === "log" ? logSourceEntries : []; + const bundleMetadata = + details?.metadata ?? + (activeView === "log" + ? logBundleMetadata + : isIntuneWorkspace(activeView) + ? intuneEvidenceBundle + : null); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + useEffect(() => { + if (isOpen) { + previouslyFocusedElementRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + dialogRef.current?.focus(); + return; + } + + previouslyFocusedElementRef.current?.focus(); + }, [isOpen]); + + useEffect(() => { + if (!isOpen || !bundleRootPath) { + return; + } + + let cancelled = false; + setIsLoading(true); + setErrorMessage(null); + setArtifactActionMessage(null); + setSelectedArtifact(null); + setArtifactPreview(null); + setIsArtifactPreviewLoading(false); + + inspectEvidenceBundle(bundleRootPath) + .then((result) => { + if (cancelled) { + return; + } + + setDetails(result); + }) + .catch((error) => { + if (cancelled) { + return; + } + + setDetails(null); + setErrorMessage( + error instanceof Error ? error.message : "Failed to inspect evidence bundle." + ); + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [bundleRootPath, isOpen]); + + if (!isOpen || !bundleMetadata) { + return null; + } + + const availableEntryPoints = new Set(bundleMetadata.availablePrimaryEntryPoints); + const fileCount = sourceEntries.filter((entry) => !entry.isDir).length; + const folderCount = sourceEntries.filter((entry) => entry.isDir).length; + const artifacts = details?.artifacts ?? []; + const expectedEvidence = details?.expectedEvidence ?? []; + const requiredMissingEvidence = expectedEvidence.filter( + (entry) => entry.required && !entry.available + ); + const artifactCategoryCounts = Array.from( + artifacts.reduce((counts, artifact) => { + counts.set(artifact.category, (counts.get(artifact.category) ?? 0) + 1); + return counts; + }, new Map()) + ).sort((left, right) => left[0].localeCompare(right[0])); + const intakeStatusCounts = Array.from( + artifacts.reduce((counts, artifact) => { + counts.set(artifact.intake.status, (counts.get(artifact.intake.status) ?? 0) + 1); + return counts; + }, new Map()) + ).sort((left, right) => right[1] - left[1]); + const recognizedFamilies = Array.from( + artifacts.reduce((labels, artifact) => { + if (artifact.intake.recognizedAs) { + labels.add(artifact.intake.recognizedAs); + } + return labels; + }, new Set()) + ).sort((left, right) => left.localeCompare(right)); + const noisyParsedArtifacts = artifacts.filter( + (artifact) => (artifact.intake.parseDiagnostics?.parseErrors ?? 0) > 0 + ); + + const handleArtifactOpen = async (artifact: EvidenceArtifactRecord) => { + const navigation = getArtifactNavigationState(artifact, activeView); + const canPreview = canPreviewAdjacentEvidence(artifact); + + if (navigation.canOpen && artifact.absolutePath) { + setArtifactActionMessage(null); + + try { + await openPathForActiveWorkspace(artifact.absolutePath); + onClose(); + } catch (error) { + setArtifactActionMessage( + error instanceof Error ? error.message : "The selected artifact could not be opened." + ); + } + return; + } + + if (canPreview && artifact.absolutePath) { + setArtifactActionMessage(null); + setSelectedArtifact(artifact); + setArtifactPreview(null); + setIsArtifactPreviewLoading(true); + + try { + const preview = await inspectEvidenceArtifact( + artifact.absolutePath, + artifact.intake.kind, + artifact.originPath + ); + setArtifactPreview(preview); + } catch (error) { + setArtifactActionMessage( + error instanceof Error + ? error.message + : "The selected adjacent evidence could not be inspected." + ); + } finally { + setIsArtifactPreviewLoading(false); + } + return; + } + + if (!artifact.absolutePath) { + setArtifactActionMessage(navigation.reason); + return; + } + + setArtifactActionMessage(navigation.reason); + }; + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+
+ Evidence Bundle +
+
+ {bundleMetadata.bundleLabel ?? bundleMetadata.bundleId ?? "Collected evidence summary"} +
+
+ {bundleMetadata.summary ?? "This folder was recognized as a CMTrace Open evidence bundle."} +
+
+ +
+
+ {([ + ["summary", "Summary"], + ["inventory", "Inventory"], + ["notes", "Notes"], + ["manifest", "Manifest"], + ] as const).map(([tabId, label]) => ( + + ))} +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {artifactActionMessage && ( +
+ {artifactActionMessage} +
+ )} + + {isLoading && ( +
+ Loading evidence bundle details... +
+ )} + + {requiredMissingEvidence.length > 0 && ( +
+
+ {requiredMissingEvidence.length} required evidence item{requiredMissingEvidence.length === 1 ? " is" : "s are"} missing from this bundle. +
+
+ {requiredMissingEvidence + .slice(0, 3) + .map((entry) => entry.relativePath) + .join(" • ")} + {requiredMissingEvidence.length > 3 + ? ` • +${requiredMissingEvidence.length - 3} more` + : ""} +
+
+ )} + + {activeTab === "summary" && ( + <> +
+ {[ + ["Collected", String(bundleMetadata.artifactCounts?.collected ?? 0), "#dcfce7", "#166534"], + ["Missing", String(bundleMetadata.artifactCounts?.missing ?? 0), "#fef3c7", "#92400e"], + ["Failed", String(bundleMetadata.artifactCounts?.failed ?? 0), "#fee2e2", "#991b1b"], + ["Skipped", String(bundleMetadata.artifactCounts?.skipped ?? 0), "#e0f2fe", "#0f766e"], + ].map(([label, value, backgroundColor, color]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+ +
+
Bundle metadata
+ + + + + + + + + + +
+ +
+
Current intake view
+ + + + + +
+ +
+
Primary evidence entry points
+
+ {bundleMetadata.primaryEntryPoints.map((entryPath: string) => { + const isAvailable = availableEntryPoints.has(entryPath); + return ( +
+
+ {isAvailable ? "Available" : "Missing"} +
+
+ {entryPath} +
+
+ ); + })} +
+
+ + {details && ( +
+
Investigation guidance
+ {details.handoffSummary &&
{details.handoffSummary}
} + {details.priorityQuestions.length > 0 && ( +
+ {details.priorityQuestions.map((question) => ( +
+ {question} +
+ ))} +
+ )} + {details.observedGaps.length > 0 && ( +
+ {details.observedGaps.map((gap) => ( +
+ {gap} +
+ ))} +
+ )} +
+ )} + + )} + + {activeTab === "inventory" && ( + <> +
+
Artifact inventory
+
+ {artifactCategoryCounts.length === 0 ? ( +
No artifact records were found in the manifest.
+ ) : ( + artifactCategoryCounts.map(([category, count]) => ( +
+ {formatCategoryLabel(category)}: {count} +
+ )) + )} +
+
+ {intakeStatusCounts.length === 0 ? ( +
No intake diagnostics are available yet.
+ ) : ( + intakeStatusCounts.map(([status, count]) => { + const tone = formatIntakeStatusTone(status); + return ( +
+ {formatIntakeStatusLabel(status)}: {count} +
+ ); + }) + )} +
+ {recognizedFamilies.length > 0 && ( +
+ Recognized intake families: {recognizedFamilies.join(", ")} +
+ )} + {noisyParsedArtifacts.length > 0 && ( +
+ Parser quality watchlist: {noisyParsedArtifacts.length} recognized log artifact{noisyParsedArtifacts.length === 1 ? "" : "s"} reported parse issues. +
+ )} +
+ +
+
Artifacts
+ {artifacts.length === 0 ? ( +
No artifact detail was available.
+ ) : ( + artifacts.map((artifact) => { + const tone = formatArtifactStatusTone(artifact.status); + const intakeTone = formatIntakeStatusTone(artifact.intake.status); + const parseQualityLabel = formatParseQualityLabel(artifact.intake.parserSelection?.parseQuality); + const parseDiagnosticsSummary = formatParseDiagnosticsSummary(artifact); + const navigation = getArtifactNavigationState(artifact, activeView); + const canPreview = canPreviewAdjacentEvidence(artifact); + const canInteract = navigation.canOpen || canPreview; + const isSelected = + selectedArtifact?.absolutePath != null && + selectedArtifact.absolutePath === artifact.absolutePath; + return ( + + ); + }) + )} +
+ + {(selectedArtifact || isArtifactPreviewLoading) && ( +
+
+ Adjacent evidence preview +
+ {selectedArtifact && ( +
+
+ {selectedArtifact.relativePath} +
+
+ {selectedArtifact.originPath ?? selectedArtifact.intake.recognizedAs ?? "Selected artifact"} +
+
+ )} + {isArtifactPreviewLoading ? ( +
+ Inspecting adjacent evidence... +
+ ) : artifactPreview?.registrySnapshot ? ( + <> +
+ {artifactPreview.summary} +
+
+
+
Keys
+
+ {artifactPreview.registrySnapshot.keyCount} +
+
+
+
Values
+
+ {artifactPreview.registrySnapshot.valueCount} +
+
+
+
+ {artifactPreview.registrySnapshot.keys.map((keyPreview) => ( +
+
+ {keyPreview.valueCount} value{keyPreview.valueCount === 1 ? "" : "s"} +
+
+ {keyPreview.path} +
+
+ {keyPreview.values.map((valuePreview) => ( +
+ {valuePreview.name} [{valuePreview.valueType}]: {valuePreview.value} +
+ ))} +
+
+ ))} +
+ + ) : artifactPreview?.eventLogExport ? ( +
+
+ {artifactPreview.summary} +
+ + + + +
+ ) : ( +
+ Select a registry snapshot or curated event export to inspect it here. +
+ )} +
+ )} + +
+
Expected evidence
+ {expectedEvidence.length === 0 ? ( +
No expected-evidence detail was recorded in the manifest.
+ ) : ( + expectedEvidence.map((entry) => ( +
+
+ {entry.available ? "Available" : entry.required ? "Required gap" : "Optional gap"} +
+
{entry.relativePath}
+ {entry.reason &&
{entry.reason}
} +
+ )) + )} +
+ + )} + + {activeTab === "notes" && ( +
+
Notes preview
+ +
+ )} + + {activeTab === "manifest" && ( +
+
Manifest preview
+ +
+ )} +
+ +
+
+ Notes file: {getBaseName(bundleMetadata.notesPath)} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/dialogs/FindDialog.tsx b/src/components/dialogs/FindDialog.tsx index c6cbe9422..ab83a029d 100644 --- a/src/components/dialogs/FindDialog.tsx +++ b/src/components/dialogs/FindDialog.tsx @@ -1,4 +1,17 @@ import { useEffect, useRef } from "react"; +import { + Button, + Caption1, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Field, + Input, +} from "@fluentui/react-components"; import { useLogStore } from "../../stores/log-store"; interface FindDialogProps { @@ -30,11 +43,6 @@ export function FindDialog({ isOpen, onClose }: FindDialogProps) { } const handleKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - return; - } - if (event.key === "Enter" || event.key === "F3") { event.preventDefault(); @@ -56,83 +64,55 @@ export function FindDialog({ isOpen, onClose }: FindDialogProps) { } return ( -
{ - if (event.target === event.currentTarget) { + { + if (!data.open) { onClose(); } }} > -
-
- Find -
- -
- - setFindQuery(event.target.value)} - style={{ - flex: 1, - fontSize: "12px", - padding: "2px 4px", - }} - /> -
- -
- - {statusText && ( - {statusText} - )} -
+ + Find + + + setFindQuery(data.value)} + placeholder="Search the current log view" + /> + -
- - - -
-
-
+
+ setFindCaseSensitive(Boolean(data.checked))} + /> + {statusText && {statusText}} +
+ + + + + + + + + ); } diff --git a/src/components/dsregcmd/DsregcmdEventLogSurface.tsx b/src/components/dsregcmd/DsregcmdEventLogSurface.tsx new file mode 100644 index 000000000..3a1dd87d2 --- /dev/null +++ b/src/components/dsregcmd/DsregcmdEventLogSurface.tsx @@ -0,0 +1,393 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useMemo, useRef, useCallback } from "react"; +import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import type { EventLogAnalysis, EventLogEntry, EventLogChannel, EventLogSeverity } from "../../types/event-log"; + +const COLLAPSED_ROW_ESTIMATE = 28; +const EXPANDED_ROW_ESTIMATE = 200; + +const SEVERITY_COLORS: Record = { + Critical: "#dc2626", + Error: "#ea580c", + Warning: "#d97706", + Information: "#2563eb", + Verbose: "#6b7280", + Unknown: "#9ca3af", +}; + +function channelKey(channel: EventLogChannel): string { + if (typeof channel === "string") return channel; + return channel.Other ?? "Other"; +} + +function formatTimestamp(iso: string): string { + try { + const date = new Date(iso); + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return iso; + } +} + +function getFileName(path: string): string { + const parts = path.split(/[\\/]/); + return parts[parts.length - 1] ?? path; +} + +interface DsregcmdEventLogSurfaceProps { + eventLogAnalysis: EventLogAnalysis; +} + +export function DsregcmdEventLogSurface({ eventLogAnalysis }: DsregcmdEventLogSurfaceProps) { + const filterChannel = useDsregcmdStore((s) => s.eventLogFilterChannel); + const filterSeverity = useDsregcmdStore((s) => s.eventLogFilterSeverity); + const selectedEntryId = useDsregcmdStore((s) => s.selectedEventLogEntryId); + const setFilterChannel = useDsregcmdStore((s) => s.setEventLogFilterChannel); + const setFilterSeverity = useDsregcmdStore((s) => s.setEventLogFilterSeverity); + const selectEntry = useDsregcmdStore((s) => s.selectEventLogEntry); + + const scrollRef = useRef(null); + + const filteredEntries = useMemo(() => { + return eventLogAnalysis.entries.filter((entry) => { + if (filterChannel !== "All") { + const entryKey = channelKey(entry.channel); + const filterKey = typeof filterChannel === "string" ? filterChannel : channelKey(filterChannel); + if (entryKey !== filterKey) return false; + } + if (filterSeverity !== "All" && entry.severity !== filterSeverity) return false; + return true; + }); + }, [eventLogAnalysis.entries, filterChannel, filterSeverity]); + + const virtualizer = useVirtualizer({ + count: filteredEntries.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + filteredEntries[index]?.id === selectedEntryId + ? EXPANDED_ROW_ESTIMATE + : COLLAPSED_ROW_ESTIMATE, + overscan: 10, + }); + + const handleRowClick = useCallback( + (id: number) => selectEntry(id), + [selectEntry], + ); + + const uniqueChannels = useMemo(() => { + const seen = new Set(); + const channels: { key: string; display: string }[] = []; + for (const entry of eventLogAnalysis.entries) { + const key = channelKey(entry.channel); + if (!seen.has(key)) { + seen.add(key); + channels.push({ key, display: entry.channelDisplay }); + } + } + return channels; + }, [eventLogAnalysis.entries]); + + if (eventLogAnalysis.entries.length === 0) { + return ( +
+ No event log entries were collected for dsregcmd-related channels. + {eventLogAnalysis.liveQuery && ( +
+ Attempted {eventLogAnalysis.liveQuery.attemptedChannelCount} channels,{" "} + {eventLogAnalysis.liveQuery.failedChannelCount} failed. +
+ )} +
+ ); + } + + return ( +
+ {/* Filter bar */} +
+ + + + + + + + {filteredEntries.length} of {eventLogAnalysis.totalEntryCount} entries + +
+ + {/* Channel summary chips */} +
+ {eventLogAnalysis.channelSummaries.map((summary) => { + const isActive = + filterChannel !== "All" && + channelKey(summary.channel) === + (typeof filterChannel === "string" ? filterChannel : channelKey(filterChannel)); + return ( + + ); + })} +
+ + {/* Virtualized entries */} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const entry = filteredEntries[virtualItem.index]; + if (!entry) return null; + const isExpanded = entry.id === selectedEntryId; + + return ( +
+ +
+ ); + })} +
+
+
+ ); +} + +interface EventLogRowProps { + entry: EventLogEntry; + isExpanded: boolean; + onClick: (id: number) => void; +} + +function EventLogRow({ entry, isExpanded, onClick }: EventLogRowProps) { + const severityColor = SEVERITY_COLORS[entry.severity] ?? "#9ca3af"; + + return ( +
onClick(entry.id)} + style={{ + borderBottom: "1px solid #f3f4f6", + cursor: "pointer", + background: isExpanded ? "#f9fafb" : "transparent", + }} + > + {/* Collapsed row */} +
+ + + {formatTimestamp(entry.timestamp)} + + + {entry.channelDisplay} + + + {entry.eventId} + + + {entry.message} + +
+ + {/* Expanded details */} + {isExpanded && ( +
+
+ {entry.message} +
+
+ Provider: + {entry.provider} + Severity: + {entry.severity} + Event ID: + {entry.eventId} + {entry.computer && ( + <> + Computer: + {entry.computer} + + )} + {entry.correlationActivityId && ( + <> + Activity ID: + + {entry.correlationActivityId} + + + )} + Source: + {getFileName(entry.sourceFile)} +
+
+ )} +
+ ); +} diff --git a/src/components/dsregcmd/DsregcmdWorkspace.tsx b/src/components/dsregcmd/DsregcmdWorkspace.tsx index e659e1062..8c0cedf59 100644 --- a/src/components/dsregcmd/DsregcmdWorkspace.tsx +++ b/src/components/dsregcmd/DsregcmdWorkspace.tsx @@ -1,7 +1,19 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Badge, Button, Textarea } from "@fluentui/react-components"; import { save } from "@tauri-apps/plugin-dialog"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { + formatDisplayDateTime, + parseDisplayDateTime, +} from "../../lib/date-time-format"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { DsregcmdEventLogSurface } from "./DsregcmdEventLogSurface"; import { useAppActions } from "../layout/Toolbar"; import { writeTextOutputFile } from "../../lib/commands"; import type { @@ -40,16 +52,6 @@ interface DisplayConfidenceAssessment { 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 { @@ -64,7 +66,9 @@ function formatBool(value: boolean | null): string { return "Unknown"; } -function formatValue(value: string | number | boolean | null | undefined): string { +function formatValue( + value: string | number | boolean | null | undefined, +): string { if (value === null || value === undefined || value === "") { return NOT_REPORTED_LABEL; } @@ -76,7 +80,9 @@ function formatValue(value: string | number | boolean | null | undefined): strin return String(value); } -function formatEvidenceSource(source: DsregcmdEvidenceSource | null | undefined): string { +function formatEvidenceSource( + source: DsregcmdEvidenceSource | null | undefined, +): string { switch (source) { case "dsregcmd": return "dsregcmd"; @@ -101,7 +107,7 @@ function getPathBaseName(path: string): string { function getPolicyDisplayValue( dsregValue: boolean | null | undefined, - policyValue: DsregcmdPolicyEvidenceValue + policyValue: DsregcmdPolicyEvidenceValue, ): string { if (dsregValue != null) { return `${formatBool(dsregValue)} (dsregcmd)`; @@ -119,7 +125,7 @@ function getPolicyDisplayValue( function getPolicyValueTone( dsregValue: boolean | null | undefined, - policyValue: DsregcmdPolicyEvidenceValue + policyValue: DsregcmdPolicyEvidenceValue, ): FactRow["tone"] { if (dsregValue != null) { return toneForBool(dsregValue); @@ -134,11 +140,17 @@ function formatPolicyEvidenceValue(value: DsregcmdPolicyEvidenceValue): string { } const currentLabel = - value.currentValue == null ? null : `effective ${formatBool(value.currentValue)}`; + value.currentValue == null + ? null + : `effective ${formatBool(value.currentValue)}`; const providerLabel = - value.providerValue == null ? null : `provider ${formatBool(value.providerValue)}`; + value.providerValue == null + ? null + : `provider ${formatBool(value.providerValue)}`; const sourceLabel = formatEvidenceSource(value.source); - const parts = [currentLabel, providerLabel].filter((part): part is string => Boolean(part)); + const parts = [currentLabel, providerLabel].filter((part): part is string => + Boolean(part), + ); if (parts.length > 0 && sourceLabel) { return `${parts.join(" / ")} (${sourceLabel})`; @@ -165,7 +177,11 @@ function getPolicyEvidenceSummary(result: DsregcmdAnalysisResult): string { } const firstNote = uniqueNotes[0]; - if (firstNote.includes("no mapped PassportForWork PolicyManager values were present")) { + if ( + firstNote.includes( + "no mapped PassportForWork PolicyManager values were present", + ) + ) { return "Registry captured, but no mapped WHfB policy values were found."; } @@ -185,38 +201,31 @@ function formatRegistryArtifacts(paths: string[]): string { 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 getEffectivePolicyEnabled( + result: DsregcmdAnalysisResult, +): boolean | null { + return ( + result.facts.userState.policyEnabled ?? + result.policyEvidence.policyEnabled.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 getEffectivePostLogonEnabled( + result: DsregcmdAnalysisResult, +): boolean | null { + return ( + result.facts.userState.postLogonEnabled ?? + result.policyEvidence.postLogonEnabled.displayValue + ); } function formatLocalDateTime(value: string | null | undefined): string | null { - const parsed = parseDsregcmdDateTime(value); - return parsed ? localDateTimeFormatter.format(parsed) : null; + return formatDisplayDateTime(value); } -function parseCertificateValidityRange(value: string | null | undefined): { from: string; to: string } | null { +function parseCertificateValidityRange( + value: string | null | undefined, +): { from: string; to: string } | null { if (!value) { return null; } @@ -232,11 +241,13 @@ function parseCertificateValidityRange(value: string | null | undefined): { from function formatCertificateValidityRange( rawValue: string | null | undefined, validFrom: string | null | undefined, - validTo: 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); + const from = + formatLocalDateTime(validFrom) ?? formatLocalDateTime(parsedRange?.from); + const to = + formatLocalDateTime(validTo) ?? formatLocalDateTime(parsedRange?.to); if (from && to) { return `${from} to ${to}`; @@ -280,7 +291,9 @@ function toneForBool(value: boolean | null | undefined): FactRow["tone"] { return "neutral"; } -function toneForWorkplaceJoined(value: boolean | null | undefined): FactRow["tone"] { +function toneForWorkplaceJoined( + value: boolean | null | undefined, +): FactRow["tone"] { if (value === true) { return "warn"; } @@ -288,11 +301,15 @@ function toneForWorkplaceJoined(value: boolean | null | undefined): FactRow["ton return "neutral"; } -function toneForEnterpriseJoined(_value: boolean | null | undefined): FactRow["tone"] { +function toneForEnterpriseJoined( + _value: boolean | null | undefined, +): FactRow["tone"] { return "neutral"; } -function toneForDomainJoined(value: boolean | null | undefined): FactRow["tone"] { +function toneForDomainJoined( + value: boolean | null | undefined, +): FactRow["tone"] { if (value === true) { return "good"; } @@ -300,7 +317,9 @@ function toneForDomainJoined(value: boolean | null | undefined): FactRow["tone"] return "neutral"; } -function toneForEnterprisePrt(value: boolean | null | undefined): FactRow["tone"] { +function toneForEnterprisePrt( + value: boolean | null | undefined, +): FactRow["tone"] { if (value === true) { return "good"; } @@ -308,13 +327,15 @@ function toneForEnterprisePrt(value: boolean | null | undefined): FactRow["tone" return "neutral"; } -function toneForJoinType(joinType: DsregcmdAnalysisResult["derived"]["joinType"]): FactRow["tone"] { +function toneForJoinType( + joinType: DsregcmdAnalysisResult["derived"]["joinType"], +): FactRow["tone"] { return joinType === "NotJoined" ? "bad" : "good"; } function toneForPrtState( prtPresent: boolean | null, - stalePrt: boolean | null | undefined + stalePrt: boolean | null | undefined, ): FactRow["tone"] { if (prtPresent === null) { return "neutral"; @@ -327,7 +348,9 @@ function toneForPrtState( return stalePrt ? "warn" : "good"; } -function formatPhaseLabel(phase: DsregcmdAnalysisResult["derived"]["dominantPhase"]): string { +function formatPhaseLabel( + phase: DsregcmdAnalysisResult["derived"]["dominantPhase"], +): string { switch (phase) { case "precheck": return "Precheck"; @@ -344,7 +367,9 @@ function formatPhaseLabel(phase: DsregcmdAnalysisResult["derived"]["dominantPhas } } -function toneForPhase(phase: DsregcmdAnalysisResult["derived"]["dominantPhase"]): FactRow["tone"] { +function toneForPhase( + phase: DsregcmdAnalysisResult["derived"]["dominantPhase"], +): FactRow["tone"] { if (phase === "unknown") { return "neutral"; } @@ -353,7 +378,7 @@ function toneForPhase(phase: DsregcmdAnalysisResult["derived"]["dominantPhase"]) } function formatConfidenceLabel( - confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"] + confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"], ): string { switch (confidence) { case "high": @@ -366,7 +391,7 @@ function formatConfidenceLabel( } function toneForCaptureConfidence( - confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"] + confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"], ): FactRow["tone"] { switch (confidence) { case "high": @@ -380,22 +405,25 @@ function toneForCaptureConfidence( function qualifyByCaptureConfidence( confidence: DsregcmdAnalysisResult["derived"]["captureConfidence"], - text: string + text: string, ): string { - return confidence === "high" ? text : `Based on this capture, ${text.charAt(0).toLowerCase()}${text.slice(1)}`; + return confidence === "high" + ? text + : `Based on this capture, ${text.charAt(0).toLowerCase()}${text.slice(1)}`; } function getDisplayPhaseAssessment( result: DsregcmdAnalysisResult, errorCount: number, - warningCount: 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.", + summary: + "Current evidence does not show an active failure phase in this capture.", }; } @@ -409,9 +437,12 @@ function getDisplayPhaseAssessment( function getDisplayConfidenceAssessment( result: DsregcmdAnalysisResult, - sourceContext: DsregcmdSourceContext + sourceContext: DsregcmdSourceContext, ): DisplayConfidenceAssessment { - if (sourceContext.source?.kind === "capture" && result.derived.remoteSessionSystem !== true) { + if ( + sourceContext.source?.kind === "capture" && + result.derived.remoteSessionSystem !== true + ) { return { confidence: "high", reason: @@ -426,18 +457,24 @@ function getDisplayConfidenceAssessment( } function toneForMdmVisibility( - derived: DsregcmdAnalysisResult["derived"] + derived: DsregcmdAnalysisResult["derived"], ): FactRow["tone"] { if (derived.mdmEnrolled === true) { - return derived.missingMdm || derived.missingComplianceUrl ? "neutral" : "good"; + return derived.missingMdm || derived.missingComplianceUrl + ? "neutral" + : "good"; } return "neutral"; } -function getMdmVisibilityLabel(derived: DsregcmdAnalysisResult["derived"]): string { +function getMdmVisibilityLabel( + derived: DsregcmdAnalysisResult["derived"], +): string { if (derived.mdmEnrolled === true) { - return derived.missingMdm || derived.missingComplianceUrl ? "Partial" : "Present"; + return derived.missingMdm || derived.missingComplianceUrl + ? "Partial" + : "Present"; } return "Unknown"; @@ -451,7 +488,9 @@ function getNgcReadinessValue(result: DsregcmdAnalysisResult): string { return "Recovery Required"; } - if ((facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail")) { + if ( + (facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail") + ) { return "Key Health Issue"; } @@ -459,7 +498,9 @@ function getNgcReadinessValue(result: DsregcmdAnalysisResult): string { return "Configured"; } - if ((facts.registration.preReqResult ?? "").toLowerCase() === "willprovision") { + if ( + (facts.registration.preReqResult ?? "").toLowerCase() === "willprovision" + ) { return "Will Provision"; } @@ -481,7 +522,9 @@ function toneForNgcReadiness(result: DsregcmdAnalysisResult): FactRow["tone"] { return "warn"; } - if ((facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail")) { + if ( + (facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail") + ) { return "warn"; } @@ -489,7 +532,9 @@ function toneForNgcReadiness(result: DsregcmdAnalysisResult): FactRow["tone"] { return "good"; } - if ((facts.registration.preReqResult ?? "").toLowerCase() === "willprovision") { + if ( + (facts.registration.preReqResult ?? "").toLowerCase() === "willprovision" + ) { return "good"; } @@ -505,7 +550,9 @@ function getNgcCaption(result: DsregcmdAnalysisResult): string { return "Post-join diagnostics indicate the current Windows Hello key state is marked for recovery."; } - if ((facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail")) { + if ( + (facts.postJoinDiagnostics.keySignTest ?? "").toLowerCase().includes("fail") + ) { return "Post-join diagnostics indicate the Windows Hello key health check did not pass."; } @@ -521,7 +568,9 @@ function getNgcCaption(result: DsregcmdAnalysisResult): string { return "Windows Hello for Business is already configured for the current user."; } - if ((facts.registration.preReqResult ?? "").toLowerCase() === "willprovision") { + if ( + (facts.registration.preReqResult ?? "").toLowerCase() === "willprovision" + ) { return "Prerequisites look satisfied enough for Windows Hello provisioning to happen later."; } @@ -551,16 +600,16 @@ function getFactGroups( displayedPrtAgeHours: number | null, displayPhase: DisplayPhaseAssessment, displayConfidence: DisplayConfidenceAssessment, - sourceContext: DsregcmdSourceContext + sourceContext: DsregcmdSourceContext, ): FactGroup[] { const { facts, derived } = result; const policyEnabledDisplay = getPolicyDisplayValue( facts.userState.policyEnabled, - result.policyEvidence.policyEnabled + result.policyEvidence.policyEnabled, ); const postLogonEnabledDisplay = getPolicyDisplayValue( facts.userState.postLogonEnabled, - result.policyEvidence.postLogonEnabled + result.policyEvidence.postLogonEnabled, ); const ngcRows = withNotReportedMetadata([ { @@ -581,14 +630,17 @@ function getFactGroups( { label: "Policy Enabled", value: policyEnabledDisplay, - tone: getPolicyValueTone(facts.userState.policyEnabled, result.policyEvidence.policyEnabled), + tone: getPolicyValueTone( + facts.userState.policyEnabled, + result.policyEvidence.policyEnabled, + ), }, { label: "Post-Logon Enabled", value: postLogonEnabledDisplay, tone: getPolicyValueTone( facts.userState.postLogonEnabled, - result.policyEvidence.postLogonEnabled + result.policyEvidence.postLogonEnabled, ), }, { @@ -608,7 +660,10 @@ function getFactGroups( }, ]); - if (facts.registration.certEnrollment && facts.registration.certEnrollment.toLowerCase() !== "none") { + if ( + facts.registration.certEnrollment && + facts.registration.certEnrollment.toLowerCase() !== "none" + ) { ngcRows.push({ label: "Cert Enrollment", value: formatValue(facts.registration.certEnrollment), @@ -636,7 +691,9 @@ function getFactGroups( ngcRows.push({ label: "Logon Cert Template", value: formatValue(facts.registration.logonCertTemplateReady), - tone: facts.registration.logonCertTemplateReady.includes("StateReady") ? "good" : "neutral", + tone: facts.registration.logonCertTemplateReady.includes("StateReady") + ? "good" + : "neutral", }); } @@ -644,7 +701,9 @@ function getFactGroups( ngcRows.push({ label: "Key Sign Test", value: formatValue(facts.postJoinDiagnostics.keySignTest), - tone: facts.postJoinDiagnostics.keySignTest.toLowerCase().includes("pass") ? "good" : "warn", + tone: facts.postJoinDiagnostics.keySignTest.toLowerCase().includes("pass") + ? "good" + : "warn", }); } @@ -660,7 +719,8 @@ function getFactGroups( { id: "phase-evidence", title: "Phase and Confidence", - caption: "Derived stage and evidence used to explain where the current problem appears to sit.", + caption: + "Derived stage and evidence used to explain where the current problem appears to sit.", rows: withNotReportedMetadata([ { label: "Dominant Phase", @@ -682,17 +742,38 @@ function getFactGroups( 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: "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) }, + { + 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), + }, ]), }, { @@ -700,7 +781,11 @@ function getFactGroups( title: "Join State", caption: "Identity, join posture, and major derived signals.", rows: withNotReportedMetadata([ - { label: "Join Type", value: formatValue(derived.joinTypeLabel), tone: "good" }, + { + label: "Join Type", + value: formatValue(derived.joinTypeLabel), + tone: "good", + }, { label: "Azure AD Joined", value: formatBool(facts.joinState.azureAdJoined), @@ -738,11 +823,26 @@ function getFactGroups( 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: "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), @@ -753,7 +853,7 @@ function getFactGroups( value: formatCertificateValidityRange( facts.deviceDetails.deviceCertificateValidity, derived.certificateValidFrom, - derived.certificateValidTo + derived.certificateValidTo, ), tone: derived.certificateExpiringSoon ? "warn" : "neutral", }, @@ -762,7 +862,8 @@ function getFactGroups( { 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.", + 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", @@ -779,7 +880,10 @@ function getFactGroups( value: formatValue(facts.managementDetails.mdmComplianceUrl), tone: derived.missingComplianceUrl ? "neutral" : "neutral", }, - { label: "Settings URL", value: formatValue(facts.managementDetails.settingsUrl) }, + { + label: "Settings URL", + value: formatValue(facts.managementDetails.settingsUrl), + }, { label: "DM Service URL", value: formatValue(facts.managementDetails.deviceManagementSrvUrl), @@ -832,15 +936,42 @@ function getFactGroups( 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) }, + { + 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), + }, ]), }, { @@ -848,10 +979,22 @@ function getFactGroups( 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: "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), @@ -860,7 +1003,10 @@ function getFactGroups( label: "Fallback to Sync-Join", value: formatValue(facts.preJoinTests.fallbackToSyncJoin), }, - { label: "Error Phase", value: formatValue(facts.registration.errorPhase) }, + { + label: "Error Phase", + value: formatValue(facts.registration.errorPhase), + }, { label: "Logon Cert Template", value: formatValue(facts.registration.logonCertTemplateReady), @@ -870,13 +1016,15 @@ function getFactGroups( { 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.", + 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.", + caption: + "Registry-backed WHfB policy state used only when dsregcmd leaves policy fields unreported.", rows: withNotReportedMetadata([ { label: "Policy Enabled Evidence", @@ -885,28 +1033,48 @@ function getFactGroups( }, { label: "Post-Logon Evidence", - value: formatPolicyEvidenceValue(result.policyEvidence.postLogonEnabled), - tone: toneForBool(result.policyEvidence.postLogonEnabled.displayValue), + 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), + 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), + 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), + 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), + value: formatPolicyEvidenceValue( + result.policyEvidence.useCloudTrustForOnPremAuth, + ), + tone: toneForBool( + result.policyEvidence.useCloudTrustForOnPremAuth.displayValue, + ), }, { label: "Evidence Status", @@ -923,25 +1091,217 @@ function getFactGroups( 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: "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), }, ]), }, + ...(result.osVersion + ? [ + { + id: "os-version", + title: "Operating System", + caption: "OS version details from the registry evidence.", + rows: withNotReportedMetadata([ + { + label: "Product Name", + value: formatValue(result.osVersion.productName), + }, + { + label: "Display Version", + value: formatValue(result.osVersion.displayVersion), + }, + { + label: "Current Build", + value: formatValue(result.osVersion.currentBuild), + }, + { + label: "UBR", + value: + result.osVersion.ubr != null + ? String(result.osVersion.ubr) + : "Not reported", + }, + { + label: "Edition", + value: formatValue(result.osVersion.editionId), + }, + ]), + }, + ] + : []), + ...(result.proxyEvidence + ? [ + { + id: "proxy-config", + title: "Proxy Configuration", + caption: "Proxy settings that may affect connectivity to Entra ID endpoints.", + rows: withNotReportedMetadata([ + { + label: "Proxy Enabled", + value: formatBool(result.proxyEvidence.proxyEnabled ?? null), + tone: result.proxyEvidence.proxyEnabled === true + ? ("warn" as const) + : ("neutral" as const), + }, + { + label: "Proxy Server", + value: formatValue(result.proxyEvidence.proxyServer), + }, + { + label: "Proxy Override", + value: formatValue(result.proxyEvidence.proxyOverride), + }, + { + label: "Auto Config URL", + value: formatValue(result.proxyEvidence.autoConfigUrl), + }, + { + label: "WPAD Detected", + value: result.proxyEvidence.wpadDetected ? "Yes" : "No", + tone: result.proxyEvidence.wpadDetected + ? ("warn" as const) + : ("neutral" as const), + }, + { + label: "WinHTTP Proxy", + value: formatValue(result.proxyEvidence.winhttpProxy), + }, + ]), + }, + ] + : []), + ...(result.enrollmentEvidence + ? [ + { + id: "enrollment-status", + title: "Enrollment Status", + caption: "MDM enrollment entries found in the registry.", + rows: withNotReportedMetadata([ + { + label: "Enrollment Count", + value: String(result.enrollmentEvidence.enrollmentCount), + tone: + result.enrollmentEvidence.enrollmentCount === 0 && + facts.joinState.azureAdJoined === true + ? ("warn" as const) + : result.enrollmentEvidence.enrollmentCount > 1 + ? ("warn" as const) + : ("good" as const), + }, + ...result.enrollmentEvidence.enrollments.map((e, i) => ({ + label: `Enrollment ${i + 1}`, + value: [ + e.upn ?? "(no UPN)", + e.providerId ?? "(no provider)", + e.enrollmentState != null + ? `state=${e.enrollmentState}` + : "", + ] + .filter(Boolean) + .join(" — "), + })), + ]), + }, + ] + : []), + ...(result.activeEvidence?.connectivityTests?.length + ? [ + { + id: "endpoint-connectivity", + title: "Endpoint Connectivity", + caption: "Live reachability tests to required Microsoft Entra endpoints.", + rows: result.activeEvidence.connectivityTests.map((test) => ({ + label: new URL(test.endpoint).hostname, + value: test.reachable + ? `Reachable${test.statusCode ? ` (${test.statusCode})` : ""}${test.latencyMs != null ? ` — ${test.latencyMs}ms` : ""}` + : `Unreachable${test.errorMessage ? ` — ${test.errorMessage}` : ""}`, + tone: test.reachable + ? test.latencyMs != null && test.latencyMs > 2000 + ? ("warn" as const) + : ("good" as const) + : ("bad" as const), + })), + }, + ] + : []), + ...(result.activeEvidence?.scpQuery + ? [ + { + id: "scp-config", + title: "SCP Configuration", + caption: "Service Connection Point query results from Active Directory.", + rows: withNotReportedMetadata([ + { + label: "SCP Found", + value: result.activeEvidence.scpQuery.scpFound ? "Yes" : "No", + tone: result.activeEvidence.scpQuery.scpFound + ? ("good" as const) + : facts.joinState.domainJoined === true + ? ("bad" as const) + : ("neutral" as const), + }, + { + label: "Tenant Domain", + value: formatValue(result.activeEvidence.scpQuery.tenantDomain), + }, + { + label: "Azure AD ID", + value: formatValue(result.activeEvidence.scpQuery.azureadId), + }, + { + label: "Domain Controller", + value: formatValue(result.activeEvidence.scpQuery.domainController), + }, + ...(result.activeEvidence.scpQuery.error + ? [ + { + label: "Error", + value: result.activeEvidence.scpQuery.error, + tone: "warn" as const, + }, + ] + : []), + ]), + }, + ] + : []), { id: "source-details", title: "Source Details", - caption: "Where this dsregcmd analysis came from and how much text was processed.", + 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: "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) }, ]), @@ -953,12 +1313,20 @@ function getSummaryText( result: DsregcmdAnalysisResult, sourceLabel: string, displayPhase: DisplayPhaseAssessment, - displayConfidence: DisplayConfidenceAssessment + 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"); + 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}`, @@ -968,22 +1336,24 @@ function getSummaryText( `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", + criticalIssue + ? `Top issue: ${criticalIssue.title}` + : "Top issue: No critical issues detected", qualifyByCaptureConfidence( displayConfidence.confidence, - `PRT present: ${formatBool(result.derived.azureAdPrtPresent)}` + `PRT present: ${formatBool(result.derived.azureAdPrtPresent)}`, ), qualifyByCaptureConfidence( displayConfidence.confidence, - `MDM visibility: ${getMdmVisibilityLabel(result.derived)}` + `MDM visibility: ${getMdmVisibilityLabel(result.derived)}`, ), qualifyByCaptureConfidence( displayConfidence.confidence, - `NGC readiness: ${getNgcReadinessValue(result)}` + `NGC readiness: ${getNgcReadinessValue(result)}`, ), qualifyByCaptureConfidence( displayConfidence.confidence, - `Device auth status: ${formatValue(result.facts.deviceDetails.deviceAuthStatus)}` + `Device auth status: ${formatValue(result.facts.deviceDetails.deviceAuthStatus)}`, ), ].join("\n"); } @@ -1015,33 +1385,72 @@ function StatCard({ backgroundColor: colors.background, padding: "12px", minWidth: 0, + borderRadius: "10px", }} > -
+
{title}
-
+
{value}
-
+
{caption}
); } -function SectionFrame({ title, caption, children }: { title: string; caption: string; children: ReactNode }) { +function SectionFrame({ + title, + caption, + children, +}: { + title: string; + caption: string; + children: ReactNode; +}) { return (
-
-
{title}
-
{caption}
+
+
+ {title} +
+
+ {caption} +
+
{children}
); @@ -1056,14 +1465,22 @@ function IssueCard({ issue }: { issue: DsregcmdDiagnosticInsight }) { border: `1px solid ${colors.border}`, backgroundColor: colors.background, padding: "12px", + borderRadius: "10px", }} > -
- + {issue.severity} - - + + {issue.category}
-
{issue.title}
-
{issue.summary}
+
+ {issue.title} +
+
+ {issue.summary} +
{issue.suggestedFixes.length > 0 && (
-
Suggested fixes
-
    +
    + Suggested fixes +
    +
      {issue.suggestedFixes.map((item) => (
    • {item}
    • ))} @@ -1093,8 +1543,17 @@ function IssueCard({ issue }: { issue: DsregcmdDiagnosticInsight }) { {issue.nextChecks.length > 0 && (
      -
      Next checks
      -
        +
        + Next checks +
        +
          {issue.nextChecks.map((item) => (
        • {item}
        • ))} @@ -1104,8 +1563,17 @@ function IssueCard({ issue }: { issue: DsregcmdDiagnosticInsight }) { {issue.evidence.length > 0 && (
          -
          Evidence
          -
            +
            + Evidence +
            +
              {issue.evidence.map((item) => (
            • {item}
            • ))} @@ -1116,64 +1584,103 @@ function IssueCard({ issue }: { issue: DsregcmdDiagnosticInsight }) { ); } -function FactsTable({ group, showNotReported }: { group: FactGroup; showNotReported: boolean }) { +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}
              +
              +
              +
              + {group.title} +
              +
              + {group.caption} +
              +
              {visibleRows.length === 0 ? ( -
              - All fields in this group were not reported by dsregcmd for this capture. +
              + 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}
              + ) : ( + 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.value} +
              + {row.label} +
              +
              + {row.value} +
              -
              - ); - })} + ); + }) + )} {!showNotReported && hiddenCount > 0 && ( -
              - {hiddenCount} not reported {hiddenCount === 1 ? "field" : "fields"} hidden. +
              + {hiddenCount} not reported {hiddenCount === 1 ? "field" : "fields"}{" "} + hidden.
              )}
              @@ -1190,15 +1697,23 @@ function EmptyWorkspace({ title, body }: { title: string; body: string }) { backgroundColor: "#f8fafc", padding: "24px", color: "#334155", + borderRadius: "12px", }} > -
              {title}
              -
              {body}
              +
              + {title} +
              +
              + {body} +
              ); } -function buildTimelineItems(facts: DsregcmdFacts, result: DsregcmdAnalysisResult) { +function buildTimelineItems( + facts: DsregcmdFacts, + result: DsregcmdAnalysisResult, +) { return [ { id: "cert-valid-from", @@ -1216,7 +1731,9 @@ function buildTimelineItems(facts: DsregcmdFacts, result: DsregcmdAnalysisResult formatLocalDateTime(result.derived.certificateValidTo) ?? result.derived.certificateValidTo ?? facts.deviceDetails.deviceCertificateValidity, - tone: result.derived.certificateExpiringSoon ? "warn" as const : "neutral" as const, + tone: result.derived.certificateExpiringSoon + ? ("warn" as const) + : ("neutral" as const), }, { id: "previous-prt", @@ -1228,7 +1745,7 @@ function buildTimelineItems(facts: DsregcmdFacts, result: DsregcmdAnalysisResult id: "prt-update", label: "Azure AD PRT update", value: formatDateTimeValue(facts.ssoState.azureAdPrtUpdateTime), - tone: result.derived.stalePrt ? "warn" as const : "good" as const, + tone: result.derived.stalePrt ? ("warn" as const) : ("good" as const), }, { id: "client-time", @@ -1239,7 +1756,15 @@ function buildTimelineItems(facts: DsregcmdFacts, result: DsregcmdAnalysisResult ].filter((item) => item.value); } -function FlowBox({ title, detail, tone = "neutral" }: { title: string; detail: string; tone?: "neutral" | "good" | "warn" | "bad" }) { +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" }, @@ -1256,10 +1781,22 @@ function FlowBox({ title, detail, tone = "neutral" }: { title: string; detail: s border: `1px solid ${palette.border}`, backgroundColor: palette.background, padding: "12px", + borderRadius: "10px", }} > -
              {title}
              -
              {detail}
              +
              + {title} +
              +
              + {detail} +
              ); } @@ -1270,14 +1807,30 @@ export function DsregcmdWorkspace() { 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 { + 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 activeTab = useDsregcmdStore((s) => s.activeTab); + const setActiveTab = useDsregcmdStore((s) => s.setActiveTab); + + const eventLogEntryCount = result?.eventLogAnalysis?.totalEntryCount ?? 0; const diagnostics = result?.diagnostics ?? []; - const errorCount = diagnostics.filter((item) => item.severity === "Error").length; - const warningCount = diagnostics.filter((item) => item.severity === "Warning").length; + const errorCount = diagnostics.filter( + (item) => item.severity === "Error", + ).length; + const warningCount = diagnostics.filter( + (item) => item.severity === "Warning", + ).length; const displayedPrtAgeHours = useMemo(() => { if (!result) { @@ -1285,7 +1838,7 @@ export function DsregcmdWorkspace() { } if (sourceContext.source?.kind === "capture") { - const lastUpdate = parseDsregcmdDateTime(result.derived.prtLastUpdate); + const lastUpdate = parseDisplayDateTime(result.derived.prtLastUpdate); if (!lastUpdate) { return result.derived.prtAgeHours; } @@ -1297,31 +1850,52 @@ export function DsregcmdWorkspace() { }, [result, sourceContext.source]); const displayPhase = useMemo( - () => (result ? getDisplayPhaseAssessment(result, errorCount, warningCount) : null), - [errorCount, result, warningCount] + () => + result + ? getDisplayPhaseAssessment(result, errorCount, warningCount) + : null, + [errorCount, result, warningCount], ); const displayConfidence = useMemo( - () => (result ? getDisplayConfidenceAssessment(result, sourceContext) : null), - [result, sourceContext] + () => + result ? getDisplayConfidenceAssessment(result, sourceContext) : null, + [result, sourceContext], ); const factGroups = useMemo( () => result && displayPhase && displayConfidence - ? getFactGroups(result, displayedPrtAgeHours, displayPhase, displayConfidence, sourceContext) + ? getFactGroups( + result, + displayedPrtAgeHours, + displayPhase, + displayConfidence, + sourceContext, + ) : [], - [displayConfidence, displayPhase, displayedPrtAgeHours, result, sourceContext] + [ + displayConfidence, + displayPhase, + displayedPrtAgeHours, + result, + sourceContext, + ], ); const summaryText = useMemo( () => result && displayPhase && displayConfidence - ? getSummaryText(result, sourceContext.displayLabel, displayPhase, displayConfidence) + ? getSummaryText( + result, + sourceContext.displayLabel, + displayPhase, + displayConfidence, + ) : "", - [displayConfidence, displayPhase, result, sourceContext.displayLabel] + [displayConfidence, displayPhase, result, sourceContext.displayLabel], ); const timelineItems = useMemo( () => (result ? buildTimelineItems(result.facts, result) : []), - [result] + [result], ); useEffect(() => { @@ -1357,7 +1931,9 @@ export function DsregcmdWorkspace() { } 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." + error instanceof Error + ? error.message + : "Could not copy dsregcmd JSON to the clipboard.", ); } }; @@ -1373,7 +1949,9 @@ export function DsregcmdWorkspace() { } 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." + error instanceof Error + ? error.message + : "Could not copy the dsregcmd summary to the clipboard.", ); } }; @@ -1390,7 +1968,9 @@ export function DsregcmdWorkspace() { } 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." + error instanceof Error + ? error.message + : "Could not copy dsregcmd status text to the clipboard.", ); } }; @@ -1416,17 +1996,18 @@ export function DsregcmdWorkspace() { return; } - const contents = kind === "json" ? JSON.stringify(result, null, 2) : summaryText; + const contents = + kind === "json" ? JSON.stringify(result, null, 2) : summaryText; await writeTextOutputFile(destination, contents); setExportSuccess( - `Saved ${kind === "json" ? "JSON export" : "summary export"} to ${destination}.` + `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.` + : `Could not save the ${kind === "json" ? "JSON" : "summary"} export.`, ); } }; @@ -1435,7 +2016,10 @@ export function DsregcmdWorkspace() { return ( ); } @@ -1444,14 +2028,24 @@ export function DsregcmdWorkspace() { return ( ); } if (!result) { return ( -
              +
              -
              dsregcmd Workspace
              -
              - Capture a live snapshot, paste clipboard text, open a text file, or select an evidence bundle folder. +
              + dsregcmd Workspace +
              +
              + Capture a live snapshot, paste clipboard text, open a text file, + or select an evidence bundle folder.
              - - + - + - + +
              @@ -1493,7 +2106,10 @@ export function DsregcmdWorkspace() { ); } - const issueSpotlight = diagnostics.find((item) => item.severity === "Error") ?? diagnostics[0] ?? null; + const issueSpotlight = + diagnostics.find((item) => item.severity === "Error") ?? + diagnostics[0] ?? + null; const stage = displayPhase ?? { phase: result.derived.dominantPhase, label: formatPhaseLabel(result.derived.dominantPhase), @@ -1504,9 +2120,15 @@ export function DsregcmdWorkspace() { confidence: result.derived.captureConfidence, reason: result.derived.captureConfidenceReason, }; - return ( -
              +
              -
              dsregcmd Workspace
              -
              +
              + dsregcmd Workspace +
              +
              {sourceContext.displayLabel} {sourceContext.resolvedPath && ` • ${sourceContext.resolvedPath}`} - {sourceContext.evidenceFilePath && sourceContext.evidenceFilePath !== sourceContext.resolvedPath + {sourceContext.evidenceFilePath && + sourceContext.evidenceFilePath !== sourceContext.resolvedPath ? ` • evidence ${sourceContext.evidenceFilePath}` : ""}
              - - + - + - + +
              -
              -
              + {/* Tab strip */} +
              + setActiveTab("analysis")} + /> + setActiveTab("event-logs")} + /> +
              + + {activeTab === "event-logs" && result.eventLogAnalysis ? ( +
              + +
              + ) : ( +
              +
              - -
              + +
              -
              {summaryText}
              +
              + {summaryText} +
              {issueSpotlight && ( -
              -
              Issue spotlight
              -
              {issueSpotlight.title}
              -
              - {issueSpotlight.summary} {confidence.confidence === "high" ? "" : `Interpret this in the context of ${formatConfidenceLabel(confidence.confidence).toLowerCase()} capture confidence.`} +
              +
              + Issue spotlight +
              +
              + {issueSpotlight.title} +
              +
              + {issueSpotlight.summary}{" "} + {confidence.confidence === "high" + ? "" + : `Interpret this in the context of ${formatConfidenceLabel(confidence.confidence).toLowerCase()} capture confidence.`}
              )}
              -
              -
              Quick interpretation
              -
                +
                +
                + 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."}
                • +
                • + {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.
              +
              + No diagnostics were produced for this dsregcmd capture. +
              ) : ( -
              +
              {diagnostics.map((issue) => ( ))} @@ -1648,24 +2443,55 @@ export function DsregcmdWorkspace() { )} - -
              - + +
              +
              -
              +
              {factGroups.map((group) => ( - + ))}
              - + {timelineItems.length === 0 ? ( -
              No timeline-friendly timestamps were found in this capture.
              +
              + No timeline-friendly timestamps were found in this capture. +
              ) : ( -
              +
              {timelineItems.map((item, index) => { const palette = item.tone === "warn" @@ -1675,16 +2501,68 @@ export function DsregcmdWorkspace() { : { line: "#94a3b8", dot: "#64748b", card: "#f8fafc" }; return ( -
              -
              -
              +
              +
              +
              {index < timelineItems.length - 1 && ( -
              +
              )}
              -
              -
              {item.label}
              -
              {item.value}
              +
              +
              + {item.label} +
              +
              + {item.value} +
              ); @@ -1693,8 +2571,18 @@ export function DsregcmdWorkspace() { )} - -
              + +
              @@ -1725,15 +2618,18 @@ export function DsregcmdWorkspace() { title="PRT and session" detail={qualifyByCaptureConfidence( confidence.confidence, - `PRT present is ${formatBool(result.derived.azureAdPrtPresent)}, stale is ${formatBool(result.derived.stalePrt)}, and remote SYSTEM is ${formatBool(result.derived.remoteSessionSystem)}.` + `PRT present is ${formatBool(result.derived.azureAdPrtPresent)}, stale is ${formatBool(result.derived.stalePrt)}, and remote SYSTEM is ${formatBool(result.derived.remoteSessionSystem)}.`, + )} + tone={toneForPrtState( + result.derived.azureAdPrtPresent, + result.derived.stalePrt, )} - tone={toneForPrtState(result.derived.azureAdPrtPresent, result.derived.stalePrt)} /> @@ -1745,49 +2641,137 @@ export function DsregcmdWorkspace() {
              - -
              -
              -
              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. + +
              +
              +
              + 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. +
              +
              + 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. +
              +
              + 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 && (
              )} {showRawInput && ( -