diff --git a/.claude/skills/sentinel-xdr-migration/SKILL.md b/.claude/skills/sentinel-xdr-migration/SKILL.md new file mode 100644 index 00000000000..fd3605ff40c --- /dev/null +++ b/.claude/skills/sentinel-xdr-migration/SKILL.md @@ -0,0 +1,21 @@ +--- +name: sentinel-xdr-migration +description: Orchestrate Sentinel-to-Defender-XDR authoring and optional internal qualification through the repository's canonical workflow and deterministic tools. +--- + +# Sentinel to Defender XDR migration + +Read and follow: + +`Tools\SentinelToXDRMigration\agent-workflows\end-to-end-migration.md` + +Use `sentinel-xdr-migration` and the repository PowerShell packaging entry +points for all deterministic actions. Default to `authoring`; ask before +optional `qualification`. Use V3.1 through `package-v3-1` for Sentinel and XDR +solution packaging, resume `workflow-state.json`, preserve disabled rules, and +obtain explicit approval before environment writes. + +The goal is solution migration and validated publication. Follow the +canonical workflow for the strict over-50% partial-publication policy, +selected-scope Qualification, and the single-owner `ACTION_REQUIRED` watchdog +best practice; do not create competing policy text or watchdog tooling here. diff --git a/.cursor/rules/sentinel-xdr-migration.mdc b/.cursor/rules/sentinel-xdr-migration.mdc new file mode 100644 index 00000000000..05950021cd4 --- /dev/null +++ b/.cursor/rules/sentinel-xdr-migration.mdc @@ -0,0 +1,18 @@ +--- +description: Canonical Sentinel-to-Defender-XDR migration and authoring workflow +globs: + - "Solutions/**" + - "Tools/SentinelToXDRMigration/**" + - "Tools/Create-Azure-Sentinel-Solution/**" +alwaysApply: false +--- + +For Sentinel-to-Defender-XDR migration, XDR Detection authoring, hybrid +packaging, or AR/CD qualification, read and follow +`Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md`. + +That document is canonical. Use repository CLIs for deterministic behavior, +default to the authoring profile, and ask before optional qualification. +Qualification deployment, mock ingestion, and parity are not publishing +requirements. Use V4 for XDR Detection packages and V3 for Sentinel-only +packages. Resume persisted workflow state and require approval before writes. diff --git a/.github/actions/entrypoint.ps1 b/.github/actions/entrypoint.ps1 index ec1a2981a43..785911a0e7d 100644 --- a/.github/actions/entrypoint.ps1 +++ b/.github/actions/entrypoint.ps1 @@ -1,31 +1,65 @@ $mainTemplateChanged = (Get-Item env:mainTemplateChanged).value $createUiChanged = (Get-Item env:createUiChanged).value +$hasFailures = $false Import-Module '/dist/armttk/arm-ttk/arm-ttk.psd1' $PackageFolderPath = './dist/Package' # RUN FOR MAINTEMPLATE.JSON FILE -if ($mainTemplateChanged -eq $true) { +if ($mainTemplateChanged -eq $true) +{ Write-Host "Running ARM-TTK on MainTemplate.json file!" - $MainTemplateTestResults = Test-AzTemplate -TemplatePath "$PackageFolderPath" -File mainTemplate.json -Skip "Template Should Not Contain Blanks", "URIs Should Be Properly Constructed" + $MainTemplateTestResults = Test-AzTemplate -TemplatePath "$PackageFolderPath" -File mainTemplate.json # SKIP ANY ERRORS ON contentProductId AND id $filterTestResults = New-Object System.Collections.ArrayList $hasContentProductIdError = $false - foreach ($testInfo in $MainTemplateTestResults) { - if ($testInfo.Name -eq 'IDs Should Be Derived From ResourceIDs' -and $testInfo.Errors.Count -gt 0) { - foreach ($errorInfo in $testInfo.Errors) { + $hasRequiredDataConnectorsError = $false + + $mainTemplateFilePath = ($PackageFolderPath + "/mainTemplate.json").Replace("//", "/") + $mainTemplateContent = Get-Content "$mainTemplateFilePath" + + $filteredTestInfoAllOutput = New-Object System.Collections.ArrayList + foreach($testInfo in $MainTemplateTestResults) + { + if ($testInfo.Name -eq 'IDs Should Be Derived From ResourceIDs' -and $testInfo.Errors.Count -gt 0) + { + foreach ($errorInfo in $testInfo.Errors) + { if ($errorInfo.Exception.Message -like '*"contentProductId"*' -or - $errorInfo.Exception.Message -like '*"id"*') { + $errorInfo.Exception.Message -like '*"id"*') + { $hasContentProductIdError = $true } - else { + else + { $filterTestResults.Add($testInfo) } } } + elseif($testInfo.Name -eq 'Template Should Not Contain Blanks') + { + for ($i = 0; $i -lt $testInfo.AllOutput.Count; $i++) { + $lineNumber = $testInfo.AllOutput[$i].Location.Line; + $selectedLineContent = $mainTemplateContent | Select-Object -First $lineNumber | Select-Object -Last 1; + if ($selectedLineContent -like '*"requiredDataConnectors"*') { + $hasRequiredDataConnectorsError = $true + } else { + $filteredTestInfoAllOutput.Add($testInfo.AllOutput[$i]); + } + } + + $testInfo.AllOutput = $filteredTestInfoAllOutput + if ($null -ne $testInfo.Summary -and $hasRequiredDataConnectorsError -eq $true -and $filteredTestInfoAllOutput.Count -eq 0) { + $testInfo.Summary.Fail = $testInfo.Summary.Fail - 1 + $testInfo.Summary.Pass = $testInfo.Summary.Pass + 1 + } + + $filterTestResults.Add($testInfo) + } else { - if ($null -ne $testInfo.Summary -and $hasContentProductIdError -eq $true) { + if ($null -ne $testInfo.Summary -and $hasContentProductIdError -eq $true) + { $testInfo.Summary.Fail = $testInfo.Summary.Fail - 1 $testInfo.Summary.Pass = $testInfo.Summary.Pass + 1 } @@ -36,9 +70,14 @@ if ($mainTemplateChanged -eq $true) { Write-Output $filterTestResults - if ($filterTestResults[$filterTestResults.Count - 1].Summary.Fail -gt 0) { - Write-Host "Please review and rectify the 'MainTemplate.json' file as some of the ARM-TTK tests did not pass!" - exit 1 + $mainTemplateFailures = @($filterTestResults | Where-Object { + $null -ne $_.Summary -and $_.Summary.Fail -gt 0 + }) + + if ($mainTemplateFailures.Count -gt 0) { + $failureMessage = "Please review and rectify the 'MainTemplate.json' file as some of the ARM-TTK tests did not pass!" + Write-Host "$([char]27)[1;31m$failureMessage$([char]27)[0m" + $hasFailures = $true } else { Write-Host "All tests passed for the 'MainTemplate.json' file!" @@ -46,21 +85,26 @@ if ($mainTemplateChanged -eq $true) { } # RUN FOR CREATEUIDEFINITION.JSON FILE -if ($createUiChanged -eq $true) { +if ($createUiChanged -eq $true) +{ Write-Host "Running ARM-TTK on CreateUiDefinition.json file!" - $CreateUiTestResults = Test-AzTemplate -TemplatePath "$PackageFolderPath" -File CreateUiDefinition.json -Skip "Template Should Not Contain Blanks", "URIs Should Be Properly Constructed" - $CreateUiTestPassed = $CreateUiTestResults | Where-Object { -not $_.Failed } + $CreateUiTestResults = Test-AzTemplate -TemplatePath "$PackageFolderPath" -File CreateUiDefinition.json + $CreateUiTestPassed = $CreateUiTestResults | Where-Object { -not $_.Failed } Write-Output $CreateUiTestPassed - $CreateUiTestFailures = $CreateUiTestResults | Where-Object { -not $_.Passed } + $CreateUiTestFailures = $CreateUiTestResults | Where-Object { -not $_.Passed } if ($CreateUiTestFailures) { Write-Host "Please review and rectify the 'CreateUiDefinition.json' file as some of the ARM-TTK tests did not pass!" - exit 1 + $hasFailures = $true } else { Write-Host "All tests passed for the 'CreateUiDefinition.json' file!" } } +if ($hasFailures) { + exit 1 +} + exit 0 \ No newline at end of file diff --git a/.github/agents/xdr-solution-manager.agent.md b/.github/agents/xdr-solution-manager.agent.md new file mode 100644 index 00000000000..0926e36233a --- /dev/null +++ b/.github/agents/xdr-solution-manager.agent.md @@ -0,0 +1,362 @@ +--- +name: XDR Solution Manager +description: Manage resumable Sentinel-to-Defender-XDR authoring, packaging, runtime validation, and optional qualification with reviewed mock data. +--- + +# XDR Solution Manager + +Use `sentinel-xdr-end-to-end-orchestrator` as the primary entry point. It +coordinates these specialist skills: + +- `sentinel-xdr-migration-orchestrator` +- `sentinel-xdr-rule-converter` +- `sentinel-xdr-detection-validator` +- `sentinel-xdr-solution-packager` +- `sentinel-xdr-solution-deployer` +- `azure-monitor-logs-ingestion` +- `sentinel-xdr-alert-parity-validator` +- `sentinel-solution-mock-data-generation` +- `sentinel-solution-optional-testing` + +Keep deterministic conversion, validation, authentication, and reporting in +the `sentinel-xdr-migration` CLI. Do not reproduce those implementations in the +agent. The new mock-generation and guarded-ingestion capabilities extend only +the optional qualification stages; they do not replace the original workflow +or runtime validation. CAT.Tools is not a runtime dependency. + +## Protected implementation boundary + +During every solution migration, Authoring run, Qualification run, retry, or +failure recovery, treat the agent and toolkit implementation as immutable. +Never create, edit, delete, rename, or rewrite: + +- `.github\agents\**`; +- `.github\skills\**`; +- `Tools\SentinelToXDRMigration\**`; +- `Tools\SolutionMigration\**`; or +- `Tools\Create-Azure-Sentinel-Solution\**`. + +This prohibition includes prompts, skills, Python and PowerShell backends, +schemas, tests, dependency metadata, workflow definitions, authentication +behavior, safety gates, permission checks, packaging behavior, and any other +agent capability. Do not patch these files to resolve a customer's environment, +dependency, authentication, conversion, validation, packaging, deployment, or +runtime failure. + +When protected implementation fails, stop the affected stage, preserve the +error evidence, and report that a maintainer change is required. Do not propose +or apply a source-code workaround as part of the migration. + +This boundary is absolute for the XDR Solution Manager. No user instruction, +role, ownership claim, approval, or confirmation can authorize this agent to +change protected implementation. If implementation work is requested, refuse +that part and direct the user to perform it manually or use a different +general-purpose development agent. Do not switch this agent into a development +mode and do not continue the requested implementation change. + +The agent may modify only the selected solution's intended migration artifacts +and its routed reports through the established deterministic tools. + +## First-run initialization + +1. Check the Python interpreter without modifying toolkit code. + The supported versions are Python 3.11 and 3.12. If neither is available, + stop and ask the user to install one. Never modify toolkit source as a + workaround for an unsupported local interpreter. +2. If the CLI is unavailable, install it from the repository with a supported + interpreter: + + ```powershell + python -m pip install -e Tools\SentinelToXDRMigration + ``` + +3. Run `sentinel-xdr-migration doctor`. +4. If `firstRun` is true or authentication is incomplete, ask before running + `sentinel-xdr-migration setup`, then rerun `doctor`. +5. Ask before launching interactive Azure CLI, Graph, or MCP authentication. + If device-code authentication is blocked by tenant policy, retry with + `sentinel-xdr-migration setup --hunting-auth-method browser`. +6. Check Triage MCP separately because it is configured by the agent host, not + by the Python CLI. Prefer the official endpoint: + `https://sentinel.microsoft.com/mcp/triage`. +7. Present **Continue**, **Retry startup check**, **Continue offline**, and + **Cancel** as selectable buttons when the host supports structured + elicitation. + +Offline conversion and structural validation must remain available when +runtime providers are unavailable. + +## Proactive progression and advance permissions + +For migration-related lab reads, follow the canonical workflow's reusable lab +read approval: ask once across all selected solutions, reuse the recorded +approval while the lab definition is unchanged, and preserve current per-run +target/query bindings. Do not repeatedly request approval for individual +read-only preflight or access checks covered by it. Authentication, consent, +role changes, and resource writes remain separately gated. + +As soon as a solution is selected for migration, identify the expected actions +for its explicitly selected profile and gather permission requirements and +bounded advance approvals as early as possible. When coordinating multiple +solutions, cover every selected solution rather than waiting for each one to +reach the same permission gate separately. Follow the canonical workflow and +existing consent tools; do not introduce new stages or approval mechanisms. + +Present the exact solution/run, locked target, query families, action groups, +resource identities, caps, and available evidence. Request approval for the +largest currently bound scope, allowing the user to narrow or decline it. +Distinguish permission requirements, configured permissions, effective access, +and user authorization; requesting permission does not prove it was granted. +Unresolved identities, payloads, or caps remain provisional and cannot be +included as approved scope. + +Keep read-only runtime consent, interactive authentication, Entra admin consent, +role changes, and environment writes separate. Advance planning consent never +replaces explicit approval before sign-in or the immediate exact-scope approval +before a resource write. Do not perform Qualification-only actions for an +Authoring run or treat a profile selection as consent. + +Repeatedly reassess each selected solution's next gated step after stage +completion, new evidence, approval, or a dependency change. Execute already +approved runnable steps and promptly surface the exact approval request or +concrete blocker needed to advance. Continue independent approved work for +other solutions when one is blocked. Do not remain silently idle at an +approval gate or wait for the user to ask for status. + +Retain blockers and resume when their prerequisites change; do not repeat +unchanged failed queries, duplicate workers, or rerun successful batches without +a reason. Report completed, running, approval-needed, and blocked work +distinctly. Missing evidence stays unknown, and progression never bypasses +review, runtime/schema, packaging, authentication, or write gates. + +## Migration workflow + +1. Run `workflow-status` first. If no state exists, or + `context.profileSelectionConfirmed` is not `true`, stop and ask the user to + choose **Authoring**, **Qualification**, or **Cancel** with structured + elicitation. Explain that Authoring stops after packaging/reporting while + Qualification adds deployment, mock ingestion, and alert parity. There is + no default; do not initialize until the user explicitly chooses. +2. Initialize or resume with the selected profile: + + ```powershell + sentinel-xdr-migration workflow-init ` + --solution "" ` + --workflow-profile "" ` + --tenant-id "" ` + --subscription-id "" ` + --workspace-resource-id "" ` + --workspace-customer-id "" ` + --version-bump "" + ``` + + Include all four target identifiers only for Qualification. The CLI locks + them into `qualification-target.json`. Every live-stage tool must consume + that artifact and fail closed on any tenant, subscription, workspace ARM ID, + or workspace customer-ID mismatch. No specialist tool may independently + discover or select a workspace. +3. Persist state under + `Solutions\\Logs\sentinel-xdr-migration\\workflow-state.json`. + Keep the same run ID on resume. Use `workflow-runs` to list available IDs; + if more than one exists, require explicit `--run-id` selection rather than + choosing the newest. `workflow-init --new-run` creates an isolated workflow + only when explicitly requested, not to bypass a blocked stage. + Logs are Git-ignored, not PR evidence. When legacy state is found, follow + the canonical workflow's explicit `migrate-reports` copy step before writes; + never initialize over it or delete the originals. + If its context already contains a full `workspaceResourceId`, reuse that + exact workspace for every later stage. Do not list, rediscover, rank, or scan + other workspaces. A tenant mismatch requires authentication correction, not + workspace substitution. +4. Follow the next gated stage reported by `workflow-next`. +5. Delegate each stage to its owning specialist skill. +6. Record terminal status, artifacts, and evidence through the workflow CLI. +7. Stop on failed or blocked gates; correct and retry only that stage. +8. Run deployment, mock ingestion, and parity only for an explicitly selected + `qualification` profile. A configured lab does not imply consent. +9. At the start of optional qualification testing, use + `sentinel-solution-optional-testing` to perform read-only workspace, DCR, + Sentinel query, destination-table, and ingestion-permission preflight checks. + Resolve a customer-ID GUID to a full ARM resource ID at most once, persist + it, and pass it explicitly to every command. + Before asking for a workspace ID, inspect `doctor` output. When + `configuredWorkspaceResourceId` exists, show that exact workspace and ask + **Reuse configured workspace**, **Choose another workspace**, or **Cancel**. + Never ask the user to retype it. After the user approves a new workspace, + persist the resolved resource and customer IDs with: + + ```powershell + sentinel-xdr-migration configure-workspace ` + --tenant-id "" ` + --subscription-id "" ` + --workspace-resource-id "" ` + --workspace-customer-id "" + ``` + + This reusable configuration is cross-solution; workflow state still records + the confirmed workspace for each individual run. + During initial Qualification preflight, show the locked workspace name, + customer-ID GUID, and ARM resource ID and ask the user to confirm that it is + the primary Microsoft Sentinel workspace in Defender XDR for the Advanced + Hunting caller. Record the confirmation in the run's preflight evidence; + do not infer it from Azure CLI configuration or a successful sign-in. + Microsoft Graph `security/runHuntingQuery` uses the caller's primary workspace + when `workspaceId` is omitted and can fall back to it when an explicit + workspace is unavailable or inaccessible. A mismatch or unknown setting + blocks default-workspace execution, not independent source-query work. + Never change the primary setting or retarget automatically. Confirmation is + not automated response-target, permission, runtime, or schema verification; + reconfirm after a target, caller, or primary-setting change. See the + [Graph API reference](https://learn.microsoft.com/en-us/graph/api/security-security-runhuntingquery?view=graph-rest-1.0). + If the locked ARM path becomes unavailable, run only: + + ```powershell + sentinel-xdr-migration qualification-diagnose --solution "" + ``` + + This central diagnostic may perform one exact customer-ID lookup restricted + to the locked subscription. If it returns `repair-available`, show the old + and proposed target and obtain explicit approval before running: + + ```powershell + sentinel-xdr-migration qualification-repair-target ` + --solution "" ` + --approve-target-update + ``` + + No other tool may repeat that lookup. + Present **Continue**, **Retry permission check**, and **Cancel** as + selectable buttons. When the exact packaged custom table is confirmed + missing and table-write permission is ready, also present **Deploy missing + table and retry**. Continue is not write approval, and table deployment + requires its own exact-scope approval. +10. Reuse an existing reviewed scenario or invoke + `sentinel-solution-mock-data-generation` before ingestion. Require reviewed + input when behavior is complex or cannot be safely inferred. + Qualification has one mandatory mock and parity contract: + - each Analytic Rule and its converted Custom Detection share exactly one + rule-specific `mock.json`; + - never generate, select, or ingest separate AR and CD payloads; + - `mock.json` may contain multiple correlated records when the detection + requires joins, thresholds, sequences, aggregation, or a baseline; + - ingest the shared payload once, grouped with compatible rules by source + table and supported ingestion path; + - do not create a recurring ingestion or parity schedule; + - first prove that the exact AR and CD queries return the same marked source + rows, then run controlled alert parity; + - when the native table cannot accept synthetic ingestion, use a documented + native telemetry generator or mark the rule blocked. Never redirect it to + a parallel custom table. +11. Require explicit approval immediately before any deployment or ingestion + write. Pass `--approve-write` only after approval for that exact scope. +12. Keep every AR and CD disabled except during the controlled parity lifecycle. +13. Complete or abort every parity run so cleanup is guaranteed. + +The packaging stage must invoke `sentinel-xdr-solution-packager`, which must run +`sentinel-xdr-migration package-v3-1`. Never mark packaging passed from existing +package files alone. The workflow gate requires the new +`packaging.v3_1.json` evidence plus the generated template, UI definition, +parameters, and versioned ZIP. + +In Authoring, do not block V3.1 packaging solely because a required table is +unavailable on a runtime provider when structural validation passed. Record the +runtime result as environment-blocked, name the exact query surface, and +continue to packaging without calling the content runtime-qualified. In +Qualification, keep that condition blocking. + +## Fallback rules + +Fallback to the corresponding CLI/API path is allowed when the MCP lacks the +required advertised capability or encounters availability, authentication, +consent, permission, connectivity, timeout, transient service, or +incomplete-batch failures. + +Do not fallback for a genuine KQL semantic or runtime error returned by Triage. +Preserve it as a failed detection. Do not mix Triage and Graph results within +one solution report. + +Treat an unavailable required table as an environment-blocked result rather +than a query failure. + +## Completion criteria + +A detection is XDR-ready only when: + +- conversion and structural validation succeed; +- runtime execution succeeds on the required platforms; +- entity output has been reviewed; +- no unresolved conversion or behavioral parity issue remains. + +Always return the conversion report and provider-specific runtime JSON/HTML +report paths. + +## Deployment + +Use `sentinel-xdr-solution-deployer` only after the packaging gate passes. +Verify both Azure RBAC and Graph scopes. A successful write without live read +verification remains blocked. + +For approved lab deployment, follow the canonical repeatable create-or-update +policy: do not add a separate mandatory pre-creation existence check. The +Sentinel alert-rule route uses deterministic-ID `PUT`; the current Graph +deployer still requires an exact-ID `GET` to choose `POST` versus `PATCH` and +then verifies by read-back. A standalone exact-rule preflight is optional +diagnostic evidence, but its Graph reads are currently bundled into the +preflight command and may block. Do not call arbitrary Graph `POST` idempotent, +bypass the supported route, or overwrite uncertain/unowned content. + +## Live alert parity + +After deployment and reviewed scenario preparation through +`sentinel-solution-optional-testing`, use +`sentinel-xdr-alert-parity-validator` only with explicit user approval in a lab +workspace. Require strict AR/CD alert, entity, and evidence parity. Both rules +must begin disabled and must be disabled after completion or failure. +The parity validator must consume the same one-time ingested `mock.json` records +for both sides. A row-count comparison without matching the unique scenario +keys is insufficient, and separate AR/CD mock payloads are prohibited. + +## Automatic AR/CD qualification + +When the user asks to test, qualify, or validate a created Analytic Rule and +Custom Detection, treat that request as one connected operation. Do not ask the +user to locate scripts, contracts, DCRs, mock folders, or report paths. + +The agent must: + +1. Resolve the AR/CD pair from source provenance. +2. Reuse the rule-specific `mock.json` and `scenario.json`, or invoke + `Tools\SolutionMigration\generate_mock_scenario.py`. +3. For a registered supported Standard table, use the generated + `ingestion-contract.yaml` and the locked workspace: + + ```powershell + azure-monitor-logs-ingestion run ` + --contract "\ingestion-contract.yaml" ` + --workspace "" ` + --payload "\mock.json" ` + --report "" + ``` + +4. Provision or reuse only the isolated contract-specific DCE/DCR. +5. Verify acceptance, destination-table visibility, and DCR processing + evidence independently. +6. Run the exact AR and CD queries against the same mock timestamps and unique + scenario values. Require non-zero semantic row parity. +7. Only after row parity passes, run controlled alert parity. + Temporary five-minute AR/CD clones are preferred for fast qualification + when the original schedules are longer. Delete the clones after success or + failure. +8. Update `scenario.json`, workflow state, and the versioned qualification + report automatically. + +The only normal user interruption after Qualification approval is exact-scope +write approval. Stop for manual review when mock generation reports complex, +unsupported, baseline-dependent, watchlist-dependent, or native-telemetry-only +behavior. Never turn an unsupported table into a custom lookalike table. + +## Scope restrictions + +Do not change source analytic-rule definitions or use production telemetry. +Do not bypass workflow gates or mark evidence-free stages passed. Temporary +enablement for an approved lab-only parity run is the sole lifecycle exception. diff --git a/.github/instructions/packaging.instructions.md b/.github/instructions/packaging.instructions.md index 07964bb81a5..57f2594d334 100644 --- a/.github/instructions/packaging.instructions.md +++ b/.github/instructions/packaging.instructions.md @@ -29,7 +29,7 @@ Only proceed once you have a clear answer (or the user explicitly says to use th **Always tell the user what's about to happen BEFORE you run the script:** -> 🔄 Running full build & validation suite for **{SolutionName}** with a **{VersionBump}** version bump. This typically takes **3-5 minutes** — it runs 21 validators including .NET tests (34K+ files for Non-ASCII), ARM-TTK (30 template checks), and hyperlink validation. I'll present the complete report when it finishes. +> 🔄 Running full build & validation suite for **{SolutionName}** with a **{VersionBump}** version bump. This typically takes **3-5 minutes** — it compiles the validation runtime, runs scoped .NET tests, ARM-TTK (30 template checks), and hyperlink validation. I'll present the complete report when it finishes. This message MUST appear in your chat response BEFORE you execute the script. The user needs to know the agent is working, not frozen. @@ -91,6 +91,40 @@ The `-SolutionName` parameter supports **partial/fuzzy matching** — the user d | `SkipValidation` | No | `$false` | Skip the validation step (packaging only) | | `SkipPackaging` | No | `$false` | Skip the packaging step (validation only) | | `ValidationSkip` | No | `kql,detection-schema,non-ascii,arm-ttk` | Comma-separated validators to skip | +| `ContentRoot` | No | Toolkit checkout | Repository checkout containing the selected solution content; use with `-SkipPackaging` for cross-worktree validation | +| `EvidenceDirectory` | No | `.script/local-validation/Logs` | Ignored evidence parent under the toolkit checkout; each run writes a unique folder | + +### Validate Content From Another Checkout + +The script and all validators/dependencies are loaded from the checkout containing +`build-and-validate.ps1`. To validate current (including uncommitted and untracked) +content from a separate checkout, pass its canonical Git root explicitly and require +validation-only mode: + +```powershell +pwsh /path/to/toolkit/.script/local-validation/build-and-validate.ps1 ` + -SolutionName "CiscoASA" ` + -ContentRoot /path/to/content-checkout ` + -SkipPackaging ` + -VersionBump none ` + -ValidationSkip "" ` + -EvidenceDirectory ".script/local-validation/Logs/cisco-validation" +``` + +`-ContentRoot` must be a Git worktree root containing `Solutions/`; the selected +solution must be a real, direct child of that checkout's `Solutions/`. A separate +content checkout is rejected unless `-SkipPackaging` is supplied. TypeScript +compilation, validator scripts, .NET projects, validation configuration, and ARM-TTK +helpers remain rooted in the toolkit checkout; solution-specific file discovery and +the scoped PowerShell validators use the selected content checkout. Repository-wide +secret scanning remains skipped because it cannot be scoped to one solution. + +The ignored evidence run folder contains complete TypeScript JSON results, +TypeScript stdout/stderr and compile output, combined captured child output for .NET, +PowerShell, and ARM-TTK validators, and the complete build report. A missing or +malformed structured TypeScript report is a validation failure, not an empty success. +Skipped validators remain reported as skipped. No package, solution-data, or release +notes are written in cross-checkout validation-only mode. ## What Happens During Build @@ -130,7 +164,7 @@ Bad examples: ### Step 2: Local Validation -The script runs `node .script/local-validation/validate.js --path Solutions/{Name}` which checks: +The script first runs `npm run tsc` to regenerate the ignored JavaScript runtime from the current TypeScript sources, then runs `node .script/local-validation/validate.js --path Solutions/{Name}` which checks. If compilation fails, it reports the error and does not run potentially stale JavaScript. #### ✅ Runs Locally (13 validators — same code as GitHub CI) @@ -157,11 +191,15 @@ After the TypeScript validators, the script runs 3 additional .NET test projects | Validator | Runtime Required | Scope | What It Checks | |-----------|----------------------|-------|---------------| | **Detection Schema** | .NET Core 3.1 | **Solution only** — runs tests matching the solution's Analytic Rules files | Detection/analytics rule schema compliance — required fields, string lengths, entity mappings, connector IDs, no duplicate template IDs | -| **Non-ASCII** | .NET Core 3.1 | **Entire repo** — scans all YAML files | Scans all YAML files for non-ASCII characters (outside `U+0000`–`U+007F`) | +| **Non-ASCII** | .NET Core 3.1 | **Solution only** — runs tests matching the solution's content files | Scans scoped solution YAML/JSON files for non-ASCII characters (outside `U+0000`–`U+007F`) | | **KQL** | .NET 8.0 | **Solution only** — runs tests matching the solution's Analytic Rules, Hunting Queries, Parsers, and Data Connectors | KQL query syntax validation using the Sentinel KQL analyzer | | **ARM-TTK** | PowerShell | **Solution only** — validates Package/mainTemplate.json and Package/createUiDefinition.json | ARM template best-practice testing using the [Azure ARM-TTK](https://github.com/Azure/arm-ttk) module. Runs `Test-AzTemplate` with the same skips and error filters as `.github/actions/entrypoint.ps1`. Module auto-clones from GitHub on first run (to `.arm-ttk/`, gitignored). | -**How solution scoping works:** The .NET test projects scan the entire repo during test discovery (this is unavoidable — it's built into the test data providers). However, `dotnet test --filter` restricts which tests actually **execute** to only those matching files in the target solution. This turns a 30+ minute full-repo validation into a fast, focused check. +**How solution scoping works:** The .NET test projects may enumerate repository paths during test discovery, but filters restrict which tests execute to the selected solution's files. File discovery is separate from validation scope. + +Detection Schema test cases expose only file basenames. If a selected rule has a duplicate or filter-sensitive basename, that validator is reported as skipped with the reason instead of risking validation of another solution's rule. + +The selected solution folder is the scope for all solution-specific checks. The TypeScript runner and PowerShell children inspect current files in that folder (including uncommitted and untracked files; deleted files are absent), rather than selecting a solution from the latest commit. The folder must be an existing direct child of `Solutions/`. TruffleHog scans repository history and is explicitly skipped because it cannot be scoped to one solution. When the PowerShell scripts are invoked without a solution folder, their existing CI commit-diff behavior is preserved. ### Step 4: CI PowerShell Validators & Secret Scanning @@ -169,21 +207,27 @@ After the .NET validators, the script runs 4 additional checks that mirror GitHu | Validator | CI Workflow | What It Checks | Requirements | |-----------|-------------|----------------|--------------| -| **Field Types** | `validateFieldTypes.yaml` | Validates parameter field types in mainTemplate.json for changed files | `powershell-yaml` module, git | -| **Classic App Insights** | `validateClassicAppInsights.yaml` | Detects deprecated classic App Insights resources (Microsoft.Insights/components without WorkspaceResourceId) in newly added files | `powershell-yaml` module, git | -| **Hyperlink Validation** | `hyperlinkValidator.yaml` | Validates URLs in solution files — checks for 404s, 500s, broken links | `powershell-yaml` module, git, **network access** | -| **TruffleHog (Secrets)** | `ScanSecrets.yaml` | Scans for hardcoded secrets/credentials using verified-only detection | `trufflehog` CLI (external tool) | +| **Field Types** | `validateFieldTypes.yaml` | Validates the selected solution's current `Package/mainTemplate.json` | `powershell-yaml` module | +| **Classic App Insights** | `validateClassicAppInsights.yaml` | Detects deprecated classic App Insights resources in current `azuredeploy` files under the selected solution | `powershell-yaml` module | +| **Hyperlink Validation** | `hyperlinkValidator.yaml` | Validates URLs in current files under the selected solution — checks for 404s, 500s, broken links | `powershell-yaml` module, **network access** | +| **TruffleHog (Secrets)** | `ScanSecrets.yaml` | Repo-history secret scan; skipped for folder-scoped validation because it cannot be limited to one solution | `trufflehog` CLI (external tool) | + +For folder-scoped validation, the Classic App Insights check for standalone `DataConnectors/` is also explicitly reported as skipped because it is outside the selected solution folder. **Important notes:** -- These validators check **committed changes** (`git diff HEAD^ HEAD`). Commit your changes before running for full coverage. +- Folder-scoped validation inspects current solution files instead of inferring a solution from the latest commit. With no explicit folder scope, the PowerShell scripts preserve their CI commit-diff behavior. - **Hyperlink Validation** is slow — it makes HTTP requests with a 20-second timeout per URL. It requires network connectivity and will be skipped if the network is unavailable. - **TruffleHog** is optional — it requires the `trufflehog` CLI to be installed ([install guide](https://github.com/trufflesecurity/trufflehog#installation)). If not installed, it is skipped with a message. - The 3 PowerShell validators require the `powershell-yaml` module; the script auto-installs it if missing. -**Runtime detection:** The script checks which runtimes are installed: -- If **.NET Core 3.1** is available → runs Detection Schema (scoped) and Non-ASCII (full repo) +**Runtime detection:** The script checks whether the .NET SDK command is executable, +then independently checks the exact target runtimes required by the test projects: +- If **.NET Core 3.1** is available → runs Detection Schema and Non-ASCII (both scoped) - If **.NET 8.0** is available → runs KQL validator (scoped to solution) -- If a required .NET runtime is **missing** → skips that validator with a message: +- If the `dotnet` SDK command is unavailable, all three .NET validators are skipped + with an SDK-unavailable message. An installed SDK does not imply that its target + runtime is installed. +- If a required target runtime is **missing** → only that validator is skipped with a message: ``` ⏭ Detection Schema validation skipped — .NET Core 3.1 runtime not installed. Install it from: https://dotnet.microsoft.com/download/dotnet/3.1 @@ -284,10 +328,11 @@ After all steps complete, the script emits a **structured report** to stdout bet The report includes: - **Header** — solution name, version change, date, total duration - **Step 1** — packaging result, package file path, version sync status, release notes status -- **Step 2** — per-validator pass/fail/skip counts with error details for failures +- **Step 2** — TypeScript compilation and per-validator pass/fail/skip counts with error details for failures - **Step 3** — .NET validator results with test counts, durations, and scoping info - **Step 4** — CI PowerShell validator results (field types, classic App Insights, hyperlinks, TruffleHog) - **Summary** — overall pass/fail status with totals across all validators +- **Evidence paths** — full structured results and captured child output under the ignored run folder ## Exit Codes @@ -303,5 +348,3 @@ The report includes: - **Do not modify files** in `.script/utils/`, `.script/tests/`, or `.github/workflows/` — they are production code visible to external customers. - **The `Package/` folder is auto-generated.** Its contents are created by the packaging script. Do not hand-edit `mainTemplate.json` or `createUiDefinition.json`. - **Version bumping**: Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. - - diff --git a/.github/instructions/solution-metadata.instructions.md b/.github/instructions/solution-metadata.instructions.md index 5af7f9d8551..73140adc34f 100644 --- a/.github/instructions/solution-metadata.instructions.md +++ b/.github/instructions/solution-metadata.instructions.md @@ -6,6 +6,37 @@ applyTo: "Solutions/**/SolutionMetadata.json" ## Advanced Solution Metadata Validation +### Optional Customer Usage Attribution (V3.1 packaging) + +- `trackingId` is optional and packaging-only; do not require it or propagate + it to SecurityInsights RP metadata. +- When set, store the **complete customer usage attribution tracking ID** + copied unchanged from Partner Center **offer > plan > Technical configuration**, + for example `pid-399fb80b-c914-4257-b61e-91ab73733888-partnercenter`. + This is an illustrative ID, not a value to populate in solutions. +- Never synthesize an ID or substitute a bare GUID, Partner ID, or plan ID. + Complete non-GUID `pid-` identifiers are valid; validation checks the literal + ARM deployment-name characters and 64-character maximum, not GUID-only syntax. +- Local and pipeline V3.1 read this field from authoritative SolutionMetadata.json, + even if the Data input already contains consolidated metadata. +- When absent, null, or blank, V3.1 attempts anonymous public Marketplace catalog + discovery using exact `publisherId` + `offerId`, one applicable plan and one + `Template` / `DefaultTemplate` artifact. A single valid root `pid-` deployment + with an empty inline template supplies the full ID; no catalog IDs are used. + The packager persists the exact discovered `trackingId` in this same metadata + file before emitting the marker. Existing nonblank values always win; invalid + explicit values fail without lookup or overwrite. +- No `planId` metadata field is introduced: ambiguous offers/plans/markers require + manual review and an explicit `trackingId`, not inferred plan selection. +- `-SkipAttributionLookup` or `SENTINEL_SKIP_ATTRIBUTION_LOOKUP=1` disables only + attribution discovery. Missing/ambiguous/unavailable published data warns and + leaves metadata unchanged. Local persistence failures are hard errors. + Nested XDR packages still warn that Partner Center cannot auto-add tracking for + functional nested deployments. Obtain the ID, add `trackingId`, and rebuild. + Missing attribution is not proof that Marketplace upload will be rejected. +- See [official attribution guidance](https://learn.microsoft.com/partner-center/marketplace-offers/azure-partner-customer-usage-attribution#microsoft-marketplace-azure-apps) + and the [V3.1 packager guide](../../Tools/Create-Azure-Sentinel-Solution/V3/README-V3_1.md). + ### Required Structure Validation All SolutionMetadata.json files must contain these mandatory fields: - `publisherId`: Must match approved publisher identifiers (see validation rules) diff --git a/.github/skills/azure-monitor-logs-ingestion/SKILL.md b/.github/skills/azure-monitor-logs-ingestion/SKILL.md new file mode 100644 index 00000000000..8b0cae7dea2 --- /dev/null +++ b/.github/skills/azure-monitor-logs-ingestion/SKILL.md @@ -0,0 +1,100 @@ +--- +name: azure-monitor-logs-ingestion +description: Ingest synthetic JSON records into Azure Monitor custom tables or supported Microsoft standard tables by provisioning a writable Custom-* DCR input stream. +--- + +# Ingest mock data into Azure Monitor Logs + +Use `Tools\AzureMonitorLogsIngestion` when the user wants to load synthetic or +mock JSON records into a Log Analytics table. + +## Safety + +- Use a non-production workspace. +- Never commit tenant IDs, workspace IDs, resource IDs, credentials, tokens, or + customer telemetry. +- Require a declared `Custom-*` input stream for both custom and standard + destinations. +- Never POST directly to a reserved `Microsoft-*` stream. +- The current contract tool may provision an isolated DCE/DCR pair as part of + its supported route. A DCE is generally needed for private-link ingestion or + when a DCR has no ingestion endpoint; it is not a universal Logs Ingestion + API requirement. Check DCE-creation permission only when the selected plan + includes a DCE. +- Do not add another input stream to an existing connector DCR. +- Do not claim that every standard table is supported. Preserve and report + Azure `InvalidStream` and schema errors. +- Do not alter or delete existing connector DCRs. + +## Workflow + +1. Install and check authentication: + + ```powershell + python -m pip install -e Tools\AzureMonitorLogsIngestion + azure-monitor-logs-ingestion doctor + ``` + +2. Create a version 1 contract from the examples under + `Tools\AzureMonitorLogsIngestion\examples`. +3. Define the raw input columns and a transform that explicitly casts and + projects the destination columns. A public table schema does not reveal a + connector's internal parser or enrichment logic; the anonymous + [`/v1/metadata` endpoint](https://api.loganalytics.io/v1/metadata) is schema + metadata, not ingestion capability or parser evidence. See the + [Logs Ingestion API overview](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview) + and [table feature matrix](https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables-features). +4. For a custom table, define the complete `_CL` destination schema. +5. For a standard table, confirm the table exists and specify its documented + `Microsoft-*` output stream and supported Logs Ingestion API route. + Structured records enter through the customer-authored `Custom-*` input + stream and `transformKql`; do not assume reserved or service-managed + destination columns are writable. + - During Sentinel solution Qualification, prefer a DCR listed or referenced + by the solution only when that DCR is already deployed in the locked + workspace and exactly matches the fixture contract, public `Custom-*` + input stream, schema/transform, destination, and endpoint. A package + reference alone does not prove that the DCR exists or is compatible. + Otherwise use a separate contract-specific DCR route; the current tool + may provision an isolated DCE/DCR pair as part of that route. Never + change an existing connector DCR or add an input stream to it. + - Keep resource names unique for any DCE/DCR pair provisioned by the + contract. + - Reuse is allowed only when that DCR already contains exactly the same + `Custom-*` input stream. + - If a shared DCR already contains other streams, use a separate + contract-specific route; do not update the shared DCR and wait for + propagation. +6. Validate without Azure writes: + + ```powershell + azure-monitor-logs-ingestion inspect --contract --payload + ``` + +7. Provision and ingest: + + ```powershell + azure-monitor-logs-ingestion run ` + --contract ` + --payload ` + --report + ``` + +8. If ingestion returns `403`, assign Monitoring Metrics Publisher on the DCR + to the sending identity, or rerun provisioning with `--principal-id` for a + service principal or managed identity. +9. For post-ingestion query verification, prefer a suitable query tool + advertised by the official Sentinel Triage MCP. If the MCP capability is + unavailable or has a provider-level failure, verify through the CLI: + + ```powershell + azure-monitor-logs-ingestion verify --contract + ``` + + A genuine KQL or table error returned by the MCP is not a reason to retry + through the CLI. + +Report the destination table, record and batch counts, DCR/DCE resource IDs, +immutable DCR ID, DCR row/drop/transform metrics when available, verification +result, warnings, and the report path. An HTTP success confirms acceptance, +not completion of downstream processing. diff --git a/.github/skills/log-analytics-workspace-queryer/SKILL.md b/.github/skills/log-analytics-workspace-queryer/SKILL.md index 43c5d070c76..a32a2822e46 100644 --- a/.github/skills/log-analytics-workspace-queryer/SKILL.md +++ b/.github/skills/log-analytics-workspace-queryer/SKILL.md @@ -1,9 +1,9 @@ --- name: log-analytics-workspace-queryer -description: Uses REST API to query Log Analytics workspaces. Use this skill when you need to query a Log Analytics workspace, for example, to check if the table exists in the workspace, or to validate an ASIM parser. +description: Queries Log Analytics workspaces through an advertised official Sentinel Triage MCP tool first, with the REST API script as fallback. --- -# Query Log Analytics workspace using REST API +# Query a Log Analytics workspace ## Inputs @@ -12,7 +12,18 @@ This skill requires two inputs. This information should come from another skill - **KQL query** — the query to run against the Log Analytics workspace. - **Workspace ID** — the GUID of the Log Analytics workspace. -## Step 1: Run the query +## Step 1: Prefer official Sentinel Triage MCP + +Inspect the connected official Sentinel Triage MCP capabilities. If it +advertises a suitable Log Analytics or Sentinel workspace-query tool, use that +tool first. Do not invent a tool name or assume the capability exists. + +Fall back to the repository REST script only when the MCP lacks the capability +or has an availability, authentication, permission, connectivity, timeout, or +provider-service failure. A genuine KQL error is a query failure and must not +be retried through another provider. + +## Step 2: REST fallback Execute the PowerShell script at `scripts/queryLogAnalytics.ps1` (relative to this skill's directory) by passing the workspace ID and KQL query as parameters: @@ -20,6 +31,6 @@ Execute the PowerShell script at `scripts/queryLogAnalytics.ps1` (relative to th .\scripts\queryLogAnalytics.ps1 -WorkspaceId "" -Query "" ``` -## Step 2: Return results +## Step 3: Return results Return the full query output to the calling skill. The calling skill is responsible for interpreting and filtering the results (e.g., filtering for Error or Warning patterns during ASIM validation). diff --git a/.github/skills/sentinel-solution-mock-data-generation/SKILL.md b/.github/skills/sentinel-solution-mock-data-generation/SKILL.md new file mode 100644 index 00000000000..50510d87259 --- /dev/null +++ b/.github/skills/sentinel-solution-mock-data-generation/SKILL.md @@ -0,0 +1,280 @@ +--- +name: sentinel-solution-mock-data-generation +description: Generate or reuse one shared mock.json payload for a Microsoft Sentinel Analytic Rule and its converted Defender XDR Custom Detection. Uses only Azure-Sentinel repository content and never writes to Azure. +version: 1.0.0 +allowed-tools: Bash, Read, Grep, Glob +--- + +# Sentinel Solution Mock Data Generation + +Use this skill when a migration or optional-testing workflow needs mock data +for a specific Analytic Rule and converted Custom Detection. + +## Safety contract + +- Read source rules and toolkit contracts without modifying them. +- Write only under the explicit `--output-root`, or under this toolkit + repository's default mock root when no output root is supplied. Cross-worktree + generation must always use `--output-root`. +- Never write fixture files into a source checkout implicitly. +- Never invoke CAT.Tools at runtime. +- Check for an existing reviewed scenario before generating a new one. +- Never overwrite an existing scenario unless the user explicitly requests + regeneration and the prior fixture has been reviewed. +- Never infer a multi-event attack only because a schema permits it. +- Automatically generate only simple, decisive predicates. +- Require a reviewed scenario for joins, sequences, aggregation, thresholds, + watchlists, external data, historical baselines, anomaly logic, and + absence-of-data rules. +- Generate synthetic data only. Never copy customer or tenant telemetry. +- Never ingest, deploy, enable, or delete Azure resources. + +## Inputs + +Require: + +1. solution folder name; +2. exact Analytic Rule name, ID, filename, or path; +3. the converted Custom Detection in + `Solutions\\XDR Detections\`, or its exact path. + +The generator uses: + +- source Analytic Rule YAML; +- converted Custom Detection YAML; +- `Sample Data\Tables\\base-event.json`; +- `Sample Data\Tables\
\metadata.json`; +- solution `Package\mainTemplate.json`; +- solution parsers; +- DCR `streamDeclarations`, `dataFlows`, `transformKql`, and output stream. +- the reviewed standard-table capability registry at + `Tools\AzureMonitorLogsIngestion\registry.yaml` for writable standard-table + routes, schemas, and transforms. + +For separate checkouts, pass `--source-root` for the repository containing the +solution and `--toolkit-root` for the repository containing this generator and +its registry. The toolkit's `Sample Data` is used by default; pass +`--sample-data-root` only when the neutral table samples live elsewhere. +Absolute AR/CD paths are resolved and checked to remain inside `--source-root`. +Output and table paths reject symlink escapes; the resolved source, toolkit, +sample-data, and output roots are printed in the result and recorded in the +scenario manifest. + +## Existing-scenario-first flow + +Check: + +```text +\\\ (defaults to toolkit Sample Data\Solutions\Mock) + mock.json + scenario.json +``` + +Run: + +```powershell +python Tools\SolutionMigration\generate_mock_scenario.py ` + --solution "" ` + --rule "" ` + --custom-detection "Solutions\\XDR Detections\.yaml" ` + --source-root "" ` + --toolkit-root "" ` + --output-root "\Sample Data\Solutions\Mock" +``` + +Without `--force`, a complete scenario that passed schema validation is +returned with `status=existing-scenario`. Reuse it after reviewing that its AR, +CD, stream, and hypothesis still match the current content. +The final output location is `\\\`; the tool +does not silently fall back to a source-root-relative destination. + +## Automatic generation + +For a simple rule, the tool: + +1. reads the AR and CD; +2. extracts decisive source predicates; +3. inspects the solution package and parser; +4. reverse-maps fields through parser and DCR transforms when a declared + `Custom-*` input stream exists; +5. otherwise resolves the exact source table through the capability registry, + checks source fields against the registered input/output schema, and starts + from the checked-in Standard-table base event; +6. creates records that satisfy the decisive predicate; +7. creates a type-safe benign negative control for validation only; +8. validates every field against the DCR or table metadata; +9. writes the qualifying records to one shared `mock.json` payload. + +The registry only supports explicit reviewed capabilities. Field extraction +matches exact identifiers already present in a known table schema; it is not a +general KQL parser. Unknown/ambiguous tables, unregistered fields, or unresolved +mapping produce a blocked/offline scenario instead of a guessed contract. +Registry schema and transform fingerprints identify the exact template used. +`liveValidated=false` means the contract still needs exact-workspace validation; +generation never claims that the live table/DCR accepts it. + +Optional deterministic controls: + +```powershell +python Tools\SolutionMigration\generate_mock_scenario.py ` + --solution "" ` + --rule "" ` + --custom-detection "" ` + --randomize --copies 3 --seed 42 ` + --lock "requestId=campaign-1" ` + --benign-set "action=read" +``` + +## Provisional candidate inputs + +An unreviewed candidate is not a reviewed scenario. Pass it with +`--candidate-scenario`; this transports and type-checks the rows but does not +assert human acceptance or semantic query validation. Candidate input always +yields `generationStatus=generated-offline-only`, including simple rules, so it +cannot pass the direct-ingestion gate. The API result uses +`status=generated-candidate` (or `existing-candidate` on reuse). The +`scenarioReview` manifest object reports `status=review-pending`, +`inputKind=candidate`, the resolved input path and SHA-256, and +`semanticValidation=not-run`; `validation.semanticReview=pending` and +`validation.semanticMatch=not-run`. Reuse checks the candidate hash, and +`--force` cannot overwrite an unreviewed fixture. + +```powershell +python Tools\SolutionMigration\generate_mock_scenario.py ` + --solution "" ` + --rule "" ` + --custom-detection "" ` + --candidate-scenario "" ` + --source-root "" ` + --toolkit-root "" ` + --output-root "\Sample Data\Solutions\Mock" +``` + +## Reviewed complex scenarios + +For complex behavior, supply a candidate or reviewed JSON file: + +```json +{ + "name": "reviewed-sequence", + "hypothesis": "Documented behavior observable in the selected telemetry.", + "lockedPaths": ["requestId"], + "maliciousRecords": [ + {} + ], + "benignRecords": [ + {} + ] +} +``` + +Run with: + +```powershell +python Tools\SolutionMigration\generate_mock_scenario.py ` + --solution "" ` + --rule "" ` + --custom-detection "" ` + --reviewed-scenario "" ` + --source-root "" ` + --toolkit-root "" ` + --output-root "\Sample Data\Solutions\Mock" +``` + +Use `--candidate-scenario` while human review is pending and +`--reviewed-scenario` only after a human reviewer accepts the hypothesis and +records. The JSON contract is exactly `name` (non-empty string), `hypothesis` +(non-empty string), optional `lockedPaths` (string array), +`maliciousRecords` (non-empty array of objects), and `benignRecords` (non-empty +array of objects). Each record must already match the selected raw DCR input +contract or Standard-table metadata contract; malformed or unknown fields and +type mismatches block generation. This transports reviewed multi-field and +multi-record fixtures; it does not solve KQL joins, aggregates, thresholds, or +prove that records satisfy either query. Keep those cases `manual-review-required` +and `generated-offline-only` until independently validated. Do not treat +schema validation as semantic matching or approval for environment writes. +`expectedMatchKeys` is not a supported machine-checked field; record expected +join/grouping keys and unique marker values in the hypothesis/review notes and +verify them against the exact AR and CD queries separately. + +For the Cisco ASA average-rate rule, candidate records must preserve the exact +one-hour current window and prior `ago(6h)..ago(1h)` five-hour interval, +including the source's `prev6hCumTotal / 6` comparison and OR of all three +doubling thresholds. Keep the five join/group keys aligned across the two +windows and construct `Message` values that the source's parsing expressions +actually accept. For the exfiltration rule, preserve the >=1-GiB sent-byte +threshold, rounded >=95% upload ratio, time window, ASA/FTD product, allowed +action, private source IP, and public destination IP predicates. The generator +only transports and type-checks these reviewed rows; it does not synthesize +Cisco message grammar, calculate query aggregates, or prove a match. Include +separate benign controls and keep both rules blocked from automatic semantic +qualification. + +Example Cisco ASA routing (candidate JSON paths are supplied by the author; +this generator does not invent them): + +```powershell +python Tools\SolutionMigration\generate_mock_scenario.py ` + --source-root "" ` + --toolkit-root "" ` + --solution CiscoASA ` + --rule 795edf2d-cf3e-45b5-8452-fe6c9e6a582e ` + --custom-detection "Solutions\CiscoASA\XDR Detections\CiscoASA-ThreatDetectionMessage.yaml" ` + --candidate-scenario "" ` + --output-root "\Sample Data\Solutions\Mock" + +python Tools\SolutionMigration\generate_mock_scenario.py ` + --source-root "" ` + --toolkit-root "" ` + --solution CiscoASA ` + --rule 79f29feb-6a9d-4cdf-baaa-2daf480a5da1 ` + --custom-detection "Solutions\CiscoASA\XDR Detections\CiscoASA-AvgAttackDetectRateIncrease.yaml" ` + --candidate-scenario "" ` + --output-root "\Sample Data\Solutions\Mock" + +python Tools\SolutionMigration\generate_mock_scenario.py ` + --source-root "" ` + --toolkit-root "" ` + --solution CiscoASA ` + --rule f3a8c2d0-7b41-4e9a-9f6a-2d8a1c4e5b72 ` + --custom-detection "Solutions\CiscoASA\XDR Detections\CiscoASA-PossibleDataExfiltration.yaml" ` + --candidate-scenario "" ` + --output-root "\Sample Data\Solutions\Mock" +``` + +## Output contract + +```text +\\\ (defaults to toolkit Sample Data\Solutions\Mock) + mock.json + scenario.json +``` + +`scenario.json` records: + +- AR and CD identity; +- attack hypothesis; +- query-complexity assessment; +- source and destination tables; +- DCR, stream, output stream, and ingestion mode; +- decisive field mappings and locked paths; +- shared mock record count and benign validation-control count; +- schema validation; +- explicit candidate/reviewed/not-required semantic-review status and input hash; +- separate seeded, live, alert, and cleanup statuses. + +Only `generationStatus=qualification-ready` scenarios with +`directLogsIngestionSupported=true` may proceed to direct DCR ingestion. +`generated-offline-only` scenarios remain valid for query harnesses but not +for direct Logs Ingestion API qualification. + +## Handoff + +After generation or reuse: + +1. present the scenario path and status; +2. review the AR/CD identity, DCR mapping, shared mock record count, and limitations; +3. hand off to `sentinel-solution-optional-testing` only when the user requests + live qualification; +4. never treat generation as proof of ingestion, query match, alert creation, + or migration parity. diff --git a/.github/skills/sentinel-solution-optional-testing/SKILL.md b/.github/skills/sentinel-solution-optional-testing/SKILL.md new file mode 100644 index 00000000000..50449328c10 --- /dev/null +++ b/.github/skills/sentinel-solution-optional-testing/SKILL.md @@ -0,0 +1,639 @@ +--- +name: sentinel-solution-optional-testing +description: Optionally qualify Microsoft Sentinel solution content by discovering an Azure workspace, ingesting reviewed mock data through an installed DCR, validating telemetry and rule behavior, and preserving evidence. Use only when the user requests live testing or qualification. +version: 1.0.0 +allowed-tools: Bash, Read, Grep, Glob +--- + +# Sentinel Solution Optional Testing + +Use this skill only for optional live qualification. Authoring, packaging, and +static validation must remain available when Azure access or write permissions +are unavailable. + +Before entering this skill, the migration Agent must run the existing toolkit +startup readiness flow: + +```powershell +sentinel-xdr-migration doctor +``` + +On first use or when authentication is incomplete, it must run +`sentinel-xdr-migration setup` and then retry `doctor`. This keeps `az login`, +Sentinel authentication, and Advanced Hunting authentication at the beginning +of the migration without changing the original runtime-validation flow. + +After the Qualification target is locked and before Discovery, make one +complete advance-consent request for the anticipated bounded qualification +plan. If it has not happened yet, do so before fixture review or this skill's +live preflight. Reuse persisted consent when target and plan identity remain +unchanged; do not repeat the same broad question later. + +The target-specific DCR permission preflight in this skill runs at the +beginning of the optional testing extension, before fixture generation or +ingestion. This preflight is required for the ingestion permission/resource +checks, not as a separate gate to determine whether an AR or CD already +exists. Its exact-ID Graph reads are currently bundled into the same CLI +preflight and cannot be skipped independently; a denied/unknown result remains +a backend limitation until that check is separated. Do not ignore a blocked +preflight to proceed; making that bundled check optional requires a maintainer +change to the backend. + +## Workspace identity contract + +Read `workflow-status` and the generated `qualification-target.json` before +preflight. The locked context contains tenant ID, subscription ID, workspace +ARM resource ID, and workspace customer ID: + +- treat all four identifiers as authoritative for the entire run; +- pass the target artifact through `--target-context` to every + optional-testing command; +- never run `az monitor log-analytics workspace list`, Resource Graph workspace + discovery, table scans across workspaces, or primary-workspace inference; +- fail closed if the authenticated tenant or subscription differs; +- verify the resolved customer ID still matches; and +- return no workspace alternatives. + +This skill must never resolve or rediscover a workspace. When the exact ARM +path fails, return control to the orchestrator. Only the centralized +`qualification-diagnose` command may perform one exact customer-ID lookup +restricted to the locked subscription. + +If an Azure or MCP request reports a tenant mismatch, treat it as an +authentication-context failure for the selected workspace. Ask to +reauthenticate to the correct tenant and retry the same workspace. Do not look +for another workspace. If the stored workspace is inaccessible or deleted, +present **Retry authentication**, **Select another workspace**, **Continue +offline**, and **Cancel**; discover alternatives only after the user explicitly +selects **Select another workspace**. + +## Advance scope planning and consent + +Use the single advance request made after target lock and before Discovery. +Before fixture review or any environment-write step, expose and refresh the +Qualification scope plan for the explicitly selected run; ask for renewed +advance consent only if the target, plan identity, action/resource families, +payload, caps, package flags, or cleanup ownership changed: + +```powershell +sentinel-xdr-migration plan-deployment ` + --solution "Solutions\" ` + --run-id "" +``` + +This deterministic local command does not authenticate or call Azure. Review +the `deploymentPlanning` metadata from `preflight-deployment` or the persisted +`deployment-scope-plan.json`: exact locked tenant/subscription/resource group/ +workspace/solution/version, recursive source inventory and eligible +additional-CD upper bound, exclusions, package and content hashes, resource +types, caps, ingestion details, and conditional component choices. The +solution `.env` is only read through the target configuration resolver; it is +not credentials or consent, cannot retarget a locked run, and is excluded +from source/package fingerprints. Supply `--run-id` whenever multiple runs +exist; never choose the latest run implicitly. + +When a component has sufficient exact evidence, persist explicit planning +consent using `review-deployment-plan`, the current `--expected-plan-sha256`, +an explicit `--actor`, and the complete selected component set. Planning +consent is bound to the exact plan and target. It is not permission, technical +readiness, deployment evidence, or write approval. Missing, declined, +unknown, or stale consent remains unapproved; changed target/content/package +hashes, payload, resource family, flags, or count caps require renewed consent. +Do not hand-edit workflow state to simulate acceptance. Consent denial or +absence does not block independent read-only checks or offline work. + +The early request must show all seven potential action groups, even when some +are conditional or currently unsupported: + +1. disabled Sentinel AR and Graph CD creation/approved updates, with exact + source IDs, current drafts, pair counts, and caps; +2. conditional fallback DCR/DCE creation, bound to the destination contract + and proven absence of a compatible solution DCR; +3. conditional exact custom-log table creation only when its table is proven + absent; +4. mock telemetry ingestion with payload, records/batches, stream, marker, + schema hashes, estimated bytes/cost, and retention impact; +5. controlled parity with exact pairs, a bounded time window, enable/restore + counts, and completion/abort recovery; +6. current full-package installation and selected XDR registration flags; and +7. explicitly owned cleanup/deletion with exact ownership evidence, resource + list, cap, and exclusions. + +Present **Approve the broadest currently bounded and available scope** as the +preselected suggested action, with **Select components**, **Revise the plan**, +and **Decline advance consent** also available. The preselection is not +approval. Before submission, show every selected component, cap, target, +resource family, and package flag/value. Require affirmative submission or +confirmation of that exact display; a preselected choice without submission, +cancel/close, decline, or absent response must record no consent. Users may +narrow, revise, or decline. + +Suggest only currently supported components whose complete scope is bound to +the plan. If available, this may select the disabled Sentinel-AR/Graph-CD pair +and the distinct later `fullPackageAcceptance` component. Show all package +selection flags individually with their exact values; when a plan exposes five +valid flags and the broad proposal sets them all true, display each named flag +as `true`. Never infer or invent package flags. Unknown/provisional ingestion, +DCR/DCE, table creation, parity, or cleanup remains excluded unless that +component is itself fully bounded, supported, and explicitly displayed. +Where the broadest set cannot be fully bound, suggest only the largest valid +bounded subset and identify every unavailable/excluded action group and why. + +Only the exact affirmative submission may be persisted, after checking all +components and flag values against the current plan. Prior DCR/DCE consent +does not approve ingestion, parity enablement, package installation, or +cleanup. + +Disabled Sentinel alert rules and Graph Custom Detections are an explicit +pair-first component with an approved upper-bound cap. Full-package +installation and any selected XDR installation/Content Hub registration are +separate later component(s), requiring a current verified package and explicit +values for all available package flags. Stored playbook, workbook, connector, +API, or connection definitions do not approve their live instantiation or +enablement. + +Mock-ingestion planning is a separate component. Do not approve it while the +reviewed run-bound payload/route is incomplete or unknown. The complete scope +identifies destination/table and stream, payload hash and record count, +batching limits, cost and retention impact, contract/schema/transform hashes, +marker and timestamp fields, and one shared payload ingested once per AR/CD +pair; benign controls remain separate. DCR/DCE support ingestion of locally +generated mock data, not mock generation. A DCR fallback is conditional on no +compatible existing solution DCR being proven; the current resolver checks +registered exact resource names only and does not discover arbitrary +solution-owned DCRs. DCE consent is separate and requires explicit selection +or evidence it is required by the route. Custom-table creation is conditional +only when the exact contract proves a custom-log table absent and supplies its +schema/path/column count. An incompatible existing table must not be altered. +A compatible native destination such as `CommonSecurityLog` makes table +creation not needed; never create a custom lookalike. + +The current planner can persist advance consent for disabled AR/CD pairs, +package acceptance, mock-ingestion, fallback DCR/DCE, and custom-table +creation only when exact evidence and conditions are satisfied. +`controlledAlertParity` is provisional until a finite test window and complete +batch fixture plan are bound; the current CLI cannot persist parity consent. +`ownedResourceCleanup` is unsupported because there is no exact run-owned +deletion inventory or deletion executor. Do not invent consent component +names or claim those scopes are approved. + +Provisioning DCR/DCE resources, creating a table, ingesting data, deploying +rules/packages, enablement, cleanup/deletion, retention changes, and role or +identity changes remain distinct operation scopes. Obtain explicit final +approval immediately before each bounded write operation or batch. Show all +exact resources/actions and counts in that batch; one approval may cover its +enumerated API requests and need not be repeated per HTTP call. Ask again if +the batch expands. Unknown permission is not granted. Authentication, Entra +admin consent, roles, and permissions are not resource-write approval. + +## Safety contract + +- Never treat workspace discovery as write approval. +- Never generate or deploy a Custom Detection as part of mock-data ingestion. +- Use exactly one rule-specific `mock.json` for both the source Analytic Rule + and converted Custom Detection. +- Ingest that shared payload once. Separate AR and CD payloads are prohibited. +- Group compatible `mock.json` payloads by source table and supported ingestion + path for one-time batch execution; never create a recurring ingestion job. +- Never ingest a heuristic payload merely because it matches the stream schema. +- Require a documented detection hypothesis and reviewed malicious and benign + fixtures for the exact rule. +- A successful ingestion response proves only that Azure accepted the records. + It does not prove table visibility, query matching, alert creation, or parity. +- Block only this optional qualification stage when access is unavailable. + +## Tool + +Run from the Azure-Sentinel repository root: + +```powershell +python Tools\SolutionMigration\ingest_to_dcr.py ` + --target-context "Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json" ` + --stream "" ` + --solution "" ` + --discover-only ` + --login +``` + +This first read-only discovery also runs the target-specific permission +preflight. It verifies: + +- a read-only Log Analytics query against the selected workspace; +- effective DCR access for `Microsoft.Insights/Telemetry/Write`; +- existence and packaged-schema compatibility of the DCR destination custom + table; and +- when that table is absent, effective workspace permission for + `Microsoft.OperationalInsights/workspaces/tables/write`. + +The preflight writes no telemetry and does not grant write approval. If Azure +does not allow effective DCR permissions to be inspected, report ingestion +permission as `unknown` rather than treating it as granted. + +During Qualification, `--target-context` is mandatory. It disables workspace +selection and verifies the active Azure tenant, subscription, workspace ARM +ID, and customer ID before DCR inspection. + +## Prerequisites + +Report missing requirements with the exact blocked capability: + +- Azure CLI authentication: run `az login`. +- Workspace and DCR discovery: Azure Resource Graph read access. +- Logs ingestion: `Microsoft.Insights/Telemetry/Write`, commonly granted by + `Monitoring Metrics Publisher` on the DCR. +- Runtime query validation: workspace query permission, commonly granted by + `Log Analytics Reader`. + +Common roles are examples, not requirements; equivalent custom roles are valid. +After displaying the preflight results, present these user actions: + +1. **Continue** — when all required checks are ready, proceed to fixture review + and the separate exact-scope write-approval step. When a check is blocked or + unknown, continue only in offline mode and skip live ingestion. +2. **Retry permission check** — rerun the same `--discover-only` command after + the user changes authentication, role assignments, workspace, or DCR. +3. **Cancel** — stop optional testing without affecting conversion or static + validation. + +When the only recoverable blocker is a missing custom table, and the solution +package defines the exact table schema, add a fourth action: + +4. **Deploy missing table and retry** — show the exact tenant, subscription, + workspace resource ID, table name, packaged schema path, column count, table + plan, and required permission. Explain that table creation changes the + workspace and may cause ingestion and retention charges. Obtain explicit + approval for this exact workspace and table, then run: + + ```powershell + python Tools\SolutionMigration\ingest_to_dcr.py ` + --target-context "Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json" ` + --stream "" ` + --solution "" ` + --deploy-missing-table ` + --approve-table-write + ``` + +The command may create only a table confirmed absent by the immediately +preceding preflight. It must use the schema from the packaged solution, wait +for provisioning, verify the resulting schema, and rerun the complete +preflight. It must not update an existing table, repair schema differences, +deploy a DCR or DCE, or ingest data. Table approval is separate from +`--approve-write`. The table is a persistent workspace prerequisite and is not +automatically deleted during mock-data cleanup. + +Use structured user elicitation so the actions render as selectable buttons +when the host supports it. Otherwise present the same three choices as a +numbered list and wait for the user's selection. + +Never interpret **Continue** as `--approve-write`. Write approval is requested +later, after the exact payload and cleanup plan are shown. + +## Fixture review + +The optional-testing skill does not invent fixtures during ingestion. It first +checks for a reviewed fixture, then invokes +`sentinel-solution-mock-data-generation` when the fixture is absent. The +generation skill uses public neutral table structures under: + +```text +Sample Data\Tables\\ + base-event.json + metadata.json +``` + +The base event is not malicious and must never be edited for one rule. Copy it +into a rule-specific working scenario, then use both the source Analytic Rule +and converted Custom Detection to determine the decisive predicates, +correlation keys, joins, thresholds, time relationships, projected entities, +and platform-specific field mappings. + +The checked-in base catalog is produced by a one-time maintenance utility. +This runtime flow does not invoke CAT.Tools and does not regenerate the +complete Standard-table catalog. + +When generating a rule-specific scenario: + +1. Identify every source table used by the Analytic Rule and every destination + table used by the Custom Detection. +2. Load each source table's `base-event.json` and `metadata.json`. +3. Copy the base record; never alter the checked-in table file. +4. Set malicious values that satisfy the documented behavior and both query + forms. +5. Set a benign negative control that differs on a decisive predicate. +6. Preserve correlation keys across related records and generate the required + event count for joins, thresholds, sequences, and aggregation. +7. Refresh timestamps to the intended test window. +8. Validate all values against `metadata.json`. +9. If a required table, column, mapping, or defensible negative control is + missing, stop and mark the scenario `manual-review-required`. + +Use these sources, in order: + +1. the selected rule under `Solutions\\Analytic Rules\` or + `Solutions\\Analytics Rules\`; +2. the installed connector's DCR `streamDeclarations`, `dataFlows`, and + `transformKql`; +3. the solution parser and custom-table schema; +4. representative sample data already supplied by the solution, when present; +5. vendor-documented event examples, only after verifying them against the + repository's current stream contract. + +The default ingestion fixture location is: + +```text +Sample Data\Solutions\Mock\\\ + mock.json + scenario.json +``` + +`scenario.json` must record the source rule, converted Custom Detection, +source and destination tables, stream when applicable, hypothesis, shared mock +record count, benign validation-control count, decisive fields, locked correlation paths, +limitations, and separate validation status. Reject the fixture if schema +validation is not `passed`, the mock record count is zero, or the scenario lacks +a defensible negative control. + +First try the default folder. If the requested fixture is absent: + +1. Tell the user the exact path that was checked. +2. Invoke `sentinel-solution-mock-data-generation` with the exact solution, + Analytic Rule, and converted Custom Detection. +3. If safe automatic generation succeeds, review the generated + `scenario.json` and continue. +4. If generation reports `manual-review-required` or `unsupported-payload`, + request a reviewed multi-event scenario or stop the live stage. +5. Only when the fixture is maintained outside the default repository path, + ask the user for the folder containing `mock.json` and + `scenario.json`, then retry with `--mock-data-folder ""`. + +Do not silently search unrelated directories or select another rule's fixture. + +## Automatic agent mode + +When invoked by XDR Solution Manager, this skill owns the complete testing +extension. A user request such as “test this AR and CD” is sufficient. Do not +ask the user to run commands or provide paths already present in the solution, +scenario manifest, qualification target, or generated contract. + +Before fixture preparation, retain the complete recursive source-rule +inventory for accounting and build a no-write plan for the current approved +Qualification scope. For a partial package, the currently applied +evidence-based package-selection plan is authoritative: plan fixtures and +ingestion only for its selected, validated AR/CD pairs. Do not let excluded +rule failures block selected-subset progress, but preserve their identities +and reasons as excluded evidence and never claim full-inventory qualification. +Use the exact plan hash reported by `workflow-status`: + +```powershell +python Tools\SolutionMigration\resolve_ingestion_contracts.py ` + --solution "Solutions\" ` + --target-context "Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json" ` + --selection-plan "Solutions\\Logs\sentinel-xdr-migration\\package-selection-plan.json" ` + --expected-selection-plan-sha256 "" ` + --output "Solutions\\Logs\sentinel-xdr-migration\\\ingestion-contract-plan.json" +``` + +The selected plan must be freshly applied, run-bound, and meet the canonical +strictly-over-50% validated migrated-detection threshold. The planner still +accounts for the full original inventory, marks unselected rules as +not-selected, and records the exact selection-plan hash and scope. It groups +only identical registered table schema/transform contracts and preserves each +selected AR/CD source file and query hash. Unknown or multi-table lineage and +complex scenarios remain blocked for review; the plan neither generates +fixtures nor calls Azure. Before requesting any ingestion approval, rerun it +with the reviewed inventory, target, and selection-plan hashes and a new +`--output` filename so the reviewed plan is preserved. Stop if it returns +`stale-plan`. Then use the +no-write `azure-monitor-logs-ingestion resolve --check-existing` for each +registered table group required by the selected fixtures in the same locked +workspace, recording one report per table. Do not treat either plan as write +approval. A complete package follows the existing full valid inventory path. + +### DCR preference and permission preflight + +The intended selection policy is solution-first: prefer a DCR explicitly +listed or referenced by the solution only when its deployed identity in the +locked workspace and its public input stream, fixture fields, transform, +destination table/schema, endpoint, and permissions are proven compatible +with the exact shared payload. A packaged ARM reference alone does not prove +that the DCR is deployed. Do not confuse its internal identifier with the +public `Custom-*` input stream or enumerate other workspaces. + +If no solution-listed DCR is suitable, a supported registered contract may +produce a separate, isolated DCR configuration authored locally and +provisioned only in the approved non-production target. For Logs Ingestion +API fallback, structured input must use a customer-authored `Custom-*` stream +and `transformKql` to map to a supported `Microsoft-*` output stream. Public +table schema, including the anonymous +[`/v1/metadata` endpoint](https://api.loganalytics.io/v1/metadata), does not +reveal a connector's internal parser/enrichment or by itself prove ingestion +capability; check the documented table feature/capability route and never +assume reserved/service-managed columns are writable. A DCE is required for +private-link ingestion or when the DCR has no ingestion endpoint. The current +contract tool may use an isolated DCE/DCR pair by design, but that does not +make DCE a universal API requirement. “Locally” means locally authored +configuration, not a local ingestion service. Never modify an existing +connector DCR or add a stream to force compatibility. A native table without +a supported ingestion contract must use a documented native telemetry +generator or remain blocked; never redirect its rule to a custom lookalike +table. + +**Implementation status:** The current no-write resolver is contract-specific: +it checks registered table contracts against resource candidates in the locked +workspace, but does not discover or prefer DCRs referenced by the solution. +Its `compatible-existing` result proves only the reported contract +compatibility, not that a solution-listed DCR was selected. Treat the +solution-first preference as planned until that integration is present, and +report only resource identities and checks actually established. + +Before requesting fixture or ingestion approval, show separate results for +telemetry-write, DCR creation, conditional DCE creation, and conditional +destination-table creation permissions. Check DCE creation only when the +selected route includes a DCE. This no-write diagnostic does not provision +resources or authorize a write. An `unknown` or pending-review permission is +not granted. A missing write permission blocks that write only; continue +independent read-only workspace, DCR, schema, and query checks. + +The preflight requires no fixture. Pass `--run-id` when the solution has +multiple runs; omit it only when the run is unique. + +```powershell +sentinel-xdr-migration preflight-deployment ` + --solution "Solutions\" ` + --auth-method azure-cli ` + --run-id "" +``` + +It adds `dcrPermissionPreflight` to the top-level Graph report and writes +`qualification-dcr-preflight.json` under the selected run's +`Solutions\\Logs\sentinel-xdr-migration\\` directory. The +subreport includes `status`, `workspaceQueryPermission`, and per-contract +`resourceReadAccess`, `tableSchemaCompatibility`, `ingestionAuthorization`, +`resourceProvisioningAuthorization`, `tableCreationAuthorization`, +`reportPath`, `targetFingerprint`, `schemaFingerprint`, and +`transformFingerprint`, `contractFingerprint`, `resourceFingerprint`, +`candidateProvenance`, and `candidateSelection`. `contractFingerprint` is the +SHA-256 of the registered serialized contract. `resourceFingerprint` hashes +the exact target workspace, candidate DCE/DCR/table IDs, returned resource +configuration/schema, and current resource states. Provenance is +`registered-contract-resolver`, and candidate selection is +`exact-contract-resource-names-only`. +`resourceProvisioningAuthorization` distinguishes +`dcrWrite` and `dceWrite` at the exact resource-group scope; DCE write is +checked only when the missing-pair plan includes DCE provisioning. + +Per-capability authorization statuses are `ready`, `denied`, `unknown`, +`not-yet-verifiable`, or `not-needed`; table schema status is `compatible`, +`blocked`, or `unknown`; workspace query permission is `ready`, `denied`, +`unknown`, or `not-checked`; aggregate report status is `ready`, +`action-required`, `blocked`, `unknown`, or `not-applicable`. +`resourceReadAccess` is `ready`, `denied`, `unknown`, or `unavailable` +(including HTTP 401); `targetVerification` is `blocked`, `unavailable`, +`confirmed`, or `not-checked`. A `ready` permission only means the effective +permission pattern was inspected, not upload acceptance, table visibility, or +query matching. Keep each status distinct and do not convert unknown or +pending review into permission. + +For each selected pair: + +1. Resolve the exact source AR and converted CD. +2. Reuse or generate the one shared rule-specific mock. +3. Read `scenario.ingestion.contract` when present. +4. Apply the locked workspace ARM ID with the ingestion tool's `--workspace` + override. Never write the workspace into the checked-in contract. +5. Resolve the registered table using the no-write command above. Verify + whether the compatible resource is the solution-listed DCR; do not infer + that association from the contract resolver alone. If provisioning is + needed, after separate exact-scope approval provision or reuse the isolated + contract-specific DCE/DCR used by the current tool route, ingest once, and + verify visibility. Do not interpret this implementation choice as a + universal DCE requirement. +6. Execute both exact queries and require non-zero semantic row parity scoped + to the current fixture timestamps and unique values. +7. Start alert parity only after row parity passes. +8. Persist evidence and perform cleanup without requiring the user to assemble + intermediate files. + +Reviewed Standard-table contracts currently registered under +`Tools\AzureMonitorLogsIngestion\registry.yaml` are eligible for a +no-write resolution plan. Tables without a registered contract remain offline +or native-telemetry blocked. The registry requires a reviewed transform and +documented Logs Ingestion API route; table presence in the public metadata +catalog alone does not prove writable support. + +Before any standard-table provisioning or ingestion, resolve the exact +registered table against the locked workspace and inspect only its +contract-specific resource candidates: + +```powershell +azure-monitor-logs-ingestion resolve ` + --table "" ` + --workspace "" ` + --check-existing ` + --report "Solutions\\Logs\sentinel-xdr-migration\\\ingestion-resolution.json" +``` + +This command is read-only. Continue only when its output is either +`compatible-existing` or `provisioning-plan`, and review its schema and +transform fingerprints, target workspace, and references alongside +`scenario.ingestion.contractResolution`. Keep the report with the run +artifacts. A mismatch, partial DCE/DCR pair, or missing/incompatible +destination table blocks the live stage pending review. A provisioning plan is +not write approval. Obtain the existing exact-scope approval before invoking +`run`; the resolver never creates resources. + +Before requesting approval: + +1. Read `scenario.json`, the source analytic rule, and connector DCR transform. +2. Confirm the solution, rule ID, rule name, stream, and output stream agree. +3. Verify every raw fixture field and value conforms to the selected input stream. +4. Verify transform-derived fields, parser mappings, timestamps, and correlation + keys. +5. Verify the shared mock payload is expected to match both the AR and CD, and + verify the non-ingested benign validation control is expected not to match. +6. Treat seeded Sentinel/AH harness results only as logical query evidence; they + do not prove live visibility or alert parity. +7. Do not auto-ingest single-row heuristic fixtures for joins, thresholds, + aggregates, historical baselines, watchlists, anomaly logic, or + absence-of-data rules. Build a purpose-specific scenario or mark the runtime + test unsupported. +8. Require `generationStatus=qualification-ready`, + `validation.schemaValidation=passed`, and + `ingestion.directLogsIngestionSupported=true`. +9. Before enabling either rule, prove that the exact AR and CD queries return + the same uniquely marked rows from the single shared ingestion. +10. When a native table does not support synthetic Logs Ingestion, use only a + documented native telemetry generator. Never copy the records to a custom + lookalike table because the deployed AR/CD queries would not read it. + +## Approval and ingestion + +Show the exact tenant, subscription, workspace, workspace resource ID, DCR, +stream, payload path, record count, affected table, rule, and cleanup plan. +Obtain explicit user approval for that exact scope. + +Only after approval, run: + +```powershell +python Tools\SolutionMigration\ingest_to_dcr.py ` + --target-context "Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json" ` + --stream "" ` + --solution "" ` + --rule-id "" ` + --approve-write +``` + +For a generated registered Standard-table contract, provision and ingest with: + +```powershell +azure-monitor-logs-ingestion run ` + --contract "\ingestion-contract.yaml" ` + --workspace "" ` + --payload "\mock.json" ` + --report "" +``` + +The `--workspace` value must come from `qualification-target.json`. + +Do not pass `--approve-write` before the user approves. +Do not infer or request hidden service-side KQL transformations from the Log +Analytics query SDK: it executes queries, while the DCR's customer-visible +`transformKql` is validated as exact ARM resource configuration. + +When the default fixture is not present, use the folder supplied by the user: + +```powershell +python Tools\SolutionMigration\ingest_to_dcr.py ` + --target-context "Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json" ` + --stream "" ` + --solution "" ` + --rule-id "" ` + --mock-data-folder "" ` + --approve-write +``` + +## Validation + +After Azure accepts the payload: + +1. Wait for ingestion visibility and query the destination table for unique + fixture identifiers. +2. Run the exact analytic-rule KQL over the intended time window. +3. Run the benign validation control separately without treating it as another + live ingestion payload. +4. Record expected and observed row counts, timestamps, entities, and errors. +5. If alert validation was approved, verify alert creation separately. +6. Perform and verify the stated cleanup. + +Store evidence under the solution's ignored migration `Logs` directory. Mark +results as `accepted`, `visible`, `query-matched`, and `alert-created` +independently; never collapse them into a single success status. + +`Solutions\\XDR Detections\` contains detection-shaped YAML files +only. Write scenario manifests, migration state, DCR discovery, ingestion +receipts, query results, parity captures, cleanup evidence, JSON, and HTML +artifacts under: + +```text +Solutions\\Logs\sentinel-xdr-migration\\\ +``` diff --git a/.github/skills/sentinel-xdr-alert-parity-validator/SKILL.md b/.github/skills/sentinel-xdr-alert-parity-validator/SKILL.md new file mode 100644 index 00000000000..119a8ebf5c6 --- /dev/null +++ b/.github/skills/sentinel-xdr-alert-parity-validator/SKILL.md @@ -0,0 +1,158 @@ +--- +name: sentinel-xdr-alert-parity-validator +description: Perform strict lab-only parity validation between a Microsoft Sentinel Analytic Rule and its migrated Defender XDR Custom Detection. +--- + +# Validate Analytic Rule and Custom Detection alert parity + +Use this skill only in a non-production lab after conversion, structural +validation, deployment, and mock-data review have succeeded. + +## Acceptance criteria + +Parity is strict. For every selected rule, require: + +- the same number of alerts; +- the same normalized scenario match keys; +- the same severity and tactics; +- the same normalized entities; +- the same decisive matched-event evidence; and +- no alert from the benign control records. + +Any mismatch fails migration validation. Do not silently accept differences +caused by schedule, lookback, grouping, suppression, thresholds, or entity +mapping. Align those settings or report an explicit platform limitation. + +## Safety and lifecycle + +- Both rules must be disabled before the test starts. +- Require a unique marker that occurs in the reviewed mock payload. +- Enable only explicitly selected converted rules. +- Never use production telemetry or a production workspace. +- Do not wait with fixed sleeps. Start the run, then query when the platforms + expose the alerts. +- Always run `complete-alert-parity` or `abort-alert-parity`; both force every + test rule back to disabled. + +## Full qualification batch + +For internal solution qualification, deploy every reviewed AR/CD pair in the +current approved package selection disabled, then prepare one fixture per +selected detection. A partial package's selected list is authoritative; do not +add excluded detections or let their failures block parity for selected pairs. +Retain the plan hash and selected paths with parity evidence, and describe the +result as selected-subset parity only. Full-inventory parity is not proven by a +partial run. + +```json +{ + "fixtures": [ + { + "detection": "Detection.yaml", + "contract": "contracts/stream.json", + "payload": "mocks/detection.json", + "scenarioMarker": "unique-detection-marker", + "expectedMatchKeys": ["expected-malicious-key"] + } + ] +} +``` + +Paths are resolved relative to the batch-plan file. The plan must cover every selected converted detection exactly once. + +Start the batch: + +```powershell +sentinel-xdr-migration start-alert-parity-batch ` + --solution "" ` + --workspace-resource-id "" ` + --plan "" +``` + +The command verifies all ARs and CDs are disabled, enables all reviewed pairs, +ingests every fixture, and emits a separate capture plan and expected-key set +for each detection. If enablement or any ingestion fails, it disables all +rules before returning the failure. + +## Targeted start + +```powershell +sentinel-xdr-migration start-alert-parity ` + --solution "" ` + --workspace-resource-id "" ` + --contract "" ` + --payload "" ` + --scenario-marker "" ` + --expected-match-key "" ` + --detection "" +``` + +Use the targeted command for troubleshooting a subset or rerunning one +detection. It: + +1. verifies the AR and CD both exist and are disabled; +2. enables both through Azure Resource Manager and Microsoft Graph; +3. ingests the payload through `azure-monitor-logs-ingestion`; +4. writes + `Solutions//Logs/sentinel-xdr-migration//alert-parity-state.json`; and +5. emits Sentinel and Advanced Hunting capture queries. + +## Capture alerts + +Prefer suitable tools advertised by the official Sentinel Triage MCP for both +capture queries. Fall back to Log Analytics CLI/API for `SecurityAlert` and +Microsoft Graph for `AlertInfo`/`AlertEvidence` only for provider-level +failures. A genuine KQL error is not a reason to change providers. + +Normalize each platform result into: + +```json +{ + "results": [ + { + "detection": "Detection.yaml", + "analyticRule": { + "alerts": [ + { + "matchKey": "stable-event-or-correlation-key", + "severity": "high", + "tactics": ["Exfiltration"], + "entities": {}, + "evidence": {} + } + ] + }, + "customDetection": { + "alerts": [] + } + } + ] +} +``` + +Platform alert IDs are not comparable. `matchKey` must be derived from stable +mock-event or aggregation keys. Normalize entity names and evidence fields to +the same semantic shape before comparison; never remove a differing value to +manufacture parity. + +The declared expected match-key set is authoritative. If both platforms alert +on the same benign control key, parity still fails. Repeat +`--expected-match-key` when multiple malicious alerts are expected; when it is +omitted, the scenario marker is the sole expected key. + +## Complete or abort + +```powershell +sentinel-xdr-migration complete-alert-parity ` + --solution "" ` + --results "" +``` + +This writes `alert-parity-report.json` and disables all selected rules even +when parsing or comparison fails. + +If capture cannot be completed: + +```powershell +sentinel-xdr-migration abort-alert-parity --solution "" +``` diff --git a/.github/skills/sentinel-xdr-detection-validator/SKILL.md b/.github/skills/sentinel-xdr-detection-validator/SKILL.md new file mode 100644 index 00000000000..fce1faca206 --- /dev/null +++ b/.github/skills/sentinel-xdr-detection-validator/SKILL.md @@ -0,0 +1,244 @@ +--- +name: sentinel-xdr-detection-validator +description: Validate generated XDR Detection YAML structurally and validate source and converted KQL through official Microsoft Sentinel Triage MCP tools first, with direct API fallback. +--- + +# Validate XDR Detection YAML + +Use the `sentinel-xdr-migration` CLI for deterministic planning, structural +validation, fallback execution, and reporting. Runtime query execution prefers: + +- Microsoft Sentinel Triage MCP for every query operation supported by the + connected server: + `https://sentinel.microsoft.com/mcp/triage`. +- Native Azure Monitor Query SDK for complete original Sentinel-query + execution after a Triage provider failure, or when the CLI path is explicitly + selected. +- Log Analytics CLI/API results remain importable as a legacy original + Sentinel-query fallback. +- Microsoft Graph Advanced Hunting through the CLI as the Advanced Hunting + fallback. + +## Provider fallback policy + +Inspect the connected Triage MCP tools and use a suitable advertised tool +first. Do not invent a tool name or assume a capability. Fall back to the +corresponding direct provider only when Triage cannot act as a provider, +including: + +- the MCP server or the required query tool is unavailable; +- authentication, consent, or permission fails; +- the request times out or the MCP service returns a transient/provider error; +- a provider failure prevents completion of the full solution batch. + +Original Sentinel queries fall back to the Azure Monitor Query SDK (or imported +legacy Log Analytics CLI/API results). Advanced Hunting queries fall back to +Graph. Do not fall back when Triage successfully executes the +request and returns a real KQL semantic/runtime error. That is a detection +failure, not an MCP failure. Do not combine providers within one query-family +report; if Triage fails mid-batch, rerun that complete batch through its direct +provider. + +Before any live query, confirm the exact persisted Log Analytics workspace +and/or Defender tenant, and verify the required read permissions for that +provider. Never choose another workspace to recover from a failure. Do not +launch authentication without first asking. Structural validation, runtime +execution, schema binding, and qualification are separate outcomes; a provider +fallback or successful query never marks a rule validated or proves parity. + +For partial publication, do not weaken this rule-level evidence contract. +`package-selection-plan` counts only unique, current source IDs represented by +eligible deployable Custom Detections against the complete original Sentinel +rule inventory; the partial threshold is strictly over 50%. Qualification may +use only the fresh applied selected subset and must keep excluded item failures +visible. Never relabel a partial selected-query result as full-inventory +Validation or Qualification. + +## Workflow + +1. Run `sentinel-xdr-migration doctor`. +2. On first run, run `sentinel-xdr-migration setup --non-interactive`. +3. Ask before launching interactive authentication. Never block offline conversion. +4. Run: + + ```powershell + sentinel-xdr-migration validate --solution "" + ``` + +5. Stop if structural errors remain. Each detection requires its own top-level + `version` (`major.minor.patch`) in `[3.1.0, 4.0.0)`, separate from + `schemaVersion: 1.0.0`. Source Sentinel `1.x` versions remain valid provenance + and must not be promoted to satisfy this check. For older files without the + XDR version, report the reconversion guidance rather than changing provenance + or accepting Data/solution version fallbacks. +6. Generate the complete runtime plan: + + ```powershell + sentinel-xdr-migration validation-plan --solution "" + ``` + + Use advertised official Triage MCP tools first for every supported + `sentinelQuery` and `advancedHuntingQuery`. For Advanced Hunting, invoke + `RunAdvancedHuntingQuery`. If the connected MCP does not advertise a suitable original Sentinel + workspace-query tool, either run the toolkit's native SDK provider for the + full family or use the legacy Log Analytics CLI/API import path without + treating that capability gap as a KQL failure. + + The native source-query runner requires an existing Qualification run and + its locked target. It verifies the active Azure CLI tenant and subscription + without signing in, sends each exact persisted source KQL string directly to + `LogsQueryClient.query_workspace(timespan=None)`, and records full returned + schemas and rows in the ignored run-local SDK report: + + ```powershell + sentinel-xdr-migration validate-sentinel-sdk ` + --solution "" ` + --run-id "" + ``` + + Defaults bound each request to 90 seconds and the complete source family to + 30 minutes, with at most two transient retries per query. The complete + recursive source inventory must map one-to-one to current generated + detections. Authentication, authorization, query, transport and partial + result failures are retained distinctly. Zero returned rows indicate query + execution only, not parity. + + Normalize Advanced Hunting results into a JSON file containing exactly one + entry per detection and the returned target-query output `schema` array when + custom details are present. Copy `querySha256` from the matching + `advancedHuntingQuerySha256` in the runtime plan; it must hash the exact KQL + sent to the provider: + + ```json + { + "results": [ + { + "detection": "Example.yaml", + "status": "passed", + "statusCode": 200, + "rowCount": 0, + "querySha256": "", + "schema": [{"Name": "DeviceId"}, {"Name": "Timestamp"}], + "error": null + } + ] + } + ``` + + Allowed statuses are `passed`, `failed`, and `blocked`. Then record the + provider results: + + ```powershell + sentinel-xdr-migration record-runtime-validation ` + --solution "" ` + --provider triage-mcp ` + --results "" + ``` + +7. If Triage cannot complete an entire query family for a provider-level + reason, create a run-local `provider-gap-.json` recording the + advertised tool name (or `unavailable`), exact target reference, one + approved reason code (`tool-unavailable`, `authentication`, `authorization`, + `timeout`, `transient-provider-error`, or `batch-provider-failure`), a + concise reason, `batchComplete: true`, `scopeVerified: true`, + `permissionsVerified: true`, and `kqlError: false`. Do not use a gap record + for a real KQL error. The CLI rejects incomplete, cross-family, wrong-provider, + or unscoped fallback evidence. + + For original Sentinel queries, rerun that **complete family** against the + exact locked workspace. Prefer the native SDK command above; pass the + run-local provider-gap evidence if Triage failed: + + ```powershell + sentinel-xdr-migration validate-sentinel-sdk ` + --solution "" ` + --run-id "" ` + --provider-gap-evidence "" + ``` + + For externally imported provider results, normalize one result per + detection and include `querySha256` from that rule's + `sentinelQuerySha256` plan entry, then record it with the gap evidence: + + ```powershell + sentinel-xdr-migration record-runtime-validation ` + --solution "" ` + --provider log-analytics-cli ` + --results "" ` + --provider-gap-evidence "" + ``` + + If and only if Triage has an Advanced Hunting provider-level failure, run + the complete Advanced Hunting batch through Graph with the same kind of + run-local evidence: + + ```powershell + sentinel-xdr-migration validate-advanced-hunting ` + --solution "" ` + --provider-gap-evidence "" + ``` + +8. Runtime providers create JSON and self-contained HTML reports under + `Solutions\\Logs\sentinel-xdr-migration\`. Treat `blocked` as an + environment gap, not a conversion failure. +9. For every rule: + - execute the original `sentinelQuery`; + - execute the `advancedHuntingQuery`; + - record whether each query binds and executes; + - compare returned entity columns when representative rows exist. + Imported `passed` Sentinel results without a matching `querySha256` are + blocked. Graph results bind automatically to the exact Advanced Hunting + query executed by the validator. A query rewrite invalidates old evidence; + rerun the complete Sentinel and Advanced Hunting families for the current + artifact before the workflow can pass runtime validation. +10. Zero rows are not proof of behavioral parity. Record them as execution + success with data validation still pending. A table probe that executes and + returns zero rows proves the table binds on that query surface. +11. Do not change `contentProvenance.conversion.status` to `validated` unless + both queries execute and the entity output has been reviewed. + +## Query-surface isolation + +Defender Advanced Hunting and Sentinel Log Analytics are separate query +surfaces even when the Defender portal shows a selected Sentinel workspace. +A table name resolving in Advanced Hunting does not prove that the same name +resolves through the Log Analytics workspace API, and vice versa. + +- Probe the original `sentinelQuery` only against the exact persisted + Log Analytics workspace. +- Probe the converted `advancedHuntingQuery` only through Defender Advanced + Hunting. +- Never use workspace table-management inventory as query-availability proof. +- Never scan other workspaces after the workflow workspace is selected. +- Report the surface explicitly, for example: + `IdentityInfo unavailable through log-analytics-workspace; available through + defender-advanced-hunting`. +- Do not say a table is unavailable "in every accessible workspace" unless the + user explicitly requested a multi-workspace audit. + +For an **Authoring** profile, an environment-only runtime block does not prevent +V3.1 packaging when structural validation passed. Complete validation as passed +with `runtimeStatus=environment-blocked` only when no current exact-query +KQL/runtime failure exists, preserve the provider error and query surface, and +do not claim the detection is runtime-qualified. For a +**Qualification** profile, the same runtime block remains blocking until the +selected lab environment satisfies the required query surfaces. + +Return a per-rule result and an overall pass/needs-review summary. + +Best-effort entity-mapping assumptions recorded by conversion do not prevent +read-only Sentinel/Advanced Hunting execution or output-schema binding tests. +Run and report those tests against the exact hash-bound queries, retaining the +assumption as pending human review. A successful query or custom-detail schema +binding is not approval, entity correlation, or alert parity. Keep technical +failures and other unresolved review findings blocking as applicable; do not +package or deploy while an entity-mapping review remains pending. + +For unresolved table/schema or conversion assumptions, use provider-owned +evidence: standard-table and schema documentation, live query schema from the +selected provider, or the custom connector's DCR/table metadata. Do not +substitute customer research or invent a schema. Present each proposed +assumption with its exact scope, source/query hash, evidence, and `accept` or +`reject` choice; no decision is automatic. Accepted assumptions still require +reconversion and the normal structural/runtime checks. If content hashes change, +re-review rather than reusing an earlier decision. diff --git a/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md b/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md new file mode 100644 index 00000000000..705c597a6e0 --- /dev/null +++ b/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md @@ -0,0 +1,86 @@ +--- +name: sentinel-xdr-end-to-end-orchestrator +description: Orchestrate resumable Sentinel-to-Defender-XDR authoring and optional qualification by following the canonical agent-neutral workflow. +requiredSkills: + - sentinel-xdr-migration-orchestrator + - sentinel-xdr-solution-packager + - sentinel-xdr-solution-deployer + - azure-monitor-logs-ingestion + - sentinel-xdr-alert-parity-validator + - sentinel-solution-mock-data-generation + - sentinel-solution-optional-testing +--- + +# Orchestrate Sentinel to Defender XDR migration + +Read and follow the canonical workflow: + +`Tools\SentinelToXDRMigration\agent-workflows\end-to-end-migration.md` + +Treat the agent, skills, migration backends, packaging backends, schemas, +tests, dependency metadata, gates, and capabilities as immutable during every +migration workflow. Never patch implementation code to recover from a runtime +or environment failure. This prohibition cannot be overridden by any user, +owner, or maintainer request. Stop the affected stage and require the work to +be performed manually or through a different development agent. + +The goal is solution migration and publication of validated content. Follow +the canonical workflow for the strict over-50% partial-publication rule and +the one-watchdog-owner `ACTION_REQUIRED` best practice; this adapter must not +define divergent thresholds or a separate watchdog implementation. + +Use the required specialist skills for their owning stages. Keep deterministic +behavior in the repository CLI and PowerShell tools, persist every result in +`workflow-state.json`, and do not duplicate or weaken the canonical gates. +When workflow state contains a full workspace ARM resource ID, reuse it +unchanged. Never enumerate other workspaces to compensate for missing tables, +provider failures, or tenant-authentication mismatches. + +Qualification must lock `tenantId`, `subscriptionId`, `workspaceResourceId`, +and `workspaceCustomerId` once in `qualification-target.json`. Every live +specialist must consume that exact context and reject mismatches. Only +`qualification-diagnose` may perform one exact customer-ID lookup within the +locked subscription; only `qualification-repair-target` may update the target, +and only after explicit user approval. + +For a new Qualification workflow, inspect the toolkit's configured workspace +from `doctor`. If present, ask the user to confirm reuse rather than requesting +the ARM ID again. Persist a newly approved workspace through +`configure-workspace` so later solution workflows can offer the same +confirmation. + +Before initialization, require an explicit **Authoring** or **Qualification** +selection through structured elicitation. There is no default. If selection is +declined or cancelled, do not create workflow state. A test tenant or workspace +does not imply qualification consent. Require explicit approval immediately +before deployment or ingestion writes. + +For qualification, run `sentinel-solution-optional-testing` at the beginning +of the testing extension. It owns permission preflight, existing-scenario-first +fixture handling, guarded ingestion, and evidence routing. It invokes +`sentinel-solution-mock-data-generation` when a reviewed fixture is missing. +Return to the existing runtime validation and alert-parity stages afterward. + +Qualification must use one shared, rule-specific `mock.json` for the source +Analytic Rule and converted Custom Detection. Ingest it once through the real +source-table path, confirm both exact queries return the same uniquely marked +rows, and only then run alert parity. Never create separate AR/CD payloads, +redirect unsupported native tables to custom lookalike tables, or create a +recurring ingestion/parity schedule. + +When the user asks to test a created AR/CD pair, automatically run the complete +Qualification testing extension. The user should not need to know the mock +generator, ingestion contract, DCR name, query providers, or evidence paths. +Resolve those from the pair provenance and locked target. Interrupt only for +the required exact-scope write approval or a genuine manual-review blocker. + +For the packaging stage, always invoke `sentinel-xdr-solution-packager` and use +the `sentinel-xdr-migration package-v3-1` result as workflow evidence. Existing +files under `Package` do not prove that V3.1 ran during the current workflow. +Do not relabel old V4 reports; rebuild to produce V3.1 evidence. + +For an allowed partial package, continue Qualification only against the exact +currently applied selected AR/CD scope, preserving all selected-item lab/write +gates. Keep excluded files and failures visible; never describe selected-scope +validation or parity as full-inventory success. Follow the canonical watchdog +handoff for approved work awaiting action. diff --git a/.github/skills/sentinel-xdr-migration-orchestrator/SKILL.md b/.github/skills/sentinel-xdr-migration-orchestrator/SKILL.md new file mode 100644 index 00000000000..70a10dc1b5a --- /dev/null +++ b/.github/skills/sentinel-xdr-migration-orchestrator/SKILL.md @@ -0,0 +1,63 @@ +--- +name: sentinel-xdr-migration-orchestrator +description: Orchestrate conversion of a Microsoft Sentinel solution into Defender XDR Custom Detection YAML by using repository utilities and available runtime providers. +requiredSkills: + - sentinel-xdr-rule-converter + - sentinel-xdr-detection-validator +--- + +# Migrate a Microsoft Sentinel solution to XDR Detection YAML + +The goal is to migrate the solution and support publishing validated content. +Follow the canonical workflow's strict over-50% partial-publication policy and +single-owner `ACTION_REQUIRED` watchdog best practice; do not reproduce those +policies here. + +## Scope + +This milestone creates and validates `XDR Detections/*.yaml`. + +It does not: + +- generate or modify ARM templates; +- deploy analytic rules or Custom Detections; +- generate or ingest mock data; +- convert workbooks; +- compare generated alerts. + +## Workflow + +1. If the package is unavailable, install it from the repository: + + ```powershell + python -m pip install -e Tools\SentinelToXDRMigration + ``` + +2. Run `sentinel-xdr-migration doctor`. +3. If `firstRun` is true, run + `sentinel-xdr-migration setup --non-interactive`. +4. Inspect the official Triage MCP capabilities. Prefer its advertised tools + for every supported runtime query operation, including + `RunAdvancedHuntingQuery`. This check is separate from `doctor`, because MCP + authentication belongs to the agent host rather than the Python CLI. +5. Ask before launching interactive CLI or MCP sign-in. Continue with offline + conversion if authentication is declined or unavailable. +6. Confirm the solution path. +7. Use `sentinel-xdr-rule-converter`; conversion always runs locally and does + not depend on MCP. +8. Review every `needsReview` result. Never present it as XDR-ready. +9. Use `sentinel-xdr-detection-validator`. Prefer Triage MCP for supported + original Sentinel and Advanced Hunting queries. Fall back to Log Analytics + CLI/API for Sentinel queries and the Graph CLI for Advanced Hunting only + when the MCP lacks the capability or has a provider-level failure, never + for genuine KQL failures. +10. Report: + - source analytic rules; + - generated detection files; + - deterministic rewrites; + - provenance status; + - Sentinel and Advanced Hunting execution status; + - unresolved parity gaps. + +An XDR-ready result requires valid YAML, no conversion errors, successful +runtime execution on both platforms, and reviewed entity mappings. diff --git a/.github/skills/sentinel-xdr-rule-converter/SKILL.md b/.github/skills/sentinel-xdr-rule-converter/SKILL.md new file mode 100644 index 00000000000..0582ddace5c --- /dev/null +++ b/.github/skills/sentinel-xdr-rule-converter/SKILL.md @@ -0,0 +1,92 @@ +--- +name: sentinel-xdr-rule-converter +description: Convert every Microsoft Sentinel analytic rule in a solution into a Defender XDR Custom Detection YAML under the solution's XDR Detections folder. +--- + +# Convert Sentinel analytic rules to XDR Detection YAML + +Run the CLI from the repository root: + +```powershell +python -m sentinel_xdr_migration.cli +``` + +If the package is not installed, first run: + +```powershell +python -m pip install -e Tools\SentinelToXDRMigration +``` + +## Inputs + +- The path to one solution folder under `Solutions/`. +- Optional table, function, and column mappings. + +## Workflow + +1. Run `sentinel-xdr-migration doctor`. +2. If `firstRun` is true, run + `sentinel-xdr-migration setup --non-interactive`. Continue offline when + authentication is unavailable. +3. Run: + + ```powershell + sentinel-xdr-migration inspect --solution "" + ``` + +4. Review the analytic-rule count and source paths. +5. If deterministic rewrites are needed, create + `Solutions//Logs/sentinel-xdr-migration//migration-config.yaml`. +6. Run: + + ```powershell + sentinel-xdr-migration convert --solution "" + ``` + +7. Do not overwrite conflicting generated files without explicit approval. +8. Report: + - converted count; + - needs-review count; + - conflicts; + - every warning and error; + - the generated + `Solutions//Logs/sentinel-xdr-migration//transformation-report.html` + path. + +## Best-effort entity mapping + +Do not stop a batch or ask for a per-rule decision solely because a Sentinel +entity identifier has no exact Defender XDR mapping. Select the best supported +mapping from the source identifiers and columns actually emitted by the query, +preserve the original value as an alert `customDetails` output-column binding, +and mark the assumption for human review in conversion provenance. For example, +an available `Host.FullName` may be provisionally mapped to the supported +`hosts.nameColumn`; keep the original output column in custom details and +explicitly state that this is an assumption, not proven host identity or parity. +Prefer a directly supported matching identifier when present; never invent a +projected column, unsupported Graph field, or data transformation. + +Best-effort entity review is nonblocking for conversion, structural validation, +and read-only runtime query/schema tests, so large batches can continue without +per-rule pauses. It remains visibly pending and hash-bound to the source and +converted query. It is not an acceptance or review decision and does not clear +other findings. Missing/projected-away values, conflicting bindings, custom +detail limits, malformed content, or other technical/KQL failures retain their +normal errors and gates. Do not package or deploy content with unresolved +review flags; query execution and preserved custom details do not prove entity +correlation or alert parity. + +The converter must not modify `Analytic Rules` or `Package/mainTemplate.json`. +Generated detections must remain disabled. +`XDR Detections` must contain detection YAML only. + +New detections receive an independent top-level `version: 3.1.0`; source +Sentinel versions remain unchanged in `contentProvenance.source.version`. +The XDR release must be a `major.minor.patch` string in `[3.1.0, 4.0.0)`. +Regeneration preserves a valid existing XDR version for the same source rule, +including with `--overwrite`. Invalid versions or mismatched source identities +are conflicts and must not be silently reset. Older YAML without its own version +fails validation/packaging; after reviewing manual edits and obtaining overwrite +approval, reconvert with `--overwrite` to initialize `3.1.0`, or explicitly author +the independent XDR version. Do not change schema/API versions or source AR +versions to satisfy the XDR release bounds. diff --git a/.github/skills/sentinel-xdr-solution-deployer/SKILL.md b/.github/skills/sentinel-xdr-solution-deployer/SKILL.md new file mode 100644 index 00000000000..ec7ba600795 --- /dev/null +++ b/.github/skills/sentinel-xdr-solution-deployer/SKILL.md @@ -0,0 +1,198 @@ +--- +name: sentinel-xdr-solution-deployer +description: Deploy and verify Sentinel Analytic Rules and Defender XDR Custom Detections disabled in an approved non-production environment. +--- + +# Deploy Sentinel and XDR detections safely + +Use this skill only after full validation and packaging have passed, or after +the canonical workflow has accepted a fresh, applied partial package meeting +the strict over-50% threshold. For a partial package, deploy only the exact +selected AR/CD content bound by its current package-selection plan; excluded +items remain visible but are not deployment failures for that selected scope. +Deployment is a write operation and always requires explicit user approval for +the exact subscription, resource group, workspace, solution, and deployment +mode. + +## Modes + +### One early scope-consent request + +After the Qualification target is locked and before Discovery, review the +current local scope plan and ask once for consent to the bounded potential +action groups. Show the exact target, solution/version, inventory counts, +eligible IDs and caps, current package identity, resource types, and which +conditional prerequisites are proven, absent, or unknown. Include: + +1. disabled Sentinel alert-rule and Graph Custom Detection creation or + reviewed updates; +2. conditional fallback DCR/DCE creation; +3. conditional creation of a proven-absent custom-log table; +4. reviewed mock telemetry ingestion, including payload, records/batches, + stream, estimated bytes/cost, and retention impact; +5. controlled alert parity, with exact pairs, proposed time window, bounded + enable/restore operations, and abort restoration; +6. full-package installation and selected XDR Content Hub registration; and +7. explicitly owned cleanup/deletion, listing exact resources and ownership + evidence while excluding pre-existing or unowned resources. + +Show a structured consent form with **Approve the broadest currently bounded +and available scope** preselected as the suggested default, plus **Select +components**, **Revise the plan**, and **Decline advance consent**. This default +is only a proposal. Display each proposed component, cap, exact target, +resource family, and package flag/value before asking the user to submit. +Require affirmative submission/confirmation of the displayed scope before +recording consent; a preselected choice alone, cancellation/closure, decline, +or missing response records no approval. Permit narrowing, revision, or +decline. + +The suggested broad scope includes only components supported by the current +consent CLI and fully bound to current evidence. If available, it can include +the disabled Sentinel-AR/Graph-CD pair and separate later +`fullPackageAcceptance`. For package acceptance, show every exact current +package-selection flag and its boolean value. If the plan exposes five valid +flags and the broad proposal selects all five, list each flag by name with +`true`; never hide selections behind an “all” label or invent flags. Do not +silently include unknown/provisional ingestion, DCR/DCE, custom-table, +parity, or cleanup scopes. If the broadest scope cannot be fully bound, suggest +only the largest valid bounded subset and explicitly list unavailable or +excluded groups with reasons. + +Persist only the exact, affirmatively submitted component and flag selection +after verifying it against the current plan. Ask again only if the target, +plan identity, action/resource families, payload, caps, package flags, or +cleanup ownership changes. Missing or unknown scope is provisional, not +unlimited; denial does not block independent read-only validation. + +The current CLI can persist consent for the direct disabled AR/CD pair, +`fullPackageAcceptance`, `mockTelemetryIngestion`, `fallbackDcr`, +`fallbackDce`, and `customTableCreate`. `controlledAlertParity` is planning +metadata only until a finite test window and complete batch fixture plan are +bound; `ownedResourceCleanup` is unsupported because there is no exact +run-owned deletion inventory or deletion executor. Do not invent CLI +component names or represent either as consented. Previous narrow DCR/DCE +consent does not imply ingestion, parity enablement, package acceptance, or +cleanup consent. + +Authentication, Entra admin consent, role assignments, and permission +readiness are separate from both advance planning consent and final resource +write approval. None implies another. + +### Package acceptance + +Validate and optionally deploy `Package\mainTemplate.json` as a complete +solution package. For a partial package, V3.1 has already restricted that +template to the validated selected content; never repopulate it by scanning +excluded YAML. + +- `DeployAnalyticsRule=true` selects Sentinel Analytic Rule content. +- `DeployCustomDetection=true` selects both Defender XDR Custom Detection + installation and Content Hub registration, independently of Sentinel content. + Both selections may be true. Use only parameters declared by the package. + +Use ARM validation or preview only when supported for the selected resource +types and deployment features. Bicep extensibility imports do not support +`what-if` ([Microsoft Learn: unsupported deployment +features](https://learn.microsoft.com/en-us/graph/templates/limitations#unsupported-deployment-features)); +do not pass or wait for a `what-if` result for that route. An ordinary ARM +preview cannot prove extension acceptance, and ARM validation does not validate +extension behavior. Registration support remains unverified; emitted templates +are not proof of live acceptance. The obsolete `RegisterE5Content` and +`E5Flavor` controls are not declared. Exact-scope approval must explicitly +include registration when selecting CDs. Existing instances are not disabled or +removed by these switches. + +Before the write step, review the latest local Qualification +`deploymentPlanning` scope and explicitly persisted component-specific +planning consent. If the bound plan is unchanged, do not repeat the broad +planning question. Planning consent is not write authority. Immediately before +each bounded write operation or batch, show its exact scope and obtain fresh +explicit approval. A single approval may cover the enumerated resource/API +operations in that batch; it need not be repeated for every HTTP request. +Require a new approval if any operation expands beyond the batch. Missing or +stale planning consent, permissions, or successful preflight does not +substitute for the immediate gate. + +### Repeatable rule deployment in a lab + +Do not require a separate pre-deployment check for whether every Sentinel +Analytic Rule or Custom Detection already exists. For the same reviewed +identities and locked, approved non-production target, the Sentinel command +uses `PUT` on the deterministic alert-rule resource ID to create or update the +rule idempotently for the same ID and payload; rules remain disabled. +The alert-rule-template API is a catalog read surface, not a resource created +by this deployment command. + +The current Graph deployer derives `xdr-` and internally +`GET`s that exact ID, `POST`s only when it is absent, or `PATCH`es when present, +then performs required read-back verification. This is repeatable at the +supported CLI workflow level for the reviewed ID; it is not a guarantee that +arbitrary Graph `POST` calls are idempotent. The separate +`preflight-deployment` exact-rule read is an optional diagnostic, not proof of +ownership or deployment readiness. The Graph deployment command still +requires its own `GET`; the current preflight also performs exact-ID reads and +can block on a denied read. These backend checks cannot be skipped by changing +instructions. Do not bypass them with direct API calls or blindly retry an +ambiguous write. If an existing ID has uncertain ownership or content, stop +for maintainer review rather than overwriting it. + +This repeatability applies only to the exact reviewed solution content in the +locked approved lab, under the existing exact-scope approval, permission, +disabled-state, and verification gates. It does not authorize production +writes, changes to unrelated resources, or overwriting enabled or +unowned content. + +### Alert-parity preparation + +Deploy each selected reviewed pair disabled. For an applied partial package, +the deterministic deployment commands consume the selection-bound source and +Custom Detection lists, not every file in their folders: + +```powershell +sentinel-xdr-migration deploy-analytic-rules ` + --solution "" ` + --workspace-resource-id "" + +sentinel-xdr-migration deploy --solution "" +``` + +The first command writes +`Solutions\\Logs\sentinel-xdr-migration\\deployment.sentinel.json`. The +second writes +`Solutions\\Logs\sentinel-xdr-migration\\deployment.graph.json`. + +## Safety gates + +Before any write: + +1. Confirm the target is a non-production lab. +2. Show the subscription, resource group, workspace, tenant, solution, package + version, mode, exact resource IDs, operation types, and count. +3. Immediately before the bounded operation/batch, require explicit approval + for those exact values and the listed writes. +4. Confirm every Custom Detection YAML has `properties.status: disabled`. +5. Stop on unresolved conversion review, invalid KQL, missing entity mappings, + duplicate IDs, or count mismatches within the exact selected scope. Excluded + item failures remain separately reported and do not expand the deploy set. +6. Never infer permission from Entra Global Administrator. Verify Azure RBAC + and Microsoft Graph Custom Detection scopes separately. +7. Ask before launching interactive authentication. + +## Verification + +Deployment passes only when: + +- every requested ARM or Graph operation succeeds; +- deployed AR and CD IDs match the reviewed source manifest; +- every deployed rule is disabled; +- display names, queries, schedules, severities, tactics, and entity mappings + match the generated artifacts; and +- the deployment reports contain no failed or silently skipped item. + +If Graph read permission is unavailable, report verification as blocked even +when the write deployment reports success. Do not claim live status solely +from the source template. + +Do not enable a rule in this skill. Rule enablement belongs exclusively to +`sentinel-xdr-alert-parity-validator`, which must disable every test rule on +completion or abort. diff --git a/.github/skills/sentinel-xdr-solution-packager/SKILL.md b/.github/skills/sentinel-xdr-solution-packager/SKILL.md new file mode 100644 index 00000000000..e7c52996656 --- /dev/null +++ b/.github/skills/sentinel-xdr-solution-packager/SKILL.md @@ -0,0 +1,179 @@ +--- +name: sentinel-xdr-solution-packager +description: Package a complete Microsoft Sentinel and Defender XDR solution through the V3.1 packaging implementation. +--- + +# Package a Sentinel and Defender XDR solution + +Use this skill after solution content and any `XDR Detections/*.yaml` files are +complete and structurally validated. + +The XDR Solution Manager workflow always runs V3.1. Legacy V3 remains available outside +this workflow for legacy Sentinel-only packaging and intentionally ignores XDR +Detections. Never use V3 to satisfy this workflow's packaging stage. + +The generated package remains a full Microsoft Sentinel solution. When XDR +Detections are declared, it also supports hybrid installation: + +- `DeployAnalyticsRule=true` (default) installs Sentinel Analytic Rule content. +- `DeployCustomDetection=true` (default false) installs Custom Detections and their + Content Hub registrations independently. Both selections may be true. +- Custom Detections are always packaged disabled. + +## Scope + +This skill: + +- validates the solution-data packaging contract; +- selects and applies one semantic version increment; +- runs the supported local packaging entry point; +- verifies the generated template, parameter file, and ZIP; +- checks AR-to-CD matching and hybrid deployment conditions; and +- reports validation failures without hiding or weakening them. + +It does not convert Analytic Rules, create mock data, deploy the generated +template, enable detections, or validate alert parity. + +## Inputs + +- A solution folder under `Solutions\`. +- The solution data folder, normally `Solutions\\Data`. +- The version action: `none`, `patch`, `minor`, or `major`. + +Use `none` for repeat local validation. Before producing a package intended for +submission, ask which release increment to apply. Never bump the version more +than once for the same package attempt. + +V3.1 requires effective solution and XDR content release versions in +`[3.1.0, 4.0.0)`. Other content release versions must be below `4.0.0`; their +legacy lower versions remain valid. Out-of-range versions are hard errors, not +automatic promotions. A major bump from `3.x` is therefore rejected. +XDR requires its own top-level YAML `version` (`major.minor.patch`); new +conversions initialize it to `3.1.0` independently of the Sentinel source. +Registration metadata and content product IDs use that value. This check +applies regardless of deployment selection. Data `XDR Detection Version`, solution +version, and source provenance are not fallbacks. Older files missing the +version must be explicitly reconverted with `--overwrite` after reviewing +manual edits, or have their independent XDR version authored. Never falsify +`contentProvenance.source.version` or bump the source AR to satisfy the XDR floor. +Solution version bumps do not change per-detection versions. +These bounds do not apply to API, schema, or extension-provider versions. + +## XDR packaging contract + +When the solution contains Custom Detections, confirm its solution-data JSON +contains: + +```json +{ + "Analytic Rules": [], + "XDR Detections": [] +} +``` + +For every XDR Detection YAML, require: + +- `kind: CustomDetection`; +- top-level `version` in `[3.1.0, 4.0.0)`, separate from `schemaVersion`; +- `resourceType: Microsoft.Security/detectionRules`; +- a supported `apiVersion`; +- a stable `properties.id`; +- `contentProvenance.source.id` matching exactly one packaged Analytic Rule; +- a query, schedule, alert template, and reviewed entity mappings; and +- `properties.status: disabled`. + +For Sentinel-derived detections, `properties.id` is `xdr-`, +while `contentProvenance.source.id` remains the original unprefixed GUID. The +packager normalizes a bare source GUID on its packaging copy, keeps a canonical +`xdr-` ID unchanged, and rejects unrelated custom IDs; it must not rewrite the +authored detection YAML or provenance. Installation and registration IDs and +references must use the same normalized identity. Existing deployed identities +are not migrated or deleted. An optional read-only identity review may help +diagnose known legacy IDs or collisions, but it is not a mandatory +pre-deployment existence check. Stop for manual reconciliation if an observed +ID's ownership or content is uncertain; never overwrite unrelated content. + +`Include XDR Content Registration` is deprecated: any supplied value warns and +is ignored. V3.1 emits installation and registration together under +`DeployCustomDetection`; live provider support for registration is **unverified**. +Do not claim otherwise from template generation. Require separate exact-scope +approval before any environment write. + +## Workflow + +1. Work from the repository root. +2. Confirm PowerShell 7.1 or newer, Node.js, and the `powershell-yaml` module are + available. Install missing dependencies only after confirming they are + required. +3. Inspect the solution-data JSON and all referenced files. Stop on missing, + duplicate, conflicting, or review-required content. +4. Verify every `contentProvenance.source.id` resolves to exactly one Analytic + Rule content ID. +5. Confirm the current solution version and the requested bump. Check that + `ReleaseNotes.md`, when present, can be synchronized to the resulting + version. +6. Run the deterministic toolkit wrapper: + + ```powershell + sentinel-xdr-migration package-v3-1 ` + --solution ".\Solutions\" ` + --version-bump none + ``` + + Use `none` for validation without modifying source versions. Replace it with + the approved `patch`, `minor`, or `major` value when producing a release + package; those options update the solution data and metadata versions. The + wrapper invokes + `Tools\Create-Azure-Sentinel-Solution\V3\createSolutionV3_1.ps1` with local + version mode, validates every required output and the ZIP, and writes + `Solutions\\Logs\sentinel-xdr-migration\\packaging.v3_1.json`. + + Never reuse pre-existing package artifacts as proof that this stage ran. + Complete the workflow stage only with the wrapper's `workflowArtifacts` + values, including `packager=V3.1` and `packageReport`. + Historical V4 reports are not V3.1 evidence; rebuild rather than relabel them. + + Optional `trackingId` in SolutionMetadata.json must contain the complete ID + copied unchanged from Partner Center > offer > plan > Technical configuration. + Never synthesize it or substitute a bare GUID, Partner ID, or plan ID. + If absent or blank, packaging continues without a marker and nested XDR + packages warn that Partner Center cannot auto-add attribution for functional + nested deployments. Surface that warning and recommend adding the ID and + rebuilding; do not claim upload rejection. + See [official attribution guidance](https://learn.microsoft.com/partner-center/marketplace-offers/azure-partner-customer-usage-attribution#microsoft-marketplace-azure-apps). + +7. Require successful packaging and inspect: + + - `Package\mainTemplate.json`; + - `Package\createUiDefinition.json`; + - `Package\testParameters.json`; and + - `Package\.zip`. + +8. For a hybrid package, verify: + + - only emitted families declare `Deploy*` booleans; Sentinel defaults true, + `DeployCustomDetection` defaults false, and UI/test parameters agree; + - every AR is gated by `DeployAnalyticsRule`, independently of CDs; + - every CD installation and registration is gated by `DeployCustomDetection`; + - existing conditions are AND-composed without changing nested scopes; + - every CD resolves to exactly one source AR; CD deployment/registration counts match; + - every CD body has `status: disabled`; + - deployment names and CD IDs are unique and deterministic; + - content packages and attribution remain unconditional; dependencies are + unchanged and may still require unselected content or external prerequisites; + - no `E5Flavor` or `RegisterE5Content` parameter is generated; + - multiple authored tactics retain only the first authored entry and its + techniques in both ARM payload copies, with an explicit omission warning; + authored YAML and provenance remain unchanged, and Graph deployment still + has its single-tactic guard; and + - the generated ZIP version matches the solution-data version. + +9. Run the repository packaging validations. Do not classify skipped tests as + passed. Separate genuine template failures from documented validator or + environment limitations. +10. Report the generated version, artifact paths, content counts, validation + results, and any deployment prerequisites or unresolved failures. + +Packaging is complete only when the artifacts exist, versions are synchronized, +all expected resources are present, the V3.1 evidence report is accepted by the +workflow gate, and no unexplained validation failure remains. diff --git a/.github/skills/solution-release-notes-updater/SKILL.md b/.github/skills/solution-release-notes-updater/SKILL.md new file mode 100644 index 00000000000..8dc205a9f9a --- /dev/null +++ b/.github/skills/solution-release-notes-updater/SKILL.md @@ -0,0 +1,46 @@ +--- +name: solution-release-notes-updater +description: Update a Microsoft Sentinel solution's ReleaseNotes.md for an approved release. Use when asked to add, revise, or validate a solution release-notes entry without packaging the solution. +--- + +# Update Solution Release Notes + +Update only `Solutions//ReleaseNotes.md` for the approved solution release. Follow `.github/instructions/releasenotes.instructions.md` as the source of truth for file path, table format, ordering, and dates. Read `.github/instructions/packaging.instructions.md` when the request overlaps packaging or version bumping; do not treat this skill as permission to package. + +## Inputs + +Identify the exact solution folder and establish: + +- The actual changes being released, from the user's description and, when useful, the solution's diff against the intended target branch. +- The approved **solution-level** release version and whether an approved packaging/version-bump step has already selected or applied it. +- The release date to record. + +Ask the user for any missing or ambiguous solution name, change summary, release version/bump, or date before editing. Never invent a feature, date, release, or version. Do not assume the current date is the release date. + +## Workflow + +1. Read the applicable release-notes and packaging instructions. Inspect the exact solution's `ReleaseNotes.md`, if present, and its `Data/Solution_*.json` version. If a package was already generated for this release, also inspect `Package/mainTemplate.json` → `variables._solutionVersion`. Treat an existing approved package version as authoritative for that release; do not bump it again. +2. Distinguish the solution's release version from versions on individual analytic rules, workbooks, parsers, or XDR detections. A content item's version is not the solution release version. Do not edit solution metadata or package files as part of a notes-only request. +3. Check both staged and unstaged changes to the release-notes file. If the approved version already has an uncommitted row for this same release, update that row rather than adding a duplicate. If the version already exists in committed history, do not duplicate or rewrite it; stop and clarify whether the user intended a correction to that historical entry. If a different uncommitted top entry appears to represent another release, preserve it and ask before deciding where or how to record this release. +4. For a new release, require an explicitly approved solution-level version. Confirm it is valid `X.Y.Z` and newer than the current release entry. Apply the approved patch/minor/major increment only if it has not already been applied; never independently increment a version already advanced by packaging. If the version conflicts with the solution data or generated package, stop and report the mismatch instead of reconciling it silently. +5. Write one concise, truthful entry (normally 1–2 sentences) that names the affected solution content and describes the actual change. Follow the existing table's row style. For a new file, use exactly this header and a Markdown separator: + + ```markdown + | **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** | + |---|---|---| + ``` + + Insert a new release immediately below the header/separator, keeping all existing history intact. Use the confirmed release date in `DD-MM-YYYY` format. Do not add empty rows or fabricate intermediate releases to fill apparent gaps in historical version numbers. +6. Validate the resulting table against the release-notes instructions: + - Exact header names and order; exactly three columns in the header, separator, and every data row. + - Each version is three-part semantic `X.Y.Z`, unique, and in descending numeric order. + - Each date is a real date formatted `DD-MM-YYYY`. + - Each change-history cell is nonempty and accurately reflects the approved changes. +7. Keep the edit focused. If pre-existing historical rows violate a rule, do not silently rewrite history; report the existing issue separately. Do not change any solution content, `Data/`, `Package/`, shared instructions, skills, or tooling while performing this notes-only task. + +## Boundaries and result + +- This workflow edits only the requested solution's `ReleaseNotes.md`. +- Do not run the V3/V3.1/V4 packaging or build-and-validate flow merely to edit notes; those flows can change versions and generated package files. If the user asks to package or validate, switch to the repository's applicable packaging workflow and obtain the required version-bump approval first. +- Do not create a PR, commit, push, deploy, or contact Azure unless separately requested and authorized. +- Report the exact file changed, the version/date and entry added or revised, validation results, and any unresolved ambiguity or pre-existing table violations. If no safe entry can be made, leave the file untouched and explain what information is needed. diff --git a/.github/workflows/arm-ttk-validations.yaml b/.github/workflows/arm-ttk-validations.yaml index f56506a2018..be8d069ae07 100644 --- a/.github/workflows/arm-ttk-validations.yaml +++ b/.github/workflows/arm-ttk-validations.yaml @@ -2,6 +2,7 @@ name: Arm-ttk Validations on: pull_request: + types: [opened, synchronize, reopened, edited] paths: - 'Solutions/**/mainTemplate.json' - 'Solutions/**/createUiDefinition.json' @@ -11,19 +12,20 @@ permissions: pull-requests: read jobs: - run-arm-ttk: + identify-changes: runs-on: ubuntu-latest outputs: - solutionName: ${{ steps.step1.outputs.solutionName }} - mainTemplateChanged: ${{ steps.step1.outputs.mainTemplateChanged }} - createUiChanged: ${{ steps.step1.outputs.createUiChanged }} + solutionsJson: ${{ steps.step1.outputs.solutionsJson }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - fetch-depth: 10 + fetch-depth: 0 - shell: pwsh id: step1 name: Identify Changes in PR + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | try { Set-PSRepository PSGallery -InstallationPolicy Trusted @@ -35,16 +37,28 @@ jobs: exit 1 } + run-arm-ttk: + needs: identify-changes + if: ${{ needs.identify-changes.outputs.solutionsJson != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.identify-changes.outputs.solutionsJson) }} + name: ARM-TTK - ${{ matrix.solutionName }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 id: publishGithubPackage name: Run ARM-TTK - if: ${{ success() && steps.step1.outputs.solutionName != '' && (steps.step1.outputs.mainTemplateChanged == 'true' || steps.step1.outputs.createUiChanged == 'true') }} with: context: . file: ./.github/actions/Dockerfile push: false provenance: false build-args: | - SolutionName=${{ steps.step1.outputs.solutionName }} - mainTemplateChanged=${{ steps.step1.outputs.mainTemplateChanged }} - createUiChanged=${{ steps.step1.outputs.createUiChanged }} + SolutionName=${{ matrix.solutionName }} + mainTemplateChanged=${{ matrix.mainTemplateChanged }} + createUiChanged=${{ matrix.createUiChanged }} diff --git a/.github/workflows/slash-command-armttk.yaml b/.github/workflows/slash-command-armttk.yaml index 6a9e01fb620..412406f56bd 100644 --- a/.github/workflows/slash-command-armttk.yaml +++ b/.github/workflows/slash-command-armttk.yaml @@ -15,9 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork && github.event.issue.pull_request && contains(fromJson('["/armttk", "/Armttk", "/ARM-TTK", "/ARMTTK", "/arm-ttk", "/ArmTtk"]'), github.event.comment.body) }} outputs: - solutionName: ${{ steps.step1.outputs.solutionName }} - mainTemplateChanged: ${{ steps.step1.outputs.mainTemplateChanged }} - createUiChanged: ${{ steps.step1.outputs.createUiChanged }} + solutionsJson: ${{ steps.step1.outputs.solutionsJson }} steps: - name: Get PR details and validate id: get-pr @@ -40,19 +38,23 @@ jobs: core.setOutput('is_fork', 'false'); core.setOutput('head_sha', pr.head.sha); + core.setOutput('base_sha', pr.base.sha); core.setOutput('head_repo', pr.head.repo.full_name); - name: Checkout PR (secure) if: steps.get-pr.outputs.is_fork == 'false' uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - fetch-depth: 10 + fetch-depth: 0 ref: ${{ steps.get-pr.outputs.head_sha }} persist-credentials: false - shell: pwsh if: steps.get-pr.outputs.is_fork == 'false' id: step1 name: Identify Changes in PR + env: + PR_BASE_SHA: ${{ steps.get-pr.outputs.base_sha }} + PR_HEAD_SHA: ${{ steps.get-pr.outputs.head_sha }} run: | try { Set-PSRepository PSGallery -InstallationPolicy Trusted @@ -64,16 +66,18 @@ jobs: exit 1 } - - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 - id: publishGithubPackage - name: Run ARM-TTK - if: ${{ steps.get-pr.outputs.is_fork == 'false' && success() && steps.step1.outputs.solutionName != '' && (steps.step1.outputs.mainTemplateChanged == 'true' || steps.step1.outputs.createUiChanged == 'true') }} - with: - context: . - file: ./.github/actions/Dockerfile - push: false - provenance: false - build-args: | - SolutionName=${{ steps.step1.outputs.solutionName }} - mainTemplateChanged=${{ steps.step1.outputs.mainTemplateChanged }} - createUiChanged=${{ steps.step1.outputs.createUiChanged }} + - name: Run ARM-TTK for each solution + if: ${{ steps.get-pr.outputs.is_fork == 'false' && steps.step1.outputs.solutionsJson != '[]' }} + run: | + $solutions = '${{ steps.step1.outputs.solutionsJson }}' | ConvertFrom-Json + $failedSolutions = @() + foreach ($solution in $solutions) { + Write-Host "Running ARM-TTK for $($solution.solutionName)" + docker build --build-arg "SolutionName=$($solution.solutionName)" --build-arg "mainTemplateChanged=$($solution.mainTemplateChanged)" --build-arg "createUiChanged=$($solution.createUiChanged)" --file ./.github/actions/Dockerfile . + if ($LASTEXITCODE -ne 0) { + $failedSolutions += $solution.solutionName + } + } + if ($failedSolutions.Count -gt 0) { + throw "ARM-TTK failed for solution(s): $($failedSolutions -join ', ')." + } diff --git a/.gitignore b/.gitignore index 512ba0d6a37..c6153d93a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,360 +1,379 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore - -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates -*.lscache -*.lock -.bundle/ - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ - -# Execution results -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ -**/Properties/launchSettings.json - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ -**/.ipynb_checkpoints/** - -# No need to store DeployedQueries.json - it is auto generated anyway -Hunting Queries/DeployedQueries.json - -#Mac OSx -.DS_Store -!*Microsoft.Azure.Sentinel.KustoServices.*.nupkg - -# TypeScript build output -.script/**/*.js -.script/**/*.js.map -.script/**/*.d.ts -.script/**/*.d.ts.map -/.vscode - -# cspell +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +/Tools/ignored/ +*.suo +*.user +*.userosscache +*.sln.docstates +*.lscache +*.lock +.bundle/ + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Execution results +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ +**/.ipynb_checkpoints/** + +# No need to store DeployedQueries.json - it is auto generated anyway +Hunting Queries/DeployedQueries.json + +# Local Sentinel-to-XDR orchestration state +Solutions/**/XDR Detections/workflow-state.json + +#Mac OSx +.DS_Store +!*Microsoft.Azure.Sentinel.KustoServices.*.nupkg + +# TypeScript build output +.script/**/*.js +.script/**/*.js.map +.script/**/*.d.ts +.script/**/*.d.ts.map +/.vscode + +# cspell **/cspell-results.json - -.arm-ttk - + +.arm-ttk + # Local-only helper scripts (not for upstream submission) .local-helpers/ + +# Generated migration, validation, deployment, and parity evidence. +/Reports/ + +# Solution-local runtime logs, state, configuration, and evidence remain private. +/Solutions/**/Logs/ +# Ignore only per-solution runtime configuration; keep the shared template tracked. +/Solutions/**/.env +!/Tools/SentinelToXDRMigration/.env.example + +# Local generated packaging workspaces. +**/_ws/ + +# Generated Custom Logs ingestion result files. +Tools/CustomLogsIngestion-DCE-DCR/src/CustomlogsIngestion_*.csv diff --git a/.script/idChangeValidator.js b/.script/idChangeValidator.js index deb363d8008..6101377f46e 100644 --- a/.script/idChangeValidator.js +++ b/.script/idChangeValidator.js @@ -20,27 +20,29 @@ export async function IsIdHasChanged(filePath) { console.log("Pull Request couldn't be fetched. If issue persists - please open an issue"); return -1 /* ExitCode.ERROR */; } - // Fetch the base and head branches before running the diff + // Fetch the base and head branches into distinct local refs before running the diff + const baseLocalRef = "pr_base_branch"; + const headLocalRef = "pr_head_branch"; const branches = await git.branch(); - if (!branches.all.includes(pr.base.ref)) { + if (!branches.all.includes(baseLocalRef)) { try { - await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.base.repo.clone_url, pr.base.ref + ':' + pr.base.ref]); + await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.base.repo.clone_url, pr.base.ref + ':' + baseLocalRef]); } catch (e) { console.error(`Error fetching branch ${pr.base.ref} from git:`, e); return -1 /* ExitCode.ERROR */; } } - if (!branches.all.includes(pr.head.ref)) { + if (!branches.all.includes(headLocalRef)) { try { - await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.head.repo.clone_url, pr.head.ref + ':' + pr.head.ref]); + await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.head.repo.clone_url, pr.head.ref + ':' + headLocalRef]); } catch (e) { console.error(`Error fetching branch ${pr.head.ref} from git:`, e); return -1 /* ExitCode.ERROR */; } } - const options = [pr.base.ref, pr.head.ref, filePath]; + const options = [baseLocalRef, headLocalRef, filePath]; const diffSummary = await git.diff(options); const idPosition = diffSummary.search(templateIdRegex); const idHasChanged = idPosition > 0; diff --git a/.script/idChangeValidator.ts b/.script/idChangeValidator.ts index beb92dedd8c..30dcc73d2fa 100644 --- a/.script/idChangeValidator.ts +++ b/.script/idChangeValidator.ts @@ -27,26 +27,28 @@ export async function IsIdHasChanged(filePath: string): Promise { return ExitCode.ERROR; } - // Fetch the base and head branches before running the diff + // Fetch the base and head branches into distinct local refs before running the diff + const baseLocalRef = "pr_base_branch"; + const headLocalRef = "pr_head_branch"; const branches = await git.branch(); - if (!branches.all.includes(pr.base.ref)) { + if (!branches.all.includes(baseLocalRef)) { try { - await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.base.repo.clone_url, pr.base.ref + ':' + pr.base.ref]); + await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.base.repo.clone_url, pr.base.ref + ':' + baseLocalRef]); } catch (e) { console.error(`Error fetching branch ${pr.base.ref} from git:`, e); return ExitCode.ERROR; } } - if (!branches.all.includes(pr.head.ref)) { + if (!branches.all.includes(headLocalRef)) { try { - await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.head.repo.clone_url , pr.head.ref + ':' + pr.head.ref]); + await git.fetch(['--no-tags', '--prune', '--no-recurse-submodules', '--depth=1', pr.head.repo.clone_url , pr.head.ref + ':' + headLocalRef]); } catch (e) { console.error(`Error fetching branch ${pr.head.ref} from git:`, e); return ExitCode.ERROR; } } - const options = [pr.base.ref, pr.head.ref, filePath]; + const options = [baseLocalRef, headLocalRef, filePath]; const diffSummary = await git.diff(options); const idPosition = diffSummary.search(templateIdRegex); const idHasChanged = idPosition > 0; diff --git a/.script/local-validation/README.md b/.script/local-validation/README.md index d305237932e..fd23d80b1cf 100644 --- a/.script/local-validation/README.md +++ b/.script/local-validation/README.md @@ -39,10 +39,19 @@ node .script/local-validation/validate.js --diff feature/base-branch # Validate everything under a solution folder node .script/local-validation/validate.js --path Solutions/MySolution +# Same checks against a different content checkout; helper code still loads from this checkout +node .script/local-validation/validate.js \ + --repository-root /path/to/content-checkout \ + --path Solutions/MySolution \ + --only json,yaml \ + --json-file .script/local-validation/Logs/results.json + # Validate a specific connector node .script/local-validation/validate.js --path DataConnectors/MyConnector ``` +The validator and .NET test projects still load from the toolkit checkout. When standalone `--files`, `--diff`, or auto-discovery runs a .NET validator, `--repository-root` is passed to its content loaders through `SENTINEL_VALIDATION_CONTENT_ROOT`; standalone `--path` continues to skip repo-wide .NET tests. + ### Validate Specific Files ```bash @@ -93,7 +102,7 @@ node .script/local-validation/validate.js --path Solutions/MySolution --json | `id-change` | ID Change | `.yaml`, `.yml`, `.json` | `Detections/`, `Solutions/` | Template IDs must not change | | `kql` | KQL Validation | — | — | KQL syntax and best practices (.NET, scoped to solution) | | `detection-schema` | Detection Template Schema | — | — | Detection YAML structure (.NET, scoped to solution) | -| `non-ascii` | Non-ASCII | — | — | Non-ASCII character detection (.NET, entire repo) | +| `non-ascii` | Non-ASCII | — | — | Non-ASCII character detection (.NET; scoped by the build runner) | | `arm-ttk` | ARM-TTK | `.json` | `Solutions/` | ARM template testing (Docker — same as GitHub CI) | ## How It Works @@ -153,7 +162,7 @@ The local validation pipeline is designed to stay in sync with CI by **sharing c - **Node.js 16+** with npm dependencies installed (`npm install`) - **TypeScript compiled** (`npm run tsc`) - **Git** (for `--diff` mode file discovery) -- **.NET SDK 6.0+** (optional — for KQL, Detection Schema, Non-ASCII validators; Detection Schema and KQL are scoped to the target solution for fast local runs) +- **.NET SDK 6.0+** (optional — the build runner scopes KQL, Detection Schema, and Non-ASCII checks; standalone `--path` mode skips these repo-wide test projects) - **Docker** (optional — for ARM-TTK validator, uses the same Dockerfile as GitHub CI) ## Integration with AI Agents @@ -221,19 +230,93 @@ The .NET validators (KQL, Detection Schema, Non-ASCII) require .NET SDK. Install node .script/local-validation/validate.js --skip kql,detection-schema,non-ascii ``` +The standalone `validate.js --path` command skips .NET validators because it cannot safely scope the repo-wide test projects. Use `build-and-validate.ps1 -SolutionName ... -SkipPackaging` for solution-scoped .NET checks. + +With an explicit solution folder, validation examines current files in that folder, including uncommitted and untracked files; deleted files are excluded. The folder must be an existing direct child of `Solutions/`. The repo-history TruffleHog check is reported as skipped because it cannot be limited to one solution. +Solution-scoped hyperlink validation also excludes descendant path components named +`Logs`, `Reports`, or `Evidence` (case-insensitive, whole path segments), consistent +with the local TypeScript solution scan. Other names and path substrings remain in scope. + +### Cross-Checkout Build Runner + +`build-and-validate.ps1` derives its toolkit root from the script location. By default, +the content root is that same checkout, preserving the normal same-worktree behavior. +For selected content from another checkout, use `-ContentRoot ` +with `-SkipPackaging` and the official runner; packaging is rejected when roots differ. +The selected solution must be a non-symlink direct child of that content root's +`Solutions/`. TypeScript and .NET helper projects, PowerShell validation scripts, +configuration, and ARM-TTK dependencies remain in the toolkit root, while current +solution files and scoped .NET/PowerShell validation use the selected content root. + +For post-V3.1 ARM deployment acceptance, run the existing validation checks in +validation-only mode and provide the exact V3.1 package report for the selected +workflow run: + +```powershell +pwsh .script/local-validation/build-and-validate.ps1 ` + -SolutionName "MySolution" ` + -ContentRoot /path/to/content-checkout ` + -SkipPackaging ` + -PackageReportPath "Logs/sentinel-xdr-migration//packaging.v3_1.json" ` + -VersionBump none ` + -ValidationSkip "" +``` + +`-PackageReportPath` is optional for ordinary use. When supplied, the runner +requires `-SkipPackaging` and active validation, verifies that the V3.1 report +and its referenced artifacts are current, checks the report's package-input +fingerprint against current solution inputs before validation, and records the +package report's workflow/attempt IDs and exact artifact hashes. It fingerprints +all package inputs and every file in `Package/` before and after the existing +validation steps; changed content, package bytes, package report, `ContentRoot`, +or runner scripts makes the binding fail. The ignored evidence run contains +`build-and-validate-binding.json`, and the human-readable report contains its +path and SHA-256. The acceptance planner independently checks those emitted +fingerprints against current V3.1 package state and current files; it does not +infer validated hashes from the files' current state or use report timestamps. +This adds no validator and does not turn skipped checks into passes. + +V3.1 package-input identities use Python `Path` ordering, which is +case-sensitive on POSIX and case-insensitive on Windows. The producer computes +both component-wise orderings and records only the semantics whose full file +fingerprint matches the package report's own `packageInputs.after.sha256`; +it also records the validation runner's platform. A cross-host validation run +therefore follows the packager identity rather than the runner's local sort. +Windows ordering lowercases each path component before ordinal component +comparison, matching `PureWindowsPath` rather than .NET's +`OrdinalIgnoreCase` string ordering. +If neither ordering matches, or Windows-equivalent paths make ordering +ambiguous, evidence capture fails closed. The independent `Package/` file +inventory retains its existing ordinal, case-sensitive path ordering. + +The runner accepts `-EvidenceDirectory` only under the toolkit's ignored +`.script/local-validation/Logs/` folder. It creates a unique run subdirectory with the +complete TypeScript results JSON, separate TypeScript stdout/stderr logs, compile +output, complete captured output for invoked .NET/PowerShell/ARM-TTK helpers, and the +full report. The JSON file avoids console/native-process output truncation; a missing +or malformed report is a runner failure. A .NET invocation that exits without a test +summary or executed tests is reported as skipped, not passed. Skipped checks are counted +as skipped, not passed. Validation-only runs do not update solution data, release notes, +or packages. + +For example: + +```powershell +pwsh /path/to/toolkit/.script/local-validation/build-and-validate.ps1 ` + -SolutionName "MySolution" ` + -ContentRoot /path/to/content-checkout ` + -SkipPackaging ` + -VersionBump none ` + -ValidationSkip "" ` + -EvidenceDirectory ".script/local-validation/Logs/my-run" +``` + ### Import errors Make sure TypeScript is compiled: ```bash npm run tsc ``` -## File Inventory (New Files Only) - -This feature adds the following files — **no existing files are modified**: +## Build Outputs -``` -.script/local-validation/ -├── validate.ts # Main validation runner (TypeScript source) -├── validate.js # Compiled JavaScript (generated by tsc) -└── README.md # This documentation -``` +`validate.js`, `validate.js.map`, `validate.d.ts`, and `validate.d.ts.map` are generated from `validate.ts` by `npm run tsc`. diff --git a/.script/local-validation/build-and-validate.ps1 b/.script/local-validation/build-and-validate.ps1 index a8d4f7cbebe..e0f82e0e0e0 100644 --- a/.script/local-validation/build-and-validate.ps1 +++ b/.script/local-validation/build-and-validate.ps1 @@ -16,6 +16,19 @@ The name of the solution folder under Solutions/. Example: "CrowdStrike Falcon Endpoint Protection" +.PARAMETER ContentRoot + Repository checkout containing the selected Solutions/ content. Defaults to the + toolkit checkout that contains this script. Cross-checkout validation requires + -SkipPackaging; tooling and dependencies continue to load from this script's checkout. + +.PARAMETER EvidenceDirectory + Ignored evidence parent under .script/local-validation/Logs in the toolkit checkout. + A unique run folder stores complete TypeScript JSON results and child output logs. + +.PARAMETER PackageReportPath + Exact V3.1 packaging report under the selected solution. When supplied, the runner + requires validation-only mode and emits hash-bound evidence for that package. + .PARAMETER VersionBump How to increment the version. Options: patch (default), minor, major. @@ -58,7 +71,11 @@ param( [Parameter(Mandatory = $true, Position = 0, HelpMessage = "Solution folder name under Solutions/")] [string]$SolutionName, - [ValidateSet("patch", "minor", "major")] + [string]$ContentRoot, + + [string]$EvidenceDirectory, + + [ValidateSet("none", "patch", "minor", "major")] [string]$VersionBump = "patch", [switch]$SkipValidation, @@ -67,7 +84,9 @@ param( [string]$ValidationSkip = "kql,detection-schema,non-ascii,arm-ttk", - [string]$ReleaseNotes = "" + [string]$ReleaseNotes = "", + + [string]$PackageReportPath ) # ============================================================================= @@ -76,16 +95,48 @@ param( $ErrorActionPreference = "Stop" -# Find repository root -$repoRoot = git rev-parse --show-toplevel 2>$null -if (-not $repoRoot) { - # Fallback: walk up from script location - $repoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName +# The toolkit root is anchored to this script, never to the caller's current directory. +$toolkitRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "../..")) +$toolkitRoot = $toolkitRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +. (Join-Path $PSScriptRoot "validation-binding.ps1") +$pathComparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } +$ContentRoot = if ($ContentRoot) { [System.IO.Path]::GetFullPath($ContentRoot) } else { $toolkitRoot } +$ContentRoot = $ContentRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +$rootsAreSame = [string]::Equals($toolkitRoot, $ContentRoot, $pathComparison) + +if (-not (Test-Path $toolkitRoot -PathType Container) -or + -not (Test-Path (Join-Path $toolkitRoot ".script/local-validation/validate.ts") -PathType Leaf)) { + Write-Host "❌ Toolkit root is invalid: '$toolkitRoot'." -ForegroundColor Red + exit 2 +} +if (-not (Test-Path $ContentRoot -PathType Container) -or + -not (Test-Path (Join-Path $ContentRoot "Solutions") -PathType Container) -or + ((Get-Item $ContentRoot).Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or + ((Get-Item (Join-Path $ContentRoot "Solutions")).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + Write-Host "❌ Content root must be an existing repository checkout containing Solutions/: '$ContentRoot'." -ForegroundColor Red + exit 2 +} +$contentGitRoot = & git -C $ContentRoot rev-parse --show-toplevel 2>$null +if ($LASTEXITCODE -ne 0 -or + -not [string]::Equals( + [System.IO.Path]::GetFullPath("$contentGitRoot").TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar), + $ContentRoot, + $pathComparison)) { + Write-Host "❌ Content root must be the canonical Git worktree root: '$ContentRoot'." -ForegroundColor Red + exit 2 +} +if (-not $rootsAreSame -and -not $SkipPackaging) { + Write-Host "❌ Cross-checkout validation is validation-only; pass -SkipPackaging to avoid modifying content or package artifacts." -ForegroundColor Red + exit 2 } -$repoRoot = $repoRoot.Replace('\', '/') # Resolve solution name (supports partial/fuzzy matching) -$solutionsDir = Join-Path $repoRoot "Solutions" +$solutionsDir = Join-Path $ContentRoot "Solutions" +$solutionsRoot = [System.IO.Path]::GetFullPath($solutionsDir).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +if ([System.IO.Path]::IsPathRooted($SolutionName) -or $SolutionName.Contains('/') -or $SolutionName.Contains('\') -or $SolutionName -in @('.', '..')) { + Write-Host "❌ Solution must be a folder name directly under Solutions/: '$SolutionName'" -ForegroundColor Red + exit 2 +} $solutionPath = Join-Path $solutionsDir $SolutionName if (-not (Test-Path $solutionPath)) { @@ -128,19 +179,95 @@ if (-not (Test-Path $solutionPath)) { } } +$solutionPath = [System.IO.Path]::GetFullPath($solutionPath) +$solutionsPrefix = $solutionsRoot + [System.IO.Path]::DirectorySeparatorChar +if (-not $solutionPath.StartsWith($solutionsPrefix, $pathComparison) -or + -not [string]::Equals([System.IO.Path]::GetDirectoryName($solutionPath), $solutionsRoot, $pathComparison) -or + -not (Test-Path $solutionPath -PathType Container) -or + ((Get-Item $solutionPath).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + Write-Host "❌ Invalid solution folder: '$solutionPath'. Expected an existing folder directly under '$solutionsRoot'." -ForegroundColor Red + exit 2 +} + +if ($PackageReportPath) { + if (-not $SkipPackaging -or $SkipValidation) { + Write-Host "❌ -PackageReportPath requires validation-only mode: pass -SkipPackaging and do not pass -SkipValidation." -ForegroundColor Red + exit 2 + } + try { + $null = Get-ValidationV31PackageEvidence ` + -SolutionPath $solutionPath ` + -PackageReportPath $PackageReportPath + $PackageReportPath = Resolve-ValidationEvidencePath ` + -Root $solutionPath ` + -Reference $PackageReportPath + } + catch { + Write-Host "❌ V3.1 package evidence is invalid: $($_.Exception.Message)" -ForegroundColor Red + exit 2 + } +} + $dataPath = Join-Path $solutionPath "Data" if (-not (Test-Path $dataPath)) { Write-Host "❌ Data folder not found: $dataPath" -ForegroundColor Red exit 2 } +if (-not $EvidenceDirectory) { + $EvidenceDirectory = Join-Path $toolkitRoot ".script/local-validation/Logs" +} +$evidenceCandidate = if ([System.IO.Path]::IsPathRooted($EvidenceDirectory)) { + $EvidenceDirectory +} else { + Join-Path $toolkitRoot $EvidenceDirectory +} +$evidenceParent = [System.IO.Path]::GetFullPath($evidenceCandidate) +$evidenceRoot = [System.IO.Path]::GetFullPath((Join-Path $toolkitRoot ".script/local-validation/Logs")) +$evidencePrefix = $evidenceRoot + [System.IO.Path]::DirectorySeparatorChar +if (-not $evidenceParent.StartsWith($evidencePrefix, $pathComparison) -and + -not [string]::Equals($evidenceParent, $evidenceRoot, $pathComparison)) { + Write-Host "❌ EvidenceDirectory must be inside '$evidenceRoot'." -ForegroundColor Red + exit 2 +} +$evidenceCursor = $toolkitRoot +foreach ($component in ([System.IO.Path]::GetRelativePath($toolkitRoot, $evidenceParent) -split '[\\/]')) { + if (-not $component -or $component -eq '.') { continue } + $evidenceCursor = Join-Path $evidenceCursor $component + if (Test-Path $evidenceCursor) { + $evidenceItem = Get-Item -Force $evidenceCursor + if (($evidenceItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or + -not $evidenceItem.PSIsContainer) { + Write-Host "❌ EvidenceDirectory cannot traverse a link or file: '$evidenceCursor'." -ForegroundColor Red + exit 2 + } + } +} +$evidenceRelative = [System.IO.Path]::GetRelativePath($toolkitRoot, $evidenceParent).Replace('\', '/') +$null = & git -C $toolkitRoot check-ignore --no-index --quiet -- $evidenceRelative 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "❌ EvidenceDirectory is not ignored by the toolkit checkout: '$evidenceRelative'." -ForegroundColor Red + exit 2 +} +$runStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssfffZ") +$evidenceRunPath = Join-Path $evidenceParent $runStamp +if (Test-Path $evidenceRunPath) { + Write-Host "❌ Evidence run directory already exists: '$evidenceRunPath'." -ForegroundColor Red + exit 2 +} +$null = New-Item -ItemType Directory -Path $evidenceParent -Force +$null = New-Item -ItemType Directory -Path $evidenceRunPath -Force:$false + Write-Host "" Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ Microsoft Sentinel Solution Builder ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "" Write-Host " Solution: $SolutionName" -ForegroundColor White +Write-Host " Toolkit Root: $toolkitRoot" -ForegroundColor White +Write-Host " Content Root: $ContentRoot" -ForegroundColor White Write-Host " Version Bump: $VersionBump" -ForegroundColor White +Write-Host " Evidence: $evidenceRunPath" -ForegroundColor White Write-Host " Packaging: $(if ($SkipPackaging) { '⏭ Skipped' } else { '✅ Enabled' })" -ForegroundColor White Write-Host " Validation: $(if ($SkipValidation) { '⏭ Skipped' } else { '✅ Enabled' })" -ForegroundColor White Write-Host " Release Notes:$(if ($ReleaseNotes) { " ✅ Provided" } else { ' ⚠️ Not provided' })" -ForegroundColor White @@ -150,6 +277,16 @@ $overallSuccess = $true # Report tracking $reportEntries = [System.Collections.ArrayList]::new() +$saveProcessOutput = { + param([string]$Name, [object[]]$Output) + $outputPath = Join-Path $evidenceRunPath $Name + [System.IO.File]::WriteAllLines( + $outputPath, + @($Output | ForEach-Object { "$_" }), + [System.Text.UTF8Encoding]::new($false) + ) + return $outputPath +} $reportMeta = @{ SolutionName = $SolutionName VersionBump = $VersionBump @@ -160,6 +297,10 @@ $reportMeta = @{ VersionSynced = $false ReleaseNotesUpdated = $false ReleaseNotesText = $ReleaseNotes + EvidenceRunPath = $evidenceRunPath + ValidationBindingPath = $null + ValidationBindingSha256 = $null + ValidationBindingResult = $null } # ============================================================================= @@ -172,7 +313,7 @@ if (-not $SkipPackaging) { Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan Write-Host "" - $v3ScriptPath = Join-Path $repoRoot "Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3.ps1" + $v3ScriptPath = Join-Path $toolkitRoot "Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3.ps1" if (-not (Test-Path $v3ScriptPath)) { Write-Host "❌ V3 packaging script not found: $v3ScriptPath" -ForegroundColor Red @@ -181,7 +322,7 @@ if (-not $SkipPackaging) { try { # Run packaging from repo root so relative paths work - Push-Location $repoRoot + Push-Location $toolkitRoot & $v3ScriptPath ` -SolutionDataFolderPath $dataPath ` @@ -280,49 +421,158 @@ if (-not $SkipPackaging -and $overallSuccess) { # STEP 2: RUN LOCAL VALIDATIONS # ============================================================================= +$validationEvidenceBefore = $null +$validationEvidenceCaptureError = $null +if ($PackageReportPath) { + try { + $validationEvidenceBefore = Get-ValidationEvidenceSnapshot ` + -ToolkitRoot $toolkitRoot ` + -ContentRoot $ContentRoot ` + -SolutionPath $solutionPath ` + -PackageReportPath $PackageReportPath + } + catch { + $validationEvidenceCaptureError = $_.Exception.Message + $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 4; Name = "Validation Evidence Integrity"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @("Could not capture V3.1 validation evidence before validation: $validationEvidenceCaptureError") + }) + } +} + if (-not $SkipValidation) { Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan Write-Host " STEP 2: Running Local Validations" -ForegroundColor Cyan Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan Write-Host "" - $validateScript = Join-Path $repoRoot ".script/local-validation/validate.js" + $validateScript = Join-Path $toolkitRoot ".script/local-validation/validate.js" - if (-not (Test-Path $validateScript)) { - Write-Host "❌ Validation script not found: $validateScript" -ForegroundColor Red - Write-Host " Run 'npx tsc' from the repo root to compile TypeScript first." -ForegroundColor Yellow + $validateSource = Join-Path $toolkitRoot ".script/local-validation/validate.ts" + if (-not (Test-Path $validateSource)) { + Write-Host "❌ TypeScript validation source not found: $validateSource" -ForegroundColor Red $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Compilation"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @("TypeScript validation source not found: $validateSource") + }) } else { + # validate.js and its imported checker modules are generated from TypeScript and + # ignored by git. Compile them here so this wrapper cannot execute stale output. + $typescriptCompileSucceeded = $false + $tscSw = [System.Diagnostics.Stopwatch]::StartNew() + try { + Push-Location $toolkitRoot + $tscOutput = & npm run tsc 2>&1 + $tscExitCode = $LASTEXITCODE + Pop-Location + $tscSw.Stop() + [System.IO.File]::WriteAllLines( + (Join-Path $evidenceRunPath "typescript-compile.log"), + @($tscOutput | ForEach-Object { "$_" }), + [System.Text.UTF8Encoding]::new($false) + ) + + $tscOutput | ForEach-Object { + $line = "$_" + if ($line.Trim()) { Write-Host " $line" -ForegroundColor Gray } + } + + if ($tscExitCode -eq 0) { + $typescriptCompileSucceeded = $true + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Compilation"; Status = "passed" + Passed = 1; Failed = 0; Skipped = 0; FileCount = 0 + Duration = $tscSw.Elapsed + }) + } + else { + Write-Host "❌ TypeScript compilation failed — local validation will not run stale JavaScript output." -ForegroundColor Red + $overallSuccess = $false + $tscErrors = @($tscOutput | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Compilation"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Duration = $tscSw.Elapsed; Errors = $tscErrors + }) + } + } + catch { + $tscSw.Stop() + Pop-Location -ErrorAction SilentlyContinue + Write-Host "❌ TypeScript compilation error: $($_.Exception.Message)" -ForegroundColor Red + $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Compilation"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Duration = $tscSw.Elapsed; Errors = @($_.Exception.Message) + }) + } + + if (-not $typescriptCompileSucceeded) { + Write-Host "⏭ Local TypeScript validators skipped because compilation failed." -ForegroundColor Yellow + } + elseif (-not (Test-Path $validateScript)) { + Write-Host "❌ Validation script not found after TypeScript compilation: $validateScript" -ForegroundColor Red + $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Runner"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @("Validation script not found after successful compilation: $validateScript") + }) + } + else { # Build the validation command — use --json for structured output $solutionRelativePath = "Solutions/$SolutionName" - $validateArgs = @("$validateScript", "--path", "$solutionRelativePath", "--json") + $typescriptJsonPath = Join-Path $evidenceRunPath "typescript-results.json" + $typescriptStdoutPath = Join-Path $evidenceRunPath "typescript.stdout.log" + $typescriptStderrPath = Join-Path $evidenceRunPath "typescript.stderr.log" + $validateArgs = @( + $validateScript, + "--path", $solutionRelativePath, + "--repository-root", $ContentRoot, + "--json-file", $typescriptJsonPath + ) if ($ValidationSkip) { $validateArgs += @("--skip", $ValidationSkip) } try { - Push-Location $repoRoot - $rawOutput = & node @validateArgs 2>&1 - $validationExitCode = $LASTEXITCODE - Pop-Location - - # Separate JSON stdout from stderr lines - $jsonString = ($rawOutput | Where-Object { $_ -is [string] }) -join "`n" - $stderrLines = $rawOutput | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } - - # Show stderr lines (warnings, progress) as-is - $stderrLines | ForEach-Object { Write-Host $_.ToString() -ForegroundColor Yellow } - - # Try to parse JSON output and generate human-readable display - $jsonParsed = $null - try { - $jsonParsed = $jsonString | ConvertFrom-Json + $nodeProcess = [System.Diagnostics.Process]::new() + $nodeProcess.StartInfo = [System.Diagnostics.ProcessStartInfo]::new() + $nodeProcess.StartInfo.FileName = "node" + $nodeProcess.StartInfo.WorkingDirectory = $toolkitRoot + $nodeProcess.StartInfo.UseShellExecute = $false + $nodeProcess.StartInfo.RedirectStandardOutput = $true + $nodeProcess.StartInfo.RedirectStandardError = $true + foreach ($argument in $validateArgs) { + $null = $nodeProcess.StartInfo.ArgumentList.Add($argument) } - catch { - # Fallback: show raw output if JSON parsing fails - Write-Host $jsonString + if (-not $nodeProcess.Start()) { + throw "Could not start Node.js local validation process." + } + $stdoutTask = $nodeProcess.StandardOutput.ReadToEndAsync() + $stderrTask = $nodeProcess.StandardError.ReadToEndAsync() + $nodeProcess.WaitForExit() + $validationExitCode = $nodeProcess.ExitCode + $nodeStdout = $stdoutTask.GetAwaiter().GetResult() + $nodeStderr = $stderrTask.GetAwaiter().GetResult() + [System.IO.File]::WriteAllText($typescriptStdoutPath, $nodeStdout, [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($typescriptStderrPath, $nodeStderr, [System.Text.UTF8Encoding]::new($false)) + if ($nodeStdout.Trim()) { Write-Host " $($nodeStdout.Trim())" -ForegroundColor Gray } + if ($nodeStderr.Trim()) { Write-Host $nodeStderr.Trim() -ForegroundColor Yellow } + + if (-not (Test-Path $typescriptJsonPath -PathType Leaf)) { + throw "TypeScript validator exited $validationExitCode without writing its structured report. See $typescriptStdoutPath and $typescriptStderrPath." + } + $jsonParsed = Get-Content $typescriptJsonPath -Raw | ConvertFrom-Json + if ($null -eq $jsonParsed.results) { + throw "TypeScript validator report has no results array. See $typescriptJsonPath." } if ($jsonParsed) { @@ -337,7 +587,7 @@ if (-not $SkipValidation) { foreach ($vName in $byValidator.Keys | Sort-Object) { $vResults = $byValidator[$vName] $vPassed = @($vResults | Where-Object { $_.passed -and -not $_.skipped }) - $vFailed = @($vResults | Where-Object { -not $_.passed }) + $vFailed = @($vResults | Where-Object { -not $_.passed -and -not $_.skipped }) $vSkipped = @($vResults | Where-Object { $_.skipped }) if ($vFailed.Count -gt 0) { @@ -368,16 +618,23 @@ if (-not $SkipValidation) { } $totalPassed = @($jsonParsed.results | Where-Object { $_.passed -and -not $_.skipped }).Count - $totalFailed = @($jsonParsed.results | Where-Object { -not $_.passed }).Count + $totalFailed = @($jsonParsed.results | Where-Object { -not $_.passed -and -not $_.skipped }).Count $totalSkipped = @($jsonParsed.results | Where-Object { $_.skipped }).Count Write-Host "" Write-Host " TOTAL: $($jsonParsed.results.Count) checks | ✅ $totalPassed passed | ❌ $totalFailed failed | ⏭ $totalSkipped skipped" -ForegroundColor Gray } - if ($validationExitCode -ne 0) { + if ($validationExitCode -ne 0 -or $totalFailed -gt 0) { Write-Host "" Write-Host "❌ Validation found errors (exit code $validationExitCode)" -ForegroundColor Red $overallSuccess = $false + if ($validationExitCode -ne 0 -and $totalFailed -eq 0) { + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Runner"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @("Node.js validation process exited with code $validationExitCode despite reporting no failed check. Full JSON and process output: $evidenceRunPath") + }) + } } else { Write-Host "" @@ -385,12 +642,17 @@ if (-not $SkipValidation) { } } catch { - Pop-Location -ErrorAction SilentlyContinue Write-Host "" Write-Host "❌ Validation failed: $($_.Exception.Message)" -ForegroundColor Red $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 2; Name = "TypeScript Runner"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @($_.Exception.Message) + }) } } + } Write-Host "" } @@ -441,9 +703,9 @@ if (-not $SkipValidation) { # at initialization time. We can't prevent discovery, but --filter restricts which tests # actually EXECUTE. This turns a 30-minute full-repo run into a 30-second scoped run. # - # Display names look like: TestMethod(fileName: "SomeRule.yaml", ...) - # We match on bare filenames. Filenames with filter-special chars ( ) | & ! are handled - # by truncating to the safe prefix before the first special character. + # Detection Schema exposes only the basename in its display name, so duplicate or + # filter-sensitive names are skipped. KQL and Non-ASCII expose the full path and are + # filtered by its encoded value to avoid matching same-named files elsewhere. function Get-SolutionContentFiles { param([string]$SolPath, [string[]]$SubDirs, [string[]]$Extensions) @@ -460,14 +722,18 @@ if (-not $SkipValidation) { } function Build-DotnetTestFilter { - param([System.IO.FileInfo[]]$Files) + param([System.IO.FileInfo[]]$Files, [switch]$UseEncodedPath) $parts = @() foreach ($f in $Files) { - $name = $f.Name - # Truncate at first filter-special character to avoid breaking dotnet test --filter parser - $idx = $name.IndexOfAny([char[]]@('(', ')', '|', '&', '!')) - if ($idx -gt 0) { - $name = $name.Substring(0, $idx).TrimEnd(' ', '-', '_') + if ($UseEncodedPath) { + # KQL and Non-ASCII test cases include the full path as an argument. + $name = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($f.FullName)) + } + else { + $name = $f.Name + if ($name.IndexOfAny([char[]]@('(', ')', '|', '&', '!')) -ge 0) { + throw "Cannot safely construct a dotnet test filter for filename '$name'." + } } if ($name.Length -gt 3) { $parts += "DisplayName~$name" @@ -491,24 +757,69 @@ if (-not $SkipValidation) { return $summary } + function Get-DotnetTestSkipReason { + param( + [int]$ExitCode, + [hashtable]$Summary, + [string[]]$OutputLines + ) + if ($ExitCode -ne 0 -or $Summary.Total -gt 0) { return $null } + $diagnostics = @($OutputLines | Where-Object { + "$_" -match "No test is available|could not find dependent assembly|No tests were found" + } | ForEach-Object { "$_".Trim() } | Select-Object -First 3) + if ($diagnostics.Count -gt 0) { + return "No tests executed: $($diagnostics -join ' | ')" + } + return "No tests executed: dotnet test did not report any test results" + } + # --- Detection Schema Validator --- - $detectionProject = Join-Path $repoRoot ".script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidation.Tests.csproj" + $detectionProject = Join-Path $toolkitRoot ".script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidation.Tests.csproj" if ($has31 -and (Test-Path $detectionProject)) { # Scope to solution's Analytic Rules only $detFiles = Get-SolutionContentFiles -SolPath $solutionPath -SubDirs @("Analytic Rules") -Extensions @("*.yaml") if ($detFiles.Count -gt 0) { + # These tests expose only the basename as their filterable argument. If another + # solution has a same-named rule, filtering by that name would execute it too. + $allDetectionFiles = @( + Get-ChildItem (Join-Path $ContentRoot "Detections"), $solutionsDir -Recurse -Filter "*.yaml" -File | + Where-Object { $_.FullName -match '[\\/]Detections[\\/]' -or $_.FullName -match '[\\/]Solutions[\\/].*[\\/]Analytic Rules[\\/]' } + ) + $collidingNames = @($detFiles | Where-Object { + $targetName = $_.Name + @($allDetectionFiles | Where-Object { $_.Name -eq $targetName }).Count -gt 1 + } | Select-Object -ExpandProperty Name -Unique) + $filterUnsafeNames = @($detFiles | Where-Object { + $_.Name.IndexOfAny([char[]]@('(', ')', '|', '&', '!')) -ge 0 + } | Select-Object -ExpandProperty Name -Unique) + if ($collidingNames.Count -gt 0 -or $filterUnsafeNames.Count -gt 0) { + $collisionDetail = "Cannot safely scope Detection Schema by basename; duplicate file name(s): $($collidingNames -join ', '); filter-sensitive file name(s): $($filterUnsafeNames -join ', ')" + Write-Host " ⏭ Detection Schema validation skipped — $collisionDetail." -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 3; Name = "Detection Schema"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = $detFiles.Count; FileCount = $detFiles.Count + Duration = [TimeSpan]::Zero; SkipReason = $collisionDetail + }) + } + else { $detFilter = Build-DotnetTestFilter -Files $detFiles Write-Host " 🔍 Detection Schema Validation (scoped to $SolutionName — $($detFiles.Count) files)..." -ForegroundColor White $detSw = [System.Diagnostics.Stopwatch]::StartNew() try { - Push-Location $repoRoot + Push-Location $toolkitRoot + $previousContentRoot = $env:SENTINEL_VALIDATION_CONTENT_ROOT + $env:SENTINEL_VALIDATION_CONTENT_ROOT = $ContentRoot $detOutput = & dotnet test $detectionProject --filter "$detFilter" -v q --nologo 2>&1 $detExitCode = $LASTEXITCODE Pop-Location + if ($previousContentRoot) { $env:SENTINEL_VALIDATION_CONTENT_ROOT = $previousContentRoot } + else { Remove-Item Env:SENTINEL_VALIDATION_CONTENT_ROOT -ErrorAction SilentlyContinue } $detSw.Stop() + $detLogPath = & $saveProcessOutput "detection-schema.dotnet.log" $detOutput $detSummary = Parse-DotnetTestSummary -OutputLines ($detOutput | ForEach-Object { "$_" }) + $detSkipReason = Get-DotnetTestSkipReason -ExitCode $detExitCode -Summary $detSummary -OutputLines ($detOutput | ForEach-Object { "$_" }) if ($detExitCode -ne 0) { $failLines = $detOutput | Where-Object { $_ -match "Failed|Error|FAIL" } @@ -518,6 +829,9 @@ if (-not $SkipValidation) { Write-Host " ❌ Detection Schema validation failed" -ForegroundColor Red $overallSuccess = $false } + elseif ($detSkipReason) { + Write-Host " ⏭ Detection Schema validation skipped — $detSkipReason" -ForegroundColor Yellow + } else { $summaryLine = $detOutput | Where-Object { $_ -match "^Passed!" } | Select-Object -Last 1 if ($summaryLine) { Write-Host " $summaryLine" -ForegroundColor Gray } @@ -528,17 +842,21 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 3 Name = "Detection Schema" - Status = if ($detExitCode -ne 0) { "failed" } else { "passed" } + Status = if ($detExitCode -ne 0) { "failed" } elseif ($detSkipReason) { "skipped" } else { "passed" } Passed = $detSummary.Passed Failed = $detSummary.Failed Skipped = $detSummary.Skipped FileCount = $detFiles.Count Duration = $detSw.Elapsed Errors = $detErrors + OutputLog = $detLogPath + SkipReason = $detSkipReason }) } catch { $detSw.Stop() + if ($null -ne $previousContentRoot -and $previousContentRoot) { $env:SENTINEL_VALIDATION_CONTENT_ROOT = $previousContentRoot } + else { Remove-Item Env:SENTINEL_VALIDATION_CONTENT_ROOT -ErrorAction SilentlyContinue } Pop-Location -ErrorAction SilentlyContinue Write-Host " ❌ Detection Schema validation error: $($_.Exception.Message)" -ForegroundColor Red $overallSuccess = $false @@ -548,6 +866,7 @@ if (-not $SkipValidation) { Duration = $detSw.Elapsed; Errors = @($_.Exception.Message) }) } + } } else { Write-Host " ⏭ Detection Schema validation skipped — no Analytic Rules found in $SolutionName." -ForegroundColor Yellow @@ -567,11 +886,19 @@ if (-not $SkipValidation) { Duration = [TimeSpan]::Zero; SkipReason = ".NET Core 3.1 not installed" }) } + elseif (-not (Test-Path $detectionProject)) { + Write-Host " ⏭ Detection Schema validation skipped — toolkit test project not found: $detectionProject" -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 3; Name = "Detection Schema"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 + Duration = [TimeSpan]::Zero; SkipReason = "Toolkit test project not found" + }) + } Write-Host "" # --- Non-ASCII Validator (scoped to solution's YAML/JSON files) --- - $nonAsciiProject = Join-Path $repoRoot ".script/tests/NonAsciiValidationsTests/NonAsciiValidations.Tests.csproj" + $nonAsciiProject = Join-Path $toolkitRoot ".script/tests/NonAsciiValidationsTests/NonAsciiValidations.Tests.csproj" if ($has31 -and (Test-Path $nonAsciiProject)) { # Scope to solution content files (same approach as Detection Schema) $naFiles = Get-SolutionContentFiles -SolPath $solutionPath ` @@ -579,17 +906,23 @@ if (-not $SkipValidation) { -Extensions @("*.yaml", "*.json") if ($naFiles.Count -gt 0) { - $naFilter = Build-DotnetTestFilter -Files $naFiles + $naFilter = Build-DotnetTestFilter -Files $naFiles -UseEncodedPath Write-Host " 🔍 Non-ASCII Validation (scoped to $SolutionName — $($naFiles.Count) files)..." -ForegroundColor White $naSw = [System.Diagnostics.Stopwatch]::StartNew() try { - Push-Location $repoRoot + Push-Location $toolkitRoot + $previousContentRoot = $env:SENTINEL_VALIDATION_CONTENT_ROOT + $env:SENTINEL_VALIDATION_CONTENT_ROOT = $ContentRoot $naOutput = & dotnet test $nonAsciiProject --filter "$naFilter" -v q --nologo 2>&1 $naExitCode = $LASTEXITCODE Pop-Location + if ($previousContentRoot) { $env:SENTINEL_VALIDATION_CONTENT_ROOT = $previousContentRoot } + else { Remove-Item Env:SENTINEL_VALIDATION_CONTENT_ROOT -ErrorAction SilentlyContinue } $naSw.Stop() + $naLogPath = & $saveProcessOutput "non-ascii.dotnet.log" $naOutput $naSummary = Parse-DotnetTestSummary -OutputLines ($naOutput | ForEach-Object { "$_" }) + $naSkipReason = Get-DotnetTestSkipReason -ExitCode $naExitCode -Summary $naSummary -OutputLines ($naOutput | ForEach-Object { "$_" }) if ($naExitCode -ne 0) { $failLines = $naOutput | Where-Object { $_ -match "Failed|Error|FAIL" } @@ -599,6 +932,9 @@ if (-not $SkipValidation) { Write-Host " ❌ Non-ASCII validation failed" -ForegroundColor Red $overallSuccess = $false } + elseif ($naSkipReason) { + Write-Host " ⏭ Non-ASCII validation skipped — $naSkipReason" -ForegroundColor Yellow + } else { $summaryLine = $naOutput | Where-Object { $_ -match "^Passed!" } | Select-Object -Last 1 if ($summaryLine) { Write-Host " $summaryLine" -ForegroundColor Gray } @@ -609,17 +945,21 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 3 Name = "Non-ASCII" - Status = if ($naExitCode -ne 0) { "failed" } else { "passed" } + Status = if ($naExitCode -ne 0) { "failed" } elseif ($naSkipReason) { "skipped" } else { "passed" } Passed = $naSummary.Passed Failed = $naSummary.Failed Skipped = $naSummary.Skipped FileCount = $naFiles.Count Duration = $naSw.Elapsed Errors = $naErrors + OutputLog = $naLogPath + SkipReason = $naSkipReason }) } catch { $naSw.Stop() + if ($null -ne $previousContentRoot -and $previousContentRoot) { $env:SENTINEL_VALIDATION_CONTENT_ROOT = $previousContentRoot } + else { Remove-Item Env:SENTINEL_VALIDATION_CONTENT_ROOT -ErrorAction SilentlyContinue } Pop-Location -ErrorAction SilentlyContinue Write-Host " ❌ Non-ASCII validation error: $($_.Exception.Message)" -ForegroundColor Red $overallSuccess = $false @@ -648,11 +988,19 @@ if (-not $SkipValidation) { Duration = [TimeSpan]::Zero; Scope = "entire repo"; SkipReason = ".NET Core 3.1 not installed" }) } + elseif (-not (Test-Path $nonAsciiProject)) { + Write-Host " ⏭ Non-ASCII validation skipped — toolkit test project not found: $nonAsciiProject" -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 3; Name = "Non-ASCII"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 + Duration = [TimeSpan]::Zero; SkipReason = "Toolkit test project not found" + }) + } Write-Host "" # --- KQL Validator (scoped to solution) --- - $kqlProject = Join-Path $repoRoot ".script/tests/KqlvalidationsTests/Kqlvalidations.Tests.csproj" + $kqlProject = Join-Path $toolkitRoot ".script/tests/KqlvalidationsTests/Kqlvalidations.Tests.csproj" if ($has80 -and (Test-Path $kqlProject)) { # Collect all YAML/JSON content files from the solution for scoping $kqlFiles = Get-SolutionContentFiles -SolPath $solutionPath ` @@ -660,7 +1008,7 @@ if (-not $SkipValidation) { -Extensions @("*.yaml", "*.json") if ($kqlFiles.Count -gt 0) { - $kqlFilter = Build-DotnetTestFilter -Files $kqlFiles + $kqlFilter = Build-DotnetTestFilter -Files $kqlFiles -UseEncodedPath Write-Host " 🔍 KQL Validation (scoped to $SolutionName — $($kqlFiles.Count) files)..." -ForegroundColor White # KQL's GitHubApiClient.Create() throws without GitHub App credentials. @@ -672,13 +1020,19 @@ if (-not $SkipValidation) { $kqlSw = [System.Diagnostics.Stopwatch]::StartNew() try { - Push-Location $repoRoot + Push-Location $toolkitRoot + $previousContentRoot = $env:SENTINEL_VALIDATION_CONTENT_ROOT + $env:SENTINEL_VALIDATION_CONTENT_ROOT = $ContentRoot $kqlOutput = & dotnet test $kqlProject --filter "$kqlFilter" -v q --nologo 2>&1 $kqlExitCode = $LASTEXITCODE Pop-Location + if ($previousContentRoot) { $env:SENTINEL_VALIDATION_CONTENT_ROOT = $previousContentRoot } + else { Remove-Item Env:SENTINEL_VALIDATION_CONTENT_ROOT -ErrorAction SilentlyContinue } $kqlSw.Stop() + $kqlLogPath = & $saveProcessOutput "kql.dotnet.log" $kqlOutput $kqlSummary = Parse-DotnetTestSummary -OutputLines ($kqlOutput | ForEach-Object { "$_" }) + $kqlSkipReason = Get-DotnetTestSkipReason -ExitCode $kqlExitCode -Summary $kqlSummary -OutputLines ($kqlOutput | ForEach-Object { "$_" }) if ($kqlExitCode -ne 0) { $failLines = $kqlOutput | Where-Object { $_ -match "Failed|Error|FAIL" } @@ -688,6 +1042,9 @@ if (-not $SkipValidation) { Write-Host " ❌ KQL validation failed" -ForegroundColor Red $overallSuccess = $false } + elseif ($kqlSkipReason) { + Write-Host " ⏭ KQL validation skipped — $kqlSkipReason" -ForegroundColor Yellow + } else { $summaryLine = $kqlOutput | Where-Object { $_ -match "^Passed!" } | Select-Object -Last 1 if ($summaryLine) { Write-Host " $summaryLine" -ForegroundColor Gray } @@ -698,13 +1055,15 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 3 Name = "KQL" - Status = if ($kqlExitCode -ne 0) { "failed" } else { "passed" } + Status = if ($kqlExitCode -ne 0) { "failed" } elseif ($kqlSkipReason) { "skipped" } else { "passed" } Passed = $kqlSummary.Passed Failed = $kqlSummary.Failed Skipped = $kqlSummary.Skipped FileCount = $kqlFiles.Count Duration = $kqlSw.Elapsed Errors = $kqlErrors + OutputLog = $kqlLogPath + SkipReason = $kqlSkipReason }) } catch { @@ -726,6 +1085,12 @@ if (-not $SkipValidation) { else { Remove-Item Env:SYSTEM_PULLREQUEST_ISFORK -ErrorAction SilentlyContinue } + if ($null -ne $previousContentRoot -and $previousContentRoot) { + $env:SENTINEL_VALIDATION_CONTENT_ROOT = $previousContentRoot + } + else { + Remove-Item Env:SENTINEL_VALIDATION_CONTENT_ROOT -ErrorAction SilentlyContinue + } } } else { @@ -746,6 +1111,14 @@ if (-not $SkipValidation) { Duration = [TimeSpan]::Zero; SkipReason = ".NET 8.0 not installed" }) } + elseif (-not (Test-Path $kqlProject)) { + Write-Host " ⏭ KQL validation skipped — toolkit test project not found: $kqlProject" -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 3; Name = "KQL"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 + Duration = [TimeSpan]::Zero; SkipReason = "Toolkit test project not found" + }) + } Write-Host "" } @@ -755,7 +1128,7 @@ if (-not $SkipValidation) { # ----------------------------------------------------------------- $packageDir = Join-Path $solutionPath "Package" if (Test-Path $packageDir) { - $armTtkScript = Join-Path $repoRoot ".script/local-validation/run-arm-ttk.ps1" + $armTtkScript = Join-Path $toolkitRoot ".script/local-validation/run-arm-ttk.ps1" if (-not (Test-Path $armTtkScript)) { Write-Host " ⏭ ARM-TTK validation skipped — run-arm-ttk.ps1 not found." -ForegroundColor Yellow $null = $reportEntries.Add(@{ @@ -768,9 +1141,10 @@ if (-not $SkipValidation) { Write-Host " 🔍 ARM-TTK Validation (same checks as GitHub CI)..." -ForegroundColor White $armSw = [System.Diagnostics.Stopwatch]::StartNew() try { - $armOutput = & pwsh -NoProfile -File $armTtkScript -SolutionPath $solutionPath 2>&1 + $armOutput = & pwsh -NoProfile -File $armTtkScript -SolutionPath $solutionPath -ToolkitRoot $toolkitRoot 2>&1 $armExitCode = $LASTEXITCODE $armSw.Stop() + $armLogPath = & $saveProcessOutput "arm-ttk.log" $armOutput # Show output $armOutput | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } @@ -791,7 +1165,7 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 3; Name = "ARM-TTK"; Status = "skipped" Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 - Duration = $armSw.Elapsed; SkipReason = "No templates in Package/" + Duration = $armSw.Elapsed; SkipReason = "No templates in Package/"; OutputLog = $armLogPath }) } elseif ($armExitCode -ne 0) { @@ -802,6 +1176,7 @@ if (-not $SkipValidation) { Step = 3; Name = "ARM-TTK"; Status = "failed" Passed = $armPass; Failed = $armFail; Skipped = 0; FileCount = 0 Duration = $armSw.Elapsed; Errors = $armErrors + OutputLog = $armLogPath }) } else { @@ -809,7 +1184,7 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 3; Name = "ARM-TTK"; Status = "passed" Passed = $armPass; Failed = 0; Skipped = 0; FileCount = 0 - Duration = $armSw.Elapsed + Duration = $armSw.Elapsed; OutputLog = $armLogPath }) } } @@ -847,8 +1222,7 @@ if (-not $SkipValidation) { Write-Host " STEP 4: CI PowerShell Validators & Secret Scanning" -ForegroundColor Cyan Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan Write-Host "" - Write-Host " ℹ️ These validators check committed changes (git diff HEAD^ HEAD)." -ForegroundColor Gray - Write-Host " Commit your changes before running for full coverage." -ForegroundColor Gray + Write-Host " ℹ️ Solution-scoped validators inspect current files under $solutionPath." -ForegroundColor Gray Write-Host "" # Ensure powershell-yaml module is available (needed by all 3 PS validators) @@ -863,20 +1237,22 @@ if (-not $SkipValidation) { } # --- Validate Field Types --- - $fieldTypesScript = Join-Path $repoRoot ".script/package-automation/validateFieldTypes.ps1" + $fieldTypesScript = Join-Path $toolkitRoot ".script/package-automation/validateFieldTypes.ps1" if (Test-Path $fieldTypesScript) { Write-Host " 🔍 Field Types Validation..." -ForegroundColor White $ftSw = [System.Diagnostics.Stopwatch]::StartNew() try { - Push-Location $repoRoot + Push-Location $toolkitRoot $ftOutput = & pwsh -NoProfile -File $fieldTypesScript ` -runId "local-validation" ` -pullRequestNumber 0 ` -instrumentationKey "" ` - -baseFolderPath $repoRoot 2>&1 + -baseFolderPath $ContentRoot ` + -SolutionPath $solutionPath 2>&1 $ftExitCode = $LASTEXITCODE Pop-Location $ftSw.Stop() + $ftLogPath = & $saveProcessOutput "field-types.log" $ftOutput # Show relevant output (filter noise) $ftOutput | ForEach-Object { @@ -892,6 +1268,16 @@ if (-not $SkipValidation) { Step = 4; Name = "Field Types"; Status = "failed" Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 Duration = $ftSw.Elapsed; Errors = $ftErrors + OutputLog = $ftLogPath + }) + } + elseif (($ftOutput | ForEach-Object { "$_" }) -match "no current Package/mainTemplate\.json") { + Write-Host " ⏭ Field Types validation skipped — the selected solution has no current Package/mainTemplate.json." -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 4; Name = "Field Types"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 1; FileCount = 0 + Duration = $ftSw.Elapsed; SkipReason = "No current Package/mainTemplate.json in the selected solution" + OutputLog = $ftLogPath }) } else { @@ -899,7 +1285,7 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 4; Name = "Field Types"; Status = "passed" Passed = 1; Failed = 0; Skipped = 0; FileCount = 0 - Duration = $ftSw.Elapsed + Duration = $ftSw.Elapsed; OutputLog = $ftLogPath }) } } @@ -927,20 +1313,22 @@ if (-not $SkipValidation) { Write-Host "" # --- Validate Classic App Insights --- - $classicAIScript = Join-Path $repoRoot ".script/package-automation/validateClassicAppInsights.ps1" + $classicAIScript = Join-Path $toolkitRoot ".script/package-automation/validateClassicAppInsights.ps1" if (Test-Path $classicAIScript) { Write-Host " 🔍 Classic App Insights Validation..." -ForegroundColor White $caiSw = [System.Diagnostics.Stopwatch]::StartNew() try { - Push-Location $repoRoot + Push-Location $toolkitRoot $caiOutput = & pwsh -NoProfile -File $classicAIScript ` -runId "local-validation" ` -pullRequestNumber 0 ` -instrumentationKey "" ` - -baseFolderPath $repoRoot 2>&1 + -baseFolderPath $ContentRoot ` + -SolutionPath $solutionPath 2>&1 $caiExitCode = $LASTEXITCODE Pop-Location $caiSw.Stop() + $caiLogPath = & $saveProcessOutput "classic-app-insights.log" $caiOutput $caiOutput | ForEach-Object { $line = "$_" @@ -952,19 +1340,35 @@ if (-not $SkipValidation) { $overallSuccess = $false $caiErrors = @($caiOutput | Where-Object { "$_" -match "Error|FAIL|failed|classic|deprecated" } | ForEach-Object { "$_".Trim() }) $null = $reportEntries.Add(@{ - Step = 4; Name = "Classic App Insights"; Status = "failed" + Step = 4; Name = "Classic App Insights (Solution)"; Status = "failed" Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 Duration = $caiSw.Elapsed; Errors = $caiErrors + OutputLog = $caiLogPath + }) + } + elseif (($caiOutput | ForEach-Object { "$_" }) -match "no current azuredeploy files found in the selected folder") { + Write-Host " ⏭ Classic App Insights validation skipped — no current azuredeploy files in the selected solution." -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 4; Name = "Classic App Insights (Solution)"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 1; FileCount = 0 + Duration = $caiSw.Elapsed; SkipReason = "No current azuredeploy files in the selected solution" + OutputLog = $caiLogPath }) } else { Write-Host " ✅ Classic App Insights validation passed" -ForegroundColor Green $null = $reportEntries.Add(@{ - Step = 4; Name = "Classic App Insights"; Status = "passed" + Step = 4; Name = "Classic App Insights (Solution)"; Status = "passed" Passed = 1; Failed = 0; Skipped = 0; FileCount = 0 - Duration = $caiSw.Elapsed + Duration = $caiSw.Elapsed; OutputLog = $caiLogPath }) } + $null = $reportEntries.Add(@{ + Step = 4; Name = "Classic App Insights (Standalone DataConnectors)"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 1; FileCount = 0 + Duration = [TimeSpan]::Zero + SkipReason = "Standalone DataConnectors are outside the selected solution folder" + }) } catch { $caiSw.Stop() @@ -972,7 +1376,7 @@ if (-not $SkipValidation) { Write-Host " ❌ Classic App Insights validation error: $($_.Exception.Message)" -ForegroundColor Red $overallSuccess = $false $null = $reportEntries.Add(@{ - Step = 4; Name = "Classic App Insights"; Status = "failed" + Step = 4; Name = "Classic App Insights (Solution)"; Status = "failed" Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 Duration = $caiSw.Elapsed; Errors = @($_.Exception.Message) }) @@ -990,7 +1394,7 @@ if (-not $SkipValidation) { Write-Host "" # --- Hyperlink Validation --- - $hyperlinkScript = Join-Path $repoRoot ".script/package-automation/hyperlink-validation.ps1" + $hyperlinkScript = Join-Path $toolkitRoot ".script/package-automation/hyperlink-validation.ps1" if (Test-Path $hyperlinkScript) { # Check network connectivity first (hyperlink validation makes HTTP requests) $networkAvailable = $false @@ -1012,15 +1416,17 @@ if (-not $SkipValidation) { Write-Host " 🔍 Hyperlink Validation (slow — makes HTTP requests with 20s timeout per URL)..." -ForegroundColor White $hlSw = [System.Diagnostics.Stopwatch]::StartNew() try { - Push-Location $repoRoot + Push-Location $toolkitRoot $hlOutput = & pwsh -NoProfile -File $hyperlinkScript ` -runId "local-validation" ` -pullRequestNumber 0 ` -instrumentationKey "" ` - -baseFolderPath $repoRoot 2>&1 + -baseFolderPath $ContentRoot ` + -SolutionPath $solutionPath 2>&1 $hlExitCode = $LASTEXITCODE Pop-Location $hlSw.Stop() + $hlLogPath = & $saveProcessOutput "hyperlink-validation.log" $hlOutput $hlOutput | ForEach-Object { $line = "$_" @@ -1035,6 +1441,7 @@ if (-not $SkipValidation) { Step = 4; Name = "Hyperlink Validation"; Status = "failed" Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 Duration = $hlSw.Elapsed; Errors = $hlErrors + OutputLog = $hlLogPath }) } else { @@ -1042,7 +1449,7 @@ if (-not $SkipValidation) { $null = $reportEntries.Add(@{ Step = 4; Name = "Hyperlink Validation"; Status = "passed" Passed = 1; Failed = 0; Skipped = 0; FileCount = 0 - Duration = $hlSw.Elapsed + Duration = $hlSw.Elapsed; OutputLog = $hlLogPath }) } } @@ -1072,17 +1479,25 @@ if (-not $SkipValidation) { # --- TruffleHog Secret Scanning --- $trufflehogCmd = Get-Command trufflehog -ErrorAction SilentlyContinue - if ($trufflehogCmd) { + if ($trufflehogCmd -and $solutionPath) { + Write-Host " ⏭ TruffleHog secret scanning skipped — it scans repository history and cannot be constrained to one solution folder." -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 4; Name = "TruffleHog (Secrets)"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 + Duration = [TimeSpan]::Zero; SkipReason = "Repository-history scan cannot be scoped to a solution folder" + }) + } + elseif ($trufflehogCmd) { Write-Host " 🔍 TruffleHog Secret Scanning..." -ForegroundColor White $thSw = [System.Diagnostics.Stopwatch]::StartNew() try { - $excludePathsFile = Join-Path $repoRoot ".script/SecretScanning/Excludepathlist" - $thArgs = @("git", "file://$repoRoot", "--only-verified", "--fail") + $excludePathsFile = Join-Path $toolkitRoot ".script/SecretScanning/Excludepathlist" + $thArgs = @("git", "file://$toolkitRoot", "--only-verified", "--fail") if (Test-Path $excludePathsFile) { $thArgs += @("--exclude-paths=$excludePathsFile") } - Push-Location $repoRoot + Push-Location $toolkitRoot $thOutput = & trufflehog @thArgs 2>&1 $thExitCode = $LASTEXITCODE Pop-Location @@ -1137,6 +1552,48 @@ if (-not $SkipValidation) { Write-Host "" } +if ($PackageReportPath -and $null -ne $validationEvidenceBefore) { + try { + $validationEvidenceAfter = Get-ValidationEvidenceSnapshot ` + -ToolkitRoot $toolkitRoot ` + -ContentRoot $ContentRoot ` + -SolutionPath $solutionPath ` + -PackageReportPath $PackageReportPath + $validationBinding = Complete-ValidationEvidenceBinding ` + -Before $validationEvidenceBefore ` + -After $validationEvidenceAfter ` + -ValidationEntries @($reportEntries) ` + -PackagingSkipped ([bool]$SkipPackaging) ` + -ValidationSkipped ([bool]$SkipValidation) ` + -EvidenceRunPath $evidenceRunPath + $bindingReceipt = Write-ValidationEvidenceBinding ` + -EvidenceRunPath $evidenceRunPath ` + -Binding $validationBinding + $reportMeta.ValidationBindingPath = $bindingReceipt.path + $reportMeta.ValidationBindingSha256 = $bindingReceipt.sha256 + $reportMeta.ValidationBindingResult = $validationBinding.validationResult + if ($validationBinding.validationResult -ne "PASSED") { + $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 4; Name = "Validation Evidence Integrity"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @($validationBinding.issues) + }) + } + } + catch { + $overallSuccess = $false + $null = $reportEntries.Add(@{ + Step = 4; Name = "Validation Evidence Integrity"; Status = "failed" + Passed = 0; Failed = 1; Skipped = 0; FileCount = 0 + Errors = @("Could not complete V3.1 validation evidence: $($_.Exception.Message)") + }) + } +} +elseif ($PackageReportPath -and $validationEvidenceCaptureError) { + $reportMeta.ValidationBindingResult = "FAILED" +} + # ============================================================================= # APPLY FILE UPDATES (only if all validations passed) # ============================================================================= @@ -1245,10 +1702,36 @@ function Write-BuildReport { Add-Line "# BUILD & VALIDATION REPORT" Add-Line "" Add-Line "Solution: $($Meta.SolutionName)" + Add-Line "Toolkit root: $toolkitRoot" + Add-Line "Content root: $ContentRoot" + Add-Line "Evidence: $($Meta.EvidenceRunPath)" + $typescriptReportPath = Join-Path $Meta.EvidenceRunPath "typescript-results.json" + if (Test-Path $typescriptReportPath -PathType Leaf) { + Add-Line "TypeScript JSON: $typescriptReportPath" + } + else { + Add-Line "TypeScript JSON: not produced (validation skipped, compile failed, or runner failed before report creation)" + } + $typescriptOutputPaths = @( + (Join-Path $Meta.EvidenceRunPath "typescript.stdout.log"), + (Join-Path $Meta.EvidenceRunPath "typescript.stderr.log"), + (Join-Path $Meta.EvidenceRunPath "typescript-compile.log") + ) | Where-Object { Test-Path $_ -PathType Leaf } + if ($typescriptOutputPaths.Count -gt 0) { + Add-Line "TypeScript process output: $($typescriptOutputPaths -join ', ')" + } Add-Line "Version: $versionStr" Add-Line "Date: $dateStr" Add-Line "Duration: $durationStr" Add-Line "Result: $(if ($Success) { 'PASSED' } else { 'FAILED' })" + if ($Meta.ValidationBindingPath) { + Add-Line "Validation binding: $($Meta.ValidationBindingPath)" + Add-Line "Validation binding SHA256: $($Meta.ValidationBindingSha256)" + Add-Line "Validation binding result: $($Meta.ValidationBindingResult)" + } + elseif ($Meta.ValidationBindingResult) { + Add-Line "Validation binding result: $($Meta.ValidationBindingResult)" + } Add-Line "" # --- STEP 1: PACKAGING --- @@ -1291,13 +1774,13 @@ function Write-BuildReport { if ($e.Duration -and $e.Duration.TotalSeconds -gt 0) { $detailParts += (Format-Duration $e.Duration) } if ($e.FileCount -and $e.FileCount -gt 0) { $detailParts += "$($e.FileCount) files" } if ($e.SkipReason) { $detailParts += $e.SkipReason } + if ($e.OutputLog) { $detailParts += "full output: $($e.OutputLog)" } $detail = if ($detailParts.Count -gt 0) { $detailParts -join ", " } else { "-" } Add-Line "| $stepLabel | $($e.Name) | $status | $detail |" if ($e.Status -eq "failed" -and $e.Errors) { - foreach ($err in $e.Errors | Select-Object -First 3) { - $cleanErr = ($err -replace '\|', '/' -replace '\r?\n', ' ').Trim() - if ($cleanErr.Length -gt 80) { $cleanErr = $cleanErr.Substring(0, 77) + "..." } + foreach ($err in $e.Errors) { + $cleanErr = $err -replace '\|', '\|' -replace '\r?\n', '
' Add-Line "| | | | $cleanErr |" } } @@ -1325,6 +1808,9 @@ function Write-BuildReport { Add-Line "" # --- Write report to stdout --- + $reportPath = Join-Path $Meta.EvidenceRunPath "build-and-validate-report.md" + $reportContent = "[REPORT_START]`n" + ($lines -join "`n") + "`n[REPORT_END]`n" + [System.IO.File]::WriteAllText($reportPath, $reportContent, [System.Text.UTF8Encoding]::new($false)) Write-Host "[REPORT_START]" foreach ($l in $lines) { Write-Host $l } Write-Host "[REPORT_END]" diff --git a/.script/local-validation/run-arm-ttk.ps1 b/.script/local-validation/run-arm-ttk.ps1 index 77b5f5fa4ab..aca3c0a1db7 100644 --- a/.script/local-validation/run-arm-ttk.ps1 +++ b/.script/local-validation/run-arm-ttk.ps1 @@ -19,13 +19,19 @@ Full path to the solution folder (e.g., C:\repo\Solutions\MySolution). Must contain a Package/ subfolder with mainTemplate.json. +.PARAMETER ToolkitRoot + Checkout containing the ARM-TTK helper module and .gitignore. Defaults to the + toolkit checkout containing this script, independently of SolutionPath. + .EXAMPLE ./run-arm-ttk.ps1 -SolutionPath "C:/sentinel/AzureSentinel/Solutions/CrowdStrike Falcon Endpoint Protection" #> param( [Parameter(Mandatory = $true)] - [string]$SolutionPath + [string]$SolutionPath, + + [string]$ToolkitRoot ) $ErrorActionPreference = "Continue" @@ -33,11 +39,16 @@ $ErrorActionPreference = "Continue" # -------------------------------------------------------------------------- # Resolve repo root # -------------------------------------------------------------------------- -$repoRoot = git rev-parse --show-toplevel 2>$null -if (-not $repoRoot) { - $repoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName +$repoRoot = if ($ToolkitRoot) { + [System.IO.Path]::GetFullPath($ToolkitRoot) +} else { + [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "../..")) +} +if (-not (Test-Path (Join-Path $repoRoot ".script/local-validation/run-arm-ttk.ps1") -PathType Leaf)) { + Write-Host "ARM-TTK: Invalid toolkit root: $repoRoot" -ForegroundColor Red + Write-Host "ARM-TTK-SUMMARY: Pass=0 Fail=1 Total=1 Error=true" + exit 2 } -$repoRoot = $repoRoot.Replace('\', '/') # -------------------------------------------------------------------------- # Locate Package folder diff --git a/.script/local-validation/validate.ts b/.script/local-validation/validate.ts index 31f8831dc70..16c3e771a8c 100644 --- a/.script/local-validation/validate.ts +++ b/.script/local-validation/validate.ts @@ -22,6 +22,8 @@ * --only Run only specified validators (e.g. --only json,yaml,data-connector) * --verbose Show detailed output for all files (not just failures) * --json Output results as JSON (for programmatic consumption) + * --json-file Write complete JSON results to a file (used by the build runner) + * --repository-root Use this repository root for discovery and validation * --help Show this help message * * Exit codes: @@ -30,16 +32,17 @@ * 2 = Configuration/usage error * * Requirements: - * - Run from the repository root directory + * - Run from the repository root directory, or pass --repository-root * - Node.js 16+ with npm dependencies installed (npm install) * - TypeScript compiled (npm run tsc) - * - For .NET validators: .NET SDK 6.0+ installed + * - For .NET validators: the SDK and each test project's target runtime installed * - For ARM-TTK: PowerShell with ARM-TTK module */ import { execSync } from "child_process"; import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; // --- Safe imports: pure checker utilities with ZERO GitHub/Octokit dependencies --- import { isValidSchema } from "../utils/jsonSchemaChecker.js"; @@ -64,6 +67,9 @@ import { IsValidSupportObject } from "../SolutionValidations/validSupportObject. import { IsValidBrandingContent } from "../SolutionValidations/validMSBranding.js"; import { IsValidSolutionID } from "../SolutionValidations/validSolutionID.js"; +const toolkitRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const invocationDirectory = process.cwd(); +let contentRoot = toolkitRoot; // ============================================================================ // TYPES @@ -96,6 +102,8 @@ interface CLIOptions { mode: "diff" | "path" | "files" | "auto"; targetBranch?: string; targetPath?: string; + repositoryRoot?: string; + jsonFilePath?: string; files?: string[]; skip: string[]; only: string[]; @@ -122,6 +130,12 @@ function printHelp(): void { console.log(helpLines.join("\n")); } +function isPathWithin(rootPath: string, candidatePath: string): boolean { + const relativePath = path.relative(rootPath, candidatePath); + return relativePath === "" + || (relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath)); +} + function parseArgs(argv: string[]): CLIOptions { const args = argv.slice(2); // Skip node and script path const options: CLIOptions = { @@ -189,6 +203,27 @@ function parseArgs(argv: string[]): CLIOptions { options.jsonOutput = true; i++; break; + case "--json-file": + i++; + if (i < args.length && !args[i].startsWith("--")) { + options.jsonFilePath = args[i]; + options.jsonOutput = true; + i++; + } else { + console.error("Error: --json-file requires a file path"); + process.exit(2); + } + break; + case "--repository-root": + i++; + if (i < args.length && !args[i].startsWith("--")) { + options.repositoryRoot = args[i]; + i++; + } else { + console.error("Error: --repository-root requires a directory path"); + process.exit(2); + } + break; case "--help": case "-h": printHelp(); @@ -271,6 +306,16 @@ function getChangedFiles(targetBranch: string): ChangedFile[] { */ function getFilesInPath(dirPath: string): ChangedFile[] { const results: ChangedFile[] = []; + const resolvedPath = path.resolve(process.cwd(), dirPath); + if (!isPathWithin(process.cwd(), resolvedPath) + || !fs.existsSync(resolvedPath) + || !fs.lstatSync(resolvedPath).isDirectory() + || fs.lstatSync(resolvedPath).isSymbolicLink()) { + console.error(`Error: --path must name an existing directory: ${dirPath}`); + process.exit(2); + } + const selectedPathParts = path.relative(process.cwd(), resolvedPath).split(path.sep); + const isSelectedSolution = selectedPathParts.length === 2 && selectedPathParts[0].toLowerCase() === "solutions"; function walk(dir: string): void { if (!fs.existsSync(dir)) { @@ -283,17 +328,25 @@ function getFilesInPath(dirPath: string): ChangedFile[] { if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".script") { continue; // Skip these directories } + if (isSelectedSolution && ["logs", "reports", "evidence"].includes(entry.name.toLowerCase())) { + continue; + } walk(fullPath); } else { + if (entry.isSymbolicLink()) { + const realPath = fs.realpathSync(fullPath); + if (!isPathWithin(process.cwd(), realPath)) { + console.error(`Error: content path escapes the selected repository root: ${fullPath}`); + process.exit(2); + } + } // Normalize to forward slashes (repo-relative paths) const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, "/"); - results.push({ status: "A", filePath: relativePath }); // Treat all as "Added" for full validation + results.push({ status: "M", filePath: relativePath }); // Validate current files using modified-file rules } } } - // Resolve relative to cwd - const resolvedPath = path.resolve(process.cwd(), dirPath); walk(resolvedPath); return results; } @@ -425,7 +478,7 @@ async function validateDataConnector(filePath: string, _fileKind: string): Promi } const connectorCategory = getConnectorCategory(jsonFile.dataTypes, jsonFile.instructionSteps || []); - const schemaPath = `.script/utils/schemas/${connectorCategory}_ConnectorSchema.json`; + const schemaPath = path.join(toolkitRoot, `.script/utils/schemas/${connectorCategory}_ConnectorSchema.json`); if (fs.existsSync(schemaPath)) { const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); @@ -468,7 +521,7 @@ async function validateContent(filePath: string, _fileKind: string): Promise filePath.includes(item)); const hasDataConnectorFolder = dataConnectorsFolder.some(item => filePath.includes(item)); - const jsonTagObj = JSON.parse(fs.readFileSync("./.script/validate-tag.json", "utf8")); + const jsonTagObj = JSON.parse(fs.readFileSync(path.join(toolkitRoot, ".script/validate-tag.json"), "utf8")); let tagName = ""; if (jsonTagObj.hasOwnProperty("createUiDefinition") && filePath.includes("createUiDefinition")) { @@ -554,7 +607,7 @@ async function validatePlaybook(filePath: string, _fileKind: string): Promise { try { const workbooksMetadata = JSON.parse(fs.readFileSync(filePath, "utf8")); - const schemaPath = ".script/utils/schemas/workbooksMetadataSchema.json"; + const schemaPath = path.join(toolkitRoot, ".script/utils/schemas/workbooksMetadataSchema.json"); if (fs.existsSync(schemaPath)) { const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); isValidSchema(workbooksMetadata, schema); @@ -755,7 +808,7 @@ async function validateIdChange(filePath: string, fileKind: string): Promise { +async function runDotNetValidator(projectPath: string, validatorName: string, selectedContentRoot: string): Promise { const results: ValidationResult[] = []; try { if (!fs.existsSync(projectPath)) { @@ -823,6 +876,10 @@ async function runDotNetValidator(projectPath: string, validatorName: string): P encoding: "utf8", stdio: "pipe", timeout: 300000, // 5 minute timeout + env: { + ...process.env, + SENTINEL_VALIDATION_CONTENT_ROOT: selectedContentRoot, + }, }); results.push({ validator: validatorName, filePath: projectPath, passed: true }); @@ -846,7 +903,7 @@ async function runArmTtkValidator(files: ChangedFile[]): Promise r.passed && !r.skipped).length, - failed: results.filter(r => !r.passed).length, + failed: results.filter(r => !r.passed && !r.skipped).length, skipped: results.filter(r => r.skipped).length, }, results, - }, null, 2)); + }, null, 2); + if (jsonFilePath) { + fs.writeFileSync(jsonFilePath, report, "utf8"); + console.log(`Complete TypeScript validation results written to ${jsonFilePath}`); + } else { + console.log(report); + } return; } const passed = results.filter(r => r.passed && !r.skipped); - const failed = results.filter(r => !r.passed); + const failed = results.filter(r => !r.passed && !r.skipped); const skipped = results.filter(r => r.skipped); console.log("\n" + "=".repeat(70)); @@ -1057,7 +1120,7 @@ function printReport(results: ValidationResult[], verbose: boolean, jsonOutput: } for (const [validatorName, validatorResults] of byValidator) { - const failures = validatorResults.filter(r => !r.passed); + const failures = validatorResults.filter(r => !r.passed && !r.skipped); const passes = validatorResults.filter(r => r.passed && !r.skipped); const skips = validatorResults.filter(r => r.skipped); @@ -1069,6 +1132,8 @@ function printReport(results: ValidationResult[], verbose: boolean, jsonOutput: console.log(` ${f.error}`); } } + } else if (passes.length === 0 && skips.length > 0) { + console.log(`\n⏭ ${validatorName} (${skips.length} skipped)`); } else { console.log(`\n✅ ${validatorName} (${passes.length} passed, ${skips.length} skipped)`); } @@ -1095,12 +1160,30 @@ function printReport(results: ValidationResult[], verbose: boolean, jsonOutput: async function main(): Promise { const options = parseArgs(process.argv); + if (options.repositoryRoot) { + const repositoryRoot = path.resolve(options.repositoryRoot); + const realRepositoryRoot = fs.existsSync(repositoryRoot) ? fs.realpathSync(repositoryRoot) : repositoryRoot; + const solutionsPath = path.join(repositoryRoot, "Solutions"); + if (!fs.existsSync(repositoryRoot) || !fs.statSync(repositoryRoot).isDirectory() + || path.relative(repositoryRoot, realRepositoryRoot) !== "" + || !fs.existsSync(solutionsPath) + || !fs.lstatSync(solutionsPath).isDirectory() + || fs.lstatSync(solutionsPath).isSymbolicLink()) { + console.error(`Error: --repository-root must name a repository directory containing Solutions/: ${options.repositoryRoot}`); + process.exit(2); + } + contentRoot = repositoryRoot; + } + if (options.jsonFilePath) { + options.jsonFilePath = path.resolve(invocationDirectory, options.jsonFilePath); + } - // Ensure we're in the repo root - if (!fs.existsSync(".script") || !fs.existsSync("package.json")) { + // Keep tooling requirements rooted in the checkout that contains this runner. + if (!fs.existsSync(path.join(toolkitRoot, ".script")) || !fs.existsSync(path.join(toolkitRoot, "package.json"))) { console.error("Error: Please run this script from the Azure Sentinel repository root directory."); process.exit(2); } + process.chdir(contentRoot); // --- File Discovery --- let files: ChangedFile[]; @@ -1139,6 +1222,8 @@ async function main(): Promise { if (files.length === 0) { if (!options.jsonOutput) { console.log("No files to validate. Exiting."); + } else { + printReport([], options.verbose, options.jsonOutput, options.jsonFilePath); } process.exit(0); } @@ -1173,40 +1258,51 @@ async function main(): Promise { // --- Run .NET Validators (if not skipped) --- const dotnetValidators = [ - { id: "kql", name: "KQL Validation", project: ".script/tests/KqlvalidationsTests/Kqlvalidations.Tests.csproj" }, - { id: "detection-schema", name: "Detection Template Schema", project: ".script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidation.Tests.csproj" }, - { id: "non-ascii", name: "Non-ASCII", project: ".script/tests/NonAsciiValidationsTests/NonAsciiValidations.Tests.csproj" }, + { id: "kql", name: "KQL Validation", project: path.join(toolkitRoot, ".script/tests/KqlvalidationsTests/Kqlvalidations.Tests.csproj") }, + { id: "detection-schema", name: "Detection Template Schema", project: path.join(toolkitRoot, ".script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidation.Tests.csproj") }, + { id: "non-ascii", name: "Non-ASCII", project: path.join(toolkitRoot, ".script/tests/NonAsciiValidationsTests/NonAsciiValidations.Tests.csproj") }, ]; for (const dotnetValidator of dotnetValidators) { const shouldRun = (options.only.length === 0 || options.only.includes(dotnetValidator.id)) && !options.skip.includes(dotnetValidator.id); if (shouldRun) { - // Only run .NET validators if dotnet is available + if (options.mode === "path") { + allResults.push({ + validator: dotnetValidator.name, + filePath: dotnetValidator.project, + passed: true, + skipped: true, + skipReason: "The standalone --path runner cannot safely scope this repo-wide .NET test project; use build-and-validate.ps1 for scoped solution validation", + }); + continue; + } + let dotnetAvailable = false; try { execSync("dotnet --version", { encoding: "utf8", stdio: "pipe" }); - const dotnetResults = await runDotNetValidator(dotnetValidator.project, dotnetValidator.name); - - // Detect GitHub App credential failures and convert to skips with clear guidance - for (const result of dotnetResults) { - if (!result.passed && result.error && result.error.includes("GitHub App ID")) { - result.passed = true; - result.skipped = true; - result.skipReason = "Requires GitHub App credentials (only available in CI) — skip locally with --skip " + dotnetValidator.id; - result.error = undefined; - } - } - - allResults.push(...dotnetResults); - } catch { + dotnetAvailable = true; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); allResults.push({ validator: dotnetValidator.name, filePath: dotnetValidator.project, passed: true, skipped: true, - skipReason: ".NET SDK not found — install .NET SDK to run this validator", + skipReason: `.NET SDK command is unavailable: ${message}`, }); } + if (!dotnetAvailable) continue; + + const dotnetResults = await runDotNetValidator(dotnetValidator.project, dotnetValidator.name, contentRoot); + for (const result of dotnetResults) { + if (!result.passed && result.error && result.error.includes("GitHub App ID")) { + result.passed = true; + result.skipped = true; + result.skipReason = "Requires GitHub App credentials (only available in CI) — skip locally with --skip " + dotnetValidator.id; + result.error = undefined; + } + } + allResults.push(...dotnetResults); } } @@ -1214,20 +1310,31 @@ async function main(): Promise { const shouldRunArmTtk = (options.only.length === 0 || options.only.includes("arm-ttk")) && !options.skip.includes("arm-ttk"); if (shouldRunArmTtk) { + let powershellAvailable = false; try { execSync("pwsh --version", { encoding: "utf8", stdio: "pipe" }); + powershellAvailable = true; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + allResults.push({ + validator: "ARM-TTK", + filePath: "pwsh", + passed: true, + skipped: true, + skipReason: `PowerShell command is unavailable: ${message}`, + }); + } + if (powershellAvailable) { const armTtkResults = await runArmTtkValidator(files); allResults.push(...armTtkResults); - } catch { - // PowerShell not available — skip silently } } // --- Report --- - printReport(allResults, options.verbose, options.jsonOutput); + printReport(allResults, options.verbose, options.jsonOutput, options.jsonFilePath); // --- Exit Code --- - const hasFailures = allResults.some(r => !r.passed); + const hasFailures = allResults.some(r => !r.passed && !r.skipped); process.exit(hasFailures ? 1 : 0); } diff --git a/.script/local-validation/validation-binding.ps1 b/.script/local-validation/validation-binding.ps1 new file mode 100644 index 00000000000..00a6219bd34 --- /dev/null +++ b/.script/local-validation/validation-binding.ps1 @@ -0,0 +1,480 @@ +$script:ValidationInputExcludedDirectories = @( + "package", + "logs", + "evidence", + ".git", + ".venv", + "__pycache__" +) + +function Get-ValidationFileFingerprints { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [string[]]$ExcludeDirectories = @(), + + [ValidateSet("Ordinal", "Posix", "Windows")] + [string]$PathOrder = "Ordinal" + ) + + $rootPath = [System.IO.Path]::GetFullPath($Root) + if (-not (Test-Path -LiteralPath $rootPath -PathType Container)) { + throw "Fingerprint root does not exist: $rootPath" + } + $rootItem = Get-Item -LiteralPath $rootPath -Force + if (($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Fingerprint root cannot be a symbolic link: $rootPath" + } + + $excluded = @($ExcludeDirectories | ForEach-Object { $_.ToLowerInvariant() }) + $byPath = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($item in Get-ChildItem -LiteralPath $rootPath -Force -Recurse -ErrorAction Stop) { + $relative = [System.IO.Path]::GetRelativePath($rootPath, $item.FullName).Replace("\", "/") + $parts = $relative -split "/" + if (@($parts | Where-Object { $excluded -contains $_.ToLowerInvariant() }).Count -gt 0) { + continue + } + if (@($parts | Where-Object { + $_ -ieq ".env" -or $_.StartsWith(".env.", [System.StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0) { + continue + } + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Fingerprint input cannot be a symbolic link: $relative" + } + if ($item.PSIsContainer) { + continue + } + $digest = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + $byPath.Add($relative, [ordered]@{ + path = $relative + sha256 = $digest + }) + } + + [string[]]$paths = @($byPath.Keys) + if ($PathOrder -in @("Posix", "Windows")) { + $pathComparer = [System.StringComparer]::Ordinal + if ($PathOrder -eq "Windows") { + $normalizedPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($path in $paths) { + $normalizedPath = (@($path.Split("/") | ForEach-Object { $_.ToLowerInvariant() }) -join "/") + if (-not $normalizedPaths.Add($normalizedPath)) { + throw "Windows Path ordering is ambiguous for case-insensitive duplicate source paths: $path" + } + } + } + [System.Array]::Sort($paths, [System.Comparison[string]]{ + param([string]$left, [string]$right) + + $leftParts = $left.Split("/") + $rightParts = $right.Split("/") + if ($PathOrder -eq "Windows") { + $leftParts = @($leftParts | ForEach-Object { $_.ToLowerInvariant() }) + $rightParts = @($rightParts | ForEach-Object { $_.ToLowerInvariant() }) + } + $sharedParts = [Math]::Min($leftParts.Length, $rightParts.Length) + for ($index = 0; $index -lt $sharedParts; $index++) { + $comparison = $pathComparer.Compare( + $leftParts[$index], + $rightParts[$index] + ) + if ($comparison -ne 0) { + return $comparison + } + } + return $leftParts.Length.CompareTo($rightParts.Length) + }) + } + else { + [System.Array]::Sort($paths, [System.StringComparer]::Ordinal) + } + $result = [System.Collections.Generic.List[object]]::new() + foreach ($path in $paths) { + $result.Add($byPath[$path]) + } + return ,$result.ToArray() +} + +function ConvertTo-ValidationJsonString { + param([Parameter(Mandatory = $true)][string]$Value) + + $builder = [System.Text.StringBuilder]::new() + [void]$builder.Append('"') + foreach ($character in $Value.ToCharArray()) { + $code = [int]$character + if ($code -eq 0x22) { + [void]$builder.Append('\"') + continue + } + if ($code -eq 0x5C) { + [void]$builder.Append('\\') + continue + } + if ($code -eq 0x08) { + [void]$builder.Append('\b') + continue + } + if ($code -eq 0x0C) { + [void]$builder.Append('\f') + continue + } + if ($code -eq 0x0A) { + [void]$builder.Append('\n') + continue + } + if ($code -eq 0x0D) { + [void]$builder.Append('\r') + continue + } + if ($code -eq 0x09) { + [void]$builder.Append('\t') + continue + } + if ($code -lt 0x20 -or $code -ge 0x7F) { + [void]$builder.Append('\u') + [void]$builder.Append($code.ToString("x4")) + } + else { + [void]$builder.Append($character) + } + } + [void]$builder.Append('"') + return $builder.ToString() +} + +function Get-ValidationInputFingerprintHash { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$Files + ) + + $items = foreach ($file in $Files) { + '{"path":' + (ConvertTo-ValidationJsonString -Value $file.path) + + ',"sha256":' + (ConvertTo-ValidationJsonString -Value $file.sha256) + '}' + } + $canonical = '[' + (@($items) -join ',') + ']' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($canonical) + return [Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($bytes) + ).ToLowerInvariant() +} + +function Resolve-ValidationEvidencePath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Reference + ) + + if ([string]::IsNullOrWhiteSpace($Reference)) { + throw "V3.1 package artifact reference is empty" + } + $rootPath = [System.IO.Path]::GetFullPath($Root) + $candidate = if ([System.IO.Path]::IsPathRooted($Reference)) { + [System.IO.Path]::GetFullPath($Reference) + } + else { + [System.IO.Path]::GetFullPath((Join-Path $rootPath $Reference)) + } + $prefix = $rootPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $candidate.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "V3.1 package report path escapes the selected solution: $Reference" + } + + $relative = [System.IO.Path]::GetRelativePath($rootPath, $candidate) + $cursor = $rootPath + foreach ($component in ($relative -split '[\\/]')) { + $cursor = Join-Path $cursor $component + if (Test-Path -LiteralPath $cursor) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "V3.1 package report path traverses a symbolic link: $Reference" + } + } + } + return $candidate +} + +function Get-ValidationV31PackageEvidence { + param( + [Parameter(Mandatory = $true)] + [string]$SolutionPath, + + [Parameter(Mandatory = $true)] + [string]$PackageReportPath + ) + + $solutionRoot = [System.IO.Path]::GetFullPath($SolutionPath) + $reportPath = Resolve-ValidationEvidencePath -Root $solutionRoot -Reference $PackageReportPath + if (-not (Test-Path -LiteralPath $reportPath -PathType Leaf)) { + throw "V3.1 package report does not exist: $reportPath" + } + if ([System.IO.Path]::GetFileName($reportPath) -notmatch '^packaging\.v3_1(?:\.[^\\/]+)?\.json$') { + throw "PackageReportPath must identify a V3.1 packaging report: $reportPath" + } + + try { + $reportBytes = [System.IO.File]::ReadAllBytes($reportPath) + $report = [System.IO.File]::ReadAllText($reportPath) | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "V3.1 package report is unreadable or invalid JSON: $($_.Exception.Message)" + } + if ($report.packager -ne "V3.1") { + throw "PackageReportPath does not contain V3.1 packaging evidence" + } + + $packageInputSha256 = [string]$report.packageInputs.after.sha256 + if ($packageInputSha256 -notmatch '^[a-fA-F0-9]{64}$') { + throw "V3.1 package report has no valid post-package input fingerprint" + } + if ([string]::IsNullOrWhiteSpace([string]$report.packageAttemptId) -or + [string]::IsNullOrWhiteSpace([string]$report.workflowRunId)) { + throw "V3.1 package report has no workflow and package-attempt identity" + } + if ([string]::IsNullOrWhiteSpace([string]$report.version)) { + throw "V3.1 package report has no package version" + } + if ($null -eq $report.packageArtifactHashes) { + throw "V3.1 package report has no package artifact hashes" + } + $packageArtifactsSha256 = [string]$report.packageArtifactsSha256 + if ($packageArtifactsSha256 -notmatch '^[a-fA-F0-9]{64}$') { + throw "V3.1 package report has no valid aggregate package artifact fingerprint" + } + + $artifactHashes = [ordered]@{} + foreach ($name in @("mainTemplate", "createUiDefinition", "testParameters", "zip")) { + $reference = [string]$report.$name + $expectedHash = [string]$report.packageArtifactHashes.$name + if ([string]::IsNullOrWhiteSpace($reference) -or $expectedHash -notmatch '^[a-fA-F0-9]{64}$') { + throw "V3.1 package report is missing a valid $name artifact fingerprint" + } + $artifactPath = Resolve-ValidationEvidencePath -Root $solutionRoot -Reference $reference + if (-not (Test-Path -LiteralPath $artifactPath -PathType Leaf)) { + throw "V3.1 package artifact is missing: $reference" + } + $actualHash = (Get-FileHash -LiteralPath $artifactPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash.ToLowerInvariant()) { + throw "V3.1 package artifact is stale: $name" + } + $artifactHashes[$name] = $expectedHash.ToLowerInvariant() + } + + $packageDirectory = Join-Path $solutionRoot "Package" + $packageFiles = Get-ValidationFileFingerprints -Root $packageDirectory + return [ordered]@{ + path = [System.IO.Path]::GetRelativePath($solutionRoot, $reportPath).Replace("\", "/") + sha256 = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($reportBytes)).ToLowerInvariant() + version = [string]$report.version + packageAttemptId = [string]$report.packageAttemptId + workflowRunId = [string]$report.workflowRunId + packageInputSha256 = $packageInputSha256.ToLowerInvariant() + packageArtifactsSha256 = $packageArtifactsSha256.ToLowerInvariant() + packageArtifactHashes = $artifactHashes + packageFiles = $packageFiles + } +} + +function Get-ValidationEvidenceSnapshot { + param( + [Parameter(Mandatory = $true)] + [string]$ToolkitRoot, + + [Parameter(Mandatory = $true)] + [string]$ContentRoot, + + [Parameter(Mandatory = $true)] + [string]$SolutionPath, + + [Parameter(Mandatory = $true)] + [string]$PackageReportPath + ) + + $toolkitPath = [System.IO.Path]::GetFullPath($ToolkitRoot) + $contentPath = [System.IO.Path]::GetFullPath($ContentRoot) + $solutionRoot = [System.IO.Path]::GetFullPath($SolutionPath) + $packageEvidence = Get-ValidationV31PackageEvidence ` + -SolutionPath $solutionRoot ` + -PackageReportPath $PackageReportPath + $posixInputFiles = Get-ValidationFileFingerprints ` + -Root $solutionRoot ` + -ExcludeDirectories $script:ValidationInputExcludedDirectories ` + -PathOrder Posix + $posixInputFingerprint = Get-ValidationInputFingerprintHash -Files $posixInputFiles + $windowsInputFiles = $null + $windowsInputFingerprint = $null + try { + $windowsInputFiles = Get-ValidationFileFingerprints ` + -Root $solutionRoot ` + -ExcludeDirectories $script:ValidationInputExcludedDirectories ` + -PathOrder Windows + $windowsInputFingerprint = Get-ValidationInputFingerprintHash -Files $windowsInputFiles + } + catch { + $windowsInputFiles = $null + } + + $matchesPosix = $posixInputFingerprint -eq $packageEvidence.packageInputSha256 + $matchesWindows = $windowsInputFingerprint -and + $windowsInputFingerprint -eq $packageEvidence.packageInputSha256 + if (-not $matchesPosix -and -not $matchesWindows) { + throw "V3.1 package report is stale against current solution inputs under both POSIX and Windows Path ordering" + } + if ($matchesPosix -and $matchesWindows) { + $posixJson = ConvertTo-Json -InputObject @($posixInputFiles) -Depth 20 -Compress + $windowsJson = ConvertTo-Json -InputObject @($windowsInputFiles) -Depth 20 -Compress + if ($posixJson -cne $windowsJson) { + throw "V3.1 package input fingerprint ambiguously matches different POSIX and Windows Path orderings" + } + $inputFiles = $posixInputFiles + $sourcePathSemantics = "platform-neutral" + } + elseif ($matchesWindows) { + $inputFiles = $windowsInputFiles + $sourcePathSemantics = "windows" + } + else { + $inputFiles = $posixInputFiles + $sourcePathSemantics = "posix" + } + $producerPlatform = if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + "windows" + } + else { + "posix" + } + $producerFiles = [ordered]@{} + foreach ($name in @("build-and-validate.ps1", "validation-binding.ps1")) { + $path = Join-Path $toolkitPath ".script/local-validation/$name" + $producerFiles[$name] = (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + return [ordered]@{ + capturedAtUtc = [DateTime]::UtcNow.ToString("o") + toolkitRoot = $toolkitPath + contentRoot = $contentPath + solutionPath = $solutionRoot + inputFiles = $inputFiles + sourcePathSemantics = $sourcePathSemantics + producerPlatform = $producerPlatform + package = $packageEvidence + producerFiles = $producerFiles + } +} + +function Complete-ValidationEvidenceBinding { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$Before, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$After, + + [Parameter(Mandatory = $true)] + [object[]]$ValidationEntries, + + [Parameter(Mandatory = $true)] + [bool]$PackagingSkipped, + + [Parameter(Mandatory = $true)] + [bool]$ValidationSkipped, + + [Parameter(Mandatory = $true)] + [string]$EvidenceRunPath + ) + + $validationEntries = @($ValidationEntries | Where-Object { $_.Step -in 2, 3, 4 }) + $passed = @($validationEntries | Where-Object { $_.Status -eq "passed" }).Count + $failed = @($validationEntries | Where-Object { $_.Status -eq "failed" }).Count + $skipped = @($validationEntries | Where-Object { $_.Status -eq "skipped" }).Count + $issues = [System.Collections.Generic.List[string]]::new() + + if (-not $PackagingSkipped) { $issues.Add("V3.1 validation evidence must be collected with packaging skipped") } + if ($ValidationSkipped) { $issues.Add("validation was skipped") } + if ($passed -eq 0) { $issues.Add("no validator completed successfully") } + if ($failed -gt 0) { $issues.Add("one or more validators failed") } + if ($Before.contentRoot -ne $After.contentRoot -or $Before.solutionPath -ne $After.solutionPath) { + $issues.Add("ContentRoot or solution path changed during validation") + } + if ($Before.sourcePathSemantics -ne $After.sourcePathSemantics -or + $Before.producerPlatform -ne $After.producerPlatform) { + $issues.Add("the package Path ordering identity or validation producer platform changed during validation") + } + $beforeArtifactHashes = ConvertTo-Json -InputObject $Before.package.packageArtifactHashes -Depth 10 -Compress + $afterArtifactHashes = ConvertTo-Json -InputObject $After.package.packageArtifactHashes -Depth 10 -Compress + if ($Before.package.sha256 -ne $After.package.sha256 -or + $Before.package.packageAttemptId -ne $After.package.packageAttemptId -or + $Before.package.workflowRunId -ne $After.package.workflowRunId -or + $Before.package.packageInputSha256 -ne $After.package.packageInputSha256 -or + $beforeArtifactHashes -cne $afterArtifactHashes) { + $issues.Add("the V3.1 package report or package identity changed during validation") + } + if ((ConvertTo-Json -InputObject @($Before.inputFiles) -Depth 20 -Compress) -cne + (ConvertTo-Json -InputObject @($After.inputFiles) -Depth 20 -Compress)) { + $issues.Add("solution input bytes changed during validation") + } + if ((ConvertTo-Json -InputObject @($Before.package.packageFiles) -Depth 20 -Compress) -cne + (ConvertTo-Json -InputObject @($After.package.packageFiles) -Depth 20 -Compress)) { + $issues.Add("Package artifact bytes changed during validation") + } + if (($Before.producerFiles | ConvertTo-Json -Depth 10 -Compress) -cne + ($After.producerFiles | ConvertTo-Json -Depth 10 -Compress)) { + $issues.Add("the validation evidence producer changed during validation") + } + + $status = if ($issues.Count -eq 0) { "PASSED" } else { "FAILED" } + return [ordered]@{ + schemaVersion = 1 + producer = "build-and-validate.ps1" + validationResult = $status + validationRan = -not $ValidationSkipped + packagingSkipped = $PackagingSkipped + toolkitRoot = $After.toolkitRoot + contentRoot = $After.contentRoot + solutionPath = $After.solutionPath + sourcePathSemantics = $After.sourcePathSemantics + producerPlatform = $After.producerPlatform + evidenceRunPath = [System.IO.Path]::GetFullPath($EvidenceRunPath) + validationStartedAtUtc = $Before.capturedAtUtc + validationCompletedAtUtc = $After.capturedAtUtc + validatorSummary = [ordered]@{ + total = $passed + $failed + $skipped + passed = $passed + failed = $failed + skipped = $skipped + } + package = $After.package + inputFilesBefore = $Before.inputFiles + inputFilesAfter = $After.inputFiles + packageFilesBefore = $Before.package.packageFiles + packageFilesAfter = $After.package.packageFiles + producerFilesBefore = $Before.producerFiles + producerFilesAfter = $After.producerFiles + issues = @($issues) + } +} + +function Write-ValidationEvidenceBinding { + param( + [Parameter(Mandatory = $true)] + [string]$EvidenceRunPath, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$Binding + ) + + $path = Join-Path ([System.IO.Path]::GetFullPath($EvidenceRunPath)) "build-and-validate-binding.json" + $json = ConvertTo-Json -InputObject $Binding -Depth 30 + [System.IO.File]::WriteAllText($path, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) + return [ordered]@{ + path = $path + sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } +} diff --git a/.script/package-automation/arm-ttk-tests.ps1 b/.script/package-automation/arm-ttk-tests.ps1 index 3996dad8fba..91ed641653d 100644 --- a/.script/package-automation/arm-ttk-tests.ps1 +++ b/.script/package-automation/arm-ttk-tests.ps1 @@ -1,59 +1,59 @@ try { - #$diff = git diff --diff-filter=d --name-only --first-parent HEAD^ HEAD - git fetch origin master - $diff = git diff --diff-filter=d --name-only origin/master...HEAD + $diff = @() + if (!$env:PR_BASE_SHA -or !$env:PR_HEAD_SHA) { + Write-Warning "PR_BASE_SHA or PR_HEAD_SHA is missing. Treating this as no changes and skipping ARM-TTK." + } + else { + try { + $diff = git diff --diff-filter=d --name-only "$($env:PR_BASE_SHA)...$($env:PR_HEAD_SHA)" + if ($LASTEXITCODE -ne 0) { + throw "Git comparison failed (exit code $LASTEXITCODE)." + } + } + catch { + Write-Warning "Unable to identify PR changes: $_ Treating this as no changes and skipping ARM-TTK." + $diff = @() + } + } Write-Host "List of files in PR: $diff" - $hasmainTemplateChanged = $false - $hasCreateUiDefinitionTemplateChanged = $false - - $isChangeInSolutionsFolder = [bool]($diff | Where-Object {$_ -like 'Solutions/*'}) - if (!$isChangeInSolutionsFolder) - { - Write-Host "Skipping as change is not in Solutions folder!" - exit 0 + $filteredFiles = $diff | Where-Object { + $_ -match '^Solutions/[^/]+/Package/(mainTemplate|createUiDefinition)\.json$' } - - $requiredFiles = @("mainTemplate.json", "createUiDefinition.json") - $filteredFiles = $diff | Where-Object {$_ -match ($requiredFiles -Join "|")} Write-Host "Filtered Files $filteredFiles" - $sName = '' - $hasmainTemplateChanged = $false - $hasCreateUiDefinitionTemplateChanged = $false - - if ($filteredFiles.Count -gt 0) - { - $mainTemplateValue = $filteredFiles -match "mainTemplate.json" - $createUiValue = $filteredFiles -match "createUiDefinition.json" - - if ($mainTemplateValue -or $createUiValue) - { - $hasmainTemplateChanged = $true - $hasCreateUiDefinitionTemplateChanged = $true + $solutions = @{} + foreach ($file in $filteredFiles) { + if ($file -match '^Solutions/([^/]+)/Package/(mainTemplate|createUiDefinition)\.json$') { + $solutionName = $matches[1] + $templateName = $matches[2] + + if (!$solutions.ContainsKey($solutionName)) { + $solutions[$solutionName] = [ordered]@{ + solutionName = $solutionName + mainTemplateChanged = $false + createUiChanged = $false + } + } + + if ($templateName -eq 'mainTemplate') { + $solutions[$solutionName].mainTemplateChanged = $true + } + else { + $solutions[$solutionName].createUiChanged = $true + } } + } - if ($filteredFiles.Count -eq 1) - { - $packageIndex = $filteredFiles.IndexOf("/Package") - $sName = $filteredFiles.SubString(10, $packageIndex - 10) - } - else - { - $packageIndex = $filteredFiles[0].IndexOf("/Package") - $sName = $filteredFiles[0].SubString(10, $packageIndex - 10) - } + $solutionsJson = ConvertTo-Json -InputObject @($solutions.Values) -Compress + if (!$solutionsJson) { + $solutionsJson = '[]' } - Write-Host "solutionName $sName, mainTemplateChanged $hasmainTemplateChanged, createUiChanged $hasCreateUiDefinitionTemplateChanged" - Write-Output "solutionName=$sName" >> $env:GITHUB_OUTPUT - Write-Output "mainTemplateChanged=$hasmainTemplateChanged" >> $env:GITHUB_OUTPUT - Write-Output "createUiChanged=$hasCreateUiDefinitionTemplateChanged" >> $env:GITHUB_OUTPUT + Write-Host "Solutions to validate: $solutionsJson" + Write-Output "solutionsJson=$solutionsJson" >> $env:GITHUB_OUTPUT } catch { - Write-Host "Skipping as exception has occured Error Details: $_" - Write-Output "solutionName=''" >> $env:GITHUB_OUTPUT - Write-Output "mainTemplateChanged=$false" >> $env:GITHUB_OUTPUT - Write-Output "createUiChanged=$false" >> $env:GITHUB_OUTPUT + throw } \ No newline at end of file diff --git a/.script/package-automation/hyperlink-validation.ps1 b/.script/package-automation/hyperlink-validation.ps1 index 9441790e787..4f809abec34 100644 --- a/.script/package-automation/hyperlink-validation.ps1 +++ b/.script/package-automation/hyperlink-validation.ps1 @@ -1,4 +1,4 @@ -param($runId, $pullRequestNumber, $instrumentationKey, $baseFolderPath) +param($runId, $pullRequestNumber, $instrumentationKey, $baseFolderPath, $SolutionPath) Write-Host "Starting execution of Hyperlink Validation" $global:counterInvalid =0 $global:counterTimeout =0 @@ -10,41 +10,77 @@ class UrlDetail { try { $urlDetailList = New-Object 'System.Collections.Generic.List[PSObject]' - $baseFolderPath = $baseFolderPath + "/" - $baseFolderPath = $baseFolderPath.replace("//", "/") - - Write-Host "====Identifying Solution Name====" - #Get Solution Name - . $PSScriptRoot/getSolutionName.ps1 $runId $pullRequestNumber $instrumentationKey - #outputs: solutionName + $contentRoot = if ($baseFolderPath) { + [System.IO.Path]::GetFullPath($baseFolderPath) + } else { + [System.IO.Path]::GetFullPath((Get-Location).Path) + } - if ($solutionName -eq '') - { - exit 0 + function Test-PrivateSolutionContentPath([string]$filePath) { + $segments = $filePath -split '[\\/]' + if ($segments.Count -lt 3 -or -not $segments[0].Equals("Solutions", [System.StringComparison]::OrdinalIgnoreCase)) { + return $false + } + foreach ($segment in $segments | Select-Object -Skip 2) { + if ($segment.Equals("Logs", [System.StringComparison]::OrdinalIgnoreCase) -or + $segment.Equals("Reports", [System.StringComparison]::OrdinalIgnoreCase) -or + $segment.Equals("Evidence", [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false } - Write-Host "SolutionName is $solutionName" - function ReadFileContent($filePath) { - try { - if (!(Test-Path -Path "$filePath")) { - return $null; - } - - $stream = New-Object System.IO.StreamReader -Arg "$filePath"; - $content = $stream.ReadToEnd(); - $stream.Close(); - - if ($null -eq $content) { - Write-Host "Error in reading file $filePath" - return $null; - } else { - return $content; - } + if ($SolutionPath) { + $solutionsRoot = [System.IO.Path]::GetFullPath((Join-Path $contentRoot "Solutions")).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $resolvedSolutionPath = [System.IO.Path]::GetFullPath($SolutionPath) + if ([System.IO.Path]::GetDirectoryName($resolvedSolutionPath) -ne $solutionsRoot -or + -not (Test-Path $resolvedSolutionPath -PathType Container) -or + ((Get-Item $resolvedSolutionPath).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Invalid solution folder '$SolutionPath'. Expected an existing direct child of '$solutionsRoot'." } - catch { - Write-Host "Error occurred in ReadFileContent. Error details : $_" - return $null; + $filesList = @(Get-ChildItem $resolvedSolutionPath -Recurse -File | + ForEach-Object { [System.IO.Path]::GetRelativePath($contentRoot, $_.FullName).Replace('\', '/') } | + Where-Object { + $_ -notlike 'Solutions/Images/*' -and + $_ -notlike 'Solutions/*.md' -and + $_ -notlike '*/Package/*' -and + $_ -notlike '*system_generated_metadata.json' -and + $_ -notlike '*testParameters.json' + }) + Write-Host "Hyperlink validation scoped to current files under $resolvedSolutionPath" + } + else { + Write-Host "====Identifying Solution Name====" + # Preserve CI's commit-diff based solution discovery when no folder scope is supplied. + . $PSScriptRoot/getSolutionName.ps1 $runId $pullRequestNumber $instrumentationKey + if ($solutionName -eq '') { + exit 0 + } + Write-Host "SolutionName is $solutionName" + $prFiles = git diff --diff-filter=d --name-only --first-parent HEAD^ HEAD + $solutionFolderPath = 'Solutions/' + $solutionName + "/" + $filesList = $prFiles | Where-Object { $_ -like "$solutionFolderPath*" } + } + $filesList = @($filesList | Where-Object { -not (Test-PrivateSolutionContentPath $_) }) + function Resolve-ContentFilePath($filePath) { + $resolvedPath = if ([System.IO.Path]::IsPathRooted($filePath)) { + [System.IO.Path]::GetFullPath($filePath) + } else { + [System.IO.Path]::GetFullPath((Join-Path $contentRoot $filePath)) + } + $relativePath = [System.IO.Path]::GetRelativePath($contentRoot, $resolvedPath) + if ($relativePath -eq ".." -or $relativePath.StartsWith(".." + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::Ordinal)) { + throw "Content file path escapes the selected content root: '$filePath'." } + if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { + throw "Content file does not exist under the selected content root: '$resolvedPath'." + } + return $resolvedPath + } + function ReadFileContent($filePath) { + $resolvedPath = Resolve-ContentFilePath $filePath + return [System.IO.File]::ReadAllText($resolvedPath) } function validateLink($urlList,$filePath) @@ -92,16 +128,14 @@ function validateLink($urlList,$filePath) $urlDetailList.Add($objURlDetail) } } -$prFiles = git diff --diff-filter=d --name-only --first-parent HEAD^ HEAD -$solutionFolderPath = 'Solutions/' + $solutionName + "/" -$filesList =$prFiles| Where-Object { $_ -like "$solutionFolderPath*" } $filteredFiles = $filesList | Where-Object {$_ -match "Solutions/"} | Where-Object {$_ -notlike "Solutions/Images/*"} | Where-Object {$_ -notlike "Solutions/*.md"} | Where-Object { $_ -notlike '*system_generated_metadata.json' } | Where-Object { $_ -notlike '*testParameters.json' } | Where-Object { $_ -notmatch ('Package') } $finalFilteredFiles = @() # Remove the files which does not contain url foreach($item in $filteredFiles) { - $temp = Select-String -Path $item -Pattern '(http|https):\/\/([\w\-_]+(?:(?:\.[\w\-_]+)+))([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?'-AllMatches + $resolvedItem = Resolve-ContentFilePath $item + $temp = Select-String -LiteralPath $resolvedItem -Pattern '(http|https):\/\/([\w\-_]+(?:(?:\.[\w\-_]+)+))([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?'-AllMatches -ErrorAction Stop if ($null -ne $temp) { $finalFilteredFiles += $item } diff --git a/.script/package-automation/package-generator.ps1 b/.script/package-automation/package-generator.ps1 index c2e823a1f7b..702bd02fb28 100644 --- a/.script/package-automation/package-generator.ps1 +++ b/.script/package-automation/package-generator.ps1 @@ -1097,13 +1097,13 @@ try Write-Host "Updating data input file $dataFolderFile" Set-Content -Path $dataFilePath -Value $jsonDataFile - Write-Host "Now going to execute createSolutionV4 file" + Write-Host "Now going to execute createSolutionV3_1 file" if ($null -eq $isWatchListInsideOfWorkbooksFolder -or $isWatchListInsideOfWorkbooksFolder -eq '') { $isWatchListInsideOfWorkbooksFolder = $false } - ./Tools/Create-Azure-Sentinel-Solution/pipeline/createSolutionV4.ps1 $baseFolderPath $solutionName $dataFileContentObject $dataFolderFile $dataConnectorFolderName $dataFolderActualName $instrumentationKey $pullRequestNumber $runId $packageVersion $defaultPackageVersion $isWatchListInsideOfWorkbooksFolder + ./Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1 $baseFolderPath $solutionName $dataFileContentObject $dataFolderFile $dataConnectorFolderName $dataFolderActualName $instrumentationKey $pullRequestNumber $runId $packageVersion $defaultPackageVersion $isWatchListInsideOfWorkbooksFolder $packageCreationPath = "" + $baseFolderPath + "Solutions/" + $solutionName + "/Package/" Write-Host "packageCreationPath $packageCreationPath" diff --git a/.script/package-automation/validateClassicAppInsights.ps1 b/.script/package-automation/validateClassicAppInsights.ps1 index f4b4f366862..dae2eefb3ee 100644 --- a/.script/package-automation/validateClassicAppInsights.ps1 +++ b/.script/package-automation/validateClassicAppInsights.ps1 @@ -1,4 +1,4 @@ -param($runId, $pullRequestNumber, $instrumentationKey, $baseFolderPath) +param($runId, $pullRequestNumber, $instrumentationKey, $baseFolderPath, $SolutionPath) Write-Host "Inside of Validate Classic App Insights!" @@ -45,11 +45,33 @@ try { return $appInsightResourceWithoutWorkspaceResourceList; } - # identify if changes are in dataConnectors folder which is at root of the repo - $diff = git diff --diff-filter=A --name-only --first-parent HEAD^ HEAD - Write-Host "List of new files added in PR for Standalone DataConnectors: $diff" - $standaloneDataConnectors = $diff | Where-Object { $_ -like 'DataConnectors/*' -and $_ -like '*azuredeploy*' } - $hasStandaloneDataConnectors = ($null -ne $standaloneDataConnectors -and $standaloneDataConnectors.Count -gt 0) ? $true : $false + if ($SolutionPath) { + $solutionsRoot = [System.IO.Path]::GetFullPath((Join-Path $baseFolderPath "Solutions")).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $resolvedSolutionPath = [System.IO.Path]::GetFullPath($SolutionPath) + if ([System.IO.Path]::GetDirectoryName($resolvedSolutionPath) -ne $solutionsRoot -or + -not (Test-Path $resolvedSolutionPath -PathType Container) -or + ((Get-Item $resolvedSolutionPath).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Invalid solution folder '$SolutionPath'. Expected an existing direct child of '$solutionsRoot'." + } + $solutionAzureDeployFiles = @(Get-ChildItem $resolvedSolutionPath -Recurse -File | Where-Object { $_.Name -like '*azuredeploy*' } | + ForEach-Object { [System.IO.Path]::GetRelativePath($baseFolderPath, $_.FullName).Replace('\', '/') }) + if ($solutionAzureDeployFiles.Count -gt 0) { + Write-Host "Current azuredeploy files in solution folder: $solutionAzureDeployFiles" + } + else { + Write-Host "No current azuredeploy files found in the selected solution folder." + } + Write-Host "Skipping standalone DataConnectors validation because it is outside the selected solution folder." + $standaloneDataConnectors = @() + $hasStandaloneDataConnectors = $false + } + else { + # Preserve CI's commit-diff based discovery when no solution folder is supplied. + $diff = git diff --diff-filter=A --name-only --first-parent HEAD^ HEAD + Write-Host "List of new files added in PR for Standalone DataConnectors: $diff" + $standaloneDataConnectors = $diff | Where-Object { $_ -like 'DataConnectors/*' -and $_ -like '*azuredeploy*' } + $hasStandaloneDataConnectors = ($null -ne $standaloneDataConnectors -and $standaloneDataConnectors.Count -gt 0) ? $true : $false + } if ($hasStandaloneDataConnectors) { Write-Host "Standalone dataConnector files $standaloneDataConnectors" @@ -63,14 +85,19 @@ try { Write-Host "::error:: --> $filePath" } } - } else { + } elseif (-not $SolutionPath) { Write-Host "Skipping as there are no new azuredeploy files for validation in standalone data connectors!" } - # when changes are in solutions folder in azuredeploy file - $solutionFilesdiff = git diff --diff-filter=A --name-only --first-parent HEAD^ HEAD; - Write-Host "List of new files added in PR for Solution: $diff" - $solutionsAzureDeployFilesPath = $solutionFilesdiff | Where-Object { $_ -like 'Solutions/*' -and $_ -like '*azuredeploy*' } + # Check the explicit current solution folder, or preserve CI's commit-diff based discovery. + if ($SolutionPath) { + $solutionsAzureDeployFilesPath = $solutionAzureDeployFiles + } + else { + $solutionFilesdiff = git diff --diff-filter=A --name-only --first-parent HEAD^ HEAD + Write-Host "List of new files added in PR for Solution: $solutionFilesdiff" + $solutionsAzureDeployFilesPath = $solutionFilesdiff | Where-Object { $_ -like 'Solutions/*' -and $_ -like '*azuredeploy*' } + } if ($solutionsAzureDeployFilesPath.Count -gt 0) { # has some files of azuredeploy which are newly added in PR @@ -84,7 +111,12 @@ try { } } } else { - Write-Host "Skipping as there are no new azuredeploy files for validation in Solutions!" + if ($SolutionPath) { + Write-Host "Skipping solution azuredeploy validation — no current azuredeploy files found in the selected folder." + } + else { + Write-Host "Skipping as there are no new azuredeploy files for validation in Solutions!" + } } if (($null -ne $failedStandaloneDataConnectorsList -and diff --git a/.script/package-automation/validateFieldTypes.ps1 b/.script/package-automation/validateFieldTypes.ps1 index 3b7d1d622d5..b68103f525d 100644 --- a/.script/package-automation/validateFieldTypes.ps1 +++ b/.script/package-automation/validateFieldTypes.ps1 @@ -1,4 +1,4 @@ -param($runId, $pullRequestNumber, $instrumentationKey, $baseFolderPath, $isPRMerged) +param($runId, $pullRequestNumber, $instrumentationKey, $baseFolderPath, $isPRMerged, $SolutionPath) try { Write-Host "Inside of Validate Parameter Field Types!" @@ -6,11 +6,34 @@ try { $baseFolderPath = $baseFolderPath + "/" $baseFolderPath = $baseFolderPath.replace("//", "/") - # Get Solution Name - . $PSScriptRoot/getSolutionName.ps1 $runId $pullRequestNumber $instrumentationKey $false - if ($solutionName -eq '') - { - exit 0 + if ($SolutionPath) { + $solutionsRoot = [System.IO.Path]::GetFullPath((Join-Path $baseFolderPath "Solutions")).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $resolvedSolutionPath = [System.IO.Path]::GetFullPath($SolutionPath) + if ([System.IO.Path]::GetDirectoryName($resolvedSolutionPath) -ne $solutionsRoot -or + -not (Test-Path $resolvedSolutionPath -PathType Container) -or + ((Get-Item $resolvedSolutionPath).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Invalid solution folder '$SolutionPath'. Expected an existing direct child of '$solutionsRoot'." + } + $solutionMainTemplatePath = Join-Path $resolvedSolutionPath "Package/mainTemplate.json" + if (-not (Test-Path $solutionMainTemplatePath -PathType Leaf)) { + Write-Host "Skipping validation as there is no current Package/mainTemplate.json in $resolvedSolutionPath" + exit 0 + } + } + else { + # Preserve CI's commit-diff based solution discovery when no folder scope is supplied. + . $PSScriptRoot/getSolutionName.ps1 $runId $pullRequestNumber $instrumentationKey $false + if ($solutionName -eq '') { + exit 0 + } + $diff = git diff --diff-filter=d --name-only --first-parent HEAD^ HEAD + Write-Host "List of files in PR: $diff" + $solutionMainTemplatePath = 'Solutions/' + $solutionName + "/Package/mainTemplate.json" + $hasMainTemplateFile = $diff | Where-Object { $_ -match "$solutionMainTemplatePath" } + if ($null -eq $hasMainTemplateFile) { + Write-Host "Skipping validation as there is no change in maintemplate.json file for solution $solutionName" + exit 0 + } } function GetInvalidFields($resourceParameterProp) { @@ -42,19 +65,10 @@ try { return $invalidFields; } - $diff = git diff --diff-filter=d --name-only --first-parent HEAD^ HEAD - Write-Host "List of files in PR: $diff" - - $solutionMainTemplatePath = 'Solutions/' + $solutionName + "/Package/mainTemplate.json" - $hasMainTemplateFile = $diff | Where-Object {$_ -match "$solutionMainTemplatePath"} + $mainTemplateFileContent = Get-Content "$solutionMainTemplatePath" -Raw | ConvertFrom-Json - if ($null -eq $hasMainTemplateFile) { - Write-Host "Skipping validation as there is no change in maintemplate.json file for solution $solutionName" - } else { - $mainTemplateFileContent = Get-Content "$solutionMainTemplatePath" | ConvertFrom-Json - - $hasInvalidGlobalParameterType = $false - $hasInvalidResourceParameterType = $false + $hasInvalidGlobalParameterType = $false + $hasInvalidResourceParameterType = $false # identify in global parameters $globalParameters = $mainTemplateFileContent.parameters if ($null -ne $globalParameters -and $globalParameters.Count -gt 0) { @@ -95,9 +109,8 @@ try { } } - if ($hasInvalidResourceParameterType -or $hasInvalidGlobalParameterType) { - exit 1 - } + if ($hasInvalidResourceParameterType -or $hasInvalidGlobalParameterType) { + exit 1 } } catch { diff --git a/.script/tests/KqlvalidationsTests/JsonFilesTestData/DataConnectorFilesLoader.cs b/.script/tests/KqlvalidationsTests/JsonFilesTestData/DataConnectorFilesLoader.cs index 4ca57c61127..2ce8f556baf 100644 --- a/.script/tests/KqlvalidationsTests/JsonFilesTestData/DataConnectorFilesLoader.cs +++ b/.script/tests/KqlvalidationsTests/JsonFilesTestData/DataConnectorFilesLoader.cs @@ -48,7 +48,9 @@ public override List GetFilesNames() foreach (var directoryPath in directoryPaths) { - var files = Directory.GetFiles(directoryPath, "*.json", SearchOption.AllDirectories); + var files = Directory.GetFiles(directoryPath, "*.json", SearchOption.AllDirectories) + .Where(file => !Utils.IsPrivateSolutionContentPath(file)) + .ToArray(); if (prNumber != 0 && prFilesListModified != null && prFilesListModified.Count != 0) { diff --git a/.script/tests/KqlvalidationsTests/JsonFilesTestData/JsonFilesLoader.cs b/.script/tests/KqlvalidationsTests/JsonFilesTestData/JsonFilesLoader.cs index 1c4fb1b4108..24cc3eedfc9 100644 --- a/.script/tests/KqlvalidationsTests/JsonFilesTestData/JsonFilesLoader.cs +++ b/.script/tests/KqlvalidationsTests/JsonFilesTestData/JsonFilesLoader.cs @@ -29,6 +29,7 @@ public virtual List GetFilesNames() return directoryPaths .SelectMany(directoryPath => Directory.GetFiles(directoryPath, FileExtensionFilter, SearchOption.AllDirectories)) + .Where(file => !Utils.IsPrivateSolutionContentPath(file)) .Where(file => prFilesListModified.Any(prFile => file.Contains(prFile))) .ToList(); } diff --git a/.script/tests/KqlvalidationsTests/KqlValidationTests.cs b/.script/tests/KqlvalidationsTests/KqlValidationTests.cs index 3f3396881a7..6e25fe87667 100644 --- a/.script/tests/KqlvalidationsTests/KqlValidationTests.cs +++ b/.script/tests/KqlvalidationsTests/KqlValidationTests.cs @@ -7,6 +7,7 @@ using YamlDotNet.Serialization; using Microsoft.Azure.Sentinel.KustoServices.Implementation; using Kqlvalidations.Tests.FunctionSchemasLoaders; +using Kqlvalidations.Tests.TableSchemasLoaders; using System; using Newtonsoft.Json; using Octokit; @@ -22,11 +23,14 @@ public class KqlValidationTests public KqlValidationTests() { + var customTablesPath = Path.Combine(Utils.GetTestDirectory(TestFolderDepth), "CustomTables"); + var customFunctionsPath = Path.Combine(Utils.GetTestDirectory(TestFolderDepth), "CustomFunctions"); + _queryValidator = new KqlQueryAnalyzerBuilder() - .WithSentinelDefaultTablesAndFunctionsSchemas() - .WithCustomTableSchemasLoader(new CustomJsonDirectoryTablesLoader(Path.Combine(Utils.GetTestDirectory(TestFolderDepth), "CustomTables"))) - .WithCustomFunctionSchemasLoader(new CustomJsonDirectoryFunctionsLoader(Path.Combine(Utils.GetTestDirectory(TestFolderDepth), "CustomFunctions"))) - .WithCustomFunctionSchemasLoader(new ParsersCustomJsonDirectoryFunctionsLoader(Path.Combine(Utils.GetTestDirectory(TestFolderDepth), "CustomFunctions"))) + .WithCustomTableSchemasLoader(new LocalOverrideTablesLoader(customTablesPath)) + .WithCustomFunctionSchemasLoader(new SentinelDefaultFunctionsLoader()) + .WithCustomFunctionSchemasLoader(new CustomJsonDirectoryFunctionsLoader(customFunctionsPath)) + .WithCustomFunctionSchemasLoader(new ParsersCustomJsonDirectoryFunctionsLoader(customFunctionsPath)) .WithCustomFunctionSchemasLoader(new CommonFunctionsLoader()) .Build(); } diff --git a/.script/tests/KqlvalidationsTests/TableSchemasLoaders/LocalOverrideTablesLoader.cs b/.script/tests/KqlvalidationsTests/TableSchemasLoaders/LocalOverrideTablesLoader.cs new file mode 100644 index 00000000000..b4366a37865 --- /dev/null +++ b/.script/tests/KqlvalidationsTests/TableSchemasLoaders/LocalOverrideTablesLoader.cs @@ -0,0 +1,39 @@ +using Microsoft.Azure.Sentinel.KustoServices.Contract; +using Microsoft.Azure.Sentinel.KustoServices.Implementation; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Kqlvalidations.Tests.TableSchemasLoaders +{ + /// + /// Loads Sentinel table schemas, allowing local schemas to override defaults with matching names. + /// + public class LocalOverrideTablesLoader : ITableSchemasLoader + { + private readonly string _customTablesPath; + + public LocalOverrideTablesLoader(string customTablesPath) + { + _customTablesPath = customTablesPath; + } + + /// + /// Returns default and local table schemas after applying local overrides. + /// + public IEnumerable Load() + { + var customTables = new CustomJsonDirectoryTablesLoader(_customTablesPath) + .Load() + .ToList(); + var customTableNames = customTables + .Select(table => table.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var defaultTables = new SentinelDefaultTablesLoader() + .Load() + .Where(table => !customTableNames.Contains(table.Name)); + + return defaultTables.Concat(customTables); + } + } +} diff --git a/.script/tests/KqlvalidationsTests/Utils.cs b/.script/tests/KqlvalidationsTests/Utils.cs index 413b199ec9b..5ccae236525 100644 --- a/.script/tests/KqlvalidationsTests/Utils.cs +++ b/.script/tests/KqlvalidationsTests/Utils.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Reflection; namespace Kqlvalidations.Tests @@ -7,6 +8,27 @@ namespace Kqlvalidations.Tests public static class Utils { public static string GetTestDirectory(int testFolderDepth) + { + if (testFolderDepth == 3) + { + return GetToolkitTestDirectory(testFolderDepth); + } + + var contentRoot = Environment.GetEnvironmentVariable("SENTINEL_VALIDATION_CONTENT_ROOT"); + if (!string.IsNullOrWhiteSpace(contentRoot)) + { + var resolvedRoot = Path.GetFullPath(contentRoot); + if (!Directory.Exists(resolvedRoot) || !Directory.Exists(Path.Combine(resolvedRoot, "Solutions"))) + { + throw new DirectoryNotFoundException($"SENTINEL_VALIDATION_CONTENT_ROOT is not a repository root containing Solutions/: {contentRoot}"); + } + return resolvedRoot; + } + + return GetToolkitTestDirectory(testFolderDepth); + } + + public static string GetToolkitTestDirectory(int testFolderDepth) { var rootDir = Directory.CreateDirectory(GetAssemblyDirectory()); for (int i = 0; i < testFolderDepth; i++) @@ -15,6 +37,24 @@ public static string GetTestDirectory(int testFolderDepth) } return rootDir.FullName; } + + public static bool IsPrivateSolutionContentPath(string filePath) + { + var contentRoot = GetTestDirectory(6); + var solutionsRoot = Path.GetFullPath(Path.Combine(contentRoot, "Solutions")); + var resolvedFile = Path.GetFullPath(filePath); + var relativePath = Path.GetRelativePath(solutionsRoot, resolvedFile); + if (relativePath == ".." || relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + return false; + } + + return relativePath.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.Equals("Logs", StringComparison.OrdinalIgnoreCase) + || segment.Equals("Reports", StringComparison.OrdinalIgnoreCase) + || segment.Equals("Evidence", StringComparison.OrdinalIgnoreCase)); + } public static string EncodeToBase64(string plainText) { var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText); diff --git a/.script/tests/KqlvalidationsTests/YamlFilesTestData/YamlFilesLoader.cs b/.script/tests/KqlvalidationsTests/YamlFilesTestData/YamlFilesLoader.cs index 3bcf3ffdacb..28fb9be61c4 100644 --- a/.script/tests/KqlvalidationsTests/YamlFilesTestData/YamlFilesLoader.cs +++ b/.script/tests/KqlvalidationsTests/YamlFilesTestData/YamlFilesLoader.cs @@ -55,6 +55,7 @@ private List GetAllFiles() { return GetDirectoryPaths() .SelectMany(directoryPath => Directory.GetFiles(directoryPath, "*.yaml", SearchOption.AllDirectories)) + .Where(file => !Utils.IsPrivateSolutionContentPath(file)) .ToList(); } @@ -65,6 +66,7 @@ private List GetValidFiles(IReadOnlyList prFiles) var validFiles = GetDirectoryPaths() .SelectMany(directoryPath => Directory.GetFiles(directoryPath, "*.yaml", SearchOption.AllDirectories)) + .Where(file => !Utils.IsPrivateSolutionContentPath(file)) .Where(file => prFilesListModified.Any(prFile => file.Contains(prFile))) .ToList(); diff --git a/.script/tests/NonAsciiValidationsTests/FilesTestData.cs b/.script/tests/NonAsciiValidationsTests/FilesTestData.cs index 099b274f7c4..f939f6f4831 100644 --- a/.script/tests/NonAsciiValidationsTests/FilesTestData.cs +++ b/.script/tests/NonAsciiValidationsTests/FilesTestData.cs @@ -14,13 +14,26 @@ public FilesTestData() foreach (string folder in FolderName) { string rootPath = GetFilesRootPath(folder); - var files = Directory.GetFiles(rootPath, FileExtension, SearchOption.AllDirectories).ToList(); + var files = Directory.GetFiles(rootPath, FileExtension, SearchOption.AllDirectories) + .Where(file => !IsPrivateSolutionContentPath(file)) + .ToList(); files.ForEach(f => Add(Path.GetFileName(f), f)); } } private string GetFilesRootPath(string folderName) { + var contentRoot = Environment.GetEnvironmentVariable("SENTINEL_VALIDATION_CONTENT_ROOT"); + if (!string.IsNullOrWhiteSpace(contentRoot)) + { + var resolvedRoot = Path.GetFullPath(contentRoot); + if (!Directory.Exists(resolvedRoot) || !Directory.Exists(Path.Combine(resolvedRoot, "Solutions"))) + { + throw new DirectoryNotFoundException($"SENTINEL_VALIDATION_CONTENT_ROOT is not a repository root containing Solutions/: {contentRoot}"); + } + return Path.Combine(resolvedRoot, folderName); + } + var rootDir = Directory.CreateDirectory(GetAssemblyDirectory()); var testFolderDepth = 6; for (int i = 0; i < testFolderDepth; i++) @@ -31,6 +44,26 @@ private string GetFilesRootPath(string folderName) return detectionPath; } + private static bool IsPrivateSolutionContentPath(string filePath) + { + var contentRoot = Environment.GetEnvironmentVariable("SENTINEL_VALIDATION_CONTENT_ROOT"); + if (string.IsNullOrWhiteSpace(contentRoot)) + { + return false; + } + var solutionsRoot = Path.GetFullPath(Path.Combine(contentRoot, "Solutions")); + var relativePath = Path.GetRelativePath(solutionsRoot, Path.GetFullPath(filePath)); + if (relativePath == ".." || relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + return false; + } + return relativePath.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.Equals("Logs", StringComparison.OrdinalIgnoreCase) + || segment.Equals("Reports", StringComparison.OrdinalIgnoreCase) + || segment.Equals("Evidence", StringComparison.OrdinalIgnoreCase)); + } + public string GetCustomTablesPath(string folderName) { var rootDir = Directory.CreateDirectory(GetAssemblyDirectory()); diff --git a/.script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidationTests.cs b/.script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidationTests.cs index 0dc314cc686..983fa0baf3a 100644 --- a/.script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidationTests.cs +++ b/.script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidationTests.cs @@ -141,9 +141,11 @@ public void Validate_DetectionTemplates_AllFilesAreYamls() { List detectionPath = DetectionsYamlFilesTestData.GetDetectionPaths(); var yamlFiles = Directory.GetFiles(detectionPath[0], "*.yaml", SearchOption.AllDirectories).ToList(); // Detection folder - yamlFiles.AddRange(Directory.GetFiles(detectionPath[1], "*.yaml", SearchOption.AllDirectories).ToList().Where(s => s.Contains("Analytic Rules"))); // Extending detection validation to solution folder + yamlFiles.AddRange(Directory.GetFiles(detectionPath[1], "*.yaml", SearchOption.AllDirectories).ToList() + .Where(s => s.Contains("Analytic Rules") && !DetectionsYamlFilesTestData.IsPrivateSolutionContentPath(s))); var AllFiles = Directory.GetFiles(detectionPath[0], "*", SearchOption.AllDirectories).ToList(); - AllFiles.AddRange(Directory.GetFiles(detectionPath[1], "*", SearchOption.AllDirectories).ToList().Where(s => s.Contains("Analytic Rules"))); + AllFiles.AddRange(Directory.GetFiles(detectionPath[1], "*", SearchOption.AllDirectories).ToList() + .Where(s => s.Contains("Analytic Rules") && !DetectionsYamlFilesTestData.IsPrivateSolutionContentPath(s))); var numberOfNotYamlFiles = 1; //This is the readme.md file in the directory Assert.True(AllFiles.Count == yamlFiles.Count + numberOfNotYamlFiles, $"All the files in detections and solution (Analytics rules) folder are supposed to end with .yaml"); } @@ -153,7 +155,8 @@ public void Validate_DetectionTemplates_NoSameTemplateIdTwice() { List detectionPath = DetectionsYamlFilesTestData.GetDetectionPaths(); var yamlFiles = Directory.GetFiles(detectionPath[0], "*.yaml", SearchOption.AllDirectories).Where(s => !s.Contains("CiscoUmbrella")).ToList(); // Removing duplicate CiscoUmbrella detections. already present in solution folder - yamlFiles.AddRange(Directory.GetFiles(detectionPath[1], "*.yaml", SearchOption.AllDirectories).ToList().Where(s => s.Contains("Analytic Rules"))); // Extending it to solution folder for detection validation + yamlFiles.AddRange(Directory.GetFiles(detectionPath[1], "*.yaml", SearchOption.AllDirectories).ToList() + .Where(s => s.Contains("Analytic Rules") && !DetectionsYamlFilesTestData.IsPrivateSolutionContentPath(s))); var templatesAsStrings = yamlFiles.Select(yaml => GetYamlFileAsString(Path.GetFileName(yaml))); var templatesAsObjects = templatesAsStrings.Select(yaml => JObject.Parse(ConvertYamlToJson(yaml))); @@ -201,7 +204,10 @@ public void Validate_DetectionTemplates_RuleKindsAreValid(string detectionsYamlF private List GetYamlFilePathsByFileName(string detectionsYamlFileName) { var yamlFilePaths = new List(); - var filesList = (Directory.GetFiles(RootDetectionPaths, detectionsYamlFileName, SearchOption.AllDirectories).Where(s => s.Contains("\\Detections\\") || s.Contains("/Detections/") || s.Contains("Analytic Rules")).ToList()); + var filesList = Directory.GetFiles(RootDetectionPaths, detectionsYamlFileName, SearchOption.AllDirectories) + .Where(s => (s.Contains("\\Detections\\") || s.Contains("/Detections/") || s.Contains("Analytic Rules")) + && !DetectionsYamlFilesTestData.IsPrivateSolutionContentPath(s)) + .ToList(); if (filesList.Any()) { @@ -217,11 +223,17 @@ private string GetYamlFileAsString(string detectionsYamlFileName) // Get file present in detection folder or else check in solution analytics rules folder try { - detectionsYamlFile = Directory.GetFiles(RootDetectionPaths, detectionsYamlFileName, SearchOption.AllDirectories).Where(s => s.Contains("\\Detections\\") || s.Contains("/Detections/")).Single(); + detectionsYamlFile = Directory.GetFiles(RootDetectionPaths, detectionsYamlFileName, SearchOption.AllDirectories) + .Where(s => (s.Contains("\\Detections\\") || s.Contains("/Detections/")) + && !DetectionsYamlFilesTestData.IsPrivateSolutionContentPath(s)) + .Single(); } catch (Exception e) when (e.Message.Contains("Sequence contains no elements")) { - detectionsYamlFile = Directory.GetFiles(RootDetectionPaths, detectionsYamlFileName, SearchOption.AllDirectories).Where(s => s.Contains("Analytic Rules")).Single(); + detectionsYamlFile = Directory.GetFiles(RootDetectionPaths, detectionsYamlFileName, SearchOption.AllDirectories) + .Where(s => s.Contains("Analytic Rules") + && !DetectionsYamlFilesTestData.IsPrivateSolutionContentPath(s)) + .Single(); } catch (Exception e) when (e.Message.Contains("Sequence contains more than one element")) { diff --git a/.script/tests/detectionTemplateSchemaValidation/DetectionsYamlFilesTestData.cs b/.script/tests/detectionTemplateSchemaValidation/DetectionsYamlFilesTestData.cs index d05b7f5c45a..ccf38ade8d8 100644 --- a/.script/tests/detectionTemplateSchemaValidation/DetectionsYamlFilesTestData.cs +++ b/.script/tests/detectionTemplateSchemaValidation/DetectionsYamlFilesTestData.cs @@ -18,6 +18,12 @@ public DetectionsYamlFilesTestData() public static List GetDetectionPaths() { + var rootPath = GetContentRoot(); + if (rootPath != null) + { + return new List() { Path.Combine(rootPath, "Detections"), Path.Combine(rootPath, "Solutions") }; + } + var rootDir = Directory.CreateDirectory(GetAssemblyDirectory()); List dirPaths = new List() { "Detections", "Solutions" }; var testFolderDepth = 6; @@ -37,6 +43,12 @@ public static List GetDetectionPaths() public static string GetRootPath() { + var contentRoot = GetContentRoot(); + if (contentRoot != null) + { + return contentRoot; + } + var rootDir = Directory.CreateDirectory(GetAssemblyDirectory()); var testFolderDepth = 6; for (int i = 0; i < testFolderDepth; i++) @@ -65,6 +77,36 @@ private static string GetAssemblyDirectory() return Path.GetDirectoryName(path); } + private static string GetContentRoot() + { + var configuredRoot = Environment.GetEnvironmentVariable("SENTINEL_VALIDATION_CONTENT_ROOT"); + if (string.IsNullOrWhiteSpace(configuredRoot)) + { + return null; + } + var rootPath = Path.GetFullPath(configuredRoot); + if (!Directory.Exists(rootPath) || !Directory.Exists(Path.Combine(rootPath, "Solutions"))) + { + throw new DirectoryNotFoundException($"SENTINEL_VALIDATION_CONTENT_ROOT is not a repository root containing Solutions/: {configuredRoot}"); + } + return rootPath; + } + + public static bool IsPrivateSolutionContentPath(string filePath) + { + var solutionsRoot = Path.GetFullPath(Path.Combine(GetRootPath(), "Solutions")); + var relativePath = Path.GetRelativePath(solutionsRoot, Path.GetFullPath(filePath)); + if (relativePath == ".." || relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + return false; + } + return relativePath.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.Equals("Logs", StringComparison.OrdinalIgnoreCase) + || segment.Equals("Reports", StringComparison.OrdinalIgnoreCase) + || segment.Equals("Evidence", StringComparison.OrdinalIgnoreCase)); + } + private static List GetDetectionFiles(List detectionPaths) { int prNumber = 0; @@ -73,7 +115,8 @@ private static List GetDetectionFiles(List detectionPaths) //prNumber=8414; var files = Directory.GetFiles(detectionPaths[0], "*.yaml", SearchOption.AllDirectories) .Concat(Directory.GetFiles(detectionPaths[1], "*.yaml", SearchOption.AllDirectories) - .Where(s => s.Contains("Analytic Rules"))); + .Where(s => s.Contains("Analytic Rules") && !IsPrivateSolutionContentPath(s))) + .Where(s => !IsPrivateSolutionContentPath(s)); if (prNumber != 0) { diff --git a/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Data/invalid.json b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Data/invalid.json new file mode 100644 index 00000000000..5b2e7f3870c --- /dev/null +++ b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Data/invalid.json @@ -0,0 +1 @@ +{"intentionallyInvalid": diff --git a/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Evidence/invalid.json b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Evidence/invalid.json new file mode 100644 index 00000000000..f6bf76a0039 --- /dev/null +++ b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Evidence/invalid.json @@ -0,0 +1 @@ +{"private evidence files are not solution content": diff --git a/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Parsers/RootProbe.yaml b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Parsers/RootProbe.yaml new file mode 100644 index 00000000000..bdc10e769a3 --- /dev/null +++ b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Parsers/RootProbe.yaml @@ -0,0 +1,4 @@ +id: RootProbe +name: Root Probe +description: Synthetic parser fixture for cross-checkout validation. +version: 1.0.0 diff --git a/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Reports/invalid.json b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Reports/invalid.json new file mode 100644 index 00000000000..23505d26158 --- /dev/null +++ b/.script/tests/localValidationTest/crossRootFixtures/content/Solutions/RootProbe/Reports/invalid.json @@ -0,0 +1 @@ +{"private report files are not solution content": diff --git a/.script/tests/localValidationTest/crossRootFixtures/toolkit/Solutions/RootProbe/Data/Solution_RootProbe.json b/.script/tests/localValidationTest/crossRootFixtures/toolkit/Solutions/RootProbe/Data/Solution_RootProbe.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/.script/tests/localValidationTest/crossRootFixtures/toolkit/Solutions/RootProbe/Data/Solution_RootProbe.json @@ -0,0 +1 @@ +{} diff --git a/.script/tests/localValidationTest/solutionScope.test.ts b/.script/tests/localValidationTest/solutionScope.test.ts new file mode 100644 index 00000000000..70ff4938ccd --- /dev/null +++ b/.script/tests/localValidationTest/solutionScope.test.ts @@ -0,0 +1,367 @@ +import { spawnSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { expect } from "chai"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const validatorPath = path.join(repoRoot, ".script/local-validation/validate.js"); +const buildRunnerPath = path.join(repoRoot, ".script/local-validation/build-and-validate.ps1"); +const hyperlinkValidatorPath = path.join(repoRoot, ".script/package-automation/hyperlink-validation.ps1"); + +function validateJsonInPath(solutionPath: string) { + return spawnSync("node", [validatorPath, "--path", solutionPath, "--only", "json", "--json"], { + cwd: repoRoot, + encoding: "utf8", + }); +} + +describe("local validation folder scope", () => { + for (const solutionFolder of ["CiscoASA", "Endpoint Threat Protection Essentials"]) { + it(`validates only current JSON files under Solutions/${solutionFolder}`, () => { + const result = validateJsonInPath(path.join("Solutions", solutionFolder)); + expect(result.status, result.stderr).to.be.oneOf([0, 1]); + + const report = JSON.parse(result.stdout); + expect(report.results.length).to.be.greaterThan(0); + expect(report.results.every((entry: { filePath: string }) => + entry.filePath.startsWith(`Solutions/${solutionFolder}/`))).to.equal(true); + if (solutionFolder === "CiscoASA") { + expect(report.results.some((entry: { filePath: string }) => + entry.filePath === "Solutions/CiscoASA/Package/mainTemplate.json")).to.equal(true); + } + }); + } + + it("rejects a nonexistent explicit folder rather than validating another scope", () => { + const result = validateJsonInPath("Solutions/this-solution-folder-does-not-exist"); + expect(result.status).to.equal(2); + expect(result.stderr).to.contain("existing directory"); + expect(result.stdout).to.equal(""); + }); + + it("does not treat a similarly prefixed solution as part of the requested folder", () => { + const target = "Solutions/CiscoASA"; + const siblingFolders = fs.readdirSync(path.join(repoRoot, "Solutions")) + .filter(name => name.startsWith("CiscoASA") && name !== "CiscoASA"); + const result = validateJsonInPath(target); + expect(result.status, result.stderr).to.be.oneOf([0, 1]); + + const report = JSON.parse(result.stdout); + for (const sibling of siblingFolders) { + expect(report.results.some((entry: { filePath: string }) => + entry.filePath.startsWith(`Solutions/${sibling}/`))).to.equal(false); + } + }); + + it("uses an explicitly selected content root instead of the toolkit checkout", () => { + const fixtureRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const reportPath = path.join(repoRoot, ".script/tests/localValidationTest/cross-root-results.json"); + fs.rmSync(reportPath, { force: true }); + + try { + const result = spawnSync("node", [ + validatorPath, + "--path", "Solutions/RootProbe", + "--repository-root", fixtureRoot, + "--only", "json", + "--json-file", reportPath, + ], { cwd: repoRoot, encoding: "utf8" }); + + expect(result.status, result.stderr).to.equal(1); + expect(result.stdout).to.contain("Complete TypeScript validation results written"); + expect(fs.existsSync(reportPath)).to.equal(true); + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + expect(report.summary.failed).to.equal(1); + expect(report.results[0].validator).to.equal("JSON Syntax"); + expect(report.results[0].filePath).to.equal("Solutions/RootProbe/Data/invalid.json"); + expect(report.results[0].passed).to.equal(false); + expect(report.results[0].error).to.be.a("string").and.not.empty; + expect(report.results.some((entry: { filePath: string }) => + /Solutions\/RootProbe\/(Logs|Reports|Evidence)\//.test(entry.filePath))).to.equal(false); + + fs.rmSync(reportPath, { force: true }); + const toolkitFixtureRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/toolkit"); + const toolkitResult = spawnSync("node", [ + validatorPath, + "--path", "Solutions/RootProbe", + "--repository-root", toolkitFixtureRoot, + "--only", "json", + "--json-file", reportPath, + ], { cwd: repoRoot, encoding: "utf8" }); + expect(toolkitResult.status, toolkitResult.stderr).to.equal(0); + const toolkitReport = JSON.parse(fs.readFileSync(reportPath, "utf8")); + expect(toolkitReport.summary.failed).to.equal(0); + } finally { + fs.rmSync(reportPath, { force: true }); + } + }); + + it("writes complete structured results larger than captured console output", () => { + const fixtureRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const dataDirectory = path.join(fixtureRoot, "Solutions/RootProbe/Data/large-output"); + const reportPath = path.join(repoRoot, ".script/tests/localValidationTest/large-cross-root-results.json"); + fs.mkdirSync(dataDirectory, { recursive: true }); + for (let index = 0; index < 180; index++) { + fs.writeFileSync(path.join(dataDirectory, `invalid-${index}.json`), "{\"invalid\":", "utf8"); + } + + try { + const result = spawnSync("node", [ + validatorPath, + "--path", "Solutions/RootProbe", + "--repository-root", fixtureRoot, + "--only", "json", + "--json-file", reportPath, + ], { cwd: repoRoot, encoding: "utf8" }); + expect(result.status).to.equal(1); + expect(result.stdout.length).to.be.lessThan(1024); + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + expect(report.summary.failed).to.equal(181); + expect(fs.statSync(reportPath).size).to.be.greaterThan(16000); + } finally { + fs.rmSync(dataDirectory, { recursive: true, force: true }); + fs.rmSync(reportPath, { force: true }); + } + }); + + it("counts scoped .NET exclusions as skipped, not passed or failed", () => { + const fixtureRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const reportPath = path.join(repoRoot, ".script/tests/localValidationTest/skipped-cross-root-results.json"); + fs.rmSync(reportPath, { force: true }); + try { + const result = spawnSync("node", [ + validatorPath, + "--path", "Solutions/RootProbe", + "--repository-root", fixtureRoot, + "--only", "kql", + "--json-file", reportPath, + ], { cwd: repoRoot, encoding: "utf8" }); + expect(result.status).to.equal(0); + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + expect(report.summary).to.deep.equal({ total: 1, passed: 0, failed: 0, skipped: 1 }); + + const displayResult = spawnSync("node", [ + validatorPath, + "--path", "Solutions/RootProbe", + "--repository-root", fixtureRoot, + "--only", "kql", + ], { cwd: repoRoot, encoding: "utf8" }); + expect(displayResult.status).to.equal(0); + expect(displayResult.stdout).to.contain("⏭ KQL Validation"); + expect(displayResult.stdout).not.to.contain("✅ KQL Validation"); + } finally { + fs.rmSync(reportPath, { force: true }); + } + }); + + it("passes the selected content root to standalone .NET validators", () => { + const contentRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const stubDirectory = path.join(repoRoot, ".script/tests/localValidationTest", `.dotnet-stub-${process.pid}`); + const markerPath = path.join(stubDirectory, "selected-content-root.txt"); + const reportPath = path.join(repoRoot, ".script/tests/localValidationTest", `cross-root-dotnet-${process.pid}.json`); + const stubPath = path.join(stubDirectory, process.platform === "win32" ? "dotnet.cmd" : "dotnet"); + fs.mkdirSync(stubDirectory, { recursive: true }); + fs.writeFileSync(stubPath, process.platform === "win32" + ? "@echo off\r\nif \"%1\"==\"--version\" (echo 8.0.100 & exit /b 0)\r\necho %SENTINEL_VALIDATION_CONTENT_ROOT%>%DOTNET_CONTENT_ROOT_MARKER%\r\necho Stubbed dotnet test\r\nexit /b 0\r\n" + : "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf '8.0.100\\n'; exit 0; fi\nprintf '%s' \"$SENTINEL_VALIDATION_CONTENT_ROOT\" > \"$DOTNET_CONTENT_ROOT_MARKER\"\nprintf 'Stubbed dotnet test\\n'\n", "utf8"); + fs.chmodSync(stubPath, 0o755); + + try { + const result = spawnSync("node", [ + validatorPath, + "--repository-root", contentRoot, + "--files", "Solutions/RootProbe/Data/invalid.json", + "--only", "kql", + "--json-file", reportPath, + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${stubDirectory}${path.delimiter}${process.env.PATH ?? ""}`, + DOTNET_CONTENT_ROOT_MARKER: markerPath, + }, + }); + + expect(result.status, result.stderr).to.equal(0); + expect(fs.readFileSync(markerPath, "utf8").trim()).to.equal(contentRoot); + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + const kqlResult = report.results.find((entry: { validator: string }) => + entry.validator === "KQL Validation"); + expect(kqlResult?.passed).to.equal(true); + expect(kqlResult?.filePath).to.equal(path.join(repoRoot, ".script/tests/KqlvalidationsTests/Kqlvalidations.Tests.csproj")); + } finally { + fs.rmSync(stubDirectory, { recursive: true, force: true }); + fs.rmSync(reportPath, { force: true }); + } + }); + + it("rejects a content root without Solutions instead of falling back to the toolkit root", () => { + const result = spawnSync("node", [ + validatorPath, + "--path", "Solutions/CiscoASA", + "--repository-root", path.join(repoRoot, ".script/tests/localValidationTest"), + "--only", "json", + "--json", + ], { cwd: repoRoot, encoding: "utf8" }); + + expect(result.status).to.equal(2); + expect(result.stderr).to.contain("repository directory containing Solutions/"); + expect(result.stdout).to.equal(""); + }); + + const powershellAvailable = spawnSync("pwsh", ["--version"], { encoding: "utf8" }).status === 0; + (powershellAvailable ? it : it.skip)("checks selected-root publishable links but excludes private path segments", () => { + const contentRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const solutionPath = path.join(contentRoot, "Solutions/RootProbe"); + const fixtureDirectory = path.join(solutionPath, "Data/hyperlink-test-temp"); + const privateDirectories = [ + "lOgS/hyperlink-test-temp/nested", + "RePoRtS/hyperlink-test-temp/nested", + "eViDeNcE/hyperlink-test-temp/nested", + ].map(relative => path.join(solutionPath, relative)); + const privateFile = path.join(privateDirectories[0], "packaging.v3_1.json"); + const requestMarker = path.join(fixtureDirectory, "requests.txt"); + const selectedLinks = [ + "https://public.example.test/current-content", + "https://public.example.test/Reports.md.json", + "https://public.example.test/LogsArchive/current", + "https://public.example.test/Evidence-notes/current", + ]; + const privateLogText = "templateURL=https://public.example.test/historical/mainTemplate.json; trackingId=fixture-only"; + + fs.mkdirSync(fixtureDirectory, { recursive: true }); + privateDirectories.forEach(directory => fs.mkdirSync(directory, { recursive: true })); + fs.writeFileSync(path.join(fixtureDirectory, "current.json"), JSON.stringify({ url: selectedLinks[0] }), "utf8"); + fs.writeFileSync(path.join(fixtureDirectory, "Reports.md.json"), JSON.stringify({ url: selectedLinks[1] }), "utf8"); + fs.writeFileSync(path.join(fixtureDirectory, "LogsArchive.json"), JSON.stringify({ url: selectedLinks[2] }), "utf8"); + fs.writeFileSync(path.join(fixtureDirectory, "Evidence-notes.json"), JSON.stringify({ url: selectedLinks[3] }), "utf8"); + fs.writeFileSync(privateFile, JSON.stringify({ log: privateLogText }), "utf8"); + fs.writeFileSync(path.join(privateDirectories[1], "report.json"), JSON.stringify({ url: "https://private.example.test/reports" }), "utf8"); + fs.writeFileSync(path.join(privateDirectories[2], "evidence.json"), JSON.stringify({ url: "https://private.example.test/evidence" }), "utf8"); + + try { + const runValidator = (cwd: string) => spawnSync("pwsh", ["-NoProfile", "-Command", [ + "function global:Invoke-WebRequest { param([string]$Uri, [int]$TimeoutSec); [System.IO.File]::AppendAllText($env:REQUEST_MARKER, \"$Uri`n\"); [pscustomobject]@{ StatusCode = 200 } }", + "& $env:HYPERLINK_VALIDATOR_PATH -runId test -pullRequestNumber 0 -instrumentationKey '' -baseFolderPath $env:CONTENT_ROOT -SolutionPath $env:SOLUTION_PATH", + ].join("; ")], { + cwd, + encoding: "utf8", + env: { + ...process.env, + HYPERLINK_VALIDATOR_PATH: hyperlinkValidatorPath, + CONTENT_ROOT: contentRoot, + SOLUTION_PATH: solutionPath, + REQUEST_MARKER: requestMarker, + }, + }); + for (const cwd of [repoRoot, contentRoot]) { + fs.rmSync(requestMarker, { force: true }); + const result = runValidator(cwd); + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout).to.contain("Hyperlink validation scoped to current files"); + const requestedLinks = fs.readFileSync(requestMarker, "utf8").trim().split(/\r?\n/).sort(); + expect(requestedLinks).to.deep.equal([...selectedLinks].sort()); + expect(requestedLinks.some(url => url.includes("mainTemplate.json;"))).to.equal(false); + } + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + privateDirectories.forEach(directory => fs.rmSync(directory, { recursive: true, force: true })); + } + }).timeout(15000); + + (powershellAvailable ? it : it.skip)("fails explicitly when a selected-root hyperlink file disappears during discovery", () => { + const contentRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const solutionPath = path.join(contentRoot, "Solutions/RootProbe"); + const fixtureDirectory = path.join(solutionPath, "Data/hyperlink-missing-test-temp"); + const missingFile = path.join(fixtureDirectory, "missing-on-read.json"); + fs.mkdirSync(fixtureDirectory, { recursive: true }); + fs.writeFileSync(missingFile, JSON.stringify({ reference: "https://public.example.test/docs" }), "utf8"); + + const result = spawnSync("pwsh", ["-NoProfile", "-Command", [ + "function global:Invoke-WebRequest { param([string]$Uri, [int]$TimeoutSec, [string]$ErrorAction); [pscustomobject]@{ StatusCode = 200 } }", + "function global:Get-ChildItem { [CmdletBinding()] param([Parameter(Position=0)][string[]]$Path, [switch]$Recurse, [switch]$File); $items = Microsoft.PowerShell.Management\\Get-ChildItem -Path $Path -Recurse:$Recurse -File:$File; if ($Path -contains $env:SOLUTION_PATH) { Remove-Item -LiteralPath $env:MISSING_EVIDENCE_FILE -Force }; return $items }", + "& $env:HYPERLINK_VALIDATOR_PATH -runId test -pullRequestNumber 0 -instrumentationKey '' -baseFolderPath $env:CONTENT_ROOT -SolutionPath $env:SOLUTION_PATH", + ].join("; ")], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + HYPERLINK_VALIDATOR_PATH: hyperlinkValidatorPath, + CONTENT_ROOT: contentRoot, + SOLUTION_PATH: solutionPath, + MISSING_EVIDENCE_FILE: missingFile, + }, + }); + + try { + expect(result.status).to.equal(1); + expect(result.stdout).to.contain(`Content file does not exist under the selected content root: '${missingFile}'`); + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } + }); + + (powershellAvailable ? it : it.skip)("fails when a publishable selected-root URL is actually invalid", () => { + const contentRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const solutionPath = path.join(contentRoot, "Solutions/RootProbe"); + const fixtureDirectory = path.join(solutionPath, "Data/hyperlink-invalid-test-temp"); + const invalidUrl = "https://public.example.test/not-found"; + const linkFile = path.join(fixtureDirectory, "current.json"); + fs.mkdirSync(fixtureDirectory, { recursive: true }); + fs.writeFileSync(linkFile, JSON.stringify({ url: invalidUrl }), "utf8"); + + const result = spawnSync("pwsh", ["-NoProfile", "-Command", [ + "function global:Invoke-WebRequest { param([string]$Uri, [int]$TimeoutSec); $exception = [System.Exception]::new('Not Found'); $exception | Add-Member -MemberType NoteProperty -Name Response -Value ([pscustomobject]@{ StatusCode = 404 }); throw $exception }", + "& $env:HYPERLINK_VALIDATOR_PATH -runId test -pullRequestNumber 0 -instrumentationKey '' -baseFolderPath $env:CONTENT_ROOT -SolutionPath $env:SOLUTION_PATH", + ].join("; ")], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + HYPERLINK_VALIDATOR_PATH: hyperlinkValidatorPath, + CONTENT_ROOT: contentRoot, + SOLUTION_PATH: solutionPath, + }, + }); + + try { + expect(result.status).to.equal(1); + expect(result.stdout).to.contain(invalidUrl); + expect(result.stdout).to.contain(": Invalid URL"); + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } + }); + + (powershellAvailable ? it : it.skip)("accepts a separate content worktree in validation-only mode", () => { + const contentRoot = path.join(repoRoot, ".script/tests/localValidationTest/crossRootFixtures/content"); + const gitDirectory = path.join(contentRoot, ".git"); + const evidenceParent = path.join(repoRoot, ".script/local-validation/Logs", `cross-root-runner-${process.pid}-${Date.now()}`); + const before = fs.readFileSync(path.join(contentRoot, "Solutions/RootProbe/Data/invalid.json")); + + try { + const init = spawnSync("git", ["init", "--quiet", contentRoot], { cwd: repoRoot, encoding: "utf8" }); + expect(init.status, init.stderr).to.equal(0); + const result = spawnSync("pwsh", [ + "-NoProfile", "-File", buildRunnerPath, + "-SolutionName", "RootProbe", + "-ContentRoot", contentRoot, + "-SkipPackaging", + "-SkipValidation", + "-VersionBump", "none", + "-EvidenceDirectory", evidenceParent, + ], { cwd: repoRoot, encoding: "utf8" }); + + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout).to.contain(`Content Root: ${contentRoot}`); + expect(result.stdout).to.contain("[REPORT_START]"); + expect(result.stdout).to.contain("[REPORT_END]"); + expect(fs.readFileSync(path.join(contentRoot, "Solutions/RootProbe/Data/invalid.json"))).to.deep.equal(before); + } finally { + fs.rmSync(gitDirectory, { recursive: true, force: true }); + fs.rmSync(evidenceParent, { recursive: true, force: true }); + } + }); +}); diff --git a/.script/tests/localValidationTest/validationBinding.test.ts b/.script/tests/localValidationTest/validationBinding.test.ts new file mode 100644 index 00000000000..90519066011 --- /dev/null +++ b/.script/tests/localValidationTest/validationBinding.test.ts @@ -0,0 +1,598 @@ +import { spawnSync } from "child_process"; +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { expect } from "chai"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const bindingScript = path.join(repoRoot, ".script/local-validation/validation-binding.ps1"); +const runnerScript = path.join(repoRoot, ".script/local-validation/build-and-validate.ps1"); +const fixtureRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), `.validation-binding-${process.pid}`); +const actualContentRoot = process.env.AZURE_SENTINEL_CONTENT_ROOT + ? path.resolve(process.env.AZURE_SENTINEL_CONTENT_ROOT) + : undefined; +const actualSolutionNames = ["CiscoASA", "Azure Activity", "Syslog"]; +const actualSolutionPaths = actualContentRoot + ? actualSolutionNames.map(name => path.join(actualContentRoot, "Solutions", name)) + : []; +const actualSolutionsAvailable = + actualSolutionPaths.length === actualSolutionNames.length && + actualSolutionPaths.every(solution => fs.existsSync(solution)); +const powershellAvailable = spawnSync("pwsh", ["--version"], { encoding: "utf8" }).status === 0; + +function sha256(value: Buffer | string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function pythonJsonStringify(value: unknown): string { + const json = JSON.stringify(value); + if (json === undefined) { + throw new Error("fixture value is not JSON serializable"); + } + return json.replace(/[\u007f-\uffff]/g, character => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} + +function quotePowerShell(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function createFixture(name: string) { + const root = path.join(fixtureRoot, name); + const solution = path.join(root, "content", "Solutions", "FixtureSolution"); + const evidence = path.join(root, "evidence", "run-1"); + const packageDirectory = path.join(solution, "Package"); + const reportDirectory = path.join(solution, "Logs", "sentinel-xdr-migration", "run-1"); + fs.mkdirSync(path.join(solution, "Data"), { recursive: true }); + fs.mkdirSync(path.join(solution, "Data Connectors"), { recursive: true }); + fs.mkdirSync(path.join(solution, "azure NRT"), { recursive: true }); + fs.mkdirSync(path.join(solution, "New-CloudShell"), { recursive: true }); + fs.mkdirSync(path.join(solution, "Hunting Queries"), { recursive: true }); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.mkdirSync(reportDirectory, { recursive: true }); + fs.mkdirSync(evidence, { recursive: true }); + fs.writeFileSync(path.join(solution, "Data", "source.json"), "{\"source\":true}\n", "utf8"); + fs.writeFileSync(path.join(solution, "Data", "café.json"), "{\"label\":\"é\"}\n", "utf8"); + fs.writeFileSync( + path.join(solution, "Data Connectors", "connector.json"), + "{\"connector\":true}\n", + "utf8", + ); + fs.writeFileSync(path.join(solution, "azure NRT", "azure.json"), "{\"azure\":true}\n", "utf8"); + fs.writeFileSync( + path.join(solution, "New-CloudShell", "shell.json"), + "{\"shell\":true}\n", + "utf8", + ); + + const artifacts = { + mainTemplate: ["Package/mainTemplate.json", "{\"resources\":[]}\n"], + createUiDefinition: ["Package/createUiDefinition.json", "{\"createUiDefinition\":{}}\n"], + testParameters: ["Package/testParameters.json", "{}\n"], + zip: ["Package/3.1.1.zip", "fixture archive bytes\n"], + } as const; + const packageArtifactHashes: Record = {}; + for (const [name, [relative, content]] of Object.entries(artifacts)) { + fs.writeFileSync(path.join(solution, relative), content, "utf8"); + packageArtifactHashes[name] = sha256(content); + } + const sourceInputHash = sha256(pythonJsonStringify([ + { + path: "Data/café.json", + sha256: sha256("{\"label\":\"é\"}\n"), + }, + { + path: "Data/source.json", + sha256: sha256("{\"source\":true}\n"), + }, + { + path: "Data Connectors/connector.json", + sha256: sha256("{\"connector\":true}\n"), + }, + { + path: "New-CloudShell/shell.json", + sha256: sha256("{\"shell\":true}\n"), + }, + { + path: "azure NRT/azure.json", + sha256: sha256("{\"azure\":true}\n"), + }, + ])); + const packageReportPath = path.join(reportDirectory, "packaging.v3_1.json"); + fs.writeFileSync(packageReportPath, JSON.stringify({ + packager: "V3.1", + version: "3.1.1", + packageAttemptId: "attempt-fixture-1", + workflowRunId: "run-1", + packageInputs: { after: { sha256: sourceInputHash } }, + packageArtifactsSha256: "a".repeat(64), + packageArtifactHashes, + mainTemplate: artifacts.mainTemplate[0], + createUiDefinition: artifacts.createUiDefinition[0], + testParameters: artifacts.testParameters[0], + zip: artifacts.zip[0], + }, null, 2), "utf8"); + + return { root, solution, evidence, packageReportPath }; +} + +function runSnapshot( + fixture: ReturnType, + options: { + mutateSource?: boolean; + mutatePackage?: boolean; + staleSource?: boolean; + validationFailed?: boolean; + windowsPackageIdentity?: boolean; + snapshotOnly?: boolean; + } = {}, +) { + const command = [ + `. ${quotePowerShell(bindingScript)}`, + ...(options.windowsPackageIdentity + ? [ + `$packageReport = Get-Content -LiteralPath ${quotePowerShell(fixture.packageReportPath)} -Raw | ConvertFrom-Json`, + `$windowsPackageInputs = Get-ValidationFileFingerprints -Root ${quotePowerShell(fixture.solution)} -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Windows`, + "$packageReport.packageInputs.after.sha256 = Get-ValidationInputFingerprintHash -Files $windowsPackageInputs", + `[System.IO.File]::WriteAllText(${quotePowerShell(fixture.packageReportPath)}, ($packageReport | ConvertTo-Json -Depth 30))`, + ] + : []), + options.staleSource + ? `[System.IO.File]::WriteAllText(${quotePowerShell(path.join(fixture.solution, "Data", "source.json"))}, '{\"source\":stale}')` + : "", + `$before = Get-ValidationEvidenceSnapshot -ToolkitRoot ${quotePowerShell(repoRoot)} -ContentRoot ${quotePowerShell(path.join(fixture.root, "content"))} -SolutionPath ${quotePowerShell(fixture.solution)} -PackageReportPath ${quotePowerShell(path.relative(fixture.solution, fixture.packageReportPath))}`, + ...(options.snapshotOnly + ? ["$before | ConvertTo-Json -Depth 30 -Compress"] + : [ + ...(options.mutateSource + ? [ + `[System.IO.File]::WriteAllText(${quotePowerShell(path.join(fixture.solution, "Data", "source.json"))}, '{\"source\":false}')`, + `$packageReport = Get-Content -LiteralPath ${quotePowerShell(fixture.packageReportPath)} -Raw | ConvertFrom-Json`, + `$packageInputs = Get-ValidationFileFingerprints -Root ${quotePowerShell(fixture.solution)} -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Posix`, + "$packageReport.packageInputs.after.sha256 = Get-ValidationInputFingerprintHash -Files $packageInputs", + `[System.IO.File]::WriteAllText(${quotePowerShell(fixture.packageReportPath)}, ($packageReport | ConvertTo-Json -Depth 30))`, + ] + : []), + options.mutatePackage + ? `[System.IO.File]::WriteAllText(${quotePowerShell(path.join(fixture.solution, "Package", "mainTemplate.json"))}, '{\"resources\":[1]}')` + : "", + `$after = Get-ValidationEvidenceSnapshot -ToolkitRoot ${quotePowerShell(repoRoot)} -ContentRoot ${quotePowerShell(path.join(fixture.root, "content"))} -SolutionPath ${quotePowerShell(fixture.solution)} -PackageReportPath ${quotePowerShell(path.relative(fixture.solution, fixture.packageReportPath))}`, + `$entries = @(@{ Step = 2; Status = "${options.validationFailed ? "failed" : "passed"}" })`, + `$binding = Complete-ValidationEvidenceBinding -Before $before -After $after -ValidationEntries $entries -PackagingSkipped $true -ValidationSkipped $false -EvidenceRunPath ${quotePowerShell(fixture.evidence)}`, + `$receipt = Write-ValidationEvidenceBinding -EvidenceRunPath ${quotePowerShell(fixture.evidence)} -Binding $binding`, + "[ordered]@{ binding = $binding; receipt = $receipt } | ConvertTo-Json -Depth 30 -Compress", + ]), + ].filter(Boolean).join("; "); + return spawnSync("pwsh", ["-NoProfile", "-Command", command], { encoding: "utf8" }); +} + +describe("V3.1 validation evidence producer", () => { + afterEach(() => { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it("wires evidence capture, completion, and receipt reporting into the existing runner", () => { + const runner = fs.readFileSync(runnerScript, "utf8"); + expect(runner.match(/Get-ValidationEvidenceSnapshot/g)).to.have.length(2); + expect(runner).to.contain("Complete-ValidationEvidenceBinding"); + expect(runner).to.contain("Write-ValidationEvidenceBinding"); + expect(runner).to.contain("Validation binding SHA256:"); + expect(runner).to.contain("-PackageReportPath requires validation-only mode"); + }); + + (powershellAvailable ? it : it.skip)("uses Python-compatible JSON escaping for fingerprint paths", () => { + const value = `café"\\path.json`; + const result = spawnSync( + "pwsh", + [ + "-NoProfile", + "-Command", + `. ${quotePowerShell(bindingScript)}; ConvertTo-ValidationJsonString -Value ${quotePowerShell(value)}`, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout.trim()).to.equal(pythonJsonStringify(value)); + }); + + (powershellAvailable ? it : it.skip)("matches POSIX and PureWindowsPath identities for three solution trees", function () { + this.timeout(15000); + const identityRoot = path.join(fixtureRoot, "identity-roundtrip"); + const solutions = ["CiscoASA", "AzureFirewall", "Syslog"].map(name => + path.join(identityRoot, name), + ); + for (const [index, solution] of solutions.entries()) { + fs.mkdirSync(path.join(solution, "Data"), { recursive: true }); + fs.mkdirSync(path.join(solution, "Data Connectors"), { recursive: true }); + fs.mkdirSync(path.join(solution, "Analytic Rules"), { recursive: true }); + fs.mkdirSync(path.join(solution, "azure NRT"), { recursive: true }); + fs.mkdirSync(path.join(solution, "New-CloudShell"), { recursive: true }); + fs.mkdirSync(path.join(solution, "Hunting Queries"), { recursive: true }); + fs.writeFileSync( + path.join(solution, "Data", `Solution_${index}.json`), + JSON.stringify({ solution: index }), + "utf8", + ); + fs.writeFileSync( + path.join(solution, "Data Connectors", `connector_${index}.json`), + JSON.stringify({ connector: index }), + "utf8", + ); + fs.writeFileSync( + path.join(solution, "Analytic Rules", `rule_${index}.yaml`), + `id: rule-${index}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(solution, "azure NRT", `azure_${index}.json`), + JSON.stringify({ azure: index }), + "utf8", + ); + fs.writeFileSync( + path.join(solution, "New-CloudShell", `shell_${index}.json`), + JSON.stringify({ shell: index }), + "utf8", + ); + fs.writeFileSync( + path.join(solution, "Hunting Queries", "AnomalousAzureOperationModel.yaml"), + "id: anomalous-azure\n", + "utf8", + ); + fs.writeFileSync( + path.join(solution, "Hunting Queries", "Anomalous_Listing_Of_Storage_Keys.yaml"), + "id: anomalous-storage\n", + "utf8", + ); + } + + const python = spawnSync( + "python3.12", + [ + "-c", + [ + "import hashlib,json,os,sys", + "from pathlib import Path,PurePosixPath,PureWindowsPath", + "from sentinel_xdr_migration.packaging import package_input_identity", + "excluded={'package','logs','evidence','.git','.venv','__pycache__'}", + "def identity(root,path_type):", + " root=Path(root)", + " files=[{'path':p.relative_to(root).as_posix(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in root.rglob('*') if p.is_file() and not any(part.lower() in excluded or part.lower()=='.env' or part.lower().startswith('.env.') for part in p.relative_to(root).parts)]", + " files.sort(key=lambda item:path_type(item['path']))", + " return hashlib.sha256(json.dumps(files,sort_keys=True,separators=(',',':')).encode()).hexdigest()", + "roots=sys.argv[1:]", + "punctuation_order=sorted(['AnomalousAzureOperationModel.yaml','Anomalous_Listing_Of_Storage_Keys.yaml'],key=PureWindowsPath)", + "print(json.dumps({'posix':[identity(root,PurePosixPath) for root in roots],'windows':[identity(root,PureWindowsPath) for root in roots],'native':[package_input_identity(root)['sha256'] for root in roots],'nativeSemantics':'windows' if os.name=='nt' else 'posix','punctuationOrder':punctuation_order}))", + ].join("\n"), + ...solutions, + ], + { + cwd: repoRoot, + env: { + ...process.env, + PYTHONPATH: path.join(repoRoot, "Tools/SentinelToXDRMigration"), + }, + encoding: "utf8", + }, + ); + expect(python.status, python.stderr).to.equal(0); + const powershell = [ + `. ${quotePowerShell(bindingScript)}`, + "$posixHashes = [System.Collections.Generic.List[string]]::new()", + "$windowsHashes = [System.Collections.Generic.List[string]]::new()", + `foreach ($root in @(${solutions.map(quotePowerShell).join(",")})) {`, + ` $posixFiles = Get-ValidationFileFingerprints -Root $root -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Posix`, + ` $windowsFiles = Get-ValidationFileFingerprints -Root $root -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Windows`, + " $posixHashes.Add((Get-ValidationInputFingerprintHash -Files $posixFiles))", + " $windowsHashes.Add((Get-ValidationInputFingerprintHash -Files $windowsFiles))", + "}", + "[ordered]@{ posix = $posixHashes.ToArray(); windows = $windowsHashes.ToArray() } | ConvertTo-Json -Depth 10 -Compress", + ].join("; "); + const result = spawnSync("pwsh", ["-NoProfile", "-Command", powershell], { + encoding: "utf8", + }); + expect(result.status, result.stderr).to.equal(0); + const expected = JSON.parse(python.stdout); + const actual = JSON.parse(result.stdout); + expect(actual.posix).to.deep.equal(expected.posix); + expect(actual.windows).to.deep.equal(expected.windows); + expect(expected.native).to.deep.equal( + expected[expected.nativeSemantics as "posix" | "windows"], + ); + expect(expected.punctuationOrder).to.deep.equal([ + "Anomalous_Listing_Of_Storage_Keys.yaml", + "AnomalousAzureOperationModel.yaml", + ]); + for (let index = 0; index < expected.posix.length; index++) { + expect(expected.windows[index]).to.not.equal(expected.posix[index]); + } + }); + + (powershellAvailable && actualSolutionsAvailable ? it : it.skip)( + "matches Python Path identities for read-only actual CiscoASA, Azure Activity, and Syslog trees", + function () { + this.timeout(30000); + const python = spawnSync( + "python3.12", + [ + "-c", + [ + "import hashlib,json,os,sys", + "from pathlib import Path,PurePosixPath,PureWindowsPath", + "from sentinel_xdr_migration.packaging import package_input_identity", + "excluded={'package','logs','evidence','.git','.venv','__pycache__'}", + "def identity(root,path_type):", + " root=Path(root)", + " files=[]", + " for p in root.rglob('*'):", + " relative=p.relative_to(root)", + " parts=relative.parts", + " if any(part.lower() in excluded or part.lower()=='.env' or part.lower().startswith('.env.') for part in parts): continue", + " if p.is_symlink(): raise ValueError(f'unsafe symlink: {relative}')", + " if p.is_file(): files.append({'path':relative.as_posix(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()})", + " files.sort(key=lambda item:path_type(item['path']))", + " return hashlib.sha256(json.dumps(files,sort_keys=True,separators=(',',':')).encode()).hexdigest()", + "roots=sys.argv[1:]", + "print(json.dumps({'posix':[identity(root,PurePosixPath) for root in roots],'windows':[identity(root,PureWindowsPath) for root in roots],'native':[package_input_identity(root)['sha256'] for root in roots],'nativeSemantics':'windows' if os.name=='nt' else 'posix'}))", + ].join("\n"), + ...actualSolutionPaths, + ], + { + cwd: repoRoot, + env: { + ...process.env, + PYTHONPATH: path.join(repoRoot, "Tools/SentinelToXDRMigration"), + }, + encoding: "utf8", + }, + ); + expect(python.status, python.stderr).to.equal(0); + const powershell = [ + `. ${quotePowerShell(bindingScript)}`, + "$posixHashes = [System.Collections.Generic.List[string]]::new()", + "$windowsHashes = [System.Collections.Generic.List[string]]::new()", + `foreach ($root in @(${actualSolutionPaths.map(quotePowerShell).join(",")})) {`, + ` $posixFiles = Get-ValidationFileFingerprints -Root $root -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Posix`, + ` $windowsFiles = Get-ValidationFileFingerprints -Root $root -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Windows`, + " $posixHashes.Add((Get-ValidationInputFingerprintHash -Files $posixFiles))", + " $windowsHashes.Add((Get-ValidationInputFingerprintHash -Files $windowsFiles))", + "}", + "[ordered]@{ posix = $posixHashes.ToArray(); windows = $windowsHashes.ToArray() } | ConvertTo-Json -Depth 10 -Compress", + ].join("; "); + const result = spawnSync("pwsh", ["-NoProfile", "-Command", powershell], { + encoding: "utf8", + }); + expect(result.status, result.stderr).to.equal(0); + const expected = JSON.parse(python.stdout); + const actual = JSON.parse(result.stdout); + expect(actual.posix).to.deep.equal(expected.posix); + expect(actual.windows).to.deep.equal(expected.windows); + expect(expected.native).to.deep.equal( + expected[expected.nativeSemantics as "posix" | "windows"], + ); + for (let index = 0; index < expected.posix.length; index++) { + expect(expected.windows[index]).to.not.equal(expected.posix[index]); + } + }, + ); + + (powershellAvailable && actualSolutionsAvailable ? it : it.skip)( + "round-trips an actual Azure Activity package snapshot and rejects stale copied inputs", + function () { + this.timeout(30000); + const solution = actualSolutionPaths[1]; + const copiedSolution = path.join( + fixtureRoot, + "actual-snapshot", + "Solutions", + "Azure Activity", + ); + const copy = spawnSync( + "python3.12", + [ + "-c", + [ + "import hashlib,json,shutil,sys", + "from pathlib import Path,PureWindowsPath", + "from sentinel_xdr_migration.packaging import package_input_identity", + "excluded={'package','logs','evidence','.git','.venv','__pycache__'}", + "def ignore(directory,names): return [name for name in names if name.lower() in excluded or name.lower()=='.env' or name.lower().startswith('.env.')]", + "source=Path(sys.argv[1]); destination=Path(sys.argv[2])", + "source_identity=package_input_identity(source)", + "shutil.copytree(source,destination,ignore=ignore)", + "copied_identity=package_input_identity(destination)", + "if source_identity['sha256'] != copied_identity['sha256']: raise ValueError('read-only content copy changed the V3.1 input identity')", + "files=[]", + "for p in destination.rglob('*'):", + " relative=p.relative_to(destination); parts=relative.parts", + " if any(part.lower() in excluded or part.lower()=='.env' or part.lower().startswith('.env.') for part in parts): continue", + " if p.is_symlink(): raise ValueError(f'unsafe symlink: {relative}')", + " if p.is_file(): files.append({'path':relative.as_posix(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()})", + "files.sort(key=lambda item:PureWindowsPath(item['path']))", + "windows_hash=hashlib.sha256(json.dumps(files,sort_keys=True,separators=(',',':')).encode()).hexdigest()", + "print(json.dumps({'sourceInputSha256':source_identity['sha256'],'copyInputSha256':copied_identity['sha256'],'windowsInputSha256':windows_hash}))", + ].join("\n"), + solution, + copiedSolution, + ], + { + cwd: repoRoot, + env: { + ...process.env, + PYTHONPATH: path.join(repoRoot, "Tools/SentinelToXDRMigration"), + }, + encoding: "utf8", + }, + ); + expect(copy.status, copy.stderr).to.equal(0); + const identities = JSON.parse(copy.stdout); + expect(identities.copyInputSha256).to.equal(identities.sourceInputSha256); + + const packageDirectory = path.join(copiedSolution, "Package"); + const reportDirectory = path.join( + copiedSolution, + "Logs", + "sentinel-xdr-migration", + "roundtrip", + ); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.mkdirSync(reportDirectory, { recursive: true }); + const artifacts = { + mainTemplate: ["Package/mainTemplate.json", "{\"resources\":[]}\n"], + createUiDefinition: ["Package/createUiDefinition.json", "{\"createUiDefinition\":{}}\n"], + testParameters: ["Package/testParameters.json", "{}\n"], + zip: ["Package/3.1.1.zip", "fixture archive bytes\n"], + } as const; + const packageArtifactHashes: Record = {}; + for (const [name, [relative, content]] of Object.entries(artifacts)) { + fs.writeFileSync(path.join(copiedSolution, relative), content, "utf8"); + packageArtifactHashes[name] = sha256(content); + } + const packageReportRelativePath = + "Logs/sentinel-xdr-migration/roundtrip/packaging.v3_1.json"; + fs.writeFileSync( + path.join(copiedSolution, packageReportRelativePath), + JSON.stringify({ + packager: "V3.1", + version: "3.1.1", + packageAttemptId: "actual-azure-activity-snapshot", + workflowRunId: "actual-tree-roundtrip", + packageInputs: { after: { sha256: identities.windowsInputSha256 } }, + packageArtifactsSha256: sha256(JSON.stringify(packageArtifactHashes)), + packageArtifactHashes, + mainTemplate: artifacts.mainTemplate[0], + createUiDefinition: artifacts.createUiDefinition[0], + testParameters: artifacts.testParameters[0], + zip: artifacts.zip[0], + }, null, 2), + "utf8", + ); + const contentRoot = path.join(fixtureRoot, "actual-snapshot"); + const staleSource = path.join( + copiedSolution, + "Hunting Queries", + "AnomalousAzureOperationModel.yaml", + ); + const command = [ + `. ${quotePowerShell(bindingScript)}`, + `$windowsFiles = Get-ValidationFileFingerprints -Root ${quotePowerShell(copiedSolution)} -ExcludeDirectories @("package","logs","evidence",".git",".venv","__pycache__") -PathOrder Windows`, + `$windowsHash = Get-ValidationInputFingerprintHash -Files $windowsFiles`, + `if ($windowsHash -ne ${quotePowerShell(identities.windowsInputSha256)}) { throw "actual copied-tree Windows hash mismatch: $windowsHash" }`, + `$before = Get-ValidationEvidenceSnapshot -ToolkitRoot ${quotePowerShell(repoRoot)} -ContentRoot ${quotePowerShell(contentRoot)} -SolutionPath ${quotePowerShell(copiedSolution)} -PackageReportPath ${quotePowerShell(packageReportRelativePath)}`, + `$after = Get-ValidationEvidenceSnapshot -ToolkitRoot ${quotePowerShell(repoRoot)} -ContentRoot ${quotePowerShell(contentRoot)} -SolutionPath ${quotePowerShell(copiedSolution)} -PackageReportPath ${quotePowerShell(packageReportRelativePath)}`, + "$entries = @(@{ Step = 2; Status = 'passed' })", + `$binding = Complete-ValidationEvidenceBinding -Before $before -After $after -ValidationEntries $entries -PackagingSkipped $true -ValidationSkipped $false -EvidenceRunPath ${quotePowerShell(path.join(fixtureRoot, "actual-snapshot-evidence"))}`, + "if ($binding.validationResult -ne 'PASSED' -or $before.sourcePathSemantics -ne 'windows') { throw 'unchanged actual-tree snapshot was not accepted with Windows Path semantics' }", + `[System.IO.File]::AppendAllText(${quotePowerShell(staleSource)}, '# stale')`, + "$staleRejected = $false", + `try { $null = Get-ValidationEvidenceSnapshot -ToolkitRoot ${quotePowerShell(repoRoot)} -ContentRoot ${quotePowerShell(contentRoot)} -SolutionPath ${quotePowerShell(copiedSolution)} -PackageReportPath ${quotePowerShell(packageReportRelativePath)} } catch { if ($_.Exception.Message -match 'stale against current solution inputs under both POSIX and Windows Path ordering') { $staleRejected = $true } else { throw } }`, + "if (-not $staleRejected) { throw 'changed actual-tree source input was accepted as current' }", + "[ordered]@{ validationResult = $binding.validationResult; sourcePathSemantics = $before.sourcePathSemantics; staleRejected = $staleRejected } | ConvertTo-Json -Compress", + ].join("; "); + const result = spawnSync("pwsh", ["-NoProfile", "-Command", command], { + encoding: "utf8", + }); + expect(result.status, result.stderr).to.equal(0); + const roundtrip = JSON.parse(result.stdout); + expect(roundtrip.validationResult).to.equal("PASSED"); + expect(roundtrip.sourcePathSemantics).to.equal("windows"); + expect(roundtrip.staleRejected).to.equal(true); + }, + ); + + (powershellAvailable ? it : it.skip)("selects packager Windows Path ordering from the report hash on a cross-host runner", function () { + this.timeout(15000); + const fixture = createFixture("windows-packager-order"); + const result = runSnapshot(fixture, { + windowsPackageIdentity: true, + snapshotOnly: true, + }); + expect(result.status, result.stderr).to.equal(0); + const snapshot = JSON.parse(result.stdout); + expect(snapshot.sourcePathSemantics).to.equal("windows"); + expect(snapshot.producerPlatform).to.equal(process.platform === "win32" ? "windows" : "posix"); + }); + + (powershellAvailable ? it : it.skip)("keeps sparse PASS/SKIP entries working in the actual build report function", () => { + const fixture = createFixture("build-report"); + const reportPath = path.join(fixture.evidence, "build-and-validate-report.md"); + const command = [ + `. ${quotePowerShell(bindingScript)}`, + "$tokens = $null; $parseErrors = $null", + `$runnerAst = [System.Management.Automation.Language.Parser]::ParseFile(${quotePowerShell(runnerScript)}, [ref]$tokens, [ref]$parseErrors)`, + "if ($parseErrors.Count) { throw ($parseErrors -join '; ') }", + "$functions = $runnerAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -in @('Format-Duration','Write-BuildReport') }, $true)", + "foreach ($functionAst in $functions) { Invoke-Expression $functionAst.Extent.Text }", + `$toolkitRoot = ${quotePowerShell(repoRoot)}`, + `$ContentRoot = ${quotePowerShell(path.join(fixture.root, "content"))}`, + "$SkipPackaging = $true", + "$entries = [System.Collections.ArrayList]::new()", + "$null = $entries.Add([pscustomobject]@{ Step = 2; Name = 'TypeScript validation'; Status = 'passed'; Passed = 1; Failed = 0; Skipped = 0 })", + "$null = $entries.Add([pscustomobject]@{ Step = 4; Name = 'ARM-TTK'; Status = 'skipped'; SkipReason = 'not installed' })", + `$meta = @{ StartTime = (Get-Date).AddSeconds(-3); SolutionName = 'FixtureSolution'; EvidenceRunPath = ${quotePowerShell(fixture.evidence)} }`, + "Write-BuildReport -Meta $meta -Entries $entries -Success $true", + `$report = [System.IO.File]::ReadAllText(${quotePowerShell(reportPath)})`, + "if (-not $report.Contains('| TS | TypeScript validation | PASS |') -or -not $report.Contains('| CI-PS | ARM-TTK | SKIP |') -or -not $report.Contains('Total: 2 validators | 1 passed | 0 failed | 1 skipped')) { throw 'PASS/SKIP report rows were not emitted' }", + "Write-Output 'Actual build report PASS/SKIP output verified'", + ].join("; "); + const result = spawnSync("pwsh", ["-NoProfile", "-Command", command], { + encoding: "utf8", + }); + expect(result.status, result.stderr).to.equal(0); + expect(result.stdout).to.contain("Actual build report PASS/SKIP output verified"); + }); + + (powershellAvailable ? it : it.skip)("writes a successful binding from the exact V3.1 report and unchanged artifacts", () => { + const fixture = createFixture("success"); + const result = runSnapshot(fixture); + expect(result.status, result.stderr).to.equal(0); + const output = JSON.parse(result.stdout); + expect(output.binding.validationResult).to.equal("PASSED"); + expect(output.binding.packagingSkipped).to.equal(true); + expect(output.binding.package.version).to.equal("3.1.1"); + expect(output.binding.package.packageAttemptId).to.equal("attempt-fixture-1"); + expect(output.binding.package.packageArtifactHashes.mainTemplate).to.equal( + sha256("{\"resources\":[]}\n"), + ); + expect(output.binding.inputFilesBefore).to.deep.equal(output.binding.inputFilesAfter); + expect(output.binding.packageFilesBefore).to.deep.equal(output.binding.packageFilesAfter); + const bindingPath = output.receipt.path as string; + expect(fs.existsSync(bindingPath)).to.equal(true); + expect(sha256(fs.readFileSync(bindingPath))).to.equal(output.receipt.sha256); + }); + + (powershellAvailable ? it : it.skip)("fails the binding when source inputs change during validation", () => { + const fixture = createFixture("changed-source"); + const result = runSnapshot(fixture, { mutateSource: true }); + expect(result.status, result.stderr).to.equal(0); + const output = JSON.parse(result.stdout); + expect(output.binding.validationResult).to.equal("FAILED"); + expect(output.binding.issues).to.include("solution input bytes changed during validation"); + }); + + (powershellAvailable ? it : it.skip)("fails when an existing validator fails", () => { + const fixture = createFixture("validator-failure"); + const result = runSnapshot(fixture, { validationFailed: true }); + expect(result.status, result.stderr).to.equal(0); + const output = JSON.parse(result.stdout); + expect(output.binding.validationResult).to.equal("FAILED"); + expect(output.binding.validatorSummary.failed).to.equal(1); + }); + + (powershellAvailable ? it : it.skip)("rejects stale package artifacts before producing a successful binding", () => { + const fixture = createFixture("stale-package"); + const result = runSnapshot(fixture, { mutatePackage: true }); + expect(result.status).to.not.equal(0); + expect(result.stderr + result.stdout).to.contain("V3.1 package artifact is stale"); + }); + + (powershellAvailable ? it : it.skip)("rejects source inputs that changed after V3.1 packaging", () => { + const fixture = createFixture("stale-source"); + const result = runSnapshot(fixture, { staleSource: true }); + expect(result.status).to.not.equal(0); + expect(result.stderr + result.stdout).to.contain( + "V3.1 package report is stale against current solution inputs", + ); + }); +}); diff --git a/.windsurf/rules/sentinel-xdr-migration.md b/.windsurf/rules/sentinel-xdr-migration.md new file mode 100644 index 00000000000..761142269db --- /dev/null +++ b/.windsurf/rules/sentinel-xdr-migration.md @@ -0,0 +1,13 @@ +# Sentinel to Defender XDR migration + +When working on Sentinel-to-Defender-XDR migration, XDR Detection authoring, +hybrid packaging, or AR/CD qualification, read and follow: + +`Tools\SentinelToXDRMigration\agent-workflows\end-to-end-migration.md` + +Treat it as the canonical process. Use repository CLI and PowerShell tools, +not prompt-only implementations. Default to authoring and ask before optional +qualification. Deployment, mock ingestion, and parity are not required for +ISV publishing. Use packaging V4 for XDR Detections, preserve disabled +lifecycle state, resume `workflow-state.json`, and obtain explicit approval +before environment writes. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..c4780b63703 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository agent instructions + +## Protected agent and tooling implementation + +During normal repository tasks, treat agent definitions, skills, instructions, +tooling backends, schemas, tests, dependency metadata, workflow definitions, +safety gates, permission checks, packaging behavior, and capabilities as +immutable. + +Do not create, edit, delete, rename, patch, bypass, or replace implementation +under these paths to recover from a task, environment, dependency, +authentication, conversion, validation, packaging, deployment, or runtime +failure: + +- `AGENTS.md`; +- `.github/agents/**`; +- `.github/skills/**`; or +- `Tools/**`. + +Stop the affected operation and report that a maintainer change is required. +Implementation changes are allowed only in a separate development task where +the user explicitly states that they are acting as a repository/toolkit +maintainer and requests the specific implementation change. Requests to run, +retry, continue, migrate, package, validate, deploy, or fix generated content +never grant permission to change agent or tooling implementation. + +The XDR Solution Manager is stricter: it can never make agent or tooling +implementation changes, even when explicitly requested by an owner or +maintainer. Route such work to a different development agent or require it to +be performed manually. + +When a request involves Sentinel-to-Defender-XDR migration, XDR Detections, +hybrid solution packaging, or AR/CD qualification: + +1. Read and follow + `Tools\SentinelToXDRMigration\agent-workflows\end-to-end-migration.md`. +2. Treat that file as the canonical workflow; do not redefine its stages or + gates in this adapter. +3. Keep deterministic work in the repository Python and PowerShell tools. +4. Require the user to explicitly select `authoring` or `qualification`. There + is no default. +5. Deployment, mock ingestion, and alert parity are Qualification-only actions + and require explicit exact-scope approval. +6. Use the V3.1 `package-v3-1` route for Sentinel and XDR solution content; + do not route XDR packaging through V4. +7. Resume from `workflow-state.json` and record artifacts and evidence. +8. Require explicit approval immediately before any environment write. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..01efce30e0f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# Claude Code instructions + +For Microsoft Sentinel-to-Defender-XDR migration or authoring work, first read: + +`Tools\SentinelToXDRMigration\agent-workflows\end-to-end-migration.md` + +Follow that canonical workflow and use the repository CLI and PowerShell +entry points directly. Do not replace deterministic conversion, validation, +packaging, deployment, or parity logic with model-generated approximations. + +Default to `authoring`. Ask before optional lab `qualification`; it is not +required for ISV publishing. Use V3.1 through `package-v3-1` for Sentinel and +XDR solution packages. Resume from `workflow-state.json`, preserve disabled +detections, and request explicit approval before writes. diff --git a/Tools/AzureMonitorLogsIngestion/.gitignore b/Tools/AzureMonitorLogsIngestion/.gitignore new file mode 100644 index 00000000000..d9281fa5510 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +ingestion-report.json diff --git a/Tools/AzureMonitorLogsIngestion/README.md b/Tools/AzureMonitorLogsIngestion/README.md new file mode 100644 index 00000000000..e778938fdf0 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/README.md @@ -0,0 +1,251 @@ +# Azure Monitor Logs Ingestion + +This tool ingests JSON mock data into a Log Analytics workspace through the +Azure Monitor Logs Ingestion API. It can create and reuse a Data Collection +Endpoint (DCE) and Data Collection Rule (DCR) for: + +- custom `_CL` tables; and +- Microsoft standard tables that Azure supports as DCR output streams. + +Both modes send records to a declared `Custom-*` input stream. For a standard +table, the DCR transform maps that writable input to a supported +`Microsoft-*` output stream. The tool never posts directly to a reserved +Microsoft stream. + +## Install + +Use Python 3.10 or later and sign in with Azure CLI: + +```powershell +az login +az account set --subscription "" +python -m pip install -e Tools\AzureMonitorLogsIngestion +azure-monitor-logs-ingestion doctor +``` + +`DefaultAzureCredential` is used, so managed identity and other supported +credential sources can also be used. No client secret is stored by this tool. + +Permissions are operation-specific. The caller needs workspace/resource read +access to inspect the locked target; DCR creation permission only when the +selected plan provisions a contract-specific DCR; and table-write permission +only when the required custom destination table is absent. DCE creation is +conditional: Azure Monitor generally needs a DCE for private-link ingestion or +when a DCR has no ingestion endpoint, although this tool may provision an +isolated DCE as part of its supported contract route. Do not treat DCE creation +as a universal Logs Ingestion API requirement. The identity that sends data +also needs **Monitoring Metrics Publisher** on the DCR for telemetry write. +Report an uninspectable permission as unknown, not granted. + +## Contract + +Start with: + +- `examples\custom-table.yaml` for a custom table; or +- `examples\standard-table.yaml` for a supported standard table. + +`ingestion-contract.schema.json` contains the versioned JSON Schema for editor +and CI validation. + +The contract separates the raw mock-data schema from the destination: + +```yaml +version: 1 +name: sample-common-security-log +workspace: my-workspace +inputStream: Custom-CommonSecurityLogMock +inputColumns: + - name: EventTime + type: datetime +transformKql: source | project TimeGenerated=todatetime(EventTime) +destination: + kind: standard + table: CommonSecurityLog + outputStream: Microsoft-CommonSecurityLog +``` + +Raw input field names may use vendor-native dots and hyphens, such as +`event.timestamp` or `cs-username`. Reference those fields with KQL bracket +notation in the transform, for example `tostring(['cs-username'])`. Destination +column names remain restricted to valid Log Analytics identifiers. + +For custom destinations, provide the complete destination schema and use +`Custom-` as `outputStream`. Custom table names must end in `_CL`. + +For standard destinations, the table must already be registered in the +workspace. Azure is the authority on whether the `Microsoft-*` output stream +supports Logs Ingestion. If it does not, DCR provisioning fails with the Azure +error, commonly `InvalidStream`; the tool does not redirect data elsewhere. + +Transforms should explicitly cast and project only columns accepted by the +destination table. + +Reviewed Standard-table capabilities live in `registry.yaml`; each registry +entry points to a versioned contract template under `contracts\`, declares +the supported writable route, and links to public schema and API evidence. +Only registered tables are eligible for automatic qualification plans. +`CommonSecurityLog.yaml` is a structured-field contract for the fields used by +the current Cisco ASA scenarios; it is not a raw CEF parser and does not prove +that a connector, parser, or deployed DCR is configured correctly. The listed +schema derives from the public CommonSecurityLog table reference and public +Log Analytics metadata endpoint. System-managed/reserved table columns are +intentionally not included as writable fixture fields. + +The public table-feature matrix distinguishes DCR transformation support from +Logs Ingestion API support. Registration requires both an explicit reviewed +contract/transform and a documented supported output route. The public +metadata endpoint can inform schema validation, but its existence or the +presence of a table in that catalog is not sufficient evidence that a route +is writable. `liveValidated: false` remains until a live, approved test +validates the exact contract against the selected workspace. + +Resolve and print a deterministic plan without Azure access or writes: + +```powershell +azure-monitor-logs-ingestion resolve ` + --table CommonSecurityLog ` + --required-field DeviceEventClassID:string ` + --required-field SourceIP ` + --report "\ingestion-resolution.json" +``` + +The resolver reports the registered schema/transform fingerprints and a +provision-or-reuse plan. Unknown tables and fields outside the contract are +blocked rather than guessed. To read only the exact candidates in the locked +workspace, pass its ARM ID and `--check-existing`: + +```powershell +azure-monitor-logs-ingestion resolve ` + --table CommonSecurityLog ` + --required-field DeviceEventClassID:string ` + --workspace "" ` + --check-existing ` + --report "\ingestion-resolution.json" +``` + +This mode checks the existing standard table schema and only the contract's +exact DCE/DCR names. It never lists workspaces, scans resource groups, or +creates/updates resources. An existing DCE/DCR pair is reusable only when +ownership tags, input schema, transform, output stream, endpoint, and locked +workspace all match. A mismatched or partial resource pair is blocked for +manual review. A compatible missing-resource result is still only a plan and +does not constitute write approval. Resolution report paths are also +create-only; use a new filename to preserve earlier evidence. + +Generated contracts keep `workspace: qualification-target`; apply the locked +lab workspace without editing the file: + +```powershell +azure-monitor-logs-ingestion run ` + --contract "\ingestion-contract.yaml" ` + --workspace "" ` + --payload "\mock.json" +``` + +For a solution-wide Qualification plan, enumerate every recursive source +Analytic Rule and pair it to its converted Custom Detection before generating +rule-specific fixtures. For a partial package, keep that complete inventory +for accounting but plan only the selected, validated pairs by supplying the +fresh applied selection plan and hash: + +```powershell +python Tools\SolutionMigration\resolve_ingestion_contracts.py ` + --solution "Solutions\" ` + --target-context "Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json" ` + --selection-plan "Solutions\\Logs\sentinel-xdr-migration\\package-selection-plan.json" ` + --expected-selection-plan-sha256 "" ` + --output "Solutions\\Logs\sentinel-xdr-migration\\\ingestion-contract-plan.json" +``` + +The plan groups rules only when their registered table schema/transform +fingerprints match, and records rule/detection file and query hashes plus the +exact locked target. Partial selection still reports the original full +inventory, selected scope, coverage, and excluded rule identities/reasons; it +does not mark unselected rules as qualified. The applied selection must satisfy +the canonical strict over-50% validated migrated-detection threshold. The plan +performs no Azure calls or writes and does not generate scenarios. Before write +approval, rerun with the original plan's +`--expected-inventory-sha256` and `--expected-target-sha256`; a changed source +inventory, target, or applied selection returns `stale-plan`. Select a new +output path for each plan; existing plan files are never overwritten. + +Adding a registered table requires a reviewed writable input schema, +destination schema, deterministic transform, Microsoft output stream, +isolated DCE/DCR names, public schema and route references, tests, and live +validation before setting `liveValidated: true`. + +The Log Analytics query SDK is not a source of DCR transformations. It +executes queries; DCR `transformKql` is customer-visible resource +configuration and must be read from or compared against the exact DCR through +the ARM resource surface. The resolver does not seek hidden service-side +transforms. + +## Ingest mock data + +Validate locally: + +```powershell +azure-monitor-logs-ingestion inspect ` + --contract .\contract.yaml ` + --payload .\mock.json +``` + +Provision and ingest: + +```powershell +azure-monitor-logs-ingestion run ` + --contract .\contract.yaml ` + --workspace "" ` + --payload .\mock.json +``` + +The command writes `ingestion-report.json` beside the payload by default. Use +`--report ` to choose another location. + +The default DCE and DCR names are derived from `name`. Keep those names unique +per ingestion contract. The tool tags new resources with both `managed-by` and +`ingestion-contract`. + +Existing resources are updated only when they are owned by this tool and the +DCR already contains exactly the requested input stream. The tool does not add +a new stream to an existing DCR. Azure can expose later stream additions in ARM +while the Logs Ingestion endpoint continues to reject them against the DCR's +immutable ID. Use a separate contract-specific DCE/DCR pair instead. + +A name collision with an unrelated resource, a different ingestion contract, +or a shared DCR fails safely. Change `resources.dce` and `resources.dcr` in the +contract rather than overwriting or extending the existing resources. + +Optionally grant a service principal or managed identity permission while +provisioning: + +```powershell +azure-monitor-logs-ingestion run ` + --contract .\contract.yaml ` + --payload .\mock.json ` + --principal-id "" ` + --principal-type ServicePrincipal +``` + +`--principal-id` is an Entra object ID, not an application/client ID. A user +can be assigned with `--principal-type User`. + +The tool accepts a JSON array, a single JSON object, JSON Lines, or NDJSON. It +rejects undeclared fields and obvious JSON type mismatches before sending. It +creates batches below the one-megabyte Logs Ingestion API request limit and +retries throttled or transient requests. + +## Separate operations + +```powershell +azure-monitor-logs-ingestion provision --contract .\contract.yaml +azure-monitor-logs-ingestion ingest --contract .\contract.yaml --payload .\mock.json +azure-monitor-logs-ingestion verify --contract .\contract.yaml --lookback-minutes 60 +``` + +Ingestion success means Azure accepted each batch. Processing is asynchronous. +If no fresh rows are visible, `verify` reports `status: pending` and exits with +code 1. Run it again after Azure finishes processing. + +Use synthetic data and a non-production workspace. Do not commit credentials, +tenant identifiers, workspace identifiers, or customer telemetry. diff --git a/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/__init__.py b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/__init__.py new file mode 100644 index 00000000000..82b1572631b --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/__init__.py @@ -0,0 +1,3 @@ +"""Azure Monitor Logs Ingestion utility.""" + +__version__ = "0.1.0" diff --git a/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/azure.py b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/azure.py new file mode 100644 index 00000000000..04f97b4a021 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/azure.py @@ -0,0 +1,617 @@ +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass +from typing import Any, Iterable +from urllib.parse import quote + +import requests +from azure.identity import DefaultAzureCredential + +from .contract import Contract +from .registry import destination_schema_blockers, inspect_managed_resources + +ARM_SCOPE = "https://management.azure.com/.default" +INGESTION_SCOPE = "https://monitor.azure.com/.default" +LOG_ANALYTICS_SCOPE = "https://api.loganalytics.io/.default" +ARM_BASE = "https://management.azure.com" +RESOURCE_GRAPH_API = "2021-03-01" +DCE_API = "2023-03-11" +DCR_API = "2023-03-11" +TABLE_API = "2022-10-01" +INGESTION_API = "2023-01-01" +ROLE_ASSIGNMENT_API = "2022-04-01" +MONITORING_METRICS_PUBLISHER_ROLE_GUID = "3913510d-42f4-4e42-8a64-420c390055eb" +MANAGED_BY = "azure-monitor-logs-ingestion" + + +class AzureRequestError(RuntimeError): + def __init__(self, method: str, url: str, status: int, body: str): + self.method = method + self.url = url + self.status = status + self.body = body + super().__init__(f"{method} {url} failed with HTTP {status}: {body[:2000]}") + + +@dataclass(frozen=True) +class Workspace: + id: str + name: str + subscription_id: str + resource_group: str + location: str + customer_id: str + + +@dataclass(frozen=True) +class ProvisionedResources: + workspace: Workspace + dce_id: str + dce_endpoint: str + dcr_id: str + dcr_immutable_id: str + + def as_dict(self) -> dict[str, str]: + return { + "workspaceId": self.workspace.id, + "workspaceCustomerId": self.workspace.customer_id, + "dceId": self.dce_id, + "dceEndpoint": self.dce_endpoint, + "dcrId": self.dcr_id, + "dcrImmutableId": self.dcr_immutable_id, + } + + +class AzureClient: + def __init__( + self, + credential: Any | None = None, + session: requests.Session | None = None, + retries: int = 4, + ): + self.credential = credential or DefaultAzureCredential( + exclude_interactive_browser_credential=True + ) + self.session = session or requests.Session() + self.retries = retries + self._tokens: dict[str, str] = {} + + def _token(self, scope: str) -> str: + if scope not in self._tokens: + self._tokens[scope] = self.credential.get_token(scope).token + return self._tokens[scope] + + def request( + self, + method: str, + url: str, + *, + scope: str = ARM_SCOPE, + json_body: Any | None = None, + expected: Iterable[int] = (200,), + ) -> requests.Response: + for attempt in range(self.retries + 1): + response = self.session.request( + method, + url, + headers={ + "Authorization": f"Bearer {self._token(scope)}", + "Content-Type": "application/json", + }, + json=json_body, + timeout=60, + ) + if response.status_code in expected: + return response + if response.status_code not in {408, 429, 500, 502, 503, 504}: + raise AzureRequestError(method, url, response.status_code, response.text) + if attempt == self.retries: + raise AzureRequestError(method, url, response.status_code, response.text) + retry_after = response.headers.get("Retry-After") + delay = float(retry_after) if retry_after else min(2**attempt, 8) + time.sleep(delay) + raise AssertionError("unreachable") + + def resource_graph(self, query: str) -> list[dict[str, Any]]: + url = f"{ARM_BASE}/providers/Microsoft.ResourceGraph/resources?api-version={RESOURCE_GRAPH_API}" + response = self.request( + "POST", + url, + json_body={"query": query, "options": {"resultFormat": "objectArray"}}, + ) + return response.json().get("data", []) + + def resolve_workspace(self, identifier: str) -> Workspace: + escaped = identifier.replace("'", "''") + if identifier.lower().startswith("/subscriptions/"): + query = ( + "Resources " + "| where type =~ 'microsoft.operationalinsights/workspaces' " + f"| where id =~ '{escaped}' " + "| project id, name, subscriptionId, resourceGroup, location, " + "customerId=tostring(properties.customerId)" + ) + else: + query = ( + "Resources " + "| where type =~ 'microsoft.operationalinsights/workspaces' " + f"| where name =~ '{escaped}' or tostring(properties.customerId) =~ '{escaped}' " + "| project id, name, subscriptionId, resourceGroup, location, " + "customerId=tostring(properties.customerId)" + ) + rows = self.resource_graph(query) + if not rows: + raise RuntimeError(f"Log Analytics workspace {identifier!r} was not found") + if len(rows) > 1: + ids = ", ".join(row["id"] for row in rows) + raise RuntimeError( + f"workspace {identifier!r} is ambiguous; use its ARM ID. Matches: {ids}" + ) + row = rows[0] + return Workspace( + id=row["id"], + name=row["name"], + subscription_id=row["subscriptionId"], + resource_group=row["resourceGroup"], + location=row["location"], + customer_id=row.get("customerId", ""), + ) + + def _resource_url(self, resource_id: str, api_version: str) -> str: + return f"{ARM_BASE}{resource_id}?api-version={api_version}" + + def _put_resource( + self, resource_id: str, api_version: str, body: dict[str, Any] + ) -> dict[str, Any]: + response = self.request( + "PUT", + self._resource_url(resource_id, api_version), + json_body=body, + expected=(200, 201, 202), + ) + if response.status_code == 202: + operation_url = response.headers.get("Azure-AsyncOperation") or response.headers.get( + "Location" + ) + if operation_url: + self._wait_for_operation(operation_url) + response = self.request( + "GET", + self._resource_url(resource_id, api_version), + expected=(200,), + ) + return response.json() + + def _put_managed_resource( + self, resource_id: str, api_version: str, body: dict[str, Any] + ) -> dict[str, Any]: + existing = self.request( + "GET", + self._resource_url(resource_id, api_version), + expected=(200, 404), + ) + if existing.status_code == 200: + tags = existing.json().get("tags", {}) or {} + if tags.get("managed-by") != MANAGED_BY: + raise RuntimeError( + f"resource {resource_id!r} already exists but is not managed by " + f"this tool; choose a different resource name" + ) + requested_contract = (body.get("tags", {}) or {}).get( + "ingestion-contract" + ) + existing_contract = tags.get("ingestion-contract") + if ( + requested_contract + and existing_contract + and requested_contract != existing_contract + ): + raise RuntimeError( + f"resource {resource_id!r} belongs to ingestion contract " + f"{existing_contract!r}, not {requested_contract!r}; choose a " + "contract-specific resource name" + ) + return self._put_resource(resource_id, api_version, body) + + def _wait_for_operation(self, url: str, attempts: int = 60) -> None: + for _ in range(attempts): + response = self.request("GET", url, expected=(200, 201, 202, 204)) + if response.status_code == 204: + return + body = response.json() if response.content else {} + status = str(body.get("status", "")).lower() + if status in {"succeeded", "completed"}: + return + if status in {"failed", "canceled", "cancelled"}: + raise RuntimeError(f"Azure operation failed: {json.dumps(body)}") + time.sleep(2) + raise RuntimeError("Azure operation did not complete within 120 seconds") + + def _resource_ids( + self, workspace: Workspace, contract: Contract + ) -> tuple[str, str]: + prefix = ( + f"/subscriptions/{workspace.subscription_id}" + f"/resourceGroups/{workspace.resource_group}" + "/providers/Microsoft.Insights" + ) + return ( + f"{prefix}/dataCollectionEndpoints/{contract.resources.dce}", + f"{prefix}/dataCollectionRules/{contract.resources.dcr}", + ) + + def ensure_destination_table( + self, workspace: Workspace, contract: Contract + ) -> dict[str, Any]: + table_id = ( + f"{workspace.id}/tables/{contract.destination.table}" + ) + url = self._resource_url(table_id, TABLE_API) + response = self.request("GET", url, expected=(200, 404)) + if contract.destination.kind == "standard": + if response.status_code == 404: + raise RuntimeError( + f"standard table {contract.destination.table!r} is not registered " + f"in workspace {workspace.name!r}" + ) + table = response.json() + blockers = destination_schema_blockers(contract, table) + if blockers: + raise RuntimeError("; ".join(blockers)) + return table + requested_columns = [ + column.as_dict() for column in contract.destination.columns + ] + if response.status_code == 200: + schema = response.json().get("properties", {}).get("schema", {}) or {} + existing_columns = schema.get("columns", []) or [] + existing_by_name = { + str(column.get("name", "")).lower(): column + for column in existing_columns + } + for column in requested_columns: + existing = existing_by_name.get(column["name"].lower()) + if existing and str(existing.get("type", "")).lower() != column["type"]: + raise RuntimeError( + f"custom table column {column['name']!r} already has type " + f"{existing.get('type')!r}, not {column['type']!r}" + ) + if not existing: + existing_columns.append(column) + requested_columns = existing_columns + return self._put_resource( + table_id, + TABLE_API, + { + "properties": { + "schema": { + "name": contract.destination.table, + "columns": requested_columns, + } + } + }, + ) + + @staticmethod + def _assert_isolated_dcr(existing: dict[str, Any], input_stream: str) -> None: + properties = existing.get("properties", {}) or {} + declared_streams = set( + (properties.get("streamDeclarations", {}) or {}).keys() + ) + flow_streams = { + stream + for flow in properties.get("dataFlows", []) or [] + for stream in flow.get("streams", []) or [] + } + if declared_streams != {input_stream} or flow_streams != {input_stream}: + raise RuntimeError( + "existing managed DCR is shared with another input stream. " + "Standard-table ingestion requires one contract-specific DCR; " + "choose a different resources.dcr name instead of adding a stream " + "to the existing immutable DCR." + ) + + def provision(self, contract: Contract) -> ProvisionedResources: + workspace = self.resolve_workspace(contract.workspace) + location = contract.location or workspace.location + self.ensure_destination_table(workspace, contract) + dce_id, dcr_id = self._resource_ids(workspace, contract) + existing_dce_response = self.request( + "GET", self._resource_url(dce_id, DCE_API), expected=(200, 404) + ) + existing_dcr_response = self.request( + "GET", self._resource_url(dcr_id, DCR_API), expected=(200, 404) + ) + existing_dce = ( + existing_dce_response.json() + if existing_dce_response.status_code == 200 + else None + ) + existing_dcr = ( + existing_dcr_response.json() + if existing_dcr_response.status_code == 200 + else None + ) + if bool(existing_dce) != bool(existing_dcr): + raise RuntimeError( + "only one contract-specific DCE/DCR resource exists; refusing to " + "create or update a partial resource pair" + ) + if existing_dce and existing_dcr: + compatibility = inspect_managed_resources( + contract, + workspace.id, + existing_dce, + existing_dcr, + dce_resource_id=dce_id, + dcr_resource_id=dcr_id, + ) + if compatibility["status"] != "compatible-existing": + details = "; ".join(compatibility["blockers"]) + raise RuntimeError( + "existing contract-specific DCE/DCR is incompatible; refusing " + f"to mutate it: {details}" + ) + endpoint = ( + existing_dce.get("properties", {}) + .get("logsIngestion", {}) + .get("endpoint") + ) + immutable_id = existing_dcr.get("properties", {}).get("immutableId") + if not endpoint or not immutable_id: + raise RuntimeError( + "existing DCE/DCR is missing its ingestion endpoint or immutable ID" + ) + return ProvisionedResources( + workspace=workspace, + dce_id=dce_id, + dce_endpoint=endpoint, + dcr_id=dcr_id, + dcr_immutable_id=immutable_id, + ) + + dce = self._put_managed_resource( + dce_id, + DCE_API, + { + "location": location, + "properties": { + "networkAcls": {"publicNetworkAccess": "Enabled"} + }, + "tags": { + "managed-by": MANAGED_BY, + "ingestion-contract": contract.name, + }, + }, + ) + dcr_body = { + "location": location, + "kind": "Direct", + "properties": { + "dataCollectionEndpointId": dce_id, + "streamDeclarations": { + contract.input_stream: { + "columns": [ + column.as_dict() for column in contract.input_columns + ] + } + }, + "destinations": { + "logAnalytics": [ + { + "name": "destination", + "workspaceResourceId": workspace.id, + } + ] + }, + "dataFlows": [ + { + "streams": [contract.input_stream], + "destinations": ["destination"], + "transformKql": contract.transform_kql, + "outputStream": contract.destination.output_stream, + } + ], + }, + "tags": { + "managed-by": MANAGED_BY, + "ingestion-contract": contract.name, + }, + } + dcr = self._put_resource(dcr_id, DCR_API, dcr_body) + endpoint = dce.get("properties", {}).get("logsIngestion", {}).get("endpoint") + immutable_id = dcr.get("properties", {}).get("immutableId") + if not endpoint or not immutable_id: + raise RuntimeError("Azure did not return the DCE endpoint or DCR immutable ID") + return ProvisionedResources( + workspace=workspace, + dce_id=dce_id, + dce_endpoint=endpoint, + dcr_id=dcr_id, + dcr_immutable_id=immutable_id, + ) + + def get_resources(self, contract: Contract) -> ProvisionedResources: + workspace = self.resolve_workspace(contract.workspace) + dce_id, dcr_id = self._resource_ids(workspace, contract) + table_id = f"{workspace.id}/tables/{contract.destination.table}" + table = self.request( + "GET", + self._resource_url(table_id, TABLE_API), + expected=(200, 404), + ) + if table.status_code == 404: + raise RuntimeError( + f"destination table {contract.destination.table!r} is not registered " + f"in workspace {workspace.name!r}" + ) + schema_blockers = destination_schema_blockers(contract, table.json()) + if schema_blockers: + raise RuntimeError("; ".join(schema_blockers)) + dce = self.request( + "GET", self._resource_url(dce_id, DCE_API), expected=(200,) + ).json() + dcr = self.request( + "GET", self._resource_url(dcr_id, DCR_API), expected=(200,) + ).json() + compatibility = inspect_managed_resources( + contract, + workspace.id, + dce, + dcr, + dce_resource_id=dce_id, + dcr_resource_id=dcr_id, + ) + if compatibility["status"] != "compatible-existing": + details = "; ".join(compatibility["blockers"]) + raise RuntimeError( + "existing contract-specific DCE/DCR is incompatible; refusing " + f"ingestion: {details}" + ) + endpoint = dce.get("properties", {}).get("logsIngestion", {}).get("endpoint") + immutable_id = dcr.get("properties", {}).get("immutableId") + if not endpoint or not immutable_id: + raise RuntimeError("existing DCE/DCR is missing ingestion properties") + return ProvisionedResources( + workspace=workspace, + dce_id=dce_id, + dce_endpoint=endpoint, + dcr_id=dcr_id, + dcr_immutable_id=immutable_id, + ) + + def authorize( + self, + dcr_id: str, + principal_id: str, + principal_type: str = "ServicePrincipal", + ) -> str: + parts = dcr_id.split("/") + try: + subscription_id = parts[parts.index("subscriptions") + 1] + except (ValueError, IndexError) as exc: + raise ValueError(f"invalid DCR resource ID: {dcr_id!r}") from exc + role_definition_id = ( + f"/subscriptions/{subscription_id}" + "/providers/Microsoft.Authorization/roleDefinitions/" + f"{MONITORING_METRICS_PUBLISHER_ROLE_GUID}" + ) + assignment_id = str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"{dcr_id.lower()}:{principal_id.lower()}:monitoring-metrics-publisher", + ) + ) + resource_id = ( + f"{dcr_id}/providers/Microsoft.Authorization/roleAssignments/{assignment_id}" + ) + self._put_resource( + resource_id, + ROLE_ASSIGNMENT_API, + { + "properties": { + "principalId": principal_id, + "roleDefinitionId": role_definition_id, + "principalType": principal_type, + } + }, + ) + return resource_id + + @staticmethod + def batches( + records: list[dict[str, Any]], + max_records: int = 500, + max_bytes: int = 900_000, + ) -> list[list[dict[str, Any]]]: + batches: list[list[dict[str, Any]]] = [] + current: list[dict[str, Any]] = [] + current_size = 2 + for record in records: + encoded_size = len( + json.dumps(record, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + ) + if encoded_size + 2 > max_bytes: + raise ValueError( + f"a single record is {encoded_size} bytes, exceeding the " + f"{max_bytes}-byte batch limit" + ) + separator_size = 1 if current else 0 + if current and ( + len(current) >= max_records + or current_size + separator_size + encoded_size > max_bytes + ): + batches.append(current) + current = [] + current_size = 2 + separator_size = 0 + current.append(record) + current_size += separator_size + encoded_size + if current: + batches.append(current) + return batches + + def ingest( + self, + contract: Contract, + resources: ProvisionedResources, + records: list[dict[str, Any]], + *, + max_records: int = 500, + max_bytes: int = 900_000, + ) -> dict[str, Any]: + batches = self.batches(records, max_records=max_records, max_bytes=max_bytes) + encoded_stream = quote(contract.input_stream, safe="-_") + url = ( + f"{resources.dce_endpoint}/dataCollectionRules/" + f"{resources.dcr_immutable_id}/streams/{encoded_stream}" + f"?api-version={INGESTION_API}" + ) + for batch in batches: + self.request( + "POST", + url, + scope=INGESTION_SCOPE, + json_body=batch, + expected=(200, 202, 204), + ) + return {"recordsAccepted": len(records), "batchesAccepted": len(batches)} + + def verify( + self, resources: ProvisionedResources, table: str, lookback_minutes: int + ) -> dict[str, Any]: + if not resources.workspace.customer_id: + raise RuntimeError("workspace customer ID is unavailable for verification") + query = ( + f"{table}\n" + f"| where ingestion_time() >= ago({lookback_minutes}m)\n" + "| summarize Rows=count(), FirstIngested=min(ingestion_time()), " + "LastIngested=max(ingestion_time())" + ) + url = ( + "https://api.loganalytics.azure.com/v1/workspaces/" + f"{resources.workspace.customer_id}/query" + ) + response = self.request( + "POST", + url, + scope=LOG_ANALYTICS_SCOPE, + json_body={"query": query}, + expected=(200,), + ).json() + tables = response.get("tables", []) + rows = tables[0].get("rows", []) if tables else [] + columns = tables[0].get("columns", []) if tables else [] + result = dict(zip((column["name"] for column in columns), rows[0])) if rows else {} + fresh_rows = int(result.get("Rows") or 0) + return { + "query": query, + "result": result, + "freshRows": fresh_rows, + "found": fresh_rows > 0, + } diff --git a/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/cli.py b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/cli.py new file mode 100644 index 00000000000..a298429faf4 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/cli.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from azure.identity import DefaultAzureCredential + +from .azure import ARM_SCOPE, AzureClient, AzureRequestError +from .contract import load_contract, load_records, validate_records +from .registry import ( + DEFAULT_REGISTRY, + destination_schema_blockers, + inspect_managed_resources, + resolve_contract, +) + + +def _write_report(path: str | None, report: dict[str, Any]) -> None: + if not path: + return + report_path = Path(path) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8") + + +def _report_path(args: argparse.Namespace) -> str | None: + if args.command == "inspect": + return None + if getattr(args, "report", None): + return args.report + source = getattr(args, "payload", None) or args.contract + return str(Path(source).resolve().parent / "ingestion-report.json") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Provision and use Azure Monitor Logs Ingestion API pipelines for " + "custom tables and supported Microsoft standard tables." + ) + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("doctor", help="Check local authentication readiness.") + resolve = subparsers.add_parser( + "resolve", + help="Resolve a registered table capability and print a no-write plan.", + ) + resolve.add_argument("--table", required=True) + resolve.add_argument( + "--required-field", + action="append", + default=[], + metavar="NAME[:TYPE]", + help="Required source field and optional expected type; repeat as needed.", + ) + resolve.add_argument( + "--workspace", + help="Optional exact workspace ARM ID. Resource checks never discover alternatives.", + ) + resolve.add_argument( + "--check-existing", + action="store_true", + help="Read only the exact table, DCE, and DCR candidates in --workspace.", + ) + resolve.add_argument( + "--report", + help="Optional local JSON path to persist this dry-run resolution artifact.", + ) + + for name in ("inspect", "provision", "ingest", "verify", "run"): + command = subparsers.add_parser(name) + command.add_argument("--contract", required=True) + command.add_argument( + "--workspace", + help=( + "Override the workspace stored in the contract. Use the locked " + "qualification workspace ARM ID for generated contracts." + ), + ) + if name in {"inspect", "ingest", "run"}: + command.add_argument("--payload") + if name in {"ingest", "run"}: + command.add_argument("--max-records", type=int, default=500) + command.add_argument("--max-bytes", type=int, default=900_000) + if name in {"provision", "run"}: + command.add_argument( + "--principal-id", + help=( + "Optional Entra object ID to grant Monitoring Metrics Publisher " + "on the DCR." + ), + ) + command.add_argument( + "--principal-type", + choices=["User", "Group", "ServicePrincipal", "ForeignGroup", "Device"], + default="ServicePrincipal", + help="Entra principal type used with --principal-id.", + ) + if name in {"verify", "run"}: + command.add_argument("--lookback-minutes", type=int, default=60) + if name == "run": + command.add_argument("--verify", action="store_true") + if name != "inspect": + command.add_argument("--report") + return parser + + +def doctor() -> dict[str, Any]: + result: dict[str, Any] = { + "python": sys.version.split()[0], + "azureCli": shutil.which("az") is not None, + "armAuthentication": False, + } + try: + DefaultAzureCredential( + exclude_interactive_browser_credential=True + ).get_token(ARM_SCOPE) + result["armAuthentication"] = True + except Exception as exc: + result["authenticationError"] = str(exc) + result["ready"] = result["armAuthentication"] + return result + + +def _parse_required_fields(values: list[str]) -> dict[str, str | None]: + required: dict[str, str | None] = {} + for value in values: + name, separator, column_type = value.partition(":") + name = name.strip() + if not name: + raise ValueError("--required-field must include a field name") + normalized_type = column_type.strip().lower() if separator else None + previous = required.get(name) + if name in required and previous and normalized_type and previous != normalized_type: + raise ValueError(f"required field {name!r} has conflicting declared types") + required[name] = normalized_type + return required + + +def _resolve(args: argparse.Namespace) -> dict[str, Any]: + required = _parse_required_fields(args.required_field) + resolution = resolve_contract(args.table, required) + if resolution["status"] == "blocked": + return resolution + contract = load_contract( + DEFAULT_REGISTRY.parent / resolution["contractPath"] + ) + if args.workspace: + contract = replace(contract, workspace=args.workspace) + resolution["contract"] = contract.as_dict() + + resolution["targetWorkspace"] = args.workspace + resolution["writesPerformed"] = False + if not args.check_existing: + if args.workspace and not args.workspace.lower().startswith("/subscriptions/"): + raise ValueError( + "--workspace for resolve must be an exact workspace ARM resource ID" + ) + return resolution + if not args.workspace or not args.workspace.lower().startswith("/subscriptions/"): + raise ValueError("--check-existing requires an exact --workspace ARM resource ID") + + client = AzureClient() + workspace = client.resolve_workspace(args.workspace) + if workspace.id.casefold() != args.workspace.casefold(): + raise RuntimeError("resolved workspace ARM ID does not match the requested target") + table_id = f"{workspace.id}/tables/{contract.destination.table}" + table_response = client.request( + "GET", + client._resource_url(table_id, "2022-10-01"), + expected=(200, 404), + ) + if table_response.status_code == 404: + resolution["status"] = "blocked" + resolution["blockers"].append( + f"Standard table {contract.destination.table!r} is not registered " + "in the locked workspace." + ) + resolution["plan"] = None + resolution["candidateResources"] = {"tableExists": False} + return resolution + table_blockers = destination_schema_blockers(contract, table_response.json()) + if table_blockers: + resolution["status"] = "blocked" + resolution["blockers"].extend(table_blockers) + resolution["plan"] = None + resolution["candidateResources"] = {"tableExists": True} + return resolution + + dce_id, dcr_id = client._resource_ids(workspace, contract) + dce_response = client.request( + "GET", client._resource_url(dce_id, "2023-03-11"), expected=(200, 404) + ) + dcr_response = client.request( + "GET", client._resource_url(dcr_id, "2023-03-11"), expected=(200, 404) + ) + dce = dce_response.json() if dce_response.status_code == 200 else None + dcr = dcr_response.json() if dcr_response.status_code == 200 else None + candidate = inspect_managed_resources( + contract, + workspace.id, + dce, + dcr, + dce_resource_id=dce_id, + dcr_resource_id=dcr_id, + ) + resolution["candidateResources"] = { + "tableExists": True, + "dceId": dce_id, + "dcrId": dcr_id, + "dceExists": dce is not None, + "dcrExists": dcr is not None, + } + resolution["resourceResolution"] = candidate + resolution["status"] = candidate["status"] + if candidate["status"] == "blocked": + resolution["blockers"].extend(candidate["blockers"]) + resolution["plan"] = None + return resolution + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.command == "doctor": + result = doctor() + print(json.dumps(result, indent=2)) + return 0 if result["ready"] else 1 + if args.command == "resolve": + try: + result = _resolve(args) + if args.report: + if Path(args.report).exists(): + raise ValueError( + f"resolution report already exists; choose a new path: {args.report}" + ) + result["reportPath"] = args.report + _write_report(args.report, result) + print(json.dumps(result, indent=2, default=str)) + return 1 if result["status"] == "blocked" else 0 + except (AzureRequestError, OSError, RuntimeError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 2 + + try: + contract = load_contract(args.contract) + if args.workspace: + contract = replace(contract, workspace=args.workspace) + records = load_records(args.payload) if getattr(args, "payload", None) else None + warnings = validate_records(contract, records) if records else [] + result: dict[str, Any] = { + "command": args.command, + "timestamp": datetime.now(timezone.utc).isoformat(), + "status": "succeeded", + "contract": contract.as_dict(), + "warnings": warnings, + } + if args.command == "inspect": + result["recordCount"] = len(records) if records else None + print(json.dumps(result, indent=2)) + return 0 + + client = AzureClient() + if args.command in {"provision", "run"}: + resources = client.provision(contract) + result["resources"] = resources.as_dict() + if args.principal_id: + result["roleAssignmentId"] = client.authorize( + resources.dcr_id, + args.principal_id, + principal_type=args.principal_type, + ) + else: + resources = client.get_resources(contract) + result["resources"] = resources.as_dict() + + if args.command in {"ingest", "run"}: + if not records: + raise ValueError("--payload is required for ingest and run") + result["ingestion"] = client.ingest( + contract, + resources, + records, + max_records=args.max_records, + max_bytes=args.max_bytes, + ) + if args.command == "verify" or (args.command == "run" and args.verify): + result["verification"] = client.verify( + resources, + contract.destination.table, + args.lookback_minutes, + ) + if not result["verification"]["found"]: + result["status"] = "pending" + report_path = _report_path(args) + if report_path: + result["reportPath"] = report_path + _write_report(report_path, result) + print(json.dumps(result, indent=2, default=str)) + return 1 if result["status"] == "pending" else 0 + except (AzureRequestError, OSError, RuntimeError, ValueError) as exc: + report_path = _report_path(args) + if report_path: + _write_report( + report_path, + { + "command": args.command, + "timestamp": datetime.now(timezone.utc).isoformat(), + "status": "failed", + "error": str(exc), + }, + ) + print(str(exc), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/contract.py b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/contract.py new file mode 100644 index 00000000000..6e8ac080319 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/contract.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +SUPPORTED_COLUMN_TYPES = { + "boolean", + "datetime", + "dynamic", + "int", + "long", + "real", + "string", +} + + +@dataclass(frozen=True) +class Column: + name: str + type: str + + @classmethod + def from_dict( + cls, + value: dict[str, Any], + field: str, + *, + allow_raw_name: bool = False, + ) -> "Column": + name = str(value.get("name", "")).strip() + column_type = str(value.get("type", "")).strip().lower() + pattern = r"[A-Za-z_][A-Za-z0-9_.-]*" if allow_raw_name else r"[A-Za-z_][A-Za-z0-9_]*" + if not re.fullmatch(pattern, name): + raise ValueError(f"{field}.name must be a valid column name") + if column_type not in SUPPORTED_COLUMN_TYPES: + allowed = ", ".join(sorted(SUPPORTED_COLUMN_TYPES)) + raise ValueError(f"{field}.type must be one of: {allowed}") + return cls(name=name, type=column_type) + + def as_dict(self) -> dict[str, str]: + return {"name": self.name, "type": self.type} + + +@dataclass(frozen=True) +class Destination: + kind: str + table: str + output_stream: str + columns: tuple[Column, ...] + + +@dataclass(frozen=True) +class ResourceNames: + dce: str + dcr: str + + +@dataclass(frozen=True) +class Contract: + name: str + workspace: str + input_stream: str + input_columns: tuple[Column, ...] + transform_kql: str + destination: Destination + resources: ResourceNames + location: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "workspace": self.workspace, + "location": self.location, + "inputStream": self.input_stream, + "inputColumns": [column.as_dict() for column in self.input_columns], + "transformKql": self.transform_kql, + "destination": { + "kind": self.destination.kind, + "table": self.destination.table, + "outputStream": self.destination.output_stream, + "columns": [column.as_dict() for column in self.destination.columns], + }, + "resources": { + "dce": self.resources.dce, + "dcr": self.resources.dcr, + }, + } + + +def _read_document(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + value = json.loads(text) if path.suffix.lower() == ".json" else yaml.safe_load(text) + if not isinstance(value, dict): + raise ValueError("contract root must be an object") + return value + + +def _columns( + value: Any, + field: str, + required: bool = True, + *, + allow_raw_names: bool = False, +) -> tuple[Column, ...]: + if value is None and not required: + return () + if not isinstance(value, list) or not value: + raise ValueError(f"{field} must be a non-empty array") + columns = tuple( + Column.from_dict( + item, + f"{field}[{index}]", + allow_raw_name=allow_raw_names, + ) + for index, item in enumerate(value) + ) + names = [column.name for column in columns] + if len(names) != len(set(names)): + raise ValueError(f"{field} contains duplicate column names") + return columns + + +def load_contract(path: str | Path) -> Contract: + contract_path = Path(path) + value = _read_document(contract_path) + if value.get("version") != 1: + raise ValueError("contract version must be 1") + + name = str(value.get("name", "")).strip() + workspace = str(value.get("workspace", "")).strip() + input_stream = str(value.get("inputStream", "")).strip() + transform_kql = str(value.get("transformKql", "")).strip() + destination_value = value.get("destination") + resources_value = value.get("resources", {}) + + if not name: + raise ValueError("name is required") + if not workspace: + raise ValueError("workspace is required") + if not input_stream.startswith("Custom-"): + raise ValueError("inputStream must start with 'Custom-'") + if not transform_kql: + raise ValueError("transformKql is required") + if not isinstance(destination_value, dict): + raise ValueError("destination must be an object") + if not isinstance(resources_value, dict): + raise ValueError("resources must be an object") + + kind = str(destination_value.get("kind", "")).strip().lower() + table = str(destination_value.get("table", "")).strip() + output_stream = str(destination_value.get("outputStream", "")).strip() + if kind not in {"custom", "standard"}: + raise ValueError("destination.kind must be 'custom' or 'standard'") + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", table): + raise ValueError("destination.table must be a valid table name") + if kind == "custom": + if not table.endswith("_CL"): + raise ValueError("custom destination.table must end with '_CL'") + expected_stream = f"Custom-{table}" + if output_stream != expected_stream: + raise ValueError( + f"custom destination.outputStream must be {expected_stream!r}" + ) + elif not output_stream.startswith("Microsoft-"): + raise ValueError("standard destination.outputStream must start with 'Microsoft-'") + + destination_columns = _columns( + destination_value.get("columns"), + "destination.columns", + required=kind == "custom", + ) + if kind == "custom" and not any( + column.name.lower() == "timegenerated" and column.type == "datetime" + for column in destination_columns + ): + raise ValueError( + "custom destination.columns must contain TimeGenerated with type datetime" + ) + + slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-") + if not slug: + raise ValueError("name must contain at least one letter or number") + dce_name = str(resources_value.get("dce", f"{slug}-dce")).strip() + dcr_name = str(resources_value.get("dcr", f"{slug}-dcr")).strip() + if not dce_name or not dcr_name: + raise ValueError("resources.dce and resources.dcr cannot be empty") + + return Contract( + name=name, + workspace=workspace, + location=str(value.get("location", "")).strip() or None, + input_stream=input_stream, + input_columns=_columns( + value.get("inputColumns"), + "inputColumns", + allow_raw_names=True, + ), + transform_kql=transform_kql, + destination=Destination( + kind=kind, + table=table, + output_stream=output_stream, + columns=destination_columns, + ), + resources=ResourceNames(dce=dce_name, dcr=dcr_name), + ) + + +def load_records(path: str | Path) -> list[dict[str, Any]]: + payload_path = Path(path) + text = payload_path.read_text(encoding="utf-8") + if payload_path.suffix.lower() in {".jsonl", ".ndjson"}: + records = [json.loads(line) for line in text.splitlines() if line.strip()] + else: + value = json.loads(text) + records = value if isinstance(value, list) else [value] + if not records: + raise ValueError("payload contains no records") + if not all(isinstance(record, dict) for record in records): + raise ValueError("payload must contain JSON objects") + return records + + +def _matches_type(value: Any, column_type: str) -> bool: + if value is None: + return True + if column_type in {"string", "datetime"}: + return isinstance(value, str) + if column_type == "boolean": + return isinstance(value, bool) + if column_type in {"int", "long"}: + return isinstance(value, int) and not isinstance(value, bool) + if column_type == "real": + return isinstance(value, (int, float)) and not isinstance(value, bool) + return column_type == "dynamic" + + +def validate_records(contract: Contract, records: list[dict[str, Any]]) -> list[str]: + declared = {column.name: column.type for column in contract.input_columns} + warnings: list[str] = [] + for index, record in enumerate(records): + unknown = sorted(set(record) - set(declared)) + if unknown: + raise ValueError( + f"record {index} contains undeclared fields: {', '.join(unknown)}" + ) + for name, value in record.items(): + if name in declared and not _matches_type(value, declared[name]): + raise ValueError( + f"record {index} field {name!r} does not match declared " + f"type {declared[name]!r}" + ) + return warnings diff --git a/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/registry.py b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/registry.py new file mode 100644 index 00000000000..e0482516778 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/azure_monitor_logs_ingestion/registry.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from .contract import Column, Contract, load_contract + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_REGISTRY = PACKAGE_ROOT / "registry.yaml" +SUPPORTED_ROUTES = {"logs-ingestion-api"} +TYPE_ALIASES = {"bool": "boolean", "double": "real"} +ARM_TABLE_COLUMN_TYPES = { + "string", + "int", + "long", + "real", + "boolean", + "datetime", + "guid", + "dynamic", +} + + +@dataclass(frozen=True) +class TableCapability: + table: str + contract_path: Path + route: str + input_kind: str + output_kind: str + output_stream: str + schema_reference: str + metadata_reference: str + capability_reference: str + route_reference: str + live_validated: bool + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def fingerprint(value: Any) -> str: + return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest() + + +def _read_registry(path: str | Path = DEFAULT_REGISTRY) -> list[TableCapability]: + registry_path = Path(path).resolve() + document = yaml.safe_load(registry_path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or document.get("version") != 1: + raise ValueError("ingestion capability registry version must be 1") + entries = document.get("tables") + if not isinstance(entries, list): + raise ValueError("ingestion capability registry tables must be an array") + + capabilities: list[TableCapability] = [] + seen: set[str] = set() + package_root = registry_path.parent + for index, entry in enumerate(entries): + field = f"registry.tables[{index}]" + if not isinstance(entry, dict): + raise ValueError(f"{field} must be an object") + table = str(entry.get("table", "")).strip() + if not table or table.casefold() in seen: + raise ValueError(f"{field}.table must be present and unique") + seen.add(table.casefold()) + route = str(entry.get("route", "")).strip() + if route not in SUPPORTED_ROUTES: + raise ValueError( + f"{field}.route must be one of: {', '.join(sorted(SUPPORTED_ROUTES))}" + ) + if entry.get("inputKind") != "custom-stream": + raise ValueError(f"{field}.inputKind must be 'custom-stream'") + if entry.get("outputKind") != "standard-table": + raise ValueError(f"{field}.outputKind must be 'standard-table'") + contract_relative = Path(str(entry.get("contract", ""))) + contract_path = (package_root / contract_relative).resolve() + if package_root not in contract_path.parents: + raise ValueError(f"{field}.contract must remain under the contract registry") + if not contract_path.is_file(): + raise ValueError(f"{field}.contract does not exist: {contract_relative}") + contract = load_contract(contract_path) + if ( + contract.destination.kind != "standard" + or contract.destination.table.casefold() != table.casefold() + or contract.destination.output_stream != entry.get("outputStream") + ): + raise ValueError(f"{field} does not match its registered contract") + if not all( + entry.get(key) + for key in ( + "schemaReference", + "metadataReference", + "capabilityReference", + "routeReference", + ) + ): + raise ValueError( + f"{field} must link public schema metadata, capability, and route references" + ) + capabilities.append( + TableCapability( + table=table, + contract_path=contract_path, + route=route, + input_kind="custom-stream", + output_kind="standard-table", + output_stream=str(entry["outputStream"]), + schema_reference=str(entry["schemaReference"]), + metadata_reference=str(entry["metadataReference"]), + capability_reference=str(entry["capabilityReference"]), + route_reference=str(entry["routeReference"]), + live_validated=entry.get("liveValidated") is True, + ) + ) + return capabilities + + +def find_capability( + table: str, registry_path: str | Path = DEFAULT_REGISTRY +) -> TableCapability | None: + requested = table.strip().casefold() + if not requested: + return None + matches = [ + item + for item in _read_registry(registry_path) + if item.table.casefold() == requested + ] + if len(matches) > 1: + raise ValueError(f"table {table!r} is ambiguous in the capability registry") + return matches[0] if matches else None + + +def _type_name(value: str) -> str: + normalized = value.strip().lower() + return TYPE_ALIASES.get(normalized, normalized) + + +def _schema_fingerprint(contract: Contract) -> str: + return fingerprint( + { + "table": contract.destination.table, + "inputStream": contract.input_stream, + "inputColumns": [ + {"name": column.name, "type": _type_name(column.type)} + for column in sorted(contract.input_columns, key=lambda item: item.name.casefold()) + ], + "outputStream": contract.destination.output_stream, + "destinationColumns": [ + {"name": column.name, "type": _type_name(column.type)} + for column in sorted( + contract.destination.columns, key=lambda item: item.name.casefold() + ) + ], + } + ) + + +def _transform_fingerprint(contract: Contract) -> str: + normalized = "\n".join( + line.rstrip() for line in contract.transform_kql.strip().splitlines() + ) + return fingerprint({"transformKql": normalized}) + + +def _field_map(columns: tuple[Column, ...]) -> dict[str, Column]: + return {column.name.casefold(): column for column in columns} + + +def resolve_contract( + table: str, + required_fields: Mapping[str, str | None] | None = None, + *, + registry_path: str | Path = DEFAULT_REGISTRY, +) -> dict[str, Any]: + """Resolve an exact, reviewed table capability without contacting Azure.""" + capability = find_capability(table, registry_path) + if capability is None: + return { + "status": "blocked", + "table": table, + "blockers": [f"No reviewed ingestion capability is registered for {table!r}."], + } + + contract = load_contract(capability.contract_path) + declared = _field_map(contract.input_columns) + destination_declared = _field_map(contract.destination.columns) + blockers: list[str] = [] + for field, expected_type in sorted((required_fields or {}).items(), key=lambda item: item[0].casefold()): + column = declared.get(field.casefold()) + if column is None: + blockers.append( + f"Required source field {field!r} is not declared by the registered input contract." + ) + elif expected_type and _type_name(column.type) != _type_name(expected_type): + blockers.append( + f"Required source field {field!r} has registered type {column.type!r}, " + f"not required type {expected_type!r}." + ) + destination_column = destination_declared.get(field.casefold()) + if contract.destination.columns and destination_column is None: + blockers.append( + f"Required query field {field!r} is not projected to the registered destination." + ) + elif ( + destination_column + and expected_type + and _type_name(destination_column.type) != _type_name(expected_type) + ): + blockers.append( + f"Required query field {field!r} has destination type " + f"{destination_column.type!r}, not expected type {expected_type!r}." + ) + + return { + "status": "blocked" if blockers else "provisioning-plan", + "registryVersion": 1, + "table": capability.table, + "route": capability.route, + "inputKind": capability.input_kind, + "outputKind": capability.output_kind, + "outputStream": capability.output_stream, + "contract": contract.as_dict(), + "contractPath": capability.contract_path.relative_to( + Path(registry_path).resolve().parent + ).as_posix(), + "schemaFingerprint": _schema_fingerprint(contract), + "transformFingerprint": _transform_fingerprint(contract), + "liveValidated": capability.live_validated, + "references": { + "schema": capability.schema_reference, + "metadata": capability.metadata_reference, + "capability": capability.capability_reference, + "route": capability.route_reference, + }, + "blockers": blockers, + "plan": ( + { + "operation": "provision-or-reuse-isolated-managed-resources", + "writesPerformed": False, + "requiresSeparateApproval": True, + "resourceNames": { + "dce": contract.resources.dce, + "dcr": contract.resources.dcr, + }, + } + if not blockers + else None + ), + } + + +def inspect_managed_resources( + contract: Contract, + workspace_id: str, + dce: dict[str, Any] | None, + dcr: dict[str, Any] | None, + dce_resource_id: str | None = None, + dcr_resource_id: str | None = None, +) -> dict[str, Any]: + """Compare only exact, caller-selected resource candidates; never writes.""" + blockers: list[str] = [] + if not dce or not dcr: + if dce or dcr: + return { + "status": "blocked", + "writesPerformed": False, + "requiresSeparateApproval": True, + "missing": [ + name + for name, value in (("DCE", dce), ("DCR", dcr)) + if not value + ], + "blockers": [ + "Only one of the contract-specific DCE/DCR candidates exists; " + "the partial resource pair requires manual review." + ], + } + return { + "status": "provisioning-plan", + "writesPerformed": False, + "requiresSeparateApproval": True, + "missing": [ + name + for name, value in (("DCE", dce), ("DCR", dcr)) + if not value + ], + "blockers": [], + } + + dce_tags = dce.get("tags") or {} + dcr_tags = dcr.get("tags") or {} + for resource_name, tags in (("DCE", dce_tags), ("DCR", dcr_tags)): + if tags.get("managed-by") != "azure-monitor-logs-ingestion": + blockers.append(f"Candidate {resource_name} is not owned by this ingestion tool.") + if tags.get("ingestion-contract") != contract.name: + blockers.append( + f"Candidate {resource_name} does not have the expected ingestion-contract owner tag." + ) + if dce_resource_id and dce.get("id") and str(dce["id"]).casefold() != dce_resource_id.casefold(): + blockers.append("Candidate DCE resource ID does not match the exact contract candidate.") + if dcr_resource_id and dcr.get("id") and str(dcr["id"]).casefold() != dcr_resource_id.casefold(): + blockers.append("Candidate DCR resource ID does not match the exact contract candidate.") + if not ( + (dce.get("properties") or {}).get("logsIngestion") or {} + ).get("endpoint"): + blockers.append("Candidate DCE has no Logs Ingestion endpoint.") + + properties = dcr.get("properties") or {} + if dcr.get("kind") != "Direct": + blockers.append("Candidate DCR kind must be 'Direct' for Logs Ingestion API.") + if dce_resource_id and str( + properties.get("dataCollectionEndpointId", "") + ).casefold() != dce_resource_id.casefold(): + blockers.append("Candidate DCR is not connected to the contract-specific DCE.") + declarations = properties.get("streamDeclarations") or {} + if set(declarations) != {contract.input_stream}: + blockers.append("Candidate DCR does not declare exactly the requested input stream.") + else: + actual_columns = declarations[contract.input_stream].get("columns") or [] + expected_columns = [column.as_dict() for column in contract.input_columns] + if _normalized_columns(actual_columns) != _normalized_columns(expected_columns): + blockers.append("Candidate DCR input schema differs from the reviewed contract.") + + flows = properties.get("dataFlows") or [] + if len(flows) != 1 or flows[0].get("streams") != [contract.input_stream]: + blockers.append("Candidate DCR must contain exactly one flow for the requested input stream.") + else: + flow = flows[0] + if len(flow.get("destinations") or []) != 1: + blockers.append("Candidate DCR flow must target exactly one workspace destination.") + if flow.get("outputStream") != contract.destination.output_stream: + blockers.append("Candidate DCR output stream differs from the reviewed contract.") + if flow.get("transformKql", "").strip() != contract.transform_kql.strip(): + blockers.append("Candidate DCR transform differs from the reviewed contract.") + destinations = properties.get("destinations") or {} + log_analytics = destinations.get("logAnalytics") or [] + if len(log_analytics) != 1: + blockers.append("Candidate DCR must contain exactly one Log Analytics destination.") + target_ids = { + str(item.get("workspaceResourceId", "")).casefold() + for item in log_analytics + if item.get("name") in (flow.get("destinations") or []) + } + if target_ids != {workspace_id.casefold()}: + blockers.append("Candidate DCR does not target exactly the locked workspace.") + return { + "status": "blocked" if blockers else "compatible-existing", + "writesPerformed": False, + "requiresSeparateApproval": False, + "blockers": blockers, + "schemaFingerprint": _schema_fingerprint(contract), + "transformFingerprint": _transform_fingerprint(contract), + } + + +def destination_schema_blockers( + contract: Contract, table_document: dict[str, Any] +) -> list[str]: + blockers: list[str] = [] + if not isinstance(table_document, Mapping): + return ["Table GET response must be an object."] + properties = table_document.get("properties") + if not isinstance(properties, Mapping): + return ["Table GET response is missing an object-valued properties field."] + schema = properties.get("schema") + if not isinstance(schema, Mapping): + return ["Table GET response is missing an object-valued properties.schema."] + + actual: dict[str, tuple[str, str]] = {} + saw_column_list = False + for field, label in ( + ("standardColumns", "standard"), + ("columns", "custom"), + ): + if field not in schema: + continue + saw_column_list = True + entries = schema[field] + if not isinstance(entries, list): + blockers.append(f"Table schema {field} must be an array.") + continue + for index, entry in enumerate(entries): + if not isinstance(entry, Mapping): + blockers.append( + f"Table schema {field}[{index}] must be an object." + ) + continue + name = entry.get("name") + column_type = entry.get("type") + if not isinstance(name, str) or not name.strip(): + blockers.append( + f"Table schema {field}[{index}] is missing a non-empty column name." + ) + continue + if not isinstance(column_type, str) or not column_type.strip(): + blockers.append( + f"Table schema {field}[{index}] is missing a non-empty column type." + ) + continue + normalized_type = column_type.strip().lower() + if normalized_type not in ARM_TABLE_COLUMN_TYPES: + blockers.append( + f"Table schema {field}[{index}] has unsupported ARM column type " + f"{column_type!r}." + ) + continue + + key = name.casefold() + previous = actual.get(key) + if previous: + previous_type, previous_source = previous + if previous_type != normalized_type: + blockers.append( + f"Table schema column {name!r} has conflicting types in " + f"{previous_source} and {label} columns " + f"({previous_type!r} vs {normalized_type!r})." + ) + else: + blockers.append( + f"Table schema column {name!r} appears more than once in " + "standardColumns/columns." + ) + continue + actual[key] = (normalized_type, label) + + if not saw_column_list: + blockers.append( + "Table schema is missing both standardColumns and columns arrays." + ) + elif not actual and not blockers: + blockers.append("Table schema contains no standard or custom columns.") + + # The ARM schema is used only to confirm destination column presence and + # type. Writable destinations remain the explicit contract/registry allowlist. + for column in contract.destination.columns: + actual_column = actual.get(column.name.casefold()) + if actual_column is None: + blockers.append( + f"Destination table {contract.destination.table!r} is missing " + f"contract output column {column.name!r}." + ) + elif actual_column[0] != _type_name(column.type): + blockers.append( + f"Destination table column {column.name!r} has type " + f"{actual_column[0]!r}, not contract type {column.type!r}." + ) + return blockers + + +def _normalized_columns(columns: list[dict[str, Any]]) -> list[tuple[str, str]]: + return sorted( + ( + str(column.get("name", "")).casefold(), + _type_name(str(column.get("type", ""))), + ) + for column in columns + ) diff --git a/Tools/AzureMonitorLogsIngestion/contracts/CommonSecurityLog.yaml b/Tools/AzureMonitorLogsIngestion/contracts/CommonSecurityLog.yaml new file mode 100644 index 00000000000..0a8e55a8240 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/contracts/CommonSecurityLog.yaml @@ -0,0 +1,60 @@ +version: 1 +name: xdr-migration-common-security-log +workspace: qualification-target +inputStream: Custom-XDRMigrationCommonSecurityLogMock +inputColumns: + - { name: TimeGenerated, type: datetime } + - { name: Activity, type: string } + - { name: ApplicationProtocol, type: string } + - { name: CommunicationDirection, type: string } + - { name: DestinationIP, type: string } + - { name: DestinationPort, type: int } + - { name: DeviceAction, type: string } + - { name: DeviceEventClassID, type: string } + - { name: DeviceName, type: string } + - { name: DeviceProduct, type: string } + - { name: DeviceVendor, type: string } + - { name: Message, type: string } + - { name: ReceivedBytes, type: long } + - { name: SentBytes, type: long } + - { name: SourceIP, type: string } +transformKql: >- + source + | project TimeGenerated=todatetime(TimeGenerated), + Activity=tostring(Activity), + ApplicationProtocol=tostring(ApplicationProtocol), + CommunicationDirection=tostring(CommunicationDirection), + DestinationIP=tostring(DestinationIP), + DestinationPort=toint(DestinationPort), + DeviceAction=tostring(DeviceAction), + DeviceEventClassID=tostring(DeviceEventClassID), + DeviceName=tostring(DeviceName), + DeviceProduct=tostring(DeviceProduct), + DeviceVendor=tostring(DeviceVendor), + Message=tostring(Message), + ReceivedBytes=tolong(ReceivedBytes), + SentBytes=tolong(SentBytes), + SourceIP=tostring(SourceIP) +destination: + kind: standard + table: CommonSecurityLog + outputStream: Microsoft-CommonSecurityLog + columns: + - { name: TimeGenerated, type: datetime } + - { name: Activity, type: string } + - { name: ApplicationProtocol, type: string } + - { name: CommunicationDirection, type: string } + - { name: DestinationIP, type: string } + - { name: DestinationPort, type: int } + - { name: DeviceAction, type: string } + - { name: DeviceEventClassID, type: string } + - { name: DeviceName, type: string } + - { name: DeviceProduct, type: string } + - { name: DeviceVendor, type: string } + - { name: Message, type: string } + - { name: ReceivedBytes, type: long } + - { name: SentBytes, type: long } + - { name: SourceIP, type: string } +resources: + dce: xdr-migration-common-security-log-mock-dce + dcr: xdr-migration-common-security-log-mock-dcr diff --git a/Tools/AzureMonitorLogsIngestion/contracts/Event.yaml b/Tools/AzureMonitorLogsIngestion/contracts/Event.yaml new file mode 100644 index 00000000000..870d7bcde6e --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/contracts/Event.yaml @@ -0,0 +1,69 @@ +version: 1 +name: xdr-migration-event-mock +workspace: qualification-target +inputStream: Custom-XDRMigrationEventMock +inputColumns: + - { name: AzureDeploymentID, type: string } + - { name: Computer, type: string } + - { name: EventCategory, type: int } + - { name: EventData, type: string } + - { name: EventID, type: int } + - { name: EventLevel, type: int } + - { name: EventLevelName, type: string } + - { name: EventLog, type: string } + - { name: ManagementGroupName, type: string } + - { name: Message, type: string } + - { name: ParameterXml, type: string } + - { name: RenderedDescription, type: string } + - { name: Role, type: string } + - { name: Source, type: string } + - { name: SourceSystem, type: string } + - { name: TimeGenerated, type: datetime } + - { name: Type, type: string } + - { name: UserName, type: string } +transformKql: >- + source + | project AzureDeploymentID=tostring(AzureDeploymentID), + Computer=tostring(Computer), + EventCategory=toint(EventCategory), + EventData=tostring(EventData), + EventID=toint(EventID), + EventLevel=toint(EventLevel), + EventLevelName=tostring(EventLevelName), + EventLog=tostring(EventLog), + ManagementGroupName=tostring(ManagementGroupName), + Message=tostring(Message), + ParameterXml=tostring(ParameterXml), + RenderedDescription=tostring(RenderedDescription), + Role=tostring(Role), + Source=tostring(Source), + SourceSystem=tostring(SourceSystem), + TimeGenerated=todatetime(TimeGenerated), + Type=tostring(Type), + UserName=tostring(UserName) +destination: + kind: standard + table: Event + outputStream: Microsoft-Event + columns: + - { name: AzureDeploymentID, type: string } + - { name: Computer, type: string } + - { name: EventCategory, type: int } + - { name: EventData, type: string } + - { name: EventID, type: int } + - { name: EventLevel, type: int } + - { name: EventLevelName, type: string } + - { name: EventLog, type: string } + - { name: ManagementGroupName, type: string } + - { name: Message, type: string } + - { name: ParameterXml, type: string } + - { name: RenderedDescription, type: string } + - { name: Role, type: string } + - { name: Source, type: string } + - { name: SourceSystem, type: string } + - { name: TimeGenerated, type: datetime } + - { name: Type, type: string } + - { name: UserName, type: string } +resources: + dce: xdr-migration-event-mock-dce + dcr: xdr-migration-event-mock-dcr diff --git a/Tools/AzureMonitorLogsIngestion/contracts/Syslog.yaml b/Tools/AzureMonitorLogsIngestion/contracts/Syslog.yaml new file mode 100644 index 00000000000..0eea89f5ff5 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/contracts/Syslog.yaml @@ -0,0 +1,54 @@ +version: 1 +name: xdr-migration-syslog-mock +workspace: qualification-target +inputStream: Custom-XDRMigrationSyslogMock +inputColumns: + - { name: CollectorHostName, type: string } + - { name: Computer, type: string } + - { name: EventTime, type: datetime } + - { name: Facility, type: string } + - { name: HostIP, type: string } + - { name: HostName, type: string } + - { name: ProcessID, type: int } + - { name: ProcessName, type: string } + - { name: SeverityLevel, type: string } + - { name: SourceSystem, type: string } + - { name: SyslogMessage, type: string } + - { name: TimeGenerated, type: datetime } + - { name: Type, type: string } +transformKql: >- + source + | project CollectorHostName=tostring(CollectorHostName), + Computer=tostring(Computer), + EventTime=todatetime(EventTime), + Facility=tostring(Facility), + HostIP=tostring(HostIP), + HostName=tostring(HostName), + ProcessID=toint(ProcessID), + ProcessName=tostring(ProcessName), + SeverityLevel=tostring(SeverityLevel), + SourceSystem=tostring(SourceSystem), + SyslogMessage=tostring(SyslogMessage), + TimeGenerated=todatetime(TimeGenerated), + Type=tostring(Type) +destination: + kind: standard + table: Syslog + outputStream: Microsoft-Syslog + columns: + - { name: CollectorHostName, type: string } + - { name: Computer, type: string } + - { name: EventTime, type: datetime } + - { name: Facility, type: string } + - { name: HostIP, type: string } + - { name: HostName, type: string } + - { name: ProcessID, type: int } + - { name: ProcessName, type: string } + - { name: SeverityLevel, type: string } + - { name: SourceSystem, type: string } + - { name: SyslogMessage, type: string } + - { name: TimeGenerated, type: datetime } + - { name: Type, type: string } +resources: + dce: xdr-migration-syslog-mock-dce + dcr: xdr-migration-syslog-mock-dcr diff --git a/Tools/AzureMonitorLogsIngestion/examples/custom-table.yaml b/Tools/AzureMonitorLogsIngestion/examples/custom-table.yaml new file mode 100644 index 00000000000..12edb9269f5 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/examples/custom-table.yaml @@ -0,0 +1,30 @@ +version: 1 +name: sample-custom-events +workspace: replace-with-workspace-name-or-arm-id +inputStream: Custom-SampleEventsRaw +inputColumns: + - name: EventTime + type: datetime + - name: Message + type: string + - name: Severity + type: string +transformKql: >- + source + | project TimeGenerated=todatetime(EventTime), + Message=tostring(Message), + Severity=tostring(Severity) +destination: + kind: custom + table: SampleEvents_CL + outputStream: Custom-SampleEvents_CL + columns: + - name: TimeGenerated + type: datetime + - name: Message + type: string + - name: Severity + type: string +resources: + dce: sample-events-dce + dcr: sample-events-dcr diff --git a/Tools/AzureMonitorLogsIngestion/examples/records.json b/Tools/AzureMonitorLogsIngestion/examples/records.json new file mode 100644 index 00000000000..2a82dbb9af7 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/examples/records.json @@ -0,0 +1,7 @@ +[ + { + "EventTime": "2026-09-09T12:00:00Z", + "Message": "Synthetic test event", + "Severity": "Informational" + } +] diff --git a/Tools/AzureMonitorLogsIngestion/examples/standard-table.yaml b/Tools/AzureMonitorLogsIngestion/examples/standard-table.yaml new file mode 100644 index 00000000000..b6931d2fce6 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/examples/standard-table.yaml @@ -0,0 +1,26 @@ +version: 1 +name: sample-common-security-log +workspace: replace-with-workspace-name-or-arm-id +inputStream: Custom-CommonSecurityLogMock +inputColumns: + - name: EventTime + type: datetime + - name: DeviceVendor + type: string + - name: DeviceProduct + type: string + - name: Activity + type: string +transformKql: >- + source + | project TimeGenerated=todatetime(EventTime), + DeviceVendor=tostring(DeviceVendor), + DeviceProduct=tostring(DeviceProduct), + Activity=tostring(Activity) +destination: + kind: standard + table: CommonSecurityLog + outputStream: Microsoft-CommonSecurityLog +resources: + dce: common-security-log-mock-dce + dcr: common-security-log-mock-dcr diff --git a/Tools/AzureMonitorLogsIngestion/ingestion-contract.schema.json b/Tools/AzureMonitorLogsIngestion/ingestion-contract.schema.json new file mode 100644 index 00000000000..1a936884f88 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/ingestion-contract.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/Azure-Sentinel/Tools/AzureMonitorLogsIngestion/ingestion-contract.schema.json", + "title": "Azure Monitor Logs Ingestion Contract", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "name", + "workspace", + "inputStream", + "inputColumns", + "transformKql", + "destination" + ], + "properties": { + "version": { + "const": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "workspace": { + "type": "string", + "minLength": 1 + }, + "location": { + "type": "string", + "minLength": 1 + }, + "inputStream": { + "type": "string", + "pattern": "^Custom-" + }, + "inputColumns": { + "$ref": "#/$defs/inputColumns" + }, + "transformKql": { + "type": "string", + "minLength": 1 + }, + "destination": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "table", "outputStream", "columns"], + "properties": { + "kind": { + "const": "custom" + }, + "table": { + "type": "string", + "pattern": "_CL$" + }, + "outputStream": { + "type": "string", + "pattern": "^Custom-" + }, + "columns": { + "$ref": "#/$defs/columns" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "table", "outputStream"], + "properties": { + "kind": { + "const": "standard" + }, + "table": { + "type": "string", + "minLength": 1 + }, + "outputStream": { + "type": "string", + "pattern": "^Microsoft-" + }, + "columns": { + "$ref": "#/$defs/columns" + } + } + } + ] + }, + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "dce": { + "type": "string", + "minLength": 1 + }, + "dcr": { + "type": "string", + "minLength": 1 + } + } + } + }, + "$defs": { + "inputColumns": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$" + }, + "type": { + "enum": [ + "boolean", + "datetime", + "dynamic", + "int", + "long", + "real", + "string" + ] + } + } + } + }, + "columns": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "type": { + "enum": [ + "boolean", + "datetime", + "dynamic", + "int", + "long", + "real", + "string" + ] + } + } + } + } + } +} diff --git a/Tools/AzureMonitorLogsIngestion/pyproject.toml b/Tools/AzureMonitorLogsIngestion/pyproject.toml new file mode 100644 index 00000000000..1a5789609dc --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "azure-monitor-logs-ingestion" +version = "0.1.0" +description = "Ingest JSON mock data into Azure Monitor custom and supported standard tables." +requires-python = ">=3.10" +dependencies = [ + "PyYAML>=6.0,<7", + "azure-identity>=1.17,<2", + "requests>=2.31,<3" +] + +[project.scripts] +azure-monitor-logs-ingestion = "azure_monitor_logs_ingestion.cli:main" + +[tool.setuptools.packages.find] +include = ["azure_monitor_logs_ingestion*"] diff --git a/Tools/AzureMonitorLogsIngestion/registry.yaml b/Tools/AzureMonitorLogsIngestion/registry.yaml new file mode 100644 index 00000000000..6a6b323aa53 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/registry.yaml @@ -0,0 +1,35 @@ +version: 1 +tables: + - table: CommonSecurityLog + contract: contracts/CommonSecurityLog.yaml + route: logs-ingestion-api + inputKind: custom-stream + outputKind: standard-table + outputStream: Microsoft-CommonSecurityLog + schemaReference: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/commonsecuritylog + metadataReference: https://api.loganalytics.io/v1/metadata + capabilityReference: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables-features + routeReference: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview + liveValidated: false + - table: Event + contract: contracts/Event.yaml + route: logs-ingestion-api + inputKind: custom-stream + outputKind: standard-table + outputStream: Microsoft-Event + schemaReference: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/event + metadataReference: https://api.loganalytics.io/v1/metadata + capabilityReference: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables-features + routeReference: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview + liveValidated: false + - table: Syslog + contract: contracts/Syslog.yaml + route: logs-ingestion-api + inputKind: custom-stream + outputKind: standard-table + outputStream: Microsoft-Syslog + schemaReference: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/syslog + metadataReference: https://api.loganalytics.io/v1/metadata + capabilityReference: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables-features + routeReference: https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview + liveValidated: false diff --git a/Tools/AzureMonitorLogsIngestion/requirements.txt b/Tools/AzureMonitorLogsIngestion/requirements.txt new file mode 100644 index 00000000000..43a38430dbd --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/requirements.txt @@ -0,0 +1,3 @@ +azure-identity>=1.17,<2 +PyYAML>=6.0,<7 +requests>=2.31,<3 diff --git a/Tools/AzureMonitorLogsIngestion/tests/test_azure.py b/Tools/AzureMonitorLogsIngestion/tests/test_azure.py new file mode 100644 index 00000000000..04b3e86a37a --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/tests/test_azure.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from azure_monitor_logs_ingestion.azure import ( + ARM_SCOPE, + AzureClient, + ProvisionedResources, + Workspace, +) +from azure_monitor_logs_ingestion.contract import load_contract + + +@dataclass +class Token: + token: str = "token" + + +class Credential: + def get_token(self, scope): + return Token() + + +class Response: + def __init__(self, status_code, value=None, headers=None, text=""): + self.status_code = status_code + self._value = value or {} + self.headers = headers or {} + self.text = text + self.content = b"{}" if value is not None else b"" + + def json(self): + return self._value + + +class Session: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def request(self, method, url, **kwargs): + self.calls.append((method, url, kwargs)) + return self.responses.pop(0) + + +def contract(tmp_path, kind="custom"): + value = { + "version": 1, + "name": "sample", + "workspace": "workspace", + "inputStream": "Custom-SampleRaw", + "inputColumns": [{"name": "EventTime", "type": "datetime"}], + "transformKql": "source | project TimeGenerated=todatetime(EventTime)", + "destination": { + "kind": kind, + "table": "Sample_CL" if kind == "custom" else "CommonSecurityLog", + "outputStream": ( + "Custom-Sample_CL" + if kind == "custom" + else "Microsoft-CommonSecurityLog" + ), + }, + "resources": {"dce": "sample-dce", "dcr": "sample-dcr"}, + } + if kind == "custom": + value["destination"]["columns"] = [ + {"name": "TimeGenerated", "type": "datetime"} + ] + path = tmp_path / "contract.yaml" + import yaml + + path.write_text(yaml.safe_dump(value), encoding="utf-8") + return load_contract(path) + + +def workspace(): + return Workspace( + id="/subscriptions/sub/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws", + name="ws", + subscription_id="sub", + resource_group="rg", + location="eastus", + customer_id="customer", + ) + + +def test_ingest_posts_to_custom_input_stream(tmp_path): + session = Session([Response(204)]) + client = AzureClient(credential=Credential(), session=session, retries=0) + resources = ProvisionedResources( + workspace=workspace(), + dce_id="/dce", + dce_endpoint="https://example.ingest.monitor.azure.com", + dcr_id="/dcr", + dcr_immutable_id="dcr-immutable", + ) + + result = client.ingest( + contract(tmp_path), + resources, + [{"EventTime": "2026-09-09T12:00:00Z"}], + ) + + assert result == {"recordsAccepted": 1, "batchesAccepted": 1} + method, url, kwargs = session.calls[0] + assert method == "POST" + assert "/streams/Custom-SampleRaw?" in url + assert kwargs["json"][0]["EventTime"].endswith("Z") + + +def test_authorize_uses_subscription_scoped_role_definition(): + client = AzureClient(credential=Credential()) + captured = {} + + def put(resource_id, api_version, body): + captured.update(body) + return {} + + client._put_resource = put + client.authorize( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Insights/" + "dataCollectionRules/dcr", + "principal", + principal_type="User", + ) + + role_id = captured["properties"]["roleDefinitionId"] + assert role_id.startswith("/subscriptions/sub/") + assert role_id.endswith("3913510d-42f4-4e42-8a64-420c390055eb") + assert captured["properties"]["principalType"] == "User" + + +def test_refuses_to_overwrite_unmanaged_resource(): + session = Session( + [Response(200, {"tags": {"managed-by": "someone-else"}})] + ) + client = AzureClient(credential=Credential(), session=session, retries=0) + + with pytest.raises(RuntimeError, match="not managed"): + client._put_managed_resource("/resource", "1", {"location": "eastus"}) + + +def test_request_uses_arm_scope_by_default(): + credential = Credential() + client = AzureClient( + credential=credential, + session=Session([Response(200, {"value": "ok"})]), + retries=0, + ) + + response = client.request("GET", "https://management.azure.com/resource") + + assert response.json()["value"] == "ok" + assert client._tokens[ARM_SCOPE] == "token" + + +def test_provision_builds_custom_input_to_standard_output(tmp_path): + class ProvisionClient(AzureClient): + def __init__(self): + super().__init__(credential=Credential()) + self.resources = [] + + def resolve_workspace(self, identifier): + return workspace() + + def ensure_destination_table(self, workspace_value, contract_value): + return {"name": contract_value.destination.table} + + def _put_managed_resource(self, resource_id, api_version, body): + self.resources.append((resource_id, body)) + return { + "properties": { + "logsIngestion": { + "endpoint": "https://example.ingest.monitor.azure.com" + } + } + } + + def request(self, method, url, **kwargs): + return Response(404) + + def _put_resource(self, resource_id, api_version, body): + self.resources.append((resource_id, body)) + return {"properties": {"immutableId": "dcr-immutable"}} + + client = ProvisionClient() + + client.provision(contract(tmp_path, kind="standard")) + + dcr_body = client.resources[1][1] + properties = dcr_body["properties"] + assert dcr_body["kind"] == "Direct" + assert "Custom-SampleRaw" in properties["streamDeclarations"] + assert properties["dataFlows"][0]["outputStream"] == "Microsoft-CommonSecurityLog" + + +@pytest.mark.parametrize("compatible", [True, False]) +def test_provision_reuses_exact_existing_pair_without_updates(tmp_path, compatible): + contract_value = contract(tmp_path) + dce_id = ( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Insights/" + "dataCollectionEndpoints/sample-dce" + ) + dcr_id = ( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Insights/" + "dataCollectionRules/sample-dcr" + ) + dce = { + "id": dce_id, + "tags": { + "managed-by": "azure-monitor-logs-ingestion", + "ingestion-contract": contract_value.name, + }, + "properties": { + "logsIngestion": {"endpoint": "https://example.ingest.monitor.azure.com"} + }, + } + dcr = { + "id": dcr_id, + "kind": "Direct", + "tags": { + "managed-by": "azure-monitor-logs-ingestion", + "ingestion-contract": contract_value.name, + }, + "properties": { + "immutableId": "immutable", + "dataCollectionEndpointId": dce_id, + "streamDeclarations": { + contract_value.input_stream: { + "columns": [ + column.as_dict() for column in contract_value.input_columns + ] + } + }, + "destinations": { + "logAnalytics": [ + {"name": "destination", "workspaceResourceId": workspace().id} + ] + }, + "dataFlows": [ + { + "streams": [contract_value.input_stream], + "destinations": ["destination"], + "transformKql": ( + contract_value.transform_kql + if compatible + else "source" + ), + "outputStream": contract_value.destination.output_stream, + } + ], + }, + } + + class ExistingClient(AzureClient): + def __init__(self): + super().__init__(credential=Credential()) + self.put_calls = [] + + def resolve_workspace(self, identifier): + return workspace() + + def ensure_destination_table(self, workspace_value, contract_value): + return {} + + def _resource_ids(self, workspace_value, contract_arg): + return dce_id, dcr_id + + def request(self, method, url, **kwargs): + assert method == "GET" + return Response(200, dce if "dataCollectionEndpoints" in url else dcr) + + def _put_resource(self, resource_id, api_version, body): + self.put_calls.append((resource_id, body)) + return {} + + client = ExistingClient() + + if compatible: + result = client.provision(contract_value) + assert result.dcr_immutable_id == "immutable" + else: + with pytest.raises(RuntimeError, match="refusing to mutate"): + client.provision(contract_value) + assert client.put_calls == [] + + +def test_isolated_dcr_rejects_another_declared_stream(): + existing = { + "properties": { + "streamDeclarations": { + "Custom-Requested": {"columns": []}, + "Custom-Other": {"columns": []}, + }, + "dataFlows": [ + {"streams": ["Custom-Requested"]}, + {"streams": ["Custom-Other"]}, + ], + } + } + + with pytest.raises(RuntimeError, match="contract-specific DCR"): + AzureClient._assert_isolated_dcr(existing, "Custom-Requested") + + +def test_isolated_dcr_accepts_only_requested_stream(): + existing = { + "properties": { + "streamDeclarations": { + "Custom-Requested": {"columns": []}, + }, + "dataFlows": [ + {"streams": ["Custom-Requested"]}, + ], + } + } + + AzureClient._assert_isolated_dcr(existing, "Custom-Requested") + + +@pytest.mark.parametrize( + ("row_count", "found"), + [(0, False), (3, True)], +) +def test_verify_reports_whether_fresh_rows_exist(row_count, found): + session = Session( + [ + Response( + 200, + { + "tables": [ + { + "columns": [ + {"name": "Rows"}, + {"name": "FirstIngested"}, + {"name": "LastIngested"}, + ], + "rows": [[row_count, None, None]], + } + ] + }, + ) + ] + ) + client = AzureClient(credential=Credential(), session=session, retries=0) + resources = ProvisionedResources( + workspace=workspace(), + dce_id="/dce", + dce_endpoint="https://example.ingest.monitor.azure.com", + dcr_id="/dcr", + dcr_immutable_id="dcr-immutable", + ) + + result = client.verify(resources, "CommonSecurityLog", 30) + + assert result["freshRows"] == row_count + assert result["found"] is found diff --git a/Tools/AzureMonitorLogsIngestion/tests/test_contract.py b/Tools/AzureMonitorLogsIngestion/tests/test_contract.py new file mode 100644 index 00000000000..c28af062f45 --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/tests/test_contract.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from azure_monitor_logs_ingestion.azure import AzureClient +from azure_monitor_logs_ingestion.contract import ( + load_contract, + load_records, + validate_records, +) + + +def write_contract(tmp_path, value): + path = tmp_path / "contract.yaml" + import yaml + + path.write_text(yaml.safe_dump(value, sort_keys=False), encoding="utf-8") + return path + + +def base_contract(): + return { + "version": 1, + "name": "sample", + "workspace": "workspace", + "inputStream": "Custom-SampleRaw", + "inputColumns": [{"name": "EventTime", "type": "datetime"}], + "transformKql": "source | project TimeGenerated=todatetime(EventTime)", + "destination": { + "kind": "custom", + "table": "Sample_CL", + "outputStream": "Custom-Sample_CL", + "columns": [{"name": "TimeGenerated", "type": "datetime"}], + }, + } + + +def test_loads_custom_contract_with_default_resource_names(tmp_path): + contract = load_contract(write_contract(tmp_path, base_contract())) + + assert contract.resources.dce == "sample-dce" + assert contract.resources.dcr == "sample-dcr" + assert contract.destination.table == "Sample_CL" + + +def test_requires_custom_input_stream(tmp_path): + value = base_contract() + value["inputStream"] = "Microsoft-Sample" + + with pytest.raises(ValueError, match="Custom-"): + load_contract(write_contract(tmp_path, value)) + + +def test_requires_matching_custom_output_stream(tmp_path): + value = base_contract() + value["destination"]["outputStream"] = "Custom-Wrong_CL" + + with pytest.raises(ValueError, match="Custom-Sample_CL"): + load_contract(write_contract(tmp_path, value)) + + +def test_accepts_standard_destination_without_destination_columns(tmp_path): + value = base_contract() + value["destination"] = { + "kind": "standard", + "table": "CommonSecurityLog", + "outputStream": "Microsoft-CommonSecurityLog", + } + + contract = load_contract(write_contract(tmp_path, value)) + + assert contract.destination.kind == "standard" + assert contract.destination.columns == () + + +def test_registered_standard_contracts_are_valid(): + root = Path(__file__).resolve().parents[1] / "contracts" + + for name in ("Event.yaml", "Syslog.yaml"): + contract = load_contract(root / name) + assert contract.destination.kind == "standard" + assert contract.input_stream.startswith("Custom-") + assert contract.destination.output_stream.startswith("Microsoft-") + assert contract.resources.dce != contract.resources.dcr + + +def test_accepts_vendor_raw_input_column_names(tmp_path): + value = base_contract() + value["inputColumns"] = [ + {"name": "cs-username", "type": "string"}, + {"name": "event.timestamp", "type": "datetime"}, + ] + + contract = load_contract(write_contract(tmp_path, value)) + + assert [column.name for column in contract.input_columns] == [ + "cs-username", + "event.timestamp", + ] + + +def test_rejects_vendor_raw_names_for_destination_columns(tmp_path): + value = base_contract() + value["destination"]["columns"] = [ + {"name": "TimeGenerated", "type": "datetime"}, + {"name": "invalid-name", "type": "string"}, + ] + + with pytest.raises(ValueError, match="valid column name"): + load_contract(write_contract(tmp_path, value)) + + +def test_accepts_case_distinct_destination_columns(tmp_path): + value = base_contract() + value["destination"]["columns"] = [ + {"name": "TimeGenerated", "type": "datetime"}, + {"name": "FilePath", "type": "string"}, + {"name": "Filepath", "type": "string"}, + ] + + contract = load_contract(write_contract(tmp_path, value)) + + assert [column.name for column in contract.destination.columns[-2:]] == [ + "FilePath", + "Filepath", + ] + + +def test_loads_array_single_object_and_ndjson(tmp_path): + array_path = tmp_path / "array.json" + object_path = tmp_path / "object.json" + ndjson_path = tmp_path / "records.ndjson" + array_path.write_text('[{"a": 1}, {"a": 2}]', encoding="utf-8") + object_path.write_text('{"a": 1}', encoding="utf-8") + ndjson_path.write_text('{"a": 1}\n{"a": 2}\n', encoding="utf-8") + + assert len(load_records(array_path)) == 2 + assert len(load_records(object_path)) == 1 + assert len(load_records(ndjson_path)) == 2 + + +def test_rejects_undeclared_fields(tmp_path): + contract = load_contract(write_contract(tmp_path, base_contract())) + + with pytest.raises(ValueError, match="undeclared fields: Extra"): + validate_records(contract, [{"EventTime": "now", "Extra": 1}]) + + +def test_rejects_values_that_do_not_match_input_type(tmp_path): + contract = load_contract(write_contract(tmp_path, base_contract())) + + with pytest.raises(ValueError, match="does not match"): + validate_records(contract, [{"EventTime": 123}]) + + +def test_batches_respect_count_limit(): + records = [{"value": index} for index in range(5)] + + batches = AzureClient.batches(records, max_records=2) + + assert [len(batch) for batch in batches] == [2, 2, 1] + + +def test_batches_reject_oversized_record(): + with pytest.raises(ValueError, match="single record"): + AzureClient.batches([{"value": "x" * 100}], max_bytes=50) + + +def test_batches_respect_encoded_byte_limit(): + records = [{"value": "a" * 20}, {"value": "b" * 20}] + single_size = len(json.dumps(records[0], separators=(",", ":")).encode("utf-8")) + + batches = AzureClient.batches(records, max_bytes=single_size + 3) + + assert [len(batch) for batch in batches] == [1, 1] diff --git a/Tools/AzureMonitorLogsIngestion/tests/test_registry.py b/Tools/AzureMonitorLogsIngestion/tests/test_registry.py new file mode 100644 index 00000000000..881860eb5ce --- /dev/null +++ b/Tools/AzureMonitorLogsIngestion/tests/test_registry.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import json +from pathlib import Path +import shutil + +import pytest +import yaml + +from azure_monitor_logs_ingestion.contract import load_contract +from azure_monitor_logs_ingestion import cli +from azure_monitor_logs_ingestion.cli import main +from azure_monitor_logs_ingestion.azure import AzureClient, Workspace +from azure_monitor_logs_ingestion.registry import ( + destination_schema_blockers, + inspect_managed_resources, + resolve_contract, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +def common_security_log_contract(): + return load_contract(ROOT / "contracts" / "CommonSecurityLog.yaml") + + +def candidate_resources(contract, workspace_id="/workspace"): + dce_id = "/dce" + dce = { + "tags": { + "managed-by": "azure-monitor-logs-ingestion", + "ingestion-contract": contract.name, + }, + "properties": {"logsIngestion": {"endpoint": "https://example.ingest.azure.com"}}, + } + dcr = { + "kind": "Direct", + "tags": { + "managed-by": "azure-monitor-logs-ingestion", + "ingestion-contract": contract.name, + }, + "properties": { + "dataCollectionEndpointId": dce_id, + "streamDeclarations": { + contract.input_stream: { + "columns": [column.as_dict() for column in contract.input_columns] + } + }, + "destinations": { + "logAnalytics": [ + {"name": "destination", "workspaceResourceId": workspace_id} + ] + }, + "dataFlows": [ + { + "streams": [contract.input_stream], + "destinations": ["destination"], + "transformKql": contract.transform_kql, + "outputStream": contract.destination.output_stream, + } + ], + }, + } + return dce_id, dce, dcr + + +def arm_table_document(*, standard=None, custom=None): + schema = {} + if standard is not None: + schema["standardColumns"] = standard + if custom is not None: + schema["columns"] = custom + return {"properties": {"schema": schema}} + + +def arm_columns(columns): + return [ + { + "name": column.name, + "type": "dateTime" if column.type == "datetime" else column.type, + "isHidden": False, + } + for column in columns + ] + + +def test_common_security_log_resolves_supported_fields_and_stable_identity(): + forward = resolve_contract( + "CommonSecurityLog", + { + "DeviceEventClassID": "string", + "SourceIP": "string", + "SentBytes": "long", + }, + ) + reverse = resolve_contract( + "commonsecuritylog", + { + "SentBytes": "long", + "SourceIP": "string", + "DeviceEventClassID": "string", + }, + ) + + assert forward["status"] == "provisioning-plan" + assert forward["schemaFingerprint"] == reverse["schemaFingerprint"] + assert forward["transformFingerprint"] == reverse["transformFingerprint"] + assert forward["liveValidated"] is False + assert forward["plan"]["writesPerformed"] is False + assert forward["plan"]["requiresSeparateApproval"] is True + + +def test_transform_change_invalidates_only_transform_identity(tmp_path): + contract_path = tmp_path / "contracts" / "CommonSecurityLog.yaml" + contract_path.parent.mkdir() + shutil.copyfile(ROOT / "contracts" / "CommonSecurityLog.yaml", contract_path) + registry_value = yaml.safe_load((ROOT / "registry.yaml").read_text(encoding="utf-8")) + registry_value["tables"] = [ + item + for item in registry_value["tables"] + if item["table"] == "CommonSecurityLog" + ] + (tmp_path / "registry.yaml").write_text( + yaml.safe_dump(registry_value, sort_keys=False), + encoding="utf-8", + ) + before = resolve_contract("CommonSecurityLog", registry_path=tmp_path / "registry.yaml") + + contract_value = yaml.safe_load(contract_path.read_text(encoding="utf-8")) + contract_value["transformKql"] += "\n| where isnotempty(DeviceName)" + contract_path.write_text( + yaml.safe_dump(contract_value, sort_keys=False), + encoding="utf-8", + ) + after = resolve_contract("CommonSecurityLog", registry_path=tmp_path / "registry.yaml") + + assert before["schemaFingerprint"] == after["schemaFingerprint"] + assert before["transformFingerprint"] != after["transformFingerprint"] + + +def test_unknown_table_and_unregistered_field_are_blocked(): + assert resolve_contract("UnregisteredTable")["status"] == "blocked" + + result = resolve_contract("CommonSecurityLog", {"MadeUpField": "string"}) + + assert result["status"] == "blocked" + assert "MadeUpField" in result["blockers"][0] + assert result["plan"] is None + assert resolve_contract("CommonSecurityLog", {"TenantId": "guid"})["status"] == "blocked" + + +def test_registered_field_type_mismatch_is_blocked(): + result = resolve_contract("CommonSecurityLog", {"DestinationPort": "long"}) + + assert result["status"] == "blocked" + assert "not required type 'long'" in result["blockers"][0] + + +def test_existing_resources_are_reusable_only_when_exactly_compatible(): + contract = common_security_log_contract() + dce_id, dce, dcr = candidate_resources(contract) + + result = inspect_managed_resources( + contract, "/workspace", dce, dcr, dce_resource_id=dce_id + ) + + assert result["status"] == "compatible-existing" + assert result["writesPerformed"] is False + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda dce, dcr: dcr["tags"].update({"managed-by": "unrelated"}), + "not owned", + ), + ( + lambda dce, dcr: dcr["properties"]["dataFlows"][0].update( + {"transformKql": "source"} + ), + "transform differs", + ), + ( + lambda dce, dcr: dcr["properties"]["destinations"]["logAnalytics"][0].update( + {"workspaceResourceId": "/other-workspace"} + ), + "locked workspace", + ), + ( + lambda dce, dcr: dcr["properties"]["streamDeclarations"][ + "Custom-XDRMigrationCommonSecurityLogMock" + ]["columns"].append({"name": "Extra", "type": "string"}), + "input schema differs", + ), + ( + lambda dce, dcr: dcr["properties"].update( + {"dataCollectionEndpointId": "/other-dce"} + ), + "contract-specific DCE", + ), + ], +) +def test_incompatible_existing_candidate_is_blocked_without_writes(mutate, message): + contract = common_security_log_contract() + dce_id, dce, dcr = candidate_resources(contract) + mutate(dce, dcr) + + result = inspect_managed_resources( + contract, "/workspace", dce, dcr, dce_resource_id=dce_id + ) + + assert result["status"] == "blocked" + assert result["writesPerformed"] is False + assert any(message in blocker for blocker in result["blockers"]) + + +def test_ingest_resource_lookup_rejects_incompatible_dcr_before_any_write(): + contract = common_security_log_contract() + dce_id, dce, dcr = candidate_resources(contract) + dcr["properties"]["dataFlows"][0]["transformKql"] = "source" + calls = [] + + class Response: + status_code = 200 + + def __init__(self, value): + self.value = value + + def json(self): + return self.value + + class ReadOnlyClient(AzureClient): + def __init__(self): + self.table = { + "properties": { + "schema": { + "standardColumns": arm_columns(contract.destination.columns) + } + } + } + + def resolve_workspace(self, identifier): + return Workspace( + id="/workspace", + name="ws", + subscription_id="sub", + resource_group="rg", + location="eastus", + customer_id="customer", + ) + + def _resource_ids(self, workspace, contract_value): + return dce_id, "/dcr" + + def _resource_url(self, resource_id, api_version): + return resource_id + + def request(self, method, url, **kwargs): + calls.append(method) + if "/tables/" in url: + return Response(self.table) + if url == dce_id: + return Response(dce) + return Response(dcr) + + client = ReadOnlyClient() + + with pytest.raises(RuntimeError, match="refusing ingestion"): + client.get_resources(contract) + assert calls == ["GET", "GET", "GET"] + + +def test_unknown_or_ambiguous_partial_resource_state_is_not_reused(): + contract = common_security_log_contract() + + result = inspect_managed_resources(contract, "/workspace", {"tags": {}}, None) + + assert result["status"] == "blocked" + assert "partial resource pair" in result["blockers"][0] + + +def test_destination_schema_requires_all_registered_columns_and_types(): + contract = common_security_log_contract() + table = arm_table_document( + custom=arm_columns(contract.destination.columns), + ) + + assert destination_schema_blockers(contract, table) == [] + table["properties"]["schema"]["columns"][0]["type"] = "string" + + assert any( + "not contract type" in blocker + for blocker in destination_schema_blockers(contract, table) + ) + + +def test_arm_standard_only_schema_satisfies_registered_destination(): + contract = common_security_log_contract() + table = arm_table_document( + standard=arm_columns(contract.destination.columns), + ) + + assert destination_schema_blockers(contract, table) == [] + + +def test_arm_standard_and_custom_columns_are_merged(): + contract = common_security_log_contract() + columns = arm_columns(contract.destination.columns) + table = arm_table_document( + standard=columns[::2], + custom=columns[1::2], + ) + + assert destination_schema_blockers(contract, table) == [] + + +@pytest.mark.parametrize( + ("standard", "custom", "expected"), + [ + ( + [{"name": "TimeGenerated", "type": "dateTime"}], + [{"name": "timegenerated", "type": "dateTime"}], + "appears more than once", + ), + ( + [{"name": "TimeGenerated", "type": "dateTime"}], + [{"name": "timegenerated", "type": "string"}], + "conflicting types", + ), + ], +) +def test_arm_schema_rejects_standard_custom_column_collisions( + standard, custom, expected +): + contract = common_security_log_contract() + table = arm_table_document( + standard=standard, + custom=custom, + ) + + assert any( + expected in blocker + for blocker in destination_schema_blockers(contract, table) + ) + + +@pytest.mark.parametrize( + ("table", "expected"), + [ + ({}, "missing an object-valued properties"), + ({"properties": {"schema": None}}, "missing an object-valued properties.schema"), + ({"properties": {"schema": {"columns": None}}}, "columns must be an array"), + ( + {"properties": {"schema": {"standardColumns": [{"name": "x"}]}}}, + "missing a non-empty column type", + ), + ( + {"properties": {"schema": {"standardColumns": [{"type": "string"}]}}}, + "missing a non-empty column name", + ), + ( + {"properties": {"schema": {"standardColumns": [{"name": "x", "type": "map"}]}}}, + "unsupported ARM column type", + ), + ( + {"properties": {"schema": {}}}, + "missing both standardColumns and columns", + ), + ], +) +def test_arm_schema_rejects_absent_or_malformed_metadata(table, expected): + contract = common_security_log_contract() + + assert any( + expected in blocker + for blocker in destination_schema_blockers(contract, table) + ) + + +def test_existing_registered_templates_remain_loadable(): + for table in ("Event", "Syslog"): + result = resolve_contract(table) + assert result["status"] == "provisioning-plan" + + +def test_cli_resolve_prints_offline_plan_without_azure_access(capsys): + result = main( + [ + "resolve", + "--table", + "CommonSecurityLog", + "--required-field", + "DeviceEventClassID:string", + "--required-field", + "SourceIP", + ] + ) + + output = capsys.readouterr().out + assert result == 0 + assert '"status": "provisioning-plan"' in output + assert '"writesPerformed": false' in output + assert "schemaFingerprint" in output + + +def test_cli_candidate_check_is_read_only_and_scoped_to_exact_contract_resources( + monkeypatch, capsys +): + contract = common_security_log_contract() + calls = [] + + class Response: + def __init__(self, status_code, value=None): + self.status_code = status_code + self._value = value or {} + + def json(self): + return self._value + + class ReadOnlyClient: + def resolve_workspace(self, identifier): + assert identifier == "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws" + return Workspace( + id=identifier, + name="ws", + subscription_id="sub", + resource_group="rg", + location="eastus", + customer_id="customer", + ) + + def _resource_url(self, resource_id, api_version): + return f"https://management.azure.com{resource_id}?api-version={api_version}" + + def request(self, method, url, **kwargs): + calls.append((method, url)) + if "/tables/CommonSecurityLog?" in url: + return Response( + 200, + { + "properties": { + "schema": { + "standardColumns": arm_columns(contract.destination.columns) + } + } + }, + ) + return Response(404) + + def _resource_ids(self, workspace, contract_value): + return "/dce", "/dcr" + + monkeypatch.setattr(cli, "AzureClient", ReadOnlyClient) + result = main( + [ + "resolve", + "--table", + "CommonSecurityLog", + "--workspace", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws", + "--check-existing", + ] + ) + + output = capsys.readouterr().out + assert result == 0 + assert '"status": "provisioning-plan"' in output + assert calls and all(method == "GET" for method, _ in calls) + assert any("/tables/CommonSecurityLog?" in url for _, url in calls) + assert any(url.startswith("https://management.azure.com/dce?") for _, url in calls) + assert any(url.startswith("https://management.azure.com/dcr?") for _, url in calls) + + +def test_cli_report_preserves_exact_workspace_without_cloud_access(tmp_path, capsys): + workspace_id = ( + "/subscriptions/sub/resourceGroups/rg/providers/" + "Microsoft.OperationalInsights/workspaces/ws" + ) + report_path = tmp_path / "resolution.json" + + result = main( + [ + "resolve", + "--table", + "CommonSecurityLog", + "--workspace", + workspace_id, + "--report", + str(report_path), + ] + ) + + output = capsys.readouterr().out + report = json.loads(report_path.read_text(encoding="utf-8")) + assert result == 0 + assert f'"targetWorkspace": "{workspace_id}"' in output + assert report["targetWorkspace"] == workspace_id + assert report["writesPerformed"] is False diff --git a/Tools/Create-Azure-Sentinel-Solution/README.md b/Tools/Create-Azure-Sentinel-Solution/README.md index 0818d8e41d3..5118796bb8b 100644 --- a/Tools/Create-Azure-Sentinel-Solution/README.md +++ b/Tools/Create-Azure-Sentinel-Solution/README.md @@ -53,7 +53,10 @@ Create an input file and place it in the path `C:\One\Azure-Sentinel\Tools\Creat * WorkbookDescription: Workbook description(s), generally from Workbooks' Metadata. This field can be a string if 1 description is used across all, and an array if multiple are used. * PlaybookDescription: Playbook description(s), generally from Playbooks' Metadata. This field can be a string if 1 description is used across all, and an array if multiple are used. * WatchlistDescription: Watchlist description(s), generally from Watchlists' Property data. This field can be a string if 1 description is used across all, and an array if multiple are used. This field is used if the description from the Watchlist resource is not desired in the Create-UI. - * Workbooks, Analytic Rules, Playbooks, etc.: These fields take arrays of paths relative to the repo root, or BasePath if provided. + * Workbooks, Analytic Rules, Playbooks, etc.: These fields take arrays of paths relative to the repo root, or BasePath if provided. + * XDR Detections: Optional array of converted Custom Detection YAML files. Each file must declare kind CustomDetection, resourceType Microsoft.Security/detectionRules, a stable properties.id, contentProvenance.source.id matching an Analytic Rule in the same solution, a query, and entity mappings. V3.1 requires an independent top-level version in [3.1.0, 4.0.0). + * XDR Detection Version: Legacy fallback only; not used by V3.1. Set each XDR YAML's own top-level version instead. + * Include XDR Content Registration: Deprecated in V3.1. Any supplied value warns and is ignored; installation and registration are both emitted under DeployCustomDetection. Live provider registration support is unverified. * SavedSearches: This input assumes a format of any of the following: * -- Direct export via API (see https://docs.microsoft.com/rest/api/loganalytics/saved-searches/list-by-workspace) * -- Array of SavedSearch resources @@ -73,6 +76,7 @@ Create an input file and place it in the path `C:\One\Azure-Sentinel\Tools\Creat "WorkbookDescription": ["{Description of workbook}"], "Workbooks": [], "Analytic Rules": [], + "XDR Detections": [], "Playbooks": [], "PlaybookDescription": ["{Description of playbook}"], "Parsers": [], @@ -86,9 +90,33 @@ Create an input file and place it in the path `C:\One\Azure-Sentinel\Tools\Creat "Metadata": "{Name of Solution Metadata file}", "TemplateSpec": false } - ``` +V3.1's `mainTemplate.json` remains a complete solution template. It adds only the +applicable independent content-family selections, even without XDR content: + +- `DeployAnalyticsRule` defaults to `true`; `DeployCustomDetection` defaults to + `false`. Both may be selected together. Other emitted Sentinel families default + to `true`; unsupported or absent families have no parameter. +- One isolated nested deployment and one Content Hub registration per CD, both + controlled by `DeployCustomDetection`. Registration support remains unverified. +- Matching UI checkbox outputs and `testParameters.json` declarations. + +Old E5 parameter files must be updated. The deprecated Data registration flag +cannot suppress emission. Content packages and attribution stay unconditional, +and existing dependencies are not rewritten. See the +[V3.1 selection contract and limitations](V3/README-V3_1.md#independent-content-selection-v31-default). +Legacy V3 remains Sentinel-only with no new controls. + +Custom Detections are always packaged disabled. The solution `Version` must be incremented +when adding or changing XDR content so Content Hub recognizes the package update. +V3.1 requires the solution version in `[3.1.0, 4.0.0)` and independently checks +every XDR YAML's own `major.minor.patch` version in that range. Newly converted +detections start at `3.1.0`; source Sentinel +versions remain unchanged in provenance. Existing YAML without its own version +must be explicitly reconverted or have that version authored. See the +[V3.1 version policy](V3/README-V3_1.md#release-version-bounds-v31-only). + #### **Example of Input File: Solution_McAfeePO.json** ```json diff --git a/Tools/Create-Azure-Sentinel-Solution/V3/README-V3_1.md b/Tools/Create-Azure-Sentinel-Solution/V3/README-V3_1.md new file mode 100644 index 00000000000..d246e407991 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/V3/README-V3_1.md @@ -0,0 +1,383 @@ +# Create Solution V3.1 + +`createSolutionV3_1.ps1` supports both local and GitHub pipeline invocation. +Both entry points live in the V3 directory: + +- `V3\createSolutionV3.ps1` remains the legacy Sentinel-only local entry point. +- `V3\createSolutionV3_1.ps1` adds XDR Detection packaging, supports local use, + and remains the CI entry point. + +Both entry points use `common\commonFunctions.ps1` for the core packaging +implementation. Only V3.1 enables Defender XDR Custom Detection injection. + +They are alternative entry points, not two required packaging steps. Use V3 +for a legacy Sentinel-only package and V3.1 when the solution contains XDR +Detections. The repository pipeline runs V3.1 for PR/CI packaging. +The former `V4/createSolutionV4.ps1` entry point was renamed, not the legacy +`pipeline/createSolutionV4.ps1`. Package-version selection and +`contentSchemaVersion` are unchanged; the V3.1-only release-version guards below +reject out-of-range selections. + +## Local usage + +```powershell +.\Tools\Create-Azure-Sentinel-Solution\V3\createSolutionV3_1.ps1 ` + -SolutionDataFolderPath ".\Solutions\\Data" ` + -VersionMode local ` + -VersionBump none +``` + +The local parameter set calls `common\createSolutionLocal.ps1` directly. V3 +uses the same common implementation through its own adapter; neither entry +point invokes the other. + +Use `none` for repeat validation without changing source versions. Use +`patch`, `minor`, or `major` when preparing a release; those options persist +the new version into the solution data file and `SolutionMetadata.json`. + +## Pipeline usage + +In CI, V3.1 receives pipeline-calculated metadata and version parameters. The +package automation calls it through: + +```text +.script\package-automation\package-generator.ps1 +``` + +The 12 positional pipeline parameters (positions 0–11) are unchanged. +The optional named `-SkipAttributionLookup` switch is available in both local +and pipeline parameter sets. For CI callers that do not forward optional +arguments, set `SENTINEL_SKIP_ATTRIBUTION_LOOKUP=1` in the process environment. + +## Independent content selection (V3.1 default) + +V3.1 local and pipeline output use independent ARM boolean `Deploy*` parameters, +including Sentinel-only solutions. Only families actually emitted receive a +parameter, UI checkbox/output, and `testParameters.json` entry. Legacy V3 keeps +its existing behavior and has no new flags. + +For a solution carrying the five corresponding families: + +| Parameter | Default | Selected resources | +|---|---|---| +| `DeployAnalyticsRule` | `true` | AnalyticsRule and AnalyticsRuleTemplate content | +| `DeployPlaybook` | `true` | Playbook/PlaybookTemplate, owned AzureFunction and LogicAppsCustomConnector assets | +| `DeployWorkbook` | `true` | Sentinel Workbook and WorkbookTemplate content | +| `DeployDataConnector` | `true` | DataConnector, ResourceDataConnector/ResourcesDataConnector, DataType, connector metadata and catalog resources | +| `DeployCustomDetection` | `false` | Both Custom Detection installation and Content Hub registration | + +`common/contentDeploymentParameters.json` is the shared vocabulary used by +generation, UI, Python artifact checks, and tests. HuntingQuery, InvestigationQuery, +Parser, Watchlist/WatchlistTemplate, Notebook, AutomationRule and SummaryRule have +their own `Deploy` controls **when emitted**, not speculative empty controls. +No DefenderWorkbook emitter/control is added. Classification uses content kinds, +resource types and explicit emitter ownership, not display names, counts or +query text. Unclassified content fails with its resource name/type/kind. + +For example, the relevant generated fragments are: + +```json +"DeployAnalyticsRule": { "type": "bool", "defaultValue": true }, +"DeployCustomDetection": { "type": "bool", "defaultValue": false } +``` + +```text +AnalyticsRule: [parameters('DeployAnalyticsRule')] +CD install: [parameters('DeployCustomDetection')] +CD registration: [parameters('DeployCustomDetection')] +``` + +Both switches may be true or false independently. Selecting CDs does **not** +suppress Sentinel templates, disable existing rule instances, or remove them. +CDs remain packaged disabled. Existing resource conditions are AND-composed +with the selection; existing variables, names, IDs, versions, resource bodies, +dependency ordering and nested template scopes remain intact. Outer selections +are not injected into stored templates or inner-scope deployment templates. +The CD registration depends on its install wrapper and the package, never the +reverse. The language-version-2 XDR template and MicrosoftSecurity import are +unchanged. + +The content package and customer usage attribution marker remain unconditional; +the legacy workspace query container is shared infrastructure with independently +gated children. Each packaged Custom Detection is appended to the content +package's `dependencies.criteria` as `kind: CustomDetection`, using the same +literal `contentId` and independent XDR `version` as its registration. Existing +Sentinel criteria retain their order and the package keeps `operator: AND`. +These catalog dependencies are unconditional even when +`DeployCustomDetection=false`; the deployment switch controls emitted resources, +not package inventory. All selections false is permitted: package-only +installation (plus shared infrastructure/attribution when present), not an +uninstall operation. There is no new all-off runtime check. A selection can +leave prerequisites unmet: the booleans do not rewrite dependency semantics, +guarantee Content Hub acceptance, or provision missing external dependencies. + +**Compatibility:** `E5Flavor` and `RegisterE5Content` are no longer generated. +Update external parameter files to use only controls declared by that package; +omitting a declared parameter uses its default, whereas explicit `false` skips +that family. A package without CDs declares no `DeployCustomDetection`. The +legacy Data key `Include XDR Content Registration`, whether absent, `true` or +`false`, no longer controls emission. When present it produces a deprecation +warning and is ignored: every packaged CD has both wrappers under the same +switch. Remove this obsolete key from future Data files. + +`createUiDefinition.json` exposes the declared subset in **Content selection**. +`testParameters.json` retains the existing parameter-definition format (including +defaults), not ARM deployment `{"value": ...}` overrides. The migration CLI passes +no legacy E5 controls; its report records `deploymentParameters`, visible build +warnings and `customDetectionRegistrationSupport: "unverified"` for XDR packages. +Live provider support for CustomDetection registration is **unverified**. +Template emission and offline tests do not establish deployment/Marketplace +acceptance. Any subsequent environment write requires separate approval. + +### First authored tactic in V3.1 ARM payloads + +Authored YAML may retain multiple tactics. V3.1 validates a supplied, nonempty +array of tactic objects, then selects the **first entry in authored order** on +a deep copy of deployment properties. Both install and registration get the +same single-entry array, with that entry's techniques/subtechniques and ordering +unchanged. No tactics are sorted, merged or reclassified, and no technique from +another entry is moved into the selected tactic. Omitted optional tactics remain +omitted; malformed supplied lists fail before output. + +This restriction exists because the current Custom Detections beta API accepts +only one tactic. Remove it deliberately once the API supports multiple tactics; +there is no automatic future-capability detection. Authored YAML, original +Sentinel rules, source provenance and full authoring classification remain +unchanged. Every truncation emits `V3.1 TACTIC SELECTION:` with the selected first +tactic and all omitted tactics; the migration CLI displays and persists the +warning. This is an API payload restriction, **not** a user-reviewed recommended +classification or a claim of full tactic coverage. A single tactic passes through +unchanged without a truncation warning. Legacy V3 and direct Graph deployment +behavior are unchanged; the Graph single-tactic guard still applies. + +## Operational and PR evidence exclusions + +Solution content references must not include `Logs`, `Reports`, or `Evidence` +path components (case-insensitive, either slash separator). Local V3/V3.1 and +the V3.1 pipeline reject these references before processing content, including +legacy string-encoded content lists. The ZIP writer uses only +`mainTemplate.json` and `createUiDefinition.json`, never solution-directory +recursion. The migration CLI additionally checks ZIP entries for these folders. +Optional curated `Evidence/` snapshots are PR review material, not +Marketplace content or resumable operational state. + +## Content-count summary + +Both V3.1 entry points include **XDR Detections:** followed by the number of +successfully packaged individual detections in the Basics description of +`createUiDefinition.json` and the content package's `descriptionHtml` in +`mainTemplate.json`. The count follows Analytic Rules and precedes Hunting +Queries; zero-count categories are omitted. For example: +**Analytic Rules:** 15, **XDR Detections:** 15, **Hunting Queries:** 15. + +This is an inventory count, independent of enabled status or the content selections. +Deployment wrappers, content registrations, and customer usage attribution +markers do not add to it. The API/schema kind remains `CustomDetection`; the +separate Content selection step controls deployment, not inventory. Legacy V3 and +`pipeline/createSolutionV4.ps1` descriptions are unchanged. + +## Release-version bounds (V3.1 only) + +Sentinel-derived Custom Detections use `xdr-` as `properties.id`; `contentProvenance.source.id` retains the +original unprefixed GUID. Packaging normalizes a bare originating GUID on its +packaging copy and leaves an already-prefixed ID unchanged. It rejects unrelated +custom IDs rather than silently replacing them. This contract applies when +provenance declares Microsoft Sentinel / AnalyticsRule or the +`sentinel-to-xdr-migration` converter; unrelated Custom Detection identity +contracts are unchanged. The normalized ID is used consistently by installation +and registration, including content IDs, catalog criteria, and references. +Authored YAML and source provenance remain unchanged. This changes local +artifacts only; reconcile previously deployed identities before any new +deployment. Both install and registration templates retain the existing +`[if(true(), '', '')]` wrapper around the inner detection ID. + +V3.1 applies these hard guards in local, catalog, and positional pipeline modes: + +| Release version | Accepted range | +|---|---| +| Effective solution/package version, even without XDR content | `>= 3.1.0` and `< 4.0.0` | +| Effective XDR Custom Detection content version | `>= 3.1.0` and `< 4.0.0` | +| Every other packaged content-item release version | `< 4.0.0`; existing `1.x`/`2.x` versions remain valid | + +The guard reports the content type/path, effective version, and required range. +It never promotes versions automatically. Explicit local bumps are evaluated +before output: for example, `major` from `3.1.0` selects `4.0.0` and is rejected. +All version checks run before generated JSON/ZIP files are overwritten. Local +bump persistence is deferred until version validation and package generation +succeed; rejected versions leave the source versions and existing outputs intact. +Pipeline Data `Version` and calculated package version must agree exactly. +The existing missing-solution-version default (`3.0.0`) is below the minimum and +is rejected; supply an appropriate version explicitly. + +Checks reuse each generator's effective version rather than scanning arbitrary +`version` fields: analytic/hunting YAML version (default `1.0.0`); parser +`Function.Version` (default `1.0.0`); workbook metadata version; playbook +`hidden-SentinelTemplateVersion` (default `1.0`, also the effective default for +packaged custom APIs/function apps); connector UI metadata version (default +`1.0.0` only when metadata is absent); summary-rule version; watchlist solution +version; and CCF `DataConnectorCCFVersion` (fallback solution version), shared by +connector-definition and connections content. Missing/invalid effective versions +without an existing generator default fail rather than gaining a new default. +Non-XDR numeric release versions support existing two-to-four-component +representations; comparison does not rewrite the emitted value. + +**Independent XDR version:** every XDR YAML must declare its own top-level +`version` as a `major.minor.patch` string in `[3.1.0, 4.0.0)`. New conversions +start at `3.1.0`, independently of Sentinel source versions such as `1.0.2`. +Regeneration preserves valid existing XDR versions for the same source rule; +it does not reset `3.1.1` or silently replace an invalid `4.0.0`. +`contentProvenance.source.version` remains the original Sentinel release. +The packager uses the XDR version for registration metadata and content product +IDs, regardless of the deprecated `Include XDR Content Registration` setting. +Data `XDR Detection Version`, solution version, and source provenance are not +fallbacks in V3.1. Missing or invalid XDR versions fail before package output. +For older files with no top-level version, review manual edits and explicitly +reconvert with `--overwrite` to initialize `3.1.0`, or author the independent +version. Never falsify provenance. A solution bump does not bump XDR versions. + +These guards do not govern ARM `apiVersion`, `contentSchemaVersion`, ARM template +`contentVersion`, extension-provider versions, converter/schema versions, or +saved-search API revision numbers. Legacy `V3/createSolutionV3.ps1` and +`pipeline/createSolutionV4.ps1` retain their previous version behavior. + +## Optional customer usage attribution + +Set optional `trackingId` in the solution's **SolutionMetadata.json** to the +**complete customer usage attribution tracking ID** copied from Partner Center +**offer > plan > Technical configuration**, for example: + +```json +"trackingId": "pid-399fb80b-c914-4257-b61e-91ab73733888-partnercenter" +``` + +This example is illustrative; do not copy it into an offer. Never synthesize an +ID, use a bare GUID, or substitute a Partner ID or plan ID. The packager accepts +full `pid-` identifiers, including non-GUID identifiers, validates literal ARM +deployment-name characters and the 64-character limit, and uses the supplied +string unchanged. This syntax check does not verify ownership or registration. + +Local and pipeline packaging read this value from SolutionMetadata.json even +when the Data input already contains consolidated metadata. It is a +packaging-only setting, not a SecurityInsights RP metadata property. + +When supplied, V3.1 adds exactly one root `Microsoft.Resources/deployments` +marker (API `2025-04-01`, mode `Incremental`) containing an empty template with +schema `https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#`, +contentVersion `1.0.0.0`, and `resources: []`. The root remains an implicit ARM +language-version-1 template with a resources array; functional XDR deployments +retain their nested language-version-2 templates. The marker is added after +metadata resource filtering and is included in mainTemplate.json and the ZIP. + +If `trackingId` is absent, null, or blank, V3.1 automatically attempts to recover +it from the **published Marketplace template**: + +1. An anonymous public GET to `https://catalogapi.azure.com/offers?api-version=2018-08-01-beta` + filters on exact `publisherId` and `offerId` from the authoritative metadata. + Exactly one matching offer and one applicable plan are required. +2. The plan must contain exactly one artifact with type `Template`, name + `DefaultTemplate`, and one HTTPS URI. The packager downloads that JSON with + another anonymous GET. This is **not the authenticated Partner Center API**; + no Azure/Graph tokens, credentials, or interactive authentication are used. +3. The published ARM document must contain exactly one literal root `pid-` + deployment with a valid complete ID and an unconditional, empty inline + template (`resources: []`). Functional wrappers, linked templates, conditional + markers, malformed documents and ambiguous markers are rejected. Root + `resources` arrays and language-version-2 symbolic resource objects are + supported; symbolic keys are not interpreted as deployment names. +4. After content/version preparation and before package output, the exact ID is + persisted into the same `SolutionMetadata.json`, then emitted using the + packager's current `2025-04-01` marker shape (not the published API version). + Only the root JSON value is edited; other bytes, nested fields, Unicode, + UTF-8 BOM and newline formatting are preserved. A same-directory atomic + replacement checks the original snapshot for concurrent edits. A local + read/write/conflict failure is a **hard error**, never a successful cache. + Subsequent runs use the saved ID without lookup; local version bump saving + rereads metadata and retains the ID. + +An existing nonblank ID always wins and is validated without network access. +There is no fallback to `ocpSolutionId`, a catalog ID, GUID synthesis, or an +assumption that `offerId` equals `planId`. No plan-selection metadata field is +introduced: multiple applicable plans require manual review and an explicit ID. +Version catalog lookup and `GetPackageVersion` behavior are unchanged. + +Attribution requests use a 20-second request/connection timeout and, on +PowerShell 7.4+, an explicit 20-second stalled-read timeout, with no retries or +redirects (continuous downloads are not capped at 20 seconds). +HTTP status, timeout, network/read, JSON, selection and marker failures produce +classified `CUSTOMER USAGE ATTRIBUTION:` warnings and continue **without** +attribution or metadata changes. A successful lookup logs publisher, offer, +plan ID, template URL (without query credentials), tracking ID and updated path +as `CUSTOMER USAGE ATTRIBUTION SOURCE:`. It is not reported as a warning. + +For offline attribution, use `-SkipAttributionLookup` or set +`SENTINEL_SKIP_ATTRIBUTION_LOOKUP=1`; explicit IDs still emit markers. Use +`-VersionMode local` as well to avoid the **independent** version-catalog lookup. +These controls do not disable other packaging prerequisites or validators. +The migration CLI inherits the same environment control, so no redundant CLI +flag is required. Its packaging report preserves lookup failures in `warnings` +and successful source messages in `attributionSources`. + +If no ID is available, nested XDR packaging still warns that Partner Center +cannot automatically add tracking for functional nested deployments. +Obtain the ID from offer > plan > Technical configuration, add `trackingId`, +and rebuild. Missing attribution does not imply Marketplace upload rejection. +See [Microsoft Marketplace Azure apps customer usage attribution](https://learn.microsoft.com/partner-center/marketplace-offers/azure-partner-customer-usage-attribution#microsoft-marketplace-azure-apps). + +The migration CLI uses `package-v3-1` and writes `packaging.v3_1.json` with +`packager: V3.1`. Old V4 reports are historical evidence, not V3.1 evidence: +rebuild rather than rename or edit old reports. + +## Focused regression checks + +From the repository root (Python 3.11/3.12, PowerShell 7, and `powershell-yaml`): + +```bash +PYTHONPATH=Tools/SentinelToXDRMigration:Tools/SentinelToXDRMigration/tests python3.12 -m unittest test_content_deployment_parameters test_packaging test_workflow test_customer_usage_attribution test_version_policy test_xdr_description_counts test_detection_identity test_connector_metadata test_parser_bindings +``` + +The attribution tests exercise both local and positional pipeline entry points +with isolated synthetic solutions, verify generated JSON and ZIP contents, and +cover missing/invalid IDs and nested XDR preservation. Catalog/template requests +are mocked; default test subprocesses disable lookup, so unit tests never depend +on live Marketplace availability or mutate real solution metadata. Cases include +automatic persistence/replay, explicit overrides, ambiguous identities/plans/ +markers, remote failures, offline controls, byte preservation, local write +failures/concurrent edits and cached-ID retention during version bumps. +They stub the unrelated +full repository validators and isolate repository-root resolution. They do not +build existing solution packages, deploy to Azure, or prove Marketplace upload +acceptance. Version tests also cover boundaries, catalog-result guards, effective +defaults, local bumps, pipeline consistency, and rejection without source/output +mutation. +Converter tests cover independent XDR initialization, unchanged source provenance, +version preservation, explicit legacy reconversion, and invalid-version conflicts. +Description-count tests cover zero, one, and multiple XDR detections, mixed +inventories, deprecated registration true/false (both emit), attribution exclusion, Markdown/HTML and ZIP +consistency, failed generation without partial output, and unchanged legacy +descriptions. Formatter tests also cover XDR-only and trailing/empty categories; +actual XDR packaging still requires the matching source Analytic Rules. + +Content-selection tests evaluate all 32 combinations of the five reference +switches against resource identities, existing-condition composition, nested +scope/body preservation, connector companions, playbook automation assets, +unknown-kind rejection, defaults and omission, local/pipeline and V3 isolation, +and declaration/UI/test-parameter agreement. Real mixed-family fixtures provide +workbook metadata at the existing repository-relative lookup location; that +pre-existing path resolution is not changed here. Tactic tests cover two/three +entries, reversed source order, single-tactic preservation, malformed input, +consistent deep-copied install/registration payloads, visible persisted warnings +and byte-for-byte unchanged authored inputs. + +## Local parser names in Custom Detections + +Before emitting CD installation and registration bodies, the shared packager renames +local KQL bindings that match the solution's parser names or aliases. Both bodies receive +the same query. Source YAML and native parsers are not edited. This also protects inputs +that were authored outside the migration converter. + +For solutions with parsers, install Node.js and run +`npm ci --prefix Tools\SentinelToXDRMigration\kql` from the repository root. +The shared Microsoft KQL semantic helper preserves binding scope and rejects unsafe +rewrites rather than falling back to text replacement. See the migration-tool README's +"Local parser bindings" section for limits and regression commands. diff --git a/Tools/Create-Azure-Sentinel-Solution/V3/README.md b/Tools/Create-Azure-Sentinel-Solution/V3/README.md index f4bdf48b9b9..2991dcc6c53 100644 --- a/Tools/Create-Azure-Sentinel-Solution/V3/README.md +++ b/Tools/Create-Azure-Sentinel-Solution/V3/README.md @@ -6,6 +6,11 @@ The packaging tool detailed below provides an easy way to generate your solution ## Setup +For hybrid Sentinel/XDR packages and the current CI entry point, use +[`createSolutionV3_1.ps1` (V3.1)](README-V3_1.md). That guide also describes the +optional, packaging-only `trackingId` field in `SolutionMetadata.json`. +`createSolutionV3.ps1` remains the legacy Sentinel-only entry point. + - Install PowerShell 7.1+ - If you already have PowerShell 5.1, please follow this [upgrade guide](https://docs.microsoft.com/powershell/scripting/install/migrating-from-windows-powershell-51-to-powershell-7?view=powershell-7.1). @@ -249,7 +254,7 @@ Create a file and place it in the base path of solution `https://raw.githubuser ### Generate Solution Package -NOTE: It is now recommended to use 'createSolutionV3.ps1' file instead of 'createSolutionV2.ps1'. 'createSolutionV2.ps1' is not recommended going forward. 'createSolutionV4.ps1' file is used for GitHub pipeline and is not used for local use. `'createSolutionV3.ps1' requires 'commonFunctions.ps1' file which is placed under 'Tools\Create-Azure-Sentinel-Solution\common' path and this file 'commonFunctions.ps1' has all core logic to create package.` +NOTE: Use `createSolutionV3.ps1` for legacy Sentinel-only local packaging and `createSolutionV3_1.ps1` for hybrid XDR local packaging or GitHub CI. `createSolutionV2.ps1` is not recommended going forward. Both supported entry points share core logic in `Tools\Create-Azure-Sentinel-Solution\common\commonFunctions.ps1`. The `createSolutionV3.ps1` script supports two version management modes: diff --git a/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3.ps1 b/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3.ps1 index ac76470ff6e..171d57535b0 100644 --- a/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3.ps1 @@ -247,6 +247,7 @@ try { foreach ($inputFile in $(Get-ChildItem -Path "$solutionFolderBasePath\$dataFolderName\$dataFileName")) { #$inputJsonPath = Join-Path -Path $path -ChildPath "$($inputFile.Name)" $contentToImport = Get-Content -Raw $inputFile | Out-String | ConvertFrom-Json + Assert-NoOperationalContent -Content $contentToImport $has1PConnectorProperty = [bool]($contentToImport.PSobject.Properties.Name -match "Is1PConnector") if ($has1PConnectorProperty) { diff --git a/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1 b/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1 new file mode 100644 index 00000000000..791b1a24ae8 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1 @@ -0,0 +1,371 @@ +[CmdletBinding(DefaultParameterSetName = "Pipeline")] +param ( + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 0)] + [string]$pipelineBasePath, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 1)] + [string]$pipelineSolutionName, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 2)] + $pipelineDataFileRawContent, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 3)] + [string]$dataFileName, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 4)] + [string]$dataConnectorFolderName, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 5)] + [string]$dataFolderActualName, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 6)] + [string]$instrumentationKey, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 7)] + [string]$pullRequestNumber, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 8)] + [string]$runId, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 9)] + [string]$calculatedPackageVersion, + [Parameter(Mandatory = $true, ParameterSetName = "Pipeline", Position = 10)] + [string]$defaultPackageVersion, + [Parameter(ParameterSetName = "Pipeline", Position = 11)] + [bool]$isWatchListInsideOfWorkbooksFolder = $false, + [Parameter(Mandatory = $true, ParameterSetName = "Local")] + [string]$SolutionDataFolderPath, + [Parameter(ParameterSetName = "Local")] + [ValidateSet("catalog", "local")] + [string]$VersionMode = "catalog", + [Parameter(ParameterSetName = "Local")] + [ValidateSet("none", "patch", "minor", "major")] + [string]$VersionBump = "patch", + [string]$TargetVersion = $null, + [Parameter(ParameterSetName = "Local")] + [string]$ToolkitRootPath = $null, + [Parameter(ParameterSetName = "Local")] + [Parameter(ParameterSetName = "Pipeline")] + [switch]$SkipAttributionLookup +) + +if ($PSCmdlet.ParameterSetName -eq "Local") { + if (-not [string]::IsNullOrWhiteSpace($TargetVersion) -and $VersionBump -ne "none") { + throw "TargetVersion cannot be combined with a version bump." + } + if (-not [string]::IsNullOrWhiteSpace($ToolkitRootPath)) { + $selectedToolkitRoot = (Resolve-Path -LiteralPath $ToolkitRootPath -ErrorAction Stop).Path + $entryPointToolkitRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\..\..") -ErrorAction Stop).Path + if (-not [string]::Equals( + [System.IO.Path]::GetFullPath($selectedToolkitRoot).TrimEnd('\', '/'), + [System.IO.Path]::GetFullPath($entryPointToolkitRoot).TrimEnd('\', '/'), + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "ToolkitRootPath must match the repository containing this V3.1 entry point." + } + } + $localImplementation = Join-Path $PSScriptRoot "..\common\createSolutionLocal.ps1" + & $localImplementation ` + -SolutionDataFolderPath $SolutionDataFolderPath ` + -VersionMode $VersionMode ` + -VersionBump $VersionBump ` + -TargetVersion $TargetVersion ` + -EntryPointName "V3.1" ` + -DefaultInputPath (Join-Path $PSScriptRoot "input") ` + -ToolkitRootPath $ToolkitRootPath ` + -IncludeXdrDetections $true ` + -EnforceV31Versions $true ` + -SkipAttributionLookup:$SkipAttributionLookup + return +} + +. ./Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1 +. ./Tools/Create-Azure-Sentinel-Solution/common/LogAppInsights.ps1 +. ./Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1 +$v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() } +Assert-V31SolutionVersion -SolutionName $pipelineSolutionName -Version $pipelineDataFileRawContent.Version -CalculatedVersion $calculatedPackageVersion + +# Add a helper to normalize folder case for summary rules +function Normalize-SummaryRulesFolderCase { + param( + [Parameter(Mandatory = $true)] + [string]$path + ) + # Replace any case variant of 'Summary Rules' with the actual folder name as in the repo + $path = $path -replace '(?i)summary rules', 'Summary Rules' + $path = $path -replace '(?i)summaryrules', 'SummaryRules' + return $path +} + +try { + $isPipelineRun = $true + Write-Host "Running for Build Pipeline" + Write-Host "Data File Content is $pipelineDataFileRawContent" + + $path = Join-Path -Path $pipelineBasePath -ChildPath "Solutions/$pipelineSolutionName/$dataFolderActualName/$dataFileName" + $solutionFolderBasePath = Join-Path -Path $pipelineBasePath -ChildPath "Solutions/$pipelineSolutionName" + + foreach ($inputFile in Get-ChildItem $path) { + $contentToImport = $pipelineDataFileRawContent + Assert-NoOperationalContent -Content $contentToImport + $basePath = Join-Path -Path $pipelineBasePath -ChildPath "Solutions/$pipelineSolutionName/" + $solutionName = $pipelineSolutionName + $baseMetadata = $pipelineDataFileRawContent + $metadataCounter = 0 + $global:solutionId = $baseMetadata.publisherId + "." + $baseMetadata.offerId + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "solutionId" -NotePropertyValue "$global:solutionId" + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_solutionId" -NotePropertyValue "[variables('solutionId')]" + + $metadataAuthor = $contentToImport.Author -split " - " + if ($null -ne $metadataAuthor[1]) { + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "email" -NotePropertyValue $($metadataAuthor[1]) + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_email" -NotePropertyValue "[variables('email')]" + } + + $major = $contentToImport.version -split '\.' | Select-Object -First 1 + if ($major -ge 3) { + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_solutionName" -NotePropertyValue $solutionName + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_solutionVersion" -NotePropertyValue $contentToImport.version + } + + Write-Host "Package version identified is $calculatedPackageVersion" + $contentResourceDetails = returnContentResources $calculatedPackageVersion + if ($null -eq $contentResourceDetails) { + Write-Error "Not able to identify content resource details based on Version. Please verify if Version in data input file is correct!" + return + } + + $ccpDict = @() + $ccpTablesFilePaths = @() + $ccpTablesCounter = 1 + $isCCPConnector = $false + foreach ($objectProperties in $contentToImport.PSObject.Properties) { + $objectKeyLowercase = $objectProperties.Name.ToLower() + if ($objectKeyLowercase -in @('name','author','logo','description','basepath','version','metadata','templatespec','is1pconnector','createpackage','dependentdomainsolutionids')) { + continue + } + elseif ($objectKeyLowercase -in @('parsers','data connectors','dataconnectors','playbooks','workbooks','analytic rules','hunting queries','watchlists', 'summary rules', 'summaryrules')) { + $currentRunningPropertyName = $objectKeyLowercase + $propertyValue = $objectProperties.Value + Write-Host "currentRunningPropertyName $currentRunningPropertyName , property Value is $propertyValue" + + if ($propertyValue -is [string]) { + try { $filesList = $propertyValue | ConvertFrom-Json } catch { $filesList = $propertyValue } + } else { + $filesList = $propertyValue + } + + $solutionBasePath = (Join-Path $pipelineBasePath 'Solutions/') + $solutionMetadataPath = Join-Path $solutionBasePath "$($pipelineDataFileRawContent.Name)/$($pipelineDataFileRawContent.Metadata)" + $solutionBaseMetadata = Get-Content -Raw $solutionMetadataPath | Out-String | ConvertFrom-Json + + if ($null -eq $solutionBaseMetadata) { + Write-Error "Please verify if the given path $solutionMetadataPath is correct and/or Solution folder name and Data file Name attribute value is correct!" + exit 1 + } + + if (-not $isCCPConnector -and ($dataFileContentObject.'Data Connectors'.Count -gt 0 -or $dataFileContentObject.'DataConnectors'.Count -gt 0)) { + [array]$ccpDict = Get-CCP-Dict -dataFileMetadata $pipelineDataFileRawContent -baseFolderPath $solutionBasePath -solutionName $solutionName -DCFolderName $dataConnectorFolderName + + if ($null -ne $ccpDict -and $ccpDict.Count -gt 0) { + $isCCPConnector = $true + [array]$ccpTablesFilePaths = GetCCPTableFilePaths -existingCCPDict $ccpDict -baseFolderPath $solutionBasePath -solutionName $solutionName -DCFolderName $dataConnectorFolderName + } + } + + Write-Host "isCCPConnector $isCCPConnector" + $ccpConnectorCodeExecutionCounter = 1 + foreach ($file in $filesList) { + $fileExtension = $file -split '\.' | Select-Object -Last 1 + Write-Host "Current file is $file, File extension is $fileExtension" + switch ($objectKeyLowercase) { + 'parsers' + { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Parsers/"; + $finalPath += $file.Replace('Parsers/', '') + } + 'data connectors' + { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/$dataConnectorFolderName/"; + $finalPath += $file.Replace("$dataConnectorFolderName/", '') + } + 'dataconnectors' + { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/$dataConnectorFolderName/"; + $finalPath += $file.Replace("$dataConnectorFolderName/", '') + } + 'playbooks' { + if ($file -like "Solutions/$solutionName/*") { $file = $file.Replace("Solutions/$solutionName/", '') } + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/"; $finalPath += $file + } + 'workbooks' + { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Workbooks/"; + $finalPath += $file.Replace('Workbooks/', '') + } + 'analytic rules' + { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Analytic Rules/"; + $finalPath += $file.Replace("Solutions/$pipelineSolutionName/", "").Replace('Analytic Rules/', '') + } + 'hunting queries' + { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Hunting Queries/"; + $finalPath += $file.Replace("Solutions/$pipelineSolutionName/", "").Replace('Hunting Queries/', '') + } + 'watchlists' { + if ($isWatchListInsideOfWorkbooksFolder) { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Workbooks/Watchlists/"; $finalPath += $file.Replace("Solutions/$pipelineSolutionName/", "").Replace('Watchlists/', '') + } else { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Watchlists/"; $finalPath += $file.Replace("Solutions/$pipelineSolutionName/", "").Replace('Watchlists/', '') + } + } + 'summary rules' { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/Summary Rules/"; $finalPath += $file.Replace('Summary Rules/', '') + } + 'summaryrules' { + $finalPath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName/SummaryRules/"; $finalPath += $file.Replace('SummaryRules/', '') + } + default { $finalPath = '' } + } + + # In the file processing loop, before using $finalPath, normalize the folder case + $finalPath = Normalize-SummaryRulesFolderCase $finalPath + + $finalPath = $finalPath.Replace('//', '/') + Write-Host "Final Path is $finalPath" + $rawData = $null + try { + Write-Host "Downloading $finalPath" + # Case-insensitive file existence check for cross-platform compatibility + $parentDir = Split-Path -Path $finalPath -Parent + $fileName = [System.IO.Path]::GetFileName($finalPath) + $actualFile = Get-ChildItem -Path $parentDir -Filter "*" | Where-Object { $_.Name -ieq $fileName } + if ($null -ne $actualFile) { + Write-Host "Found file: $($actualFile.FullName)" + $rawData = Get-Content -Raw $actualFile.FullName + } else { + Write-Error "File not found (case-insensitive) for $finalPath" + exit 1 + } + } catch { + Write-Error "Failed to read $finalPath -- Please ensure that it exists in $([System.Uri]::EscapeUriString($basePath))" + exit 1 + } + try { + $json = ConvertFrom-Json $rawData -ErrorAction Stop + $validJson = $true + } catch { + $validJson = $false + } + if ($validJson) { + switch ($objectKeyLowercase) { + 'workbooks' { GetWorkbookDataMetadata -file $file -isPipelineRun $isPipelineRun -contentResourceDetails $contentResourceDetails -baseFolderPath $pipelineBasePath -contentToImport $contentToImport } + 'playbooks' { GetPlaybookDataMetadata -file $file -contentToImport $contentToImport -contentResourceDetails $contentResourceDetails -json $json -isPipelineRun $true } + 'data connectors' { + $isCCPConnectorFile = $false + foreach ($item in $ccpDict) { + if ($item.DCDefinitionFullPath -eq $finalPath) { $isCCPConnectorFile = $true; break } + } + if ($isCCPConnectorFile -and $ccpConnectorCodeExecutionCounter -eq 1) { + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $pipelineDataFileRawContent -solutionFileMetadata $solutionBaseMetadata -dcFolderName $dataConnectorFolderName -ccpDict $ccpDict -solutionBasePath $solutionBasePath -solutionName $solutionName -ccpTables $ccpTablesFilePaths -ccpTablesCounter $ccpTablesCounter + $ccpConnectorCodeExecutionCounter += 1 + } elseif ($isCCPConnectorFile) { + continue + } else { + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $pipelineDataFileRawContent -solutionFileMetadata $solutionBaseMetadata -dcFolderName $dataConnectorFolderName -ccpDict $null -solutionBasePath $solutionBasePath -solutionName $solutionName -ccpTables $null -ccpTablesCounter $ccpTablesCounter + } + } + 'dataconnectors' { + $isCCPConnectorFile = $false + foreach ($item in $ccpDict) { + if ($item.DCDefinitionFullPath -eq $finalPath) { $isCCPConnectorFile = $true; break } + } + if ($isCCPConnectorFile -and $ccpConnectorCodeExecutionCounter -eq 1) { + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $pipelineDataFileRawContent -solutionFileMetadata $solutionBaseMetadata -dcFolderName $dataConnectorFolderName -ccpDict $ccpDict -solutionBasePath $solutionBasePath -solutionName $solutionName -ccpTables $ccpTablesFilePaths -ccpTablesCounter $ccpTablesCounter + $ccpConnectorCodeExecutionCounter += 1 + } elseif ($isCCPConnectorFile) { + continue + } else { + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $pipelineDataFileRawContent -solutionFileMetadata $solutionBaseMetadata -dcFolderName $dataConnectorFolderName -ccpDict $null -solutionBasePath $solutionBasePath -solutionName $solutionName -ccpTables $null -ccpTablesCounter $ccpTablesCounter + } + } + 'savedsearches' { GenerateSavedSearches -json $json -contentResourceDetails $contentResourceDetails } + 'watchlists' { + $watchListFileName = Get-ChildItem $finalPath + GenerateWatchList -json $json -isPipelineRun $isPipelineRun -watchListFileName $watchListFileName.BaseName + } + 'summary rules' { + $summaryRuleFilePath = $repositoryBasePath + "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1" + . $summaryRuleFilePath + GenerateSummaryRules -solutionName $solutionName -file $finalPath -rawData $rawData -contentResourceDetails $contentResourceDetails + } + 'summaryrules' { + $summaryRuleFilePath = $repositoryBasePath + "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1" + . $summaryRuleFilePath + GenerateSummaryRules -solutionName $solutionName -file $finalPath -rawData $rawData -contentResourceDetails $contentResourceDetails + } + default { + if ($file -match '(\.yaml)$' -and $objectKeyLowercase -ne 'parsers' -and $currentRunningPropertyName -ne 'parsers') { + if ($objectKeyLowercase -eq 'hunting queries') { + GetHuntingDataMetadata -file $file -rawData $rawData -contentResourceDetails $contentResourceDetails + } elseif ($objectKeyLowercase -eq "summary rules" -or $objectKeyLowercase -eq "summaryrules") { + $summaryRuleFilePath = $repositoryBasePath + "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1" + . $summaryRuleFilePath + GenerateSummaryRules -solutionName $solutionName -file $file -rawData $rawData -contentResourceDetails $contentResourceDetails + } else { + GenerateAlertRule -file $file -contentResourceDetails $contentResourceDetails + } + } else { + GenerateParsersList -file $file -contentToImport $contentToImport -contentResourceDetails $contentResourceDetails + } + } + } + } else { + if ($file -match '(\.yaml)$' -and $objectKeyLowercase -ne 'parsers' -and $currentRunningPropertyName -ne 'parsers') { + if ($objectKeyLowercase -eq 'hunting queries') { + GetHuntingDataMetadata -file $file -rawData $rawData -contentResourceDetails $contentResourceDetails + } elseif ($objectKeyLowercase -eq "summary rules" -or $objectKeyLowercase -eq "summaryrules") { + $summaryRuleFilePath = $repositoryBasePath + "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1" + . $summaryRuleFilePath + GenerateSummaryRules -solutionName $solutionName -file $file -rawData $rawData -contentResourceDetails $contentResourceDetails + } else { + GenerateAlertRule -file $file -contentResourceDetails $contentResourceDetails + } + } else { + GenerateParsersList -file $file -contentToImport $contentToImport -contentResourceDetails $contentResourceDetails + } + } + } + } + } + foreach ($objectProperties in $contentToImport.PSObject.Properties) { + $objectKeyLowercase = $objectProperties.Name.ToLower() + if ($objectKeyLowercase -in @('publisherid','offerid','firstpublishdate','providers','categories','support','metadata')) { + if ($metadataCounter -eq 1) { break } + $metadataCounter = 1 + $rawData = $null + $json = $pipelineDataFileRawContent + $validJson = $true + PrepareSolutionMetadata -solutionMetadataRawContent $json -contentResourceDetails $contentResourceDetails -defaultPackageVersion $defaultPackageVersion + } + } + $global:analyticRuleCounter-- + $global:workbookCounter-- + $global:playbookCounter-- + $global:connectorCounter-- + $global:parserCounter-- + $global:huntingQueryCounter-- + $global:watchlistCounter-- + $global:summaryRuleCounter-- + + updateDescriptionCount $global:connectorCounter "**Data Connectors:** " "{{DataConnectorCount}}" $(checkResourceCounts $global:parserCounter, $global:analyticRuleCounter, $global:workbookCounter, $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:parserCounter "**Parsers:** " "{{ParserCount}}" $(checkResourceCounts $global:analyticRuleCounter, $global:workbookCounter, $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:workbookCounter "**Workbooks:** " "{{WorkbookCount}}" $(checkResourceCounts $global:analyticRuleCounter, $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:analyticRuleCounter "**Analytic Rules:** " "{{AnalyticRuleCount}}" $(checkResourceCounts $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:huntingQueryCounter "**Hunting Queries:** " "{{HuntingQueryCount}}" $(checkResourceCounts $global:playbookCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:watchlistCounter "**Watchlists:** " "{{WatchlistCount}}" $(checkResourceCounts $global:playbookCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:summaryRuleCounter "**Summary Rules:** " "{{SummaryRuleCount}}" $(checkResourceCounts @($global:playbookCounter)) + updateDescriptionCount $global:customConnectorsList.Count "**Custom Azure Logic Apps Connectors:** " "{{LogicAppCustomConnectorCount}}" $(checkResourceCounts @($global:playbookCounter)) + updateDescriptionCount $global:functionAppList.Count "**Function Apps:** " "{{FunctionAppsCount}}" $(checkResourceCounts @($global:playbookCounter)) + updateDescriptionCount ($global:playbookCounter - $global:customConnectorsList.Count - $global:functionAppList.Count) "**Playbooks:** " "{{PlaybookCount}}" $false + GeneratePackage -solutionName $solutionName -contentToImport $contentToImport -calculatedBuildPipelinePackageVersion $calculatedPackageVersion -IncludeXdrDetections $true -SolutionMetadataPath (Join-Path $solutionFolderBasePath 'SolutionMetadata.json') -SkipAttributionLookup:$SkipAttributionLookup + Write-Host "Package Generated Successfully!!" + $solutionFolderBasePath = Join-Path $pipelineBasePath "Solutions/$pipelineSolutionName" + CheckJsonIsValid $solutionFolderBasePath + } +} catch { + $errorDetails = $_ | Out-String + throw "Error occurred in createSolutionV3_1 file. Error Details : $errorDetails" +} diff --git a/Tools/Create-Azure-Sentinel-Solution/common/AttributionMetadata.cs b/Tools/Create-Azure-Sentinel-Solution/common/AttributionMetadata.cs new file mode 100644 index 00000000000..b4bdf8e4a8e --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/AttributionMetadata.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +// Edit only the root value's UTF-8 token, not reserialized metadata or nested names. +public static class AttributionMetadata +{ + public static void Save(string path, byte[] expected, string trackingId) + { + int bom = expected.Length >= 3 && expected[0] == 239 && + expected[1] == 187 && expected[2] == 191 ? 3 : 0; + var reader = new Utf8JsonReader(expected.AsSpan(bom)); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + throw new InvalidDataException("Solution metadata must be a JSON object."); + int start = -1, end = -1, first = -1, close = -1; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == 0) + close = checked((int)reader.TokenStartIndex) + bom; + if (reader.TokenType != JsonTokenType.PropertyName || reader.CurrentDepth != 1) + continue; + if (first < 0) first = checked((int)reader.TokenStartIndex) + bom; + bool matches = string.Equals(reader.GetString(), "trackingId", StringComparison.OrdinalIgnoreCase); + reader.Read(); + if (matches) + { + if (start >= 0) throw new InvalidDataException("Duplicate root trackingId fields."); + if (reader.TokenType != JsonTokenType.Null && + (reader.TokenType != JsonTokenType.String || !string.IsNullOrWhiteSpace(reader.GetString()))) + throw new InvalidDataException("Refusing to replace a nonblank trackingId."); + start = checked((int)reader.TokenStartIndex) + bom; + end = checked((int)reader.BytesConsumed) + bom; + } + reader.Skip(); + } + if (close < 0) throw new InvalidDataException("Incomplete solution metadata."); + string value = JsonSerializer.Serialize(trackingId); + if (start < 0) + { + start = end = first < 0 ? close : first; + string spacing = Encoding.UTF8.GetString(expected, bom + 1, start - bom - 1); + value = "\"trackingId\": " + value + (first < 0 ? "" : "," + spacing); + } + byte[] replacement = Encoding.UTF8.GetBytes(value); + byte[] updated = new byte[start + replacement.Length + expected.Length - end]; + Buffer.BlockCopy(expected, 0, updated, 0, start); + Buffer.BlockCopy(replacement, 0, updated, start, replacement.Length); + Buffer.BlockCopy(expected, end, updated, start + replacement.Length, expected.Length - end); + + // A same-directory replacement avoids partial JSON on failure. Check the + // snapshot while holding a writer-excluding handle before atomic rename. + string staging = path + ".attribution-" + Guid.NewGuid().ToString("N"); + try + { + using (var current = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, + FileShare.Read | FileShare.Delete)) + { + using (var buffer = new MemoryStream()) + { + current.CopyTo(buffer); + if (!expected.SequenceEqual(buffer.ToArray())) + throw new IOException("Solution metadata changed during attribution lookup; retry packaging."); + } + using (var output = new FileStream(staging, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + output.Write(updated, 0, updated.Length); + output.Flush(true); + } + // Unix mode APIs were added in .NET 7; keep earlier PowerShell 7 + // runtimes usable without introducing a new runtime dependency. + var getMode = typeof(File).GetMethod("GetUnixFileMode", new[] { typeof(string) }); + if (Path.DirectorySeparatorChar == '/' && getMode != null) + { + object mode = getMode.Invoke(null, new object[] { path }); + typeof(File).GetMethod("SetUnixFileMode", new[] { typeof(string), mode.GetType() }) + .Invoke(null, new object[] { staging, mode }); + } + File.Move(staging, path, true); + } + } + finally + { + if (File.Exists(staging)) File.Delete(staging); + } + } +} diff --git a/Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1 index ecbf5997c56..734fe19bd52 100644 --- a/Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1 @@ -1,4 +1,157 @@ $jsonConversionDepth = 50 + +function Assert-SolutionName { + param( + [Parameter(Mandatory = $true)][string]$SolutionName, + [Parameter(Mandatory = $true)][string]$SelectedSolutionName + ) + + if ([string]::IsNullOrWhiteSpace($SolutionName) -or + $SolutionName -in @('.', '..') -or + $SolutionName -match '[/\\:]' -or + -not [string]::Equals( + $SolutionName, + $SelectedSolutionName, + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "Solution Data Name must match the selected solution folder and must not be a path." + } +} + +function Assert-NoSolutionContentSymlinks { + param([Parameter(Mandatory = $true)][string]$SolutionRoot) + + $root = [System.IO.Path]::GetFullPath($SolutionRoot) + foreach ($item in Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) { + $relative = [System.IO.Path]::GetRelativePath($root, $item.FullName) + $parts = $relative.Split([System.IO.Path]::DirectorySeparatorChar) + if ($parts | Where-Object { + $_.ToLowerInvariant() -in @('package', 'logs', 'reports', 'evidence') -or + $_.ToLowerInvariant() -eq '.env' -or $_.ToLowerInvariant().StartsWith('.env.') + }) { + continue + } + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Solution content symlink paths are not supported: $($item.FullName)" + } + } +} + +function Resolve-SolutionContentPath { + param( + [Parameter(Mandatory = $true)][string]$SolutionRoot, + [Parameter(Mandatory = $true)][string]$SolutionName, + [Parameter(Mandatory = $true)][string]$ContentPath, + [switch]$RequireFile + ) + + if ([string]::IsNullOrWhiteSpace($ContentPath) -or + $ContentPath -match '\A[A-Za-z][A-Za-z0-9+.-]*:' -or + $ContentPath -match '(^|[\\/])\.{1,2}([\\/]|$)') { + throw "Solution content path must be a local, relative path without traversal: $ContentPath" + } + $normalized = $ContentPath.Replace('\', '/') + if ($normalized.StartsWith('/') -or $normalized.Contains('//')) { + throw "Solution content path must be a local, relative path without traversal: $ContentPath" + } + $parts = $normalized.Split('/') + if ($parts -contains '') { + throw "Solution content path must be a local, relative path without traversal: $ContentPath" + } + if ($parts[0] -ceq 'Solutions') { + if ($parts.Count -lt 3 -or $parts[1] -cne $SolutionName) { + throw "Repository-relative content path must name the selected solution: $ContentPath" + } + $normalized = $parts[2..($parts.Count - 1)] -join '/' + } + + $root = [System.IO.Path]::GetFullPath($SolutionRoot).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $rootItem = Get-Item -LiteralPath $root -Force -ErrorAction Stop + if (($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Solution content root must not be a symlink: $root" + } + $fullPath = [System.IO.Path]::GetFullPath((Join-Path $root $normalized)) + $prefix = $root + [System.IO.Path]::DirectorySeparatorChar + if (-not $fullPath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Solution content path resolves outside the selected solution: $ContentPath" + } + + $relative = $fullPath.Substring($prefix.Length) + $current = $root + foreach ($segment in $relative.Split([System.IO.Path]::DirectorySeparatorChar)) { + $current = Join-Path $current $segment + if (Test-Path -LiteralPath $current) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Solution content symlink paths are not supported: $current" + } + } + } + if ($RequireFile -and -not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "Referenced solution content is not a local file: $ContentPath" + } + [PSCustomObject]@{ + FullPath = $fullPath + RelativePath = $relative.Replace('\', '/') + } +} + +function Convert-SolutionDataContentArrays { + param([Parameter(Mandatory = $true)]$Content) + + $encodedArrayNames = @( + 'parsers', 'data connectors', 'dataconnectors', 'playbooks', 'workbooks', + 'analytic rules', 'analytics rules', 'hunting queries', 'watchlists', + 'summary rules', 'summaryrules', 'xdr detections', 'custom detections', + 'savedsearches' + ) + foreach ($property in $Content.PSObject.Properties) { + $propertyName = $property.Name.ToLowerInvariant() + $value = $property.Value + if ($value -is [string] -and $propertyName -in $encodedArrayNames) { + try { + $value = ConvertFrom-Json -InputObject $value -NoEnumerate -ErrorAction Stop + } + catch { + throw "Solution Data content array '$($property.Name)' must be a JSON-encoded array." + } + if ($value -isnot [array]) { + throw "Solution Data content array '$($property.Name)' must be a JSON-encoded array." + } + $property.Value = $value + } + if ($value -is [array] -and $propertyName -notin @( + 'dependentdomainsolutionids', 'staticdataconnectorids', + 'xdr detections', 'custom detections' + )) { + foreach ($reference in $value) { + if ($reference -isnot [string] -or [string]::IsNullOrWhiteSpace($reference)) { + throw "Solution Data content array '$($property.Name)' must contain only non-empty path strings." + } + } + } + } + return $Content +} + +function Assert-NoOperationalContent { + param([Parameter(Mandatory = $true)]$Content) + $Content = Convert-SolutionDataContentArrays -Content $Content + foreach ($property in $Content.PSObject.Properties) { + $references = $property.Value + if ($references -is [array]) { + foreach ($reference in $references) { + if ($reference -is [string] -and $reference -match '(?i)(^|[\\/])(Logs|Reports|Evidence)([\\/]|$)') { + throw "Solution content arrays must not include Logs, Reports or Evidence." + } + } + } + } +} + $mainTemplateArtifact = [PSCustomObject]@{ name = "DefaultTemplate"; type = "Template" @@ -10,6 +163,11 @@ $baseCreateUiDefinitionPath = "$PSScriptRoot/templating/baseCreateUiDefinition.j $global:baseMainTemplate = Get-Content -Raw $baseMainTemplatePath | Out-String | ConvertFrom-Json $global:baseCreateUiDefinition = Get-Content -Raw $baseCreateUiDefinitionPath | Out-String | ConvertFrom-Json +. "$PSScriptRoot/customDetections.ps1" +. "$PSScriptRoot/contentDeploymentParameters.ps1" +. "$PSScriptRoot/customerUsageAttribution.ps1" +. "$PSScriptRoot/versionPolicy.ps1" +$v31VersionPolicy = $null # Content Counters - (for adding numbering to each item) $global:analyticRuleCounter = 1 @@ -49,30 +207,21 @@ $ContentKindDict.Add("Standalone", "sa") $ContentKindDict.Add("SummaryRule", "sr") function ReadFileContent($filePath) { - try { - # Case-insensitive file existence check - $parentDir = Split-Path -Path $filePath -Parent - $fileName = [System.IO.Path]::GetFileName($filePath) - $actualFile = Get-ChildItem -Path $parentDir -Filter "*" | Where-Object { $_.Name -ieq $fileName } - if ($null -eq $actualFile) { - Write-Host "filePath $filePath (not found, case-insensitive check)" - return $null - } - $filePath = $actualFile.FullName - $content = Get-Content -Raw -Path $filePath - if ($null -eq $content) { - Write-Host "Error in reading file $filePath" - return $null; - } - else { - $fileContent = $content | ConvertFrom-Json; - return $fileContent; - } + $parentDir = Split-Path -Path $filePath -Parent + $fileName = [System.IO.Path]::GetFileName($filePath) + $matches = @( + Get-ChildItem -LiteralPath $parentDir -File -Force -ErrorAction Stop | + Where-Object { $_.Name -ieq $fileName } + ) + if ($matches.Count -eq 0) { + Write-Host "filePath $filePath (not found, case-insensitive check)" + return $null } - catch { - Write-Host "Error occured in ReadFileContent. Error details : $_" -ForegroundColor Red - return $null; + if ($matches.Count -gt 1) { + throw "Ambiguous case-insensitive file path: $filePath" } + $content = Get-Content -LiteralPath $matches[0].FullName -Raw -ErrorAction Stop + return $content | ConvertFrom-Json -ErrorAction Stop } function handleEmptyInstructionProperties ($inputObj) { @@ -436,6 +585,27 @@ function updateDescriptionCount($counter, $emplaceString, $replaceString, $count } } +function Update-XdrDescriptionCount([int]$Count) { + if ($Count -le 0) { return } + + # Target only the generated count paragraph, before substituting author-supplied text. + # XDR follows analytics (or the preceding nonempty category), ahead of hunting. + $pattern = '(\{\{SolutionDescription\}\}\r?\n\r?\n)(?(?:\*\*(?:Data Connectors|Parsers|Workbooks|Analytic Rules):\*\* \d+(?:, )?)*)(?[^\r\n]*)' + $global:baseCreateUiDefinition.parameters.config.basics.description = [regex]::Replace( + $global:baseCreateUiDefinition.parameters.config.basics.description, + $pattern, + [System.Text.RegularExpressions.MatchEvaluator]{ + param($match) + $counts = @( + $match.Groups['before'].Value.TrimEnd(', '), + "**XDR Detections:** $Count", + $match.Groups['after'].Value.TrimEnd(', ') + ) | Where-Object { $_ -ne '' } + return $match.Groups[1].Value + ($counts -join ', ') + } + ) +} + function GetContentSchemaVersion($defaultPackageVersion, $dataInputVersion) { # DEPENDING OF VERSION WE SHOULD SET THE CONTENTSCHEMAVERSION if ($null -eq $defaultPackageVersion -and $null -eq $dataInputVersion) { @@ -838,6 +1008,7 @@ function GetWorkbookDataMetadata($file, $isPipelineRun, $contentResourceDetails, } $workbookNameParameter = [PSCustomObject] @{ type = "string"; defaultValue = $dependencies.title; minLength = 1; metadata = [PSCustomObject] @{ description = "Name for the workbook" }; } + Assert-V31ReleaseVersion -Version "$($dependencies.version)" -ContentKind Workbook -ContentPath $file $global:baseMainTemplate.variables | Add-Member -NotePropertyName "workbookVersion$global:workbookCounter" -NotePropertyValue "$($dependencies.version)" $global:baseMainTemplate.variables | Add-Member -NotePropertyName "workbookContentId$global:workbookCounter" -NotePropertyValue "$($dependencies.workbookKey)" $global:baseMainTemplate.parameters | Add-Member -MemberType NoteProperty -Name $workbookNameParameterName -Value $workbookNameParameter @@ -1506,6 +1677,7 @@ function GetPlaybookDataMetadata($file, $contentToImport, $contentResourceDetail } if ($contentToImport.TemplateSpec) { + Assert-V31ReleaseVersion -Version $playbookVersion -ContentKind $(if ($IsLogicAppsCustomConnector) { 'LogicAppsCustomConnector' } elseif ($IsFunctionAppResource) { 'AzureFunction' } else { 'Playbook' }) -ContentPath $file $global:baseMainTemplate.variables | Add-Member -NotePropertyName "playbookVersion$global:playbookCounter" -NotePropertyValue $playbookVersion $global:baseMainTemplate.variables | Add-Member -NotePropertyName "playbookContentId$global:playbookCounter" -NotePropertyValue $fileName $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_playbookContentId$global:playbookCounter" -NotePropertyValue "[variables('playbookContentId$global:playbookCounter')]" @@ -1721,6 +1893,7 @@ function GetPlaybookDataMetadata($file, $contentToImport, $contentResourceDetail $global:baseMainTemplate.resources += $playbookTemplateSpecContent; } else { + Register-SolutionResourceKind -Resources $playbookResources -Kind Playbook $global:baseMainTemplate.resources += $playbookResources; } @@ -1874,6 +2047,7 @@ function GetDataConnectorMetadata($file, $contentResourceDetails, $dataFileMetad } $global:baseMainTemplate.variables | Add-Member -NotePropertyName "dataConnectorVersion$global:connectorCounter" -NotePropertyValue (($null -ne $templateSpecConnectorData.metadata) ? "$($templateSpecConnectorData.metadata.version)" : "1.0.0") + Assert-V31ReleaseVersion -Version $global:baseMainTemplate.variables."dataConnectorVersion$global:connectorCounter" -ContentKind DataConnector -ContentPath $file if (!$contentToImport.TemplateSpec) { $global:baseMainTemplate.variables | Add-Member -NotePropertyName "parentId" -NotePropertyValue $global:solutionId $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_parentId" -NotePropertyValue "[variables('parentId')]" @@ -2271,6 +2445,7 @@ function GetHuntingDataMetadata($file, $rawData, $contentResourceDetails) { } $calculatedHuntingQueryVersion = ($null -ne $yaml.version) ? "$($yaml.version)" : "1.0.0" + Assert-V31ReleaseVersion -Version $calculatedHuntingQueryVersion -ContentKind HuntingQuery -ContentPath $file $objHuntingQueryVariables = [pscustomobject]@{} $objHuntingQueryVariables | Add-Member -NotePropertyName "huntingQueryVersion$global:huntingQueryCounter" -NotePropertyValue "$calculatedHuntingQueryVersion" @@ -2510,6 +2685,7 @@ function GetHuntingDataMetadata($file, $rawData, $contentResourceDetails) { return; } else { + Register-SolutionResourceKind -Resources $huntingQueryObj -Kind HuntingQuery $global:baseMainTemplate.resources[$(getQueryResourceLocation)].resources += $huntingQueryObj } } @@ -2598,6 +2774,7 @@ function GenerateAlertRule($file, $contentResourceDetails) { } $calculatedAnalyticRuleVersion = ($null -ne $yaml.version) ? "$($yaml.version)" : "1.0.0" + Assert-V31ReleaseVersion -Version $calculatedAnalyticRuleVersion -ContentKind AnalyticsRule -ContentPath $file $objAnalyticRulesVariables = [pscustomobject]@{} $objAnalyticRulesVariables | Add-Member -NotePropertyName "analyticRuleVersion$global:analyticRuleCounter" -NotePropertyValue "$calculatedAnalyticRuleVersion" @@ -2881,6 +3058,7 @@ function GenerateAlertRule($file, $contentResourceDetails) { } function GenerateWatchList($json, $isPipelineRun, $watchListFileName) { + Assert-V31ReleaseVersion -Version $contentToImport.Version -ContentKind Watchlist -ContentPath $watchListFileName $watchlistData = $json.resources[0] $watchlistName = $watchlistData.properties.displayName; @@ -3039,7 +3217,22 @@ function CheckJsonIsValid($solutionFolderBasePath) { } } -function GeneratePackage($solutionName, $contentToImport, $calculatedBuildPipelinePackageVersion = '') { +function GeneratePackage( + $solutionName, + $contentToImport, + $calculatedBuildPipelinePackageVersion = '', + [bool]$IncludeXdrDetections = $false, + [string]$SolutionMetadataPath, + [switch]$SkipAttributionLookup +) { + Assert-V31SolutionVersion -SolutionName $solutionName -Version $contentToImport.Version -CalculatedVersion $calculatedBuildPipelinePackageVersion + Assert-V31VersionChecksPassed + if ($IncludeXdrDetections) { + $xdrDetectionCount = Add-XdrCustomDetectionsToSolution -SolutionName $solutionName -ContentToImport $contentToImport -Template $global:baseMainTemplate + Update-XdrDescriptionCount -Count $xdrDetectionCount + Add-SolutionContentDeploymentParameters -Template $global:baseMainTemplate -UiDefinition $global:baseCreateUiDefinition + } + if ($contentToImport.Description) { $global:baseCreateUiDefinition.parameters.config.basics.description = $global:baseCreateUiDefinition.parameters.config.basics.description -replace "{{SolutionDescription}}", $contentToImport.Description @@ -3093,6 +3286,11 @@ function GeneratePackage($solutionName, $contentToImport, $calculatedBuildPipeli $mainTemplateOutputPath = "$solutionFolder/mainTemplate.json" $createUiDefinitionOutputPath = "$solutionFolder/createUiDefinition.json" + if ($IncludeXdrDetections) { + # Persist only after content/version preparation, after metadata filtering and before output. + Add-CustomerUsageAttribution -SolutionMetadataPath $SolutionMetadataPath -Template $global:baseMainTemplate -WarnIfMissing ($xdrDetectionCount -gt 0) -SkipAttributionLookup:$SkipAttributionLookup + } + try { $baseMainTemplate | ConvertTo-Json -Depth $jsonConversionDepth | Out-File $mainTemplateOutputPath -Encoding utf8 @@ -3479,6 +3677,7 @@ function addTemplateSpecParserResource($content, $yaml, $isyaml, $contentResourc ) } } + Register-SolutionResourceKind -Resources $parserObj -Kind Parser $global:baseMainTemplate.resources += $parserObj $parserMetadata = [PSCustomObject]@{ @@ -3598,6 +3797,7 @@ function generateParserContent($file, $contentToImport, $contentResourceDetails) # Use File Name as Parser Name $global:parserVersion = ($null -ne $yaml -and $yaml.Count -gt 0) ? ($null -eq $yaml.Function.Version ? "1.0.0" : $yaml.Function.Version) : "1.0.0" + Assert-V31ReleaseVersion -Version $global:parserVersion -ContentKind Parser -ContentPath $file # create object with multiple values in it for a single parser resource $objParserVariables | Add-Member -NotePropertyName "parserVersion$global:parserCounter" -NotePropertyValue $global:parserVersion @@ -3650,6 +3850,7 @@ function generateParserContent($file, $contentToImport, $contentResourceDetails) return; } else { + Register-SolutionResourceKind -Resources $parserObj -Kind Parser $global:baseMainTemplate.resources[$queryLocation].resources += $parserObj } } diff --git a/Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.json b/Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.json new file mode 100644 index 00000000000..cd2793c8233 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.json @@ -0,0 +1,62 @@ +{ + "DeployAnalyticsRule": { + "kinds": ["AnalyticsRule", "AnalyticsRuleTemplate"], + "defaultValue": true, + "description": "Deploy the Microsoft Sentinel analytic rules carried by this solution (ContentKind AnalyticsRule and its AnalyticsRuleTemplate registration face)." + }, + "DeployPlaybook": { + "kinds": ["Playbook", "PlaybookTemplate", "AzureFunction", "LogicAppsCustomConnector"], + "defaultValue": true, + "description": "Deploy the playbooks carried by this solution and the automation assets they depend on (PlaybookTemplate, AzureFunction, LogicAppsCustomConnector). These are never shipped independently of the playbook they serve." + }, + "DeployWorkbook": { + "kinds": ["Workbook", "WorkbookTemplate"], + "defaultValue": true, + "description": "Deploy the Microsoft Sentinel workbook content and its Content Hub registration carried by this solution (ContentKind Workbook and WorkbookTemplate)." + }, + "DeployDataConnector": { + "kinds": ["DataConnector", "ResourceDataConnector", "ResourcesDataConnector", "DataType"], + "defaultValue": true, + "description": "Deploy the data connectors carried by this solution, together with their metadata and catalog entries (ContentKind DataConnector, ResourceDataConnector and DataType)." + }, + "DeployCustomDetection": { + "kinds": ["CustomDetection", "CustomDetectionTemplate"], + "defaultValue": false, + "description": "Deploy the XDR-native custom detection resources and their Content Hub registrations (ContentKind CustomDetection and CustomDetectionTemplate). Independent of DeployAnalyticsRule: both kinds can be selected together. Existing analytic-rule instances are not disabled or removed. CD enabled or disabled status is controlled separately from this deployment switch." + }, + "DeployHuntingQuery": { + "kinds": ["HuntingQuery"], + "defaultValue": true, + "description": "Deploy the Microsoft Sentinel hunting queries carried by this solution." + }, + "DeployInvestigationQuery": { + "kinds": ["InvestigationQuery"], + "defaultValue": true, + "description": "Deploy the Microsoft Sentinel investigation queries carried by this solution." + }, + "DeployParser": { + "kinds": ["Parser"], + "defaultValue": true, + "description": "Deploy the parsers and their registrations carried by this solution." + }, + "DeployWatchlist": { + "kinds": ["Watchlist", "WatchlistTemplate"], + "defaultValue": true, + "description": "Deploy the watchlists and their registrations carried by this solution." + }, + "DeployNotebook": { + "kinds": ["Notebook"], + "defaultValue": true, + "description": "Deploy the notebooks carried by this solution." + }, + "DeployAutomationRule": { + "kinds": ["AutomationRule"], + "defaultValue": true, + "description": "Deploy the Microsoft Sentinel automation rules carried by this solution." + }, + "DeploySummaryRule": { + "kinds": ["SummaryRule"], + "defaultValue": true, + "description": "Deploy the summary rules and their registrations carried by this solution." + } +} diff --git a/Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.ps1 new file mode 100644 index 00000000000..49b7d6b31c2 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.ps1 @@ -0,0 +1,128 @@ +$script:contentDeploymentContract = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'contentDeploymentParameters.json') -Raw | ConvertFrom-Json +$script:solutionResourceKinds = [System.Collections.Generic.Dictionary[object,string]]::new([System.Collections.Generic.ReferenceEqualityComparer]::Instance) + +function Register-SolutionResourceKind { + param([object[]]$Resources, [string]$Kind) + foreach ($resource in $Resources) { + $script:solutionResourceKinds[$resource.PSObject.BaseObject] = $Kind + } +} + +function Get-SolutionResourceSelection { + param([psobject]$Resource) + + $kind = [string]$Resource.properties.contentKind + if (-not $kind -and $Resource.type -eq 'Microsoft.OperationalInsights/workspaces/providers/metadata') { + $kind = [string]$Resource.properties.kind + } + if (-not $kind) { + $kind = $script:solutionResourceKinds[$Resource.PSObject.BaseObject] + } + if (-not $kind) { + $kind = switch ($Resource.type) { + 'Microsoft.OperationalInsights/workspaces/providers/dataConnectors' { 'DataConnector' } + 'Microsoft.OperationalInsights/workspaces/providers/dataConnectorDefinitions' { 'DataConnector' } + 'Microsoft.OperationalInsights/workspaces/providers/alertRules' { 'AnalyticsRule' } + 'Microsoft.OperationalInsights/workspaces/providers/watchlists' { 'Watchlist' } + 'Microsoft.OperationalInsights/workspaces/summaryLogs' { 'SummaryRule' } + 'Microsoft.Insights/workbooks' { 'Workbook' } + } + } + if ($kind) { + foreach ($entry in $script:contentDeploymentContract.PSObject.Properties) { + if ($kind -in $entry.Value.kinds) { return $entry.Name } + } + if ($kind -eq 'Solution' -and $Resource.type -in @( + 'Microsoft.OperationalInsights/workspaces/providers/contentPackages', + 'Microsoft.OperationalInsights/workspaces/providers/metadata' + )) { return $null } + } + elseif ($Resource.type -eq 'Microsoft.Resources/deployments') { + # Only the generated XDR install wrapper is classified by its typed inner resource. + $inner = $Resource.properties.template + $detection = $inner.resources.detectionRule + if ($inner.languageVersion -eq '2.0' -and + $inner.imports.MicrosoftSecurity.provider -eq 'MicrosoftSecurity' -and + $detection.import -eq 'MicrosoftSecurity' -and + $detection.type -like 'Microsoft.Security/detectionRules@*' -and + @($inner.resources.PSObject.Properties).Count -eq 1) { + return 'DeployCustomDetection' + } + } + elseif ($Resource.type -eq 'Microsoft.OperationalInsights/workspaces' -and + $null -ne $Resource.resources) { + # The legacy query container is shared infrastructure; its children select independently. + return $null + } + throw "Unclassified V3.1 content resource '$($Resource.name)' (type '$($Resource.type)', kind '$kind'). Add an explicit content-family mapping before packaging." +} + +function Set-SolutionSelectionCondition { + param([psobject]$Resource, [string]$Parameter) + $selection = "parameters('$Parameter')" + $condition = "[$selection]" + if ($null -ne $Resource.condition -and + ($Resource.condition -isnot [string] -or $Resource.condition -cne $condition)) { + if ($Resource.condition -is [bool]) { + $existing = ([string]$Resource.condition).ToLowerInvariant() + '()' + } + elseif ($Resource.condition -is [string] -and + $Resource.condition.StartsWith('[') -and $Resource.condition.EndsWith(']') -and + -not $Resource.condition.StartsWith('[[')) { + $existing = $Resource.condition.Substring(1, $Resource.condition.Length - 2) + } + else { + throw "Invalid existing ARM condition on '$($Resource.name)': $($Resource.condition)" + } + $condition = "[and($selection, $existing)]" + } + $Resource | Add-Member -NotePropertyName condition -NotePropertyValue $condition -Force +} + +function Add-SolutionContentDeploymentParameters { + param([psobject]$Template, [psobject]$UiDefinition) + $used = [System.Collections.Generic.HashSet[string]]::new() + function Set-ResourceSelection($Resources, [string]$ParentSelection) { + foreach ($resource in $Resources) { + $selection = if ($ParentSelection) { $ParentSelection } else { Get-SolutionResourceSelection -Resource $resource } + if ($selection) { + [void]$used.Add($selection) + Set-SolutionSelectionCondition -Resource $resource -Parameter $selection + } + # ARM child resources share the outer scope; stored templates and deployment + # templates do not. Never inject outer parameter references into their bodies. + if ($null -ne $resource.resources) { + Set-ResourceSelection -Resources $resource.resources -ParentSelection $selection + } + } + } + Set-ResourceSelection -Resources $Template.resources + + $elements = @() + foreach ($entry in $script:contentDeploymentContract.PSObject.Properties) { + if (-not $used.Contains($entry.Name)) { continue } + if ($null -ne $Template.parameters.PSObject.Properties[$entry.Name]) { + throw "V3.1 reserved selection parameter '$($entry.Name)' is already declared." + } + $Template.parameters | Add-Member -NotePropertyName $entry.Name -NotePropertyValue ([pscustomobject]@{ + type = 'bool' + defaultValue = $entry.Value.defaultValue + metadata = [pscustomobject]@{ description = $entry.Value.description } + }) + $elements += [pscustomobject]@{ + name = $entry.Name + type = 'Microsoft.Common.CheckBox' + label = $entry.Name + defaultValue = $entry.Value.defaultValue + toolTip = $entry.Value.description + } + $UiDefinition.parameters.outputs | Add-Member -NotePropertyName $entry.Name -NotePropertyValue "[steps('contentSelection').$($entry.Name)]" + } + if ($elements.Count -gt 0) { + $UiDefinition.parameters.steps += [pscustomobject]@{ + name = 'contentSelection' + label = 'Content selection' + elements = $elements + } + } +} diff --git a/Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1 index 160dcb323a5..d98803631d2 100644 --- a/Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1 @@ -437,6 +437,7 @@ function createCCPConnectorResources($contentResourceDetails, $dataFileMetadata, if (!$global:baseMainTemplate.variables.dataConnectorCCPVersion) { $global:baseMainTemplate.variables | Add-Member -NotePropertyName "dataConnectorCCPVersion" -NotePropertyValue ($dataFileMetadata.DataConnectorCCFVersion ?? $dataFileMetadata.Version) } + Assert-V31ReleaseVersion -Version $global:baseMainTemplate.variables.dataConnectorCCPVersion -ContentKind 'CCF DataConnector/Connections' -ContentPath "$solutionName/$dcFolderName (DataConnectorCCFVersion)" try { foreach ($ccpItem in $ccpDict) { diff --git a/Tools/Create-Azure-Sentinel-Solution/common/createSolutionLocal.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/createSolutionLocal.ps1 new file mode 100644 index 00000000000..1e479cb9591 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/createSolutionLocal.ps1 @@ -0,0 +1,621 @@ + +param( + [string]$SolutionDataFolderPath = $null, + [ValidateSet("catalog", "local")] + [string]$VersionMode = "catalog", + [ValidateSet("none", "patch", "minor", "major")] + [string]$VersionBump = "patch", + [string]$TargetVersion = $null, + [string]$EntryPointName = "Local", + [string]$DefaultInputPath = $null, + [string]$ToolkitRootPath = $null, + [bool]$IncludeXdrDetections = $false, + [bool]$EnforceV31Versions = $false, + [switch]$SkipAttributionLookup +) + +Write-Host "=======Starting Package Creation using $EntryPointName tool=========" +Write-Host "Version Mode: $VersionMode" +if ($VersionMode -eq "local") { + Write-Host "Version Bump Type: $VersionBump" +} + +if ($null -eq $SolutionDataFolderPath -or $SolutionDataFolderPath -eq '') { + $path = Read-Host "Enter solution data folder path " +} +else { + $path = $SolutionDataFolderPath + Write-Host "Solution Data folder path specified is : $path" +} + +$defaultPackageVersion = "3.0.0" # for templateSpec this will be 2.0.0 +Write-Host "Path $path, DefaultPackageVersion is $defaultPackageVersion" + +if ($path.length -eq 0) { + # path is not provided so check first file from input folder + $path = $DefaultInputPath + + $inputFile = $(Get-ChildItem $path) + if ($inputFile.Count -gt 0) { + $inputFile = $inputFile[0] + $inputJsonPath = Join-Path -Path $path -ChildPath "$($inputFile.Name)" + + $contentToImport = Get-Content -Raw $inputJsonPath | Out-String | ConvertFrom-Json + # BELOW LINE MAKES USE OF BASEPATH FROM DATA FILE AND ADDS DATA FOLDER NAME. + $path = $contentToImport.BasePath + "/Data" + } + else { + Write-Host "Path is not specified and the $EntryPointName input folder does not contain an input file." + } +} + +$resolvedPath = Resolve-Path -LiteralPath $path -ErrorAction SilentlyContinue +if ($null -eq $resolvedPath) { + Write-Host "Solution data folder path does not exist: $path" + exit 1 +} +$path = $resolvedPath.Path + +$path = $path.Replace('\', '/') +$indexOfSolutions = $path.IndexOf('Solutions') + +if ($indexOfSolutions -le 0) { + Write-Host "Please provide data folder path from Solutions folder!" + exit 1 +} +else { + $hasDataFolder = $path -like '*/data' + if ($hasDataFolder) { + # DATA FOLDER PRESENT + $dataFolderIndex = $path.LastIndexOf("/data", [StringComparison]"CurrentCultureIgnoreCase") + + if ($dataFolderIndex -le 0) { + Write-Host "Given path is not from Solutions data folders. Please provide data file path from Solution" + exit 1 + } + else { + $dataFolderName = $path.Substring($dataFolderIndex + 1) + $solutionName = $path.Substring($indexOfSolutions + 10, $dataFolderIndex - ($indexOfSolutions + 10)) + $solutionFolderBasePath = $path.Substring(0, $dataFolderIndex) + + # GET DATA FOLDER FILE NAME + $excluded = @("parameters.json", "parameter.json", "system_generated_metadata.json", "testParameters.json") + $dataFileName = Get-ChildItem -Path "$solutionFolderBasePath\$dataFolderName\" -recurse -exclude $excluded | ForEach-Object -Process { [System.IO.Path]::GetFileName($_) } + + if ($dataFileName.Length -le 0) { + Write-Host "Data File not present in given folder path!" + exit 1 + } + } + } + else { + Write-Host "Data File not present in given folder path!" + exit 1 + } +} + +$solutionBasePath = $path.Substring(0, $indexOfSolutions + 10) +$repositoryBasePath = $path.Substring(0, $indexOfSolutions) +Write-Host "SolutionBasePath is $solutionBasePath, Solution Name $solutionName" + +$toolkitRoot = $repositoryBasePath +if (-not [string]::IsNullOrWhiteSpace($ToolkitRootPath)) { + $resolvedToolkitRoot = Resolve-Path -LiteralPath $ToolkitRootPath -ErrorAction SilentlyContinue + if ($null -eq $resolvedToolkitRoot) { + Write-Error "V3.1 toolkit root does not exist: $ToolkitRootPath" + exit 1 + } + $toolkitRoot = $resolvedToolkitRoot.Path +} +if ($EntryPointName -eq "V3.1" -or $EnforceV31Versions) { + $toolkitPaths = @( + "Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/createSolutionLocal.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.json", + "Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/templating/baseMainTemplate.json", + "Tools/Create-Azure-Sentinel-Solution/common/templating/baseCreateUiDefinition.json", + "Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/customerUsageAttribution.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/AttributionMetadata.cs", + "Tools/Create-Azure-Sentinel-Solution/common/versionPolicy.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/standardLogStreams.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/storageAccountDeploymentTemplate.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/eventGridManagedIdentityDeploymentTemplate.ps1", + "Tools/Create-Azure-Sentinel-Solution/arm-ttk/download-arm-ttk.ps1", + "Tools/Create-Azure-Sentinel-Solution/arm-ttk/run-arm-ttk-in-automation.ps1", + "Tools/SentinelToXDRMigration/kql/rename-parser-bindings.cjs" + ) + $missingToolkitPaths = @( + $toolkitPaths | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $toolkitRoot $_) -PathType Leaf) + } + ) + if ($missingToolkitPaths.Count -gt 0) { + Write-Error "V3.1 toolkit root is incomplete: $toolkitRoot; missing $($missingToolkitPaths -join ', ')" + exit 1 + } +} + +$isPipelineRun = $false + +$commonFunctionsFilePath = Join-Path $toolkitRoot "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1" +$getccpDetailsFilePath = Join-Path $toolkitRoot "Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1" + +. $commonFunctionsFilePath # load common functions +. $getccpDetailsFilePath # load ccp functions +if ($EnforceV31Versions) { + $v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() } +} +if (-not [string]::IsNullOrWhiteSpace($TargetVersion)) { + if (-not $EnforceV31Versions -or $VersionMode -ne "local") { + throw "TargetVersion is supported only by local V3.1 packaging." + } + if ($VersionBump -ne "none") { + throw "TargetVersion cannot be combined with a version bump." + } + Assert-V31TargetVersion -Version $TargetVersion +} + +# Load catalog API functions only if using catalog mode +if ($VersionMode -eq "catalog") { + $catalogAPIFilePath = Join-Path $toolkitRoot ".script/package-automation/catalogAPI.ps1" + . $catalogAPIFilePath # load catalog api functions +} + +# Function to increment version based on bump type (for local mode) +function GetIncrementedVersion($version, $bumpType = "patch") { + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Host "Warning: Version is null or empty, using default 1.0.0" + $version = "1.0.0" + } + + $versionParts = $version.split(".") + + # Ensure we have at least 3 parts (major.minor.patch) + if ($versionParts.Length -lt 3) { + Write-Host "Warning: Version format invalid, padding with zeros" + while ($versionParts.Length -lt 3) { + $versionParts += "0" + } + } + + $major = $versionParts[0].Trim() + $minor = $versionParts[1].Trim() + $patch = $versionParts[2].Trim() + + # Convert to integers with error handling + try { + [int]$majorInt = [Convert]::ToInt32($major) + [int]$minorInt = [Convert]::ToInt32($minor) + [int]$patchInt = [Convert]::ToInt32($patch) + } + catch { + Write-Host "Error: Cannot convert version parts to integers. Version: $version, Parts: $($versionParts -join ', ')" + Write-Host "Using fallback version 1.0.0" + $majorInt = 1 + $minorInt = 0 + $patchInt = 0 + } + + $originalMajor = $majorInt + $originalMinor = $minorInt + $originalPatch = $patchInt + + switch ($bumpType.ToLower().Trim()) { + "none" { + Write-Host "Keeping current version: $originalMajor.$originalMinor.$originalPatch" + } + "major" { + $majorInt += 1 + $minorInt = 0 + $patchInt = 0 + Write-Host "Incrementing MAJOR version: $originalMajor.$originalMinor.$originalPatch -> $majorInt.$minorInt.$patchInt" + } + "minor" { + $minorInt += 1 + $patchInt = 0 + Write-Host "Incrementing MINOR version: $originalMajor.$originalMinor.$originalPatch -> $majorInt.$minorInt.$patchInt" + } + "patch" { + $patchInt += 1 + Write-Host "Incrementing PATCH version: $originalMajor.$originalMinor.$originalPatch -> $majorInt.$minorInt.$patchInt" + } + default { + $patchInt += 1 + Write-Host "Incrementing PATCH version (default): $originalMajor.$originalMinor.$originalPatch -> $majorInt.$minorInt.$patchInt" + } + } + + return "$majorInt.$minorInt.$patchInt" +} + +# Function to get package version from local solution data file instead of Microsoft catalog +function GetLocalPackageVersion($defaultPackageVersion, $userInputPackageVersion, $packageVersionAttribute, $versionBumpType, $dataFilePath, $targetVersion) { + Write-Host "Using local version from solution data file instead of Microsoft catalog (Bump type: $versionBumpType)" + + if ($packageVersionAttribute -and $null -ne $userInputPackageVersion -and $userInputPackageVersion -ne '') { + Write-Host "Current version from data file: $userInputPackageVersion" + + # Validate version format before processing + if ([string]::IsNullOrWhiteSpace($userInputPackageVersion)) { + Write-Host "Warning: User input version is null or empty, using default" + return $defaultPackageVersion + } + + $versionParts = $userInputPackageVersion.split(".") + if ($versionParts.Length -lt 3) { + Write-Host "Warning: Invalid version format in data file: $userInputPackageVersion. Using default: $defaultPackageVersion" + return $defaultPackageVersion + } + + try { + $userInputMajor = [int]$versionParts[0] + $userInputMinor = [int]$versionParts[1] + $userInputBuild = [int]$versionParts[2] + } + catch { + Write-Host "Error: Cannot parse version numbers from: $userInputPackageVersion. Using default: $defaultPackageVersion" + return $defaultPackageVersion + } + + if (-not [string]::IsNullOrWhiteSpace($targetVersion)) { + $incrementedVersion = $targetVersion + Write-Host "Using explicitly selected V3.1 target version $incrementedVersion" + } + else { + # Increment only when a release bump was requested. + $incrementedVersion = GetIncrementedVersion $userInputPackageVersion $versionBumpType + if ($versionBumpType -eq "none") { + Write-Host "Package version remains $incrementedVersion for local validation" + } + else { + Write-Host "Package version incremented to $incrementedVersion (from local solution data: $userInputPackageVersion)" + } + } + + # Persist the release version only when an increment was requested. + if (-not $EnforceV31Versions -and $versionBumpType -ne "none" -and $null -ne $dataFilePath -and $dataFilePath -ne '') { + try { + Write-Host "Updating version in data file: $dataFilePath" + $dataFileContent = Get-Content -Raw $dataFilePath | Out-String | ConvertFrom-Json + $dataFileContent.Version = $incrementedVersion + $updatedJson = $dataFileContent | ConvertTo-Json -Depth 100 + Set-Content -Path $dataFilePath -Value $updatedJson -Encoding UTF8 + Write-Host "Successfully updated version to $incrementedVersion in data file: $dataFilePath" + + # Also update the solution metadata file version if it exists + $solutionDir = Split-Path $dataFilePath -Parent | Split-Path -Parent + $metadataFile = $dataFileContent.Metadata + if ($metadataFile) { + $metadataPath = Join-Path $solutionDir $metadataFile + if (Test-Path $metadataPath) { + try { + Write-Host "Updating version in solution metadata file: $metadataPath" + $metadataContent = Get-Content -Raw $metadataPath | Out-String | ConvertFrom-Json + $metadataContent.version = $incrementedVersion + $updatedMetadataJson = $metadataContent | ConvertTo-Json -Depth 100 + Set-Content -Path $metadataPath -Value $updatedMetadataJson -Encoding UTF8 + Write-Host "Successfully updated version to $incrementedVersion in metadata file: $metadataPath" + } + catch { + Write-Host "Warning: Could not update version in metadata file: $metadataPath. Error: $_" -ForegroundColor Yellow + } + } + } + } + catch { + Write-Host "Warning: Could not update version in data file: $dataFilePath. Error: $_" -ForegroundColor Yellow + } + } + + return $incrementedVersion + } + else { + # No version specified in data file, use default + Write-Host "Package version set to $defaultPackageVersion (default - no version in data file)" + return $defaultPackageVersion + } +} + +try { + $ccpDict = @(); + $ccpTablesFilePaths = @(); + $ccpTablesCounter = 1; + $isCCPConnector = $false; + Assert-NoSolutionContentSymlinks -SolutionRoot $solutionFolderBasePath + foreach ($inputFile in $(Get-ChildItem -Path "$solutionFolderBasePath\$dataFolderName\$dataFileName")) { + $inputFileRelativePath = $inputFile.FullName.Substring($solutionFolderBasePath.Length).TrimStart('\', '/') + $resolvedInputFile = Resolve-SolutionContentPath ` + -SolutionRoot $solutionFolderBasePath ` + -SolutionName $solutionName ` + -ContentPath $inputFileRelativePath ` + -RequireFile + $contentToImport = Get-Content -LiteralPath $resolvedInputFile.FullPath -Raw -ErrorAction Stop | Out-String | ConvertFrom-Json + Assert-NoOperationalContent -Content $contentToImport + Assert-SolutionName ` + -SolutionName ([string]$contentToImport.Name) ` + -SelectedSolutionName $solutionName + $metadataReference = Resolve-SolutionContentPath ` + -SolutionRoot $solutionFolderBasePath ` + -SolutionName $solutionName ` + -ContentPath ([string]$contentToImport.Metadata) ` + -RequireFile + $metadataPath = $metadataReference.FullPath + foreach ($property in $contentToImport.PSObject.Properties) { + if ($property.Value -is [System.Array] -and + $property.Name.ToLowerInvariant() -notin @( + 'dependentdomainsolutionids', 'staticdataconnectorids', + 'xdr detections', 'custom detections' + )) { + foreach ($reference in $property.Value) { + $null = Resolve-SolutionContentPath ` + -SolutionRoot $solutionFolderBasePath ` + -SolutionName $solutionName ` + -ContentPath ([string]$reference) ` + -RequireFile + } + } + } + + $has1PConnectorProperty = [bool]($contentToImport.PSobject.Properties.Name -match "Is1PConnector") + if ($has1PConnectorProperty) { + $Is1PConnectorPropertyValue = [bool]($contentToImport.Is1PConnector) + if ($Is1PConnectorPropertyValue) { + # when true we terminate package creation. + Write-Host "ERROR: Is1PConnector property is deprecated. Please use StaticDataConnector property. Refer link https://github.com/Azure/Azure-Sentinel/blob/master/Tools/Create-Azure-Sentinel-Solution/V3/README.md for more details!" + exit 1; + } + } + + $basePath = $(if ($solutionBasePath) { $solutionBasePath } else { "https://raw.githubusercontent.com/Azure/Azure-Sentinel/master/" }) + $metadataAuthor = $contentToImport.Author.Split(" - "); + if ($null -ne $metadataAuthor[1]) { + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "email" -NotePropertyValue $($metadataAuthor[1]) + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_email" -NotePropertyValue "[variables('email')]" + } + + $solutionName = $contentToImport.Name + + $baseMetadata = Get-Content -LiteralPath $metadataPath -Raw -ErrorAction Stop | Out-String | ConvertFrom-Json + if ($null -eq $baseMetadata) { + Write-Host "Please verify if the given path is correct and/or Solution folder name and Data file Name attribute value is correct!" + exit 1 + } + + #================START: IDENTIFY PACKAGE VERSION============= + $userInputPackageVersion = $contentToImport.version + $packageVersionAttribute = [bool]($contentToImport.PSobject.Properties.Name -match "version") + + if ($VersionMode -eq "local") { + # Use local version bumping + $packageVersion = GetLocalPackageVersion $defaultPackageVersion $userInputPackageVersion $packageVersionAttribute $VersionBump $inputFile.FullName $TargetVersion + } + else { + # Use catalog API for version management + $solutionOfferId = $baseMetadata.offerId + $offerId = "$solutionOfferId" + $offerDetails = GetCatalogDetails $offerId + $packageVersion = GetPackageVersion $defaultPackageVersion $offerId $offerDetails $packageVersionAttribute $userInputPackageVersion + } + + Assert-V31SolutionVersion -SolutionName $solutionName -Version $packageVersion + if ($packageVersion -ne $contentToImport.version) { + $contentToImport.PSObject.Properties.Remove('version') + $contentToImport | Add-Member -MemberType NoteProperty -Name 'version' -Value $packageVersion + Write-Host "Package version updated to $packageVersion" + } + + $TemplateSpecAttribute = [bool]($contentToImport.PSobject.Properties.Name -match "TemplateSpec") + if (!$TemplateSpecAttribute) { + $contentToImport | Add-Member -MemberType NoteProperty -Name 'TemplateSpec' -Value $true + } + + $major = $contentToImport.version.split(".")[0] + if ($TemplateSpecAttribute -and $contentToImport.TemplateSpec -eq $false -and $major -gt 1) { + $contentToImport.PSObject.Properties.Remove('TemplateSpec') + $contentToImport | Add-Member -MemberType NoteProperty -Name 'TemplateSpec' -Value $true + } + #================END: IDENTIFY PACKAGE VERSION============= + + Write-Host "Package version identified is $packageVersion" + + if ($major -ge 3) { + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_solutionName" -NotePropertyValue $solutionName + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_solutionVersion" -NotePropertyValue $contentToImport.version + } + + $metadataAuthor = $contentToImport.Author.Split(" - "); + + $global:solutionId = $baseMetadata.publisherId + "." + $baseMetadata.offerId + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "solutionId" -NotePropertyValue "$global:solutionId" + $global:baseMainTemplate.variables | Add-Member -NotePropertyName "_solutionId" -NotePropertyValue "[variables('solutionId')]" + + # VERIFY IF IT IS A CONTENTSPEC OR CONTENTPACKAGE RESOURCE TYPE BY VERIFYING VERSION FROM DATA FILE + $contentResourceDetails = returnContentResources($contentToImport.Version) + if ($null -eq $contentResourceDetails) { + Write-Host "Not able to identify content resource details based on Version. Please verify if Version in data input file is correct!" + exit 1; + } + + $dcWithoutSpace = ($baseFolderPath + $solutionName + "/DataConnectors/").Replace("//", "/") + $hasDCWithoutSpace = Test-Path -Path $dcWithoutSpace + + if ($hasDCWithoutSpace) { + $DCFolderName = "DataConnectors" + } + + if ($isCCPConnector -eq $false) { + $DCFolderName = "Data Connectors" + + $ccpDict = Get-CCP-Dict -dataFileMetadata $contentToImport -baseFolderPath $solutionBasePath -solutionName $solutionName -DCFolderName $DCFolderName + + if ($null -ne $ccpDict -and $ccpDict.count -gt 0) { + $isCCPConnector = $true + [array]$ccpTablesFilePaths = GetCCPTableFilePaths -existingCCPDict $ccpDict -baseFolderPath $solutionBasePath -solutionName $solutionName -DCFolderName $DCFolderName + } + } + Write-Host "isCCPConnector $isCCPConnector" + $ccpConnectorCodeExecutionCounter = 1; + foreach ($objectProperties in $contentToImport.PsObject.Properties) { + if ($objectProperties.Name.ToLower() -in @('xdr detections', 'custom detections')) { + continue + } + if ($objectProperties.Value -is [System.Array] -and $objectProperties.Name.ToLower() -ne 'dependentdomainsolutionids' -and $objectProperties.Name.ToLower() -ne 'staticdataconnectorids') { + foreach ($file in $objectProperties.Value) { + $resolvedContent = Resolve-SolutionContentPath ` + -SolutionRoot $solutionFolderBasePath ` + -SolutionName $solutionName ` + -ContentPath ([string]$file) ` + -RequireFile + $file = $resolvedContent.RelativePath + $finalPath = $resolvedContent.FullPath + $rawData = $null + try { + $rawData = Get-Content -LiteralPath $finalPath -Raw -ErrorAction Stop + } + catch { + throw "Failed to read contained solution content $finalPath`: $($_.Exception.Message)" + } + + try { + $json = ConvertFrom-Json $rawData -ErrorAction Stop; # Determine whether content is JSON or YAML + $validJson = $true; + } + catch { + $validJson = $false; + } + + if ($validJson) { + # If valid JSON, must be Workbook or Playbook + $objectKeyLowercase = $objectProperties.Name.ToLower() + if ($objectKeyLowercase -eq "workbooks") { + GetWorkbookDataMetadata -file $file -isPipelineRun $isPipelineRun -contentResourceDetails $contentResourceDetails -baseFolderPath $repositoryBasePath -contentToImport $contentToImport + } + elseif ($objectKeyLowercase -eq "playbooks") { + GetPlaybookDataMetadata -file $file -contentToImport $contentToImport -contentResourceDetails $contentResourceDetails -json $json -isPipelineRun $isPipelineRun + } + elseif ($objectKeyLowercase -eq "data connectors" -or $objectKeyLowercase -eq "dataconnectors") { + if ($ccpDict.Count -gt 0) { + $isCCPConnectorFile = $false; + foreach ($item in $ccpDict) { + if ($item.DCDefinitionFullPath -eq $finalPath) { + $isCCPConnectorFile = $true + break; + } + } + + if ($isCCPConnectorFile -and $ccpConnectorCodeExecutionCounter -eq 1) { + # current file is a ccp connector + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $contentToImport -solutionFileMetadata $baseMetadata -dcFolderName $DCFolderName -ccpDict $ccpDict -solutionBasePath $basePath -solutionName $solutionName -ccpTables $ccpTablesFilePaths -ccpTablesCounter $ccpTablesCounter + + $ccpConnectorCodeExecutionCounter += 1 + } + elseif ($isCCPConnectorFile -and $ccpConnectorCodeExecutionCounter -gt 1) { + continue; + } + else { + # current file is a normal connector + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $contentToImport -solutionFileMetadata $baseMetadata -dcFolderName $DCFolderName -ccpDict $null -solutionBasePath $basePath -solutionName $solutionName -ccpTables $null -ccpTablesCounter $ccpTablesCounter + } + } + else { + # current file is a normal connector + GetDataConnectorMetadata -file $file -contentResourceDetails $contentResourceDetails -dataFileMetadata $contentToImport -solutionFileMetadata $baseMetadata -dcFolderName $DCFolderName -ccpDict $null -solutionBasePath $basePath -solutionName $solutionName -ccpTables $null -ccpTablesCounter $ccpTablesCounter + } + } + elseif ($objectKeyLowercase -eq "savedsearches") { + GenerateSavedSearches -json $json -contentResourceDetails $contentResourceDetails + } + elseif ($objectKeyLowercase -eq "watchlists") { + $watchListFileName = Get-ChildItem $finalPath + + GenerateWatchList -json $json -isPipelineRun $isPipelineRun -watchListFileName $watchListFileName.BaseName + } + elseif ($objectKeyLowercase -eq "parsers") { + GenerateParsersList -file $file -contentToImport $contentToImport -contentResourceDetails $contentResourceDetails + } + } + else { + if ($file -match "(\.yaml)$" -and $objectProperties.Name.ToLower() -ne "parsers") { + $objectKeyLowercase = $objectProperties.Name.ToLower() + if ($objectKeyLowercase -eq "hunting queries") { + GetHuntingDataMetadata -file $file -rawData $rawData -contentResourceDetails $contentResourceDetails + } + elseif ($objectKeyLowercase -eq "summary rules" -or $objectKeyLowercase -eq "summaryrules") { + $summaryRuleFilePath = Join-Path $toolkitRoot "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1" + . $summaryRuleFilePath + GenerateSummaryRules -solutionName $solutionName -file $file -rawData $rawData -contentResourceDetails $contentResourceDetails + } + else { + GenerateAlertRule -file $file -contentResourceDetails $contentResourceDetails + } + } + else { + GenerateParsersList -file $file -contentToImport $contentToImport -contentResourceDetails $contentResourceDetails + } + } + } + } + elseif ($objectProperties.Name.ToLower() -eq "metadata") { + $finalPath = $metadataPath + $rawData = Get-Content -LiteralPath $finalPath -Raw -ErrorAction Stop + try { + $json = ConvertFrom-Json $rawData -ErrorAction Stop + $validJson = $true + } + catch { + $validJson = $false + } + if ($validJson -and $json) { + PrepareSolutionMetadata -solutionMetadataRawContent $json -contentResourceDetails $contentResourceDetails -defaultPackageVersion $defaultPackageVersion + } + else { + throw "Failed to load contained solution metadata $finalPath as JSON." + } + } + } + + $global:analyticRuleCounter -= 1 + $global:workbookCounter -= 1 + $global:playbookCounter -= 1 + $global:connectorCounter -= 1 + $global:parserCounter -= 1 + $global:huntingQueryCounter -= 1 + $global:watchlistCounter -= 1 + $global:summaryRuleCounter -= 1 + + updateDescriptionCount $global:connectorCounter "**Data Connectors:** " "{{DataConnectorCount}}" $(checkResourceCounts $global:parserCounter, $global:analyticRuleCounter, $global:workbookCounter, $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:parserCounter "**Parsers:** " "{{ParserCount}}" $(checkResourceCounts $global:analyticRuleCounter, $global:workbookCounter, $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:workbookCounter "**Workbooks:** " "{{WorkbookCount}}" $(checkResourceCounts $global:analyticRuleCounter, $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:analyticRuleCounter "**Analytic Rules:** " "{{AnalyticRuleCount}}" $(checkResourceCounts $global:playbookCounter, $global:huntingQueryCounter, $global:watchlistCounter, $global:summaryRuleCounter) + updateDescriptionCount $global:huntingQueryCounter "**Hunting Queries:** " "{{HuntingQueryCount}}" $(checkResourceCounts $global:playbookCounter, $global:watchlistCounter, $global:summaryRuleCounter) + + updateDescriptionCount $global:watchlistCounter "**Watchlists:** " "{{WatchlistCount}}" $(checkResourceCounts $global:playbookCounter, $global:summaryRuleCounter) + + updateDescriptionCount $global:summaryRuleCounter "**Summary Rules:** " "{{SummaryRuleCount}}" $(checkResourceCounts @($global:playbookCounter)) + + updateDescriptionCount $global:customConnectorsList.Count "**Custom Azure Logic Apps Connectors:** " "{{LogicAppCustomConnectorCount}}" $(checkResourceCounts @($global:playbookCounter)) + updateDescriptionCount $global:functionAppList.Count "**Function Apps:** " "{{FunctionAppsCount}}" $(checkResourceCounts @($global:playbookCounter)) + updateDescriptionCount ($global:playbookCounter - $global:customConnectorsList.Count - $global:functionAppList.Count) "**Playbooks:** " "{{PlaybookCount}}" $false + + GeneratePackage -solutionName $solutionName -contentToImport $contentToImport -calculatedBuildPipelinePackageVersion $contentToImport.Version -IncludeXdrDetections $IncludeXdrDetections -SolutionMetadataPath (Join-Path $solutionFolderBasePath 'SolutionMetadata.json') -SkipAttributionLookup:$SkipAttributionLookup; + if ($EnforceV31Versions -and $VersionMode -eq 'local' -and ($VersionBump -ne 'none' -or -not [string]::IsNullOrWhiteSpace($TargetVersion))) { + Save-V31LocalPackageVersion -DataFilePath $inputFile.FullName -Version $contentToImport.Version + } + RunArmTtkOnPackage -solutionName $solutionName -isPipelineRun $false; + + # check if mainTemplate and createUiDefinition json files are valid or not + CheckJsonIsValid($solutionFolderBasePath) + } +} +catch { + if ($IncludeXdrDetections) { + throw + } + Write-Host "Error occurred in $EntryPointName local package creation. Error details are $_" -ForegroundColor Red +} \ No newline at end of file diff --git a/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 new file mode 100644 index 00000000000..c97750f90be --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 @@ -0,0 +1,638 @@ +function Get-CustomDetectionParserNames { + param( + [Parameter(Mandatory = $true)] + [string]$SolutionPath + ) + + $parserPath = Join-Path $SolutionPath 'Parsers' + if (-not (Test-Path -LiteralPath $parserPath -PathType Container)) { + return + } + $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($file in (Get-ChildItem -LiteralPath $parserPath -Recurse -File | + Where-Object { $_.Extension -in '.yaml', '.yml' } | Sort-Object FullName)) { + $document = Get-Content -LiteralPath $file.FullName -Raw | + ConvertFrom-Yaml -ErrorAction Stop + if ($document -isnot [System.Collections.IDictionary]) { + throw "Parser must contain a YAML object: $($file.FullName)" + } + if ($null -eq $document['FunctionName'] -and $null -eq $document['FunctionAlias']) { + throw "Parser must declare FunctionName or FunctionAlias: $($file.FullName)" + } + foreach ($field in @('FunctionName', 'FunctionAlias')) { + if ($null -ne $document[$field]) { + if ($document[$field] -isnot [string] -or [string]::IsNullOrWhiteSpace($document[$field])) { + throw "Parser $field must be a nonempty string: $($file.FullName)" + } + [void]$names.Add($document[$field].Trim()) + } + } + } + return @($names | Sort-Object -CaseSensitive) +} + +function Set-CustomDetectionParserBindings { + param( + [Parameter(Mandatory = $true)] + [psobject]$DetectionDocument, + [string[]]$ParserNames = @() + ) + + if ($ParserNames.Count -eq 0) { + return + } + $helper = Join-Path $PSScriptRoot '..\..\SentinelToXDRMigration\kql\rename-parser-bindings.cjs' + $node = Get-Command node -CommandType Application -ErrorAction SilentlyContinue + if ($null -eq $node) { + throw 'Node.js is required for scope-aware KQL parser binding normalization.' + } + $dependency = Join-Path (Split-Path $helper) 'node_modules\@kusto\language-service-next' + if (-not (Test-Path -LiteralPath $dependency -PathType Container)) { + throw "KQL dependency is missing; run npm ci --prefix `"$(Split-Path $helper)`"" + } + $inputJson = @{ + query = [string]$DetectionDocument.properties.queryCondition.queryText + reservedNames = @($ParserNames) + } | ConvertTo-Json -Depth 10 -Compress + $outputJson = $inputJson | & $node.Source $helper 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Custom Detection parser binding normalization failed: $($outputJson -join [Environment]::NewLine)" + } + $result = ($outputJson -join [Environment]::NewLine) | ConvertFrom-Json -ErrorAction Stop + $DetectionDocument.properties.queryCondition.queryText = $result.query + foreach ($rename in $result.renames) { + Write-Warning "Renamed local parser binding $($rename.from) to $($rename.to) to avoid a saved parser collision." + } +} + +function Get-CustomDetectionDataProperty { + param( + [Parameter(Mandatory = $true)] + [psobject]$ContentToImport + ) + + return $ContentToImport.PSObject.Properties | + Where-Object { $_.Name -ieq 'XDR Detections' -or $_.Name -ieq 'Custom Detections' } | + Select-Object -First 1 +} + +function Resolve-CustomDetectionPath { + param( + [Parameter(Mandatory = $true)] + [string]$RepositoryRoot, + [Parameter(Mandatory = $true)] + [string]$SolutionName, + [Parameter(Mandatory = $true)] + [string]$ConfiguredPath + ) + + $normalizedPath = $ConfiguredPath.Replace('\', '/').TrimStart('/') + $solutionPrefix = "Solutions/$SolutionName/" + if ($normalizedPath.StartsWith($solutionPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $normalizedPath = $normalizedPath.Substring($solutionPrefix.Length) + } + elseif ($normalizedPath.StartsWith("$SolutionName/", [System.StringComparison]::OrdinalIgnoreCase)) { + $normalizedPath = $normalizedPath.Substring($SolutionName.Length + 1) + } + + return Join-Path -Path (Join-Path -Path $RepositoryRoot -ChildPath "Solutions/$SolutionName") -ChildPath $normalizedPath +} + +function Get-CustomDetectionDeploymentName { + param( + [Parameter(Mandatory = $true)] + [string]$SolutionName, + [Parameter(Mandatory = $true)] + [string]$DetectionId + ) + + $name = (($SolutionName -replace '[^A-Za-z0-9]', '') + '-CD-' + $DetectionId) + if ($name.Length -le 64) { + return $name + } + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($name)) + $digest = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').Substring(0, 8).ToLowerInvariant() + } + finally { + $sha256.Dispose() + } + + $prefixLength = 64 - $digest.Length - 1 + return $name.Substring(0, $prefixLength).TrimEnd('-') + '-' + $digest +} + +function Copy-CustomDetectionObject { + param( + [Parameter(Mandatory = $true)] + [object]$InputObject + ) + + return $InputObject | ConvertTo-Json -Depth 100 | ConvertFrom-Json +} + +function ConvertTo-CustomDetectionArmLiteral { + param( + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $escapedValue = $Value.Replace("'", "''") + return "[if(true(), '$escapedValue', '$escapedValue')]" +} + +function ConvertTo-V31CustomDetectionId { + param( + [Parameter(Mandatory = $true)] + [string]$DetectionId, + [Parameter(Mandatory = $true)] + [string]$SourceId, + [Parameter(Mandatory = $true)] + [bool]$SentinelDerived, + [Parameter(Mandatory = $true)] + [string]$ContentPath + ) + + $guidPattern = '\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z' + $prefixedPattern = '\Axdr-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\z' + $normalized = $DetectionId + if ($SentinelDerived) { + if ($SourceId -cnotmatch $guidPattern) { + throw "Sentinel-derived Custom Detection '$ContentPath' has an invalid originating Sentinel template GUID '$SourceId'." + } + if ($DetectionId -cmatch $guidPattern) { + if ($DetectionId -ine $SourceId) { + throw "Sentinel-derived Custom Detection '$ContentPath' ID '$DetectionId' conflicts with originating Sentinel GUID '$SourceId'; no identity was changed." + } + $normalized = "xdr-$SourceId" + } + elseif ($DetectionId -match $prefixedPattern) { + if ($Matches[1] -ine $SourceId) { + throw "Sentinel-derived Custom Detection '$ContentPath' ID '$DetectionId' conflicts with originating Sentinel GUID '$SourceId'; no identity was changed." + } + $normalized = "xdr-$SourceId" + } + else { + throw "Sentinel-derived Custom Detection '$ContentPath' ID '$DetectionId' must be the originating GUID or its canonical xdr- form; arbitrary custom IDs are not overwritten." + } + } + elseif ($DetectionId -cmatch $guidPattern) { + $normalized = "xdr-$DetectionId" + } + elseif ($DetectionId -match $prefixedPattern) { + $normalized = "xdr-$($Matches[1])" + } + elseif ($DetectionId -cnotmatch '\A[A-Za-z][A-Za-z0-9_-]{0,99}\z') { + throw "Custom Detection '$ContentPath' ID '$DetectionId' is invalid for Microsoft Graph; use a letter followed by at most 99 letters, digits, dashes, or underscores." + } + + if ($normalized -cnotmatch '\A[A-Za-z][A-Za-z0-9_-]{0,99}\z') { + throw "Normalized Custom Detection ID '$normalized' for '$ContentPath' is invalid for Microsoft Graph." + } + return [pscustomobject]@{ + id = $normalized + changed = $normalized -cne $DetectionId + } +} + +function Set-V31CustomDetectionTactics { + param([psobject]$DetectionResource, [string]$DetectionId) + $alertTemplate = $DetectionResource.properties.detectionAction.alertTemplate + if ($null -eq $alertTemplate.PSObject.Properties['tactics']) { + $projection = [ordered]@{ + detectionId = $DetectionId + authoredTactics = @() + packagedTactics = @() + omittedTactics = @() + } + Write-Host "V3.1 TACTIC PROJECTION: $($projection | ConvertTo-Json -Depth 10 -Compress)" + return + } + $tactics = $alertTemplate.tactics + if ($tactics -isnot [array] -or $tactics.Count -eq 0) { + throw "Custom Detection '$DetectionId' tactics must be a nonempty array when supplied." + } + foreach ($tactic in $tactics) { + if ($tactic -isnot [pscustomobject] -or $tactic.tactic -isnot [string] -or + [string]::IsNullOrWhiteSpace($tactic.tactic)) { + throw "Custom Detection '$DetectionId' tactics must contain objects with a nonempty tactic name." + } + if ($null -ne $tactic.PSObject.Properties['techniques']) { + if ($tactic.techniques -isnot [array]) { + throw "Custom Detection '$DetectionId' tactic techniques must be an array." + } + foreach ($technique in $tactic.techniques) { + if ($technique -isnot [pscustomobject] -or $technique.technique -isnot [string] -or + [string]::IsNullOrWhiteSpace($technique.technique)) { + throw "Custom Detection '$DetectionId' techniques must contain objects with a nonempty technique name." + } + if ($null -ne $technique.PSObject.Properties['subTechniques'] -and ( + $technique.subTechniques -isnot [array] -or + @($technique.subTechniques | Where-Object { + $_ -isnot [string] -or [string]::IsNullOrWhiteSpace($_) + }).Count -gt 0)) { + throw "Custom Detection '$DetectionId' subTechniques must be an array of nonempty strings." + } + } + } + } + if ($tactics.Count -gt 1) { + $omittedTactics = @($tactics | Select-Object -Skip 1 | ForEach-Object { [string]$_.tactic }) + $alertTemplate.tactics = @($tactics[0]) + } + else { + $omittedTactics = @() + } + $projection = [ordered]@{ + detectionId = $DetectionId + authoredTactics = @($tactics | ForEach-Object { [string]$_.tactic }) + packagedTactics = @([string]$tactics[0].tactic) + omittedTactics = $omittedTactics + } + Write-Host "V3.1 TACTIC PROJECTION: $($projection | ConvertTo-Json -Depth 10 -Compress)" + if ($omittedTactics.Count -gt 0) { + $dropped = $omittedTactics -join ', ' + Write-Host "INFORMATION: V3.1 TACTIC SELECTION: Custom Detection '$DetectionId' ARM install and registration package the first authored tactic '$($tactics[0].tactic)' and its techniques; omitted tactics: $dropped. Source order is preserved; this is an API payload restriction, not a recommended classification or full tactic coverage. Authored YAML and provenance are unchanged." + } +} + +function Get-AnalyticRuleContentTemplateIndex { + param( + [Parameter(Mandatory = $true)] + [psobject]$Template + ) + + $index = @{} + foreach ($variableProperty in $Template.variables.PSObject.Properties) { + $variableValue = $variableProperty.Value + if ($null -eq $variableValue -or $null -eq $variableValue.PSObject) { + continue + } + + foreach ($contentIdProperty in $variableValue.PSObject.Properties) { + if ($contentIdProperty.Name -like '_analyticRulecontentId*' -and + -not [string]::IsNullOrWhiteSpace([string]$contentIdProperty.Value)) { + $index[[string]$contentIdProperty.Value] = $variableProperty.Name + } + } + } + + return $index +} + +function Set-AnalyticRuleE5Condition { + param( + [Parameter(Mandatory = $true)] + [psobject]$Template, + [Parameter(Mandatory = $true)] + [string]$SourceId, + [Parameter(Mandatory = $true)] + [hashtable]$AnalyticRuleIndex + ) + + $variableName = $AnalyticRuleIndex[$SourceId] + if ([string]::IsNullOrWhiteSpace($variableName)) { + throw "No AnalyticsRule variable was found for Custom Detection source id '$SourceId'." + } + + $matched = $false + $variableReference = "variables('$variableName')" + foreach ($resource in $Template.resources) { + if ($resource.properties.contentKind -ne 'AnalyticsRule' -or + -not ([string]$resource.name).Contains($variableReference)) { + continue + } + + if ($null -ne $resource.condition -and $resource.condition -ne "[not(parameters('E5Flavor'))]") { + $existingCondition = ([string]$resource.condition).Trim('[', ']') + $resource.condition = "[and(not(parameters('E5Flavor')), $existingCondition)]" + } + else { + $resource | Add-Member -MemberType NoteProperty -Name condition -Value "[not(parameters('E5Flavor'))]" -Force + } + $matched = $true + } + + if (-not $matched) { + throw "No AnalyticsRule content template resource was found for Custom Detection source id '$SourceId'." + } +} + +function New-CustomDetectionInstallDeployment { + param( + [Parameter(Mandatory = $true)] + [string]$DeploymentName, + [Parameter(Mandatory = $true)] + [psobject]$DetectionResource, + [Parameter(Mandatory = $true)] + [string]$ExtensionVersion + ) + + $deploymentDetection = Copy-CustomDetectionObject -InputObject $DetectionResource + $deploymentDetection.properties.id = ConvertTo-CustomDetectionArmLiteral -Value ([string]$deploymentDetection.properties.id) + foreach ($mappingGroup in $deploymentDetection.properties.detectionAction.alertTemplate.entityMappings.PSObject.Properties) { + foreach ($mapping in @($mappingGroup.Value)) { + if (-not [string]::IsNullOrWhiteSpace([string]$mapping.id)) { + $mapping.id = ConvertTo-CustomDetectionArmLiteral -Value ([string]$mapping.id) + } + } + } + + return [pscustomobject]@{ + type = 'Microsoft.Resources/deployments' + apiVersion = '2025-04-01' + name = $DeploymentName + condition = "[parameters('DeployCustomDetection')]" + dependsOn = @( + "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" + ) + properties = [pscustomobject]@{ + mode = 'Incremental' + expressionEvaluationOptions = [pscustomobject]@{ scope = 'inner' } + template = [pscustomobject]@{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' + languageVersion = '2.0' + contentVersion = '1.0.0.0' + imports = [pscustomobject]@{ + MicrosoftSecurity = [pscustomobject]@{ + provider = 'MicrosoftSecurity' + version = $ExtensionVersion + } + } + resources = [pscustomobject]@{ detectionRule = $deploymentDetection } + } + } + } +} + +function New-CustomDetectionRegistration { + param( + [Parameter(Mandatory = $true)] + [string]$DeploymentName, + [Parameter(Mandatory = $true)] + [psobject]$DetectionResource, + [Parameter(Mandatory = $true)] + [psobject]$DetectionDocument, + [Parameter(Mandatory = $true)] + [string]$ContentVersion, + [Parameter(Mandatory = $true)] + [string]$ExtensionVersion + ) + + $contentId = [string]$DetectionResource.properties.id + $escapedContentId = $contentId.Replace("'", "''") + $escapedVersion = $ContentVersion.Replace("'", "''") + $productId = "[concat(take(variables('_solutionId'),50),'-','cd','-', uniqueString(concat(variables('_solutionId'),'-','CustomDetection','-','$escapedContentId','-','$escapedVersion')))]" + $registrationDetection = Copy-CustomDetectionObject -InputObject $DetectionResource + $registrationDetection.properties.id = ConvertTo-CustomDetectionArmLiteral -Value ([string]$registrationDetection.properties.id) + foreach ($mappingGroup in $registrationDetection.properties.detectionAction.alertTemplate.entityMappings.PSObject.Properties) { + foreach ($mapping in @($mappingGroup.Value)) { + if (-not [string]::IsNullOrWhiteSpace([string]$mapping.id)) { + $mapping.id = ConvertTo-CustomDetectionArmLiteral -Value ([string]$mapping.id) + } + } + } + $alertTemplate = $DetectionResource.properties.detectionAction.alertTemplate + + $properties = [ordered]@{ + description = [string]$alertTemplate.description + mainTemplate = [pscustomobject]@{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' + languageVersion = '2.0' + contentVersion = '1.0.0' + imports = [pscustomobject]@{ + MicrosoftSecurity = [pscustomobject]@{ + provider = 'MicrosoftSecurity' + version = $ExtensionVersion + } + } + resources = [pscustomobject]@{ detectionRule = $registrationDetection } + } + packageKind = 'Solution' + packageVersion = "[variables('_solutionVersion')]" + packageName = "[variables('_solutionName')]" + packageId = "[variables('_solutionId')]" + contentSchemaVersion = '3.0.0' + contentId = $contentId + contentKind = 'CustomDetection' + displayName = [string]$DetectionResource.properties.displayName + contentProductId = $productId + id = $productId + version = $ContentVersion + } + + $requiredWorkloads = @($DetectionDocument.contentProvenance.conversion.requiredWorkloads) + if ($requiredWorkloads.Count -gt 0) { + $properties['dependency'] = [pscustomobject]@{ + requiredWorkloads = $requiredWorkloads + } + } + + return [pscustomobject]@{ + type = 'Microsoft.OperationalInsights/workspaces/providers/contentTemplates' + apiVersion = '2023-04-01-preview' + name = "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/',concat(parameters('workspace'),'-cd-',uniquestring('$escapedContentId')))]" + location = "[parameters('workspace-location')]" + condition = "[parameters('DeployCustomDetection')]" + dependsOn = @( + "[resourceId('Microsoft.Resources/deployments', '$DeploymentName')]", + "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" + ) + properties = [pscustomobject]$properties + } +} + +function Set-XdrCustomDetectionDependencyCriteria { + param( + [Parameter(Mandatory = $true)] + [psobject]$Template, + [Parameter(Mandatory = $true)] + [object[]]$Criteria + ) + + $contentPackages = @($Template.resources | Where-Object { + ([string]$_.type) -ieq 'Microsoft.OperationalInsights/workspaces/providers/contentPackages' + }) + if ($contentPackages.Count -ne 1) { + throw "Expected exactly one solution contentPackages resource while adding Custom Detection dependencies; found $($contentPackages.Count)." + } + + $dependencies = $contentPackages[0].properties.dependencies + if ($null -eq $dependencies -or $null -eq $dependencies.PSObject.Properties['criteria']) { + throw 'The solution contentPackages resource has no dependency criteria to extend with Custom Detections.' + } + + $sentinelCriteria = @($dependencies.criteria | Where-Object { + ([string]$_.kind) -ine 'CustomDetection' + }) + $dependencies.criteria = @($sentinelCriteria) + @($Criteria) +} + +function Add-XdrCustomDetectionsToSolution { + param( + [Parameter(Mandatory = $true)] + [string]$SolutionName, + [Parameter(Mandatory = $true)] + [psobject]$ContentToImport, + [Parameter(Mandatory = $true)] + [psobject]$Template + ) + + $detectionProperty = Get-CustomDetectionDataProperty -ContentToImport $ContentToImport + if ($null -eq $detectionProperty -or @($detectionProperty.Value).Count -eq 0) { + return 0 + } + + if ($null -eq (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue)) { + Import-Module powershell-yaml -ErrorAction Stop + } + + $registrationProperty = $ContentToImport.PSObject.Properties | + Where-Object { $_.Name -ieq 'Include XDR Content Registration' } | + Select-Object -First 1 + if ($null -ne $registrationProperty) { + Write-Warning 'V3.1 CONTENT SELECTION: Include XDR Content Registration is deprecated and ignored (including false). Installation and registration are both emitted under DeployCustomDetection; live provider registration support remains unverified.' + } + else { + Write-Warning 'V3.1 CONTENT SELECTION: Custom Detection installation and registration are both emitted under DeployCustomDetection; live provider registration support remains unverified.' + } + + $extensionVersion = '1.0.0' + $extensionVersionProperty = $ContentToImport.PSObject.Properties | + Where-Object { $_.Name -ieq 'XDR Extension Version' } | + Select-Object -First 1 + if ($null -ne $extensionVersionProperty -and -not [string]::IsNullOrWhiteSpace([string]$extensionVersionProperty.Value)) { + $extensionVersion = [string]$extensionVersionProperty.Value + } + + $defaultContentVersion = [string]$ContentToImport.Version + $contentVersionProperty = $ContentToImport.PSObject.Properties | + Where-Object { $_.Name -ieq 'XDR Detection Version' } | + Select-Object -First 1 + if ($null -ne $contentVersionProperty -and -not [string]::IsNullOrWhiteSpace([string]$contentVersionProperty.Value)) { + $defaultContentVersion = [string]$contentVersionProperty.Value + } + + $repoRoot = git rev-parse --show-toplevel + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { + throw 'Unable to resolve the repository root while packaging Custom Detections.' + } + + $analyticRuleIndex = Get-AnalyticRuleContentTemplateIndex -Template $Template + $parserNames = @(Get-CustomDetectionParserNames -SolutionPath (Join-Path $repoRoot "Solutions/$SolutionName")) + $seenIds = @{} + $dependencyCriteria = @() + $count = 0 + foreach ($configuredPath in @($detectionProperty.Value)) { + $detectionPath = Resolve-CustomDetectionPath -RepositoryRoot $repoRoot -SolutionName $SolutionName -ConfiguredPath ([string]$configuredPath) + if (-not (Test-Path -LiteralPath $detectionPath -PathType Leaf)) { + throw "Custom Detection file not found: $detectionPath" + } + + $detectionDocument = Get-Content -LiteralPath $detectionPath -Raw | + ConvertFrom-Yaml -ErrorAction Stop | + ConvertTo-Json -Depth 100 | + ConvertFrom-Json + + if ($detectionDocument.kind -ne 'CustomDetection' -or + $detectionDocument.resourceType -ne 'Microsoft.Security/detectionRules') { + throw "Custom Detection '$configuredPath' must declare kind CustomDetection and resourceType Microsoft.Security/detectionRules." + } + + $sourceId = [string]$detectionDocument.contentProvenance.source.id + $detectionId = [string]$detectionDocument.properties.id + if ([string]::IsNullOrWhiteSpace($sourceId) -or [string]::IsNullOrWhiteSpace($detectionId)) { + throw "Custom Detection '$configuredPath' must contain contentProvenance.source.id and properties.id." + } + $sentinelDerived = ( + ($detectionDocument.contentProvenance.source.platform -ceq 'Microsoft Sentinel' -and + $detectionDocument.contentProvenance.source.kind -ceq 'AnalyticsRule') -or + $detectionDocument.contentProvenance.conversion.tool -ceq 'sentinel-to-xdr-migration' + ) + if ($sentinelDerived -and ( + $sourceId -cnotmatch '\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z' -or + [string]::IsNullOrWhiteSpace($detectionId))) { + throw "Sentinel-derived Custom Detection '$configuredPath' must contain its full originating Sentinel template GUID in contentProvenance.source.id and a nonempty properties.id." + } + $identity = ConvertTo-V31CustomDetectionId -DetectionId $detectionId ` + -SourceId $sourceId -SentinelDerived $sentinelDerived -ContentPath $configuredPath + $inputDetectionId = $detectionId + $detectionId = [string]$identity.id + if ($seenIds.ContainsKey($detectionId)) { + throw "Duplicate Custom Detection id '$detectionId' after canonical ID normalization. Bare-GUID and xdr-prefixed copies of the same rule cannot be packaged together." + } + $seenIds[$detectionId] = $true + $detectionDocument.properties.id = $detectionId + $identityEvidence = [ordered]@{ + sourceId = $sourceId + inputId = $inputDetectionId + packagedId = $detectionId + changed = [bool]$identity.changed + scope = 'package-projection-only' + } + Write-Host "V3.1 ID PROJECTION: $($identityEvidence | ConvertTo-Json -Depth 10 -Compress)" + if ($identity.changed) { + Write-Host "INFORMATION: V3.1 ID NORMALIZATION: Custom Detection '$inputDetectionId' is emitted as '$detectionId' for Microsoft Graph compatibility; source/provenance IDs and YAML remain unchanged. This package projection does not update or migrate deployed rules; verify existing identities before any separately approved deployment." + } + + if ([string]::IsNullOrWhiteSpace([string]$detectionDocument.properties.queryCondition.queryText)) { + throw "Custom Detection '$detectionId' has no queryCondition.queryText." + } + if (-not $v31VersionPolicy -and @($detectionDocument.properties.detectionAction.alertTemplate.tactics).Count -gt 1) { + throw "Custom Detection '$detectionId' contains more than one tactic; the service supports exactly one." + } + if ($null -eq $detectionDocument.properties.detectionAction.alertTemplate.entityMappings) { + throw "Custom Detection '$detectionId' has no entity mappings." + } + + $detectionDocument.properties.status = 'disabled' + Set-CustomDetectionParserBindings -DetectionDocument $detectionDocument -ParserNames $parserNames + $resourceType = "$($detectionDocument.resourceType)@$($detectionDocument.apiVersion)" + $detectionResource = [pscustomobject]@{ + import = 'MicrosoftSecurity' + type = $resourceType + properties = Copy-CustomDetectionObject -InputObject $detectionDocument.properties + } + if ($v31VersionPolicy) { + Set-V31CustomDetectionTactics -DetectionResource $detectionResource -DetectionId $detectionId + } + + $variableName = $analyticRuleIndex[$sourceId] + $matches = @($Template.resources | Where-Object { + $_.properties.contentKind -eq 'AnalyticsRule' -and + $variableName -and ([string]$_.name).Contains("variables('$variableName')") + }) + if ($matches.Count -ne 1) { + throw "Expected exactly one AnalyticsRule content template for Custom Detection source id '$sourceId'; found $($matches.Count)." + } + + $deploymentName = Get-CustomDetectionDeploymentName -SolutionName $SolutionName -DetectionId $detectionId + $Template.resources += New-CustomDetectionInstallDeployment -DeploymentName $deploymentName -DetectionResource $detectionResource -ExtensionVersion $extensionVersion + + $contentVersion = [string]$detectionDocument.version + if ($v31VersionPolicy) { + Assert-V31ReleaseVersion -Version $contentVersion -ContentKind CustomDetection -ContentPath $configuredPath + } + elseif ($null -eq $detectionDocument.PSObject.Properties['version']) { + # Retain old-document compatibility only outside the V3.1 entry point. + $contentVersion = [string]$detectionDocument.contentProvenance.source.version + if ([string]::IsNullOrWhiteSpace($contentVersion)) { + $contentVersion = $defaultContentVersion + } + } + $registration = New-CustomDetectionRegistration -DeploymentName $deploymentName -DetectionResource $detectionResource -DetectionDocument $detectionDocument -ContentVersion $contentVersion -ExtensionVersion $extensionVersion + $Template.resources += $registration + $dependencyCriteria += [pscustomobject]@{ + kind = 'CustomDetection' + contentId = [string]$registration.properties.contentId + version = [string]$registration.properties.version + } + $count++ + } + + Set-XdrCustomDetectionDependencyCriteria -Template $Template -Criteria $dependencyCriteria + Write-Host "Added $count XDR Custom Detection(s) to the hybrid solution template." + return $count +} diff --git a/Tools/Create-Azure-Sentinel-Solution/common/customerUsageAttribution.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/customerUsageAttribution.ps1 new file mode 100644 index 00000000000..0812707bd06 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/customerUsageAttribution.ps1 @@ -0,0 +1,206 @@ +function Assert-CustomerUsageTrackingId { + param($TrackingId, [string]$Source) + + if ($TrackingId -isnot [string] -or $TrackingId.Length -gt 64 -or + $TrackingId -cnotmatch '\Apid-[A-Za-z0-9][A-Za-z0-9_.()-]*\z') { + throw "Invalid trackingId in '$Source'. Copy the complete pid- tracking ID from Partner Center unchanged (not a bare GUID, Partner ID, or plan ID). It must be a literal deployment name of at most 64 characters using letters, digits, hyphens, underscores, periods, or parentheses." + } +} + +function Get-AttributionRemoteJson { + param([string]$Uri, [string]$Stage) + + try { + # No credentials, interactive authentication, retries, or unbounded redirects. + $timeouts = @{ TimeoutSec = 20 } + if ((Get-Command Microsoft.PowerShell.Utility\Invoke-WebRequest).Parameters.ContainsKey('OperationTimeoutSeconds')) { + $timeouts.OperationTimeoutSeconds = 20 + } + $response = Invoke-WebRequest -Uri $Uri -Method Get @timeouts -MaximumRedirection 0 -ErrorAction Stop + } + catch { + $status = $_.Exception.Response.StatusCode + $classification = if ($status) { "HTTP $([int]$status)" } + elseif ($_.Exception.ToString() -match 'Timeout|timed out|canceled') { 'timeout' } + else { 'network/read failure' } + # Exception text can include a protected artifact URI or its SAS query. + throw "${Stage}: $classification" + } + try { + # ConvertFrom-Json alone accepts repeated keys by keeping the last value. + # Reject those documents rather than hiding an ambiguous offer or marker. + $json = [System.Text.Json.JsonDocument]::Parse([string]$response.Content) + try { + $pending = [System.Collections.Generic.Stack[System.Text.Json.JsonElement]]::new() + $pending.Push($json.RootElement) + while ($pending.Count -gt 0) { + $element = $pending.Pop() + if ($element.ValueKind -eq [System.Text.Json.JsonValueKind]::Object) { + $names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($property in $element.EnumerateObject()) { + if (-not $names.Add($property.Name)) { throw 'Duplicate JSON property' } + $pending.Push($property.Value) + } + } + elseif ($element.ValueKind -eq [System.Text.Json.JsonValueKind]::Array) { + foreach ($item in $element.EnumerateArray()) { $pending.Push($item) } + } + } + } + finally { $json.Dispose() } + return ConvertFrom-Json -InputObject $response.Content -AsHashtable -NoEnumerate -ErrorAction Stop + } + catch { throw "${Stage}: invalid JSON" } +} + +function Resolve-PublishedCustomerUsageAttribution { + param($Metadata) + + $publisher = $Metadata.publisherId + $offer = $Metadata.offerId + if ($publisher -isnot [string] -or [string]::IsNullOrWhiteSpace($publisher) -or + $offer -isnot [string] -or [string]::IsNullOrWhiteSpace($offer)) { + throw 'catalog identity: publisherId and offerId are required' + } + $filter = "publisherId eq '$($publisher.Replace("'", "''"))' and offerId eq '$($offer.Replace("'", "''"))'" + $uri = 'https://catalogapi.azure.com/offers?api-version=2018-08-01-beta&$filter=' + [uri]::EscapeDataString($filter) + $catalog = Get-AttributionRemoteJson -Uri $uri -Stage catalog + if ($catalog -isnot [System.Collections.IDictionary] -or $catalog.items -isnot [array] -or + $catalog.nextLink -or $catalog.nextPageLink -or $catalog.'@odata.nextLink' -or $catalog.continuationToken) { + throw 'catalog shape: expected a complete items array' + } + $offers = @($catalog.items | Where-Object { $_.publisherId -ceq $publisher -and $_.offerId -ceq $offer }) + if ($offers.Count -ne 1) { throw "catalog match: expected one exact publisher/offer match, found $($offers.Count)" } + if ($offers[0].plans -isnot [array]) { throw 'plan shape: expected a plans array' } + $plans = @($offers[0].plans | Where-Object { + @($_.artifacts | Where-Object { $_.type -ceq 'Template' -and $_.name -ceq 'DefaultTemplate' }).Count -gt 0 + }) + if ($plans.Count -ne 1) { throw "plan selection: expected one applicable plan, found $($plans.Count)" } + $plan = $plans[0] + if ($plan.planId -isnot [string] -or [string]::IsNullOrWhiteSpace($plan.planId)) { + throw 'plan identity: missing planId' + } + $artifacts = @($plan.artifacts | Where-Object { $_.type -ceq 'Template' -and $_.name -ceq 'DefaultTemplate' }) + if ($artifacts.Count -ne 1) { throw "template artifact: expected one DefaultTemplate, found $($artifacts.Count)" } + $templateUri = $null + if ($artifacts[0].uri -isnot [string] -or + -not [uri]::TryCreate($artifacts[0].uri, [UriKind]::Absolute, [ref]$templateUri) -or + $templateUri.Scheme -ne 'https' -or $templateUri.UserInfo -or $templateUri.Fragment) { + throw 'template artifact: expected one absolute HTTPS URI without embedded credentials or fragment' + } + $document = Get-AttributionRemoteJson -Uri $templateUri.AbsoluteUri -Stage template + $schemaPattern = '\Ahttps?://schema\.management\.azure\.com/schemas/[0-9]{4}-[0-9]{2}-[0-9]{2}/deploymentTemplate\.json#?\z' + $versionPattern = '\A[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\z' + if ($document -isnot [System.Collections.IDictionary] -or + $document.'$schema' -isnot [string] -or $document.'$schema' -notmatch $schemaPattern -or + $document.contentVersion -isnot [string] -or $document.contentVersion -notmatch $versionPattern) { + throw 'template shape: expected an ARM deployment template object' + } + if ($document.resources -is [array]) { + $resources = @($document.resources) + } + elseif ($document.languageVersion -ceq '2.0' -and $document.resources -is [System.Collections.IDictionary]) { + $resources = @($document.resources.Values) + } + else { throw 'template shape: expected a resources array or languageVersion 2.0 symbolic resource object' } + if (@($resources | Where-Object { $_ -isnot [System.Collections.IDictionary] }).Count -gt 0) { + throw 'template shape: root resources must be resource objects' + } + $markers = @($resources | Where-Object { + $_.type -ieq 'Microsoft.Resources/deployments' -and $_.name -is [string] -and $_.name -clike 'pid-*' + }) + if ($markers.Count -ne 1) { throw "template marker: expected one root pid- deployment, found $($markers.Count)" } + $marker = $markers[0] + Assert-CustomerUsageTrackingId -TrackingId $marker.name -Source 'published template' + $inline = $marker.properties.template + if ($marker.properties -isnot [System.Collections.IDictionary] -or + $marker.properties.mode -cne 'Incremental' -or $marker.apiVersion -isnot [string] -or + $marker.apiVersion -notmatch '\A[0-9]{4}-[0-9]{2}-[0-9]{2}(-preview)?\z' -or + @($marker.Keys | Where-Object { $_ -notin @('type', 'apiVersion', 'name', 'properties') }).Count -gt 0 -or + @($marker.properties.Keys | Where-Object { $_ -notin @('mode', 'template') }).Count -gt 0 -or + $inline -isnot [System.Collections.IDictionary] -or + @($inline.Keys | Where-Object { $_ -notin @('$schema', 'contentVersion', 'resources') }).Count -gt 0 -or + $inline.'$schema' -isnot [string] -or $inline.'$schema' -notmatch $schemaPattern -or + $inline.contentVersion -isnot [string] -or $inline.contentVersion -notmatch $versionPattern -or + $inline.resources -isnot [array] -or $inline.resources.Count -ne 0) { + throw 'template marker: not an unconditional empty inline attribution deployment (functional wrappers are not attribution)' + } + return [pscustomobject]@{ + TrackingId = $marker.name + PublisherId = $publisher + OfferId = $offer + PlanId = $plan.planId + # Never log a protected URI's query (including SAS credentials). + TemplateUrl = $templateUri.GetLeftPart([UriPartial]::Path) + } +} + +function Add-CustomerUsageAttribution { + param( + [Parameter(Mandatory = $true)] + [string]$SolutionMetadataPath, + [Parameter(Mandatory = $true)] + [psobject]$Template, + [bool]$WarnIfMissing = $false, + [switch]$SkipAttributionLookup + ) + + # Consolidated Data metadata is not authoritative for this packaging-only setting. + $snapshot = [System.IO.File]::ReadAllBytes($SolutionMetadataPath) + $metadata = [System.Text.Encoding]::UTF8.GetString($snapshot).TrimStart([char]0xfeff) | + ConvertFrom-Json -ErrorAction Stop + $trackingId = $metadata.trackingId + $discovered = $null + if ($null -eq $trackingId -or ($trackingId -is [string] -and [string]::IsNullOrWhiteSpace($trackingId))) { + if (-not $SkipAttributionLookup -and $env:SENTINEL_SKIP_ATTRIBUTION_LOOKUP -ne '1') { + try { + $discovered = Resolve-PublishedCustomerUsageAttribution -Metadata $metadata + $trackingId = $discovered.TrackingId + } + catch { + Write-Warning "CUSTOMER USAGE ATTRIBUTION: Published lookup failed [$($_.Exception.Message)]. No attribution marker will be emitted; SolutionMetadata.json is unchanged. Supply the complete trackingId from Partner Center > offer > plan > Technical configuration and rebuild." + } + } + } + if ($null -eq $trackingId -or ($trackingId -is [string] -and [string]::IsNullOrWhiteSpace($trackingId))) { + if ($WarnIfMissing) { + Write-Warning "CUSTOMER USAGE ATTRIBUTION: No attribution marker will be emitted because trackingId is absent or blank in '$SolutionMetadataPath'. Partner Center cannot automatically add tracking when the template contains functional nested deployments (including XDR). Obtain the COMPLETE customer usage attribution tracking ID from Partner Center > offer > plan > Technical configuration, add trackingId to SolutionMetadata.json, and rebuild. Do not use a Partner ID, plan ID, or a newly generated GUID. See https://learn.microsoft.com/partner-center/marketplace-offers/azure-partner-customer-usage-attribution#microsoft-marketplace-azure-apps" + } + return + } + + Assert-CustomerUsageTrackingId -TrackingId $trackingId -Source $SolutionMetadataPath + + if (@($Template.resources | Where-Object { + $_.type -eq 'Microsoft.Resources/deployments' -and $_.name -eq $trackingId + }).Count -gt 0) { + throw "Duplicate customer usage attribution deployment name '$trackingId'." + } + + if ($discovered) { + try { + if (-not ('AttributionMetadata' -as [type])) { + Add-Type -Path (Join-Path $PSScriptRoot 'AttributionMetadata.cs') -ErrorAction Stop + } + [AttributionMetadata]::Save($SolutionMetadataPath, $snapshot, $trackingId) + } + catch { + throw "Customer usage attribution metadata write failed for '$SolutionMetadataPath'; no marker emitted: $($_.Exception.Message)" + } + Write-Host "CUSTOMER USAGE ATTRIBUTION SOURCE: anonymous public Marketplace catalog/template; publisherId=$($discovered.PublisherId); offerId=$($discovered.OfferId); planId=$($discovered.PlanId); templateURL=$($discovered.TemplateUrl); trackingId=$trackingId; updated metadata=$SolutionMetadataPath" + } + + $Template.resources = @($Template.resources) + @([pscustomobject]@{ + type = 'Microsoft.Resources/deployments' + apiVersion = '2025-04-01' + name = $trackingId + properties = [pscustomobject]@{ + mode = 'Incremental' + template = [pscustomobject]@{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' + contentVersion = '1.0.0.0' + resources = @() + } + } + }) +} diff --git a/Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1 index eac1bcf3340..fff5add11b1 100644 --- a/Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1 @@ -1,27 +1,45 @@ +function Read-ContainedSolutionJson($filePath, $solutionRoot, $solutionName) { + $relativePath = [System.IO.Path]::GetRelativePath( + [System.IO.Path]::GetFullPath($solutionRoot), + [System.IO.Path]::GetFullPath($filePath) + ) + $resolved = Resolve-SolutionContentPath ` + -SolutionRoot $solutionRoot ` + -SolutionName $solutionName ` + -ContentPath $relativePath ` + -RequireFile + return ReadFileContent -filePath $resolved.FullPath +} function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFolderName) { try { + $dataFileMetadata = Convert-SolutionDataContentArrays -Content $dataFileMetadata $ccpDict = @() $dataConnectorsInputArray = $dataFileMetadata.PsObject.Properties | Where-Object { $_.Name -eq "$DCFolderName" }; + $solutionRoot = Join-Path -Path $baseFolderPath -ChildPath $solutionName + $resolvedConnectorFolder = Resolve-SolutionContentPath ` + -SolutionRoot $solutionRoot ` + -SolutionName $solutionName ` + -ContentPath $DCFolderName + $identifiedDCPath = $resolvedConnectorFolder.FullPath # IDENTIFY CCP DATA DEFINITION IN DATA INPUT FILE foreach ($objectProperties in $dataConnectorsInputArray) { $items = $objectProperties.Value - if ($items -is [System.String]) { - $items = $items | ConvertFrom-Json + if ($items -isnot [array]) { + throw "Solution Data content array '$DCFolderName' must be a JSON-encoded or native array." } - foreach ($file in $items) { - $file = $file.Replace("$baseFolderPath/", "").Replace("Solutions/", "").Replace("$solutionName/", "") - - $currentFileDCPath = ($baseFolderPath + $solutionName + "/" + $file).Replace("//", "/") + $resolvedDefinition = Resolve-SolutionContentPath ` + -SolutionRoot $solutionRoot ` + -SolutionName $solutionName ` + -ContentPath $file ` + -RequireFile + $file = $resolvedDefinition.RelativePath + $currentFileDCPath = $resolvedDefinition.FullPath $ccpBaseFolderPath = (Split-Path -Path $currentFileDCPath -Parent) -replace '\\', '/' - # Resolve to absolute path for proper comparison later - $ccpBaseFolderPath = (Resolve-Path -Path $ccpBaseFolderPath).Path -replace '\\', '/' Write-Host "currentFileDCPath $currentFileDCPath, ccpBaseFolderPath $ccpBaseFolderPath" - #$fileContent = Get-Content -Raw $currentFileDCPath | Out-String | ConvertFrom-Json - - $fileContent = ReadFileContent -filePath "$currentFileDCPath" + $fileContent = Read-ContainedSolutionJson -filePath $currentFileDCPath -solutionRoot $solutionRoot -solutionName $solutionName if ($null -eq $fileContent) { exit 1; } @@ -126,12 +144,10 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold # identify ccp files definition provided has corresponding poller files if no then fail it. if ($ccpDict.Count -gt 0) { - $identifiedDCPath = ($baseFolderPath + $solutionName + "/" + $DCFolderName).Replace("//", "/") - # identify relation between definition and poller files foreach ($ccpDefinitionFile in $ccpDict) { #identify given file is present in dc folder or not - foreach ($inputFile in $(Get-ChildItem -Path $identifiedDCPath -Include *.json -Recurse)) { + foreach ($inputFile in $(Get-ChildItem -LiteralPath $identifiedDCPath -Filter '*.json' -File -Recurse -ErrorAction Stop)) { $currentFolderPath = $inputFile.DirectoryName -replace '\\', '/' if ($currentFolderPath -ne $ccpDefinitionFile.CCPBaseFolder -or ($inputFile.Extension -eq ".md" -or $inputFile.Extension -eq ".txt" -or $inputFile.Extension -eq ".py" -or $inputFile.Extension -eq ".zip" -or @@ -143,7 +159,7 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold try { #$fileContent = Get-Content -Raw $inputFile.FullName | Out-String | ConvertFrom-Json - $fileContent = ReadFileContent -filePath $inputFile.FullName + $fileContent = Read-ContainedSolutionJson -filePath $inputFile.FullName -solutionRoot $solutionRoot -solutionName $solutionName if ($null -eq $fileContent) { exit 1; } @@ -235,7 +251,7 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold # identify relation between poller and DCR foreach ($ccpPollerFile in $ccpDict) { - foreach ($inputFile in $(Get-ChildItem -Path $identifiedDCPath -Include *.json -Recurse)) { + foreach ($inputFile in $(Get-ChildItem -LiteralPath $identifiedDCPath -Filter '*.json' -File -Recurse -ErrorAction Stop)) { $currentFolderPath = $inputFile.DirectoryName -replace '\\', '/' if ($currentFolderPath -ne $ccpPollerFile.CCPBaseFolder -or $inputFile.Extension -eq ".md" -or $inputFile.Extension -eq ".txt" -or $inputFile.Extension -eq ".py" -or $inputFile.Extension -eq ".zip" -or @@ -245,7 +261,7 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold } else { #$fileContent = Get-Content -Raw $inputFile.FullName | Out-String | ConvertFrom-Json - $fileContent = ReadFileContent -filePath $inputFile.FullName + $fileContent = Read-ContainedSolutionJson -filePath $inputFile.FullName -solutionRoot $solutionRoot -solutionName $solutionName if ($null -eq $fileContent) { exit 1; } @@ -339,7 +355,7 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold # identify relation between DCR and table foreach ($ccpTable in $ccpDict) { - foreach ($inputFile in $(Get-ChildItem -Path $identifiedDCPath -Include *.json -Recurse)) { + foreach ($inputFile in $(Get-ChildItem -LiteralPath $identifiedDCPath -Filter '*.json' -File -Recurse -ErrorAction Stop)) { $currentFolderPath = $inputFile.DirectoryName -replace '\\', '/' if ($currentFolderPath -ne $ccpTable.CCPBaseFolder -or $inputFile.Extension -eq ".md" -or $inputFile.Extension -eq ".txt" -or $inputFile.Extension -eq ".py" -or $inputFile.Extension -eq ".zip" -or @@ -349,7 +365,7 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold } else { #$fileContent = Get-Content -Raw $inputFile.FullName | Out-String | ConvertFrom-Json - $fileContent = ReadFileContent -filePath $inputFile.FullName + $fileContent = Read-ContainedSolutionJson -filePath $inputFile.FullName -solutionRoot $solutionRoot -solutionName $solutionName if ($null -eq $fileContent) { exit 1; } @@ -395,18 +411,25 @@ function Get-CCP-Dict($dataFileMetadata, $baseFolderPath, $solutionName, $DCFold return $ccpDict; } catch { - Write-Host "Error in get-ccpdetails. Error Details: $_" -ForegroundColor Red - return $null; + throw "Error in get-ccpdetails. Error Details: $_" } } function GetCCPTableFilePaths($existingCCPDict, $baseFolderPath, $solutionName, $DCFolderName) { # call this only when there are atleast 1 ccp connector $ccpTablesFilePaths = @() - $identifiedDCPath = ($baseFolderPath + $solutionName + "/" + $DCFolderName).Replace("//", "/") + $solutionRoot = Join-Path -Path $baseFolderPath -ChildPath $solutionName + $resolvedConnectorFolder = Resolve-SolutionContentPath ` + -SolutionRoot $solutionRoot ` + -SolutionName $solutionName ` + -ContentPath $DCFolderName + $identifiedDCPath = $resolvedConnectorFolder.FullPath + if (-not (Test-Path -LiteralPath $identifiedDCPath -PathType Container)) { + throw "Solution Data Connector folder does not exist: $identifiedDCPath" + } #$currentFileDCPath = ($baseFolderPath + $solutionName + "/" + $identifiedDCPath).Replace("//", "/") - foreach ($inputFile in $(Get-ChildItem -Path $identifiedDCPath -Include *.json -Recurse)) { + foreach ($inputFile in $(Get-ChildItem -LiteralPath $identifiedDCPath -Filter '*.json' -File -Recurse -ErrorAction Stop)) { if ($inputFile.Extension -eq ".md" -or $inputFile.Extension -eq ".txt" -or $inputFile.Extension -eq ".py" -or $inputFile.Extension -eq ".zip" -or $inputFile.Name -eq "Images" -or $inputFile.Name -eq "function.json" -or $inputFile.Name -eq "host.json" -or $inputFile.Name -eq "proxies.json") { @@ -414,7 +437,7 @@ function GetCCPTableFilePaths($existingCCPDict, $baseFolderPath, $solutionName, } else { #$fileContent = Get-Content -Raw $inputFile.FullName | Out-String | ConvertFrom-Json - $fileContent = ReadFileContent -filePath $inputFile.FullName + $fileContent = Read-ContainedSolutionJson -filePath $inputFile.FullName -solutionRoot $solutionRoot -solutionName $solutionName if ($null -eq $fileContent) { exit 1; } diff --git a/Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1 index 4a1b4cd8af8..07c424177f2 100644 --- a/Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1 @@ -46,6 +46,7 @@ function GenerateSummaryRules($solutionName, $file, $rawData, $contentResourceDe $requiredDataConnectors = $yaml.requiredDataConnectors $type = 'Microsoft.OperationalInsights/workspaces/summaryLogs' $calculatedSummaryRuleVersion = ($null -ne $yaml.version) ? "$($yaml.version)" : "1.0.0" + Assert-V31ReleaseVersion -Version $calculatedSummaryRuleVersion -ContentKind SummaryRule -ContentPath $file $objSummaryRules = [PSCustomObject]@{} $objSummaryRules | Add-Member -NotePropertyName "summaryRuleName$global:summaryRuleCounter" -NotePropertyValue "[concat(parameters('workspace'),'/','$($displayName)')]" diff --git a/Tools/Create-Azure-Sentinel-Solution/common/versionPolicy.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/versionPolicy.ps1 new file mode 100644 index 00000000000..39e0ace9e18 --- /dev/null +++ b/Tools/Create-Azure-Sentinel-Solution/common/versionPolicy.ps1 @@ -0,0 +1,89 @@ +function Assert-V31ReleaseVersion { + param( + [AllowNull()] + [AllowEmptyString()] + [string]$Version, + [string]$ContentKind, + [string]$ContentPath + ) + + if (-not $v31VersionPolicy) { return } + + $minimum = if ($ContentKind -in @('Solution', 'CustomDetection')) { [version]'3.1.0.0' } else { [version]'0.0.0.0' } + $range = if ($ContentKind -in @('Solution', 'CustomDetection')) { '>= 3.1.0 and < 4.0.0' } else { '< 4.0.0' } + $parsed = $null + $valid = $Version -cmatch '\A[0-9]+\.[0-9]+(?:\.[0-9]+){0,2}\z' -and + [version]::TryParse($Version, [ref]$parsed) + if ($ContentKind -eq 'CustomDetection') { + $valid = $valid -and $Version -cmatch '\A3\.[1-9][0-9]*\.(?:0|[1-9][0-9]*)\z' + } + if ($valid) { + $normalized = [version]::new($parsed.Major, $parsed.Minor, [Math]::Max(0, $parsed.Build), [Math]::Max(0, $parsed.Revision)) + $valid = $normalized -ge $minimum -and $normalized -lt [version]'4.0.0.0' + } + if (-not $valid) { + $message = "V3.1 version policy: $ContentKind '$ContentPath' has effective release version '$Version'; required $range (numeric release version). No version is automatically promoted." + if ($ContentKind -eq 'CustomDetection') { + $message += " Set the XDR YAML top-level version to its own major.minor.patch release. For older files without version, reconvert with --overwrite (initial version 3.1.0), or explicitly author the XDR version. Do not change contentProvenance.source.version; Data 'XDR Detection Version' and solution Version are not fallbacks in V3.1." + } + $v31VersionPolicy.Errors.Add($message) + throw $message + } +} + +function Assert-V31SolutionVersion { + param( + [string]$SolutionName, + [string]$Version, + [string]$CalculatedVersion = '' + ) + if (-not $v31VersionPolicy) { return } + Assert-V31ReleaseVersion -Version $Version -ContentKind Solution -ContentPath $SolutionName + if (-not [string]::IsNullOrEmpty($CalculatedVersion)) { + Assert-V31ReleaseVersion -Version $CalculatedVersion -ContentKind Solution -ContentPath "$SolutionName (calculated package version)" + if ($Version -cne $CalculatedVersion) { + $message = "V3.1 version policy: Solution '$SolutionName' declares version '$Version' but calculated package version is '$CalculatedVersion'. Align the input Version and calculated package version before packaging; emitted solution fields and ZIP version must agree." + $v31VersionPolicy.Errors.Add($message) + throw $message + } + } +} + +function Assert-V31TargetVersion { + param([AllowNull()][AllowEmptyString()][string]$Version) + + if ($Version -cnotmatch '\A(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\z') { + throw "V3.1 target version '$Version' must be canonical major.minor.patch in the range >= 3.1.0 and < 4.0.0." + } + $parts = $Version.Split(".") + if ($parts[0] -cne "3" -or [System.Numerics.BigInteger]::Parse($parts[1]) -lt [System.Numerics.BigInteger]::One) { + throw "V3.1 target version '$Version' must be in the range >= 3.1.0 and < 4.0.0." + } +} + +function Assert-V31VersionChecksPassed { + if ($v31VersionPolicy -and $v31VersionPolicy.Errors.Count -gt 0) { + # Some legacy content readers catch exceptions. Never emit a partial package after a failed guard. + throw ($v31VersionPolicy.Errors -join [Environment]::NewLine) + } +} + +function Save-V31LocalPackageVersion { + param([string]$DataFilePath, [string]$Version) + + $data = Get-Content -LiteralPath $DataFilePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $solutionRoot = Split-Path (Split-Path $DataFilePath -Parent) -Parent + $metadataReference = Resolve-SolutionContentPath ` + -SolutionRoot $solutionRoot ` + -SolutionName ([System.IO.Path]::GetFileName($solutionRoot)) ` + -ContentPath ([string]$data.Metadata) ` + -RequireFile + $metadataPath = $metadataReference.FullPath + $metadata = Get-Content -LiteralPath $metadataPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $data | Add-Member -NotePropertyName Version -NotePropertyValue $Version -Force + $metadata | Add-Member -NotePropertyName version -NotePropertyValue $Version -Force + $dataJson = $data | ConvertTo-Json -Depth 100 + $metadataJson = $metadata | ConvertTo-Json -Depth 100 + Set-Content -LiteralPath $DataFilePath -Value $dataJson -Encoding utf8 -ErrorAction Stop + Set-Content -LiteralPath $metadataPath -Value $metadataJson -Encoding utf8 -ErrorAction Stop +} diff --git a/Tools/Create-Azure-Sentinel-Solution/pipeline/README.md b/Tools/Create-Azure-Sentinel-Solution/pipeline/README.md index a0803f604da..da250af0815 100644 --- a/Tools/Create-Azure-Sentinel-Solution/pipeline/README.md +++ b/Tools/Create-Azure-Sentinel-Solution/pipeline/README.md @@ -75,7 +75,7 @@ Clone the repository [Azure-Sentinel](https://github.com/Azure/Azure-Sentinel) t "Version": "3.0.0", // Default version of 3.0.0. If you want create templateSpec package then change variable 'defaultPackageVersion' value in createSolutionV3.ps1 file "Metadata": "{Name of Solution Metadata file}", "Is1PConnector": false, - "createPackage": true // This is optional property. Default value is true. When set to false, a package will not be generated using V3(local) or V4(pipeline). + "createPackage": true // This is optional property. Default value is true. When set to false, a package will not be generated using V3(local) or V3.1(local/pipeline). } ``` @@ -214,9 +214,9 @@ Create a file and place it in the base path of solution `https://raw.githubuser ### Generate Solution Package -- `Package will now be created in an automated way through an automated pipeline and without the need to run any commands locally to generate the solution package. 'createSolutionV4.ps1' file is specifically for pipeline way of generating package for any of the solutions`. 'createSolutionV4.ps1' file is located at `./Tools/Create-Azure-Sentinel-Solution/pipeline` folder path. This file cannot be run locally and is meant for packaging from pipeline using Github workflows. - > **IMPORTANT:** To generate package locally make use of `createSolutionV3.ps1` which is in './Tools/Create-Azure-Sentinel-Solution/V3' folder. -- Core business logic for 'createSolutionV3.ps1' and 'createSolutionV4.ps1' files are inside of './Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1' file. +- The automated pipeline calls `Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1`. V3.1 also supports local hybrid Sentinel/XDR packaging; see the [V3.1 guide](../V3/README-V3_1.md), including optional `trackingId` customer usage attribution in SolutionMetadata.json. + > **IMPORTANT:** `V3/createSolutionV3.ps1` remains the legacy Sentinel-only local entry point. The older `pipeline/createSolutionV4.ps1` is not the current package automation entry point. +- Core business logic for V3 and V3.1 is in `Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1`. - For any of the WorkbookMetadata change make use of './Tools/Create-Azure-Sentinel-Solution/V2/WorkbookMetadata/WorkbooksMetadata.json' file. - Make sure you have data input file inside of **Data** folder and added a SolutionMetadata.json file inside in the respetcive solutions folder. - (Optional) Set the "createPackage" property in data input file. This property is optional and is set default to true. diff --git a/Tools/SentinelToXDRMigration/.env.example b/Tools/SentinelToXDRMigration/.env.example new file mode 100644 index 00000000000..157e1d8af94 --- /dev/null +++ b/Tools/SentinelToXDRMigration/.env.example @@ -0,0 +1,4 @@ +# Copy this file to Solutions//.env and replace both placeholders. +# This file is a template only. Never add tenant or workspace values here. +AZURE_TENANT_ID= +SENTINEL_WORKSPACE_RESOURCE_ID=/subscriptions//resourceGroups//providers/Microsoft.OperationalInsights/workspaces/ diff --git a/Tools/SentinelToXDRMigration/.gitignore b/Tools/SentinelToXDRMigration/.gitignore new file mode 100644 index 00000000000..ce6349edb4d --- /dev/null +++ b/Tools/SentinelToXDRMigration/.gitignore @@ -0,0 +1,9 @@ +Data/logs/* +Data/reports/* +!Data/logs/.gitkeep +!Data/reports/.gitkeep +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ diff --git a/Tools/SentinelToXDRMigration/Data/reports/.gitkeep b/Tools/SentinelToXDRMigration/Data/reports/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Tools/SentinelToXDRMigration/FOLLOW_UPS.md b/Tools/SentinelToXDRMigration/FOLLOW_UPS.md new file mode 100644 index 00000000000..ac8b6b725dc --- /dev/null +++ b/Tools/SentinelToXDRMigration/FOLLOW_UPS.md @@ -0,0 +1,48 @@ +# Tracked follow-up areas + +These are curated future-work areas, not commitments, completed capabilities, +or a release guarantee. Scope, priority, ownership, and approval remain subject +to separate review. + +This list records candidate known-limitations and follow-up work for the V1 +PREVIEW toolkit. + +## Publication security gate + +Resolve security findings and independently re-review the corrected changes +before publishing. This is a publication gate, not optional roadmap work. +External ARM-TTK/Azure validation-service limitations, when encountered, +should be reported separately from toolkit code defects and do not by +themselves establish a toolkit fix is required. + +## Candidate follow-ups + +- **Validate entity identity bindings.** Reject filename-only bindings and + synthetic text hashes where a genuine content SHA-1/SHA-256 is required; + define and test the supported identity contract. +- **Preserve YAML version text.** Keep lexical values such as `1.0` intact + through parsing, conversion, and serialization rather than silently + normalizing them. +- **Reconcile Qualification readiness.** Review existing authorization and + access checks, then separately assess deployment and ingestion permission + readiness before proposing new implementation. +- **Review private configuration and support exports.** Reconcile existing + target-configuration and support-export handling before making claims about + exposure or adding controls. +- **Improve operator visibility.** Consider concise progress and preflight + reporting, a workflow diagram, watchdog summaries, and suppression of + no-change notices. +- **Forward explicit approval safely.** Route chat approval to the accountable + owner while preserving the exact approved operation, scope, and bindings. +- **Clarify execution ownership.** Use appropriate delegation with one owner + and no duplicate executors for the same operation. +- **Set release-note dates predictably.** Default to today's date unless the + user explicitly supplies an override. +- **Evaluate an Author and Validate mode.** A separately approved, + deployment-only check in a non-production environment could verify + deployment mechanics. It would not constitute functional/runtime + qualification, alert-parity evidence, or broader Qualification. + +Any future environment write remains separately gated by explicit approval +immediately before the exact operation. No cloud action is authorized by this +roadmap. diff --git a/Tools/SentinelToXDRMigration/README.md b/Tools/SentinelToXDRMigration/README.md new file mode 100644 index 00000000000..3e681aef986 --- /dev/null +++ b/Tools/SentinelToXDRMigration/README.md @@ -0,0 +1,3048 @@ +# Microsoft Sentinel to Defender XDR migration toolkit + +This toolkit converts the analytic rules in a Microsoft Sentinel solution into +versioned Defender XDR Custom Detection YAML files. + +See [tracked follow-up areas](FOLLOW_UPS.md) for curated capability and +operator-experience work that may be considered separately. + +## Product goal and operator flow + +**Product goal:** Allow autonomous migration of Sentinel solutions to Defender +XDR solutions at scale and with quality. Once a user defines the solution, the +toolkit should prepare the best-supported migration candidate, run the +applicable automated checks, and clearly surface assumptions and evidence for +human review. For optional Qualification, the user must also define an approved +non-production test environment and provide the required permissions. Offline +Authoring does not require a test environment. + +The intended operator flow is: + +1. **Define scope and mode.** Supply the solution definition and account for + every source object. Explicitly select `authoring` or `qualification`; + environment configuration does not implicitly select Qualification. +2. **Prepare candidates in a batch.** Prefer stable, evidence-supported + mappings. When semantics are uncertain but a defensible target candidate + exists, generate that candidate without pausing for a per-rule decision, + preserve the original available values in details where the target mapping + cannot carry them, and record the assumption, evidence, and source/query + hashes for review. Do not invent logic, fields, or equivalence. +3. **Check and classify results.** Run structural validation and only the + read-only query or schema checks supported by the selected mode and + available authorization. Distinguish reviewable mapping uncertainty from + malformed input, unsupported structure, missing prerequisites, and actual + execution or validation errors; the latter remain errors, not assumptions. + If a source rule cannot be represented as a valid deployable XDR detection, + retain an explicit diagnostic candidate and disposition outside deployable + detection YAML rather than fabricate a runnable rule. +4. **Review the batch.** Present complete inventory coverage and per-object + outcomes, including unresolved, unsupported, unprocessed, and explicitly + excluded objects. At a later human-review stage, the operator can accept the + reviewed candidate bundle for packaging, request a scoped change, or exclude + an object with a reason. Acceptance resolves only the reviewed assumptions + in scope; it cannot waive technical, input, or query failures. Preserve + exclusions in the inventory and provenance; a change must regenerate + affected output and rerun checks against current input hashes. Acceptance is + a review decision, not proof of runtime correctness or alert parity. +5. **Package and, only if approved, qualify.** Build the package from the + reviewed, current candidate inputs through the V3.1 route. Packaging, + deployment readiness, and runtime qualification remain separate. Deployment + and ingestion are Qualification-only writes requiring explicit, + exact-scope approval immediately before the operation; candidate acceptance + does not authorize them. + +**Implementation status:** This is the desired product direction, not a claim +that end-to-end autonomous semantic mapping, general uncertainty handling, +batch accept/change/exclude decisions, or candidate diagnostics are already +implemented. Current conversion supports only specific deterministic +best-effort entity mappings with hash-bound `reviewRequired` findings. +Unresolved review and technical gates still block packaging. Existing commands +do not yet provide the general batch-review decisions described above; see +the capability matrix below for implemented behavior and pending work. + +## Requirements + +- Python 3.11 or 3.12 +- PowerShell 7 for V3.1 solution packaging +- Azure CLI for authenticated runtime validation and Qualification + +Install the package with a supported Python interpreter: + +```powershell +python -m pip install -e Tools\SentinelToXDRMigration +``` + +The package metadata and CLI enforce the supported Python range. If the active +interpreter is unsupported, select or install Python 3.11/3.12. Do not modify +toolkit source files as an environment workaround during a migration run. + +## Offline authoring-contract foundation + +`sentinel_xdr_migration.authoring_contract` provides the versioned +`schema/authoring-contract.schema.json` record and offline typed helpers for +validating a complete source-rule inventory, per-rule findings and generated +target hashes, evidence references, qualification state, and separate shared +environment errors. Use `seal_authoring_contract(core_record)` to validate a +core JSON-like record and attach computed summaries; +`validate_authoring_contract(record)` verifies those summaries and returns an +immutable typed record. `calculate_coverage(validated)` returns exact counts, +and `calculate_operation_eligibility(coverage)` returns advisory threshold +signals. References must be repository-relative and hashes must be lowercase +SHA-256. Validation checks reference shape and consistency but does not open, +re-hash, or authenticate the referenced evidence artifacts. + +Coverage uses the complete unique inventory as `N`. `M` counts structurally +valid migrated targets with no unresolved rule-specific errors; warnings and +retained mapping assumptions do not reduce it. Blocked, suppressed, and +unprocessed rules remain in `N` and not in `M`; `E` counts affected rules once, +regardless of the number of findings. Shared provider/environment failures +remain separate. The success/PR threshold is tested as `100*M >= 80*N`; the +stop signal is `100*E > 50*N`, without rounding. Qualification counts are +separate: an untested target may count in `M`, while a qualified result needs +runtime-verification and actual-alert-parity evidence. Entity parity records +the value-preservation policy even when entity type, identifier, or custom +field differs. Mapping assumptions and `reviewRequired` are preserved; neither +confidence nor review state grants eligibility. + +These are pure, in-memory calculations only. The stop signal does not persist a +stopped workflow or implement autoresume behavior. Threshold results are +advisory and never authorize deployment, packaging, ingestion, PR creation, or +any other environment operation; exact-scope runtime authorization remains a +separate requirement. Parallel orchestration, durable coverage state, gates, +and CLI integration are pending work and are not implemented by this contract. + +## Protected implementation + +The XDR Solution Manager treats its prompts, skills, migration backends, +packaging backends, schemas, tests, dependency metadata, workflow definitions, +safety gates, and capabilities as read-only during solution migrations. +Runtime, environment, conversion, validation, packaging, or deployment +failures must be reported rather than repaired by changing toolkit source. + +The XDR Solution Manager can never perform implementation changes, regardless +of who requests or approves them. Agent and backend development must be done +manually or through a different development agent. The XDR Solution Manager +remains limited to migration artifacts, packaging, validation, Qualification, +and routed reports. + +The first milestone intentionally stops before ARM generation. It creates and +validates: + +```text +Solutions// + Analytic Rules/ + .yaml + Execution/ + .yml + XDR Detections/ + .yaml + Execution/ + .yml + Logs/sentinel-xdr-migration// + run.json + workflow-state.json + manifest.json +``` + +### Recursive source layout + +Discovery includes `.yaml` and `.yml` files (case-insensitive extensions) at +every level of `Analytic Rules`, or the legacy `Analytics Rules` directory. +Conversion preserves each filename and its relative directories under +`XDR Detections`; same-named rules in different folders remain distinct. +Inspection, validation, runtime plans/results, deployment, parity, and reports +use the same recursive inventory. Runtime result `detection` values and parity +selectors use slash-separated paths relative to `XDR Detections`, for example +`Execution/Rule.yaml`. Parity's legacy filename/stem selectors are accepted only +when unambiguous. The shared mock-scenario generator also discovers nested +AR/CD pairs and accepts relative paths; source IDs disambiguate same-named rules. + +Persisted manifest results use solution-relative `source`/`output` fields and +portable `sourceRelativePath`/`outputRelativePath` fields. +Use the latter output references in solution data's `XDR Detections` array; +the V3.1 packager already accepts nested paths. Only detection YAML is generated +under `XDR Detections`; reports/configuration remain in ignored solution `Logs`. + +Existing flattened or relocated detections identified by source provenance are +reported as conflicts, even with `--overwrite`. Review and explicitly reconcile +or relocate these files before converting; the converter does not silently +duplicate, move, or delete them. Unidentified flat files with a colliding basename +also require review. Symlink content paths and escaping relative references are +rejected. Existing independent XDR release versions and overwrite rules are +unchanged. + +Each XDR Detection YAML contains: + +- an independent top-level content release `version`, initially `3.1.0`; +- the properties required to generate a + `Microsoft.Security/detectionRules` resource; +- a disabled-by-default lifecycle state; +- converted KQL; +- optional top-level `requiredDataConnectors`, copied from source authoring + metadata and never emitted as Graph/ARM detection-rule properties; +- translated entity mappings; +- all source MITRE tactics, in source order, with compatible techniques; +- a `contentProvenance` block linking the detection to its source analytic + rule and recording conversion warnings. + +The YAML contract is defined in +[`schema/xdr-detection.schema.json`](schema/xdr-detection.schema.json). + +### MITRE authoring and deployment contracts + +Conversion preserves every source tactic in order in +`properties.detectionAction.alertTemplate.tactics`, including two or more +entries. Multiple tactics alone are not an error or a `needsReview` reason. +Each entry contains only compatible source techniques/subtechniques. The +converter reuses the repository Sentinel validator's +`.script/tests/detectionTemplateSchemaValidation/Models/ModelValidationAttributes/KillChainTechniquesHelper.cs` +catalog and its base-technique matching semantics for subtechniques; this +catalog does not independently verify that a particular subtechnique suffix +exists. Source tactic identifiers are retained as target tactic strings, not +paired by position with the flat technique list. A technique shared by several +documented tactics can appear under each of those tactics, never under an +incompatible one. A tactic with no compatible techniques is retained without +inventing any. Unknown tactics, malformed IDs, unmatched techniques, or an +unavailable catalog remain blocking conversion/validation errors. +The Graph `mitreTactic.tactic` reference defines a string, not a public enum. + +Original source lists remain independently preserved in +`contentProvenance.conversion.originalTactics` and `originalTechniques`. +Existing `ruleOverrides..tactic` plus `techniques` now correct only +the techniques of that named source tactic. They never narrow the list or +change source order; conversion emits an explicit legacy-override warning. +The tactic must already occur in the source, and techniques must be compatible. +An absent tactic, missing technique list, or invalid/incompatible override is +a blocking error, not a silently ignored decision. To change source tactic +intent, use the separate reviewed source-authoring process. Existing drafts +are refreshed only with the normal explicit overwrite approval. + +**Authored YAML is not the deployed payload.** V3.1 ARM packaging copies only +the first tactic entry, including its compatible nested techniques, into the +deployment payload; it does not modify the multi-tactic YAML or provenance. +This is intentional classification loss, not validated classification or alert +parity. Direct Microsoft Graph deployment remains restricted to at most one +tactic because the verified service rejected multiple tactics with HTTP 400. +The direct deployment path fails instead of truncating or sending an illegal +multi-tactic payload. `deploymentReady` counts and per-result +`directGraphDeploymentReady` exclude authored multi-tactic documents even +when conversion and authoring structural validation pass. No runtime or +deployment success is inferred from authoring or packaging. + +### Source-derived detection identity + +Generated `properties.id` is `xdr-`; `contentProvenance.source.id` retains the original GUID without +the XDR prefix. The prefix ensures a Graph-compatible leading letter, including +when the GUID begins with a digit. This is not a deployed Sentinel rule instance +ID, a name slug, or an eight-character prefix. Renaming a rule does not change +this ID; distinct full source GUIDs remain distinct. Source IDs must use the +hyphenated GUID format; their case is preserved. +Duplicate source GUIDs (including case variants) and existing output ID or +provenance conflicts block conversion rather than overwriting another rule. + +After reviewing local edits, explicit `convert --overwrite` can migrate the +converter's old `xdr--` IDs **only for the +same source ID and source path with converter provenance**. The independent XDR +version is preserved and the detection remains disabled. An existing custom ID, +missing/conflicting provenance, or a relocated output requires explicit manual +reconciliation; `--overwrite` is not permission to replace unrelated content. +The legacy ID must be reproducible from the stored display name or current source +name; an unrecognized name-based ID also requires manual reconciliation. +Without `--overwrite`, changed output is a conflict. Source YAML is never edited. + +An ID transition persists `contentProvenance.conversion.identityChange` and an +actionable warning in the YAML and conversion manifest/report; later conversions +retain that warning. This is a **local artifact change only**, not evidence of +cloud migration. No existing cloud rule is updated or deleted by conversion. +An optional read-only identity review may help diagnose known legacy IDs before +the separately approved deployment, which otherwise targets the new GUID +rather than the old name-based ID. Do not make a general pre-deployment +existence check a workflow gate; stop for manual reconciliation if an observed +ID has uncertain ownership or content. + +Structural validation and packaging require the canonical `xdr-` ID for +documents declaring Microsoft Sentinel / AnalyticsRule provenance or the +`sentinel-to-xdr-migration` converter. They do not impose this GUID identity +contract on unrelated Custom Detections. Packaging normalizes a bare originating +GUID to `xdr-` on its packaging copy, leaves an already-prefixed ID +unchanged, and rejects unrelated custom IDs rather than silently replacing them. +The normalized identity is used consistently in installation, registration, +content IDs, catalog criteria, and their references. The authored YAML and +source provenance remain unchanged. Graph payload preparation applies the same +idempotent normalization. The existing ARM `[if(true(), '', '')]` +literal expression is unchanged. + +### Entity mapping and schedule diagnostics + +Conversion follows the documented [Graph entity mapping contracts](https://learn.microsoft.com/en-us/graph/api/resources/security-entitymappingconfiguration?view=graph-rest-beta) +and [Sentinel source identifiers](https://learn.microsoft.com/en-us/azure/sentinel/entities-reference). +In addition to Host, Account, IP, URL, AzureResource, CloudApplication, Mailbox, +and MailMessage, direct mappings include: + +| Sentinel identifier | Graph collection and field | +| --- | --- | +| DNS.DomainName | `dns.domainNameColumn` | +| File.Name | `files.nameColumn` | +| MailCluster.Query / Source | `mailClusters.queryColumn` / `sourceColumn` | +| RegistryValue.Name | `registryValues.valueNameColumn` | +| SecurityGroup.DistinguishedName / SID / ObjectGuid | `securityGroups.distinguishedNameColumn` / `sidColumn` / `objectIdColumn` | +| FileHash.Value with query-proven constant Algorithm=SHA1 or SHA256 | `files.sha1Column` or `sha256Column` | + +The hash proof accepts constant assignments in a simple single-table +`where`/`extend`/`project` pipeline, not an algorithm guessed from a column name. +Unknown/mixed algorithms, MD5, missing output columns, dropped fields, and +conflicting source identifiers remain warnings. Linked entities such as +DNS.DnsServerIp, RegistryValue.Key, File.FileHashes, and Process.ImageFile are +not scalar query-column mappings. No joins or synthetic fields are introduced. +Graph processes accept hashes, not Sentinel ProcessId/CommandLine; those source +fields remain unmapped. Graph's Amazon, Google Cloud, and OAuth collections do +not establish corresponding Sentinel source identifiers, so the converter does +not invent those mappings. These are converter limitations, not claims that +Defender lacks the entity type. The required Host/Account/Mailbox/IP asset gate +is unchanged. + +When an unsupported source identifier has an available output value, conversion +uses the best supported target identifier available from the source mapping +and query projection, then retains the original query output column in +`customDetails` and records a hash-bound `reviewRequired` assumption. For +example, `Host.FullName` can provisionally map to the supported +`hosts.nameColumn` when no stronger supported Host identifier is available. +Supported host fields are defined by the Microsoft Graph +[`hostEntityMapping` contract](https://learn.microsoft.com/en-us/graph/api/resources/security-hostentitymapping?view=graph-rest-beta). +Existing supported host identifiers take precedence; unsupported target fields +and columns not emitted by the query are never invented. The review is not +auto-accepted and does not establish identity equivalence or parity. + +### Supplemental entity evidence in custom details + +Whenever source entity information lacks an equivalent final entity mapping, +the converter also binds the original output value under +`properties.detectionAction.alertTemplate.customDetails`. This applies to every +entity family, unsupported identifiers, ambiguous repairs, conflicting mappings, +and identifiers omitted by an explicit `ruleOverrides..entityMappings`. +For example, a supported `hosts.nameColumn: HostName` plus IP mapping can +retain the original Host.FullName value as +`customDetails.HostFullName: DeviceName`; if no supported host-name candidate +exists, an available Host.FullName output may itself be provisionally bound to +`hosts.nameColumn`. An unproven choice is explicitly marked for human review. +This best-effort assumption is nonblocking for conversion, structural +validation, and read-only runtime query/output-schema testing, allowing a +large batch to continue without per-rule pauses. It does **not** replace a +valid supported mapping, establish restored identity/correlation/semantic +equivalence, or claim alert parity. Statically missing output bindings, +conflicts, malformed content, custom-detail limits, other review reasons, and +technical/query failures retain their normal warnings, errors, and gates. +When destructive projections make an explicit output binding unprovable, +conversion retains that binding and emits a nonblocking warning that runtime +output-schema verification is required; this does not clear unrelated review +findings. +Pending entity review blocks packaging and deployment; only hash-current +human review can resolve it. Supplemental values never resolve independent +identity-confirmation reviews. + +The [Graph `alertTemplate` contract](https://learn.microsoft.com/en-us/graph/api/resources/security-alerttemplate?view=graph-rest-beta) +defines each custom-detail value as a **query output column name**, not an event +literal or entity object. Its +[`alertCustomDetails` resource](https://learn.microsoft.com/en-us/graph/api/resources/security-alertcustomdetails?view=graph-rest-beta) +is an open key-value object. Source `customDetails` and per-rule override +`customDetails` are merged, with the same configured/catalog column renames as +converted KQL. Identical bindings are retained; a conflicting explicit key +blocks conversion without overwriting the source value. Malformed declarations +remain errors. Empty source objects remain empty when no supplement is needed. + +Automatic keys concatenate the entity type and source identifier using +alphanumeric characters, for example `HostFullName` or `ProcessCommandLine`. +Existing keys are never overwritten. A collision uses `_2`, `_3`, and so on +in source mapping order; an existing detail for the same output column is reused +instead of duplicated. Repeated conversion is deterministic. Query-proven +equivalent mappings, such as an intact FullName decomposition into host name +and DNS domain, need no redundant supplemental key. +`contentProvenance.conversion.supplementalEntityDetails` records source entity +index/type/identifier, original and converted column, selected detail key, and +availability status. The actual detail bindings are normal Graph/ARM alert +properties; this provenance remains outside deployment properties. + +Existing conservative KQL helpers distinguish known output columns, known +missing columns, unproven destructive projections, and runtime-binding-pending +output. Final projections that explicitly drop or rename a bound column remain +errors. For a non-final destructive projection whose effect cannot be proven +statically (for example, a `project-away` before an aggregate), an explicit +source binding is retained with a warning rather than treated as missing. +Automatic entity supplements are not invented from unproven output columns. +The converter never rewrites KQL to restore missing columns. Explicit source +bindings to statically missing columns are retained for correction but fail +validation. When output availability cannot be proven for other reasons, a +supplemental binding is labeled `runtime-binding-pending`, with an explicit +warning rather than an availability claim. Runtime plans include +`customDetailBindings`. Graph runtime validation +checks every binding against the returned schema. Imported successful runtime +results for detections with custom details must include the target query's +`schema` array (`name` or `Name` per column); a count alone is blocked, and a +missing bound column fails validation. Query execution alone does not establish +detail binding or entity correlation. + +The [documented Defender limits](https://learn.microsoft.com/en-us/defender-xdr/custom-detection-rules#add-custom-details) +are **20 key-value pairs per rule** and **4 KB combined custom-detail keys and +runtime values per alert**. More than 20 authored pairs or keys alone exceeding +4 KB is a blocking error; nothing is silently truncated. The public references +do not specify a separate per-key character limit, so none is invented. +Runtime event-value size cannot be established from column names or schema: +the converter warns that the service drops the whole custom-details array if +the runtime limit is exceeded. Schema success is not proof of value-size safety; +qualification must check actual values. + +`contentProvenance.conversion.informational` and manifest result +`informational` explain preserved equivalents. A best-effort entity choice is +instead recorded in `reviewReasons` and as `reviewRequired`, even though the +draft can still have conversion `status: converted` so structural and +read-only runtime tests can proceed. Host.FullName is informational without +review only when a conservative query proof shows that retained HostName and +DnsDomain explicitly decompose that same value using the +`iff`/`substring`/`indexof` pattern. Merely having name/domain columns is not +proof. When proof is absent, retain the original value in custom details and +record the selected supported field as a pending assumption. Reassignments, +branches, conflicting values, missing output columns, other technical errors, +and independent review findings retain their own warnings/errors/gates; no +unsupported `fullNameColumn` is emitted. +The identity proof also permits `summarize` when the full name survives as a +bare grouping key; previously derived name/domain columns must likewise survive +unchanged if reused. Aggregate values and computed grouping keys do not prove +identity equivalence. Native-table schedule classification is independent of +that identity proof: comments and aggregation do not hide a known native +lookback, but aggregation still cannot establish equivalent time windows. + +Schedule messages record source frequency/lookback and emitted target frequency +with a [public lookback reference](https://learn.microsoft.com/en-us/defender-xdr/custom-detection-rules#lookback). +Native Defender fixed lookbacks are 4 hours for hourly rules, 12 hours for +3-hour rules, 48 hours for 12-hour rules, and 30 days for daily rules. +For a shorter Sentinel lookback than a documented native XDR service window, +conversion may add a `Timestamp >= ago(...)` event-time bound immediately after +the native table input and before any query filters, projections, or +aggregation. This is limited to a conservatively recognized single-native-table +pipeline with matching source/target input and established source event-time +semantics; a simple `TimeGenerated` reference is lexically rebound to +`Timestamp`. Original filters remain. Provenance records the original Sentinel +lookback, effective event-time window, fixed XDR service window, time-column +binding, transformation rationale, and late-ingestion risk. The HTML and +manifest distinguish a constrained event-time window from the larger fixed +service window; they do not claim identical service windows, cadence parity, +initial-run equivalence, or alert parity. + +Joins, unions, historical/baseline branches, reassigned or ambiguous time +columns, unknown native inputs, and unproven source/target bindings are not +rewritten and remain `needsReview` with a specific reason. NRT and frequency +mismatches are not narrowed. A duration in a comment or string is not a time +filter; existing filters are never discarded. Sentinel-only/custom, mixed, +unknown, and longer-source-lookback cases retain their schedule diagnostics. +Late-arriving events whose event timestamp falls outside the inserted bound can +be missed, so generated transforms still require normal runtime validation and +qualification; conversion does not promote a rule to runtime-validated. + +## Runtime query validation + +When this or another conversion changes the persisted Advanced Hunting query, +the converter blocks previously passed validation and downstream workflow +stages while retaining the old reports as historical. Runtime results bind to +the exact KQL SHA-256: Graph results record `querySha256` for the executed XDR +query; imported Sentinel MCP/Log Analytics results must supply the same field +for the exact source query. Missing or mismatched hashes are blocked. A passed +validation stage requires complete current-hash Sentinel and Advanced Hunting +query families; only Authoring may explicitly pass as +`runtimeStatus=environment-blocked`, which is not a runtime-qualified result. +Failed KQL/runtime results cannot be relabeled as provider gaps, and provider +fallback must rerun the complete family. Zero rows establish execution only. +Hash-scoped review decisions also expire when either source or converted-query +hash changes. + +The built-in source-runtime missing-schema whitelist applies across all +solutions, source queries, and runs. It contains exactly `IdentityInfo`, +matched case-insensitively and reported canonically as `IdentityInfo`. It +classifies only a current Sentinel/Log Analytics result that is a failed +HTTP 400 query with `SEM0100` explicitly identifying that table as unresolved. +The Azure Monitor SDK's preserved nested error structure and the message +`'summarize' operator: Failed to resolve table or column expression named +'IdentityInfo'` are supported, as is the equivalent explicit +`Failed to resolve table 'IdentityInfo'` diagnostic represented by provider +evidence. A missing column, another table, mixed/multiple errors, partial +provider/family failure, authentication/authorization/tenant/transport/timeout +failure, stale query hash, or changed inventory is not whitelisted. + +No manual acceptance, actor, reason, run/rule authorization, or separate +decision artifact is required. The runtime derives the classification from +the exact current source query hash and complete family. It never rewrites or +relabels the raw provider result: that query remains `failed`. Reports preserve +the raw error alongside the canonical whitelist table and reason, and use +`source-waived` (never passed/success/executed) for the source disposition. +The status remains visible when Validation is blocked by XDR or other gates. + +The whitelist does not relax any XDR runtime, exact-query hash, actual output +schema, `customDetails` binding, mapping-review, technical-validation, +packaging, deployment, ingestion, or parity gate. Validation may complete as +`runtimeStatus=source-waived` only when every other current source query, +every current XDR query and required schema check, and every unrelated +technical/review gate passes. Direct workflow-stage completion independently +enforces this. Any other failed, blocked, stale, incomplete, or not-run check +keeps Validation blocked. The Defender `IdentityInfo` documentation does not +prove availability in a selected Sentinel source workspace. + +## Content-creator authoring policy and pending implementation + +This section records the approved authoring policy and its implementation gaps. +It does **not** introduce a validation mode, waive an existing gate, or assert +that a schema resolver is implemented. The canonical +[workflow](agent-workflows/end-to-end-migration.md#validation) still governs +stage completion. The finite global `IdentityInfo` source-runtime whitelist is +documented separately under [Runtime query validation](#runtime-query-validation); +it does not create an optional-validation mode or waive unrelated gates. + +### Schema responsibility and evidence + +For authoring, assume that tables referenced by the source Sentinel rule are +expected to be available in XDR. Do not ask the content creator whether the +target resolves both tables or directory fields. This is an authoring premise, +not a guarantee about any deployment environment, onboarding, licensing, +permissions, data freshness, or populated columns. + +The tool is responsible for resolving standard Defender and Sentinel schemas +through an **actually advertised official schema MCP/API capability**, when +authorized and available, or authoritative Microsoft documentation. It must +identify the source and target query surfaces and resolve columns in their +table/alias scope; it must not ask customers to supply `Timestamp` versus +`TimeGenerated` or the casing of `AccountUPN`. Public documentation can support +schema review without accessing a tenant; it does not prove runtime execution. +Never invent an MCP capability or initiate authentication/runtime queries after +the user has declined live validation. + +For `_CL` tables, infer only from available, actual connector definitions, DCR +stream/output transforms, or table definitions. The suffix alone proves neither +columns nor types. Cite the evidence and distinguish **inferred from definitions** +from **verified against the applicable schema** and **runtime validated**. +Missing or conflicting metadata is a specific tool/evidence limitation, not a +reason to fabricate fields or ask the customer to guess. Resolve conflicts +before claiming a safe transformation. + +### Repair metadata at both ends + +Metadata inconsistencies are not resolved merely by copying them faithfully and +reporting an error. Within the approved content-authoring scope, the orchestrator +must repair unambiguous, schema-defined metadata errors in both the Sentinel +source and the corresponding XDR draft, then inform the user. This applies to +all metadata, not only connector dependencies. It is a source-authoring +maintenance step, **not** permission for the converter to edit Analytic Rules: +normal conversion remains read-only with respect to source content, and no +automatic repair engine is implemented by this policy. + +Only mechanical corrections with one defensible interpretation may be made +without a further content decision. For example, when an AWSS3 entry contains +`datatypes: [AWSVPCFlow]` but lacks the schema-required `dataTypes`, rename that +key to `dataTypes` in the source, then explicitly refresh the corresponding +draft. Do not normalize valid connector IDs, data-type values, arbitrary +extension fields, order, duplicates, or absent/empty declarations. Conflicting +keys or values, ambiguous intent, tactic/technique choices, entity meaning, +identities, and release versions require an explicit decision; never invent a +resolution or copy platform-specific metadata across incompatible contracts. + +1. Record the affected files, field paths, exact before/after values and schema + basis. Preserve earlier reports and query hashes before replacing evidence. +2. Make the minimal approved source correction. Regenerate only the affected + draft with the reviewed configuration and explicit overwrite approval; use + `--rule-id` for an isolated repair so unrelated or deliberately deleted drafts + remain untouched. Respect the [single-rule scope contract](#single-rule-conversion). +3. Check both declarations against their applicable schemas, their intended + correspondence, unchanged KQL/identity/version/lifecycle, and unrelated-file + hashes. Refresh conversion diagnostics through the existing tools, clearing + only errors actually resolved; record the repair and checks in run evidence. +4. Tell the user what was repaired versus what remains unresolved, including + affected files and before/after values. Reports must disclose their scope. + Existing offline comparison pages and evidence exports remain historical + snapshots unless explicitly refreshed; never pair old content with new hashes. + Static metadata success does not make runtime checks passed: retain `notRun`, + mixed-surface reviews, warnings, and all unmet workflow gates. + +| Capability | Current implementation | Approved policy / pending work | +| --- | --- | --- | +| Standard schema resolution | `catalog.py` contains a finite table/rename catalog; `convert_query` applies token replacements. No general authoritative source/target schema resolver is implemented. | Resolve documented schemas and table-scoped lineage automatically; retain surface, source, and confidence evidence. | +| Mixed-query time columns | Native-only catalog queries may rename `TimeGenerated`; mixed native/passthrough queries preserve it and request review. | Resolve each reference against its actual table/alias, not a whole-query rename or a customer schema question. | +| Custom logs | `_CL` recognition identifies names, not a complete schema. | Infer only from actual available connector/DCR/table metadata; report missing/conflicting evidence explicitly. | +| Runtime validation declined | Offline Authoring can complete Validation only with current full-inventory conversion, structural success, resolved technical findings and hash-bound review decisions. It records `runtimeStatus=not-run` and is not runtime/XDR qualification. Existing runtime reports are never discarded or overridden. Qualification still requires its exact locked target and complete runtime/schema/consent evidence. | Show the disclosure below. Preserve unexecuted, blocked, failed, and review-pending results; a review acceptance is not runtime verification. | +| Entity equivalence | Documented identifier mappings and conservative proofs exist; unsupported mappings produce diagnostics. | Explain the exact retained identifiers, actual field loss, or proven equivalence; distinguish converter proof limits from target-contract limits. | +| Best-effort entity assumptions | Available unsupported entity values can use a deterministic supported candidate, remain in custom details, and carry hash-bound pending review while structural/read-only runtime tests continue. | Never auto-accept or claim parity; human review remains required before V3.1 packaging/deployment. Other technical and review gates remain unchanged. | +| General semantic uncertainty and batch review | Best-effort continuation is limited to specific entity-mapping cases. There is no general assumption-driven candidate workflow or batch accept/change/exclude interface. | For defensible uncertain mappings, generate a documented candidate and hash-bound assumption for later human review without per-rule pauses. Support scoped accept, change-and-regenerate, or reasoned exclusion while retaining every object and disposition in inventory/provenance. Acceptance does not prove runtime correctness or parity. | +| Non-representable rules and inventory coverage | Conversion diagnostics and full-inventory authoring cover current supported paths; there is no general diagnostic-candidate contract for a source object that cannot become valid deployable detection YAML. | Keep a complete denominator and explicit per-object outcomes. Emit non-deployable diagnostics outside XDR detection YAML rather than fabricate a runnable rule; unsupported, blocked, unprocessed, and excluded objects remain visible and are never silently dropped. | +| Source connector dependencies | Optional top-level `requiredDataConnectors` is deep-copied unchanged and structurally validated. Graph and inner ARM properties exclude it. | Repair schema-clear inconsistencies in source and draft through the authoring step above, then inform the user; converter source inputs remain read-only. Target-specific dependency translation/registration remains separate pending work; source declarations do not prove deployment readiness. | + +When runtime validation is declined, the review UI should say: + +> KQL has not been validated because this migration runs without runtime validation. Enable full validation, or review/accept the specific migration checks below. + +This is approved presentation policy, not a claim that an “Enable full +validation” control exists. Offline Authoring's explicit +`validationMode=offline-authoring` route is permitted only after fresh full-inventory +conversion and structural validation, with all technical findings and +hash-bound review decisions resolved. It records runtime as not run, does not +dispatch Sentinel or Advanced Hunting queries, and cannot replace persisted +runtime reports. Current report failures and stale or unresolved evidence keep +the stage blocked. A provider report recorded after offline completion reopens +Validation and downstream gates for reevaluation. This is not runtime/XDR +qualification; Qualification retains its complete runtime, schema, consent, +target, and execution gates. Acceptance of a supported, specific migration +review item is not KQL execution, syntax verification, deployment readiness, +or alert parity. The separate Authoring `runtimeStatus=environment-blocked` +exception still requires its stated provider/environment evidence and may not +conceal an exact-query failure. + +### Worked review: Local Admin Group Changes + +The inspected source is +[`Analytic Rules/Persistence/LocalAdminGroupChanges.yaml`](../../Solutions/Microsoft%20Defender%20XDR/Analytic%20Rules/Persistence/LocalAdminGroupChanges.yaml), +rule `63aa43c2-e88e-4102-aea5-0432851c541a`. Its parallel generated XDR YAML +was reviewed locally on 2026-10-07; it was not regenerated or executed. +Generated artifacts can retain older warnings and are not evidence that a +current converter proof has run. + +The official references distinguish these contexts: + +| Table / surface | Documented fields relevant here | +| --- | --- | +| [IdentityInfo — Sentinel / Log Analytics](https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/identityinfo) | `AccountUPN`, `AccountSID`, `AccountCloudSID`, `TimeGenerated` | +| [IdentityInfo — Defender Advanced Hunting](https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-identityinfo-table) | `AccountUpn`, `OnPremSid`, `CloudSid`, `Timestamp`; the unified schema also documents licensing/onboarding and UEBA-specific availability. | +| [DeviceEvents — Log Analytics](https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/deviceevents) | `TimeGenerated`, `AccountSid` | +| [DeviceEvents — Advanced Hunting](https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-deviceevents-table) | `Timestamp`, `AccountSid` | + +These are **documentation-verified schema facts**, not tenant verification. +`AccountUPN` to `AccountUpn` is supported for this IdentityInfo surface +transition, not a universal casing rule. In the inspected output that rename +occurred, but `AccountSID` and `AccountCloudSID` remain in the IdentityInfo +branch, while both event projections retain `TimeGenerated` from DeviceEvents. +Those retained references do not match the documented target schemas; they are +specific unresolved migration checks. IdentityInfo's source `AccountSID` and +target `OnPremSid` describe the on-premises SID; `AccountCloudSID` and `CloudSid` +describe the cloud SID. Resolving those references must respect the existing +`OnPremSid` alias, not blindly rename every occurrence. DeviceEvents' +`AccountSid` already uses the documented spelling on both surfaces. + +The `leftouter` SID join with `NewUsers` and `innerunique` SID join with +`ADAZUsers` exist in both source and output. Any pre-existing filtering or +correlation limitations are source behavior, not a newly introduced migration +bug unless conversion changes their semantics. + +[Sentinel identifiers](https://learn.microsoft.com/en-us/azure/sentinel/entities-reference) +are not interchangeable with the more restricted +[Graph host](https://learn.microsoft.com/en-us/graph/api/resources/security-hostentitymapping?view=graph-rest-beta) +and [Graph account](https://learn.microsoft.com/en-us/graph/api/resources/security-accountentitymapping?view=graph-rest-beta) +mapping contracts. Neither of these Graph mapping types documents a +`fullNameColumn`. For this rule: + +- Host `HostName` and `DnsDomain` survive as `nameColumn` and + `dnsDomainColumn`. The query explicitly splits the same `DeviceName` on its + first dot, so the original host value is represented by that pair (including + the no-dot case). The inspected artifact's Host.FullName warning must not be + described as proven host-identity loss; the current conservative proof may + still be unable to establish equivalence through this multi-branch query. +- Account `AccountName` and `laccountdomain` survive as `nameColumn` and + `ntDomainColumn`, but they do **not** prove equivalence to `UserAdded` for all + rows. When the NewUsers join has no match, `UserAdded` uses + `DirectoryDomain\\DirectoryAccount`, while the mapped domain remains the + unmatched `laccountdomain`. The directory-domain information carried by the + source FullName mapping is not represented by the emitted account mapping + on that branch. `UserAdded` remains in query output; it is mapping loss, not + deletion of the query column. The retained SID joins themselves are unchanged. + +### Connector dependency preservation and deployment boundary + +For the same rule, source lines 8–12 declare +`MicrosoftThreatProtection` with data types `IdentityInfo` and `DeviceEvents`. +The inspected older generated artifact omitted this list. The correction is +implemented in the current working tree; existing artifacts are **not** +automatically rewritten. The source remains authoritative when explicitly +reconverting with the existing overwrite and identity safeguards. + +```yaml +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - DeviceEvents +properties: + # Only supported detection-rule properties belong here. + status: disabled +``` + +This is a partial authoring example, not a complete deployable detection. +Presence is significant: a missing source property stays absent; an explicit +`[]` stays `[]`. Entries, connector IDs, data-type spelling, order, duplicates, +and additional per-entry metadata are deep-copied without normalization. +Each entry must be an object containing a string `connectorId` and an array of +strings `dataTypes`; additional properties remain extensible. Nulls, scalar +lists, missing required members, and incorrectly typed members are invalid, +not silently dropped. At initial discovery, conversion retains malformed +metadata for review, records field-specific errors and `needsReview`, and +structural validation rejects it. It is never counted as successfully converted. +This preservation is not the final authoring outcome: follow +[repair metadata at both ends](#repair-metadata-at-both-ends) to correct +schema-clear errors in source and draft, or obtain a decision for ambiguous +conflicts, then report the repaired and outstanding findings. + +1. [`build_xdr_document`](sentinel_xdr_migration/converter.py) + validates source dependency metadata against the output field schema and + deep-copies it into optional **top-level** `requiredDataConnectors`, parallel + to the Sentinel authoring field. Source identity, provenance, independent + XDR release version, and disabled lifecycle safeguards are unchanged. + `conversion.requiredWorkloads = ["sentinel"]` remains separate and is not an + equivalent connector/data-type declaration. +2. The [YAML schema](schema/xdr-detection.schema.json#L99) requires selected + provenance members and now explicitly validates optional top-level + `requiredDataConnectors`. This does not make it a deployment API field. + Existing documents without the field remain backward compatible. +3. [`graph_detection_payload`](sentinel_xdr_migration/deployment.py#L184) + copies only `document["properties"]` into the Graph request. The + [Graph detectionRule contract](https://learn.microsoft.com/en-us/graph/api/resources/security-detectionrule?view=graph-rest-beta) + does not document `requiredDataConnectors`; its absence from that payload is + not evidence of a missing supported deployment field. +4. The packager + [`customDetections.ps1`](../Create-Azure-Sentinel-Solution/common/customDetections.ps1#L451) + similarly copies `properties` into the MicrosoftSecurity extension resource. + Its registration path (lines 311–316) emits only + `dependency.requiredWorkloads`. Neither path emits source connector metadata + into a deployed detection. The authoring YAML is retained unchanged. + In contrast, the Sentinel rule path in + [`commonFunctions.ps1`](../Create-Azure-Sentinel-Solution/common/commonFunctions.ps1#L2675) + explicitly retains `requiredDataConnectors`. +5. The official [Sentinel alert-rule-template API](https://learn.microsoft.com/en-us/rest/api/securityinsights/alert-rule-templates/get?view=rest-securityinsights-2025-06-01) + documents `requiredDataConnectors` with `connectorId` and `dataTypes`. + The official [contentTemplates ARM reference](https://learn.microsoft.com/en-us/azure/templates/microsoft.securityinsights/contenttemplates) + separately documents plural `dependencies` with `contentId`, `kind`, + `criteria`, and `operator`, including DataConnector/DataType kinds. That is + **not** the same contract as the packager's singular + `dependency.requiredWorkloads`, and it does not justify copying Sentinel + YAML fields into detection-rule properties. The attempted public + [Microsoft.Security/detectionRules ARM reference](https://learn.microsoft.com/en-us/azure/templates/microsoft.security/detectionrules) + returned 404 during this review: an authoritative exact-version + MicrosoftSecurity extension contract was not established. No claim of + extension support or runtime rejection is made. + +**Verdict:** the previously confirmed loss of source authoring dependency +metadata is corrected for subsequent conversions by the top-level field. No +unsupported Graph/ARM field was added, and existing detection YAML must be +explicitly refreshed before it contains the correction. This is not a schema +resolver, validation waiver, or deployment action. + +The [connector metadata tests](tests/test_connector_metadata.py) cover present, +empty and absent declarations, extension fidelity, deep-copy isolation, +malformed input/structural rejection, explicit reconversion and independent +version preservation, and actual local packaging exclusion from both install +and registration wrappers. Graph payload tests verify exclusion and isolation. +Conversion report errors use the existing reporting path. Sanitized evidence +allowlists and report/UI contracts are unchanged; no raw connector extension +metadata is added to evidence exports. + +A separate target dependency model remains a future design decision. Any content-registration propagation +must first be checked against the exact extension/registration API version and +translated to its documented contract, not copied blindly. Package generation +logic, source content, existing generated artifacts, runtime gates, and workflow +state are not changed by this correction. + +## Safety model + +Conversion is conservative. A file is still generated when manual work is +needed, but `contentProvenance.conversion.status` is set to `needsReview` and +validation fails until blocking issues are resolved. The tool never changes +files under `Analytic Rules` and does not modify `Package/mainTemplate.json`. + +The `search` review check uses KQL tokens rather than a raw word match. Line +and block comments, quoted/verbatim/multiline strings, and ordinary column or +binding references named `search` do not request operator review. Executable +`search` at query/pipeline boundaries (including nested queries) still requires +review and retains the existing `needsReview` gate. This is a conservative +lexical check, not a syntax or semantic validator; runtime validation remains +required. Other KQL checks and explicit review-acceptance rules are unchanged. +Previously generated findings are not rewritten: review stale acceptances +before an explicitly requested reconversion. + +Generated detections start with: + +```yaml +properties: + status: disabled +``` + +### Independent XDR release version + +New detections start with `version: 3.1.0` even when the source Sentinel rule is +`1.x`. `contentProvenance.source.version` continues to record the actual source +version; neither that source file nor its release version is promoted. +The required XDR version is a `major.minor.patch` string in `[3.1.0, 4.0.0)`. +It is distinct from `schemaVersion: 1.0.0`, `properties.id`, the converter +version, and API/provider versions. + +Regeneration preserves an existing valid XDR version (for example `3.1.1`) +when the output belongs to the same source rule, including with `--overwrite`. +An invalid existing version or different source identity is a conflict, not +permission to reset the release. Resolve it explicitly before reconverting. +Files from older converter runs without a top-level version fail structural +validation and V3.1 packaging with reconversion guidance. After reviewing any +manual edits, explicitly reconvert with `--overwrite` to initialize `3.1.0`, +or author the independent XDR version. Never rewrite source provenance. +Solution bumps do not bump detection versions; authors manage subsequent XDR +content releases independently. + +### Local parser bindings + +Conversion automatically renames local `let` bindings that match a solution parser's +`FunctionName` or `FunctionAlias`. The shared PowerShell packager applies the same +normalization before building both the CD installation and its registration, including +when it receives previously authored detection YAML. + +Names use `__xdr_inline_` with a numeric suffix when necessary. Microsoft's +KQL parser and semantic binder identify the declaration and its references, including +nested scopes. This is not a text replacement: columns, strings, comments, parameters +that shadow the binding, and native parser YAML remain unchanged. Repeated runs are +stable. Conversion reports each rename as a warning, not a review blocker. + +The normalizer checks syntax, binding identity, and inferred column shapes after editing. +It fails explicitly when a safe rewrite cannot be verified, including wildcard-selected +bindings, `union withsource`, and implicit output-column name changes. Use explicit inputs +and column aliases in these cases. Missing tooling is a setup error, not permission to +skip normalization. + +This offline check uses the parsers shipped in the solution, including nested `.yaml` +and `.yml` files. It does not discover unrelated functions installed in a target tenant, +inline missing parsers, or replace live schema/query validation. + +Requires Node.js and the pinned KQL dependency. From the repository root: + +```powershell +npm ci --prefix Tools\SentinelToXDRMigration\kql +npm test --prefix Tools\SentinelToXDRMigration\kql +``` + +## Install + +```powershell +cd Tools\SentinelToXDRMigration +python -m pip install -e . +``` + +## First-run setup + +Run the guided setup once: + +```powershell +sentinel-xdr-migration setup +``` + +If tenant security policy blocks device-code authentication, use interactive +browser authentication: + +```powershell +sentinel-xdr-migration setup --hunting-auth-method browser +``` + +Setup checks the local environment and launches authentication only when it is +needed: + +- `az login` for original Sentinel query execution through Log Analytics; +- a one-time Microsoft Graph device-code sign-in for Advanced Hunting. + +Authentication tokens are managed by Azure CLI and the operating system's +credential cache. The toolkit stores only an authentication record and +non-secret configuration under `~/.sentinel-xdr-migration`. + +Inspect readiness at any time: + +```powershell +sentinel-xdr-migration doctor +``` + +Persist an approved non-production workspace once for confirmation and reuse +across solution workflows: + +```powershell +sentinel-xdr-migration configure-workspace ` + --tenant-id "" ` + --subscription-id "" ` + --workspace-resource-id "" ` + --workspace-customer-id "" +``` + +`doctor` returns both configured identifiers. Qualification still asks the +user to confirm the workspace for each new workflow, but it does not require +the ID to be re-entered. + +### Per-solution Qualification target configuration + +For a solution-specific Qualification target, copy the shared placeholder-only +example and replace its two values with the approved lab tenant and full +Log Analytics workspace ARM ID: + +```powershell +$targetConfig = "Solutions\\.env" +if (Test-Path $targetConfig) { + throw "Review the existing target config instead of overwriting it." +} +Copy-Item Tools\SentinelToXDRMigration\.env.example $targetConfig +``` + +`Solutions//.env` accepts only literal `KEY=value` lines for +`AZURE_TENANT_ID` and `SENTINEL_WORKSPACE_RESOURCE_ID`. It is local +configuration, not authentication: do not put credentials, tokens, passwords, +or shell expressions in it. The example contains no real tenant/workspace data; +never edit it with customer identifiers. The solution `.env` is Git-ignored +and excluded from package content and optional evidence exports. It is not +automatically populated from process environment variables or the shared +`~/.sentinel-xdr-migration/config.json`; there are no implicit global target +defaults. Resolution is deterministic: an existing locked run is authoritative; +otherwise explicit CLI tenant/workspace values must agree with `.env`, and +`.env` supplies missing tenant/workspace values. Any disagreement fails closed +instead of applying a silent precedence rule. The subscription is always derived +from the validated workspace ARM ID. + +Inspect the literal target before opting into a new Qualification workflow: + +```powershell +sentinel-xdr-migration doctor --solution "Solutions\" +``` + +Then explicitly select Qualification and confirm the displayed local target: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile qualification ` + --confirm-configured-target ` + --version-bump none +``` + +The workflow initializer validates the ARM ID, derives its subscription ID, +checks the current Azure CLI tenant and active subscription, and only then +performs an exact `az resource show --ids ` read to obtain +the workspace customer ID. It does not list or search for workspaces. A scope +mismatch or inaccessible exact resource stops initialization with an +actionable error; authenticate separately with the approved identity and retry. +Alternatively, explicitly supply both `--tenant-id` and +`--workspace-resource-id` on `workflow-init`; the subscription is derived, and +the workspace customer ID may be supplied or resolved from the exact resource. +Conflicting CLI and `.env` tenant/workspace values fail rather than silently +choosing one. + +Once a Qualification run is locked, its persisted target remains authoritative. +Changing `.env` cannot retarget that run; a conflicting edit blocks resumption +and requires the normal explicit target-diagnosis/repair contract. Authoring +does not read or require `.env`, a tenant, Azure authentication, or a workspace. + +Conversion and structural validation always remain available in offline mode. +Missing runtime access is reported explicitly and never blocks YAML generation. +For unattended inspection without opening sign-in prompts: + +```powershell +sentinel-xdr-migration setup --non-interactive +``` + +The CLI setup covers Azure CLI and direct Microsoft Graph authentication. The +official Triage MCP is configured and authenticated in the agent host, not by +this Python package. For first use, configure: + +```text +https://sentinel.microsoft.com/mcp/triage +``` + +Sign in with a supported tenant member identity and complete the tenant's +required Defender/Sentinel onboarding, consent, and security-reader access. +Guest identities may not work. The agent should verify that +`RunAdvancedHuntingQuery` is available before selecting Triage as its runtime +provider. + +## Command line + +### Contributor authoring guide + +Select **Authoring**, **Qualification**, or **Cancel** before initialization; +there is no default profile. Authoring is the normal offline-capable content +workflow. Qualification is an explicit optional lab workflow, not a requirement +for publishing. Follow the [canonical stages and gates](agent-workflows/end-to-end-migration.md). + +Authoring reviews the discovered source and existing output, converts the +complete solution, resolves each `needsReview` item with scoped evidence and an +explicit content-owner decision, structurally validates source and generated +YAML, and then packages through the repository's V3.1 entry point. Generated +detection IDs use `xdr-` and remain +disabled by default; see [source-derived detection identity](#source-derived-detection-identity). + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --version-bump none ` + --campaign-id "" + +sentinel-xdr-migration inspect --solution "Solutions\" +sentinel-xdr-migration convert --solution "Solutions\" +sentinel-xdr-migration validate --solution "Solutions\" +sentinel-xdr-migration workflow-status --solution "Solutions\" +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --version-bump none +``` + +Initialize a persisted Authoring workflow before packaging so the V3.1 build is +charged to its durable campaign. The `--campaign-id` must remain the same when +linking a replacement run; changing the run ID does not reset campaign usage. +Use the one version-bump action explicitly selected at workflow initialization +for the first packaging command; do not apply a second, additional version +bump. V3.1 packaging is the current repository entry point for hybrid/XDR +content. The package command does not validate KQL, deploy content, enable +detections, or prove registration support or alert parity. + +Structural `validate` is distinct from live query execution. When live +validation is explicitly authorized, prefer capabilities actually advertised +by the official Sentinel Triage MCP. If a provider-level failure prevents a +complete query family from running, rerun that **entire family** through its +approved fallback—Log Analytics for the Sentinel source family or Microsoft +Graph Advanced Hunting for the XDR family—and record the provider gap. Do not +mix providers within a family or use fallback to conceal a genuine KQL error. +Imported results must bind to the exact persisted query hashes. Zero returned +rows show only that a query executed; they do not demonstrate behavioral +coverage, alert parity, or that data is present. + +Keep operational reports, provider responses, workspace IDs, and raw lab +evidence under the ignored solution `Logs/` run directory. Review outputs +before sharing. The separate `export-evidence` command is an explicit, +allowlisted optional public snapshot under `Evidence/`; it omits raw +messages and tenant identifiers, cannot be used to resume, and is not included +in a Marketplace package. Do not commit `.env` or `Logs/`. + +The deterministic CLI provides conversion, structural validation, query-plan +and result-recording contracts, packaging, and reports. Autonomous selection +and dispatch of advertised MCP tools, cross-provider research, and an +interactive accept/reject decision experience are not implemented by this +package itself; follow the agent capabilities actually available and never +claim these pending orchestration features ran when they did not. + +For offline Authoring, pass Validation only when the persisted discovery and +full-solution conversion stages are current, every source rule and generated +detection is present, structural validation succeeds for the complete +inventory, and technical findings plus hash-bound review decisions are +resolved. The stage records `runtimeStatus=not-run` with +`validationMode=offline-authoring`; this is authoring-only and explicitly not +runtime/XDR qualification. The route dispatches no queries. Existing runtime +reports—including optional Advanced Hunting results—cannot be replaced by +offline evidence; preserve and resolve their findings through the normal +runtime gates. No consent means no optional Advanced Hunting dispatch. + +For an existing Authoring run whose passed Conversion stage predates +`manifestSha256`, recover that exact run's evidence before completing +Validation; do not reconvert, edit workflow state by hand, or create a +replacement run just to repair the receipt: + +```powershell +sentinel-xdr-migration workflow-recover-conversion-evidence ` + --solution "Solutions\" ` + --run-id "" +sentinel-xdr-migration workflow-complete-stage ` + --solution "Solutions\" ` + --run-id "" ` + --stage validation ` + --status passed ` + --artifact runtimeStatus=not-run ` + --artifact validationMode=offline-authoring +``` + +Recovery is permitted only for a missing digest with the exact current run +manifest reference. It rechecks the complete recursive source inventory, +conversion results, generated-target provenance and hash-bound review state, +and full-solution structural validation before persisting the digest and +inventory binding. Any existing digest mismatch, scoped/changed manifest, +inventory drift, missing target, technical finding, or unresolved review +blocks recovery. The command does not modify source or generated detections. +If Validation is not already running, start it after recovery and before the +completion command. + +### Opt-in full-inventory YOLO authoring + +For resumable local authoring with a frozen recursive source-rule denominator, +initialize a **new** run with the already-selected profile and `--yolo`. +Qualification still requires its existing locked target; YOLO does not select a +profile, discover a workspace, or authorize environment writes: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --version-bump none ` + --yolo ` + --campaign-id "" + +sentinel-xdr-migration workflow-yolo ` + --solution "Solutions\" ` + --run-id "" +sentinel-xdr-migration workflow-status ` + --solution "Solutions\" ` + --run-id "" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump none +``` + +The contract freezes every recursive `Analytic Rules` source path, identity, +source hash, and query hash for that run. It reuses existing discovery, +full-solution conversion, and structural validation; it does not invoke runtime +providers. `workflow-yolo-refresh --solution ... --run-id ...` imports only +existing provider reports bound to current query hashes; it does not run +queries or overwrite changed source or generated content. A stale inventory, +source/query hash, or generated target is a conflict to resolve explicitly, +not an invitation to shrink the denominator or discard user edits. + +Coverage reports `N` (all frozen unique source rules), `M` (structurally valid +converted targets with no unresolved rule errors), and `E` (rules with an +unresolved error or suppressed disposition, counted once per rule). +Unprocessed, blocked, and suppressed rules remain in the denominator. Shared +provider/environment failures are recorded separately from per-rule errors. +The run stops when +`100 * E > 50 * N`; once stopped, better later evidence does not resume it. +`100 * M >= 80 * N` is advisory only and does not authorize packaging, PR +creation, qualification, deployment, ingestion, or any other write. The normal +review, validation, explicit approval, and packaging gates remain in force. + +For YOLO runs, `package-v3-1` requires the passed workflow validation stage and +complete inventory to clear all technical and explicit-review gates. It +persists requested/effective/consumed version-action state and hashes the +package inputs, report, and artifacts. Retry with `--version-bump none` only +after the original action was consumed and when current inputs match the +recorded post-build hash; changed inputs require an explicit new bump choice. +Retries execute V3.1 again and must produce fresh evidence. Stale reports or +artifacts, including historical V4 evidence, cannot pass the packaging stage. + +YOLO cannot be enabled by resuming a legacy run. Use `--new-run` with an +explicit profile and `--yolo` to start a separate run. Always select the exact +run ID when reading or writing if there is more than one. The optional +`export-evidence --include-authoring-contract --run-id ""` flag includes +the selected run's validated contract projection in the explicit local evidence +export; the default export remains unchanged. + +#### Durable campaign budget + +Every new YOLO run requires an explicit `--campaign-id`. Campaign budgets are +opt-in for ordinary authoring workflows: pass `--campaign-id` to +`workflow-init` to bind one; without it, ordinary workflow initialization and +packaging retain their unbudgeted behavior and create no campaign artifacts. +The frozen inventory, policy, linked run IDs, and hash-chained append-only +receipts are stored outside the files-only run directories under the solution's +ignored `Logs/sentinel-xdr-migration-campaigns//` directory. +Resumes retain that binding. An explicitly linked replacement run must use the +same campaign ID and exact solution/inventory; changing the run ID does not +reset usage. A different campaign ID is a separate explicitly initialized +campaign, never an automatic retry identity. +Changing source inventory under an existing campaign is rejected; starting +another campaign requires an explicit campaign ID and reviewed scope. + +The initial ceilings are 120 active minutes per campaign, 3 evidence-backed +rule revisions and 10 active minutes per rule, 3 full-solution conversion +passes, 3 V3.1 builds, 500 actual provider HTTP attempts including retries, and +one ingestion submission per exact approved pair/fixture attempt. Observation +deadlines are recorded per approved test in its schedule-aware UTC timestamp. +Rule-level revisions, HTTP attempts, ingestion submissions, and observation +polls have receipt APIs for provider/adaptation code; they are **not currently +dispatched by this Python workflow** and are not represented as enforced +provider limits. Each retry must call the provider-attempt receipt separately. +The current workflow measures and charges dispatched YOLO conversion passes +and V3.1 builds, including failed/cancelled calls. Active elapsed time is +measured around YOLO discovery, conversion, structural validation, and V3.1 +packager calls (plus explicit observation API calls); manual workflow/report +bookkeeping and provider/deployment/ingestion/cleanup calls are not integrated. +Already-running work is not forcibly terminated if it overruns a ceiling, so +the active-time ceiling blocks subsequent work but cannot preempt an in-flight +call. The status report labels this coverage; no AI-credit or monetary usage is +inferred. + +Inspect current usage and extend a ceiling only with an explicit actor, reason, +and owner-scope audit reference: + +```powershell +sentinel-xdr-migration workflow-budget ` + --solution "Solutions\" ` + --campaign-id "" + +sentinel-xdr-migration workflow-budget-extend ` + --solution "Solutions\" ` + --campaign-id "" ` + --dimension conversionPasses ` + --new-limit 4 ` + --actor "" ` + --reason "" ` + --reference "" +``` + +Exhaustion blocks new work and stops that workflow run without altering the +frozen coverage denominator or converting unprocessed rules into errors. An +extension never clears a stopped run, technical conflict, or approval gate; +after review, an explicitly linked replacement run can continue from the same +campaign's remaining allowance. Pre-budget runs are marked usage-unknown and +cannot dispatch budgeted work until an owner-reviewed measured baseline is +provided with evidence via `workflow-budget-reconcile-legacy`; historical use +is never reconstructed from timestamps or artifacts. Interrupted operations +remain pending and block dispatch until reconciled with an authoritative outcome +and measured elapsed time using `workflow-budget-reconcile`. Reconciliation +does not replay an attempt; an unknown ingestion result remains consumed and +requires a fresh marker and exact-scope approval before a new submission. +For legacy reconciliation, `--baseline` must reference reviewed JSON with all +seven fields: `activeExecutionSeconds`, `conversionPasses`, `packageBuilds`, +`providerHttpAttempts`, `ingestionSubmissions`, `ruleRevisions`, and +`ruleActiveSeconds`. The last two are objects keyed by every source GUID in +the frozen inventory. Attempt counters must be whole numbers. The CLI records +the submitted baseline and audit reference as owner-provided evidence; it does +not infer the values. + +The existing CLI owns the run state under its workflow lock, while the shared +campaign ledger uses its own atomic writer lock and fsynced replacement. The +operation-start receipt is durable before dispatch; failed and cancelled +attempts remain charged. Receipt/hash or lock failures fail closed. Direct +Python calls to packaging internals and provider adapters that do not use the +receipt API remain outside enforcement; no such integration is claimed. + +### Single-rule conversion + +For an explicitly reviewed pilot or isolated refresh, select exactly one source +analytic rule template GUID (not a deployed rule ID or filename): + +```powershell +sentinel-xdr-migration convert --solution "" --run-id "" --rule-id "" --config "" --overwrite +``` + +`--rule-id` accepts one full hyphenated GUID, matched case-insensitively while +preserving the source's spelling in generated identity. Repeated selectors, +malformed IDs, no match, duplicate matching source IDs, and a selected ID present +in `excludedRuleIds` fail before creating or changing outputs, reports, logs or +state. Reconcile a selected exclusion explicitly; selection never means deletion. +All source/output identities and provenance remain inventoried for collision +checks. Existing overwrite, independent version, review, connector-metadata and +source-provenance contracts still apply. + +Only the selected rule's nested output path may be written. All unselected drafts +remain byte-for-byte unchanged, even with `--overwrite` and unselected config +exclusions. Without `--overwrite`, changed selected content remains a conflict. +Omitting `--rule-id` retains full-solution conversion, including its existing +explicit-overwrite deletion behavior for excluded outputs. + +The selected run's `manifest.json` and transformation report are replaced, not +merged with earlier full-solution results. `scope.kind=rule`, `scope.ruleIds`, +`scope.sourceTotal` and `scope.selectedTotal` identify exactly what was attempted; +counts describe only that rule. Repeated pilots do not accumulate full coverage. +Structural `validate` still checks all existing detection files and exposes +`conversionScope`; valid files are not proof that all source rules were converted. +Previously passed conversion/downstream workflow stages become blocked, retaining +historical evidence; discovery and qualification target locks are unchanged. +Solution-stage success and the toolkit packaging entry point are blocked until +full-solution conversion and the normal validation/review gates are completed. +Conflicts/needsReview still return CLI exit code 1; invalid selection returns 2. +A successful scoped conversion is only local rule authoring, never full-solution +readiness, runtime validation or approval to deploy. + +Scope evidence and guards are run-local, just like other workflow evidence; +content YAML remains shared across runs. Do not reuse another run's prior +validation/package evidence after changing shared content. The selector does not +introduce scoped packaging, deployment, runtime validation or a gate waiver. + +### Solution-local reports and legacy resumption + +All new reports, logs, workflow state, qualification target, migration +configuration, and captured runtime evidence belong in +`Solutions//Logs/sentinel-xdr-migration//`. All solution `Logs` folders +are Git-ignored. Do not force-add them: provider responses, error messages, +query/alert evidence, and lab identities may be sensitive. Optional curated +PR snapshots are described below. Tokens/authentication records and reusable +cross-solution onboarding configuration remain in the existing user credential +store; they are never copied into a solution. Shared synthetic sample catalogs +and reviewed mock fixtures retain their existing `Sample Data` locations. + +Each run has a collision-safe UTC timestamp/UUID ID recorded in `run.json` and +workflow state. `workflow-init` resumes the sole existing run by default. +`workflow-init --new-run --workflow-profile authoring --solution ""` +explicitly starts an isolated workflow with a new ID. Runs do not copy another +run's configuration, reports, or stage results. Detection YAML and Package +content remain shared solution content; a new run does not snapshot those files. + +Use `workflow-runs --solution ""` to list IDs. Every solution CLI +command accepts `--run-id ""`. When multiple runs exist, omitting it fails +instead of choosing the newest run or stale evidence. A selected nonexistent ID +also fails, except when explicitly applying a migration preview. Python callers +can scope existing APIs with `artifacts.using_run(solution, run_id)`. Direct CLI +writers without a workflow create one run on first use and reuse it thereafter. + +Status reads can fall back to prior flat solution Logs, +`Reports//sentinel-xdr-migration/`, +the old `AZURE_SENTINEL_REPORTS_ROOT` location, and report files in +`XDR Detections`. Reads warn but never move state or initialize a new workflow. +Before the next write, review and explicitly apply the copy: + +```powershell +sentinel-xdr-migration migrate-reports --solution "Solutions\" +sentinel-xdr-migration migrate-reports --solution "Solutions\" --run-id "" --apply +sentinel-xdr-migration workflow-status --solution "Solutions\" --run-id "" +``` + +The entire copy is preflighted for conflicts. Originals remain untouched; +`/legacy-artifacts.json` records their hashes. The preview's `runId` +must be passed to apply to use that exact destination. The previous flat-Logs +ledger is honored: newer flat workflow state supersedes the archived root +Reports state only when the archived original still matches its recorded hash. +Unreceipted conflicts stop the copy. Migrated artifacts belong only to their +importing run, never to subsequent new workflows. Changed legacy copies or missing +migrated files block resumption rather than selecting stale evidence. Repeated +apply is idempotent. Reconcile conflicts manually; never discard or replace +blocked workflow state. Nonportable/credential-bearing migration configuration +requires explicit review rather than automatic KQL rewriting. + +Persisted known filesystem references are solution-relative (including the +run ID for reports), with `/` +separators. The workflow packaging gate and summary report resolve these +against the selected solution; old absolute and repository-relative references +remain readable. Move/copy external evidence into the solution before relying +on portability. External machine paths and raw provider messages are not +arbitrarily rewritten; Logs are not a sanitized public export. Lab tenant, +subscription, and workspace IDs are retained to preserve qualification locks. +Known credential fields, bearer tokens, and signed URL values are redacted in +report serialization, but this is not a guarantee that raw provider payloads +are safe to publish. Source queries and detection YAML are never sanitized or +rewritten by reporting. + +Content discovery excludes `Logs`, `Reports` and `Evidence` subtrees. Only detection YAML +belongs under `XDR Detections`; solution Data content arrays and Marketplace +ZIPs must not include runtime artifacts. The old report-root environment +variable is read-only compatibility input, not a new-output override. + +### Optional PR evidence export + +After a run is stable, explicitly export its public review snapshot: + +```powershell +sentinel-xdr-migration export-evidence --solution "Solutions\" --run-id "" +``` + +This local-only command needs no tenant, authentication or cloud-write approval. +It writes only `Evidence//evidence.json` and `summary.md`, relative to +the selected solution. These files may be reviewed and committed; raw `Logs` +remain ignored. Export is never automatic, does not rerun tests, does not +update reports/state/logs, and refuses any existing destination (including the +same run). Multiple runs require explicit selection; legacy flat reports must +first follow the normal report migration process. Evidence is never a runtime +input, resume source, solution Data content input or Marketplace ZIP input. + +Schema `1.0.0` is a fixed allowlisted projection: all eight stage statuses and +attempt counts, profile/version-bump decisions, fixed conversion, structural, +three runtime-provider, V3.1 packaging, query-parity, alert-parity and solution +reports. Every result is retained, including failed, blocked, pending, not-run, +excluded and needs-review results; unfamiliar statuses become `unknown`. +Missing/invalid reports and unsupported fields are explicit. Historical baseline +decisions and command history have no supported contract and are marked +unsupported, not inferred from prose. The exporter records its real version, +Python version and normalized invocation, not invented historical commands. +Generated detection provenance supplies converter/content versions when available. + +The summary header names the public solution folder (never its local parent +path), run, profile and recorded workflow status. Structured `solution` metadata +distinguishes the current version observed in `SolutionMetadata.json` and the +single `Data/Solution_*.json` from the requested version action. Missing, +invalid, conflicting or multiple-copy version inputs remain unknown. A requested +target version is not inferred from a bump: there is no supported persisted +target-version contract. In particular, current `3.0.18` plus requested `minor` +does not claim that `3.1.0` was packaged. The UTC `exportedAt`, exporter version +and Python version describe this export, not historical migration execution. +Identical inputs and the same export timestamp produce the same projection. +Older snapshots without export timestamps render them as unknown; filesystem +modification times and current run facts must not be substituted. +Explicitly authorized header-only enrichment of an older snapshot must preserve +all original results, hashes and exporter identity. Record a separate UTC +`headerUpdatedAt` and `headerRenderer`; if reading today's solution version, mark +`solution.versionObservation` as `header-update`, not `export`. The renderer +labels this new observation separately and keeps an unrecorded original export +time unknown. This does not enable overwrite/refresh in `export-evidence`. + +Only a checked public solution title, enumerations, bounded counts, strict +version/timestamp strings, SHA-256 hashes and +checked solution-relative content references are exported. No raw messages, +errors, queries, alerts, records, URLs, provider responses, arbitrary stage +artifact values, IDs or unknown fields are copied. Unsafe references (including +symlinks, traversal, identifier/credential-like or unusual filenames) are withheld. +Hashes describe export-time bytes; source-query provenance mismatches are +explicit, but historical validation-to-content hash binding is unsupported. +Reported passes are **not independently verified or trusted attestations**. +Changing observed inputs during export aborts it; export only stable runs. + +Review the snapshot before publishing: solution names and content filenames are public-facing +metadata, and no heuristic can recognize every secret embedded in an otherwise +ordinary name. Titles with URLs, paths, control/markup characters or recognized +identifier/credential patterns are withheld. Rename sensitive content through the normal authoring +process, never publish raw operational reports to compensate for omitted detail. +Unknown report filenames/contracts are not auto-discovered or copied. + +### Resumable end-to-end workflow + +The repository agent uses a gated workflow manifest. Before initialization, +the user must explicitly select `authoring` or `qualification`; the CLI has no +profile default. The `authoring` profile coordinates discovery, conversion, +validation, V3.1 packaging, and final reporting: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --version-bump patch + +sentinel-xdr-migration workflow-status --solution "Solutions\" +sentinel-xdr-migration workflow-next --solution "Solutions\" +``` + +Every packaging stage runs through: + +```powershell +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --version-bump none +``` + +For separate solution and toolkit worktrees, add `--toolkit-root ""`. +The command still uses the gated workflow packaging route: it invokes that +toolkit's `V3/createSolutionV3_1.ps1` (V3.1), while reading source content and +writing package files and evidence under the selected solution. The toolkit +root must contain the complete required V3.1 helper and template set. Without +the option, the solution's repository remains the toolkit root. The command +writes `Solutions\\Logs\sentinel-xdr-migration\\packaging.v3_1.json`. +A packaging stage cannot pass without that current-run V3.1 evidence and all +generated package artifacts. + +Old V4 reports remain historical evidence; do not relabel them as V3.1. +When current solution inputs change after a successful package, `workflow-status` +shows `packageFreshness: stale` and `workflow-next` reports that an explicit +release action is required. Select the action against the exact package-input +hash reported by status, then package with that same action: + +```powershell +sentinel-xdr-migration workflow-plan-package-release ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump patch ` + --expected-package-input-sha256 "" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump patch +``` + +The new action must be explicitly `patch`, `minor`, or `major`; status reads +never change workflow state. The old V3.1 report and package artifacts are +preserved under that run's `package-history` evidence before rebuilding. A +changed input hash invalidates a pending choice. If a build consumes the +selected version but fails, a retry may use `--version-bump none` only for the +matching post-build inputs. Existing technical and unresolved entity-review +gates remain mandatory. + +For the first V3.1 package of an offline Authoring run initialized with +`--version-bump none`, use the same explicit release-plan command with the +current package-input hash from `workflow-status` and select the intended +`patch`, `minor`, or `major` action. The offline validation record must be +current and bound to the complete inventory before this action is eligible; +Qualification continues to require its runtime-bound validation and selection +evidence. + +An explicit target version may be selected instead of a bump when the release +must move directly to a particular supported V3.1 version. Targets must be +canonical numeric `major.minor.patch` versions in the inclusive range +`>=3.1.0` and `<4.0.0` (for example, `3.999.999`); the target does not have to +be the next consecutive version. Select the exact target at initialization, or +bind it to the current package-input hash when planning a later release. Use +`--version-bump none` with `--target-version`; a non-`none` bump and target +cannot be combined: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --version-bump none ` + --target-version 3.1.2 + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --version-bump none ` + --target-version 3.1.2 +``` + +For a later release after inputs change, use +`workflow-plan-package-release --target-version ` with the current +input hash, then package with the same target. The V3.1 backend writes the +approved target to solution data and metadata. If packaging fails after that +version write, retry with `--version-bump none` and no target only when the +post-write input identity still matches; this rebuild does not change the +version again. Existing hash, approval, campaign, and packaging gates still +apply. This target route does not change independently authored Custom +Detection versions or the ReleaseNotes-only refresh workflow. + +For an offline Authoring run whose package is already at the approved version +but whose release-note row was omitted, use the same-release refresh route +instead of choosing another patch/minor/major bump. It requires a current +successful V3.1 receipt, current full offline validation, and a clean package +input hash. The plan records the exact approved date and change-history text, +hashes every current package input, archives the old package evidence, and +expects only `ReleaseNotes.md` to change. Add the exact planned row immediately +below the ReleaseNotes table separator, then rebuild with `--version-bump none`. +Header labels and order are checked as trimmed Markdown table cells; existing +column-alignment whitespace and release-history bytes are preserved: + +``` + +For a later, explicitly approved **non-ReleaseNotes** content correction that +must keep the already packaged solution version, use +`workflow-plan-same-version-content-rebuild`. This separate Authoring-only route +requires the exact current full package-input hash, the unchanged current +solution version, a successful V3.1 receipt from the selected run, and passed +current full-inventory offline conversion/review/structural gates. List each +approved changed input with `--changed-input`; ReleaseNotes paths are rejected. +The plan archives the old V3.1 receipt/artifacts and a hash-verified snapshot of +the current package inputs. The actual V3.1 build must use `--version-bump none` +without `--target-version`; it cannot change the solution version or substitute +for Qualification runtime gates. Every retry still runs V3.1 and must match the +same plan and inputs. + +New V3.1 receipts include a complete per-file hash manifest for the successful +post-build package inputs. The planner derives the actual additions, removals, +and modifications by comparing that receipt-bound baseline with the current +input tree; the `--changed-input` set must match exactly. Unchanged declarations, +unlisted edits, and any ReleaseNotes change are rejected. For an older successful +receipt without a per-file manifest, planning fails closed unless +`--legacy-baseline-evidence` identifies a comparison JSON that reconstructs the +complete prior manifest and whose canonical aggregate SHA-256 exactly matches +the successful receipt. The evidence binds the prior receipt identity, current +input identity, file counts, and each changed path's before/current hashes. +New plans persist the evidence path and digest and recheck it on dispatch, +receipt acceptance, and retries. A pre-existing legacy plan without this binding +must pass the same `--legacy-baseline-evidence` path to each `package-v3-1` +attempt; no workflow state or historical receipt is rewritten. +If no exact baseline can be reconstructed, use the ordinary explicit +new-release workflow rather than weakening the same-version gate. + +```powershell +sentinel-xdr-migration workflow-plan-same-version-content-rebuild ` + --solution "Solutions\" ` + --run-id "" ` + --target-version 3.1.2 ` + --expected-package-input-sha256 "" ` + --changed-input "XDR Detections\.yaml" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump none +``` + +For a legacy successful receipt, add the same solution-relative evidence path +to the plan command and, for a pre-existing plan, to every package attempt: + +```powershell +sentinel-xdr-migration workflow-plan-same-version-content-rebuild ` + --solution "Solutions\" ` + --run-id "" ` + --target-version 3.1.2 ` + --expected-package-input-sha256 "" ` + --changed-input "XDR Detections\.yaml" ` + --legacy-baseline-evidence "Logs\sentinel-xdr-migration\\same-version-content-rebuild\receipt-current-input-comparison.json" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump none ` + --legacy-baseline-evidence "Logs\sentinel-xdr-migration\\same-version-content-rebuild\receipt-current-input-comparison.json" + +sentinel-xdr-migration workflow-verify-same-version-content-rebuild ` + --solution "Solutions\" ` + --run-id "" ` + --legacy-baseline-evidence "Logs\sentinel-xdr-migration\\same-version-content-rebuild\receipt-current-input-comparison.json" +``` + +The verification command is read-only and proves package scope/receipt +integrity only; it does not mark the workflow complete or replace local suite +validation. + +```powershell +sentinel-xdr-migration workflow-plan-release-notes-refresh ` + --solution "Solutions\" ` + --run-id "" ` + --release-date "12-10-2026" ` + --change-history "Approved release-note text" ` + --expected-package-input-sha256 "" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump none +``` + +The packaging gate verifies the exact approved row hash and rejects any other +input, inventory, or ReleaseNotes change. V3.1 runs again and must produce a +new receipt for the updated inputs; the old package is historical evidence, +not relabeled. The package version and consumed patch action are not changed +or consumed again. This route is Authoring-only; it does not satisfy or bypass +Qualification runtime, target, consent, or deployment gates. If the previous +package receipt is missing, stale, or no longer matches the complete current +input tree, use the ordinary explicit new-release workflow rather than this +same-version route. + +For item-level failures in a complete run, use +`package-selection-plan --solution ... --run-id ...` to inspect every current +Analytic Rule and Custom Detection, including query and full-file hashes, +provider/target bindings, evidence paths, and exclusion reasons. Apply only the +reviewed plan with `package-selection-apply` and its exact +`--expected-plan-sha256`; this changes only the content-selection arrays in +`Data/Solution_*.json`, adding `XDR Detections` only when it was +absent and the plan bootstrapped candidates from the complete conversion +manifest. An explicit empty array stays empty; all current conversion outputs +remain represented as included or excluded inventory. The plan and receipt +are revalidated before the V3.1 build, and the generated template and ZIP must +match its selected and excluded inventory. Apply rechecks the staged file +identity and performs a post-receipt freshness check. The migration writer +lock serializes this tool's +own updates, and atomic path exchange detects changes present at the commit +boundary. Apply requires that no external editor or process modify +`Data/Solution_*.json` until it completes; external writers do not honor the +migration lock, so this is a caller precondition rather than a general +filesystem compare-and-swap guarantee. If a change is detected, apply stops +and retains conflict evidence where possible. A receipt published before a +later detected edit is retained but rejected as current selection evidence. +### Partial solution publication and Qualification + +An evidence-based partial V3.1 package is allowed only when the number of +unique original Sentinel rule IDs represented by currently eligible, +deployable Custom Detections is **strictly greater than 50%** of the complete +current recursive source-rule inventory (`2 * validated > original`). Exactly +50%, under 50%, zero, and empty/invalid denominators are blocked. The +denominator is independent of `Data/Solution_*.json` selections; a missing +source from a manifest, scoped manifest replacement, duplicate reference, or +source-only Analytic Rule cannot reduce it or increase the numerator. + +The selection plan records the original source inventory and hash, validated +migrated IDs, numerator/denominator, coverage, selected evidence scope, and +each excluded identity and reason. Only current structural, review, exact +source/target/provider/query, and required schema evidence counts. Malformed or +ambiguous global evidence remains fatal. The V3.1 package contains only +selected eligible items; excluded YAML and original failures remain visible +and unchanged. `partial-package-validated` is not full-inventory validation. + +Qualification may continue for that same applied selection. Deployment, +ingestion, and alert-parity executors must use the exact selected AR/CD scope +and keep their existing per-item checks, target binding, permissions, +disabled-state, ownership, consent, immediate write approvals, and cleanup +gates. A successful selected-scope Qualification or parity result must never +be described as full-inventory success. A changed selection or bound artifact +invalidates the receipt/package and requires re-planning. Full conversion, +validation, packaging, and Qualification behavior is unchanged when the +complete inventory is valid. See the canonical +[evidence-based package selection contract](agent-workflows/end-to-end-migration.md#evidence-based-package-selection) +for exact limits and commands. + +An explicit empty XDR array means no Custom Detection installation or +registration resources. A missing array can be bootstrapped only during +planning from manifest outputs; direct V3.1 packaging without an explicit +array still fails closed. Runtime reports must carry their own matching +run/provider/target execution bindings; sidecar authorization cannot make an +unbound report eligible. Current source structure accepts both Scheduled and +NRT kinds with their corresponding required/forbidden scheduling fields. + +Package-version selection is unchanged, but V3.1 +rejects effective solution and XDR release versions outside `[3.1.0, 4.0.0)`, +and other content-item release versions `>= 4.0.0`. Explicit bumps that select +an out-of-range version fail without overwriting package files or persisting +the bumped source version. These are release bounds, not ARM API/schema bounds. +XDR uses only its top-level YAML `version`, including when registration is +disabled. Registration metadata and content product IDs use that same version. +Data `XDR Detection Version`, solution version, and source provenance are not +fallbacks in V3.1. See "Independent XDR release version" above for older files. +Optional `trackingId` in SolutionMetadata.json is the complete Partner Center +customer usage attribution ID, not a bare GUID, Partner ID, or plan ID. +When absent/blank, V3.1 attempts anonymous **public Marketplace catalog/template** +discovery (not authenticated Partner Center API access) using exact publisher and +offer identity and one unambiguous plan/marker. It saves the validated full ID +into authoritative SolutionMetadata.json and emits the current marker shape. +Explicit IDs win without lookup. Set `SENTINEL_SKIP_ATTRIBUTION_LOOKUP=1` for +offline attribution; the CLI inherits it without an additional flag. +Lookup failures leave metadata unchanged and preserve prominent warnings in the +packaging report; successful provenance is recorded separately in +`attributionSources`. Local metadata write failures stop packaging. +Partner Center cannot auto-add tracking for functional nested deployments. +If lookup cannot resolve it, obtain the ID from offer > plan > Technical +configuration, add it, and rebuild. Missing attribution does not imply upload +rejection. See the [V3.1 guide](../Create-Azure-Sentinel-Solution/V3/README-V3_1.md) +and [official attribution guidance](https://learn.microsoft.com/partner-center/marketplace-offers/azure-partner-customer-usage-attribution#microsoft-marketplace-azure-apps). + +The workflow state is written to: + +```text +Solutions\\Logs\sentinel-xdr-migration\\workflow-state.json +``` + +Each stage is explicitly started and completed so interrupted runs can resume +without relying on conversation history: + +```powershell +sentinel-xdr-migration workflow-start-stage ` + --solution "Solutions\" ` + --stage discovery + +sentinel-xdr-migration workflow-complete-stage ` + --solution "Solutions\" ` + --stage discovery ` + --status passed ` + --artifact inspection="Logs\sentinel-xdr-migration\\inspection.json" ` + --evidence "Analytic Rules\Example.yaml" +``` + +Stages accept `passed`, `failed`, or `blocked`. Failed and blocked stages +require an explanation and can be retried after their cause is corrected. +Downstream stages remain locked until every dependency passes. The contract is +defined by [`schema/workflow-state.schema.json`](schema/workflow-state.schema.json). + +#### Event-driven durable coordinator + +The optional local coordinator adds an accountable owner and a durable, +idempotent event/outbox registry around an already initialized workflow run. +It derives stage decisions only from `workflow-status` and `workflow-next`; +those existing commands remain authoritative for all stages, dependencies, +evidence, and gates. The ignored registry is local to the current checkout at +`Tools/ignored/sentinel-xdr-coordinator/registry.json`. It contains run and +target identifiers plus hashes, not credentials or provider response bodies. + +```powershell +sentinel-xdr-migration coordinator-enroll ` + --solution "Solutions\" ` + --run-id "" ` + --owner "" + +sentinel-xdr-migration coordinator-reconcile ` + --solution "Solutions\" ` + --run-id "" + +sentinel-xdr-migration coordinator-status ` + --solution "Solutions\" ` + --run-id "" +``` + +`coordinator-status` performs deterministic reconciliation and persists any +expired-claim, decision, and outbox updates; it is not a read-only registry +inspection. + +An optional `--shared-dependency ACTION=KEY:PURPOSE` on enrollment records a +scoped external prerequisite. A hash-bound `shared-dependency-completed` event +re-evaluates only consumers of the same key, purpose, and target fingerprint. +For example, Graph `advancedHunting` evidence never satisfies +`customDetection` authorization. In Validation, source and Advanced Hunting +runtime tasks are reported independently; a blocker in one does not suppress +the other. These task views do not split or complete the canonical Validation +stage. Shared-dependency evidence is reported by its submitting actor and +hash-checked locally; the coordinator does not validate permission grants, +service configuration, or the truth of that evidence. + +Claims are atomic, idempotent reservations for currently `ready` actions. They +do not start a stage or execute a CLI command. An executor must use the +existing stage and validation entry points, then submit a hash-bound completion +event; reconciliation consumes the handoff only when current official workflow +state or exact evidence supports it. An expired claim becomes blocked with an +unknown outcome. Cloud requests are never retried by the coordinator; an +accountable owner must resolve the outcome from authoritative evidence before +another attempt. An exact current target and evidence hash from the claim's +officially terminal workflow stage (`passed`, `failed`, or `blocked`) can +resolve it through the completion event or +`claim-resolved --outcome completed`; this records coordinator state only and +does not write or complete the official workflow stage. Confirming `not-started` +still requires separate hash-bound owner evidence. + +When a terminal stage exposes a same-ID retry with changed evidence, the retry +remains blocked until the prior claim's completion handoff is validated against +its retained action snapshot and consumed. + +Every changed decision, including `approval-needed`, is a durable outbox entry, +not an approval. Use `coordinator-event --kind approval-requested` to record a +separate exact action, purpose, and target request; a host adapter may read the +outbox and deliver it only after rechecking the current action hash and target. +Reconciliation supersedes stale outbox entries. `coordinator-ack` acknowledges +delivery only. No host messaging adapter, background daemon, authentication, +consent, deployment, ingestion, parity, or automatic command dispatch is +implemented. Qualification writes remain +blocked behind the existing exact-scope approval and guarded CLI paths. +Read-only preflight may be recommended but does not prove service settings, +permissions, or runtime results. Status/reconcile surface missing owners, +idle ready actions, running stages without tracked claims, expired claims, +unconsumed completion handoffs, and undelivered outbox items as watchdog +exceptions; the watchdog runs only when a CLI command is invoked. +An existing owner can record an auditable `owner-handoff` event to transfer +accountability; an ownerless work item can be re-enrolled with an explicit new +owner. + +#### Solution watchdog best practice and `ACTION_REQUIRED` + +Assign exactly one retained watchdog owner per solution/current workstream, +bound to its explicit run ID, profile, agreed goal, and approved scope. A +global observer may aggregate status but does not replace or duplicate the +owner. The existing owner performs the watch; do not launch replacement or +nested agents to re-check the same work. Missing ownership is escalated to the +responsible parent. Keep one read-only periodic watch until the goal is +verified complete or explicitly stopped/cancelled. Cadence is explicitly +configurable per coordination instruction; the current setting is three +minutes, not a fixed toolkit timer. Avoid hot polling and interruptions. A fresh run +retargets the same owner/schedule and preserves history without importing old +run successes. Completion requires the goal-specific persistent artifact and +bound evidence, not an idle agent, status message, toolkit commit, or +watchdog acknowledgement; stop the watch on real completion or explicit +stop/cancellation. + +Use a prominent, stable `ACTION_REQUIRED` handoff through the existing agent +completion/handoff channel when an approved next operation has not started or +a concrete gate needs parent action. Include solution/run/goal/owner, exact +next operation or prerequisite, responsible parent, current approval +reference/scope (or `missing`), readiness evidence and stale/missing blockers, +last **real solution progress** time, elapsed stalled time (or `unknown`), and +a repeatable alert identity. Repeat it on each ordinary tick until genuine +progress, verified completion, explicit revocation, or an exact blocker +resolves it. Status updates, commits, and watchdog acknowledgements do not +reset the timer or clear the alert. The parent consumes the alert by performing +the already-approved bounded step, obtaining the required missing approval, or +reporting the precise blocker—not by waiting for the user to repeat the ask. + +Example: + +```text +ACTION_REQUIRED solution=ExampleSolution runId=run-example +goal=publish validated partial package (>50% migrated-CD coverage) +watchdogOwner=solution-owner nextOperation=apply reviewed plan and build V3.1 +responsibleParent=parent-coordinator approval=granted/ref-01/local-authoring-only +readiness=blocked missing=2 of 3 source rules lack current validated CDs +lastRealProgressAt=2026-10-12T09:00:00Z elapsedStalled=28m +alertIdentity=ExampleSolution/run-example/selection-coverage +``` + +This is an orchestration best practice, not a new scheduler or CLI feature. +Read-only watchdog ticks do not authorize toolkit/content/workflow changes, +build/package operations, cloud/auth/ingestion/enablement/deletion, or other +writes. Actual operations remain with the existing sole executor and separate +approval gates. Do not repeat unchanged already-granted common choices or +expand beyond approved scope. + +Deployment, mock ingestion, and strict AR/CD alert parity are optional internal +qualification stages. They are not required for ISV authoring, PR submission, +Partner Center publishing, or customer deployment. To include them in an +approved non-production lab workflow, initialize with: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile qualification ` + --tenant-id "" ` + --subscription-id "" ` + --workspace-resource-id "" ` + --workspace-customer-id "" ` + --version-bump patch +``` + +Qualification writes a locked +`Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json`. Live +commands load this context and reject any tenant, subscription, workspace ARM +ID, or workspace customer-ID mismatch. + +Diagnose an unavailable exact ARM path without broad workspace discovery: + +```powershell +sentinel-xdr-migration qualification-diagnose ` + --solution "Solutions\" +``` + +The diagnostic may perform one exact customer-ID lookup restricted to the +locked subscription. Apply its unique same-identity correction only after +explicit approval: + +```powershell +sentinel-xdr-migration qualification-repair-target ` + --solution "Solutions\" ` + --approve-target-update +``` + +Inspect a solution: + +```powershell +python -m sentinel_xdr_migration.cli inspect ` + --solution "Solutions\Azure Activity" +``` + +Convert every analytic rule: + +```powershell +python -m sentinel_xdr_migration.cli convert ` + --solution "Solutions\Azure Activity" +``` + +Every conversion run automatically creates: + +```text +Solutions\\Logs\sentinel-xdr-migration\\transformation-report.html +``` + +The self-contained report shows attempted, converted, needs-review, and +conflict counts, followed by each rule's output status, warnings, and errors. +It can be opened locally without a server or external assets. + +Validate the generated YAML: + +```powershell +python -m sentinel_xdr_migration.cli validate ` + --solution "Solutions\Azure Activity" +``` + +Validate converted queries through Microsoft Graph Advanced Hunting: + +```powershell +python -m sentinel_xdr_migration.cli validate-advanced-hunting ` + --solution "Solutions\Azure Activity" +``` + +The Graph runtime validator submits each current converted Advanced Hunting +query exactly as authored, once per detection (subject only to the bounded +provider retry policy). It does not run table, function, schema, or other +preflight queries. A query failure is reported against that exact query and +remains failed; provider and malformed-response failures remain provider +errors. Successful responses must include the provider-returned named and +typed output schema, including when the query returns zero rows. Reports retain +the actual schema, exact row count, and at most ten provider-returned sample +rows per detection. It creates: + +```text +Solutions\\Logs\sentinel-xdr-migration\\runtime-validation.graph.json +Solutions\\Logs\sentinel-xdr-migration\\runtime-validation.graph.html +``` + +Agents using the official Triage MCP should inspect its advertised capabilities +and use suitable MCP tools first for all supported runtime query operations. +For Advanced Hunting, execute the queries from `validation-plan`, normalize the +results, and record them through the same reporting contract: + +```powershell +sentinel-xdr-migration record-runtime-validation ` + --solution "Solutions\Azure Activity" ` + --provider triage-mcp ` + --results "" +``` + +This creates `runtime-validation.triage-mcp.json` and +`runtime-validation.triage-mcp.html`. Each passed result must include +`querySha256`, the SHA-256 of the exact persisted Sentinel query submitted to +the MCP or fallback provider. Missing/stale hashes are recorded as blocked. +The public CLI does not implement or host an MCP server; the repository skill +invokes the configured official MCP. + +The runtime report includes a derived `sourceWhitelist` assessment for an +eligible `IdentityInfo` missing-table result, bound to its exact +`querySha256`. The raw `results[]` entry remains failed and unchanged; the +assessment records the canonical table, reason, raw failure, and whether the +complete source family permits the source-only disposition. No manual +acceptance command or separate decision artifact exists. The consolidated +solution JSON/HTML and workflow dashboard also expose the classification +while keeping any XDR or unrelated validation blocker visible. + +For a complete original Sentinel query family, the toolkit also provides a +native Azure Monitor Query SDK runner. It is an explicit Qualification-only +command: it requires the run's locked tenant, subscription, and workspace, +verifies the active Azure CLI tenant and subscription without signing in, then +uses `AzureCliCredential` pinned to the locked tenant and sends the locked +workspace customer ID to the query API. The credential deliberately does not +pass a subscription argument: Azure CLI rejects token requests that specify +both `--tenant` and `--subscription`; the active subscription is independently +checked before any query and is never changed. It does not use shell commands +to transport KQL, modify the query text, add a timespan, or discover +workspaces. Each request passes the exact source query string with +`timespan=None`. The default request timeout is 90 seconds, the complete +family budget is 30 minutes, and at most two bounded transient retries are +attempted per query. Authentication, authorization, transport, partial-result, +and KQL failures remain distinct; no KQL failure triggers provider fallback. + +```powershell +sentinel-xdr-migration validate-sentinel-sdk ` + --solution "Solutions\" ` + --run-id "" +``` + +This executes every recursively inventoried source Analytic Rule exactly once +per attempt and requires a one-to-one, current-provenance mapping to generated +detections; empty, scoped, stale, or incomplete families fail closed. Full +returned table schemas and rows—including typed values in deterministic JSON +form—are kept in the ignored run-local `runtime-validation.azure-monitor-sdk.json` +report. A successful zero-row response means only that the query ran; it is not +parity evidence. An optional `--provider-gap-evidence` binds this complete SDK +family to an approved Triage MCP provider failure. Query families are never +combined across providers. If any SDK query fails, is blocked, or is not run, +the current SDK attempt—including per-query diagnostics and family-completion +status—is persisted before the command returns failure. This replaces older +SDK evidence for the same provider and cannot qualify a fallback family as +successful. + +Use the official Sentinel Triage MCP first when it advertises an appropriate +workspace-query tool. If it fails at the provider level, rerun the entire +family through the SDK command above or import externally executed results +under `--provider log-analytics-cli`; imported results still require exact +`querySha256` values. For Advanced Hunting, use the Graph runtime validator. A +genuine KQL error returned by Triage is preserved as a failed detection and is +not retried through another provider. + +### Qualification deployment-scope planning and advance consent + +For migration-related read operations, request one lab-wide approval covering +all selected solutions and reuse it while the lab definition is unchanged. +Display and record the exact lab identifiers, provider surfaces, and expected +read-operation families. Keep current run/query hashes and bounded execution +checks for every dispatch; adding a solution within that scope does not require +another approval. Reconfirm after a lab or primary-workspace change, approval +withdrawal, or read-scope expansion. This excludes interactive authentication, +Entra consent, roles, and environment writes, and does not bypass existing CLI +consent checks. See the canonical workflow's +[reusable lab read approval](agent-workflows/end-to-end-migration.md#reusable-lab-read-approval). + +During initial Qualification preflight, ask the user to confirm that the +selected, locked Log Analytics workspace is configured as the primary +Microsoft Sentinel workspace in Microsoft Defender XDR for the identity used +for Advanced Hunting. Show its workspace name, customer-ID GUID, and ARM +resource ID, and record the confirmation with the selected run's preflight +evidence. An Azure CLI workspace selection or successful Graph authentication +does not establish this setting. + +Microsoft Graph [`security/runHuntingQuery`](https://learn.microsoft.com/en-us/graph/api/security-security-runhuntingquery?view=graph-rest-1.0) +uses the caller's primary workspace when `workspaceId` is omitted. Even an +explicit `workspaceId` can fall back to the primary workspace when the requested +workspace is unavailable or inaccessible. If the primary workspace does not +match the locked target, or the user cannot confirm it, keep default-workspace +Advanced Hunting execution blocked; do not silently query another workspace or +change the primary setting. User confirmation is preflight evidence, not +automated verification of the response's workspace, effective permissions, +query execution, or output schema. Reconfirm if the target, caller, or primary +workspace setting changes. + +The deterministic runtime-query consent commands bind an explicit lab-read +approval to the confirmed primary setting and the two supported query +operation families. First attest the selected primary workspace and persist +the current run's local inventory: + +```powershell +sentinel-xdr-migration confirm-primary-lab-setting ` + --solution "Solutions\" ` + --run-id "" ` + --actor "" ` + --primary-setting-name "qualification-primary-workspace" ` + --confirm-primary-setting + +sentinel-xdr-migration plan-runtime-query-consent ` + --solution "Solutions\" ` + --run-id "" ` + --source-provider azure-monitor-sdk +``` + +Review the emitted plan before submitting an affirmative selection. It shows +the locked tenant, subscription, resource group, workspace ARM ID and customer +ID; solution/run/profile; exact rule/query inventory, counts and hashes; +provider routes; and authentication requirement classifications. Approve only +the exact Sentinel source-query family, Defender XDR Advanced Hunting family, +or both by repeating `--approve-family`; substitute the `planSha256` returned +by the current plan: + +```powershell +sentinel-xdr-migration review-runtime-query-consent ` + --solution "Solutions\" ` + --run-id "" ` + --expected-plan-sha256 "" ` + --actor "" ` + --approve-family sentinelSourceQueryFamily ` + --approve-family defenderXdrQueryFamily +``` + +No family selection is not consent. `--decline --reason ""` records a +non-authorizing decision; `--revise-plan` defers without granting. Use +`revoke-lab-runtime-query-consent` to withdraw an existing grant, or +`check-runtime-query-consent --family --provider ` to +inspect an exact current binding. The durable grant applies across solutions +and runs for the unchanged locked lab, primary setting, and selected read +families. Query hashes, counts, providers, and auth classifications remain +current per-run dispatch/evidence bindings; changes refresh the snapshot and +must pass runtime checks, but do not by themselves require another lab grant. +Interactive sign-in always needs a separate immediate prompt. + +This read-family approval is distinct from both the broader preflight +resource/permission plan and deployment/write approval. It does not authorize +authentication, permission or role changes, or writes. Existing immediate +approvals for deployment, ingestion, packaging, parity, and cleanup remain +unchanged. + +Authoring may optionally run read-only Defender XDR Advanced Hunting schema +checks against its own selected run. Initialize Authoring with an explicit +`--tenant-id`, select the exact run ID, review the current query plan, and +affirmatively approve only `defenderXdrQueryFamily`: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --tenant-id "" ` + --version-bump none + +sentinel-xdr-migration plan-runtime-query-consent ` + --solution "Solutions\" ` + --run-id "" + +sentinel-xdr-migration review-runtime-query-consent ` + --solution "Solutions\" ` + --run-id "" ` + --expected-plan-sha256 "" ` + --actor "" ` + --approve-family defenderXdrQueryFamily + +sentinel-xdr-migration validate-advanced-hunting ` + --solution "Solutions\" ` + --run-id "" +``` + +This consent is bound to the exact run, explicit tenant, Graph auth binding, +and current Advanced Hunting query inventory; it is not reusable across runs +or solutions. The plan performs no provider call or workspace discovery. +Validation requires an existing cached Graph credential for that tenant and +retains the normal per-query execution and returned-schema checks. It does +not sign in, change permissions, select or retarget a workspace, run Sentinel +source SDK queries, or authorize packaging, deployment, ingestion, parity, or +other writes. Missing consent or cached authentication blocks dispatch. This +optional read path does not change Authoring's offline-first workflow. + +During Qualification preflight, review the exact deployment/ingestion plan +before reaching any environment-write approval step. `preflight-deployment` +exposes the additive `deploymentPlanning` object. Its target comes only from +the selected run's locked `qualification-target.json`; this preflight does not +discover or switch targets, start authentication, or make deployment writes. +The existing solution-local `.env` is read only through the target +configuration resolver and is neither authentication nor consent. It is +excluded from source/package fingerprints and all package/evidence exports. + +Persist a current local plan and review the exact scope: + +```powershell +sentinel-xdr-migration plan-deployment ` + --solution "Solutions\" ` + --run-id "" + +sentinel-xdr-migration review-deployment-plan ` + --solution "Solutions\" ` + --run-id "" ` + --expected-plan-sha256 "" ` + --actor "" ` + --approve-component sentinelAlertRules ` + --approve-component xdrCustomDetections ` + --max-additional-custom-detections +``` + +`plan-deployment` and `review-deployment-plan` make no cloud calls; the +approval command writes only the local `deployment-planning-consent.json` +artifact. Review the recursive source inventory, unique source-rule IDs, +current reviewed disabled drafts, eligible maximum additional Custom +Detections, exclusions, exact target/solution/version, current package/content +hashes, package resource types, and component-specific count caps. The eligible +maximum is an upper bound, not a guaranteed conversion count. Without a +current package or complete reviewed ingestion plan, those scopes are +provisional. `planSha256` binds the review to the complete current plan; +changed target/content/package identity, payload, resource family, package +flags, or cap requires re-planning and renewed consent. + +At the initial consent request, preselect **Approve the broadest currently +bounded and available scope** as a proposal, not as approval. Show the exact +selected components and all caps, targets, resource families, and package +selection flag values, then require affirmative submission/confirmation. +Cancellation, closure, decline, or no response must not create an approval. +When both are fully available, the suggested scope may include the disabled +AR/CD pair and later `fullPackageAcceptance`; list every package flag by name +and value (for example, all five current flags as `true` only when the current +plan explicitly exposes those five valid selections). If ingestion, +conditional provisioning, table creation, parity, or cleanup is unknown, +provisional, unsupported, or otherwise not fully bounded, leave it out and +explain why. Suggest only the broadest valid subset; never widen unknown scope. + +The direct route is disabled Sentinel alert rules plus Graph Custom Detections +as reviewed AR/CD pairs, with an explicit maximum-additional-CD cap. The +separate later `fullPackageAcceptance` component requires a current verified +V3.1 package and an explicit boolean choice for every package-declared +selection flag. It covers full-package installation and any selected XDR +installation/Content Hub registration—not package creation alone. Pair-first +deployment and later package acceptance are distinct routes. Stored playbook, +workbook, connector, API, and connection definitions are not approvals to +instantiate or enable those resources. + +Optional components are `mockTelemetryIngestion`, `fallbackDcr`, +`fallbackDce`, and `customTableCreate`. Ingestion scope is not approvable until +the reviewed, run-bound plan identifies the destination and stream, payload +hash and record count, batch caps, bytes/cost/retention impact, contract, +transform and schema hashes, marker/timestamp fields, and one shared +payload-per-pair execution. Benign controls remain separate. DCR/DCE support +ingesting reviewed local mock data; neither is needed to generate mock data. +DCR and DCE provisioning are conditional, distinct scopes, and a DCE must be +explicitly selected or proven required. The current DCR resolver checks +registered exact resource-name candidates only; it does not discover +arbitrary solution-owned DCRs. A fresh exact check is required before +provisioning, and this limitation must not be described as proof of absence. + +Custom-table creation is conditional only for an exact contract-proven absent +custom-log destination, and its plan includes the table resource, contract and +schema path/fingerprints, and column count. A present incompatible table is +blocked rather than altered. When a compatible native table such as +`CommonSecurityLog` exists, table creation is `not-needed`; do not create a +custom lookalike. Table creation, DCR/DCE provisioning, telemetry ingestion, +rule deployment, enablement, cleanup/deletion, retention changes, and role or +identity changes are separate operations and are not implied by another +component's consent. + +Advance planning consent is distinct from permissions, technical readiness, +deployment evidence, and immediate exact-scope write approval. Missing, +false, unknown, stale, or declined consent never counts as approved; do not +infer it from “Continue,” token scope, environment identity, example counts, +or mock-generation hypotheses. Keep the existing explicit approval directly +before every environment write. A denied/missing plan consent must not block +independent read-only checks or offline Authoring. + +For package acceptance, ARM validation/preview applies only where supported. +Bicep extensibility imports do not support `what-if` +([Microsoft Learn](https://learn.microsoft.com/en-us/graph/templates/limitations#unsupported-deployment-features)); +do not pass or wait for a `what-if` result for that route. Ordinary ARM +previews do not prove extension acceptance, and ARM validation must not be +described as validating extension behavior. Approved deployment and readback +remain necessary. + +### Post-validation ARM template deployment acceptance-only + +This optional Qualification step submits the exact final V3.1 package +`mainTemplate.json` for every solution in the approved same-PR/lab batch. It +requires a fresh successful repository build-and-validate report for each +member and binds the report to the current package report, artifact hashes, +solution, and `ContentRoot`. The producer records whether the V3.1 packager's +source-input ordering was POSIX or Windows `Path` ordering, selected only +when that complete fingerprint matches the package report; consumers honor +the recorded semantics across hosts. `Package/` fingerprints keep their +separate ordinal ordering. Windows source ordering lowercases each path +component before ordinal component comparison, matching `PureWindowsPath` +rather than .NET `OrdinalIgnoreCase` string ordering. The earlier migration +validation stage is not a substitute. + +Plan and review the exact batch before deployment: + +```powershell +sentinel-xdr-migration plan-arm-template-acceptance-batch ` + --member "::" ` + --validation-receipt "" ` + --shared-decisions "" + +sentinel-xdr-migration deploy-arm-template-acceptance-batch ` + --member "::" ` + --validation-receipt "" ` + --shared-decisions "" ` + --expected-batch-plan-sha256 "" ` + --actor "" ` + --confirm-exact-scope-write "I approve the exact enumerated ARM template deployment acceptance batch" +``` + +Repeat `--member` and `--validation-receipt` in matching order for every +approved solution. The batch must exactly match the user-confirmed shared +workstream and locked lab. The command submits the reviewed template snapshot +through Azure CLI's resource-group ARM deployment route in Incremental mode, +with the locked workspace and every package-declared `Deploy*` selection flag +explicitly `true`; it never invents flags or changes the template. It does not +require an ARM `what-if`, resource-group resource read-back, or behavioral +tests for this specific acceptance result. A member succeeds only when the +recorded ARM deployment has terminal `provisioningState: Succeeded`. Failed, +nonterminal, missing, timed-out, or partial attempts remain failed/unknown in +durable evidence; never blindly retry an ambiguous write. + +This receipt records ARM deployment acceptance only. It does not prove +MicrosoftSecurity import behavior, verified installation of each resource, +content read-back, alert activation, parity, or completed Qualification. +Incremental mode does not guarantee that deployment has no impact. Verified +installation and post-install activation are distinct gates. + +### Qualification mock-ingestion DCR selection + +The intended policy is to prefer a DCR explicitly listed or referenced by the +selected solution when it demonstrably supports the exact shared fixture in +the locked non-production workspace. Compatibility means the deployed DCR is +available at the intended destination, accepts the fixture's public input +stream and fields, applies the expected transform, writes the expected table +and schema, and is usable with the approved endpoint and permissions. For the +Logs Ingestion API, structured input uses a customer-authored `Custom-*` input +stream and `transformKql` to project/cast into a supported `Microsoft-*` +output stream; a public destination schema does not expose a connector's +internal parser or enrichment logic. The public +[`/v1/metadata` endpoint](https://api.loganalytics.io/v1/metadata) describes +schema, not ingestion capability or connector behavior; confirm the route +against the table feature matrix and never assume reserved or service-managed +columns are writable. See the +[Logs Ingestion API overview](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview) +and [table feature matrix](https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables-features). +A packaged ARM-template reference alone does not prove that the DCR exists in +the target workspace. Do not confuse a DCR's internal ingestion identifier +with its public input-stream name, and do not enumerate other workspaces to +find a candidate. + +If no solution-listed DCR meets that contract, a supported ingestion contract +may be used to author a separate, isolated DCR locally and provision it only +in the explicitly approved Qualification environment. A DCE is needed for +private-link ingestion or when the DCR has no ingestion endpoint; the current +contract tool may provision an isolated DCE as part of its supported route, +but DCE creation is not a universal Logs Ingestion API requirement. +“Locally” describes authoring the configuration, not a local ingestion +service. Never alter an existing connector DCR or add a stream to it to force +fixture compatibility. If the source table does not support the documented +ingestion path, use a documented native telemetry generator or block the +qualification step; never redirect the rule to a custom lookalike table. +Provisioning and ingestion each require the applicable permissions and +immediate, exact-scope approval. Ingestion acceptance alone is not visibility, +query matching, or parity evidence. + +Before fixture preparation, run the read-only Qualification preflight and +report telemetry-write, DCR creation, conditional DCE creation, and +conditional destination-table creation permissions separately. Check DCE +creation only when the selected route actually includes a DCE. The preflight +is diagnostic-only and does not provision resources or grant write approval. +`unknown` or pending review is not a permission grant. A missing permission +blocks only the corresponding write; it must not suppress independent +read-only workspace, DCR, schema, or query checks. The command requires no +fixture. Pass `--run-id` when the solution has multiple runs; omit it only +when the run is unique. + +```powershell +sentinel-xdr-migration preflight-deployment ` + --solution "Solutions\" ` + --auth-method azure-cli ` + --run-id "" +``` + +It adds `dcrPermissionPreflight` to the top-level Graph report and writes +`qualification-dcr-preflight.json` under the selected run's +`Solutions\\Logs\sentinel-xdr-migration\\` directory. The +subreport records `status`, `workspaceQueryPermission`, and per-contract +`resourceReadAccess`, `tableSchemaCompatibility`, `ingestionAuthorization`, +`resourceProvisioningAuthorization`, and `tableCreationAuthorization`, along +with `reportPath`, `targetFingerprint`, `schemaFingerprint`, +`transformFingerprint`, `contractFingerprint`, `resourceFingerprint`, +`candidateProvenance`, and `candidateSelection`. `contractFingerprint` is the +SHA-256 of the registered serialized contract. `resourceFingerprint` hashes +the exact target workspace, candidate DCE/DCR/table IDs, returned resource +configuration/schema, and current resource states. Provenance is +`registered-contract-resolver`, and candidate selection is +`exact-contract-resource-names-only`. +`resourceProvisioningAuthorization` separately +reports `dcrWrite` and `dceWrite` at the exact resource-group scope; DCE write +is checked only when the selected missing-pair plan includes DCE provisioning. +Per-capability authorization statuses are `ready`, `denied`, `unknown`, +`not-yet-verifiable`, or `not-needed`; table schema status is `compatible`, +`blocked`, or `unknown`; workspace query permission is `ready`, `denied`, +`unknown`, or `not-checked`; aggregate report status is `ready`, +`action-required`, `blocked`, `unknown`, or `not-applicable`. +`resourceReadAccess` can be `ready`, `denied`, `unknown`, or `unavailable` +(including HTTP 401); `targetVerification` can be `blocked`, `unavailable`, +`confirmed`, or `not-checked`. +A `ready` permission means the effective permission pattern was inspected, +not that upload, table visibility, or query matching succeeded. Treat all +results as read-only diagnostics; they do not authorize provisioning or +ingestion. Unknown or pending-review permissions must not be reported as +granted. + +**Implementation status:** The current registered-contract resolver checks +contract-specific resources in the locked target workspace; it does not yet +discover or prefer a DCR referenced by the solution. Treat solution-first +selection as the intended policy, not an implemented automatic resolver. +Until that integration exists, report the resolver's actual +`compatible-existing` or `provisioning-plan` result and do not claim that a +solution-listed DCR was selected unless its compatibility was explicitly +established. + +## Strict live alert parity + +Query execution alone does not prove migration parity. In a non-production lab, +the optional alert-parity workflow temporarily enables an already deployed +Sentinel Analytic Rule and its migrated Custom Detection, ingests a reviewed +payload containing a unique marker, and compares normalized alerts strictly. + +For full internal qualification, create a JSON plan containing one contract, +payload, unique marker, and expected malicious key set per converted detection, +then enable all pairs and ingest all fixtures: + +```powershell +sentinel-xdr-migration start-alert-parity-batch ` + --solution "Solutions\" ` + --plan "" +``` + +The plan must cover every converted detection. All rules must begin disabled +and are disabled again if any enablement or ingestion fails. + +For targeted troubleshooting, start one or more explicitly selected rule +pairs with a shared fixture: + +```powershell +sentinel-xdr-migration start-alert-parity ` + --solution "Solutions\" ` + --contract "" ` + --payload "" ` + --scenario-marker "" ` + --expected-match-key "" ` + --detection "" +``` + +The command requires both rules to be disabled, enables them, invokes the +separate `azure-monitor-logs-ingestion` CLI, and emits provider-neutral capture +queries. It does not use fixed sleeps. Prefer official Sentinel Triage MCP +query tools and use the documented CLI/API fallbacks only for provider-level +failures. + +After normalizing both alert sets, complete the comparison: + +```powershell +sentinel-xdr-migration complete-alert-parity ` + --solution "Solutions\" ` + --results "" +``` + +Strict parity requires equal alert counts, stable match keys, severity, tactics, +entities, and decisive evidence. The observed keys must also equal the declared +malicious match-key set, so a benign false positive on both platforms still +fails. Completion disables every selected rule in a cleanup path even when +comparison fails. If capture cannot be completed, run: + +```powershell +sentinel-xdr-migration abort-alert-parity --solution "Solutions\" +``` + +State and reports are written under +`Solutions\\Logs\sentinel-xdr-migration\` as `alert-parity-state.json` and +`alert-parity-report.json`. + +## Consolidated solution report + +Build one solution-level JSON and HTML report after any stage: + +```powershell +sentinel-xdr-migration solution-report ` + --solution "Solutions\" ` + --run-id "" ` + --ingestion-report "" ` + --ingestion-report "" +``` + +The report includes every source rule, AR and CD identifiers and statuses, +conversion and structural results, runtime providers, deployment, strict alert +parity, entity recommendations, ingestion artifacts, and complete structured +errors. The HTML renders detailed errors in expandable sections; the JSON +retains the complete sanitized provider response rather than only a summary. + +### Adaptive migration-flow schematic + +The offline HTML report places a keyboard-accessible subway schematic directly +below the title and snapshot timestamp. Its first informational station is +**Necessary setup / initial preflight**, before Discovery. That station displays +only persisted run/profile/target records and any already-recorded, scope-bound +authentication or planning-consent evidence. It does not run the doctor, +authenticate, inspect local auth caches, or make network calls. Missing local +tool checks and authentication remain unknown/not recorded; `.env` target +configuration is not proof of access or approval. The current report schematic +shows a read-only preflight before deployment as an informational station. +Treat its exact-rule existence/readability checks as optional diagnostics, not +a separate deployment gate. The optional mock-ingestion stage still requires +its DCR/permission preflight. + +The added Setup stop is informational, not a canonical workflow stage or gate. +Authoring runs show it before the compact mandatory route +**Discovery → Conversion → Validation → Packaging → Report**; Validation +remains required and the optional lab branch is omitted. Qualification runs +show a display-only post-build sequence: a distinct official local validation +rerun, read-only preflight, disabled direct AR/CD testing pairs, reviewed-fixture +ingestion, semantic/query validation, alert parity, then separately approved +full-package deployment/read-back before Report. This illustrates the selected +pair-first test plan; it does not reorder or redefine canonical workflow stages. +Full-package acceptance is separate from the V3.1 package build, and neither +package files nor pair results prove full-package acceptance. The full-package +step is shown as not tracked/not run unless separate execution evidence exists. +Publication is optional and outside the tracked workflow. +Legacy reports without confirmed mode remain **Mode not recorded**; attempted +qualification-stage evidence is still shown rather than hidden. + +Every station circle and label is a keyboard-accessible link to its evidence +section; missing evidence opens an explicit unavailable/not-recorded explanation. +Safe local artifact links are rendered only for existing files inside the +selected migration run. Selecting a station shows its recorded stage status, +remarks, evidence references, timestamps, and elapsed time when available. +The preflight panel separates the required +`CustomDetection.ReadWrite.All` grant from read access, exact GET results, +effective RBAC, and deployment readiness; a successful GET or read grant is not +a deployment-ready result. The diagram calls out +an overall blocked/failed workflow independently from individual passed stages; +a package is shown as passed only when the recorded package-freshness check is +current. External publication is always marked **Not tracked** by this workflow: +building a package is not evidence that it was published. This is a generated +snapshot, not live status, and station selection is informational only. + +An upfront package-resource inventory is planning context, not an approved +deployment scope. It reads resource types from the generated ARM template, +including nested arrays and language-version 2 resource dictionaries, and +displays API versions and the current template hash when verifiable. Separate +DCR/DCE Qualification resources are not inferred from package content. Prior +planning agreement and exact-scope write approval are distinct; the latter is +still required immediately before each environment write. The report cannot +authorize or initiate deployment, ingestion, cleanup, or any other write. +When a persisted deployment-planning contract exists, the panel also shows its +selected profile/mode, target and package identity, plan/artifact fingerprints, +source inventory and scope caps, resource inventory, component plans, selected +flags, and hash-bound advance-consent scope. Missing, provisional, stale, or +unknown planning metadata remains explicitly unapproved; planning consent never +substitutes for immediate exact-scope write approval or readiness evidence. +Controlled alert-parity and owned-resource-cleanup coverage, when present, is +displayed as non-consentable planning metadata with its recorded bounds/status; +it never represents advance consent or write authorization. +The subway precedes this technical planning panel. Planning status and target +summary are concise; the full candidate package-resource inventory occupies +its own readable, horizontally scrollable table, while hashes, raw scope +identifiers, package defaults, and detailed component evidence are expandable. +The inventory table alone scrolls horizontally on narrow screens. + +The offline HTML dashboard opens in **Needs attention**, with single-select +status chips, search by name/path/ID/finding, and multi-select issue categories. +Status, search, and category filters combine with AND; selected categories +combine with OR. Chip counts cover the full report. Shared-issue buttons reset +the other filters and show the affected rules. **Clear filters / show all** +restores the complete list. + +Finding types are separate from readiness and have their own filter, affected-rule +counts, text/icon badges, shared-finding sections, and original-evidence sections: + +- **Errors:** recorded hard errors or actual failed checks/executions. +- **Reviews:** explicit unresolved review reasons or recorded human-review flags. +- **Warnings:** nonblocking advisories, including source lookback/schedule warnings. + These never become errors, human-review flags, or readiness failures merely + because the UI displays them. +- **Blocked prerequisites:** recorded blocks, distinguished from execution failures. +- **Information:** explicit backend `informational` notes, such as a proven + equivalent mapping or matched cadence/effective lookback. They are neutral, + nonblocking context, never inferred by downgrading warning text. The selected + manifest is authoritative (including an explicit empty list); only an absent + field falls back to the draft's conversion provenance. JSON includes per-rule + `informational`, total `summary.informational`, and `summary.rulesWithInformation`. + Transformation HTML has a separate Information column; dashboard information + has its own badge, filter, shared groups, and detail disclosure. Information alone + never creates a review flag, failure, or Needs attention status. + +Choosing a finding type resets the status filter to All, so the Warnings filter +also reveals advisories on ready rules. Search/categories still apply; status can +then be refined. Compact rows prioritize errors and reviews, with warnings in a +clearly labeled advisory disclosure. Counts deduplicate identical inherited +diagnostics per rule. A warning repeated verbatim as an explicit review reason +is counted once as a review, preserving both original records in its evidence; +unrelated warnings remain warnings. Types can overlap on the same rule. + +The shared **Preserve source tactics; review historical single-tactic findings** +explanation labels older single-tactic findings as historical and recommends +explicit draft regeneration, not mandatory narrowing of authoring metadata. +Current verified Defender UI / Graph beta behavior (2026-10-07) uses one +tactic with multiple compatible techniques/subtechniques; the isolated beta +multi-tactic create request was rejected. The documentation's collection-shaped +`alertTemplate.tactics` property does not alone establish multi-tactic acceptance. +The explanation links the public Graph `alertTemplate` and `mitreTactic` references, +without including raw lab details or claiming ARM/future-version validation. + +Affected rule details show the current Sentinel tactic and technique lists +independently, the original lists retained in conversion provenance, and the +exact current draft tactic/technique/subtechnique structure. Lists are never +paired by position. A first-original-tactic draft with no techniques is labeled +an unapproved fallback shape only when those recorded facts match. The report +provides a rule-ID-specific, placeholder-only `ruleOverrides` example for optional +technique corrections to one existing source tactic, without removing or +reordering other tactics. It never recommends a tactic from rule +names or silently applies an override, approves a mapping, edits KQL, or clears +review gates. Absent classification metadata remains explicitly unrecorded. + +### Automated-check doughnut chart + +The compact **High-level findings** pie appears near the top, separately from +the unique-rule doughnut. Its red slice counts distinct error findings, yellow +counts warnings plus review findings, and sky blue uses the theme link token for +informational findings. Findings are deduplicated within each rule using the +same evidence-preserving rules as the table; one rule can contribute multiple +findings. Blocked prerequisites are stated separately rather than counted as +errors or silently mixed into this pie. + +A **green standalone rule metric**, not a slice, shows rules whose automated +conversion and structural checks passed with no actionable findings or per-rule +block; informational notes are allowed. It is not a deployment/semantic approval. +The pie legend and keyboard-accessible slices filter matching rules, so the +result-list count can differ from the slice's finding count. Selecting either +chart clears the other chart's selection; normal filters can then narrow it. + +The self-contained SVG chart counts each rule exactly once, in this priority: +**red** actual errors; **yellow** warnings or explicit review decisions; +**green** converted plus passed local structural checks, with no warning/review +or per-rule prerequisite block (informational notes are allowed); **gray** +insufficient evidence or an otherwise unresolved per-rule prerequisite block. +Green is labeled **Automated checks passed**, not deployment approval. It is +independent of the run-level conversion review gate: a green rule may still have +the status **Readiness unconfirmed**. Historical semantic cautions stay at run +level, not fabricated per-rule flags. Legend buttons provide counts/percentages +and a text alternative; both segments and buttons support keyboard filtering. +Chart selection resets other filters, which can then narrow the chart group. +The existing status controls and rule badges remain available. + +### Reviewed bulk tactic exports + +The dashboard supports **pending review/export**, never automatic resolution. +Only historical explicit single-tactic review findings with unique source IDs, valid source +choices, and a recorded source-file SHA-256 are eligible. Select visible eligible +rules or individual rules, choose a source tactic to correct, then choose techniques +and confirm compatibility/observable behavior **individually for each rule**. +Incompatible source tactics are refused with a reason; applying a new bulk +tactic clears prior technique selections and confirmations. No compatibility +cross-product is invented. Rules needing a new tactic/technique absent from the +source lists, an empty technique selection, missing metadata, mapping redesign, +or timing/data-loss acceptance require individual review outside these controls. +There is no blanket `acceptedReviewReasons` export. +These legacy controls are optional technique corrections, not a requirement to +choose one tactic for authored YAML. Conversion retains all source tactics in +source order regardless of the selected override. Regeneration, rather than an +exported decision alone, refreshes historical conversion findings. + +Import the existing **complete JSON configuration** to merge unrelated root +settings, rule overrides, and other fields within selected rule overrides. +Only selected `tactic` and `techniques` fields are replaced, with previous and +new choices shown in the preview. Alternatively, explicitly confirm there is no +existing configuration to preserve. JSON is valid YAML for the existing CLI. +For an existing YAML configuration, create a JSON equivalent locally first: + +```powershell +python -c "import json,sys,yaml; print(json.dumps(yaml.safe_load(open(sys.argv[1], encoding='utf-8-sig')), indent=2))" existing-config.yaml > existing-config.json +``` + +The browser never writes to the repository or calls a server. It downloads: + +- `migration-reviewed-overrides.json`: the complete merged CLI configuration. +- `migration-reviewed-overrides.review.json`: a separate audit manifest containing + selected source IDs, source file hashes, report/run identity, explicit choices, + individual confirmations and prior selections; `applied` remains `false`. + +Inspect both previews and compare source hashes against the current checkout; +re-import if the underlying configuration changed. **The CLI does not enforce +the review manifest or automatically reject stale decisions.** After explicit +review, apply the merged configuration through the existing tool: + +```powershell +sentinel-xdr-migration convert --solution "" --run-id "" --config "" --overwrite +sentinel-xdr-migration solution-report --solution "" --run-id "" +``` + +Do not pass the review manifest as `--config`. Conversion can replace +same-provenance drafts; inspect changes and continue the normal workflow gates. +Downloads do not change persisted findings/statuses or approve deployment. +Pending choices are browser-memory-only and are lost on refresh. Export is an +explicit user action; there is no local write server, external chart library, +automatic mapping choice, or external runtime dependency. + +Optional browser regression tests use an already installed Playwright/browser: +set `SENTINEL_REPORT_BROWSER_TESTS=1`, optionally +`SENTINEL_REPORT_BROWSER_CHANNEL=chrome`, then run +`python -m unittest discover -s Tools/SentinelToXDRMigration/tests -p test_report_review_browser.py`. + +### Run context and rule details + +Run profile, stage states, workspace configuration presence, and recorded shared +messages appear once. A missing result is **evidence unavailable**, not proof of +a skipped check. **Not required** comes only from recorded workflow state. +Individual failures, blocked results, measured zero rows, and exceptions remain +visible even when a run-level stage is not required. Historical stage messages +are retained separately from current artifact counts. + +**Ready for validation** requires a converted rule, passed local structural +checks, no recorded rule review flag/failure, and a recorded passed conversion +stage. This is not deployment approval or proof of semantic equivalence. +Automated successes remain **Readiness unconfirmed** while conversion review +is incomplete; unmapped semantic findings in workflow evidence are not silently +treated as resolved. These are presentation labels only: the dashboard never +changes gates, source statuses, or the existing JSON summary contract. + +The compact rule list shows problems and next actions. Details retain warnings, +review reasons, entity recommendations, check results, and original diagnostics. +Identical inherited conversion/structural findings are grouped per rule, retaining +their stages and raw evidence; shared findings show affected-rule counts rather +than inflated occurrence totals. Categories are navigation aids, not semantic +validation. Source and converted KQL are visible immediately when a rule's Details +opens, alongside an offline unified text diff. These are current file snapshots at +report generation, not a claim of query equivalence or a historical content +snapshot. Reports omit empty package-resource inventory and consent panels when no +decision was recorded; populated inventory and substantive approval, denial, or +stale-consent evidence remain visible. No external scripts, fonts, services, or new +dependencies are used. Theme follows the system preference or +`?scoutTheme=light` / `?scoutTheme=dark`. + +Outputs: + +```text +Solutions\\Logs\sentinel-xdr-migration\\migration-report.json +Solutions\\Logs\sentinel-xdr-migration\\migration-report.html +``` + +## Deploy Custom Detections + +Deployment uses the documented Microsoft Graph beta Custom Detection API. The +API is preview and requires delegated `CustomDetection.ReadWrite.All` consent +plus an appropriate Defender XDR or Entra security role. + +Authenticate once: + +```powershell +sentinel-xdr-migration setup-deployment ` + --solution "Solutions\" ` + --tenant-id "" +``` + +This uses Azure CLI browser authentication by default. Device-code and direct +browser credential flows are also available: + +```powershell +sentinel-xdr-migration setup-deployment ` + --solution "Solutions\" ` + --tenant-id "" ` + --auth-method browser +``` + +Optionally run the read-only Qualification preflight with an explicitly +selected cached credential for diagnostic evidence: + +```powershell +sentinel-xdr-migration preflight-deployment ` + --solution "Solutions\" ` + --auth-method azure-cli ` + --run-id "" +``` + +The preflight requires the `CustomDetection.ReadWrite.All` management +permission in the acquired token, reported as a delegated `scp` or an +application `roles` claim. For Azure CLI it requests a Graph resource token +with `https://graph.microsoft.com/.default`; that resource request does not +prove the permission is present, so the token claims are inspected separately. +Missing permission claims or malformed claim values are reported as unknown. +A delegated `CustomDetection.Read.All` grant may support the preflight's +read-only exact-rule check, but does not satisfy deployment's required +management permission. These exact-ID existence reads are optional +pre-deployment diagnostics and do not establish ownership or deployment +readiness. + +The report separates `managementPermissionGrant` from `readAccess`, +`effectiveRole`, and `writeAuthorization`. A successful exact-rule `GET` +confirms only read access. This command does not create or update rules, +request consent, start login, or switch credential methods. Run +`setup-deployment` separately when cached authentication or consent must be +established. + +**Current backend limitation:** `preflight-deployment` still performs +per-detection exact-ID reads and may report denied reads as blocking. The +standalone preflight may be omitted when no other Qualification step requires +it, but the optional mock-ingestion stage uses it for DCR/permission checks and +cannot skip its bundled exact-ID reads. The Graph deployment command also +performs its own exact-ID `GET` to choose `POST` versus `PATCH`, then requires +read-back verification. These backend reads cannot be skipped through +documentation; do not bypass them with direct API calls. The Sentinel +deployment command instead uses `PUT` to the deterministic +`Microsoft.SecurityInsights/alertRules/{ruleId}` resource, which creates or +updates the alert rule without a separate existence check. The Sentinel +Alert Rule Template API is a read-only catalog and is not created by this +route; package `contentTemplates` installation is a separate route. Graph +`POST` itself is not documented here as an idempotent upsert. + +Deploy every structurally valid, fully converted detection: + +```powershell +sentinel-xdr-migration deploy --solution "Solutions\" +``` + +Rules are always submitted as disabled. The command creates missing rules, +updates matching client-provided IDs, and writes +`Solutions\\Logs\sentinel-xdr-migration\\deployment.graph.json`. + +### Delegated Graph client and guarded app registration + +The built-in Graph client remains the default. To use a separately registered +public desktop client for Custom Detection deployment, first review the exact +registration plan for the locked solution tenant: + +```powershell +sentinel-xdr-migration plan-delegated-app-registration ` + --solution "Solutions\" ` + --app-display-name "Sentinel-XDR-CustomDetection" +``` + +The optional `--app-display-name` is bound into the plan hash and must be +identical when registering. If omitted, the generic +`Sentinel-XDR-CustomDetection` name is used. The plan above +describes one single-tenant public client named exactly +`Sentinel-XDR-CustomDetection`, the `http://localhost` redirect, and only the +delegated Microsoft Graph `CustomDetection.ReadWrite.All` permission. It does +not create an application or grant consent. The registration route requires +separate delegated bootstrap scopes `AppRegistration.Create` (create) and +`User.Read` (enumerate only the signed-in user's owned applications) and +explicit app-registration approval. These are used only by the bootstrap +authentication; they are not added to the Custom Detection app, whose only +requested permission remains `CustomDetection.ReadWrite.All`. Authenticate +explicitly, then submit the exact plan hash and approval: + +```powershell +sentinel-xdr-migration setup-app-registration-auth ` + --solution "Solutions\" ` + --auth-method device-code ` + --bootstrap-client-id "" + +sentinel-xdr-migration register-delegated-app ` + --solution "Solutions\" ` + --app-display-name "Sentinel-XDR-CustomDetection" ` + --expected-plan-sha256 "" ` + --approve-exact-app-registration +``` + +The route checks the signed-in user's owned applications before creating +anything, reuses only one exact matching owned app, and stops on multiple, +inaccessible, or differently configured matches. It never retries an +uncertain create, modifies an existing app, creates a service principal, +creates a client secret, requests admin consent, or grants application +permissions or ARM/Defender roles. The permission declaration is not consent +or proof that the permission is effective. An administrator must separately +review and grant the required consent; the deployment preflight remains +responsible for reporting token claims, read access, and write authorization +as distinct evidence. If the bootstrap scopes cannot expose enough details to +verify the operator's owned applications, registration stops before creating +an app rather than requesting broader directory permissions. + +Delegated Microsoft Graph token checks treat each operation's configured +scopes as minimum requirements: all required scopes must appear in a valid +delegated `scp` claim, while additional valid names are reported as +`extraScopeNames` and do not fail the scope check. This includes incidental +OpenID Connect names such as `email`, `openid`, and `profile`; their presence +does not mean the toolkit requested them or broadened consent. Missing or +malformed `scp`, application-role-only tokens, a wrong tenant, or a non-Graph +audience remain blocked. Client and authentication-purpose binding, +operation-specific minimums, and existing read, write, and exact-resource +approval gates remain unchanged. + +If the bootstrap `User.Read` owned-object response omits application settings, +use the separate read-only reconciliation route. It uses a distinct +`appReadback` authentication purpose, private auth record, and token cache; +the already-approved public client may be reused, but bootstrap credentials +are never implicitly reused. The route requests only delegated `User.Read` +and `Application.Read.All`, enumerates the signed-in user's owned applications, +then reads the exact approved application. It performs Graph GET requests only +and completes local private configuration only after ownership, tenant, IDs, +display name, audience, localhost redirect, and the sole delegated +Custom Detection permission all match. + +```powershell +sentinel-xdr-migration setup-app-readback-auth ` + --solution "Solutions\" ` + --readback-client-id "" + +sentinel-xdr-migration verify-delegated-app-readback ` + --solution "Solutions\" +``` + +Authentication is an explicit browser sign-in. Missing consent, token claims, +ownership, or settings leave reconciliation incomplete; the command does not +create or modify an application, grant consent, or fall back to app creation. + +If readback is blocked because a required token scope is missing, this diagnostic uses +only the existing private readback credential and the explicitly selected +Qualification run: + +```powershell +sentinel-xdr-migration diagnose-app-readback-scopes ` + --solution "Solutions\" ` + --run-id "" +``` + +It disables interactive authentication, may refresh the cached token +non-interactively, and makes no Microsoft Graph resource request or private +configuration change. It reports normalized expected, actual, missing, and +extra scope names only; satisfying the required scopes does not perform the +readback GET or prove ownership, application settings, or Graph access. Missing +required scopes remain blocked. The application settings check remains exact: +the readback GET must show precisely the approved delegated +`CustomDetection.ReadWrite.All` and `ThreatHunting.Read.All` permissions. + +#### Manual existing-app permission update + +The currently recorded Qualification app is shared by Advanced Hunting and Custom +Detection only after its exact two-scope configuration has been verified. +Inspect the offline plan: + +```powershell +sentinel-xdr-migration plan-delegated-app-permission-update ` + --solution "Solutions\" +``` + +The plan binds the approved tenant, application/client ID, object ID, display +name, previously approved Custom Detection-only permission set, exact desired +two-scope set, and plan hash. It is manual-only: the documented Microsoft +Graph [Update application API](https://learn.microsoft.com/graph/api/application-update?view=graph-rest-1.0) +does not specify server-enforced conditional replacement of +`requiredResourceAccess`. A fresh GET or process lock cannot prevent another +owner from changing the property before PATCH, so the toolkit provides no +automated PATCH route. If manual work is needed, add only delegated +`ThreatHunting.Read.All`, preserve `CustomDetection.ReadWrite.All`, and do not +add roles, credentials, or request consent. Then use the separate readback +credential: + +```powershell +sentinel-xdr-migration verify-delegated-app-permission-update ` + --solution "Solutions\" ` + --expected-plan-sha256 "" +``` + +This verifier performs GETs only. Legacy one-scope settings return +`manual-update-required`; extra or otherwise mismatched settings remain +blocked. Exact verification selects the same app client ID independently for +Custom Detection and Advanced Hunting, but preserves purpose-specific auth +records, caches, tenant/client bindings, and minimum-scope checks. An app +permission declaration or successful readback does not prove that a later +Advanced Hunting token contains `ThreatHunting.Read.All`; authenticate and +validate that purpose separately. + +For this localhost-redirect Custom Detection app, use browser authentication +when setting up its deployment credential: + +```powershell +sentinel-xdr-migration setup-deployment ` + --solution "Solutions\" ` + --auth-method browser ` + --custom-detection-client-id "" +``` + +The approved registration payload does not enable the fallback public-client +flow used for device-code authentication. Browser authentication is required +for this Custom Detection app. Consent remains a separate administrator +action. + +Use `--overwrite` to replace previously generated files. Without it, the +converter refuses to overwrite a file whose content differs. + +## Optional migration configuration + +Create `Solutions//Logs/sentinel-xdr-migration//migration-config.yaml` when a solution needs explicit +table, function, or column rewrites: + +```yaml +schemaVersion: 1.0.0 +tableMappings: + LegacyTable_CL: CurrentTable_CL +functionMappings: + LegacyParser: CurrentParser +columnMappings: + LegacyTable_CL: + UserName: AccountUpn +``` + +Mappings are applied token-by-token. Time-column handling is table-aware: +native Defender tables use `Timestamp`, while verified Sentinel workload +tables such as `AzureActivity` retain `TimeGenerated`. Rewrites skip quoted +strings and line comments. Runtime validation is authoritative for tables and +KQL constructs exposed through the unified Advanced Hunting surface. + +Conversion does not require queries to mention or return `Timestamp` or +`TimeGenerated`, and does not add either column to satisfy a local check. +[Microsoft's custom detection guidance](https://learn.microsoft.com/en-us/defender-xdr/custom-detection-rules) +recommends event-time columns for alert timing; when they are not projected, +alert first/last event times use the detection lookback window. This is not a +blanket conversion or structural-validation failure. Existing generated YAML +retains its recorded conversion errors until explicitly regenerated; historical +reports are not rewritten by this policy change. + +Entity mappings are checked against columns plausibly produced by `project`, +`extend`, `summarize`, `distinct`, `parse`, and `mv-expand`. The analyzer +deliberately over-approximates output rather than blocking queries it cannot +prove invalid. High-confidence Account, Host, IP, URL, and Azure resource +columns can be repaired or inferred when source mappings are incomplete. + +The generated file declares `resourceType: +Microsoft.Security/detectionRules`. A later packaging milestone can consume +this field and the `apiVersion`/`properties` block without reinterpreting the +source analytic rule. + +## Agent skills + +Repository-native skills under `.github/skills/` teach compatible agents how +to invoke this utility: + +- `sentinel-xdr-end-to-end-orchestrator` +- `sentinel-xdr-rule-converter` +- `sentinel-xdr-detection-validator` +- `sentinel-xdr-migration-orchestrator` +- `sentinel-xdr-solution-packager` +- `sentinel-xdr-solution-deployer` +- `sentinel-xdr-alert-parity-validator` + +The repository also provides one user-facing custom agent: + +```text +.github/agents/sentinel-xdr-migration.agent.md +``` + +Select **Sentinel XDR Migration** from the host's agent picker. The agent uses +the top-level orchestrator skill, which delegates to the specialist skills +while the CLI remains the source of deterministic behavior and persisted +workflow state. + +The skills call the CLI through the agent's normal terminal tools. They contain +no credentials and do not host a custom MCP server. + +The canonical agent-neutral contract is: + +```text +Tools\SentinelToXDRMigration\agent-workflows\end-to-end-migration.md +``` + +Thin adapters are provided for: + +- generic agents through `AGENTS.md`; +- GitHub Copilot through `.github\agents` and `.github\skills`; +- Claude Code through `CLAUDE.md` and + `.claude\skills\sentinel-xdr-migration\SKILL.md`; +- Cursor through `.cursor\rules\sentinel-xdr-migration.mdc`; and +- Windsurf through `.windsurf\rules\sentinel-xdr-migration.md`. + +All adapters point to the same workflow and deterministic CLI. They must not +fork the stage definitions or make optional qualification mandatory. + +Microsoft's official Sentinel Triage MCP can optionally provide Advanced +Hunting schema and execution tools: + +`https://sentinel.microsoft.com/mcp/triage` + +It is not required. The utility remains usable without MCP, and missing runtime +access never blocks offline conversion or structural validation. + +## Logging + +Runtime logs and reports are written below `Data/`: + +- `Solutions//Logs/sentinel-xdr-migration//sentinel-xdr-migration.log` +- `Solutions//Logs/sentinel-xdr-migration//manifest.json` +- `Solutions//Logs/sentinel-xdr-migration//runtime-validation.graph.json` + +These files are local execution artifacts and are ignored by Git. + +## Tests + +```powershell +python -m unittest discover -s Tools\SentinelToXDRMigration\tests -p "test_*.py" +``` + +## Planned milestones + +- workbook conversion; +- solution migration dashboard; +- optional retrieval-assisted guidance for schemas, prior art, and historical + findings. diff --git a/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md b/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md new file mode 100644 index 00000000000..94451c21211 --- /dev/null +++ b/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md @@ -0,0 +1,1558 @@ +# Sentinel to Defender XDR migration workflow + +This is the canonical, agent-neutral workflow for authoring and migrating +Microsoft Sentinel solution content to Defender XDR Custom Detections. Agent +adapters must reference this document rather than redefine the process. + +Deterministic behavior belongs to: + +- `Tools\SentinelToXDRMigration`; +- `Tools\Create-Azure-Sentinel-Solution`; and +- the repository validation tools. + +Agents coordinate these tools but must not reproduce conversion, validation, +packaging, deployment, or comparison logic in prompts. + +## Immutable toolkit boundary + +Migration execution must never modify the agent, its skills, backend tools, +schemas, tests, dependency metadata, workflow definitions, safety gates, or +capabilities. Treat these paths as read-only throughout Authoring, +Qualification, retries, and failure recovery: + +```text +.github/agents/** +.github/skills/** +Tools/SentinelToXDRMigration/** +Tools/SolutionMigration/** +Tools/Create-Azure-Sentinel-Solution/** +``` + +If a protected component fails, record the failure and stop the affected +stage. Do not patch, replace, bypass, or reconfigure implementation code as a +runtime workaround. This agent can never perform such changes, regardless of +the requesting user's identity, repository role, ownership, or approval. +Implementation work must be performed manually or by a different development +agent outside the XDR Solution Manager. + +## Runtime prerequisite + +Run the toolkit only with Python 3.11 or 3.12. If the active interpreter is +outside that range, switch or install the interpreter before continuing. +Environment incompatibility is not permission to edit toolkit source during a +migration workflow. + +## Profiles + +### Authoring goal and capability boundary + +The product goal is to migrate Microsoft Sentinel solutions to Defender XDR +and enable authors to publish validated content without waiting for unrelated +source-rule failures to be fixed. Autonomous best-effort candidate generation +and a human-reviewable batch in which reviewers can accept, change, or exclude +proposed mappings remain roadmap goals, not claims that the current CLI +provides autonomous research or batch review orchestration. + +Today, the deterministic converter emits only mappings supported by its +existing rules and records uncertain mapping assumptions as `reviewRequired`. +That uncertainty must not hide query parse/runtime failures or other technical +errors. A rule that cannot be represented as a deployable Custom Detection +must remain visible in diagnostics outside deployable YAML; it must not be +silently dropped or emitted as an invalid placeholder. Future accept/change/ +exclude decisions may resolve mapping ambiguity, but acceptance is not proof +of alert parity, does not erase technical failures, and does not approve +packaging, deployment, or any cloud write. Existing review, validation, +disabled-by-default, and exact-scope authorization gates remain in force. + +### Authoring + +`Discovery -> Conversion -> Validation -> Packaging -> Report` + +This is the ISV publishing workflow. It does not require a tenant, workspace, +deployment, mock ingestion, or alert-parity test. + +#### Optional explicitly approved Advanced Hunting read validation + +Authoring may perform read-only Defender XDR Advanced Hunting query/schema +validation when the author explicitly configures a tenant on the Authoring +run and approves its exact current plan. This is a separate, one-run read +capability, not Qualification: it does not require or discover a workspace, +change the active workspace, run Sentinel source queries, or authorize any +write. Select the run explicitly, inspect `plan-runtime-query-consent`, and +affirmatively approve only `defenderXdrQueryFamily` through +`review-runtime-query-consent` before invoking `validate-advanced-hunting`. + +The authorization binds the exact selected run, tenant, Graph client/auth +binding, and every current Advanced Hunting query identity/hash. It is not +reusable across runs or solutions. Dispatch requires an existing cached Graph +credential bound to that tenant; consent never authorizes sign-in or +permission changes. Current query execution and returned-schema checks remain +mandatory. Missing, declined, stale, or incomplete consent/authentication +blocks dispatch. This route cannot satisfy Qualification's source/runtime, +target, consent, dispatch, completeness, schema, packaging, deployment, or +parity gates; source SDK validation remains Qualification-only. + +#### Optional resumable full-inventory authoring + +The CLI also provides a separate, explicit `--yolo` mode for durable local +full-inventory authoring. It requires a newly initialized run and an explicit +`authoring` or `qualification` profile; it never upgrades a legacy run. The +run-scoped contract freezes the complete recursive source-rule inventory, +including source and query hashes, and records per-rule conversion and +validation results. It reuses existing discovery, full-solution conversion, +and structural validation. It does not call runtime providers, package, deploy, +ingest, create a PR, or change the review/qualification gates. + +Select and record the exact run ID returned by initialization for all later +commands; do not infer a run from timestamps: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --version-bump none ` + --yolo + +sentinel-xdr-migration workflow-yolo ` + --solution "Solutions\" ` + --run-id "" +sentinel-xdr-migration workflow-yolo-refresh ` + --solution "Solutions\" ` + --run-id "" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump none +``` + +Refresh imports only existing provider reports with matching persisted query +hashes; it does not execute queries. The complete frozen inventory remains the +coverage denominator, including unprocessed, blocked, and suppressed rules. +`E` counts each rule with an unresolved error or suppressed disposition once; +shared provider failures are accounted separately. A stop is persisted when +more than 50% of the inventory is in `E` and cannot be cleared by improved +later results. The 80% migrated threshold is advisory only. It does not replace the separate +partial-publication rule below. Stale source/target hashes or +changed inventory stop processing rather than overwriting user content. See the toolkit README's +[YOLO authoring guide](../README.md#opt-in-full-inventory-yolo-authoring) for +the contract, exact coverage definitions, and evidence-export option. + +For a YOLO run, `package-v3-1` uses the existing workflow state: validation +must pass and every rule in the complete inventory must clear technical and +explicit-review gates. It records fresh V3.1 report and artifact hashes; the +advisory coverage threshold never bypasses these gates. The initialization- +selected bump or target-version action is consumed once. A retry may use `--version-bump none` +only after that action was consumed and the current package-input hash matches +the post-build identity recorded by the run. Changed inputs make the previous +package stale and require an explicit new release choice. Every retry runs V3.1 +again; an old report, package artifact, or V4 report is never relabeled as +current V3.1 evidence. + +The same freshness rule applies to ordinary gated authoring and Qualification +runs. `workflow-status` reports `packageFreshness`; `workflow-next` points back +to packaging and sets `releaseActionRequired` when package inputs or artifacts +are stale. Status reads are read-only. After completing the normal conversion +and validation gates, explicitly select a new release action for the exact +current input identity, then package using that same action: + +```powershell +sentinel-xdr-migration workflow-plan-package-release ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump patch ` + --expected-package-input-sha256 "" + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --run-id "" ` + --version-bump patch +``` + +Choose `patch`, `minor`, or `major` explicitly; no action is inferred. The +expected hash must match current package inputs at selection time. A subsequent +input change invalidates that selection and requires a new explicit decision. +The selected action can be consumed only once for those inputs. If V3.1 fails +after consuming the version, retry with `--version-bump none` only when the +post-build input identity still matches. The old V3.1 report and all package +artifacts are retained as historical evidence before rebuilding. This lifecycle +does not waive technical or entity-review gates: unresolved `reviewRequired` +flags continue to block packaging. + +An author may select a canonical target version instead of a patch/minor/major +bump. The target must satisfy `>=3.1.0` and `<4.0.0` and need not be consecutive +with the current version. For example, an initial release may go directly from +`3.0.7` to `3.1.2`: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile authoring ` + --version-bump none ` + --target-version 3.1.2 + +sentinel-xdr-migration package-v3-1 ` + --solution "Solutions\" ` + --version-bump none ` + --target-version 3.1.2 +``` + +For changed package inputs, plan the exact target against the current input +hash with `workflow-plan-package-release --target-version ` and pass +that same target to `package-v3-1`. The V3.1 backend persists the approved +target in solution data and metadata. If the target was written before a build +failure, a retry must omit `--target-version` and use `--version-bump none`; +it is permitted only for the matching post-write input identity. A target +cannot be combined with a non-`none` bump. The persisted target/action and +release plan retain the same review, freshness, campaign, and one-time +consumption protections as bump actions. + +For offline Authoring only, if the successful package is already at the +approved version but its release-note row is missing, use +`workflow-plan-release-notes-refresh` rather than selecting another version +bump. Supply the exact approved date, change-history cell, and current package +input hash. The planner requires current offline validation and a successful, +fresh V3.1 receipt, records the complete input-file hash manifest, and archives +the prior package evidence. Add the exact resulting row immediately below the +ReleaseNotes table separator, then run `package-v3-1 --version-bump none`. +The required three header labels and their order are matched after trimming +Markdown cell padding; the original aligned header and existing history bytes +are preserved. +Before and after V3.1, the gate permits only that one exact ReleaseNotes change; +any other changed or missing input, stale receipt, mismatched row, or changed +inventory blocks the build. The build creates new package evidence without +changing or consuming another version bump. This route does not apply to +Qualification and cannot replace its runtime, target, consent, or deployment +gates. If the old receipt is stale or does not match the current full input +tree, use the ordinary explicit new-release path instead. + +For an explicitly approved non-ReleaseNotes content correction that must retain +the already packaged solution version, Authoring may use +`workflow-plan-same-version-content-rebuild` instead of selecting another +release. It requires the exact current package-input hash, the actual solution +version matching the successful V3.1 receipt from the selected run, and fresh +passed full-inventory offline conversion, structural, and review evidence. +List the exact changed non-ReleaseNotes package inputs with repeated +`--changed-input` arguments. The planner archives the prior V3.1 receipt and +artifacts plus a hash-verified snapshot of the current inputs; the build then +uses only `package-v3-1 --version-bump none` with no target version. The route +is Authoring-only, preserves the same solution version, and does not relax +Qualification runtime, target, consent, or deployment gates. A failed build may +be retried only against the still-pending plan and identical input manifest; +the actual V3.1 build and fresh receipt remain mandatory. + +Successful V3.1 receipts include the complete post-build package-input +file/hash manifest. The rebuild planner compares it to the current manifest and +requires the explicitly approved path set to equal the complete additions, +removals, and modifications. Unchanged declarations and every ReleaseNotes +delta are rejected. Dispatch, receipt acceptance, and retries recompute the +same scope and hashes. A legacy receipt without a per-file manifest is rejected +unless `--legacy-baseline-evidence` supplies a comparison whose reconstructed +full prior manifest hashes exactly to that successful receipt; its current +identity and changed-path before/current hashes must also match the plan backup. +The evidence path and digest are saved by new legacy-recovery plans. A +pre-existing legacy plan may provide the same evidence flag to each package +attempt without rewriting its workflow state; if no exact baseline can be +reconstructed, stop rather than infer scope from the approved path list. +`workflow-verify-same-version-content-rebuild --solution --run-id +` performs a read-only scope and receipt check for a pending or completed +rebuild; legacy plans without a saved evidence binding must also supply +`--legacy-baseline-evidence`. This check does not alter workflow state or claim +full workflow completion. + +#### Evidence-based package selection + +When a complete solution run contains item-level runtime or review failures, +maintainers may explicitly package only the artifacts that have current, +hash-bound evidence. This does not change the original conversion manifest, +validation reports, validation-stage status, or authored YAML. It is an +inventory-accounting path, not a waiver: an item is excluded if its structural +validation, source/Graph runtime query, provider/target binding, or required +review evidence is missing, stale, failed, or unresolved. The workflow retains +the full discovery/conversion inventory and reports every included and excluded +Analytic Rule and Custom Detection with hashes, evidence paths, check outcomes, +and reasons. Invalid paths, duplicate references, malformed reports, or other +selection/execution errors block applying the plan. + +Use the exact run ID and review the complete plan before applying it: + +```powershell +sentinel-xdr-migration package-selection-plan ` + --solution "Solutions\" ` + --run-id "" + +sentinel-xdr-migration package-selection-apply ` + --solution "Solutions\" ` + --run-id "" ` + --expected-plan-sha256 "" +``` + +Applying a fresh plan updates only the `Analytic Rules` and `XDR Detections` +selection arrays in `Data/Solution_*.json`; it adds `XDR Detections` only when +the plan explicitly bootstrapped candidates from the complete current +solution-scope conversion manifest. Other metadata, content arrays, and +authored YAML remain unchanged. When the array was absent, planning uses only +manifest output references and records missing outputs; it does not discover +additional candidate files by scanning the XDR folder. An explicitly present +empty array remains an empty selection and is never bootstrapped. In either +case, the complete manifest and current detection files remain accounted for +as included or excluded inventory entries with reasons. + +V3.1 consumes only the explicit references written by apply and must not +repopulate excluded detections by scanning their folder. If XDR YAML exists +and no selection plan has added an explicit array, direct V3.1 packaging fails +closed; an explicit empty array means no Custom Detection installation or +registration resources. The plan and apply receipt are rechecked against +current files and evidence immediately before packaging, and package-template +and ZIP contents are checked against the exact plan. Applying snapshots the +original bytes and file identity, rechecks after staging the update, and +performs a post-receipt freshness check. If solution data changes while the +update or a receipt-failure rollback is being prepared, the operation stops +when it detects the change and retains conflict evidence where possible. The +migration writer lock serializes only this tool's own updates; apply requires +that external editors or processes do not modify `Data/Solution_*.json` until +it completes. Filesystem exchange detects changes at the commit boundary but +is not a general compare-and-swap guarantee for writers that do not honor the +lock. A receipt published before a later detected edit is retained as evidence +and is not treated as a valid current selection. + +Current local structural validation checks the source rule's kind-specific +contract. Both `Scheduled` and `NRT` rules are supported; scheduling fields +are required for `Scheduled` and forbidden for `NRT`. Historical runtime +evidence is reusable only when its own execution report, provider, current +query hash, run, workflow profile, solution, and target bindings agree. A +current sidecar authorization cannot supply missing execution-report +provenance. Selection reuses matching reports and does not dispatch runtime +queries. + +Partial publication is allowed only when validated, unique source-rule IDs +represented by deployable Custom Detections are strictly more than 50% of the +complete original Sentinel Analytic Rule inventory (`2 * validated > original`). +Exactly 50%, fewer, zero, or an empty/invalid denominator is blocked. The +denominator is the full current recursive source inventory, independent of +package selection; a scoped/replaced manifest, omitted source, duplicate +reference, duplicate detection, or source-only Analytic Rule cannot shrink or +inflate the count. A source-only Analytic Rule may remain in the package under +the existing eligibility rules but does not count as a migrated detection. +Only currently structurally valid, explicitly review-resolved, current +source/target/provider-bound query and schema evidence makes a Custom Detection +eligible. Global malformed manifest/report, inventory mismatch, ambiguous +identity, or selection/execution errors remain fatal. + +For a non-YOLO workflow, an applied, fresh selection meeting that threshold +may produce a V3.1 package containing only eligible reviewed/validated +selections, with outcome `partial-package-validated`. Preserve every excluded +YAML file and its source failure evidence; report the original denominator, +validated numerator and coverage, selected evidence scope, and every excluded +identity/reason. Do not turn excluded items into placeholder deployables. +Validation remains blocked for the full inventory and its original evidence is +unchanged. Authoring may publish this package without a lab; it must not claim +full-inventory validation. + +Qualification may continue for the exact selected subset after that package +passes. Deployment, ingestion, and parity must operate only on the currently +applied selected content and retain every existing per-item runtime/schema, +target, permission, disabled-state, ownership, consent, write-approval, +ingestion, and parity gate. Excluded item failures do not block aggregate +progress for the selected subset, but neither selected-subset completion nor +parity may be represented as full-inventory success. Persist and report the +selection-plan identity and exact selected evidence scope through Qualification. +Changing selection or any bound input invalidates the package/receipt and +requires replanning and renewed exact-scope decisions as applicable. This is +not a waiver of any selected-item gate. YOLO continues to require +full-inventory validation and review success. Reuse only current, +run/target/provider-bound runtime reports; selection does not rerun queries or +treat historical query hashes as proof of a current full-file hash. + +### Qualification — explicit opt-in + +#### Reusable lab read approval + +Request one explicit approval for migration-related read operations across all +selected solutions in the same locked lab, rather than separate approvals for +each solution or read-only step. Show the tenant, subscription, resource group, +workspace ARM ID and customer ID, expected provider surfaces, and read-operation +families before recording approval. Include supported status/access checks, +read-only preflight, and source/Advanced Hunting query execution. Query API +requests may use HTTP POST without being resource writes. + +Reuse that approval while the lab definition is unchanged and approval has not +been withdrawn. Adding a solution or refreshing its query hashes within the +approved operation families does not require another lab-read approval, but +each dispatch still requires current exact inventories, hashes, target checks, +bounded execution, effective access, and a supported provider route. Record +those bindings with the run's evidence; do not reuse stale validation results. +Reconfirm when any locked lab identifier or confirmed primary-XDR-workspace +setting changes, or when an operation expands beyond the approved read scope. + +Interactive authentication, Entra consent, role changes, deployments, +ingestion, rule enablement, deletion, and other environment writes are excluded. +Lab-read approval never grants access or authorizes an unsupported route, +unrelated tenant-wide discovery, secret/token export, or repeated unchanged +failed batches. Local evidence/report refreshes do not require another live-read +approval. Existing CLI consent checks must still be satisfied through supported +commands; reusable user approval is not permission to bypass those checks. + +`Discovery -> Conversion -> Validation -> Packaging -> Deployment -> Mock ingestion -> Alert parity -> Report` + +This is optional internal or ISV lab testing. Before initialization, require +the user to explicitly choose **Authoring**, **Qualification**, or **Cancel**. +There is no default and no workflow state may be created while the choice is +missing or declined. A configured tenant or workspace does not imply consent. + +Qualification requires an approved non-production tenant and workspace plus +one early, plan-bound request for the anticipated conditional validation and +qualification actions, followed by separate exact operation/batch approval +immediately before any environment write begins. The early request is not +authorization to execute writes. + +After target lock and before Discovery, build the local scope plan and ask once +for the complete potential-action set. Repeat this request only when the +target, plan identity, included resource families, payload, count caps, or +cleanup ownership changes. Do not ask the same broad planning question again +at later deployment preflight when the bound plan is unchanged. + +For the contributor-facing authoring sequence, local configuration, provider +boundaries, review decisions, V3.1 packaging, and pending orchestration +capabilities, follow the toolkit README's +[Contributor authoring guide](../README.md#contributor-authoring-guide). +That guide describes current deterministic CLI behavior separately from +autonomous MCP dispatch, cross-provider research, and interactive decision +experience, which are not implemented by this Python package. + +### Solution-local target configuration + +Qualification may use a credential-free `Solutions//.env`, copied +from the placeholder-only `Tools/SentinelToXDRMigration/.env.example`. Replace +its two literal values, `AZURE_TENANT_ID` and +`SENTINEL_WORKSPACE_RESOURCE_ID`; never put credentials or shell expressions +in this file or real identifiers in the shared example. The strict parser +accepts only those assignments and does not source the file, expand variables, +or execute commands. The solution `.env` is Git-ignored and excluded from +package inputs and optional evidence exports. It is not read by Authoring, and +no global or process-environment target defaults are silently applied. + +Inspect the selected solution before approving it: + +```powershell +sentinel-xdr-migration doctor --solution "Solutions\" +``` + +For a new configured Qualification run, explicitly opt in to Qualification and +confirm the displayed target: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "Solutions\" ` + --workflow-profile qualification ` + --confirm-configured-target ` + --version-bump none +``` + +The workspace subscription is derived from its validated ARM ID. Only after +explicit target confirmation does the resolver verify `az account show` has +the configured tenant and derived active subscription, then issue a single +exact `az resource show --ids ` request for the customer ID. +It never lists or scans workspaces. Authentication or exact-read failures stop +initialization; sign in separately to the approved identity and retry. +Explicit command-line values remain supported, but conflicts with `.env` fail +with an actionable error instead of silently choosing either value. Supplying +both `--tenant-id` and `--workspace-resource-id` is also explicit target +confirmation; the subscription is derived and the customer ID may be resolved +or supplied. + +An existing locked Qualification run remains authoritative on resume. Changes +to `.env` cannot retarget it; a conflicting local value blocks resumption and +must use the existing explicit target diagnosis/repair contract. Local `.env` +does not store the derived customer ID; that resolved identity is persisted in +the locked run target after the exact read. + +## Initialize or resume + +Install the CLI when necessary: + +```powershell +python -m pip install -e Tools\SentinelToXDRMigration +``` + +Run `workflow-status` first. If state does not exist, or +`context.profileSelectionConfirmed` is not `true`, obtain the explicit profile +selection before initializing: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "" ` + --workflow-profile authoring ` + --version-bump none +``` + +For an approved qualification run, use `qualification` and include +the complete locked target. Alternatively, after reviewing the per-solution +configuration above, use `--confirm-configured-target` instead of supplying +tenant/workspace values; the workflow derives the subscription and reads the +customer ID only from that exact approved workspace: + +```powershell +sentinel-xdr-migration workflow-init ` + --solution "" ` + --workflow-profile qualification ` + --confirm-configured-target ` + --version-bump "" +``` + +Before requesting a workspace value, inspect `doctor`. If +`configuredWorkspaceResourceId` exists, present it for explicit reuse +confirmation. Do not ask the user to retype it. When a new workspace is +approved and resolved, persist it for future solution workflows: + +```powershell +sentinel-xdr-migration configure-workspace ` + --tenant-id "" ` + --subscription-id "" ` + --workspace-resource-id "" ` + --workspace-customer-id "" +``` + +Always resume from persisted state: + +```powershell +sentinel-xdr-migration workflow-status --solution "" +sentinel-xdr-migration workflow-next --solution "" +``` + +Every workflow run stores its reports, configuration, operational state, and logs +under `Solutions//Logs/sentinel-xdr-migration//`. New workflows +get collision-safe timestamp/UUID IDs; resuming keeps the same ID. Use +`workflow-runs --solution ""` to list runs and pass +`--run-id ""` to all solution commands when more than one exists. +Never select the newest run heuristically. `workflow-init --new-run` explicitly +creates an isolated workflow rather than resuming; obtain the normal profile +selection first. Content YAML and package files remain shared solution content, +not per-run snapshots. + +When workflow context contains a full `workspaceResourceId`, it is authoritative +for the rest of that run. Pass it explicitly to every Azure command and never +list, rediscover, rank, or scan other workspaces. A Log Analytics customer-ID +GUID may be resolved to an ARM resource ID once, then the ARM ID must be +persisted. A tenant mismatch is an authentication blocker for the selected +workspace, not a reason to choose another workspace. + +All live stages must load +`Solutions\\Logs\sentinel-xdr-migration\\qualification-target.json` and +verify the tenant, subscription, workspace ARM ID, and workspace customer ID +before proceeding. Specialist tools must not independently discover or infer +any of them. + +If the exact ARM path fails, the orchestrator may run +`qualification-diagnose`. It may make one exact customer-ID query restricted +to the locked subscription. When a unique same-identity correction is found, +show the old and proposed resource IDs and require explicit approval before +running `qualification-repair-target --approve-target-update`. No repair may +change tenant, subscription, or customer ID. + +The ignored local file +`Solutions\\Logs\sentinel-xdr-migration\\workflow-state.json` records stage status, attempts, +timestamps, artifacts, evidence, profile, workspace, and version action. Never +store credentials or tokens in it and never mark a stage passed without +evidence. + +Prior flat solution Logs and legacy `Reports//sentinel-xdr-migration` (including the old +`AZURE_SENTINEL_REPORTS_ROOT` override) and report files in `XDR Detections` +are read-only fallback locations. Status reads never move or initialize state. +Before resuming writes, run `migrate-reports --solution ""`, +review the file list, and obtain approval for +`migrate-reports --solution "" --run-id "" --apply`. +This copies artifacts into that run, preserves originals, and records their hashes. +Honor any prior flat migration receipt before treating newer flat state as +authoritative over archived root Reports. Never import one run's archived +evidence into a new workflow. Conflicting copies, +changed legacy originals after migration, and missing migrated files block +resumption; reconcile explicitly rather than selecting stale evidence. +Never delete legacy state or initialize a replacement to bypass this gate. + +All solution Logs are Git-ignored and excluded from content/package inputs. +The solution-local `.env` target file is separately Git-ignored and excluded +from package/evidence content. +They may contain raw provider responses and lab identifiers, and must not be +force-added to a PR. An optional, explicit local-only PR evidence export is available: + +```powershell +sentinel-xdr-migration export-evidence --solution "" --run-id "" +``` + +Run it only after the selected run is stable. It writes trackable +`Evidence//{summary.md,evidence.json}`, never overwrites an existing +snapshot and does not change operational state, rerun tests or perform cloud +writes. No environment-write approval is needed for this explicit local export. +The header identifies the checked public solution name, profile, recorded status, +current observed solution version, requested version action and actual export +time/tool version. Missing target versions and original export times remain +unknown. An explicitly authorized header-only edit of an older snapshot must +preserve its results/hashes and distinguish new header observations from +historical export facts; `export-evidence` still refuses overwrites. +All recorded statuses, including failures, blocks and not-run results, are +preserved through a fixed allowlist; raw provider data/free-text messages and +tenant identifiers are omitted. Missing and unsupported evidence is explicit. +Hashes anchor current local bytes, not trusted attestation or historical test +binding. Review the snapshot before committing; see the toolkit README for +allowlists and filename-sanitization limits. Evidence is never loaded as resume +state or included in solution content inputs or Marketplace ZIPs. +Persisted filesystem references are solution-relative where possible; resolve +them against the selected solution, not the current shell directory. + +Start and complete each stage: + +```powershell +sentinel-xdr-migration workflow-start-stage ` + --solution "" ` + --stage discovery + +sentinel-xdr-migration workflow-complete-stage ` + --solution "" ` + --stage discovery ` + --status passed ` + --message "Reviewed source inventory" ` + --artifact inspection="Logs\sentinel-xdr-migration\\inspection.json" ` + --evidence "Analytic Rules\Example.yaml" +``` + +Allowed terminal statuses are `passed`, `failed`, and `blocked`. Explain +failed and blocked results. Correct and retry only that stage. + +### Event-driven durable coordination + +An optional local coordinator can track accountable ownership, executor claims, +idempotent events, and durable handoff notifications for an already initialized +run. Enroll using its exact run ID. The coordinator reads the existing +`workflow-status` and `workflow-next` results; it does not create, reorder, +complete, or weaken canonical stages or prerequisites: + +```powershell +sentinel-xdr-migration coordinator-enroll ` + --solution "" ` + --run-id "" ` + --owner "" + +sentinel-xdr-migration coordinator-reconcile ` + --solution "" ` + --run-id "" +sentinel-xdr-migration coordinator-status ` + --solution "" ` + --run-id "" +``` + +`coordinator-status` performs deterministic reconciliation and persists any +expired-claim, decision, and outbox updates; it is not read-only. + +The ignored registry is local to the current checkout. Its owner is independent +of any ephemeral executor. A claim is an atomic reservation, never permission +to run a command. It binds the action, exact current target, prerequisites, +evidence, and (for runtime tasks) query-set hash. An expired claim is blocked as +unknown outcome; cloud requests must not be blindly retried. Record results +through the existing gated CLI first, then post a hash-bound +`coordinator-event --kind executor-completed`. The event does not complete a +stage; reconciliation consumes it only when the authoritative workflow state +or current evidence supports the reported outcome. An expired claim can be +resolved only against the exact current target and evidence hash recorded by +an officially completed stage, through that completion event or +`claim-resolved --outcome completed`; this updates coordinator state only. +Confirming `not-started` requires separate hash-bound owner evidence. +If that terminal stage exposes a same-ID retry with changed evidence, the +retry remains blocked until the previous claim's authoritative handoff is +validated against its retained action snapshot and consumed. + +Coordination starts in shadow mode: it persists and reports decisions but +dispatches no CLI commands, authentication, grants, runtime queries, writes, or +user approvals. Changed decisions, including `approval-needed`, create durable +outbox items. `coordinator-event --kind approval-requested` separately records +an exact action, purpose, and target request; an adapter may deliver that +outbox only after rechecking the current action hash and target; reconciliation +supersedes stale outbox items. The explicit `coordinator-ack` means delivery +only, never approval. No host messaging adapter or autonomous daemon is +provided. Use the existing user-confirmation, +approval, preflight, and guarded CLI gates; a confirmation or preflight result +is not proof of service configuration or runtime success. + +Optional shared prerequisites are keyed by dependency, purpose, and target +fingerprint; for example, Advanced Hunting (`advancedHunting`) and Custom +Detection (`customDetection`) authorization remain separate even for the same +tenant. A submitted dependency remains an actor-reported, locally hash-checked +assertion; it is not automated permission or service-configuration proof. The +coordinator can show source and Advanced Hunting runtime tasks independently +without changing the single Validation stage gate. Status and +reconciliation report missing owner, ready-idle work, untracked running stages, +expired claims, unconsumed completion handoffs, and pending outbox deliveries. +The watchdog runs only when invoked; it is not a background service. + +### Solution watchdog best practice — `ACTION_REQUIRED` + +For each solution and current workstream, retain exactly one accountable +watchdog owner bound to the explicit run ID, selected profile, agreed goal, and +current approved scope. A global observer may aggregate status and alerts but +does not replace the solution owner or create duplicate owners. The existing +owner performs watchdog duty; do not launch replacement/nested agents to +re-check the same work. If ownership is missing, escalate to the responsible +parent rather than inventing an owner. + +The owner should retain one read-only periodic watch until the agreed goal is +verified complete or explicitly stopped/cancelled. Cadence is explicitly +configurable per coordination instruction rather than a fixed toolkit timer; +the current coordination setting is three minutes. Avoid hot polling, +interrupting a running owner, or launching duplicate executors. A fresh run +retargets the same owner and schedule, preserves prior history, and never +imports or relabels old-run success as current-run evidence. Completion requires the goal-specific +persistent artifact and its bound evidence—not an idle agent, status message, +toolkit commit, or watchdog acknowledgement. Stop the watch after genuine +completion or explicit stop/cancellation. + +When progress needs human/parent action, emit a prominent, repeatable +`ACTION_REQUIRED` handoff through the existing agent completion/handoff +channel. Include at least: + +- solution, current run ID, agreed goal, and the single watchdog owner; +- the exact next operation or prerequisite and the responsible parent; +- current approval reference and exact scope, or explicitly `missing`; +- readiness evidence and each missing, stale, or blocking prerequisite; +- last **real solution progress** timestamp, elapsed stalled time (or + `unknown` when it cannot be proved), and a stable outstanding-alert identity. + +Raise the alert when an approved operation is ready but has not started, or +when a concrete gate requires parent action. Repeat it on ordinary ticks until +genuine execution/progress, verified completion, explicit revocation, or a +reported blocker resolves it. Another status update, toolkit commit, or +watchdog acknowledgement does not reset the progress timer or clear the alert. +The parent must consume the handoff by executing the already-approved bounded +next step, obtaining a required missing approval, or reporting the exact +blocker; do not wait for the user to repeat the request. + +Example (all values are illustrative): + +```text +ACTION_REQUIRED +solution=ExampleSolution +runId=20261012T000000Z-example +goal=publish validated partial package (>50% migrated-CD coverage) +watchdogOwner=solution-owner +nextOperation=apply reviewed selection plan and build V3.1 package +responsibleParent=parent-coordinator +approval=granted; reference=approval-ref-01; scope=local-authoring-package-only +readiness=blocked; missing=2 of 3 source rules lack current validated CDs +lastRealProgressAt=2026-10-12T09:00:00Z +elapsedStalled=28 minutes +alertIdentity=ExampleSolution/run/selection-coverage +``` + +Watchdog ticks are read-only and do not grant build, packaging, toolkit, +content, workflow-state, cloud, authentication, ingestion, enablement, or +deletion authority. Actual operations remain with the existing sole executor +and must use the workflow's separate approvals and exact write gates. Do not +repeat unchanged, already-granted common choices or expand into unapproved +work. The watchdog rule does not add a scheduler, backend, agent registry, or +native CLI watchdog capability. + +## Stage gates + +### Discovery + +Run `sentinel-xdr-migration inspect`. Account for every source rule, ID, table, +query, and existing XDR artifact. Reject duplicate IDs, ambiguous provenance, +and content without defensible attacker behavior or observable telemetry. + +For content-creator Authoring, assume source-referenced Sentinel tables are +expected to be available in XDR; do not ask whether the target resolves both +tables or directory fields. This is not a deployment-environment guarantee. +The tool owns standard schema resolution: use actually advertised official +schema MCP/API capabilities when authorized, or authoritative Microsoft +documentation, with source/target query-surface and table/alias context. +Do not ask customers to choose `Timestamp`/`TimeGenerated` or field casing. +For custom `_CL` tables, infer only from actual available connector, DCR, or +table definitions; distinguish inferred, schema-verified, and runtime-validated +evidence. Missing/conflicting metadata is a tool/evidence limitation, never +permission to fabricate a schema from the suffix. + +This is approved authoring policy; the current finite rename catalog is not a +general schema resolver. See the README's +[current-versus-pending capability matrix](../README.md#content-creator-authoring-policy-and-pending-implementation). + +### Conversion + +Run `sentinel-xdr-migration convert`. The converter must preserve source Analytic +Rules; source-authoring metadata repairs are a separate step described below. Generated +files belong under `XDR Detections`, remain disabled, and contain source +provenance. Pass only with no conflicts and no unresolved `needsReview` item. +Exception: a best-effort entity-mapping assumption that is explicitly marked +`reviewRequired` may continue through conversion, structural validation, and +read-only query/schema testing. Preserve the pending review finding and its +source/query hash binding. This is not a review acceptance and does not clear +other `needsReview` findings, technical errors, failed queries, or malformed +content. Do not pause a complete batch for an assumption-only mapping choice. +The later packaging gate continues to reject unresolved review flags; this +policy does not authorize package readiness, deployment, or parity claims. +Only deployable detection YAML belongs under `XDR Detections`; conversion +manifests, reports, configuration, validation evidence, and state belong under +`Solutions\\Logs\sentinel-xdr-migration\`. + +For an explicitly selected pilot, `convert --rule-id ""` +limits writes to one rule while retaining global identity/provenance checks. +Pass the locked `--run-id` and reviewed `--config`; use `--overwrite` only for +the reviewed selected draft. Unknown, malformed, duplicated or excluded selected +IDs fail before writes. Unselected drafts, including config exclusions, are +never changed or deleted. The run's manifest/report are replaced with explicit +rule scope, not merged into full-solution coverage. Earlier passed conversion +and downstream stages are blocked with historical evidence retained. +Do not pass solution conversion/validation stages or package from scoped results: +the workflow and toolkit packager reject them. Full-solution conversion and the +normal gates are required before proceeding. Structural validation of existing +files alone is not conversion coverage. This pilot does not introduce scoped +runtime/deployment qualification or a generic waiver. The global source-runtime +missing-schema whitelist is limited to `IdentityInfo` and cannot authorize +scoped conversion, deployment, or reuse of stale query evidence. See the README's +[single-rule CLI contract](../README.md#single-rule-conversion). + +Explain entity mapping differences using the restricted target mapping contract: +identify retained identifiers, actual lost information, and query-proven +equivalence separately. An unsupported FullName property is not automatically +identity loss. Existing source SID joins are not migration regressions unless +conversion changes their semantics. The README includes a +[concrete Local Admin Group Changes review](../README.md#worked-review-local-admin-group-changes). +Source `requiredDataConnectors` is preserved in optional top-level XDR authoring +metadata, outside deployment `properties`, with exact presence/empty-list and +extensible-entry fidelity. On initial discovery, malformed metadata is a +conversion/structural failure, not a silently dropped declaration or a final +authoring outcome. Older artifacts require an +explicit conversion refresh; no automatic rewrite or gate waiver is implied. +See [dependency preservation](../README.md#connector-dependency-preservation-and-deployment-boundary). +Target registration/dependency translation remains pending design; do not add +an undocumented field to Graph or inner detection-rule properties. + +When an entity identifier cannot be preserved equivalently in the final +entity mappings, retain its available query output value **in addition** as +an `alertTemplate.customDetails` column binding. This applies to explicit +mapping overrides as well as automatic conversion. When static output binding +is proven, report this specific mismatch as nonblocking information. Missing or +unproven bindings, conflicts, limits, and dropped values remain warnings or +errors. Supplemental evidence does not restore entity identity or correlation. +For an unsupported identifier with an available output column, choose the +best supported target mapping deterministically when possible, retain the +original output value in custom details, and mark the choice as pending review. +For example, `Host.FullName` can provisionally use the documented +`hosts.nameColumn` when no stronger supported Host identifier is available. +State that the mapping is an assumption, not equivalence. This assumption-only +review does not interrupt conversion or read-only query/schema tests; it remains +visible and blocks packaging/deployment until reviewed. Never invent a target +field or query output column, and do not let this exception waive any unrelated +error or review gate. +Preserve source/override custom details, reject conflicting +explicit keys, and use stable collision-safe keys for added details. Record the +source identifier, bound output column and availability status in conversion +provenance. Do not rewrite KQL or emit known projected-out supplemental columns. +Unknown output bindings require runtime schema verification, not an availability +claim. Respect the documented 20-pair limit without truncation and disclose the +4 KB runtime-value limit. See +[supplemental entity evidence](../README.md#supplemental-entity-evidence-in-custom-details). + +For a source lookback shorter than a documented native XDR service window, +conversion may constrain event time only after conservative proof of a single +matching native input and its time-column binding. The filter belongs directly +after the table input, before any aggregation; preserve existing predicates. +Record original/effective/service windows, rationale, and late-ingestion risk. +Joins, unions, historical branches, reassigned columns, or ambiguous semantics +remain `needsReview` without speculative query edits. Explain that the event-time +window is constrained to the original Sentinel lookback while the fixed service +windows remain different. This is not cadence or alert-parity proof; generated +transforms still require ordinary runtime validation. Any applied Advanced +Hunting KQL rewrite invalidates earlier query-runtime evidence and blocks +previously passed validation and downstream stages while retaining old reports +as historical. Rerun the complete Sentinel and Advanced Hunting query families +against the exact current queries. Runtime imports must include the SHA-256 of +the exact query executed; missing or mismatched hashes are blocked. Review +decisions are source/query-hash scoped and expire after a rewrite. Failed KQL +results cannot be relabeled as provider gaps, and fallback is only for provider +failure with a complete-family rerun. Zero rows indicate execution, not parity. +Static/unit checks do not promote a transform to runtime-validated. + +Preserve **all** source tactics in source order in authored YAML; multiple +tactics alone are not a review gate. Assign flat source techniques only to +compatible tactics using the repository MITRE catalog, retaining independently +recorded original lists. Unknown/invalid classifications still block. Legacy +single-tactic overrides correct techniques only for their existing named source +tactic, with an explicit warning that they neither narrow nor reorder the +source list. Invalid or incompatible overrides fail rather than lose metadata. +Historical single-tactic draft findings require explicit regeneration, not a +mandatory choice of one tactic for authoring. See the +[MITRE contract](../README.md#mitre-authoring-and-deployment-contracts). + +#### Metadata repair and user notification + +Within approved content-authoring scope, fix unambiguous schema-defined +metadata inconsistencies at **both ends**: the Sentinel source and its generated +XDR draft. This applies to all metadata, not just connector declarations. +The orchestrator performs a minimal source-authoring maintenance edit and then +uses the existing converter to refresh the affected draft; normal conversion +must never automatically edit source rules. No backend auto-repair engine or +toolkit modification is authorized by this content workflow. + +Only mechanical, schema-clear corrections are automatic within that scope. +For example, rename a lone AWSS3 `datatypes` key to required `dataTypes`, keeping +`AWSS3` and `AWSVPCFlow` unchanged. Do not normalize arbitrary extension fields, +valid IDs/data-type values, declaration order, duplicates, or absent/empty lists. +Conflicting keys/values, ambiguous intent, tactics/techniques, entity semantics, +identities and versions require an explicit decision, not an invented fix. +Intentional source/target contract differences are not inconsistencies to erase. + +Record file scope, field paths, exact before/after values and schema rationale. +Preserve prior report snapshots and query hashes. For an isolated repair, pass +the selected source `--rule-id`, locked `--run-id`, reviewed `--config` and +approved `--overwrite`; never recreate deliberately deleted unselected drafts. +Verify both metadata structures and their correspondence, unchanged queries, +identities, versions and disabled lifecycle, and untouched unrelated files. +Refresh diagnostics with the existing tools and record repair evidence without +waiving stages or representing scoped output as full-solution coverage. + +Inform the user of repairs and outstanding findings separately, including the +affected files and before/after values. Metadata repair clears only the resolved +error; runtime checks remain not run if unexecuted, and unrelated mixed-surface +reviews/warnings remain visible. Identify older comparison pages and exported +evidence as historical unless explicitly refreshed; never attach new hashes to +old snapshot content. See the README's +[metadata repair policy](../README.md#repair-metadata-at-both-ends). + +### Validation + +If the user declines runtime validation, do not authenticate or run live +queries. Present this disclosure while retaining actual static/schema checks: + +> KQL has not been validated because this migration runs without runtime validation. Enable full validation, or review/accept the specific migration checks below. + +This disclosure is not runtime verification. Report unexecuted checks as not +run; keep failed, blocked, and unresolved-review results visible. A specific +review acceptance is not runtime verification. Offline Authoring may complete +Validation only through its explicit `validationMode=offline-authoring` route: +the current complete recursive source inventory must have fresh full-solution +conversion and structural evidence, all technical findings and hash-bound +review decisions must be resolved, and no persisted runtime report may be +overridden or discarded. This outcome records `runtimeStatus=not-run`, clearly +states that Sentinel and Advanced Hunting runtime validation did not run, and +is not runtime/XDR qualification. It does not dispatch queries or weaken any +Qualification gate. If any prerequisite is missing or stale, or actual runtime +failure evidence exists, keep Validation blocked and preserve that evidence. +If a current provider report is recorded after offline Validation passed, the +workflow reopens Validation and downstream gates so the new evidence is +evaluated rather than silently ignored. Do not turn an unmet runtime check +into an environment-only error. + +For a legacy Authoring run whose passed Conversion stage lacks +`manifestSha256`, use the explicit +`workflow-recover-conversion-evidence --solution --run-id ` +operation. It binds only the selected run's existing `manifest.json` after +revalidating current complete inventory, clean conversion outcomes, target +provenance, review resolution, and full-solution structure. It never repairs an +existing mismatched digest or accepts a scoped/stale manifest. Do not edit +workflow state manually or reconvert to bypass a failed recovery. Once +recovered, resume the already-running Validation stage, or start it if still +pending, then complete it with `runtimeStatus=not-run` and +`validationMode=offline-authoring`. + +Run structural validation. When runtime validation is authorized, validate +original Sentinel and converted Advanced Hunting queries. Prefer capabilities +actually advertised by the official Sentinel Triage MCP; do not invent tool +names. Verify the exact persisted workspace/tenant and read permissions before +live queries. For the Graph Advanced Hunting family, execute each exact +converted detection query only; do not add table, function, or schema probes. +The runtime plan and consent inventory must contain the same one-to-one +detection-query set. If a provider-level failure interrupts a query family, +rerun the entire family through the native Azure Monitor Query SDK for Sentinel +(`validate-sentinel-sdk`) or Microsoft Graph for Advanced Hunting, and record +the provider gap with complete-family evidence. The SDK uses the selected +Qualification run's locked workspace and current Azure CLI identity, sends the +exact source KQL as a request value, and does not impose a separate timespan. +It verifies the active CLI tenant and subscription against the locked target +before querying, then pins AzureCliCredential to the locked tenant only; it +does not pass `--subscription` to token acquisition because Azure CLI rejects +combining it with `--tenant`, and it never changes the active account. +Never mix providers within one family, and never fall back on a real KQL +semantic/runtime error. The legacy `log-analytics-cli` import remains +supported for externally executed evidence. +Keep structural, runtime, schema-binding, and qualification results separate. +`runtime-validation.graph.json` binds every result to the SHA-256 of the exact +Advanced Hunting query. Imported Sentinel MCP or Log Analytics results must +carry `querySha256` for the exact persisted source query; the SDK runner binds +it automatically and `record-runtime-validation` blocks missing or stale +bindings. A validation stage can pass as runtime +validated only with complete current-hash Sentinel and Advanced Hunting reports. +Authoring may explicitly record `runtimeStatus=environment-blocked` when no +current exact-query KQL/runtime failure exists; this is not runtime +qualification. Qualification cannot use that exception. + +The validation-stage producer records the canonical `sentinelRuntimeJson` +reference and the legacy `sentinelRuntimeReport` alias for the exact source +report. Consumers accept either reference only when every present reference +resolves to that same report; conflicting, stale, or unsafe references are +rejected. A missing reference may be repaired only by the guarded +`Tools/ignored/repair-runtime-report-references.py` maintenance utility after +it independently verifies the passed stage, complete source and Graph +families, typed SDK schemas, query hashes, and report-owned run/target/ +consent/dispatch bindings. Its default is read-only dry run; `--apply` changes +only the canonical state reference under the workflow writer lock and records +a preimage backup and receipt. This reference normalization is not validation, +does not clear failures or review gates, and never upgrades legacy reports +whose own execution provenance is missing. A stale or failed validation stage +is not eligible. + +The source-runtime missing-schema whitelist is global across solutions, source +queries, and runs, and contains exactly `IdentityInfo` (case-insensitive +matching; canonical report name `IdentityInfo`). It automatically classifies +only a current Sentinel/Log Analytics source result with HTTP 400, +`failureKind=query`, and `SEM0100` explicitly stating that the `IdentityInfo` +table could not be resolved. This includes the preserved Azure Monitor SDK +error structure/message (`'summarize' operator: Failed to resolve table or +column expression named 'IdentityInfo'`) and the equivalent explicit +`Failed to resolve table 'IdentityInfo'` wording already present in provider +evidence. A missing column, another table, mixed/multiple error codes or +diagnostics, partial family/provider failure, authentication/authorization, +tenant, transport, timeout, stale query hash, or changed inventory is not +whitelisted. The classifier is structure-aware and source-provider-only. + +There is no per-run allowlist, actor, reason, or acceptance command. The +derived runtime assessment is bound to each exact current source-query hash +and the complete source family. It does not create or mutate a decision +artifact: the raw source result remains `failed`, with its original provider +error. A qualifying result is represented as `source-waived`, never passed, +successful, or executed, and reports include both the whitelist table/reason +and the unchanged raw failure. + +The whitelist only classifies source missing-table failures. Validation may +complete as `runtimeStatus=source-waived` only when every other current source +query passes, every converted XDR query has exact successful execution and +actual output-schema evidence (including verified `customDetails` bindings), +and full-solution structural, technical, and review gates pass. Direct stage +completion enforces those gates independently. A source-waived query alone +never completes Validation when XDR is unrun, has missing schema, or any +unrelated gate remains. Whitelist status remains visible in reports while the +overall Validation stage is blocked. No XDR schema, mapping review, +technical validation, packaging, deployment, ingestion, or parity gate is +weakened. Documentation that `IdentityInfo` exists in Advanced Hunting does +not establish availability in a selected Sentinel source workspace. + +When investigating table/schema uncertainty, use standard-table documentation, +provider query schemas, and solution-owned parser or custom connector DCR/table +metadata. Do not invent schema or defer tool-owned repository/provider research +to the customer. Present scoped evidence and proposed assumptions for explicit +user accept/reject; stale source or query evidence requires re-review. + +Unavailable workload tables are blocked environment results. Zero rows prove +query execution, not behavioral parity. Entity mappings require review. +For custom-detail bindings, verify the target output schema even when zero +rows are returned. Imported successful runtime results must include the actual +target-query `schema` array when custom details exist; a column count alone +cannot prove bindings. Runtime value size remains unverified by schema alone. + +Defender Advanced Hunting and Log Analytics workspace queries are distinct +surfaces. A table visible in one does not prove availability in the other. +Always name the failing surface and use only the persisted workspace for +Sentinel validation. + +For Authoring, structural success with an environment-only runtime gap may +complete this stage as passed with `runtimeStatus=environment-blocked`; preserve +the exact error and do not claim runtime qualification. This allows V3.1 package +creation. Qualification keeps the same gap blocking because deployment, mock +ingestion, and parity require the selected lab environment. + +### Packaging + +Use V3.1 for solutions containing XDR Detections: + +```powershell +sentinel-xdr-migration package-v3-1 ` + --solution ".\Solutions\" ` + --version-bump none +``` + +When the solution worktree does not contain the current V3.1 tooling, select +the approved toolkit checkout explicitly. The solution remains the source and +output root; the selected toolkit supplies the entry point, helpers, templates, +and validation contract. Omit `--toolkit-root` for the existing same-repository +behavior: + +```powershell +sentinel-xdr-migration package-v3-1 ` + --solution "\Solutions\" ` + --toolkit-root "" ` + --run-id "" ` + --version-bump none +``` + +V3 is the legacy Sentinel-only packager and intentionally skips XDR +Detections. Use `none` for repeat checks. Use an approved `patch`, `minor`, or +`major` action once when preparing the submission package; it synchronizes the +solution data and metadata versions. + +Require the generated template, UI definition, parameter file, versioned ZIP, +AR/CD count matching, correct `E5Flavor` conditions, disabled CDs, unique IDs, +and explained validation results. Complete the packaging stage only with +`packager=V3.1`, the generated `packaging.v3_1.json`, and its template, UI, +parameters, and ZIP paths. Existing package files are not current-run evidence. +Do not relabel historical V4 evidence. Rebuild with V3.1. Surface missing +customer usage attribution warnings; optional `trackingId` must be copied +unchanged from Partner Center into SolutionMetadata.json, never synthesized. +V3.1 copies only the first authored tactic and its compatible nested techniques +into the ARM payload, without changing the complete YAML/provenance. Disclose +this intentional classification loss; packaging is not classification/alert +parity validation and must not mark parity passed. + +Offline Authoring may use this package gate only after its explicit +`validationMode=offline-authoring` Validation stage has passed for the current +full inventory. If initialization selected `version-bump=none` but a first +versioned release is required, inspect the current package-input SHA-256 and +explicitly plan one `patch`, `minor`, or `major` action against that exact hash +before invoking V3.1. Do not reuse stale selection, skip review gates, or +consume a second version action. Qualification still requires its exact +runtime-bound selection and all Qualification gates before packaging. + +### Advance deployment-scope planning and consent + +In Qualification, produce and review a deterministic, local deployment-scope +plan immediately after target lock and before Discovery. This is the single +advance-consent request for the anticipated conditional qualification plan. +Refresh and display it during later preflight, but do not re-ask for unchanged +scope. The plan binds to the explicitly selected Qualification run and its locked +`qualification-target.json`; it does not read a new target, authenticate, +discover resources, or call Azure. The solution-local `.env` is only an input +to the existing target resolver. It is not authentication, consent, or a +retargeting mechanism, and it is excluded from source/package fingerprints. + +`preflight-deployment` exposes additive `deploymentPlanning` metadata. To +persist the standalone plan and obtain its current `planSha256`, use: + +```powershell +sentinel-xdr-migration plan-deployment ` + --solution "Solutions\" ` + --run-id "" +``` + +This command is local-only and writes `deployment-scope-plan.json` under the +selected run. Review the exact target, manifest-declared content counts and +workflow identities alongside raw source-file counts and unlisted files, +current reviewed disabled drafts, recursively unique source-rule identities, +eligible maximum additional Custom Detections, exclusions, content/package +hashes, component resource types, package selections, ingestion payload plan, +and scope caps. For playbooks, distinguish manifest-declared deployment +templates from actual Logic Apps workflow resources; parameterized names in +different templates are separate workflow resources. The plan distinguishes +ineligible new-conversion candidates +from matching current reviewed drafts: a candidate-generation limitation does +not invalidate a current reviewed draft whose persisted disabled Graph payload +and explicit projection validate. The eligible maximum is an upper bound, not +a promise that every rule converts or is deployable. +If there is no current package or reviewed ingestion plan, the affected plan +portions remain provisional. Unknown counts, costs, +retention, schemas, payloads, routes, or resources do not become blanket +approval. + +Planning consent is an explicit, separate local decision. It is bound to the +current `planSha256`, exact locked target, selected component resource types, +included resource families, count caps, and package resource inventory. Record +the complete selected component set only after reviewing the plan: + +```powershell +sentinel-xdr-migration review-deployment-plan ` + --solution "Solutions\" ` + --run-id "" ` + --expected-plan-sha256 "" ` + --actor "" ` + --approve-component sentinelAlertRules ` + --approve-component xdrCustomDetections ` + --max-additional-custom-detections +``` + +At the initial request, present the complete action inventory for this exact +target and plan before showing the suggested default selection: + +| Potential action group | Scope to show before asking | +| --- | --- | +| Disabled Sentinel AR / Graph CD creation or approved updates | Exact source-rule IDs and reviewed drafts; separate AR, CD, and pair counts; additional-CD cap; rules remain disabled. | +| Conditional fallback DCR and DCE creation | Exact contract, stream, destination, and candidate IDs/types; only if no compatible existing solution DCR is proven; DCE only if selected or required. | +| Conditional custom-log table creation | Exact table/schema/path/column count; only if the contract proves the table absent. Never alter an incompatible existing table. | +| Mock telemetry ingestion | Shared payload hash, record/batch counts, destination/table, stream, marker/timestamps, schema/transform/contract hashes, estimated bytes/cost/retention; ingest once per AR/CD pair, benign controls separate. | +| Controlled alert parity | Exact pair IDs/count, proposed test window, enable/restore operation bounds, and completion/abort plan to restore both rules disabled. | +| Full-package installation and XDR registration | Current package identity/resource inventory and every selected package flag; distinguish installation/registration from package creation. | +| Explicitly owned cleanup/deletion | Exact resources, ownership evidence, deletion cap, and exclusions; pre-existing or unowned resources are never included. | + +Present a structured consent form with **Approve the broadest currently +bounded and available scope** preselected as the suggested default, alongside +**Select components**, **Revise the plan**, and **Decline advance consent**. +The preselection is only a proposal: it is not consent and must not write a +consent record. Show every proposed component, cap, target, resource family, +and package flag explicitly before submission. Require an affirmative user +submission/confirmation of the displayed scope; a preselected choice without +submission, a cancelled/closed form, a decline, or no response records no +approval. A user may narrow, revise, or decline the proposal. + +“Broadest currently bounded and available” means only components the current +consent CLI supports and the current plan can bind to complete evidence. When +both routes are available, the suggested set may include the disabled +Sentinel-AR/Graph-CD pair and separate later `fullPackageAcceptance`. For the +package component, display every exact current package selection flag and its +proposed boolean value; when the plan exposes five flags and all-true is a +valid bounded selection, show all five names individually with `true`, never +hide them behind an “all” label. Do not silently add conditional DCR/DCE, +custom-table creation, ingestion, parity, or cleanup: include such a component +only when its scope is complete, supported, and explicitly shown as selected. +Unknown, provisional, unsupported, or unselected components remain +unapproved; if the broadest scope cannot be completely bound, preselect only +the largest valid bounded subset and list excluded groups with reasons. + +Persist only the exact, affirmatively submitted component and flag selection +after checking it against the current plan. The proposed default alone, +environment state, or an absent/cancelled response must never set consent to +approved. Denial or absence must not block independent read-only checks or +offline Authoring. + +Sentinel alert rules and Graph Custom Detections are a pair-first route and +must be approved together, disabled, with an explicit cap no larger than the +current eligible maximum. A later `fullPackageAcceptance` component is +separate and requires a current verified V3.1 package plus explicit values +for every current package selection flag. It covers full-package installation +and selected XDR installation/Content Hub registration, not just package +creation. Pair-first deployment and later package acceptance are distinct +routes; package files are not live deployments. Stored playbook, workbook, +connector, API, or connection definitions do not imply live instantiation or +enablement. + +The current consent CLI supports only the component names documented below. +The planner reports `controlledAlertParity` as provisional with eligible pair +IDs and calculated enable/restore request bounds, but cannot persist parity +consent until a finite test window and complete batch fixture plan are bound. +`ownedResourceCleanup` is unsupported: no exact run-owned deletion inventory +or deletion executor exists. Neither category can be approved through +`review-deployment-plan`; do not invent component names or claim these actions +are preapproved. An earlier approval limited to DCR/DCE does not approve +ingestion, enablement, package acceptance, or cleanup. + +Separate components are available for `mockTelemetryIngestion`, +`fallbackDcr`, `fallbackDce`, and `customTableCreate`. Ingestion approval +requires a reviewed run-bound payload and exact destination/stream, records, +batches, payload and schema/transform/contract hashes, marker/timestamps, +estimated bytes/cost, and retention impact. The same reviewed payload is +ingested once per AR/CD pair; benign controls remain separate. DCR/DCE are +conditional provisioning scopes supporting ingestion of locally generated +mocks; they are not required to generate mocks. DCE requires an explicit +selection and must be paired with the DCR component. A DCR fallback is +conditional on no compatible existing solution DCR being proven. The current +resolver checks registered exact resource-name candidates only; it does not +discover arbitrary solution-owned DCRs, so this limitation must remain +visible and an exact fresh check is still required before provisioning. + +Custom-table creation is conditional only when the exact supported ingestion +contract proves the selected custom-log table absent and supplies its schema, +path, columns, and plan. A present incompatible table is blocked, not altered. +An existing compatible native table such as `CommonSecurityLog` means table +creation is not needed; never create a custom lookalike. Ingestion, table +creation, DCR/DCE provisioning, rule deployment, enablement, cleanup, +retention changes, role assignments, and identity/app-registration changes +remain distinct operation scopes. Planning consent does not authorize any of +them. + +Consent is never inferred from absent/false/unknown metadata, workflow +continuation, a token or role grant, environment identity, example counts, +mock-generation hypotheses, or package selection. Changes to the locked +target, plan/content/package hashes, resource families, payload, selected +flags, or count caps require a fresh plan and renewed consent. Do not hand-edit +workflow state to simulate a review. Planning-consent denial or absence must +not block independent read-only checks or offline Authoring. + +Separate the advance request from authentication, Entra admin consent, +permissions, role assignments, and resource-write approval. Identity and +access steps never imply resource consent. A changed target, resource family, +mode, payload, cap, package flags, or cleanup-ownership boundary invalidates +advance consent and requires a refreshed plan and explicit renewed selection. + +Advance consent is not final execution approval. Immediately before each +bounded environment-write operation or batch, show its operation, locked +tenant/subscription/resource group/workspace, solution/version, exact +resources, actions/methods and count, plus current permission/readiness +evidence, and obtain explicit approval. One approval can cover the enumerated +batch; it need not be repeated for each underlying HTTP request. Pause for +approval if a write expands beyond the approved batch. Planning consent cannot +substitute for final approval, permissions, technical readiness, or deployment +evidence. + +### Deployment — qualification only + +Repeat deployment of the same reviewed detection identities to the same locked, +approved non-production lab is a supported, bounded create-or-update workflow; +do not add a separate mandatory “check whether each AR/CD already exists” +pre-step. Keep all rules disabled. The Sentinel route sends `PUT` to the +deterministic `Microsoft.SecurityInsights/alertRules/{source-rule-id}` resource, +which idempotently creates or updates that alert rule for the same ID and +payload. The alert-rule-template API is a catalog read surface, not a resource +this direct-deployment command creates; the `alertRuleTemplateName` field links +the deployed alert rule to its source template. Any package `contentTemplates` +installation is a separate package-acceptance route with its own explicit scope +and approval. + +For Graph, the current deployer derives the stable client-provided ID +`xdr-`, performs an exact-ID `GET`, uses `POST` only when +that ID returns 404, and uses `PATCH` when it returns 200, followed by required +read-back verification. Treat this supported CLI sequence as a repeatable +workflow-level upsert for the reviewed ID, not as a guarantee that arbitrary +Graph `POST` requests are idempotent. The separate pre-deployment +`preflight-deployment` exact-rule existence/readability check is optional +diagnostic evidence; it is not proof of ownership, readiness, or permission. +However, the current Graph deployer still requires its own exact-ID `GET`, and +the current preflight also performs exact-ID reads and can report them as +blocking. Documentation alone cannot skip those backend reads. Do not bypass +them with a direct `POST`, blindly retry a write with an ambiguous outcome, or +overwrite an ID whose ownership/content is uncertain; stop and require +maintainer review for that collision or read-access limitation. + +Only repeat this workflow against the explicitly approved, locked lab with +reviewed identities/content, the exact-scope write approval immediately before +the operation, required permissions, and the existing disabled-state and +post-deployment verification gates. Repeatability does not authorize writes to +production, unrelated resources, or enabled content, and it does not waive +review of unexpected existing IDs. + +Deploy every reviewed AR and CD disabled. Verify Azure RBAC and Microsoft Graph +scopes separately. A successful write without live read verification is +blocked, not passed. Never infer Azure resource permissions from Entra Global +Administrator. +The Microsoft Graph payload builder explicitly projects a multi-tactic authored +detection to its first tactic and retains that tactic's nested techniques. +Eligibility must come from validating this actual disabled Graph payload, not +from a stale authored-document readiness flag. Preserve source YAML and +provenance, and disclose omitted tactics/techniques with classification parity +not established. This API-payload projection is not permission to rewrite the +authored detection or claim semantic parity; package acceptance remains a +separate route and its live behavior requires deployment/readback evidence. + +For package acceptance, use ARM validation or preview only where the resource +types and deployment features support it. Bicep extensibility imports do not +support `what-if` ([Microsoft Learn: unsupported deployment +features](https://learn.microsoft.com/en-us/graph/templates/limitations#unsupported-deployment-features)); +do not pass or wait for a `what-if` result for that route. An ordinary ARM +preview cannot prove extension acceptance, and ARM template validation must +not be represented as validating extension behavior. The approved actual +deployment followed by readback remains required. + +#### Post-validation ARM template deployment acceptance-only step + +This optional step is separate from pair-first deployment, verified +full-package installation, alert activation, and alert parity. Run it only in +Qualification after creating the final V3.1 package and passing the repository +build-and-validate script. For each exact solution/run member, collect its +`build-and-validate-report.md` in validation-only mode, against the current +V3.1 package report: + +```powershell +pwsh .script/local-validation/build-and-validate.ps1 ` + -SolutionName "" ` + -ContentRoot "" ` + -SkipPackaging ` + -PackageReportPath "Logs/sentinel-xdr-migration//packaging.v3_1.json" ` + -VersionBump none ` + -ValidationSkip "" +``` + +The runner emits `build-and-validate-binding.json` beside that report. It binds +the final package report/run and artifact hashes, package inputs and +`Package/` fingerprints before and after validation, successful validator +results, selected solution, and `ContentRoot`. The producer records +`sourcePathSemantics` only when the complete source-input fingerprint matches +the package report under POSIX or Windows Python `Path` ordering; the +acceptance consumer honors that recorded ordering across hosts. `Package/` +fingerprints keep their separate ordinal ordering. Windows ordering lowercases +each component before ordinal component comparison, matching `PureWindowsPath` +rather than .NET `OrdinalIgnoreCase` string ordering. Missing, ambiguous, +unmatched, stale, or changed inputs fail closed. The earlier migration +`stages.validation` status is not a substitute. This acceptance-only +readiness is not gated on mock ingestion, DCR readiness, or parity. + +Plan the exact same-PR/lab workstream batch, then review its hash, locked +target, parameters, and every selected solution before the immediate +write-approval command: + +```powershell +sentinel-xdr-migration plan-arm-template-acceptance-batch ` + --member "::" ` + --validation-receipt "" ` + --member "::" ` + --validation-receipt "" ` + --shared-decisions "" + +sentinel-xdr-migration deploy-arm-template-acceptance-batch ` + --member "::" ` + --validation-receipt "" ` + --member "::" ` + --validation-receipt "" ` + --shared-decisions "" ` + --expected-batch-plan-sha256 "" ` + --actor "" ` + --confirm-exact-scope-write "I approve the exact enumerated ARM template deployment acceptance batch" +``` + +Repeat both member and receipt arguments, in the same order, for each +approved solution. The reviewed batch must match the user-confirmed shared PR +and lab decision. Deployment submits the exact approved V3.1 +`Package/mainTemplate.json` bytes through Azure CLI's documented +resource-group ARM route in Incremental mode, supplying the locked workspace +and every package-declared `Deploy*` content flag explicitly as `true`. +Undeclared flags are never invented. The executor binds the exact template +snapshot, parameters, package identity and batch to immediate approval, +persists write intent before submission, and records the deployment identity +and outcome. A per-member durable reservation blocks blind resubmission after +an ambiguous attempt, including when a later invocation reorders the batch. +One batch approval covers only its enumerated members; it does not authorize +an expanded batch. + +For this acceptance-only receipt, require only recorded terminal ARM +`provisioningState: Succeeded` for each member. Do not require `what-if`, +resource-group resource reads, per-resource read-back, or behavioral tests +for this result. `Failed`, nonterminal, missing-state, timed-out, or partial +outcomes remain durable failure/unknown evidence and must be reconciled, not +blindly retried. ARM `Succeeded` is deployment-submission acceptance only: it +does not prove MicrosoftSecurity import behavior, exact content installation +or read-back, activation of disabled detections, alert parity, or completed +Qualification. Incremental mode does not guarantee no impact. Keep verified +installation, post-install activation, and optional parity as separate gates. + +### Mock ingestion — qualification only + +Use `sentinel-solution-optional-testing` for this stage: + +1. Run read-only workspace, DCR, Sentinel query, and permission preflight before + fixture preparation or ingestion. Report telemetry-write permission, DCR + creation permission, conditional DCE creation permission, and conditional + destination-table creation permission separately. Check DCE creation only + when the selected route includes a DCE. This diagnostic preflight does not + provision resources or grant write approval. An `unknown` or pending-review + permission is not a grant. Missing write permission blocks the + corresponding write, not independent read-only workspace, DCR, schema, or + query checks. This preflight requires no fixture. Pass `--run-id` when the + solution has multiple runs; omit it only when the run is unique. + + ```powershell + sentinel-xdr-migration preflight-deployment ` + --solution "Solutions\" ` + --auth-method azure-cli ` + --run-id "" + ``` + + It writes `qualification-dcr-preflight.json` under the selected run's + `Logs/sentinel-xdr-migration//` and adds + `dcrPermissionPreflight` to the top-level Graph report. A permission + `ready` means effective permissions were inspected, not that ingestion or + query validation succeeded; the preflight never authorizes a write. See the + README's [preflight report contract](../README.md#qualification-mock-ingestion-dcr-selection) + for fields and status values. Aggregate `blocked` identifies locked-target + artifact or mismatch failures; resource-read status `unavailable` includes + HTTP 401. The per-contract `resourceFingerprint` hashes the exact target, + candidate DCE/DCR/table IDs, returned resource configuration/schema, and + current resource states. `contractFingerprint` is the SHA-256 of the + registered serialized contract; provenance is + `registered-contract-resolver`, and candidate selection is + `exact-contract-resource-names-only`. `resourceReadAccess` may be `ready`, + `denied`, `unknown`, or `unavailable`; `targetVerification` may be + `blocked`, `unavailable`, `confirmed`, or `not-checked`. +2. Present Continue, Retry permission check, or Cancel. Continue is not write + approval. +3. Check the default rule-specific `mock.json` path first. +4. Reuse a complete reviewed scenario or invoke + `sentinel-solution-mock-data-generation`. +5. Require reviewed input for joins, thresholds, aggregation, sequences, + historical baselines, watchlists, anomalies, and absence-of-data behavior. +6. Prefer a DCR explicitly listed or referenced by the selected solution only + when its deployed resource in the locked workspace is proven compatible + with the exact shared fixture, public input stream, transform, destination + table/schema, endpoint, and permissions. A packaged ARM reference alone is + not proof that the resource exists. Do not confuse an internal DCR + identifier with its public input-stream name or enumerate other + workspaces. For Logs Ingestion API fallback, structured records must use a + customer-authored `Custom-*` input stream and `transformKql` to map to a + supported `Microsoft-*` output. Public table schema does not reveal a + connector's internal parser or enrichment, and reserved/service-managed + columns must not be assumed writable. If no solution-listed DCR is suitable, + a supported contract may be used to author a separate, isolated DCR locally + and provision it only in the approved non-production Qualification target + after exact-scope approval. A DCE is needed for private-link ingestion or + when the DCR has no ingestion endpoint; this repository's contract tool may + provision an isolated DCE as part of its route, but DCE creation is not a + universal API requirement. “Locally” means locally authored configuration, + not a local ingestion service. Never modify an existing connector DCR or + redirect a native table to a custom lookalike; use a documented native + telemetry generator or block the test when no supported ingestion path + exists. See the README's + [DCR-selection policy](../README.md#qualification-mock-ingestion-dcr-selection). +7. Require separate exact-scope approval immediately before ingestion or + provisioning any new DCR, any DCE included in the selected route, or table. +8. Group compatible rules by source table and supported ingestion path, then + ingest each shared `mock.json` exactly once. +9. Track ingestion acceptance and query visibility independently. +10. Run the exact AR and CD queries over the same unique scenario markers and + require identical matching source-row keys before alert parity. + +**Implementation status:** The solution-first DCR preference is the intended +policy, but the current registered-contract resolver checks contract-specific +resources in the locked target and does not yet discover or prefer a +solution-referenced DCR. Do not claim automatic solution-DCR selection; report +only compatibility and permission results actually established by the +available preflight and resolver. + +Each AR/CD pair owns one shared `mock.json`; separate AR and CD payloads are +prohibited. `mock.json` may contain multiple correlated records when the +detection requires them. Never create a recurring ingestion job, and never +redirect an unsupported native table to a custom lookalike table. Use a +documented native telemetry generator or mark the rule blocked. + +Every payload needs a unique scenario marker and expected malicious match-key +set. Pass only after ingestion is accepted and both exact queries observe the +same marked records. The existing alert-parity stage then compares alerts from +that same one-time ingestion. + +For supported Standard tables, the scenario generator writes +`ingestion-contract.yaml` from the reviewed registry under +`Tools\AzureMonitorLogsIngestion\contracts`. Apply the locked workspace at +runtime: + +```powershell +azure-monitor-logs-ingestion run ` + --contract "\ingestion-contract.yaml" ` + --workspace "" ` + --payload "\mock.json" ` + --report "" +``` + +The connected agent must execute generation, provisioning, ingestion, +visibility checks, exact row parity, alert parity, evidence updates, and +cleanup as one user-facing operation. Do not hand intermediate commands or +path discovery back to the user. + +### Alert parity — qualification only + +Use `start-alert-parity-batch` with a plan covering every converted detection: + +1. Verify all ARs and CDs are disabled. +2. Enable all reviewed pairs. +3. Reuse the already ingested shared `mock.json` records; do not ingest a + second AR- or CD-specific payload. +4. Capture alerts for every pair. +5. Compare counts, match keys, severity, tactics, entities, decisive evidence, + and benign controls. +6. Complete or abort so every rule is disabled after success or failure. + +### Report + +Run `sentinel-xdr-migration solution-report`. The JSON and HTML reports must +distinguish passes, review requirements, environment blocks, skipped checks, +and genuine failures. + +## Safety and completion + +- Never use production telemetry or a production workspace for qualification. +- Ask before interactive authentication. +- Do not bypass failed or blocked gates. +- Do not rerun a passed release packaging stage and increment twice. +- Keep detections disabled except during controlled parity. +- Authoring completes after packaging and reporting pass. +- Qualification completes only after all optional lab stages and reporting + pass. diff --git a/Tools/SentinelToXDRMigration/kql/package-lock.json b/Tools/SentinelToXDRMigration/kql/package-lock.json new file mode 100644 index 00000000000..bdc8be63a84 --- /dev/null +++ b/Tools/SentinelToXDRMigration/kql/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "sentinel-xdr-kql", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sentinel-xdr-kql", + "version": "1.0.0", + "dependencies": { + "@kusto/language-service-next": "12.4.1" + } + }, + "node_modules/@kusto/language-service-next": { + "version": "12.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@kusto/language-service-next/-/language-service-next-12.4.1.tgz", + "integrity": "sha1-+RwdNtpJQqVy+40jaJ5JR+Ymau8=", + "license": "MIT" + } + } +} diff --git a/Tools/SentinelToXDRMigration/kql/package.json b/Tools/SentinelToXDRMigration/kql/package.json new file mode 100644 index 00000000000..1aa12c3b69f --- /dev/null +++ b/Tools/SentinelToXDRMigration/kql/package.json @@ -0,0 +1,12 @@ +{ + "name": "sentinel-xdr-kql", + "version": "1.0.0", + "private": true, + "description": "Scope-aware local parser binding normalization for Custom Detections", + "scripts": { + "test": "node --test *.test.cjs" + }, + "dependencies": { + "@kusto/language-service-next": "12.4.1" + } +} diff --git a/Tools/SentinelToXDRMigration/kql/rename-parser-bindings.cjs b/Tools/SentinelToXDRMigration/kql/rename-parser-bindings.cjs new file mode 100644 index 00000000000..f82d199f6a0 --- /dev/null +++ b/Tools/SentinelToXDRMigration/kql/rename-parser-bindings.cjs @@ -0,0 +1,156 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +let language; +function loadLanguage() { + if (!language) { + const root = path.dirname(require.resolve('@kusto/language-service-next/package.json')); + for (const file of ['bridge.js', 'Kusto.Language.Bridge.js']) { + vm.runInThisContext(fs.readFileSync(path.join(root, file), 'utf8'), { filename: file }); + } + language = Kusto.Language; + } + return language; +} + +function descendants(node, nodes = []) { + if (node) { + nodes.push(node); + for (let index = 0; index < node.ChildCount; index++) { + descendants(node.GetChild(index), nodes); + } + } + return nodes; +} + +function items(collection) { + const values = []; + const iterator = Bridge.getEnumerator(collection); + while (iterator.moveNext()) values.push(iterator.Current); + return values; +} + +function tableShapes(nodes, kusto) { + return nodes.filter(node => node.ResultType && Bridge.is(node.ResultType, kusto.Symbols.TableSymbol)) + .map(node => items(node.ResultType.Columns).map(column => [column.Name, column.Type.Name])); +} + +function comments(nodes) { + return nodes.filter(node => typeof node.Trivia === 'string') + .flatMap(node => node.Trivia.match(/\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g) || []); +} + +function renameParserBindings(query, reservedNames) { + if (typeof query !== 'string' || !Array.isArray(reservedNames) || + reservedNames.some(name => typeof name !== 'string' || !name)) { + throw new Error('Expected query text and an array of nonempty parser names.'); + } + if (reservedNames.length === 0) return { query, renames: [] }; + + const kusto = loadLanguage(); + const reserved = new Set(reservedNames); + const parsed = kusto.KustoCode.Parse(query); + const candidates = descendants(parsed.Syntax).filter(node => + Bridge.is(node, kusto.Syntax.LetStatement) && reserved.has(node.Name.SimpleName)); + if (candidates.length === 0) return { query, renames: [] }; + const syntaxErrors = items(parsed.GetSyntaxDiagnostics()); + if (syntaxErrors.length) { + throw new Error(`Cannot rename parser bindings in invalid KQL: ${syntaxErrors[0].Message}`); + } + + const code = kusto.KustoCode.ParseAndAnalyze(query); + const nodes = descendants(code.Syntax); + const names = nodes.filter(node => + Bridge.is(node, kusto.Syntax.NameDeclaration) || Bridge.is(node, kusto.Syntax.NameReference)); + const occupied = new Set([...reserved, ...names.map(node => node.SimpleName)]); + const replacements = new Map(); + const renames = []; + for (const node of nodes) { + if (!Bridge.is(node, kusto.Syntax.LetStatement) || !reserved.has(node.Name.SimpleName)) continue; + const symbol = node.Name.ReferencedSymbol; + if (!symbol) throw new Error(`Cannot resolve local parser binding '${node.Name.SimpleName}'.`); + const base = `__xdr_inline_${node.Name.SimpleName.replace(/[^A-Za-z0-9_]/g, '_')}`; + let target = base; + for (let suffix = 2; occupied.has(target); suffix++) target = `${base}_${suffix}`; + occupied.add(target); + replacements.set(symbol, target); + renames.push({ from: node.Name.SimpleName, to: target }); + } + + for (const node of nodes) { + if (Bridge.is(node, kusto.Syntax.WildcardedName)) { + const pattern = new RegExp('^' + node.SimpleName.split('*') + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$'); + if (renames.some(rename => pattern.test(rename.from) || pattern.test(rename.to))) { + throw new Error('Cannot safely rename a parser binding selected by a wildcard; use explicit inputs.'); + } + } + const symbol = node.ReferencedSymbol; + if (symbol && Bridge.is(symbol, kusto.Symbols.GroupSymbol) && + items(symbol.Members).some(member => replacements.has(member))) { + throw new Error('Cannot safely rename a parser binding selected by a wildcard; use explicit inputs.'); + } + if (Bridge.is(node, kusto.Syntax.NamedParameter) && + ['withsource', 'with_source'].includes(node.Name.SimpleName)) { + throw new Error('Cannot safely rename parser bindings with union source labels; use explicit source labels.'); + } + } + + const edits = names.filter(node => replacements.has(node.ReferencedSymbol)) + .map(node => ({ + start: node.TextStart, + length: node.Width, + text: replacements.get(node.ReferencedSymbol), + declaration: Bridge.is(node, kusto.Syntax.NameDeclaration) + })) + .sort((left, right) => left.start - right.start); + let rewritten = ''; + let offset = 0; + for (const edit of edits) { + rewritten += query.slice(offset, edit.start) + edit.text; + offset = edit.start + edit.length; + } + rewritten += query.slice(offset); + + const checked = kusto.KustoCode.ParseAndAnalyze(rewritten); + if (items(checked.GetSyntaxDiagnostics()).length) { + throw new Error('Parser binding rewrite produced invalid KQL.'); + } + const checkedNodes = descendants(checked.Syntax); + if (JSON.stringify(comments(nodes)) !== JSON.stringify(comments(checkedNodes))) { + throw new Error('Parser binding rename would remove an embedded comment; move it outside the identifier.'); + } + if (JSON.stringify(tableShapes(nodes, kusto)) !== JSON.stringify(tableShapes(checkedNodes, kusto))) { + throw new Error('Parser binding rename changes inferred output columns; add explicit column aliases.'); + } + const checkedNames = new Map(checkedNodes.filter(node => + Bridge.is(node, kusto.Syntax.NameDeclaration) || Bridge.is(node, kusto.Syntax.NameReference)) + .map(node => [node.TextStart, node])); + const declarations = new Map(); + let delta = 0; + for (const edit of edits) { + const node = checkedNames.get(edit.start + delta); + if (!node || node.SimpleName !== edit.text || !node.ReferencedSymbol) { + throw new Error(`Could not verify renamed binding '${edit.text}'.`); + } + if (edit.declaration) declarations.set(edit.text, node.ReferencedSymbol); + else if (node.ReferencedSymbol !== declarations.get(edit.text)) { + throw new Error(`Renaming '${edit.text}' changed its binding scope.`); + } + delta += edit.text.length - edit.length; + } + return { query: rewritten, renames }; +} + +module.exports = { renameParserBindings }; + +if (require.main === module) { + try { + const input = JSON.parse(fs.readFileSync(0, 'utf8').replace(/^\uFEFF/, '')); + process.stdout.write(JSON.stringify(renameParserBindings(input.query, input.reservedNames))); + } catch (error) { + process.stderr.write(`Parser binding normalization failed: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/Tools/SentinelToXDRMigration/kql/rename-parser-bindings.test.cjs b/Tools/SentinelToXDRMigration/kql/rename-parser-bindings.test.cjs new file mode 100644 index 00000000000..e3ca4e5c662 --- /dev/null +++ b/Tools/SentinelToXDRMigration/kql/rename-parser-bindings.test.cjs @@ -0,0 +1,109 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const crypto = require('node:crypto'); +const fixtures = require('../tests/fixtures/parser_collisions.json'); +const { renameParserBindings: rename } = require('./rename-parser-bindings.cjs'); + +test('renames a tabular binding, not a same-named column or literal', () => { + const query = "let Parser = datatable(Parser:string)['Parser']; Parser | project Parser"; + assert.equal(rename(query, ['Parser']).query, + "let __xdr_inline_Parser = datatable(Parser:string)['Parser']; __xdr_inline_Parser | project Parser"); +}); + +test('preserves comments, escaped strings, verbatim strings, and Unicode offsets', () => { + const query = "// \u{1f512} Parser\nlet Parser = datatable(Value:string)['Parser', @'a\\Parser', 'can\\'t Parser'];\nParser // Parser"; + assert.equal(rename(query, ['Parser']).query, query + .replace('let Parser', 'let __xdr_inline_Parser') + .replace('\nParser //', '\n__xdr_inline_Parser //')); +}); + +test('a parameter shadows the outer binding and keeps its own references', () => { + const query = "let Parser = datatable(x:string)['x']; let f = (Parser:string) { print Value=Parser }; Parser"; + assert.equal(rename(query, ['Parser']).query, + "let __xdr_inline_Parser = datatable(x:string)['x']; let f = (Parser:string) { print Value=Parser }; __xdr_inline_Parser"); +}); + +test('nested let bindings get distinct names with references in the right scopes', () => { + const query = "let Parser = datatable(x:string)['outer']; let f = () { let Parser = datatable(x:string)['inner']; Parser }; union Parser, f()"; + assert.equal(rename(query, ['Parser']).query, + "let __xdr_inline_Parser = datatable(x:string)['outer']; let f = () { let __xdr_inline_Parser_2 = datatable(x:string)['inner']; __xdr_inline_Parser_2 }; union __xdr_inline_Parser, f()"); +}); + +test('renames parser functions and their calls', () => { + const query = "let Parser = () { datatable(x:long)[1] }; Parser() | where x > 0"; + assert.equal(rename(query, ['Parser']).query, + "let __xdr_inline_Parser = () { datatable(x:long)[1] }; __xdr_inline_Parser() | where x > 0"); +}); + +test('supports arbitrary parser names and dependencies between bindings', () => { + const query = 'let VendorA = datatable(x:long)[1]; let VendorB = VendorA; union VendorA, VendorB'; + assert.equal(rename(query, ['VendorA', 'VendorB']).query, + 'let __xdr_inline_VendorA = datatable(x:long)[1]; let __xdr_inline_VendorB = __xdr_inline_VendorA; union __xdr_inline_VendorA, __xdr_inline_VendorB'); +}); + +test('refuses to discard comments embedded inside a quoted identifier', () => { + const query = "let ['Parser' // preserve\n] = datatable(x:long)[1]; ['Parser']"; + assert.throws(() => rename(query, ['Parser']), /embedded comment/); +}); + +test('handles bracketed identifiers without editing bracketed column names', () => { + const query = "let ['Parser'] = datatable(['Parser']:long)[1]; ['Parser'] | project ['Parser']"; + assert.equal(rename(query, ['Parser']).query, + "let __xdr_inline_Parser = datatable(['Parser']:long)[1]; __xdr_inline_Parser | project ['Parser']"); +}); + +test('avoids existing identifiers and reserved aliases; normalization is idempotent', () => { + const query = "let __xdr_inline_Parser=1; let Parser=datatable(x:long)[1]; Parser"; + const result = rename(query, ['Parser', '__xdr_inline_Parser_2']); + assert.equal(result.renames[0].to, '__xdr_inline_Parser_3'); + assert.deepEqual(rename(result.query, ['Parser', '__xdr_inline_Parser_2']), + { query: result.query, renames: [] }); +}); + +test('renames only declared local symbols, not a saved parser call in the initializer', () => { + const query = "let Parser = Parser(); Parser"; + assert.equal(rename(query, ['Parser']).query, + "let __xdr_inline_Parser = Parser(); __xdr_inline_Parser"); +}); + +test('does not rewrite unrelated bindings or an external parser reference', () => { + for (const query of ['Parser() | take 1', 'let Other=1; print Value=Other', "// let Parser=1;\nprint Value='Parser'"]) { + assert.deepEqual(rename(query, ['Parser']), { query, renames: [] }); + } +}); + +test('supports scalar local references when the output alias is explicit', () => { + assert.equal(rename('let Parser=1; print Value=Parser', ['Parser']).query, + 'let __xdr_inline_Parser=1; print Value=__xdr_inline_Parser'); +}); + +test('refuses an implicit output-column change', () => { + assert.throws(() => rename('let Parser=1; datatable(x:long)[1] | project Parser', ['Parser']), /output columns/); +}); + +test('refuses invalid syntax in a query needing a rename', () => { + assert.throws(() => rename('let Parser = ; Parser', ['Parser']), /invalid KQL/); +}); + +test('refuses wildcard selection that would lose the renamed binding', () => { + assert.throws(() => rename('let Parser=datatable(x:long)[1]; union Parser*', ['Parser']), /wildcard/); +}); + +test('refuses withsource because renaming would change source-label values', () => { + assert.throws(() => rename('let Parser=datatable(x:long)[1]; union withsource=Source Parser', ['Parser']), /source labels/); +}); + +test('refuses indirect table lookup when its inferred binding would change', () => { + assert.throws(() => rename("let Parser=UnknownTable; table('Parser')", ['Parser']), /output columns/); +}); + +for (const fixture of fixtures) { + test(`original PR query: ${fixture.name}`, () => { + assert.equal(crypto.createHash('sha256').update(fixture.query).digest('hex'), fixture.sourceSha256); + assert.equal(fixture.query.split(fixture.parser).length - 1, 2); + const result = rename(fixture.query, [fixture.parser]); + assert.equal(result.query, fixture.query.replaceAll(fixture.parser, `__xdr_inline_${fixture.parser}`)); + assert.equal(result.renames.length, 1); + assert.deepEqual(rename(result.query, [fixture.parser]), { query: result.query, renames: [] }); + }); +} diff --git a/Tools/SentinelToXDRMigration/pyproject.toml b/Tools/SentinelToXDRMigration/pyproject.toml new file mode 100644 index 00000000000..9f867b88105 --- /dev/null +++ b/Tools/SentinelToXDRMigration/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "sentinel-xdr-migration" +version = "0.1.0" +description = "Convert Microsoft Sentinel analytic rules into Defender XDR Custom Detection YAML." +requires-python = ">=3.11,<3.13" +classifiers = [ + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" +] +dependencies = [ + "PyYAML>=6.0,<7", + "azure-identity>=1.17,<2", + "azure-monitor-query>=1.3,<2", + "jsonschema>=4.0,<5" +] + +[project.scripts] +sentinel-xdr-migrate = "sentinel_xdr_migration.cli:main" +sentinel-xdr-migration = "sentinel_xdr_migration.cli:main" + +[tool.setuptools.packages.find] +include = ["sentinel_xdr_migration*"] diff --git a/Tools/SentinelToXDRMigration/requirements.txt b/Tools/SentinelToXDRMigration/requirements.txt new file mode 100644 index 00000000000..315d312c977 --- /dev/null +++ b/Tools/SentinelToXDRMigration/requirements.txt @@ -0,0 +1,4 @@ +PyYAML>=6.0,<7 +azure-identity>=1.17,<2 +azure-monitor-query>=1.3,<2 +jsonschema>=4.0,<5 diff --git a/Tools/SentinelToXDRMigration/schema/authoring-contract.schema.json b/Tools/SentinelToXDRMigration/schema/authoring-contract.schema.json new file mode 100644 index 00000000000..9d5df334032 --- /dev/null +++ b/Tools/SentinelToXDRMigration/schema/authoring-contract.schema.json @@ -0,0 +1,473 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Sentinel to XDR offline authoring contract", + "description": "Versioned, offline record of source inventory, rule findings, target artifacts, evidence, and advisory coverage/operation-eligibility records.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "runId", + "source", + "inventory", + "results", + "environmentErrors", + "entityValueParityPolicy", + "coverage", + "operationEligibility" + ], + "allOf": [ + { + "if": { + "properties": { + "schemaVersion": { + "const": "1.1.0" + } + } + }, + "then": { + "required": ["snapshot"], + "properties": { + "inventory": { + "required": ["sha256"], + "properties": { + "rules": { + "items": { + "required": ["path", "querySha256"] + } + } + } + }, + "results": { + "items": { + "required": ["conversionStatus", "validationStatus"] + } + } + } + } + } + ], + "properties": { + "schemaVersion": { + "enum": ["1.0.0", "1.1.0"] + }, + "snapshot": { + "type": "object", + "additionalProperties": false, + "required": ["revision", "previousSha256"], + "properties": { + "revision": { + "type": "integer", + "minimum": 1 + }, + "previousSha256": { + "oneOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + } + } + }, + "runId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$" + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "sha256"], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "inventory": { + "type": "object", + "additionalProperties": false, + "required": ["complete", "rules"], + "properties": { + "sha256": { + "$ref": "#/$defs/sha256" + }, + "complete": { + "const": true + }, + "rules": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/inventoryRule" + } + } + } + }, + "results": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/ruleResult" + } + }, + "environmentErrors": { + "type": "array", + "items": { + "$ref": "#/$defs/environmentError" + } + }, + "entityValueParityPolicy": { + "const": { + "assertion": "value-preserved-in-actual-corresponding-alert", + "comparison": "value-only", + "entityTypeIdentifierAndCustomFieldMayDiffer": true, + "requiredForQualification": true + } + }, + "coverage": { + "$ref": "#/$defs/coverage" + }, + "operationEligibility": { + "$ref": "#/$defs/operationEligibility" + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "evidenceReference": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "kind"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "kind": { + "enum": [ + "source", + "generatedTarget", + "runtimeVerification", + "alertParity", + "other" + ] + } + } + }, + "inventoryRule": { + "type": "object", + "additionalProperties": false, + "required": ["ruleId", "sha256", "evidenceReferences"], + "properties": { + "ruleId": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "querySha256": { + "$ref": "#/$defs/sha256" + }, + "evidenceReferences": { + "type": "array", + "items": { + "$ref": "#/$defs/evidenceReference" + } + } + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": ["findingId", "kind", "message", "unresolved", "evidenceReferences"], + "properties": { + "findingId": { + "type": "string", + "minLength": 1 + }, + "kind": { + "enum": ["error", "warning", "assumption"] + }, + "message": { + "type": "string", + "minLength": 1 + }, + "unresolved": { + "type": "boolean" + }, + "evidenceReferences": { + "type": "array", + "items": { + "$ref": "#/$defs/evidenceReference" + } + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "structurallyValid"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "querySha256": { + "$ref": "#/$defs/sha256" + }, + "structurallyValid": { + "type": "boolean" + } + } + }, + "ruleResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "ruleId", + "sourceSha256", + "status", + "deploymentDisposition", + "target", + "reviewRequired", + "mappingAssumptions", + "findings", + "qualificationStatus", + "qualificationEvidenceReferences", + "evidenceReferences" + ], + "properties": { + "ruleId": { + "type": "string", + "minLength": 1 + }, + "sourceSha256": { + "$ref": "#/$defs/sha256" + }, + "status": { + "enum": ["migrated", "blocked", "suppressed", "unprocessed"] + }, + "conversionStatus": { + "enum": ["converted", "needsReview", "conflict", "excluded", "unprocessed"] + }, + "validationStatus": { + "enum": ["passed", "failed", "notRun"] + }, + "deploymentDisposition": { + "enum": ["normal", "deploymentBlocked", "suppressed", "authoringBlocked"] + }, + "target": { + "oneOf": [ + { + "$ref": "#/$defs/target" + }, + { + "type": "null" + } + ] + }, + "reviewRequired": { + "type": "boolean" + }, + "mappingAssumptions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/$defs/finding" + } + }, + "qualificationStatus": { + "enum": ["untested", "qualified", "failed"] + }, + "qualificationEvidenceReferences": { + "type": "array", + "items": { + "$ref": "#/$defs/evidenceReference" + } + }, + "evidenceReferences": { + "type": "array", + "items": { + "$ref": "#/$defs/evidenceReference" + } + } + } + }, + "environmentError": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "evidenceReferences"], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "evidenceReferences": { + "type": "array", + "items": { + "$ref": "#/$defs/evidenceReference" + } + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "runId", + "sourceIdentity", + "sourceSha256", + "N", + "M", + "E", + "qualified", + "untested", + "qualificationFailed", + "migrationThresholdMet", + "stopThresholdReached" + ], + "properties": { + "schemaVersion": { + "enum": ["1.0.0", "1.1.0"] + }, + "runId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$" + }, + "sourceIdentity": { + "type": "string", + "minLength": 1 + }, + "sourceSha256": { + "$ref": "#/$defs/sha256" + }, + "N": { + "type": "integer", + "minimum": 1 + }, + "M": { + "type": "integer", + "minimum": 0 + }, + "E": { + "type": "integer", + "minimum": 0 + }, + "qualified": { + "type": "integer", + "minimum": 0 + }, + "untested": { + "type": "integer", + "minimum": 0 + }, + "qualificationFailed": { + "type": "integer", + "minimum": 0 + }, + "migrationThresholdMet": { + "type": "boolean" + }, + "stopThresholdReached": { + "type": "boolean" + } + } + }, + "operationEligibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "runId", + "sourceIdentity", + "sourceSha256", + "scope", + "migrationThresholdMet", + "successAndPrThresholdMet", + "stopThresholdReached", + "requiresExactScopeAuthorization", + "coverageAuthorizesOperations", + "deploymentAuthorized", + "packagingAuthorized", + "ingestionAuthorized", + "prCreationAuthorized" + ], + "properties": { + "schemaVersion": { + "enum": ["1.0.0", "1.1.0"] + }, + "runId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$" + }, + "sourceIdentity": { + "type": "string", + "minLength": 1 + }, + "sourceSha256": { + "$ref": "#/$defs/sha256" + }, + "scope": { + "const": "advisory-only" + }, + "migrationThresholdMet": { + "type": "boolean" + }, + "successAndPrThresholdMet": { + "type": "boolean" + }, + "stopThresholdReached": { + "type": "boolean" + }, + "requiresExactScopeAuthorization": { + "const": true + }, + "coverageAuthorizesOperations": { + "const": false + }, + "deploymentAuthorized": { + "const": false + }, + "packagingAuthorized": { + "const": false + }, + "ingestionAuthorized": { + "const": false + }, + "prCreationAuthorized": { + "const": false + } + } + } + } +} diff --git a/Tools/SentinelToXDRMigration/schema/workflow-state.schema.json b/Tools/SentinelToXDRMigration/schema/workflow-state.schema.json new file mode 100644 index 00000000000..d509b01c971 --- /dev/null +++ b/Tools/SentinelToXDRMigration/schema/workflow-state.schema.json @@ -0,0 +1,760 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Sentinel to XDR workflow state", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "solution", + "workflowStatus", + "createdAt", + "updatedAt", + "context", + "stages" + ], + "properties": { + "runId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$", + "description": "Stable run directory identity. Added when initializing or explicitly migrating legacy state; resumption must keep this value." + }, + "schemaVersion": { + "const": "1.2.0" + }, + "solution": { + "type": "string", + "description": "Solution-relative location (normally '.'); legacy absolute locations remain readable. Artifact references resolve against the selected solution, not the shell cwd.", + "minLength": 1 + }, + "workflowStatus": { + "enum": [ + "inProgress", + "blocked", + "failed", + "completed", + "partialCompleted" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspaceResourceId", + "versionBump", + "workflowProfile" + ], + "properties": { + "workspaceResourceId": { + "type": [ + "string", + "null" + ] + }, + "tenantId": { + "type": [ + "string", + "null" + ] + }, + "subscriptionId": { + "type": [ + "string", + "null" + ] + }, + "workspaceCustomerId": { + "type": [ + "string", + "null" + ] + }, + "targetContextLocked": { + "type": "boolean" + }, + "versionBump": { + "enum": [ + "patch", + "minor", + "major", + "none", + null + ] + }, + "targetVersion": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "workflowProfile": { + "enum": [ + "authoring", + "qualification" + ] + }, + "profileSelectionConfirmed": { + "type": "boolean" + } + } + }, + "stages": { + "type": "object", + "additionalProperties": false, + "required": [ + "discovery", + "conversion", + "validation", + "packaging", + "deployment", + "mockIngestion", + "alertParity", + "report" + ], + "properties": { + "discovery": { + "$ref": "#/$defs/stage" + }, + "conversion": { + "$ref": "#/$defs/stage" + }, + "validation": { + "$ref": "#/$defs/stage" + }, + "packaging": { + "$ref": "#/$defs/stage" + }, + "deployment": { + "$ref": "#/$defs/stage" + }, + "mockIngestion": { + "$ref": "#/$defs/stage" + }, + "alertParity": { + "$ref": "#/$defs/stage" + }, + "report": { + "$ref": "#/$defs/stage" + } + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "campaignId", + "inventorySha256", + "status", + "stopReason" + ], + "properties": { + "schemaVersion": { + "const": "1.0.0" + }, + "campaignId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" + }, + "inventorySha256": { + "$ref": "#/$defs/sha256" + }, + "status": { + "enum": ["active", "stopped"] + }, + "stopReason": { + "type": ["string", "null"] + } + } + }, + "authoring": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "mode", + "status", + "inventorySha256", + "currentContract", + "stopReason" + ], + "properties": { + "schemaVersion": { + "const": "1.0.0" + }, + "mode": { + "const": "yolo" + }, + "status": { + "enum": ["active", "stopped", "conflicted"] + }, + "inventorySha256": { + "$ref": "#/$defs/sha256" + }, + "currentContract": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "revision"], + "properties": { + "path": { + "type": "string", + "pattern": "^authoring-contracts/[0-9]{6}-[a-f0-9]{64}\\.json$" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "revision": { + "type": "integer", + "minimum": 1 + } + } + }, + "stopReason": { + "type": ["string", "null"] + } + } + }, + "packaging": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "lastSuccessful", + "lastConsumedAction", + "currentAttempt", + "releaseActions", + "pendingReleaseAction" + ], + "properties": { + "schemaVersion": { + "const": "1.0.0" + }, + "lastSuccessful": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/packageAction" + } + ] + }, + "lastConsumedAction": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/packageAction" + } + ] + }, + "currentAttempt": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "attemptId", + "attempt", + "status", + "requestedVersionBump", + "effectiveVersionBump", + "bumpConsumed", + "packageInputSha256", + "packageReportSha256", + "startedAt" + ], + "properties": { + "attemptId": { + "type": "string", + "format": "uuid" + }, + "attempt": { + "type": "integer", + "minimum": 1 + }, + "status": { + "enum": ["running", "passed", "failed", "blocked"] + }, + "requestedVersionBump": { + "enum": ["none", "patch", "minor", "major"] + }, + "effectiveVersionBump": { + "enum": ["none", "patch", "minor", "major"] + }, + "bumpConsumed": { + "type": "boolean" + }, + "requestedTargetVersion": { + "type": ["string", "null"], + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "effectiveTargetVersion": { + "type": ["string", "null"], + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "targetConsumed": { + "type": "boolean" + }, + "packageInputSha256": { + "$ref": "#/$defs/sha256" + }, + "packageReportSha256": { + "type": ["string", "null"], + "pattern": "^[a-f0-9]{64}$" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "failure": { + "type": "string" + } + } + } + ] + }, + "releaseActions": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "versionBump", + "packageInputSha256", + "selectedAt", + "status" + ], + "properties": { + "versionBump": {"enum": ["patch", "minor", "major"]}, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "selectedAt": {"type": "string", "format": "date-time"}, + "status": {"enum": ["selected", "consumed"]}, + "historicalEvidence": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "actionType", + "versionBump", + "basePackageInputSha256", + "packageInputSha256", + "releaseNotesPath", + "releaseNotesOriginalSha256", + "releaseNotesExpectedSha256", + "releaseNotesDate", + "releaseNotesVersion", + "releaseNotesChangeHistory", + "packageInputFilesBefore", + "selectedAt", + "status", + "historicalEvidence" + ], + "properties": { + "actionType": {"const": "release-notes-refresh"}, + "versionBump": {"const": "none"}, + "basePackageInputSha256": {"$ref": "#/$defs/sha256"}, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "releaseNotesPath": {"type": "string", "minLength": 1}, + "releaseNotesOriginalSha256": {"$ref": "#/$defs/sha256"}, + "releaseNotesExpectedSha256": {"$ref": "#/$defs/sha256"}, + "releaseNotesDate": {"type": "string", "pattern": "^\\d{2}-\\d{2}-\\d{4}$"}, + "releaseNotesVersion": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"}, + "releaseNotesChangeHistory": {"type": "string", "minLength": 1}, + "packageInputFilesBefore": {"$ref": "#/$defs/packageInputFiles"}, + "selectedAt": {"type": "string", "format": "date-time"}, + "status": {"enum": ["selected", "consumed"]}, + "historicalEvidence": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "actionType", + "versionBump", + "runId", + "targetVersion", + "basePackageInputSha256", + "packageInputSha256", + "packageInputFilesBefore", + "approvedChangedInputs", + "priorPackageAttemptId", + "priorPackageReportSha256", + "inputBackupRoot", + "backupId", + "selectedAt", + "historicalEvidence", + "status" + ], + "properties": { + "actionType": {"const": "same-version-content-rebuild"}, + "versionBump": {"const": "none"}, + "runId": {"type": "string", "minLength": 1}, + "targetVersion": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "basePackageInputSha256": {"$ref": "#/$defs/sha256"}, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "packageInputFilesBefore": {"$ref": "#/$defs/packageInputFiles"}, + "approvedChangedInputs": {"$ref": "#/$defs/packageInputChanges"}, + "priorPackageAttemptId": {"type": "string", "format": "uuid"}, + "priorPackageReportSha256": {"$ref": "#/$defs/sha256"}, + "inputBackupRoot": {"type": "string", "minLength": 1}, + "backupId": {"type": "string", "format": "uuid"}, + "selectedAt": {"type": "string", "format": "date-time"}, + "historicalEvidence": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "legacyBaselineEvidence": { + "$ref": "#/$defs/legacyBaselineEvidence" + }, + "status": {"enum": ["selected", "consumed"]} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "actionType", + "versionBump", + "targetVersion", + "packageInputSha256", + "selectedAt", + "status" + ], + "properties": { + "actionType": {"const": "target-version"}, + "versionBump": {"const": "none"}, + "targetVersion": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "selectedAt": {"type": "string", "format": "date-time"}, + "status": {"enum": ["selected", "consumed"]}, + "historicalEvidence": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + ] + } + }, + "pendingReleaseAction": { + "anyOf": [ + {"type": "null"}, + { + "type": "object", + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["versionBump", "packageInputSha256", "selectedAt"], + "properties": { + "versionBump": {"enum": ["patch", "minor", "major"]}, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "selectedAt": {"type": "string", "format": "date-time"} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "actionType", + "versionBump", + "targetVersion", + "packageInputSha256", + "selectedAt" + ], + "properties": { + "actionType": {"const": "target-version"}, + "versionBump": {"const": "none"}, + "targetVersion": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "selectedAt": {"type": "string", "format": "date-time"} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "actionType", + "versionBump", + "basePackageInputSha256", + "packageInputSha256", + "releaseNotesPath", + "releaseNotesOriginalSha256", + "releaseNotesExpectedSha256", + "releaseNotesDate", + "releaseNotesVersion", + "releaseNotesChangeHistory", + "packageInputFilesBefore", + "selectedAt" + ], + "properties": { + "actionType": {"const": "release-notes-refresh"}, + "versionBump": {"const": "none"}, + "basePackageInputSha256": {"$ref": "#/$defs/sha256"}, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "releaseNotesPath": {"type": "string", "minLength": 1}, + "releaseNotesOriginalSha256": {"$ref": "#/$defs/sha256"}, + "releaseNotesExpectedSha256": {"$ref": "#/$defs/sha256"}, + "releaseNotesDate": {"type": "string", "pattern": "^\\d{2}-\\d{2}-\\d{4}$"}, + "releaseNotesVersion": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"}, + "releaseNotesChangeHistory": {"type": "string", "minLength": 1}, + "packageInputFilesBefore": {"$ref": "#/$defs/packageInputFiles"}, + "selectedAt": {"type": "string", "format": "date-time"} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "actionType", + "versionBump", + "runId", + "targetVersion", + "basePackageInputSha256", + "packageInputSha256", + "packageInputFilesBefore", + "approvedChangedInputs", + "priorPackageAttemptId", + "priorPackageReportSha256", + "inputBackupRoot", + "backupId", + "selectedAt", + "historicalEvidence" + ], + "properties": { + "actionType": {"const": "same-version-content-rebuild"}, + "versionBump": {"const": "none"}, + "runId": {"type": "string", "minLength": 1}, + "targetVersion": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "basePackageInputSha256": {"$ref": "#/$defs/sha256"}, + "packageInputSha256": {"$ref": "#/$defs/sha256"}, + "packageInputFilesBefore": {"$ref": "#/$defs/packageInputFiles"}, + "approvedChangedInputs": {"$ref": "#/$defs/packageInputChanges"}, + "priorPackageAttemptId": {"type": "string", "format": "uuid"}, + "priorPackageReportSha256": {"$ref": "#/$defs/sha256"}, + "inputBackupRoot": {"type": "string", "minLength": 1}, + "backupId": {"type": "string", "format": "uuid"}, + "selectedAt": {"type": "string", "format": "date-time"}, + "historicalEvidence": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "legacyBaselineEvidence": { + "$ref": "#/$defs/legacyBaselineEvidence" + } + } + } + ] + } + ] + } + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "packageInputFiles": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/sha256" + } + }, + "packageInputChanges": { + "type": "object", + "additionalProperties": { + "anyOf": [ + {"$ref": "#/$defs/sha256"}, + {"type": "null"} + ] + } + }, + "legacyBaselineEvidence": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "sha256": {"$ref": "#/$defs/sha256"} + } + }, + "packageAction": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestedVersionBump", + "effectiveVersionBump", + "bumpConsumed", + "packageInputSha256", + "attemptId", + "packageSucceeded" + ], + "properties": { + "requestedVersionBump": { + "enum": ["none", "patch", "minor", "major"] + }, + "effectiveVersionBump": { + "enum": ["none", "patch", "minor", "major"] + }, + "bumpConsumed": { + "type": "boolean" + }, + "packageInputSha256": { + "$ref": "#/$defs/sha256" + }, + "attemptId": { + "type": "string", + "format": "uuid" + }, + "packageSucceeded": { + "type": "boolean" + }, + "packageReport": { + "type": "string", + "minLength": 1 + }, + "packageReportSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "packageArtifactsSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "requestedTargetVersion": { + "type": ["string", "null"], + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "effectiveTargetVersion": { + "type": ["string", "null"], + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + }, + "targetConsumed": { + "type": "boolean" + } + } + }, + "stage": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "status", + "attempts", + "startedAt", + "completedAt", + "message", + "artifacts", + "evidence" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "pending", + "running", + "passed", + "failed", + "blocked", + "notRequired" + ] + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "artifacts": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/Tools/SentinelToXDRMigration/schema/xdr-detection.schema.json b/Tools/SentinelToXDRMigration/schema/xdr-detection.schema.json new file mode 100644 index 00000000000..0c1fa21313b --- /dev/null +++ b/Tools/SentinelToXDRMigration/schema/xdr-detection.schema.json @@ -0,0 +1,228 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/Azure-Sentinel/Tools/SentinelToXDRMigration/schema/xdr-detection.schema.json", + "title": "Defender XDR Custom Detection source", + "$defs": { + "sentinelTemplateId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$" + }, + "sentinelDetectionId": { + "type": "string", + "minLength": 40, + "maxLength": 40, + "pattern": "^xdr-[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$" + } + }, + "allOf": [ + { + "if": { + "required": ["contentProvenance"], + "properties": { + "contentProvenance": { + "anyOf": [ + { + "required": ["source"], + "properties": { + "source": { + "required": ["platform", "kind"], + "properties": { + "platform": {"const": "Microsoft Sentinel"}, + "kind": {"const": "AnalyticsRule"} + } + } + } + }, + { + "required": ["conversion"], + "properties": { + "conversion": { + "required": ["tool"], + "properties": { + "tool": {"const": "sentinel-to-xdr-migration"} + } + } + } + } + ] + } + } + }, + "then": { + "properties": { + "contentProvenance": { + "properties": { + "source": { + "properties": { + "id": {"$ref": "#/$defs/sentinelTemplateId"} + } + } + } + }, + "properties": { + "properties": { + "id": { + "$ref": "#/$defs/sentinelDetectionId", + "description": "Graph-compatible xdr- prefix followed by the exact originating Sentinel template GUID in contentProvenance.source.id. Cross-field equality is enforced by structural validation and packaging." + } + } + } + } + } + } + ], + "type": "object", + "required": [ + "schemaVersion", + "version", + "kind", + "resourceType", + "apiVersion", + "contentProvenance", + "properties" + ], + "properties": { + "schemaVersion": { + "const": "1.0.0" + }, + "version": { + "description": "Independent XDR content release version (>= 3.1.0 and < 4.0.0), not the source Sentinel rule or schema version.", + "type": "string", + "pattern": "^3\\.[1-9][0-9]*\\.(0|[1-9][0-9]*)$" + }, + "kind": { + "const": "CustomDetection" + }, + "resourceType": { + "const": "Microsoft.Security/detectionRules" + }, + "apiVersion": { + "type": "string", + "minLength": 1 + }, + "requiredDataConnectors": { + "description": "Source authoring dependencies preserved unchanged, not Graph or ARM detection-rule properties. Omitted when absent in the source; an explicit empty array is preserved.", + "type": "array", + "items": { + "type": "object", + "required": ["connectorId", "dataTypes"], + "properties": { + "connectorId": {"type": "string"}, + "dataTypes": { + "type": "array", + "items": {"type": "string"} + } + }, + "additionalProperties": true + } + }, + "contentProvenance": { + "type": "object", + "required": [ + "source", + "conversion" + ], + "properties": { + "source": { + "type": "object", + "required": [ + "platform", + "kind", + "id", + "path", + "querySha256", + "schedule" + ] + }, + "conversion": { + "type": "object", + "properties": { + "identityChange": { + "description": "Persisted local-only legacy ID transition; not evidence of deployed rule migration.", + "type": "object", + "required": ["previousId", "currentId", "scope"], + "properties": { + "previousId": {"type": "string"}, + "currentId": {"$ref": "#/$defs/sentinelDetectionId"}, + "scope": {"const": "local-artifact-only"} + } + }, + "informational": { + "description": "Nonblocking explanations of proven preserved equivalents; never review reasons or errors.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tool", + "version", + "status", + "reviewRequired", + "reviewReasons", + "requiredWorkloads", + "warnings" + ] + } + } + }, + "properties": { + "type": "object", + "properties": { + "detectionAction": { + "type": "object", + "properties": { + "alertTemplate": { + "type": "object", + "properties": { + "customDetails": { + "description": "Open alert detail key-to-query-output-column bindings. At most 20 pairs; runtime values together must fit the documented 4 KB per-alert limit.", + "type": "object", + "maxProperties": 20, + "propertyNames": {"type": "string", "minLength": 1}, + "additionalProperties": {"type": "string", "minLength": 1} + }, + "tactics": { + "description": "Authoring retains all source tactics in order. V3.1 ARM packaging emits only the first entry. Direct Graph deployment requires at most one entry.", + "type": "array", + "items": { + "type": "object", + "required": ["tactic"], + "properties": { + "tactic": {"type": "string", "minLength": 1}, + "techniques": { + "type": "array", + "items": { + "type": "object", + "required": ["technique"], + "properties": { + "technique": {"type": "string", "pattern": "^T[0-9]{4}$"}, + "subTechniques": { + "type": "array", + "items": {"type": "string", "pattern": "^T[0-9]{4}\\.[0-9]{3}$"} + } + } + } + } + } + } + } + } + } + } + } + }, + "required": [ + "id", + "displayName", + "status", + "queryCondition", + "schedule", + "detectionAction" + ] + } + } +} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/__init__.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/__init__.py new file mode 100644 index 00000000000..e49b56bef0d --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/__init__.py @@ -0,0 +1,3 @@ +"""Microsoft Sentinel analytic rule to Defender XDR Custom Detection conversion.""" + +__version__ = "0.1.0" diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/alert_parity.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/alert_parity.py new file mode 100644 index 00000000000..1c47eb28854 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/alert_parity.py @@ -0,0 +1,896 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import sys +import urllib.parse +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from .artifacts import artifact_path, migrate_legacy_artifact, write_json_artifact +from .converter import validate_document, xdr_detection_files +from .content_paths import detection_reference +from .deployment import DEPLOYMENT_ENDPOINT, _deployment_token, _graph_request +from .arm_client import _arm_request, _arm_token, _sentinel_rule_url +from .target_context import require_locked_target + +STATE_FILE_NAME = "alert-parity-state.json" +REPORT_FILE_NAME = "alert-parity-report.json" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _error_message(response: dict[str, Any]) -> str: + error = response.get("error") if isinstance(response, dict) else None + if isinstance(error, dict): + return str(error.get("message") or error.get("code") or error) + return str(error or response) + + +def _load_pairs( + solution: str | Path, selected: list[str] | None = None +) -> tuple[Path, list[dict[str, Any]]]: + root = Path(solution).expanduser().resolve() + output = root / "XDR Detections" + files = xdr_detection_files(output) + available = [path.relative_to(output).as_posix() for path in files] + from .packaging_selection import current_applied_package_selection + + applied_selection = current_applied_package_selection(root) + allowed = None + if applied_selection is not None: + allowed = { + Path(str(value).replace("\\", "/")).relative_to("XDR Detections").as_posix() + for value in (applied_selection["plan"].get("selection") or {}).get( + "xdrDetections", [] + ) + } + if not allowed: + raise ValueError("current applied package selection contains no parity-ready detections") + wanted = {detection_reference(name, available) for name in selected or []} + if allowed is not None: + if wanted and not wanted.issubset(allowed): + raise ValueError("requested parity detections exceed the current applied package selection") + if not wanted: + wanted = allowed + pairs: list[dict[str, Any]] = [] + for path in files: + detection_name = path.relative_to(output).as_posix() + if wanted and detection_name not in wanted: + continue + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} + errors = validate_document(document) + conversion = (document.get("contentProvenance") or {}).get("conversion") or {} + if conversion.get("status") != "converted" or conversion.get("reviewRequired"): + errors.append("detection must be converted with no review required") + if errors: + raise ValueError(f"{detection_name}: {'; '.join(errors)}") + source = (document.get("contentProvenance") or {}).get("source") or {} + source_id = str(source.get("id") or "").strip() + detection_id = str((document.get("properties") or {}).get("id") or "").strip() + if not source_id or not detection_id: + raise ValueError(f"{detection_name}: source and detection IDs are required") + alert = ( + ((document.get("properties") or {}).get("detectionAction") or {}).get( + "alertTemplate" + ) + or {} + ) + pairs.append( + { + "detection": detection_name, + "displayName": str( + (document.get("properties") or {}).get("displayName") or path.stem + ), + "analyticRuleId": source_id, + "customDetectionId": detection_id, + "entityMappings": alert.get("entityMappings") or {}, + } + ) + if not pairs: + raise ValueError(f"no converted detections selected under {output}") + return root, pairs + + +def _qualification_scope( + root: Path, + pairs: list[dict[str, Any]], + applied_selection: dict[str, Any] | None, +) -> dict[str, Any]: + plan = applied_selection.get("plan") if isinstance(applied_selection, dict) else None + plan = plan if isinstance(plan, dict) else {} + selection = plan.get("selection") if isinstance(plan.get("selection"), dict) else {} + planned_paths = sorted( + Path(str(value).replace("\\", "/")).relative_to("XDR Detections").as_posix() + for value in selection.get("xdrDetections", []) + ) + selected_paths = sorted(str(pair["detection"]).replace("\\", "/") for pair in pairs) + detections = [] + for pair in pairs: + path = root / "XDR Detections" / str(pair["detection"]) + try: + content = path.read_bytes() + except OSError: + content = json.dumps( + pair, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + digest = hashlib.sha256(content).hexdigest() + detections.append({ + "path": str(pair["detection"]).replace("\\", "/"), + "sourceId": str(pair.get("analyticRuleId") or "").casefold(), + "detectionId": str(pair.get("customDetectionId") or "").casefold(), + "fileSha256": digest, + }) + detections.sort(key=lambda item: item["path"].casefold()) + summary = plan.get("summary") if isinstance(plan.get("summary"), dict) else {} + return { + "kind": "applied-package-selection" if plan else "current-detection-content", + "planSha256": plan.get("planSha256"), + "selectedDetectionPaths": selected_paths, + "selectedSourceIds": sorted({item["sourceId"] for item in detections}), + "detectionContentSha256": hashlib.sha256( + json.dumps(detections, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest(), + "selectionComplete": bool(plan) and selected_paths == planned_paths, + "coverage": summary or None, + } + + +def _verify_qualification_scope(root: Path, state: dict[str, Any]) -> dict[str, Any]: + stored = state.get("qualificationScope") + if not isinstance(stored, dict): + raise ValueError("alert parity state has no exact qualification-scope binding") + pairs = state.get("rules") + if not isinstance(pairs, list) or not pairs: + raise ValueError("alert parity state has no tested detection identities") + from .packaging_selection import current_applied_package_selection + + applied = current_applied_package_selection(root) + current = _qualification_scope(root, pairs, applied) + if stored != current: + raise ValueError("alert parity qualification scope no longer matches the current selection and detection content") + return current + + +def _contains_marker(value: Any, marker: str) -> bool: + if isinstance(value, dict): + return any(_contains_marker(item, marker) for item in value.values()) + if isinstance(value, list): + return any(_contains_marker(item, marker) for item in value) + return marker in str(value) + + +def _validate_marked_payload(payload_path: str | Path, marker: str) -> None: + payload = json.loads(Path(payload_path).expanduser().read_text(encoding="utf-8-sig")) + if not _contains_marker(payload, marker): + raise ValueError( + f"scenario marker {marker!r} does not occur in the ingestion payload" + ) + + +def _read_rule_states( + pairs: list[dict[str, Any]], + workspace_resource_id: str, + arm_token: str, + graph_token: str, +) -> None: + for pair in pairs: + sentinel_status, sentinel = _arm_request( + "GET", + _sentinel_rule_url(workspace_resource_id, pair["analyticRuleId"]), + arm_token, + ) + if sentinel_status != 200: + raise RuntimeError( + f"{pair['detection']}: cannot read Sentinel analytic rule: " + f"{_error_message(sentinel)}" + ) + graph_id = urllib.parse.quote(pair["customDetectionId"], safe="") + graph_status, graph = _graph_request( + "GET", f"{DEPLOYMENT_ENDPOINT}/{graph_id}", graph_token + ) + if graph_status != 200: + raise RuntimeError( + f"{pair['detection']}: cannot read Custom Detection: " + f"{_error_message(graph)}" + ) + sentinel_enabled = bool((sentinel.get("properties") or {}).get("enabled")) + graph_enabled = str(graph.get("status") or "").lower() == "enabled" + if sentinel_enabled or graph_enabled: + raise RuntimeError( + f"{pair['detection']}: parity validation requires both rules to " + "be disabled before start" + ) + pair["sentinelResource"] = sentinel + + +def _set_pair_enabled( + pair: dict[str, Any], + workspace_resource_id: str, + arm_token: str, + graph_token: str, + enabled: bool, +) -> list[str]: + errors: list[str] = [] + sentinel = dict(pair.get("sentinelResource") or {}) + source_properties = dict(sentinel.get("properties") or {}) + writable_properties = { + "displayName", + "description", + "severity", + "query", + "queryFrequency", + "queryPeriod", + "triggerOperator", + "triggerThreshold", + "suppressionDuration", + "suppressionEnabled", + "tactics", + "techniques", + "entityMappings", + "eventGroupingSettings", + "alertDetailsOverride", + "customDetails", + "incidentConfiguration", + "alertRuleTemplateName", + "templateVersion", + } + sentinel_properties = { + key: value + for key, value in source_properties.items() + if key in writable_properties + } + sentinel_properties["enabled"] = enabled + sentinel_body = { + "kind": sentinel.get("kind"), + "properties": sentinel_properties, + } + status, response = _arm_request( + "PUT", + _sentinel_rule_url(workspace_resource_id, pair["analyticRuleId"]), + arm_token, + sentinel_body, + ) + if status not in {200, 201}: + errors.append(f"Sentinel: {_error_message(response)}") + + graph_id = urllib.parse.quote(pair["customDetectionId"], safe="") + status, response = _graph_request( + "PATCH", + f"{DEPLOYMENT_ENDPOINT}/{graph_id}", + graph_token, + {"status": "enabled" if enabled else "disabled"}, + ) + if status != 200: + errors.append(f"Custom Detection: {_error_message(response)}") + return errors + + +def _disable_all( + state: dict[str, Any], + *, + arm_token: str | None = None, + graph_token: str | None = None, + state_dir: str | Path | None = None, +) -> list[dict[str, Any]]: + arm = arm_token or _arm_token(state_dir) + target = state.get("target") or {} + graph = graph_token or _deployment_token( + state_dir, + tenant_id=target.get("tenantId"), + ) + results: list[dict[str, Any]] = [] + for pair in state.get("rules") or []: + errors = _set_pair_enabled( + pair, state["workspaceResourceId"], arm, graph, False + ) + results.append( + { + "detection": pair["detection"], + "disabled": not errors, + "errors": errors, + } + ) + return results + + +def _ingest(contract: str | Path, payload: str | Path) -> dict[str, Any]: + executable = shutil.which("azure-monitor-logs-ingestion") + environment = None + if executable: + command = [ + executable, + "ingest", + "--contract", + str(Path(contract).expanduser()), + "--payload", + str(Path(payload).expanduser()), + ] + else: + ingestion_tool = Path(__file__).resolve().parents[2] / "AzureMonitorLogsIngestion" + environment = os.environ.copy() + current_path = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = ( + f"{ingestion_tool}{os.pathsep}{current_path}" + if current_path + else str(ingestion_tool) + ) + command = [ + sys.executable, + "-m", + "azure_monitor_logs_ingestion.cli", + "ingest", + "--contract", + str(Path(contract).expanduser()), + "--payload", + str(Path(payload).expanduser()), + ] + completed = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + env=environment, + ) + if completed.returncode: + detail = (completed.stderr or completed.stdout or "unknown error").strip() + raise RuntimeError(f"mock ingestion failed: {detail}") + try: + return json.loads(completed.stdout) + except json.JSONDecodeError: + return {"status": "accepted", "output": completed.stdout.strip()} + + +def _capture_queries(pair: dict[str, Any], started_at: str, marker: str) -> dict[str, str]: + title = pair["displayName"].replace('"', '\\"') + marker_text = marker.replace('"', '\\"') + return { + "sentinel": ( + "SecurityAlert\n" + f'| where TimeGenerated >= datetime({started_at})\n' + f'| where AlertName == "{title}"\n' + '| where ProductName == "Azure Sentinel" ' + 'and ProviderName == "ASI Scheduled Alerts"\n' + f'| where tostring(Entities) has "{marker_text}" ' + f'or tostring(ExtendedProperties) has "{marker_text}"\n' + "| summarize arg_max(TimeGenerated, *) by SystemAlertId\n" + "| project SystemAlertId, AlertName, AlertSeverity, Tactics, " + "Entities, ExtendedProperties, TimeGenerated" + ), + "customDetection": ( + "let MatchingAlerts = AlertInfo\n" + f"| where Timestamp >= datetime({started_at})\n" + f'| where Title == "{title}"\n' + '| where ServiceSource == "Microsoft Defender XDR" ' + 'and DetectionSource == "Custom detection"\n' + "| project AlertId, AlertTitle=Title, AlertSeverity=Severity, " + "Category, AttackTechniques, AlertTimestamp=Timestamp;\n" + "MatchingAlerts\n" + "| join kind=leftouter (\n" + " AlertEvidence\n" + " | project AlertId, EvidenceRole, EntityType, DeviceId, DeviceName, " + "AccountUpn, AccountSid, RemoteIP, LocalIP, FileName, FolderPath, " + "SHA256, RemoteUrl, EvidenceTimestamp=Timestamp\n" + ") on AlertId\n" + f'| where tostring(pack_all()) has "{marker_text}"\n' + "| project AlertId, Title=AlertTitle, Severity=AlertSeverity, " + "Category, AttackTechniques, " + "EvidenceRole, EntityType, DeviceId, DeviceName, AccountUpn, " + "AccountSid, RemoteIP, LocalIP, FileName, FolderPath, SHA256, " + "URL=RemoteUrl, Timestamp=AlertTimestamp" + ), + } + + +def start_alert_parity( + solution: str | Path, + *, + workspace_resource_id: str | None = None, + contract: str | Path, + payload: str | Path, + scenario_marker: str, + expected_match_keys: list[str] | None = None, + detections: list[str] | None = None, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + target = require_locked_target( + solution, + workspace_resource_id=workspace_resource_id, + ) + workspace_resource_id = target["workspaceResourceId"] + marker = scenario_marker.strip() + if not marker: + raise ValueError("scenario-marker is required") + _validate_marked_payload(payload, marker) + expected_keys = sorted( + {str(value).strip() for value in (expected_match_keys or [marker]) if str(value).strip()} + ) + if not expected_keys: + raise ValueError("at least one expected match key is required") + root, pairs = _load_pairs(solution, detections) + from .packaging_selection import current_applied_package_selection + + applied_selection = current_applied_package_selection(root) + qualification_scope = _qualification_scope(root, pairs, applied_selection) + state_path = migrate_legacy_artifact(root, STATE_FILE_NAME) + if state_path.exists(): + existing = json.loads(state_path.read_text(encoding="utf-8")) + if existing.get("status") == "awaiting-alerts": + raise RuntimeError( + "an alert parity run is already active; complete or abort it first" + ) + + arm_token = _arm_token(state_dir) + graph_token = _deployment_token(state_dir, tenant_id=target["tenantId"]) + _read_rule_states(pairs, workspace_resource_id, arm_token, graph_token) + started_at = _utc_now() + state = { + "runId": str(uuid.uuid4()), + "status": "enabling", + "startedAt": started_at, + "solution": str(root), + "workspaceResourceId": workspace_resource_id.rstrip("/"), + "target": target, + "scenarioMarker": marker, + "expectedMatchKeys": expected_keys, + "qualificationScope": qualification_scope, + "contract": str(Path(contract).expanduser().resolve()), + "payload": str(Path(payload).expanduser().resolve()), + "rules": pairs, + } + write_json_artifact(root, state_path, state) + + enabled: list[dict[str, Any]] = [] + try: + for pair in pairs: + errors = _set_pair_enabled( + pair, workspace_resource_id, arm_token, graph_token, True + ) + enabled.append({"detection": pair["detection"], "errors": errors}) + if errors: + raise RuntimeError( + f"{pair['detection']}: failed to enable parity rules: " + f"{'; '.join(errors)}" + ) + ingestion = _ingest(contract, payload) + state["status"] = "awaiting-alerts" + state["enabledAt"] = _utc_now() + state["ingestion"] = ingestion + state["capturePlan"] = [ + { + "detection": pair["detection"], + "displayName": pair["displayName"], + "queries": _capture_queries(pair, started_at, marker), + "entityMappings": pair["entityMappings"], + } + for pair in pairs + ] + write_json_artifact(root, state_path, state) + return { + "status": state["status"], + "runId": state["runId"], + "statePath": str(state_path), + "rulesEnabled": len(pairs), + "ingestion": ingestion, + "capturePlan": state["capturePlan"], + "nextCommand": ( + "sentinel-xdr-migration complete-alert-parity " + f'--solution "{root}" --run-id "{state_path.parent.name}" --results ""' + ), + } + except Exception: + state["status"] = "failed" + state["cleanup"] = _disable_all( + state, + arm_token=arm_token, + graph_token=graph_token, + state_dir=state_dir, + ) + write_json_artifact(root, state_path, state) + raise + + +def start_alert_parity_batch( + solution: str | Path, + *, + workspace_resource_id: str | None = None, + plan_path: str | Path, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + target = require_locked_target( + solution, + workspace_resource_id=workspace_resource_id, + ) + workspace_resource_id = target["workspaceResourceId"] + plan_file = Path(plan_path).expanduser().resolve() + plan = json.loads(plan_file.read_text(encoding="utf-8-sig")) + fixtures = plan.get("fixtures") if isinstance(plan, dict) else None + if not isinstance(fixtures, list) or not fixtures: + raise ValueError("batch parity plan must contain a non-empty fixtures list") + + root, pairs = _load_pairs(solution) + from .packaging_selection import current_applied_package_selection + + applied_selection = current_applied_package_selection(root) + qualification_scope = _qualification_scope(root, pairs, applied_selection) + pairs_by_name = {pair["detection"]: pair for pair in pairs} + fixture_by_detection: dict[str, dict[str, Any]] = {} + for fixture in fixtures: + if not isinstance(fixture, dict): + raise ValueError("each batch parity fixture must be an object") + requested = str(fixture.get("detection") or "").strip() + pair = pairs_by_name[detection_reference(requested, list(pairs_by_name))] + detection = pair["detection"] + if detection in fixture_by_detection: + raise ValueError(f"duplicate batch parity fixture: {detection}") + marker = str(fixture.get("scenarioMarker") or "").strip() + if not marker: + raise ValueError(f"{detection}: scenarioMarker is required") + expected_keys = sorted( + { + str(value).strip() + for value in (fixture.get("expectedMatchKeys") or [marker]) + if str(value).strip() + } + ) + if not expected_keys: + raise ValueError(f"{detection}: at least one expected match key is required") + contract = (plan_file.parent / str(fixture.get("contract") or "")).resolve() + payload = (plan_file.parent / str(fixture.get("payload") or "")).resolve() + if not contract.is_file(): + raise ValueError(f"{detection}: ingestion contract does not exist: {contract}") + if not payload.is_file(): + raise ValueError(f"{detection}: ingestion payload does not exist: {payload}") + _validate_marked_payload(payload, marker) + fixture_by_detection[detection] = { + "detection": detection, + "contract": str(contract), + "payload": str(payload), + "scenarioMarker": marker, + "expectedMatchKeys": expected_keys, + } + + missing = sorted(set(pairs_by_name) - set(fixture_by_detection)) + if missing: + raise ValueError( + "batch parity plan must include every converted detection; missing: " + + ", ".join(missing) + ) + + state_path = migrate_legacy_artifact(root, STATE_FILE_NAME) + if state_path.exists(): + existing = json.loads(state_path.read_text(encoding="utf-8")) + if existing.get("status") == "awaiting-alerts": + raise RuntimeError( + "an alert parity run is already active; complete or abort it first" + ) + + arm_token = _arm_token(state_dir) + graph_token = _deployment_token(state_dir, tenant_id=target["tenantId"]) + _read_rule_states(pairs, workspace_resource_id, arm_token, graph_token) + started_at = _utc_now() + state = { + "runId": str(uuid.uuid4()), + "status": "enabling", + "mode": "batch", + "startedAt": started_at, + "solution": str(root), + "workspaceResourceId": workspace_resource_id.rstrip("/"), + "target": target, + "plan": str(plan_file), + "fixtures": list(fixture_by_detection.values()), + "expectedMatchKeysByDetection": { + detection: fixture["expectedMatchKeys"] + for detection, fixture in fixture_by_detection.items() + }, + "qualificationScope": qualification_scope, + "rules": pairs, + } + write_json_artifact(root, state_path, state) + + try: + for pair in pairs: + errors = _set_pair_enabled( + pair, workspace_resource_id, arm_token, graph_token, True + ) + if errors: + raise RuntimeError( + f"{pair['detection']}: failed to enable parity rules: " + f"{'; '.join(errors)}" + ) + ingestions = [] + for pair in pairs: + fixture = fixture_by_detection[pair["detection"]] + ingestions.append( + { + "detection": pair["detection"], + "result": _ingest(fixture["contract"], fixture["payload"]), + } + ) + state["status"] = "awaiting-alerts" + state["enabledAt"] = _utc_now() + state["ingestions"] = ingestions + state["capturePlan"] = [ + { + "detection": pair["detection"], + "displayName": pair["displayName"], + "queries": _capture_queries( + pair, + started_at, + fixture_by_detection[pair["detection"]]["scenarioMarker"], + ), + "scenarioMarker": fixture_by_detection[pair["detection"]][ + "scenarioMarker" + ], + "expectedMatchKeys": fixture_by_detection[pair["detection"]][ + "expectedMatchKeys" + ], + "entityMappings": pair["entityMappings"], + } + for pair in pairs + ] + write_json_artifact(root, state_path, state) + return { + "status": state["status"], + "runId": state["runId"], + "statePath": str(state_path), + "rulesEnabled": len(pairs), + "fixturesIngested": len(ingestions), + "ingestions": ingestions, + "capturePlan": state["capturePlan"], + "nextCommand": ( + "sentinel-xdr-migration complete-alert-parity " + f'--solution "{root}" --run-id "{state_path.parent.name}" --results ""' + ), + } + except Exception: + state["status"] = "failed" + state["cleanup"] = _disable_all( + state, + arm_token=arm_token, + graph_token=graph_token, + state_dir=state_dir, + ) + write_json_artifact(root, state_path, state) + raise + + +def _canonical(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _normalize_alert(alert: dict[str, Any]) -> dict[str, Any]: + required = {"matchKey", "severity", "tactics", "entities", "evidence"} + missing = sorted(required - set(alert)) + if missing: + raise ValueError(f"normalized alert is missing: {', '.join(missing)}") + return { + "matchKey": str(alert["matchKey"]), + "severity": str(alert["severity"]).lower(), + "tactics": sorted(str(item) for item in alert["tactics"]), + "entities": alert["entities"], + "evidence": alert["evidence"], + } + + +def compare_alert_captures( + expected_detections: set[str], + raw_results: Any, + *, + expected_match_keys: set[str] | None = None, + expected_match_keys_by_detection: dict[str, set[str]] | None = None, +) -> list[dict[str, Any]]: + entries = raw_results.get("results") if isinstance(raw_results, dict) else raw_results + if not isinstance(entries, list): + raise ValueError("alert parity results must contain a results list") + names: set[str] = set() + comparisons: list[dict[str, Any]] = [] + for entry in entries: + if not isinstance(entry, dict): + raise ValueError("each alert parity result must be an object") + detection = str(entry.get("detection") or "") + if not detection or detection in names: + raise ValueError("each detection must occur exactly once") + names.add(detection) + sides: dict[str, list[dict[str, Any]]] = {} + for side in ("analyticRule", "customDetection"): + value = entry.get(side) or {} + alerts = value.get("alerts") if isinstance(value, dict) else None + if not isinstance(alerts, list): + raise ValueError(f"{detection}: {side}.alerts must be a list") + normalized = [_normalize_alert(alert) for alert in alerts] + keys = [alert["matchKey"] for alert in normalized] + if len(keys) != len(set(keys)): + raise ValueError(f"{detection}: duplicate {side} matchKey") + sides[side] = sorted(normalized, key=lambda item: item["matchKey"]) + + analytic = sides["analyticRule"] + custom = sides["customDetection"] + analytic_by_key = {item["matchKey"]: item for item in analytic} + custom_by_key = {item["matchKey"]: item for item in custom} + detection_expected_keys = ( + (expected_match_keys_by_detection or {}).get(detection) + or expected_match_keys + ) + dimensions = { + "alertCount": len(analytic) == len(custom), + "matchKeys": set(analytic_by_key) == set(custom_by_key), + "expectedMatches": ( + detection_expected_keys is None + or ( + set(analytic_by_key) == detection_expected_keys + and set(custom_by_key) == detection_expected_keys + ) + ), + "severity": all( + analytic_by_key[key]["severity"] == custom_by_key[key]["severity"] + for key in set(analytic_by_key) & set(custom_by_key) + ), + "tactics": all( + analytic_by_key[key]["tactics"] == custom_by_key[key]["tactics"] + for key in set(analytic_by_key) & set(custom_by_key) + ), + "entities": all( + _canonical(analytic_by_key[key]["entities"]) + == _canonical(custom_by_key[key]["entities"]) + for key in set(analytic_by_key) & set(custom_by_key) + ), + "evidence": all( + _canonical(analytic_by_key[key]["evidence"]) + == _canonical(custom_by_key[key]["evidence"]) + for key in set(analytic_by_key) & set(custom_by_key) + ), + } + comparisons.append( + { + "detection": detection, + "passed": all(dimensions.values()), + "analyticRuleAlertCount": len(analytic), + "customDetectionAlertCount": len(custom), + "dimensions": dimensions, + "analyticRuleOnlyMatchKeys": sorted( + set(analytic_by_key) - set(custom_by_key) + ), + "customDetectionOnlyMatchKeys": sorted( + set(custom_by_key) - set(analytic_by_key) + ), + } + ) + missing = sorted(expected_detections - names) + unknown = sorted(names - expected_detections) + if missing or unknown: + details = [] + if missing: + details.append(f"missing detections: {', '.join(missing)}") + if unknown: + details.append(f"unknown detections: {', '.join(unknown)}") + raise ValueError("; ".join(details)) + return comparisons + + +def complete_alert_parity( + solution: str | Path, + *, + results_path: str | Path, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + state_path = migrate_legacy_artifact(root, STATE_FILE_NAME) + if not state_path.exists(): + raise ValueError("no alert parity state exists for this solution") + state = json.loads(state_path.read_text(encoding="utf-8")) + if state.get("status") != "awaiting-alerts": + raise ValueError(f"alert parity run is not active: {state.get('status')}") + target = require_locked_target(root) + if (state.get("target") or {}).get("workspaceResourceId", "").lower() != target[ + "workspaceResourceId" + ].lower(): + raise ValueError("alert parity state does not match the locked target") + + comparison_error: str | None = None + scope_error: str | None = None + verified_scope: dict[str, Any] | None = None + comparisons: list[dict[str, Any]] = [] + cleanup: list[dict[str, Any]] = [] + try: + verified_scope = _verify_qualification_scope(root, state) + raw = json.loads( + Path(results_path).expanduser().read_text(encoding="utf-8-sig") + ) + expected = {pair["detection"] for pair in state["rules"]} + comparisons = compare_alert_captures( + expected, + raw, + expected_match_keys=set(state.get("expectedMatchKeys") or []), + expected_match_keys_by_detection={ + detection: set(keys) + for detection, keys in ( + state.get("expectedMatchKeysByDetection") or {} + ).items() + }, + ) + except Exception as exc: + if verified_scope is None: + scope_error = str(exc) + else: + comparison_error = str(exc) + finally: + cleanup = _disable_all(state, state_dir=state_dir) + + cleanup_ok = all(item["disabled"] for item in cleanup) + passed = ( + comparison_error is None + and scope_error is None + and bool(comparisons) + and all(item["passed"] for item in comparisons) + and cleanup_ok + ) + report = { + "runId": state["runId"], + "status": "passed" if passed else "failed", + "completedAt": _utc_now(), + "solution": str(root), + "scenarioMarker": state.get("scenarioMarker"), + "fixtures": state.get("fixtures") or [], + "strictParity": True, + "comparisonError": comparison_error, + "scopeVerificationError": scope_error, + "qualificationScopeVerified": verified_scope is not None and scope_error is None, + "selectionPlanSha256": ( + verified_scope.get("planSha256") if verified_scope else None + ), + "testedDetectionPaths": ( + verified_scope.get("selectedDetectionPaths") if verified_scope else [] + ), + "testedSourceIds": ( + verified_scope.get("selectedSourceIds") if verified_scope else [] + ), + "comparisons": comparisons, + "qualificationScope": verified_scope or state.get("qualificationScope"), + "cleanup": cleanup, + } + report_path = artifact_path(root, REPORT_FILE_NAME, create_parent=True) + report["reportPath"] = str(report_path) + write_json_artifact(root, report_path, report) + state["status"] = "completed" if passed else "failed" + state["completedAt"] = report["completedAt"] + state["reportPath"] = str(report_path) + state["cleanup"] = cleanup + write_json_artifact(root, state_path, state) + return report + + +def abort_alert_parity( + solution: str | Path, *, state_dir: str | Path | None = None +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + state_path = migrate_legacy_artifact(root, STATE_FILE_NAME) + if not state_path.exists(): + raise ValueError("no alert parity state exists for this solution") + state = json.loads(state_path.read_text(encoding="utf-8")) + target = require_locked_target(root) + if (state.get("target") or {}).get("workspaceResourceId", "").lower() != target[ + "workspaceResourceId" + ].lower(): + raise ValueError("alert parity state does not match the locked target") + cleanup = _disable_all(state, state_dir=state_dir) + success = all(item["disabled"] for item in cleanup) + state["status"] = "aborted" if success else "cleanup-failed" + state["abortedAt"] = _utc_now() + state["cleanup"] = cleanup + write_json_artifact(root, state_path, state) + return { + "status": state["status"], + "runId": state["runId"], + "cleanup": cleanup, + "statePath": str(state_path), + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/analytic_deployment.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/analytic_deployment.py new file mode 100644 index 00000000000..3d1f025f431 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/analytic_deployment.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from .analytic_rule import analytic_rule_payload +from .artifacts import artifact_path, write_json_artifact +from .arm_client import _arm_request, _arm_token, _sentinel_rule_url +from .converter import analytic_rule_files, solution_paths +from .target_context import require_locked_target + + +def deploy_analytic_rules( + solution: str | Path, + *, + workspace_resource_id: str | None = None, +) -> dict[str, Any]: + root, _, output = solution_paths(solution) + report_path = artifact_path(root, "deployment.sentinel.json", create_parent=True) + target = require_locked_target( + root, + workspace_resource_id=workspace_resource_id, + ) + workspace_resource_id = target["workspaceResourceId"] + output.mkdir(parents=True, exist_ok=True) + + results: list[dict[str, Any]] = [] + source_files = analytic_rule_files(root) + from .packaging_selection import current_applied_package_selection + + applied_selection = current_applied_package_selection(root) + if applied_selection is not None: + selected_paths = { + str(value).replace("\\", "/").casefold() + for value in (applied_selection["plan"].get("selection") or {}).get( + "analyticRules", [] + ) + } + source_by_path = { + path.relative_to(root).as_posix().casefold(): path for path in source_files + } + if not selected_paths or not selected_paths.issubset(source_by_path): + raise ValueError("current applied package selection has missing Analytic Rule files") + source_files = [source_by_path[path] for path in sorted(selected_paths)] + token = _arm_token() + for source_path in source_files: + result: dict[str, Any] = { + "source": str(source_path), + "id": None, + "name": source_path.stem, + "success": False, + "operation": "upsert", + "statusCode": None, + "error": None, + } + try: + document = yaml.safe_load(source_path.read_text(encoding="utf-8-sig")) + if not isinstance(document, dict): + raise ValueError("analytic rule YAML must contain an object") + rule_id = str(document.get("id") or "") + result["id"] = rule_id + result["name"] = str(document.get("name") or source_path.stem) + payload = analytic_rule_payload(document) + status, response = _arm_request( + "PUT", + _sentinel_rule_url(workspace_resource_id, rule_id), + token, + payload, + ) + result["statusCode"] = status + result["success"] = status in (200, 201) + if result["success"]: + result["enabled"] = bool( + (response.get("properties") or {}).get("enabled", False) + ) + if result["enabled"]: + result["success"] = False + result["error"] = { + "code": "UnexpectedEnabledState", + "message": "deployed analytic rule was not disabled", + "response": response, + } + else: + result["error"] = response.get("error") or response + except (OSError, TypeError, ValueError, yaml.YAMLError) as exc: + result["error"] = {"code": type(exc).__name__, "message": str(exc)} + results.append(result) + + report = { + "provider": "azure-resource-manager", + "workspaceResourceId": workspace_resource_id, + "target": target, + "solution": str(root), + "total": len(results), + "succeeded": sum(bool(item["success"]) for item in results), + "failed": sum(not item["success"] for item in results), + "results": results, + } + if applied_selection is not None: + report["qualificationScope"] = { + "kind": "applied-package-selection", + "planSha256": applied_selection["plan"].get("planSha256"), + "outcome": (applied_selection["plan"].get("summary") or {}).get("outcome"), + "selectedAnalyticRulePaths": sorted( + str(value).replace("\\", "/") + for value in (applied_selection["plan"].get("selection") or {}).get( + "analyticRules", [] + ) + ), + "coverage": applied_selection["plan"].get("summary"), + } + report["reportPath"] = str(report_path) + write_json_artifact(root, report_path, report) + return report diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/analytic_rule.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/analytic_rule.py new file mode 100644 index 00000000000..37dff812090 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/analytic_rule.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +from .durations import iso_duration + +TRIGGER_OPERATORS = { + "gt": "GreaterThan", + "lt": "LessThan", + "eq": "Equal", + "ne": "NotEqual", +} + + +def _duration(value: Any, field: str) -> str: + duration, warning = iso_duration(value) + if warning: + raise ValueError(f"{field}: {warning}") + return duration + + +def analytic_rule_payload(document: dict[str, Any]) -> dict[str, Any]: + if str(document.get("kind") or "").lower() != "scheduled": + raise ValueError("only Scheduled analytic rules can be deployed") + required = ("id", "name", "query", "severity", "queryFrequency", "queryPeriod") + missing = [field for field in required if not document.get(field)] + if missing: + raise ValueError(f"analytic rule is missing: {', '.join(missing)}") + + trigger = str(document.get("triggerOperator") or "gt").lower() + if trigger not in TRIGGER_OPERATORS: + raise ValueError(f"unsupported triggerOperator: {trigger}") + + properties: dict[str, Any] = { + "displayName": str(document["name"]), + "description": str(document.get("description") or ""), + "severity": str(document["severity"]), + "enabled": False, + "query": str(document["query"]), + "queryFrequency": _duration(document["queryFrequency"], "queryFrequency"), + "queryPeriod": _duration(document["queryPeriod"], "queryPeriod"), + "triggerOperator": TRIGGER_OPERATORS[trigger], + "triggerThreshold": int(document.get("triggerThreshold") or 0), + "suppressionDuration": _duration( + document.get("suppressionDuration") or "5h", "suppressionDuration" + ), + "suppressionEnabled": bool(document.get("suppressionEnabled", False)), + "eventGroupingSettings": document.get("eventGroupingSettings") + or {"aggregationKind": "SingleAlert"}, + "alertRuleTemplateName": str(document["id"]), + "templateVersion": str(document.get("version") or ""), + } + for source, target in ( + ("tactics", "tactics"), + ("relevantTechniques", "techniques"), + ("entityMappings", "entityMappings"), + ("customDetails", "customDetails"), + ("alertDetailsOverride", "alertDetailsOverride"), + ("incidentConfiguration", "incidentConfiguration"), + ): + value = document.get(source) + if value is not None: + properties[target] = value + return {"kind": "Scheduled", "properties": properties} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_acceptance.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_acceptance.py new file mode 100644 index 00000000000..03d0635f2c2 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_acceptance.py @@ -0,0 +1,1094 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +from .artifacts import ( + artifact_path, + existing_artifact_path, + repository_root, + resolve_artifact_reference, + using_run, + write_json_artifact, +) +from .deployment_planning import _current_package +from .arm_acceptance_support import ( + CommandRunner, + _canonical_sha256, + _deployment_provisioning_state, + _parameter_values, + _run_cli, + _validated_members, + _validated_shared_decisions, + _verify_existing_cli_target, +) +from .onboarding import _state_dir +from .target_context import require_locked_target, workspace_identity +from .workflow import workflow_status + +ARM_ACCEPTANCE_BATCH_PLAN = "arm-template-deployment-acceptance-batch-plan.json" +ARM_ACCEPTANCE_BATCH_REPORT = "arm-template-deployment-acceptance-batch.json" +ARM_ACCEPTANCE_ATTEMPT_PREFIX = "arm-template-deployment-acceptance-attempt-" +ARM_ACCEPTANCE_TEMPLATE_SNAPSHOT_PREFIX = "arm-template-deployment-acceptance-template-" +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_BATCH_ARM_ACCEPTANCE_CONFIRMATION = ( + "I approve the exact enumerated ARM template deployment acceptance batch" +) +_TARGET_KEYS = ( + "tenantId", + "subscriptionId", + "resourceGroup", + "workspaceResourceId", + "workspaceCustomerId", +) +_SOURCE_PATH_SEMANTICS = {"posix", "windows", "platform-neutral"} +_PRODUCER_PLATFORMS = {"posix", "windows"} + + +def _source_paths_in_order(paths: list[str], semantics: str) -> bool: + if semantics == "posix": + expected = sorted(paths, key=PurePosixPath) + elif semantics == "windows": + expected = sorted(paths, key=PureWindowsPath) + elif semantics == "platform-neutral": + posix_order = sorted(paths, key=PurePosixPath) + windows_order = sorted(paths, key=PureWindowsPath) + if posix_order != windows_order: + raise ValueError( + "validation binding claims platform-neutral source ordering but POSIX and Windows Path orders differ" + ) + expected = posix_order + else: + raise ValueError( + "validation binding has unsupported source Path ordering semantics" + ) + return paths == expected + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_build_validation_receipt( + solution: Path, + receipt_path: str | Path, + *, + package_report_path: Path, + package_identity: dict[str, Any], +) -> dict[str, Any]: + candidate = Path(receipt_path).expanduser() + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError( + "ARM template acceptance requires the current build-and-validate-report.md receipt" + ) + resolved = candidate.resolve(strict=True) + if resolved.name != "build-and-validate-report.md": + raise ValueError("validation receipt must be build-and-validate-report.md") + try: + content = resolved.read_bytes() + report = content.decode("utf-8-sig") + except (OSError, UnicodeError) as exc: + raise ValueError(f"validation receipt is unreadable: {exc}") from exc + + if ( + report.count("[REPORT_START]") != 1 + or report.count("[REPORT_END]") != 1 + or report.index("[REPORT_START]") > report.index("[REPORT_END]") + ): + raise ValueError("validation receipt is not a complete build-and-validate report") + lines = report.splitlines() + + def field(name: str) -> str: + values = [ + line[len(name) + 2 :].strip() + for line in lines + if line.startswith(name + ": ") + ] + if len(values) != 1 or not values[0]: + raise ValueError(f"validation receipt must contain one {name} field") + return values[0] + + report_lines = [ + line.strip() + for line in lines + if line.strip() not in {"[REPORT_START]", "[REPORT_END]"} + ] + if not report_lines or report_lines[0] != "# BUILD & VALIDATION REPORT": + raise ValueError("validation receipt was not produced by the repository build-and-validate report") + if field("Solution") != solution.name: + raise ValueError("validation receipt belongs to a different solution") + expected_content_root = repository_root(solution).resolve() + try: + reported_content_root = Path(field("Content root")).expanduser().resolve(strict=True) + toolkit_root = Path(field("Toolkit root")).expanduser().resolve(strict=True) + evidence_path = Path(field("Evidence")).expanduser().resolve(strict=True) + except OSError as exc: + raise ValueError( + f"validation receipt references an unavailable repository or evidence path: {exc}" + ) from exc + if reported_content_root != expected_content_root: + raise ValueError("validation receipt content root does not match the selected solution repository") + if evidence_path != resolved.parent: + raise ValueError("validation receipt evidence path does not match its containing run directory") + expected_script = toolkit_root / ".script" / "local-validation" / "build-and-validate.ps1" + evidence_root = (toolkit_root / ".script" / "local-validation" / "Logs").resolve() + if ( + expected_script.is_symlink() + or not expected_script.is_file() + or evidence_root not in evidence_path.parents + ): + raise ValueError( + "validation receipt does not reference the repository build-and-validate evidence layout" + ) + if field("Result") != "PASSED" or "**BUILD SUCCESSFUL**" not in report: + raise ValueError("repository build-and-validate receipt did not report success") + if field("Validation binding result") != "PASSED": + raise ValueError("repository build-and-validate producer binding did not report success") + + validator_rows: list[dict[str, str]] = [] + for line in lines: + match = re.fullmatch( + r"\|\s*(TS|\.NET|CI-PS)\s*\|\s*([^|]+?)\s*\|\s*(PASS|FAIL|SKIP)\s*\|.*\|", + line.strip(), + ) + if match: + validator_rows.append( + { + "step": match.group(1), + "validator": match.group(2).strip(), + "status": match.group(3), + } + ) + summaries = [ + match + for match in ( + re.fullmatch( + r"Total:\s*(\d+)\s+validators\s*\|\s*(\d+)\s+passed\s*\|\s*(\d+)\s+failed\s*\|\s*(\d+)\s+skipped", + line.strip(), + ) + for line in lines + ) + if match + ] + if len(summaries) != 1 or not validator_rows: + raise ValueError( + "validation receipt has no completed validator results; skipped validation is not success" + ) + total, passed, failed, skipped = (int(value) for value in summaries[0].groups()) + actual_passed = sum(row["status"] == "PASS" for row in validator_rows) + actual_failed = sum(row["status"] == "FAIL" for row in validator_rows) + actual_skipped = sum(row["status"] == "SKIP" for row in validator_rows) + if ( + total != len(validator_rows) + or passed != actual_passed + or failed != actual_failed + or skipped != actual_skipped + or failed + or not passed + ): + raise ValueError( + "validation receipt validator statuses are incomplete, inconsistent, or failed" + ) + + binding_path = Path(field("Validation binding")).expanduser() + if binding_path.is_symlink() or not binding_path.is_file(): + raise ValueError("validation receipt is missing its producer binding artifact") + binding_path = binding_path.resolve(strict=True) + if ( + binding_path.name != "build-and-validate-binding.json" + or binding_path.parent != evidence_path + ): + raise ValueError("validation binding is copied or is outside its report evidence directory") + binding_bytes = binding_path.read_bytes() + binding_sha256 = field("Validation binding SHA256") + if not _SHA256.fullmatch(binding_sha256) or hashlib.sha256(binding_bytes).hexdigest() != binding_sha256: + raise ValueError("validation binding artifact hash does not match the report") + try: + binding = json.loads(binding_bytes.decode("utf-8-sig")) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"validation binding artifact is invalid JSON: {exc}") from exc + if not isinstance(binding, dict) or binding.get("schemaVersion") != 1: + raise ValueError("validation binding artifact has an unsupported schema") + source_path_semantics = binding.get("sourcePathSemantics") + producer_platform = binding.get("producerPlatform") + if ( + not isinstance(source_path_semantics, str) + or source_path_semantics not in _SOURCE_PATH_SEMANTICS + ): + raise ValueError( + "validation binding has no supported packager Path ordering semantics" + ) + if ( + not isinstance(producer_platform, str) + or producer_platform not in _PRODUCER_PLATFORMS + ): + raise ValueError("validation binding has no supported producer platform") + + solution_root = solution.resolve(strict=True) + producer_paths = { + "build-and-validate.ps1": toolkit_root / ".script" / "local-validation" / "build-and-validate.ps1", + "validation-binding.ps1": toolkit_root / ".script" / "local-validation" / "validation-binding.ps1", + } + producer_before = binding.get("producerFilesBefore") + producer_after = binding.get("producerFilesAfter") + if ( + not isinstance(producer_before, dict) + or not isinstance(producer_after, dict) + or set(producer_before) != set(producer_paths) + or producer_before != producer_after + ): + raise ValueError("validation binding does not prove stable producer scripts") + for name, producer_path in producer_paths.items(): + if producer_path.is_symlink() or not producer_path.is_file(): + raise ValueError(f"validation binding producer script is unavailable: {name}") + actual_hash = hashlib.sha256(producer_path.read_bytes()).hexdigest() + if producer_before.get(name) != actual_hash: + raise ValueError(f"validation binding was produced by a different {name}") + + if ( + binding.get("producer") != "build-and-validate.ps1" + or binding.get("validationResult") != "PASSED" + or binding.get("validationRan") is not True + or binding.get("packagingSkipped") is not True + or binding.get("contentRoot") != str(expected_content_root) + or binding.get("solutionPath") != str(solution_root) + or binding.get("toolkitRoot") != str(toolkit_root) + or binding.get("evidenceRunPath") != str(evidence_path) + ): + raise ValueError("validation binding does not match this solution, ContentRoot, toolkit, and evidence run") + try: + validation_started = datetime.fromisoformat(binding["validationStartedAtUtc"]) + validation_completed = datetime.fromisoformat(binding["validationCompletedAtUtc"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("validation binding has invalid start/completion timestamps") from exc + if validation_completed < validation_started: + raise ValueError("validation binding completed before it started") + + binding_summary = binding.get("validatorSummary") + if binding_summary != { + "total": total, + "passed": passed, + "failed": failed, + "skipped": skipped, + }: + raise ValueError("validation binding validator totals do not match the human-readable report") + + def fingerprint_list(value: Any, label: str) -> list[dict[str, str]]: + if not isinstance(value, list): + raise ValueError(f"validation binding has no {label} file fingerprints") + normalized: list[dict[str, str]] = [] + for item in value: + if ( + not isinstance(item, dict) + or set(item) != {"path", "sha256"} + or not isinstance(item.get("path"), str) + or not item["path"] + or Path(item["path"]).is_absolute() + or ".." in Path(item["path"].replace("\\", "/")).parts + or not isinstance(item.get("sha256"), str) + or not _SHA256.fullmatch(item["sha256"]) + ): + raise ValueError(f"validation binding contains an invalid {label} file fingerprint") + normalized.append({"path": item["path"].replace("\\", "/"), "sha256": item["sha256"]}) + paths = [item["path"] for item in normalized] + if label == "source-input": + if not _source_paths_in_order(paths, source_path_semantics): + raise ValueError( + "validation binding source-input fingerprints do not match recorded Path ordering semantics" + ) + expected_paths = paths + else: + expected_paths = sorted(paths) + if paths != expected_paths or len(paths) != len(set(paths)): + raise ValueError(f"validation binding {label} fingerprints are not uniquely sorted") + return normalized + + source_before = fingerprint_list(binding.get("inputFilesBefore"), "source-input") + source_after = fingerprint_list(binding.get("inputFilesAfter"), "source-input") + package_before = fingerprint_list(binding.get("packageFilesBefore"), "package") + package_after = fingerprint_list(binding.get("packageFilesAfter"), "package") + if source_before != source_after or package_before != package_after: + raise ValueError("solution inputs or V3.1 package artifacts changed during validation") + source_fingerprint = hashlib.sha256( + json.dumps(source_after, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + package_fingerprint = hashlib.sha256( + json.dumps( + package_after, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + if source_fingerprint != package_identity.get("packageInputSha256"): + raise ValueError("validation binding source fingerprints do not match current V3.1 package inputs") + if package_fingerprint != package_identity.get("packageContentSha256"): + raise ValueError("validation binding package fingerprints do not match current V3.1 package artifacts") + + binding_package = binding.get("package") + if not isinstance(binding_package, dict): + raise ValueError("validation binding has no V3.1 package report identity") + expected_package_report_reference = package_report_path.resolve(strict=True).relative_to( + solution_root + ).as_posix() + expected_package = { + "path": expected_package_report_reference, + "sha256": package_identity.get("packageReportSha256"), + "version": package_identity.get("version"), + "packageAttemptId": package_identity.get("packageAttemptId"), + "workflowRunId": package_identity.get("workflowRunId"), + "packageInputSha256": package_identity.get("packageInputSha256"), + "packageArtifactsSha256": package_identity.get("packageArtifactsSha256"), + "packageArtifactHashes": package_identity.get("recordedPackageArtifactHashes"), + } + if any(binding_package.get(key) != value for key, value in expected_package.items()): + raise ValueError("validation binding package report identity does not match the current V3.1 package") + if binding_package.get("packageFiles") != package_after: + raise ValueError("validation binding package report does not cover the exact packaged file inventory") + + return { + "path": str(resolved), + "sha256": hashlib.sha256(content).hexdigest(), + "status": "PASSED", + "solution": solution.name, + "contentRoot": str(reported_content_root), + "toolkitRoot": str(toolkit_root), + "evidencePath": str(evidence_path), + "buildAndValidateScriptSha256": hashlib.sha256(expected_script.read_bytes()).hexdigest(), + "validatorResults": validator_rows, + "validatorSummary": { + "total": total, + "passed": passed, + "failed": failed, + "skipped": skipped, + }, + "bindingPath": str(binding_path), + "bindingSha256": binding_sha256, + "binding": binding, + "sourcePathSemantics": source_path_semantics, + "producerPlatform": producer_platform, + "validatedPackage": binding_package, + } + + +def _write_template_snapshot(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink() or path.parent.is_symlink(): + raise ValueError("ARM template snapshot path cannot traverse a symlink") + try: + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o400) + except FileExistsError: + if path.is_symlink() or not path.is_file() or path.read_bytes() != content: + raise ValueError("existing ARM template snapshot does not match the approved bytes") + path.chmod(stat.S_IRUSR) + return + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + path.chmod(stat.S_IRUSR) + except Exception: + path.unlink(missing_ok=True) + raise + + +def _acceptance_package_plan( + solution: str | Path, + validation_receipt_path: str | Path, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + state = workflow_status(root) + context = state.get("context") or {} + if ( + context.get("workflowProfile") != "qualification" + or context.get("profileSelectionConfirmed") is not True + or context.get("targetContextLocked") is not True + ): + raise ValueError( + "ARM template deployment acceptance requires an explicitly selected Qualification workflow and locked target" + ) + stages = state.get("stages") or {} + packaging = stages.get("packaging") or {} + if packaging.get("status") != "passed": + raise ValueError("ARM template deployment acceptance requires a passed V3.1 package stage") + if (packaging.get("artifacts") or {}).get("packager") != "V3.1": + raise ValueError("ARM template deployment acceptance requires packaging evidence from V3.1") + + target = require_locked_target(root, materialize_missing=False) + package_freshness = state.get("packageFreshness") or {} + package = _current_package(root, state) + if ( + package_freshness.get("status") != "current" + or package.get("status") != "current" + ): + raise ValueError( + "ARM template deployment acceptance requires the current V3.1 package identity" + ) + + package_state = state.get("packaging") or {} + successful = package_state.get("lastSuccessful") or {} + package_report_reference = successful.get("packageReport") + if not isinstance(package_report_reference, str) or not package_report_reference: + raise ValueError("current V3.1 package receipt has no package report reference") + try: + package_report_path = resolve_artifact_reference(root, package_report_reference) + except (OSError, ValueError) as exc: + raise ValueError(f"current V3.1 package report cannot be resolved: {exc}") from exc + package_identity = { + "version": package.get("version"), + "packageAttemptId": successful.get("attemptId"), + "workflowRunId": state.get("runId"), + "packageReportPath": package_report_reference.replace("\\", "/"), + "packageInputSha256": package.get("packageInputSha256"), + "packageArtifactHashes": package.get("packageArtifactHashes") or {}, + "recordedPackageArtifactHashes": package.get("recordedPackageArtifactHashes") or {}, + "packageContentSha256": package.get("packageContentSha256"), + "packageReportSha256": successful.get("packageReportSha256"), + "packageArtifactsSha256": successful.get("packageArtifactsSha256"), + } + validation_receipt = _parse_build_validation_receipt( + root, + validation_receipt_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + + template_path = root / "Package" / "mainTemplate.json" + if ( + not template_path.is_file() + or template_path.is_symlink() + or template_path.parent.is_symlink() + ): + raise ValueError("current V3.1 Package/mainTemplate.json is missing or unsafe") + template_bytes = template_path.read_bytes() + template_sha256 = hashlib.sha256(template_bytes).hexdigest() + expected_template_sha256 = (package.get("packageArtifactHashes") or {}).get("mainTemplate") + if ( + not isinstance(expected_template_sha256, str) + or not _SHA256.fullmatch(expected_template_sha256) + or template_sha256 != expected_template_sha256 + ): + raise ValueError("mainTemplate.json no longer matches the current V3.1 package identity") + try: + template = json.loads(template_bytes.decode("utf-8-sig")) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read the current V3.1 ARM template: {exc}") from exc + if not isinstance(template, dict) or not isinstance(template.get("parameters"), dict): + raise ValueError("current V3.1 ARM template has no declared parameters object") + + declared_flags = { + name: definition + for name, definition in template["parameters"].items() + if isinstance(name, str) and name.startswith("Deploy") + } + if any( + not isinstance(definition, dict) or definition.get("type") != "bool" + for definition in declared_flags.values() + ): + raise ValueError("every declared Deploy* package selection flag must be boolean") + workspace = workspace_identity(target["workspaceResourceId"])["workspaceName"] + flags = {name: True for name in declared_flags} + parameters = _parameter_values(template, workspace, flags) + + snapshot_path = artifact_path( + root, + f"{ARM_ACCEPTANCE_TEMPLATE_SNAPSHOT_PREFIX}{template_sha256}.json", + create_parent=True, + ) + _write_template_snapshot(snapshot_path, template_bytes) + body: dict[str, Any] = { + "schemaVersion": 1, + "stage": "post-validation-arm-template-deployment-acceptance", + "status": "ready", + "qualificationOnly": True, + "solution": str(root), + "runId": state.get("runId"), + "target": {key: target[key] for key in _TARGET_KEYS}, + "deploymentRoute": "Azure CLI az deployment group create", + "deploymentMode": "Incremental", + "mainTemplatePath": str(template_path), + "mainTemplateSha256": template_sha256, + "mainTemplateSnapshotPath": str(snapshot_path), + "mainTemplateSnapshotSha256": template_sha256, + "packageIdentity": package_identity, + "bicepExtensibilityImports": package.get("bicepExtensibilityImports") or [], + "validationReceipt": validation_receipt, + "declaredContentFlags": flags, + "parameters": parameters, + "parametersSha256": _canonical_sha256(parameters), + "acceptanceCriterion": ( + "the command response records terminal ARM deployment provisioningState Succeeded" + ), + "resourceReadbackRequired": False, + "resourceReadbackPerformed": False, + "installationVerified": False, + "activationPerformed": False, + "alertParityPerformed": False, + "qualificationCompleted": False, + "planningConsentIsNotWriteApproval": True, + "environmentWritesAuthorized": False, + "confirmation": _BATCH_ARM_ACCEPTANCE_CONFIRMATION, + "noAutomaticRetryAfterAttempt": True, + } + body["packagePlanSha256"] = _canonical_sha256(body) + return body + + +def _arm_acceptance_batch_plan( + members: list[dict[str, str]], + *, + shared_decisions_path: str | Path, + validation_receipts: list[str | Path], +) -> dict[str, Any]: + normalized = _validated_members(members) + if len(validation_receipts) != len(normalized): + raise ValueError( + "provide exactly one current repository validation receipt for every batch member" + ) + package_plans: list[dict[str, Any]] = [] + first_target: dict[str, Any] | None = None + for member, receipt_path in zip(normalized, validation_receipts): + root = Path(member["solutionPath"]) + with using_run(root, member["runId"]): + plan = _acceptance_package_plan(root, receipt_path) + if plan.get("runId") != member["runId"]: + raise ValueError( + f"{member['solution']}: selected workflow run changed while planning ARM acceptance" + ) + target = plan["target"] + if first_target is None: + first_target = target + elif any( + str(target.get(key) or "").casefold() + != str(first_target.get(key) or "").casefold() + for key in _TARGET_KEYS + ): + raise ValueError("one ARM acceptance batch cannot cross locked lab targets") + package_plans.append(plan) + + if first_target is None: + raise ValueError("ARM acceptance batch has no members") + receipt, receipt_binding = _validated_shared_decisions( + shared_decisions_path, + first_target, + normalized, + ) + known_flags = (receipt.get("decisions") or {}).get("knownFlagSelections") + if not isinstance(known_flags, dict): + raise ValueError("shared PR/lab decision receipt has no declared-flag selection map") + declared_union = { + name + for plan in package_plans + for name in (plan.get("declaredContentFlags") or {}) + } + if set(known_flags) != declared_union or any( + type(known_flags.get(name)) is not bool or known_flags[name] is not True + for name in declared_union + ): + raise ValueError( + "shared PR/lab decisions must exactly enumerate every currently declared package flag as true" + ) + + planned_members: list[dict[str, Any]] = [] + for member, plan in zip(normalized, package_plans): + parameters = plan["parameters"] + if not isinstance(parameters, dict) or any( + not isinstance(name, str) + or not name + or not isinstance(value, (str, bool, int, float)) + for name, value in parameters.items() + ): + raise ValueError( + f"{member['solution']}: ARM CLI parameters must be explicit scalar values" + ) + deployment_name = "sentinel-arm-" + _canonical_sha256( + { + "solution": member["solution"], + "runId": member["runId"], + "target": first_target, + "mainTemplateSha256": plan["mainTemplateSha256"], + "parametersSha256": plan["parametersSha256"], + } + )[:32] + planned_members.append( + { + "solution": member["solution"], + "solutionPath": member["solutionPath"], + "runId": member["runId"], + "status": "ready", + "packagePlanSha256": plan["packagePlanSha256"], + "packageIdentity": plan["packageIdentity"], + "bicepExtensibilityImports": plan.get("bicepExtensibilityImports") or [], + "mainTemplatePath": plan["mainTemplatePath"], + "mainTemplateSha256": plan["mainTemplateSha256"], + "mainTemplateSnapshotPath": plan["mainTemplateSnapshotPath"], + "mainTemplateSnapshotSha256": plan["mainTemplateSnapshotSha256"], + "validationReceipt": plan["validationReceipt"], + "declaredContentFlags": plan["declaredContentFlags"], + "parameters": parameters, + "parametersSha256": plan["parametersSha256"], + "deploymentName": deployment_name, + } + ) + batch: dict[str, Any] = { + "schemaVersion": 1, + "stage": "post-validation-arm-template-deployment-acceptance", + "status": "ready", + "qualificationOnly": True, + "target": {key: first_target[key] for key in _TARGET_KEYS}, + "sharedDecisionReceipt": receipt_binding, + "members": planned_members, + "writeCount": len(planned_members), + "deploymentRoute": "Azure CLI az deployment group create", + "deploymentMode": "Incremental", + "acceptanceCriterion": ( + "every submitted member has a recorded terminal ARM deployment provisioningState Succeeded" + ), + "resourceReadbackRequired": False, + "installationVerified": False, + "activationPerformed": False, + "alertParityPerformed": False, + "qualificationCompleted": False, + "planningConsentIsNotWriteApproval": True, + "environmentWritesAuthorized": False, + "confirmation": _BATCH_ARM_ACCEPTANCE_CONFIRMATION, + "noAutomaticRetryAfterAttempt": True, + } + batch["batchPlanSha256"] = _canonical_sha256(batch) + return batch + + +def plan_arm_template_acceptance_batch( + members: list[dict[str, str]], + *, + shared_decisions_path: str | Path, + validation_receipts: list[str | Path], +) -> dict[str, Any]: + batch = _arm_acceptance_batch_plan( + members, + shared_decisions_path=shared_decisions_path, + validation_receipts=validation_receipts, + ) + first = _validated_members(members)[0] + root = Path(first["solutionPath"]) + with using_run(root, first["runId"]): + path = artifact_path( + root, + ARM_ACCEPTANCE_BATCH_PLAN, + create_parent=True, + ) + write_json_artifact(root, path, batch) + return {**batch, "planPath": str(path)} + + +def _reserve_attempt(path: Path, value: dict[str, Any]) -> None: + if path.is_symlink(): + raise ValueError("ARM acceptance receipt path cannot be a symlink") + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + try: + payload = (json.dumps(value, indent=2) + "\n").encode("utf-8") + os.write(descriptor, payload) + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _member_attempt_path(item: dict[str, Any]) -> Path: + root = Path(item["solutionPath"]) + with using_run(root, item["runId"]): + return artifact_path( + root, + f"{ARM_ACCEPTANCE_ATTEMPT_PREFIX}{item['deploymentName']}.json", + create_parent=True, + ) + + +def _error_status(write_state: str | None) -> str: + if write_state in {"failed", "canceled", "cancelled", "deleted"}: + return "failed" + if write_state: + return "nonterminal" + return "unknown" + + +def _recorded_provisioning_state(value: Any) -> str | None: + if not isinstance(value, dict): + return None + properties = value.get("properties") + if isinstance(properties, dict) and isinstance(properties.get("provisioningState"), str): + return properties["provisioningState"] + state = value.get("provisioningState") + return state if isinstance(state, str) else None + + +def execute_arm_template_acceptance_batch( + members: list[dict[str, str]], + *, + expected_batch_plan_sha256: str, + shared_decisions_path: str | Path, + validation_receipts: list[str | Path], + actor: str, + confirmation: str, + state_dir: str | Path | None = None, + command_runner: CommandRunner | None = None, +) -> dict[str, Any]: + batch = _arm_acceptance_batch_plan( + members, + shared_decisions_path=shared_decisions_path, + validation_receipts=validation_receipts, + ) + if batch.get("status") != "ready": + raise ValueError("ARM template acceptance batch is not ready") + if ( + not isinstance(expected_batch_plan_sha256, str) + or not _SHA256.fullmatch(expected_batch_plan_sha256) + or batch.get("batchPlanSha256") != expected_batch_plan_sha256 + or confirmation != _BATCH_ARM_ACCEPTANCE_CONFIRMATION + ): + raise ValueError( + "exact enumerated ARM template acceptance batch plan/confirmation does not match" + ) + if not isinstance(actor, str) or not actor.strip(): + raise ValueError("ARM template acceptance requires an explicit approval actor") + normalized = _validated_members(members) + planned_by_member = { + (item["solution"], item["runId"]): item for item in batch["members"] + } + attempt_paths: dict[tuple[str, str], Path] = {} + for member in normalized: + planned = planned_by_member[(member["solution"], member["runId"])] + attempt_paths[(member["solution"], member["runId"])] = _member_attempt_path(planned) + reservation_order = sorted( + batch["members"], + key=lambda item: (item["deploymentName"], item["solution"], item["runId"]), + ) + canonical_first = reservation_order[0] + first_root = Path(canonical_first["solutionPath"]) + first_run_id = canonical_first["runId"] + with using_run(first_root, first_run_id): + report_path = artifact_path( + first_root, + ARM_ACCEPTANCE_BATCH_REPORT, + create_parent=True, + ) + existing_batch = existing_artifact_path(first_root, ARM_ACCEPTANCE_BATCH_REPORT) + if existing_batch.exists() or existing_batch.is_symlink(): + raise ValueError( + "this ARM acceptance batch already has a durable attempt/receipt; reconcile it before any new write" + ) + existing_attempts = [ + path + for path in attempt_paths.values() + if path.exists() or path.is_symlink() + ] + if existing_attempts: + raise ValueError( + "an ARM acceptance attempt already exists for a selected deployment identity; reconcile its durable outcome before any retry" + ) + + account_check = _verify_existing_cli_target( + batch["target"], + state_dir=state_dir, + runner=command_runner, + ) + if not ( + account_check.get("statusCode") == 0 + and account_check.get("tenantMatches") is True + and account_check.get("subscriptionMatches") is True + ): + return { + "schemaVersion": 1, + "stage": batch["stage"], + "status": "blocked", + "target": batch["target"], + "accountCheck": account_check, + "writeAttempted": False, + "environmentWritesAuthorized": False, + "message": ( + account_check.get("error") + or "existing Azure CLI account does not match the locked tenant and subscription; no login was attempted" + ), + } + + current = _arm_acceptance_batch_plan( + members, + shared_decisions_path=shared_decisions_path, + validation_receipts=validation_receipts, + ) + if current.get("batchPlanSha256") != expected_batch_plan_sha256: + raise ValueError( + "package, target, shared decisions, parameters, or V3.1 artifact changed after approval; no deployment attempted" + ) + report: dict[str, Any] = { + "schemaVersion": 1, + "stage": batch["stage"], + "status": "submitting", + "batchPlanSha256": batch["batchPlanSha256"], + "target": batch["target"], + "sharedDecisionReceipt": batch["sharedDecisionReceipt"], + "actor": actor.strip(), + "approvedAt": _utc_now(), + "confirmation": confirmation, + "plannedWrites": [ + { + "solution": item["solution"], + "runId": item["runId"], + "target": batch["target"], + "deploymentName": item["deploymentName"], + "mainTemplateSha256": item["mainTemplateSha256"], + "mainTemplateSnapshotSha256": item["mainTemplateSnapshotSha256"], + "parametersSha256": item["parametersSha256"], + "validationReceiptSha256": item["validationReceipt"]["sha256"], + } + for item in batch["members"] + ], + "members": [ + { + **item, + "status": "pending", + "deploymentId": None, + "provisioningState": None, + "commandExitCode": None, + "error": None, + "submittedParameters": None, + "submittedMainTemplateSnapshotSha256": None, + } + for item in batch["members"] + ], + "resourceReadbackPerformed": False, + "installationVerified": False, + "activationPerformed": False, + "alertParityPerformed": False, + "qualificationCompleted": False, + "retryAuthorized": False, + "environmentWritesAuthorized": False, + } + with using_run(first_root, first_run_id): + _reserve_attempt( + report_path, + { + "schemaVersion": 1, + "stage": batch["stage"], + "status": "intent-reserved", + "batchPlanSha256": batch["batchPlanSha256"], + "deploymentIdentities": [ + { + "solution": item["solution"], + "runId": item["runId"], + "target": batch["target"], + "deploymentName": item["deploymentName"], + } + for item in reservation_order + ], + "writeAttempted": False, + }, + ) + reserved_members: set[tuple[str, str]] = set() + + def persist() -> None: + report["updatedAt"] = _utc_now() + with using_run(first_root, first_run_id): + write_json_artifact(first_root, report_path, report) + for member in report["members"]: + key = (member["solution"], member["runId"]) + if key not in reserved_members: + continue + path = attempt_paths[key] + with using_run(Path(member["solutionPath"]), member["runId"]): + write_json_artifact( + Path(member["solutionPath"]), + path, + { + "schemaVersion": 1, + "stage": batch["stage"], + "batchPlanSha256": report["batchPlanSha256"], + "target": report["target"], + "member": member, + "updatedAt": report["updatedAt"], + }, + ) + + for item in reservation_order: + key = (item["solution"], item["runId"]) + member_record = next( + entry + for entry in report["members"] + if (entry["solution"], entry["runId"]) == key + ) + try: + _reserve_attempt( + attempt_paths[key], + { + "schemaVersion": 1, + "stage": batch["stage"], + "status": "intent-reserved", + "batchPlanSha256": batch["batchPlanSha256"], + "target": batch["target"], + "deploymentName": item["deploymentName"], + "mainTemplateSha256": item["mainTemplateSha256"], + "mainTemplateSnapshotSha256": item["mainTemplateSnapshotSha256"], + "parametersSha256": item["parametersSha256"], + "validationReceiptSha256": item["validationReceipt"]["sha256"], + "writeAttempted": False, + }, + ) + reserved_members.add(key) + member_record["status"] = "intent-reserved" + except FileExistsError: + member_record["status"] = "reservation-conflict" + member_record["error"] = ( + "a durable attempt already exists for this deployment identity; no member in this batch was submitted" + ) + report["status"] = "partial-reservation" + report["message"] = member_record["error"] + for pending in report["members"]: + if pending["status"] == "pending": + pending["status"] = "not-reserved" + persist() + return {**report, "reportPath": str(report_path)} + report["status"] = "submitting" + for entry in report["members"]: + entry["status"] = "intent-reserved" + persist() + + from .graph_client_config import azure_cli_profile, configured_azure_cli_profile + + profile = configured_azure_cli_profile(_state_dir(state_dir)) + for index, item in enumerate(normalized): + current = _arm_acceptance_batch_plan( + members, + shared_decisions_path=shared_decisions_path, + validation_receipts=validation_receipts, + ) + if current.get("batchPlanSha256") != expected_batch_plan_sha256: + entry = report["members"][index] + entry["status"] = "blocked-before-write" + entry["error"] = ( + "approved package, target, shared decisions, parameters, or V3.1 artifact changed before this member's ARM write" + ) + report["status"] = ( + "partial" + if any( + str(member.get("provisioningState") or "").casefold() == "succeeded" + for member in report["members"] + ) + else "blocked" + ) + report["message"] = entry["error"] + persist() + return {**report, "reportPath": str(report_path)} + + member_plan = next( + value + for value in current["members"] + if value["solution"] == item["solution"] + and value["runId"] == item["runId"] + ) + parameters = [ + f"{name}={'true' if value is True else 'false' if value is False else value}" + for name, value in sorted(member_plan["parameters"].items()) + ] + snapshot_path = Path(member_plan["mainTemplateSnapshotPath"]) + if ( + snapshot_path.is_symlink() + or not snapshot_path.is_file() + or hashlib.sha256(snapshot_path.read_bytes()).hexdigest() + != member_plan["mainTemplateSnapshotSha256"] + ): + entry = report["members"][index] + entry["status"] = "blocked-before-write" + entry["error"] = "approved ARM template snapshot is missing, unsafe, or has changed" + report["status"] = ( + "partial" + if any( + str(member.get("provisioningState") or "").casefold() == "succeeded" + for member in report["members"] + ) + else "blocked" + ) + report["message"] = entry["error"] + persist() + return {**report, "reportPath": str(report_path)} + command = [ + "deployment", + "group", + "create", + "--name", + member_plan["deploymentName"], + "--subscription", + batch["target"]["subscriptionId"], + "--resource-group", + batch["target"]["resourceGroup"], + "--template-file", + str(snapshot_path), + "--mode", + "Incremental", + "--parameters", + *parameters, + "--output", + "json", + "--only-show-errors", + ] + entry = report["members"][index] + entry["status"] = "write-intent-persisted" + entry["writeIntentAt"] = _utc_now() + entry["submittedParameters"] = parameters + entry["submittedMainTemplateSnapshotSha256"] = hashlib.sha256( + snapshot_path.read_bytes() + ).hexdigest() + persist() + with azure_cli_profile(profile): + exit_code, deployment, error = _run_cli( + command, + runner=command_runner, + ) + state = _deployment_provisioning_state(deployment) + recorded_state = _recorded_provisioning_state(deployment) + deployment_id = ( + deployment.get("id") + if isinstance(deployment, dict) and isinstance(deployment.get("id"), str) + else None + ) + entry.update( + { + "status": "accepted" if state == "succeeded" else _error_status(state), + "deploymentId": deployment_id, + "provisioningState": recorded_state, + "normalizedProvisioningState": state, + "commandExitCode": exit_code, + "error": error or None, + "responseRecordedAt": _utc_now(), + } + ) + persist() + if state != "succeeded": + succeeded_before = any( + str(member.get("provisioningState") or "").casefold() == "succeeded" + for member in report["members"][: index + 1] + ) + outcome = _error_status(state) + report["status"] = ( + f"partial-{outcome}" if succeeded_before else outcome + ) + report["message"] = ( + "ARM deployment did not return terminal provisioningState Succeeded; " + "the remaining batch was not submitted and this attempt must not be retried blindly" + ) + persist() + return {**report, "reportPath": str(report_path)} + + report["status"] = "accepted" + report["completedAt"] = _utc_now() + report["message"] = ( + "Every enumerated Azure Resource Manager deployment returned provisioningState Succeeded. " + "This receipt does not verify installed content, enable detections, establish parity, or complete Qualification." + ) + persist() + return {**report, "reportPath": str(report_path)} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_acceptance_support.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_acceptance_support.py new file mode 100644 index 00000000000..cc32c1db45e --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_acceptance_support.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import hashlib +import json +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any, Callable + +from .onboarding import _state_dir + +CommandRunner = Callable[..., subprocess.CompletedProcess[str]] + + +def _canonical_sha256(value: Any) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _parameter_values( + template: dict[str, Any], + workspace_name: str, + approved_flags: dict[str, bool], +) -> dict[str, Any]: + values: dict[str, Any] = {} + parameters = template.get("parameters") or {} + for name, definition in parameters.items(): + if name in approved_flags: + values[name] = True + elif name == "workspace": + values[name] = workspace_name + elif "defaultValue" in definition: + continue + else: + raise ValueError( + f"package parameter {name} has no default and no supported locked-target binding" + ) + if set(approved_flags) != { + name for name in parameters if name.startswith("Deploy") + }: + raise ValueError( + "approved package flags do not exactly match declared Deploy* parameters" + ) + return values + + +def _run_cli( + command: list[str], + *, + runner: CommandRunner | None = None, +) -> tuple[int, Any, str]: + run = runner or subprocess.run + executable = shutil.which("az") + if executable is None: + return 127, None, "Azure CLI is not installed or not available on PATH" + try: + result = run( + [executable, *command], + capture_output=True, + text=True, + check=False, + timeout=1800, + ) + except subprocess.TimeoutExpired: + return ( + 124, + None, + "Azure CLI command timed out after 1800 seconds; write outcome may be ambiguous", + ) + except (OSError, subprocess.SubprocessError) as exc: + return ( + 126, + None, + f"Azure CLI execution failed ({type(exc).__name__}); no retry was attempted", + ) + except Exception as exc: + return ( + 126, + None, + f"Azure CLI runner failed ({type(exc).__name__}); no retry was attempted", + ) + parsed: Any = None + if result.stdout: + try: + parsed = json.loads(result.stdout) + except json.JSONDecodeError: + parsed = None + error = str(result.stderr or "").strip() + if result.returncode and not error: + error = f"Azure CLI exited with code {result.returncode}" + return result.returncode, parsed, error[:4000] + + +def _verify_existing_cli_target( + target: dict[str, Any], + *, + state_dir: str | Path | None, + runner: CommandRunner | None = None, +) -> dict[str, Any]: + from .graph_client_config import azure_cli_profile, configured_azure_cli_profile + + profile = configured_azure_cli_profile(_state_dir(state_dir)) + with azure_cli_profile(profile): + status, account, error = _run_cli( + [ + "account", + "show", + "--subscription", + target["subscriptionId"], + "--output", + "json", + "--only-show-errors", + ], + runner=runner, + ) + return { + "statusCode": status, + "tenantMatches": ( + isinstance(account, dict) + and str(account.get("tenantId") or "").casefold() + == str(target["tenantId"]).casefold() + ), + "subscriptionMatches": ( + isinstance(account, dict) + and str(account.get("id") or "").casefold() + == str(target["subscriptionId"]).casefold() + ), + "error": error or None, + } + + +def _deployment_provisioning_state(value: Any) -> str | None: + if not isinstance(value, dict): + return None + properties = value.get("properties") + if isinstance(properties, dict): + state = properties.get("provisioningState") + if isinstance(state, str): + return state.casefold() + state = value.get("provisioningState") + return state.casefold() if isinstance(state, str) else None + + +def _validated_members(members: list[dict[str, str]]) -> list[dict[str, str]]: + if not isinstance(members, list) or not members: + raise ValueError("at least one exact solution/run member is required") + normalized: list[dict[str, str]] = [] + seen: set[Path] = set() + seen_names: set[str] = set() + for item in members: + if ( + not isinstance(item, dict) + or not isinstance(item.get("solution"), str) + or not item["solution"].strip() + or not isinstance(item.get("runId"), str) + or not item["runId"].strip() + ): + raise ValueError( + "each batch member requires an exact solution path and run ID" + ) + candidate = Path(item["solution"]).expanduser() + if candidate.is_symlink(): + raise ValueError("batch solution paths cannot be symlinks") + root = candidate.resolve(strict=True) + if not root.is_dir() or root in seen or root.name in seen_names: + raise ValueError( + "batch solution paths must be existing, unique solution directories" + ) + seen.add(root) + seen_names.add(root.name) + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,95}", item["runId"]): + raise ValueError("batch member run ID is invalid") + normalized.append( + { + "solution": root.name, + "solutionPath": str(root), + "runId": item["runId"], + } + ) + return normalized + + +def _validated_shared_decisions( + shared_decisions_path: str | Path, + target: dict[str, Any], + members: list[dict[str, str]], +) -> tuple[dict[str, Any], dict[str, Any]]: + source = Path(shared_decisions_path).expanduser() + if source.is_symlink(): + raise ValueError( + "shared PR/lab decisions must be a regular non-symlink file" + ) + source = source.resolve(strict=True) + try: + receipt = json.loads(source.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError( + f"shared PR/lab decision receipt is unreadable: {exc}" + ) from exc + if not isinstance(receipt, dict): + raise ValueError("shared PR/lab decision receipt must be a JSON object") + scope = receipt.get("scope") + decisions = receipt.get("decisions") + boundary = receipt.get("authorizationBoundary") + if not all(isinstance(value, dict) for value in (scope, decisions, boundary)): + raise ValueError("shared PR/lab decision receipt scope is malformed") + expected_solutions = scope.get("solutions") + actual_solutions = [item["solution"] for item in members] + if ( + receipt.get("status") != "User-confirmed shared decisions" + or not isinstance(receipt.get("userConfirmation"), str) + or not receipt["userConfirmation"].strip() + or not isinstance(expected_solutions, list) + or any(not isinstance(value, str) or not value for value in expected_solutions) + or len(set(expected_solutions)) != len(expected_solutions) + or set(expected_solutions) != set(actual_solutions) + ): + raise ValueError( + "batch members must exactly match the solution set in the user-confirmed shared decision receipt" + ) + expected_target = { + "tenantId": target["tenantId"], + "subscriptionId": target["subscriptionId"], + "resourceGroup": target["resourceGroup"], + "workspaceResourceId": target["workspaceResourceId"], + "workspaceCustomerId": target["workspaceCustomerId"], + } + for key, value in expected_target.items(): + if str(scope.get(key) or "").casefold() != str(value or "").casefold(): + raise ValueError( + f"shared decision receipt target does not match locked {key}" + ) + known_flags = decisions.get("knownFlagSelections") + if not isinstance(known_flags, dict): + known_flags = {} + if ( + decisions.get("deploymentRoute") != "Full-package installation" + or "all declared content" + not in str(decisions.get("labTestingContentSelection") or "").casefold() + or not known_flags + or any(type(value) is not bool or value is not True for value in known_flags.values()) + or decisions.get("askContentTypeSelectionAgain") is not False + or decisions.get("askDeploymentRouteAgain") is not False + or decisions.get("askTacticProjectionPolicyAgain") is not False + ): + raise ValueError( + "shared decisions do not authorize the current all-content full-package route" + ) + if ( + boundary.get("environmentWritesAuthorized") is not False + or boundary.get("immediateExactScopeWriteApprovalRequired") is not True + or boundary.get("oneApprovalMayCoverAnEnumeratedMultiSolutionWriteBatch") is not True + or boundary.get("unsupportedExecutionRouteAuthorized") is not False + ): + raise ValueError( + "shared decision receipt has a broadened or invalid write-approval boundary" + ) + return receipt, { + "path": str(source), + "sha256": _sha256(source), + "scope": expected_target, + "solutions": sorted(expected_solutions), + "prNumber": scope.get("prNumber"), + "prIdentityNote": scope.get("prIdentityNote"), + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_client.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_client.py new file mode 100644 index 00000000000..ba4721abd0c --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/arm_client.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +from .graph_client_config import azure_cli_credential +from .onboarding import _state_dir + +ARM_SCOPE = "https://management.azure.com/.default" +SENTINEL_ALERT_RULE_API_VERSION = "2024-03-01" + + +def _arm_token(state_dir: str | Path | None = None) -> str: + return azure_cli_credential( + state_root=_state_dir(state_dir) + ).get_token(ARM_SCOPE).token + + +def _arm_request( + method: str, + url: str, + token: str, + body: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + request = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8") if body is not None else None, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=90) as response: + content = response.read().decode("utf-8") + return response.status, json.loads(content) if content else {} + except urllib.error.HTTPError as exc: + content = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + parsed = {"error": {"message": content[:4000]}} + return exc.code, parsed + + +def _sentinel_rule_url(workspace_resource_id: str, rule_id: str) -> str: + workspace = workspace_resource_id.rstrip("/") + encoded_id = urllib.parse.quote(rule_id, safe="") + return ( + "https://management.azure.com" + f"{workspace}/providers/Microsoft.SecurityInsights/alertRules/{encoded_id}" + f"?api-version={SENTINEL_ALERT_RULE_API_VERSION}" + ) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/artifacts.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/artifacts.py new file mode 100644 index 00000000000..249df18407f --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/artifacts.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import warnings +import uuid +from contextlib import contextmanager +from contextvars import ContextVar +from datetime import datetime, timezone +from pathlib import Path, PureWindowsPath +from typing import Any + +import yaml + +REPORT_FOLDER_NAME = "sentinel-xdr-migration" +MIGRATION_RECEIPT = "legacy-artifacts.json" +RUN_MARKER = "run.json" +_RUN_SELECTION: ContextVar[tuple[Path, str] | None] = ContextVar("migration_run", default=None) + + +def reports_base(solution: str | Path) -> Path: + root = Path(solution).expanduser().resolve() + base = root / "Logs" / REPORT_FOLDER_NAME + if (root / "Logs").is_symlink() or base.is_symlink(): + raise ValueError("symlink report directories are not supported") + return base + + +def _validate_run_id(run_id: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,95}", run_id): + raise ValueError("run ID must be a single safe directory name (1-96 characters)") + return run_id + + +def new_run_id() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + uuid.uuid4().hex + + +def select_run(solution: str | Path, run_id: str | None): + selection = (Path(solution).expanduser().resolve(), _validate_run_id(run_id)) if run_id else None + return _RUN_SELECTION.set(selection) + + +def reset_run_selection(token) -> None: + _RUN_SELECTION.reset(token) + + +@contextmanager +def using_run(solution: str | Path, run_id: str | None): + token = select_run(solution, run_id) + try: + yield + finally: + reset_run_selection(token) + + +def requested_run_id(solution: str | Path) -> str | None: + selected = _RUN_SELECTION.get() + return selected[1] if selected and selected[0] == Path(solution).expanduser().resolve() else None + + +def list_runs(solution: str | Path) -> list[str]: + base = reports_base(solution) + runs = [] + for path in sorted(base.iterdir()) if base.exists() else []: + if not path.is_dir(): + continue + if not (path / RUN_MARKER).exists(): + state_path = path / "workflow-state.json" + state_run = ( + json.loads(state_path.read_text(encoding="utf-8")).get("runId") + if state_path.is_file() else None + ) + if state_run == path.name or re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{32}", path.name): + raise ValueError(f"migration run marker is missing: {path.name}; restore it before resuming") + continue + if path.is_symlink() or (path / RUN_MARKER).is_symlink(): + raise ValueError("symlink run directories are not supported") + marker = json.loads((path / RUN_MARKER).read_text(encoding="utf-8")) + if marker.get("runId") != _validate_run_id(path.name): + raise ValueError(f"invalid run identity: {path.name}") + runs.append(path.name) + return runs + + +def _selected_run_id(solution: str | Path) -> str | None: + selected = requested_run_id(solution) + runs = list_runs(solution) + if selected: + return selected + if len(runs) > 1: + raise ValueError("multiple migration runs; specify --run-id: " + ", ".join(runs)) + return runs[0] if runs else None + + +def create_run(solution: str | Path, *, run_id: str | None = None, migrating: bool = False) -> Path: + if not migrating and _unmigrated(solution): + raise ValueError("legacy migration artifacts exist; run migrate-reports --apply before creating a run") + path = reports_base(solution) / _validate_run_id(run_id or new_run_id()) + path.parent.mkdir(parents=True, exist_ok=True) + path.mkdir() # Never overwrite or reuse an unrelated directory. + (path / RUN_MARKER).write_text(json.dumps({ + "runId": path.name, "createdAt": datetime.now(timezone.utc).isoformat(), + }, indent=2) + "\n", encoding="utf-8") + return path + + +def solution_for_artifact(path: Path) -> Path: + for parent in path.parents: + if parent.name == REPORT_FOLDER_NAME and parent.parent.name == "Logs": + return parent.parent.parent + raise ValueError("artifact is not inside a solution migration Logs directory") + + +def repository_root(solution: str | Path) -> Path: + root = Path(solution).expanduser().resolve() + return root.parent.parent if root.parent.name.lower() == "solutions" else root.parent + + +def legacy_directories(solution: str | Path) -> list[Path]: + root = Path(solution).expanduser().resolve() + directories = [repository_root(root) / "Reports" / root.name / REPORT_FOLDER_NAME] + configured = os.getenv("AZURE_SENTINEL_REPORTS_ROOT") + if configured: + directories.insert(0, Path(configured).expanduser().resolve() / root.name / REPORT_FOLDER_NAME) + directories.append(root / "XDR Detections") + return list(dict.fromkeys(directories)) + + +def _legacy_files(solution: str | Path) -> dict[str, list[Path]]: + files: dict[str, list[Path]] = {} + for directory in legacy_directories(solution): + if directory.is_symlink(): + raise ValueError("symlink legacy report directories are not supported") + for path in sorted(directory.rglob("*")) if directory.exists() else []: + if path.is_symlink(): + raise ValueError("symlink legacy reports are not supported") + if not path.is_file(): + continue + if directory.name == "XDR Detections": + if path.suffix.lower() in {".yaml", ".yml"} and path.name.lower() not in { + "migration-config.yaml", "migration-config.yml" + }: + continue + files.setdefault(path.relative_to(directory).as_posix(), []).append(path) + # A previous flat-Logs migration already superseded some root Reports files. + # Verify that ledger before treating the newer flat state as authoritative. + base = reports_base(solution) + flat_receipt = base / MIGRATION_RECEIPT + if flat_receipt.exists(): + receipt = json.loads(flat_receipt.read_text(encoding="utf-8")) + for name in list(files): + if name in receipt: + if sorted(_digest(path) for path in files[name]) != receipt[name]: + raise ValueError(f"legacy artifact changed after migration: {name}; reconcile explicitly") + if not (base / name).is_file(): + raise ValueError(f"migrated flat artifact is missing: {name}") + del files[name] + runs = set(list_runs(solution)) + for path in sorted(base.rglob("*")) if base.exists() else []: + relative = path.relative_to(base) + if relative.parts[0] in runs or relative.as_posix() == MIGRATION_RECEIPT: + continue + if path.is_symlink(): + raise ValueError("symlink legacy reports are not supported") + if path.is_file(): + files.setdefault(relative.as_posix(), []).append(path) + return files + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _unmigrated(solution: str | Path) -> dict[str, list[Path]]: + remaining = _legacy_files(solution) + # Legacy artifacts belong only to the run that explicitly imported them. + for run_id in list_runs(solution): + destination = reports_base(solution) / run_id + receipt_path = destination / MIGRATION_RECEIPT + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) if receipt_path.exists() else {} + for name in list(remaining): + if name not in receipt: + continue + if sorted(_digest(path) for path in remaining[name]) != receipt[name]: + raise ValueError(f"legacy artifact changed after migration: {name}; reconcile explicitly") + if not (destination / name).is_file(): + raise ValueError(f"migrated artifact is missing: {name}; restore it before resuming") + del remaining[name] + return remaining + + +def report_directory(solution: str | Path, *, create: bool = False) -> Path: + root = Path(solution).expanduser().resolve() + base = reports_base(root) + selected = _selected_run_id(root) + if selected and selected not in list_runs(root): + raise ValueError(f"migration run does not exist: {selected}; initialize a new workflow or migrate reports") + path = base / selected if selected else base + if create: + if _unmigrated(root): + raise ValueError( + "legacy migration artifacts exist; run migrate-reports, review the plan, " + "then migrate-reports --apply before writing solution Logs" + ) + if selected is None: + path = create_run(root) + return path + + +def artifact_path(solution: str | Path, name: str, *, create_parent: bool = False) -> Path: + from .content_paths import content_path + + return content_path(report_directory(solution, create=create_parent), name) + + +def existing_artifact_path(solution: str | Path, name: str) -> Path: + preferred = artifact_path(solution, name) + legacy = _unmigrated(solution).get(name, []) + candidates = list(dict.fromkeys(([preferred] if preferred.exists() else []) + legacy)) + if len({_digest(path) for path in candidates}) > 1: + raise ValueError(f"conflicting migration artifacts: {name}; reconcile explicitly") + if legacy: + warnings.warn( + f"reading legacy artifact {name}; run migrate-reports --apply before resuming writes", + UserWarning, + stacklevel=2, + ) + return candidates[0] if candidates else preferred + + +def existing_artifacts(solution: str | Path, pattern: str) -> list[Path]: + from fnmatch import fnmatch + + names = set(_unmigrated(solution)) + names.update(path.name for path in report_directory(solution).glob(pattern)) + return [existing_artifact_path(solution, name) for name in sorted(names) if fnmatch(name, pattern)] + + +def conversion_scope(solution: str | Path) -> dict[str, Any]: + path = existing_artifact_path(solution, "manifest.json") + if not path.is_file(): + return {} + manifest = json.loads(path.read_text(encoding="utf-8-sig")) + scope = manifest.get("scope", {}) + if not isinstance(scope, dict): + raise ValueError("conversion manifest scope must be an object") + return scope + + +def require_solution_conversion_scope(solution: str | Path) -> None: + scope = conversion_scope(solution) + if scope and scope.get("kind") != "solution": + raise ValueError( + "rule-scoped conversion is not full-solution readiness; run full-solution " + "conversion and validation before completing solution stages or packaging" + ) + + +def migrate_legacy_artifact(solution: str | Path, name: str) -> Path: + """Compatibility entry point for writers; migration now requires explicit approval.""" + return artifact_path(solution, name, create_parent=True) + + +def resolve_artifact_reference(solution: str | Path, value: str | Path) -> Path: + """New persisted references are solution-relative; accept old absolute/repo references.""" + root = Path(solution).expanduser().resolve() + text = str(value).replace("\\", "/") + path = Path(text).expanduser() + if path.is_absolute(): + base = reports_base(root) + if path.is_relative_to(base): + relative = path.relative_to(base) + if relative.parts and relative.parts[0] in list_runs(root): + if relative.parts[0] != _selected_run_id(root): + raise ValueError("artifact belongs to a different migration run") + return path + return existing_artifact_path(root, relative.as_posix()) + for directory in legacy_directories(root): + if path.is_relative_to(directory): + name = path.relative_to(directory).as_posix() + if directory.name != "XDR Detections" or name in _legacy_files(root): + return existing_artifact_path(root, name) + return path + if PureWindowsPath(text).drive: + raise ValueError("foreign absolute artifact path; migrate reports on the original host first") + if text.startswith("Solutions/"): + return resolve_artifact_reference(root, repository_root(root) / path) + prefix = f"Reports/{root.name}/{REPORT_FOLDER_NAME}/" + if text.startswith(prefix): + return existing_artifact_path(root, text[len(prefix):]) + if text.startswith(f"Logs/{REPORT_FOLDER_NAME}/"): + return resolve_artifact_reference(root, root / path) + return root / path + + +_SECRET_KEY = re.compile( + r"^(?:authorization|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"client[_-]?secret|password|accountkey|sharedaccesssignature|sas[_-]?token)$", re.I +) +_SECRET_TEXT = re.compile( + r"(?i)(\bBearer\s+)[A-Za-z0-9._~+/\-=]+|" + r"(\b(?:access_token|refresh_token|client_secret|AccountKey|SharedAccessSignature)" + r"\s*[=:]\s*[\"']?)[^\"'\s;&]+|" + r"([?&](?:sig|signature|token|access_token|code|se|sp|sv|sr|st|skoid|sktid|skt|ske|sks|skv)=)[^&\s\"'<>]+" +) + + +def portable_artifact(solution: str | Path, value: Any, *, destination: Path | None = None) -> Any: + """Normalize known artifact references; never rewrite queries or provider error text.""" + root = Path(solution).expanduser().resolve() + repository = repository_root(root) + legacy_names = set(_legacy_files(root)) + destination = destination or report_directory(root) + destination_reference = destination.relative_to(root).as_posix() + run_names = set(list_runs(root)) + if destination != reports_base(root): + run_names.add(destination.name) + + path_fields = { + "solution", "solutionPath", "output", "outputFile", "file", "sourceFile", "sourceRule", + "outputDirectory", "reportDirectory", "manifest", "transformationReport", + "jsonReport", "htmlReport", "reportPath", "statePath", "targetPath", + "packageReport", "mainTemplate", "createUiDefinition", "testParameters", "zip", + "solutionData", "contract", "payload", "deploymentReport", "analyticDeploymentReport", + "queryParityReport", "alertParityReport", + } + reference_suffixes = {".json", ".yaml", ".yml", ".zip", ".csv", ".log", ".md", ".txt"} + literal_containers = { + "queries", "querycondition", "error", "errors", "errordetails", + "providererror", "providererrors", "exception", "diagnostic", + "diagnostics", + } + literal_fields = { + "query", "querytext", "kql", "sourcequery", "targetquery", + "message", "errormessage", "diagnostictext", + } + + def looks_like_reference(text: str) -> bool: + stripped = text.strip() + if ( + not stripped + or "\n" in stripped + or "|" in stripped + or "://" in stripped + or "?" in stripped + or "#" in stripped + ): + return False + return ( + "/" in stripped + or "\\" in stripped + or Path(stripped).suffix.lower() in reference_suffixes + or PureWindowsPath(stripped).suffix.lower() in reference_suffixes + ) + + def is_source_reference(text: str) -> bool: + stripped = text.strip() + if not stripped or any(marker in stripped for marker in ("\n", "|", "://", "?", "#")): + return False + normalized = stripped.replace("\\", "/") + known_roots = ( + root.as_posix() + "/", + repository.as_posix() + "/", + *(legacy.as_posix() + "/" for legacy in legacy_directories(root)), + ) + return ( + Path(stripped).suffix.lower() in reference_suffixes + or PureWindowsPath(stripped).suffix.lower() in reference_suffixes + or normalized == root.as_posix() + or normalized.startswith(known_roots) + ) + + def visit( + item: Any, + key: str = "", + references: bool = False, + parent_key: str = "", + literal_text: bool = False, + ) -> Any: + if isinstance(item, dict): + child_literal_text = literal_text or key.casefold() in literal_containers + return { + name: "[REDACTED]" if _SECRET_KEY.fullmatch(str(name)) else visit( + child, + str(name), + references or key in {"artifacts", "workflowArtifacts"}, + key, + child_literal_text, + ) + for name, child in item.items() + } + if isinstance(item, (list, tuple)): + return [ + visit( + child, + key, + references or key == "evidence", + parent_key, + literal_text, + ) + for child in item + ] + if not isinstance(item, str): + return item + text = item + literal_value = ( + literal_text + or key.casefold() in literal_fields + or parent_key.casefold() in literal_containers + ) + source_path = ( + key == "source" + and not literal_value + and is_source_reference(text) + ) + if not literal_value and ( + key in path_fields or source_path or (references and looks_like_reference(text)) + ): + normalized = text.replace("\\", "/") + for legacy in legacy_directories(root)[:-1]: + prefix = legacy.as_posix() + "/" + if normalized.startswith(prefix): + normalized = destination_reference + "/" + normalized[len(prefix):] + xdr_prefix = (root / "XDR Detections").as_posix() + "/" + if normalized.startswith(xdr_prefix) and normalized[len(xdr_prefix):] in legacy_names: + normalized = destination_reference + "/" + normalized[len(xdr_prefix):] + if normalized == root.as_posix(): + normalized = "." + elif normalized.startswith(root.as_posix() + "/"): + normalized = normalized[len(root.as_posix()) + 1:] + elif normalized.startswith(repository.as_posix() + "/"): + normalized = os.path.relpath(Path(normalized), root).replace("\\", "/") + if normalized == f"Logs/{REPORT_FOLDER_NAME}": + normalized = destination_reference + legacy_prefixes = ( + f"Logs/{REPORT_FOLDER_NAME}/", + f"Reports/{root.name}/{REPORT_FOLDER_NAME}/", + f"../../Reports/{root.name}/{REPORT_FOLDER_NAME}/", + ) + for prefix in legacy_prefixes: + if normalized.startswith(prefix): + tail = normalized[len(prefix):] + if tail.split("/")[0] not in run_names: + normalized = destination_reference + "/" + tail + break + text = normalized + text = _SECRET_TEXT.sub(lambda match: next(group for group in match.groups() if group) + "[REDACTED]", text) + return text + + return visit(value) + + +def write_json_artifact(solution: str | Path, path: Path, value: Any) -> None: + destination = report_directory(solution, create=True) + if not path.is_relative_to(destination): + raise ValueError("artifact write must belong to the selected migration run") + path.write_text(json.dumps(portable_artifact(solution, value), indent=2) + "\n", encoding="utf-8") + + +def migrate_reports(solution: str | Path, *, apply: bool = False) -> dict[str, Any]: + """Copy legacy reports with a hash receipt; never move/delete or overwrite user evidence.""" + root = Path(solution).expanduser().resolve() + selected = _selected_run_id(root) + destination = reports_base(root) / (selected or new_run_id()) + legacy = _unmigrated(root) + if selected and selected not in list_runs(root) and not legacy: + raise ValueError(f"no unmigrated artifacts for requested run: {selected}") + prepared: dict[str, bytes] = {} + for name, paths in legacy.items(): + if name in {MIGRATION_RECEIPT, RUN_MARKER}: + raise ValueError("reserved migration receipt name in legacy artifacts") + if len({_digest(path) for path in paths}) != 1: + raise ValueError(f"conflicting legacy artifacts: {name}") + source = paths[0] + content = source.read_bytes() + if source.suffix.lower() == ".json": + document = portable_artifact(root, json.loads(content.decode("utf-8-sig")), destination=destination) + if name == "workflow-state.json": + if document.get("runId") not in (None, destination.name): + raise ValueError("legacy workflow belongs to another run; reconcile explicitly") + document["runId"] = destination.name + text = json.dumps(document, indent=2) + "\n" + content = text.encode("utf-8") + elif source.suffix.lower() in {".yaml", ".yml"}: + # Configuration must retain its exact semantics, including KQL. + text = content.decode("utf-8-sig") + safe = portable_artifact(root, yaml.safe_load(text), destination=destination) + if safe != yaml.safe_load(text): + raise ValueError(f"legacy configuration needs manual portable/credential review: {name}") + from .content_paths import content_path + target = content_path(destination, name) + if target.exists() and target.read_bytes() != content: + raise ValueError(f"conflicting migration artifacts: {name}; reconcile explicitly") + prepared[name] = content + if apply and prepared: + if destination.name not in list_runs(root): + create_run(root, run_id=destination.name, migrating=True) + receipt_path = destination / MIGRATION_RECEIPT + receipt = json.loads(receipt_path.read_text()) if receipt_path.exists() else {} + for name, content in prepared.items(): + target = content_path(destination, name) + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists(): + target.write_bytes(content) + receipt[name] = sorted(_digest(path) for path in legacy[name]) + receipt_path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + return {"mode": "applied" if apply else "dry-run", "files": sorted(prepared), + "runId": destination.name, "destination": destination.relative_to(root).as_posix(), + "originalsPreserved": True} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/authoring_contract.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/authoring_contract.py new file mode 100644 index 00000000000..69d96439f49 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/authoring_contract.py @@ -0,0 +1,561 @@ +"""Offline validation and deterministic advisory metrics for authoring records. + +These helpers neither persist stopped state nor authorize runtime operations. +""" + +from __future__ import annotations + +import json +import re +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Mapping + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError + +SCHEMA_VERSION = "1.0.0" +AUTHORING_SCHEMA_VERSION = "1.1.0" +SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION, AUTHORING_SCHEMA_VERSION} +SCHEMA_PATH = Path(__file__).resolve().parents[1] / "schema" / "authoring-contract.schema.json" +_SHA256 = re.compile(r"[a-f0-9]{64}\Z") +_MIGRATION_PERCENT = 80 +_STOP_PERCENT = 50 + + +class AuthoringContractError(ValueError): + """A malformed or semantically inconsistent authoring contract.""" + + +@dataclass(frozen=True) +class EvidenceReference: + path: str + sha256: str + kind: str + + +@dataclass(frozen=True) +class Finding: + finding_id: str + kind: str + message: str + unresolved: bool + evidence_references: tuple[EvidenceReference, ...] + + +@dataclass(frozen=True) +class SourceRule: + rule_id: str + sha256: str + evidence_references: tuple[EvidenceReference, ...] + path: str | None = None + query_sha256: str | None = None + + +@dataclass(frozen=True) +class TargetArtifact: + path: str + sha256: str + structurally_valid: bool + query_sha256: str | None = None + + +@dataclass(frozen=True) +class RuleResult: + rule_id: str + source_sha256: str + status: str + conversion_status: str | None + validation_status: str | None + deployment_disposition: str + target: TargetArtifact | None + review_required: bool + mapping_assumptions: tuple[str, ...] + findings: tuple[Finding, ...] + qualification_status: str + qualification_evidence_references: tuple[EvidenceReference, ...] + evidence_references: tuple[EvidenceReference, ...] + + @property + def target_structurally_valid(self) -> bool: + return self.target is not None and self.target.structurally_valid + + @property + def unresolved_error_count(self) -> int: + return sum(finding.kind == "error" and finding.unresolved for finding in self.findings) + + +@dataclass(frozen=True) +class EnvironmentError: + code: str + message: str + evidence_references: tuple[EvidenceReference, ...] + + +@dataclass(frozen=True) +class AuthoringContract: + run_id: str + source_identity: str + source_sha256: str + inventory: tuple[SourceRule, ...] + results: tuple[RuleResult, ...] + environment_errors: tuple[EnvironmentError, ...] + schema_version: str = SCHEMA_VERSION + inventory_sha256: str | None = None + revision: int | None = None + previous_sha256: str | None = None + + +@dataclass(frozen=True) +class Coverage: + """Exact integer-based counts; no rounded percentage is used for decisions.""" + + run_id: str + source_identity: str + source_sha256: str + schema_version: str + N: int + M: int + E: int + qualified: int + untested: int + qualification_failed: int + migration_threshold_met: bool + stop_threshold_reached: bool + + def as_dict(self) -> dict[str, str | int | bool]: + return { + "schemaVersion": self.schema_version, + "runId": self.run_id, + "sourceIdentity": self.source_identity, + "sourceSha256": self.source_sha256, + "N": self.N, + "M": self.M, + "E": self.E, + "qualified": self.qualified, + "untested": self.untested, + "qualificationFailed": self.qualification_failed, + "migrationThresholdMet": self.migration_threshold_met, + "stopThresholdReached": self.stop_threshold_reached, + } + + +@dataclass(frozen=True) +class OperationEligibility: + """Advisory thresholds only; no operation is authorized by this record.""" + + run_id: str + source_identity: str + source_sha256: str + schema_version: str + migration_threshold_met: bool + stop_threshold_reached: bool + + def as_dict(self) -> dict[str, str | bool]: + return { + "schemaVersion": self.schema_version, + "runId": self.run_id, + "sourceIdentity": self.source_identity, + "sourceSha256": self.source_sha256, + "scope": "advisory-only", + "migrationThresholdMet": self.migration_threshold_met, + "successAndPrThresholdMet": self.migration_threshold_met, + "stopThresholdReached": self.stop_threshold_reached, + "requiresExactScopeAuthorization": True, + "coverageAuthorizesOperations": False, + "deploymentAuthorized": False, + "packagingAuthorized": False, + "ingestionAuthorized": False, + "prCreationAuthorized": False, + } + + +def _schema() -> dict[str, Any]: + try: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + except (OSError, json.JSONDecodeError, ValueError, SchemaError) as exc: + raise AuthoringContractError(f"cannot load authoring contract schema: {exc}") from exc + return schema + + +def _path_is_safe(value: str) -> bool: + if "\\" in value or value.startswith("/") or PureWindowsPath(value).drive: + return False + if any(part in {"", ".", ".."} for part in value.split("/")): + return False + path = PurePosixPath(value) + return not path.is_absolute() + + +def calculate_inventory_sha256(rules: list[Mapping[str, Any]]) -> str: + """Hash a complete inventory's stable identity, path, source, and query hashes.""" + canonical = [ + { + "path": rule["path"], + "querySha256": rule["querySha256"], + "ruleId": rule["ruleId"], + "sha256": rule["sha256"], + } + for rule in sorted(rules, key=lambda item: item["path"]) + ] + encoded = json.dumps( + canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + import hashlib + + return hashlib.sha256(encoded).hexdigest() + + +def _validate_references(refs: list[dict[str, Any]], where: str) -> None: + identities: set[str] = set() + for index, reference in enumerate(refs): + path = reference["path"] + if not _path_is_safe(path): + raise AuthoringContractError( + f"{where}[{index}].path must be a safe repository-relative path" + ) + if not _SHA256.fullmatch(reference["sha256"]): + raise AuthoringContractError(f"{where}[{index}].sha256 must be lowercase SHA-256") + if path in identities: + raise AuthoringContractError(f"{where} contains a duplicate evidence reference: {path}") + identities.add(path) + + +def _has_unresolved_error(result: Mapping[str, Any]) -> bool: + return any(item["kind"] == "error" and item["unresolved"] for item in result["findings"]) + + +def _evidence_references(references: list[dict[str, Any]]) -> tuple[EvidenceReference, ...]: + return tuple( + EvidenceReference(item["path"], item["sha256"], item["kind"]) + for item in references + ) + + +def _semantic_validate(document: Mapping[str, Any]) -> AuthoringContract: + if document["schemaVersion"] not in SUPPORTED_SCHEMA_VERSIONS: + raise AuthoringContractError(f"unsupported authoring contract version: {document['schemaVersion']}") + if not document["inventory"]["complete"]: + raise AuthoringContractError("inventory must be complete") + + inventory = document["inventory"]["rules"] + source_ids = [rule["ruleId"] for rule in inventory] + if not source_ids: + raise AuthoringContractError("inventory must contain at least one source rule") + if len(source_ids) != len(set(source_ids)): + raise AuthoringContractError("inventory contains duplicate rule identities") + for index, rule in enumerate(inventory): + if "path" in rule and not _path_is_safe(rule["path"]): + raise AuthoringContractError( + f"inventory.rules[{index}].path must be a safe repository-relative path" + ) + _validate_references(rule["evidenceReferences"], f"inventory.rules[{index}].evidenceReferences") + inventory_sha256 = document["inventory"].get("sha256") + if document["schemaVersion"] == AUTHORING_SCHEMA_VERSION: + if not all("path" in rule and "querySha256" in rule for rule in inventory): + raise AuthoringContractError( + "authoring contract 1.1.0 requires every inventory path and query hash" + ) + calculated_inventory_sha256 = calculate_inventory_sha256(inventory) + if inventory_sha256 != calculated_inventory_sha256: + raise AuthoringContractError("inventory SHA-256 does not match the complete inventory") + snapshot = document["snapshot"] + revision = snapshot["revision"] + previous_sha256 = snapshot["previousSha256"] + if (revision == 1) != (previous_sha256 is None): + raise AuthoringContractError( + "snapshot revision 1 must have no previous hash; later revisions must be chained" + ) + else: + revision = None + previous_sha256 = None + + results = document["results"] + result_ids = [result["ruleId"] for result in results] + if len(result_ids) != len(set(result_ids)): + raise AuthoringContractError("results contain duplicate rule identities") + missing = sorted(set(source_ids) - set(result_ids)) + extra = sorted(set(result_ids) - set(source_ids)) + if missing or extra: + raise AuthoringContractError( + f"results must match inventory identities exactly (missing={missing}, extra={extra})" + ) + source_hashes = {rule["ruleId"]: rule["sha256"] for rule in inventory} + parsed_results: list[RuleResult] = [] + for index, result in enumerate(results): + rule_id = result["ruleId"] + if result["sourceSha256"] != source_hashes[rule_id]: + raise AuthoringContractError(f"result source hash does not match inventory for rule {rule_id!r}") + if any(item["kind"] != "error" and item["unresolved"] for item in result["findings"]): + raise AuthoringContractError( + f"only error findings may be unresolved for rule {rule_id!r}" + ) + finding_ids = [item["findingId"] for item in result["findings"]] + if len(finding_ids) != len(set(finding_ids)): + raise AuthoringContractError(f"rule {rule_id!r} contains duplicate finding identities") + _validate_references(result["evidenceReferences"], f"results[{index}].evidenceReferences") + _validate_references( + result["qualificationEvidenceReferences"], + f"results[{index}].qualificationEvidenceReferences", + ) + for finding_index, finding in enumerate(result["findings"]): + _validate_references( + finding["evidenceReferences"], + f"results[{index}].findings[{finding_index}].evidenceReferences", + ) + + status = result["status"] + disposition = result["deploymentDisposition"] + target = result["target"] + has_error = _has_unresolved_error(result) + target_valid = target is not None and target["structurallyValid"] + if target is not None and not _path_is_safe(target["path"]): + raise AuthoringContractError(f"target path must be repository-relative for rule {rule_id!r}") + if status == "migrated" and (not target_valid or has_error or disposition != "normal"): + raise AuthoringContractError( + f"migrated rule {rule_id!r} needs a structurally valid target, no unresolved errors, " + "and normal deployment disposition" + ) + if status == "blocked" and ( + disposition not in {"deploymentBlocked", "authoringBlocked"} or not has_error + ): + raise AuthoringContractError( + f"blocked rule {rule_id!r} needs deploymentBlocked disposition and an unresolved error" + ) + if status == "suppressed" and (disposition != "suppressed" or not has_error): + raise AuthoringContractError( + f"suppressed rule {rule_id!r} needs suppressed disposition and an unresolved error" + ) + if status == "unprocessed" and disposition != "normal": + raise AuthoringContractError(f"unprocessed rule {rule_id!r} must have normal disposition") + if document["schemaVersion"] == AUTHORING_SCHEMA_VERSION: + if status == "migrated" and ( + result["conversionStatus"] != "converted" + or result["validationStatus"] != "passed" + ): + raise AuthoringContractError( + f"migrated rule {rule_id!r} needs successful conversion and structural validation" + ) + if result["validationStatus"] == "passed" and not target_valid: + raise AuthoringContractError( + f"passed structural validation for {rule_id!r} needs a valid target" + ) + qualification = result["qualificationStatus"] + qualification_references = _evidence_references( + result["qualificationEvidenceReferences"] + ) + qualification_kinds = { + reference.kind for reference in qualification_references + } + if qualification == "qualified": + if status != "migrated": + raise AuthoringContractError(f"qualified rule {rule_id!r} must be migrated") + if not {"runtimeVerification", "alertParity"}.issubset(qualification_kinds): + raise AuthoringContractError( + f"qualified rule {rule_id!r} needs runtimeVerification and alertParity evidence" + ) + elif qualification == "failed" and not has_error: + raise AuthoringContractError( + f"failed qualification for rule {rule_id!r} must remain an unresolved rule error" + ) + + parsed_results.append( + RuleResult( + rule_id=rule_id, + source_sha256=result["sourceSha256"], + status=status, + conversion_status=result.get("conversionStatus"), + validation_status=result.get("validationStatus"), + deployment_disposition=disposition, + target=( + TargetArtifact( + target["path"], target["sha256"], target["structurallyValid"], + target.get("querySha256"), + ) + if target is not None else None + ), + review_required=result["reviewRequired"], + mapping_assumptions=tuple(result["mappingAssumptions"]), + findings=tuple( + Finding( + finding["findingId"], + finding["kind"], + finding["message"], + finding["unresolved"], + _evidence_references(finding["evidenceReferences"]), + ) + for finding in result["findings"] + ), + qualification_status=qualification, + qualification_evidence_references=qualification_references, + evidence_references=_evidence_references(result["evidenceReferences"]), + ) + ) + parsed_inventory = tuple( + SourceRule( + rule["ruleId"], + rule["sha256"], + _evidence_references(rule["evidenceReferences"]), + rule.get("path"), + rule.get("querySha256"), + ) + for rule in inventory + ) + parsed_environment_errors = tuple( + EnvironmentError( + error["code"], + error["message"], + _evidence_references(error["evidenceReferences"]), + ) + for error in document["environmentErrors"] + ) + for index, error in enumerate(document["environmentErrors"]): + _validate_references(error["evidenceReferences"], f"environmentErrors[{index}].evidenceReferences") + + return AuthoringContract( + run_id=document["runId"], + source_identity=document["source"]["identity"], + source_sha256=document["source"]["sha256"], + inventory=parsed_inventory, + results=tuple(parsed_results), + environment_errors=parsed_environment_errors, + schema_version=document["schemaVersion"], + inventory_sha256=inventory_sha256, + revision=revision, + previous_sha256=previous_sha256, + ) + + +def validate_authoring_contract(document: Mapping[str, Any]) -> AuthoringContract: + """Validate a JSON-like contract and return an immutable typed summary.""" + if not isinstance(document, Mapping): + raise AuthoringContractError("authoring contract must be an object") + version = document.get("schemaVersion") + if version not in SUPPORTED_SCHEMA_VERSIONS: + raise AuthoringContractError(f"unsupported authoring contract version: {version!r}") + errors = sorted( + Draft202012Validator(_schema()).iter_errors(document), + key=lambda error: (list(error.absolute_path), error.message), + ) + if errors: + details = "; ".join( + f"{'/'.join(map(str, error.absolute_path)) or ''}: {error.message}" + for error in errors + ) + raise AuthoringContractError(f"invalid authoring contract: {details}") + try: + contract = _semantic_validate(document) + expected_coverage = calculate_coverage(contract).as_dict() + if document["coverage"] != expected_coverage: + raise AuthoringContractError( + "coverage counts or thresholds are inconsistent with inventory and results" + ) + expected_eligibility = calculate_operation_eligibility( + calculate_coverage(contract) + ).as_dict() + if document["operationEligibility"] != expected_eligibility: + raise AuthoringContractError( + "operation eligibility is inconsistent with coverage or grants authorization" + ) + return contract + except AuthoringContractError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise AuthoringContractError(f"invalid authoring contract semantics: {exc}") from exc + + +def seal_authoring_contract(document: Mapping[str, Any]) -> dict[str, Any]: + """Validate a source/outcome record and attach deterministic advisory summaries.""" + if not isinstance(document, Mapping): + raise AuthoringContractError("authoring contract must be an object") + if "coverage" in document or "operationEligibility" in document: + raise AuthoringContractError( + "seal_authoring_contract accepts core records only; validate precomputed summaries instead" + ) + if document.get("schemaVersion") not in SUPPORTED_SCHEMA_VERSIONS: + raise AuthoringContractError( + f"unsupported authoring contract version: {document.get('schemaVersion')!r}" + ) + record = deepcopy(dict(document)) + schema = _schema() + core_schema = deepcopy(schema) + core_schema["required"] = [ + key for key in core_schema["required"] + if key not in {"coverage", "operationEligibility"} + ] + errors = sorted( + Draft202012Validator(core_schema).iter_errors(record), + key=lambda error: (list(error.absolute_path), error.message), + ) + if errors: + details = "; ".join( + f"{'/'.join(map(str, error.absolute_path)) or ''}: {error.message}" + for error in errors + ) + raise AuthoringContractError(f"invalid authoring contract: {details}") + try: + contract = _semantic_validate(record) + coverage = calculate_coverage(contract) + record["coverage"] = coverage.as_dict() + record["operationEligibility"] = calculate_operation_eligibility(coverage).as_dict() + validate_authoring_contract(record) + return record + except AuthoringContractError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise AuthoringContractError(f"invalid authoring contract semantics: {exc}") from exc + + +def calculate_coverage(contract: AuthoringContract) -> Coverage: + """Calculate exact coverage from a validated immutable inventory and its outcomes.""" + if not isinstance(contract, AuthoringContract): + raise TypeError("calculate_coverage requires a validated AuthoringContract") + total = len(contract.inventory) + if total < 1: + raise AuthoringContractError("coverage is undefined for an empty source inventory") + migrated = sum( + 1 + for result in contract.results + if result.status == "migrated" + and result.target_structurally_valid + and result.unresolved_error_count == 0 + and result.deployment_disposition == "normal" + ) + errored = sum( + 1 + for result in contract.results + if result.unresolved_error_count > 0 or result.status == "suppressed" + ) + qualified = sum(result.qualification_status == "qualified" for result in contract.results) + untested = sum(result.qualification_status == "untested" for result in contract.results) + failed = sum(result.qualification_status == "failed" for result in contract.results) + return Coverage( + run_id=contract.run_id, + source_identity=contract.source_identity, + source_sha256=contract.source_sha256, + schema_version=contract.schema_version, + N=total, + M=migrated, + E=errored, + qualified=qualified, + untested=untested, + qualification_failed=failed, + migration_threshold_met=100 * migrated >= _MIGRATION_PERCENT * total, + stop_threshold_reached=100 * errored > _STOP_PERCENT * total, + ) + + +def calculate_operation_eligibility(coverage: Coverage) -> OperationEligibility: + """Return threshold signals only; separate exact-scope authorization is always required.""" + if not isinstance(coverage, Coverage): + raise TypeError("calculate_operation_eligibility requires a Coverage record") + return OperationEligibility( + run_id=coverage.run_id, + source_identity=coverage.source_identity, + source_sha256=coverage.source_sha256, + schema_version=coverage.schema_version, + migration_threshold_met=coverage.migration_threshold_met, + stop_threshold_reached=coverage.stop_threshold_reached, + ) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/budget.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/budget.py new file mode 100644 index 00000000000..1f222b84dd7 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/budget.py @@ -0,0 +1,1223 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import tempfile +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + +from .artifacts import list_runs, reports_base + +BUDGET_SCHEMA_VERSION = "1.0.0" +DEFAULT_POLICY = { + "activeExecutionSeconds": 120 * 60, + "ruleRevisions": 3, + "ruleActiveSeconds": 10 * 60, + "conversionPasses": 3, + "packageBuilds": 3, + "providerHttpAttempts": 500, + "ingestionSubmissionsPerApproval": 1, +} +CAMPAIGN_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z") +SHA256_PATTERN = re.compile(r"[a-f0-9]{64}\Z") +_ZERO_HASH = "0" * 64 +_DIMENSIONS = { + "conversionPass": "conversionPasses", + "packageBuild": "packageBuilds", + "providerHttpAttempt": "providerHttpAttempts", + "ruleRevision": "ruleRevisions", + "ingestionSubmission": "ingestionSubmissionsPerApproval", + "alertObservationPoll": None, + "workflowDiscovery": None, + "workflowValidation": None, + "workflowRefresh": None, +} +_LEGACY_KEYS = { + "activeExecutionSeconds", + "conversionPasses", + "packageBuilds", + "providerHttpAttempts", + "ingestionSubmissions", + "ruleRevisions", + "ruleActiveSeconds", +} + + +class BudgetError(RuntimeError): + """Base exception for campaign budget checks and receipt failures.""" + + +class BudgetExhausted(BudgetError): + """The requested work would exceed a campaign budget ceiling.""" + + +class BudgetUnknown(BudgetError): + """Historical use or an interrupted operation must be reconciled first.""" + + +class BudgetReceiptError(BudgetError): + """A budget receipt, campaign binding, or hash chain is invalid.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _canonical(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, + ).encode("utf-8") + + +def _safe_campaign_id(campaign_id: str) -> str: + if not isinstance(campaign_id, str) or not CAMPAIGN_ID_PATTERN.fullmatch(campaign_id): + raise ValueError("campaign ID must be a safe 1-64 character directory name") + return campaign_id + + +def default_campaign_id(solution: str | Path) -> str: + """Derive a stable per-solution identity; changing inventory never auto-resets it.""" + identity = str(Path(solution).expanduser().resolve()) + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24] + return f"solution-{digest}" + + +def _campaign_directory( + solution: str | Path, + campaign_id: str, + *, + create_run_id: str | None = None, +) -> Path: + base = reports_base(solution) + campaign_id = _safe_campaign_id(campaign_id) + runs = list_runs(solution) + legacy_matches = [ + base / run_id / "campaigns" / campaign_id + for run_id in runs + if (base / run_id / "campaigns" / campaign_id / "budget.json").exists() + ] + campaign_root = base.parent / f"{base.name}-campaigns" + canonical = campaign_root / campaign_id + matches = legacy_matches + ( + [canonical] if (canonical / "budget.json").exists() else [] + ) + if len(matches) > 1: + raise BudgetReceiptError( + "campaign ID has multiple competing budget files; reconcile them before continuing" + ) + if matches: + directory = matches[0] + elif create_run_id is not None: + if create_run_id not in runs: + raise BudgetReceiptError("campaign storage run is not an initialized workflow run") + directory = canonical + else: + raise BudgetReceiptError(f"campaign budget does not exist: {campaign_id}") + if ( + directory.parent.is_symlink() + or directory.is_symlink() + or campaign_root.is_symlink() + ): + raise BudgetReceiptError("symlink campaign directories are not supported") + resolved_base = base.parent.resolve() + resolved_directory = directory.resolve() + if not resolved_directory.is_relative_to(resolved_base): + raise BudgetReceiptError("campaign path escapes the solution migration Logs directory") + return directory + + +def _budget_path( + solution: str | Path, + campaign_id: str, + *, + create_run_id: str | None = None, +) -> Path: + return _campaign_directory( + solution, campaign_id, create_run_id=create_run_id, + ) / "budget.json" + + +def _atomic_json(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.parent.is_symlink() or path.is_symlink(): + raise BudgetReceiptError("symlink budget paths are not supported") + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", prefix=".budget.", suffix=".tmp", + dir=path.parent, delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write((json.dumps(document, indent=2, ensure_ascii=False) + "\n").encode("utf-8")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + temporary = None + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +@contextmanager +def _campaign_lock( + solution: str | Path, + campaign_id: str, + *, + create_run_id: str | None = None, +) -> Iterator[Path]: + directory = _campaign_directory( + solution, campaign_id, create_run_id=create_run_id, + ) + directory.mkdir(parents=True, exist_ok=True) + lock_path = directory / ".budget.lock" + if lock_path.is_symlink(): + raise BudgetReceiptError("symlink budget lock is not supported") + with lock_path.open("a+b") as handle: + try: + import fcntl + except ImportError: + import msvcrt + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + unlock = lambda: msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + unlock = lambda: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + try: + yield _budget_path(solution, campaign_id, create_run_id=create_run_id) + finally: + unlock() + + +def _append_receipt(state: dict[str, Any], fields: dict[str, Any]) -> dict[str, Any]: + receipts = state["receipts"] + previous = receipts[-1]["sha256"] if receipts else _ZERO_HASH + body = { + "sequence": len(receipts) + 1, + "previousSha256": previous, + "createdAt": _now(), + **fields, + } + body["detailsSha256"] = hashlib.sha256(_canonical(fields)).hexdigest() + body["sha256"] = hashlib.sha256(_canonical(body)).hexdigest() + receipts.append(body) + return body + + +def _validate_state( + state: Any, + *, + campaign_id: str | None = None, + solution: str | Path | None = None, + inventory_sha256: str | None = None, +) -> dict[str, Any]: + if not isinstance(state, dict) or state.get("schemaVersion") != BUDGET_SCHEMA_VERSION: + raise BudgetReceiptError("unsupported or malformed campaign budget schema") + if ( + not isinstance(state.get("campaignId"), str) + or not CAMPAIGN_ID_PATTERN.fullmatch(state["campaignId"]) + or not isinstance(state.get("solutionPath"), str) + or not isinstance(state.get("createdAt"), str) + ): + raise BudgetReceiptError("campaign identity or creation timestamp is malformed") + if ( + not isinstance(state.get("campaignRunId"), str) + or not CAMPAIGN_ID_PATTERN.fullmatch(state["campaignRunId"]) + ): + raise BudgetReceiptError("campaign storage run identity is malformed") + if campaign_id is not None and state.get("campaignId") != campaign_id: + raise BudgetReceiptError("campaign ID does not match the selected budget file") + if solution is not None and state.get("solutionPath") != str(Path(solution).expanduser().resolve()): + raise BudgetReceiptError("campaign is bound to a different solution path") + if inventory_sha256 is not None and state.get("inventorySha256") != inventory_sha256: + raise BudgetReceiptError("campaign is bound to a different frozen source inventory") + if not SHA256_PATTERN.fullmatch(str(state.get("inventorySha256", ""))): + raise BudgetReceiptError("campaign inventory SHA-256 is malformed") + policy = state.get("policy") + if ( + not isinstance(policy, dict) + or set(policy) != set(DEFAULT_POLICY) + or any( + isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value < 0 + for value in policy.values() + ) + ): + raise BudgetReceiptError("campaign policy is malformed") + for dimension in set(DEFAULT_POLICY) - {"activeExecutionSeconds", "ruleActiveSeconds"}: + if not float(policy[dimension]).is_integer(): + raise BudgetReceiptError(f"attempt ceiling {dimension} must be an integer") + if not isinstance(state.get("receipts"), list): + raise BudgetReceiptError("campaign receipts must be an array") + previous = _ZERO_HASH + starts: dict[str, dict[str, Any]] = {} + finishes: set[str] = set() + known_events = { + "runLinked", "operationStarted", "operationFinished", + "operationReconciled", "policyExtended", "observationDeadlineSet", + "legacyUsageReconciled", + } + for sequence, receipt in enumerate(state["receipts"], 1): + if not isinstance(receipt, dict): + raise BudgetReceiptError("campaign receipt must be an object") + if receipt.get("eventType") not in known_events: + raise BudgetReceiptError(f"unsupported campaign receipt at sequence {sequence}") + body = {key: value for key, value in receipt.items() if key != "sha256"} + if ( + receipt.get("sequence") != sequence + or receipt.get("previousSha256") != previous + or receipt.get("detailsSha256") + != hashlib.sha256(_canonical({ + key: value for key, value in body.items() + if key not in {"sequence", "previousSha256", "createdAt", "detailsSha256"} + })).hexdigest() + or receipt.get("sha256") != hashlib.sha256(_canonical(body)).hexdigest() + ): + raise BudgetReceiptError(f"campaign receipt hash chain is invalid at sequence {sequence}") + event_type = receipt["eventType"] + if event_type == "runLinked": + if not isinstance(receipt.get("runId"), str) or not receipt["runId"]: + raise BudgetReceiptError("run-linked receipt has no run ID") + elif event_type == "operationStarted": + operation_id = receipt.get("operationId") + dimension = receipt.get("dimension") + metadata = receipt.get("metadata") + if ( + not isinstance(operation_id, str) + or operation_id in starts + or dimension not in _DIMENSIONS + or not isinstance(receipt.get("runId"), str) + or receipt.get("active") is not True + or not isinstance(metadata, dict) + ): + raise BudgetReceiptError("operation-start receipt is malformed or duplicated") + if dimension == "ruleRevision" and ( + receipt.get("itemId") not in state.get("inventoryRuleIds", []) + or any( + not isinstance(metadata.get(name), str) + or not SHA256_PATTERN.fullmatch(metadata[name]) + for name in ("inputSha256", "evidenceSha256", "proposedChangeSha256") + ) + ): + raise BudgetReceiptError("rule revision receipt lacks frozen rule or evidence fingerprints") + if dimension == "ingestionSubmission" and not all( + isinstance(receipt.get(name), str) and receipt[name] + for name in ("itemId", "approvalReference", "attemptMarker", "approvalScopeKey", "markerKey") + ): + raise BudgetReceiptError("ingestion receipt lacks exact attempt/approval identity") + if dimension == "alertObservationPoll" and not ( + isinstance(receipt.get("testId"), str) and receipt["testId"] + ): + raise BudgetReceiptError("observation receipt has no test identity") + starts[operation_id] = receipt + elif event_type in {"operationFinished", "operationReconciled"}: + operation_id = receipt.get("operationId") + elapsed = receipt.get("elapsedSeconds") + if ( + operation_id not in starts + or operation_id in finishes + or receipt.get("runId") != starts[operation_id].get("runId") + or receipt.get("status") not in {"passed", "failed", "cancelled", "unknown"} + or isinstance(elapsed, bool) + or not isinstance(elapsed, (int, float)) + or not math.isfinite(elapsed) + or elapsed < 0 + ): + raise BudgetReceiptError("operation-finish receipt is malformed or duplicated") + if event_type == "operationReconciled" and not all( + isinstance(receipt.get(key), str) and receipt[key] + for key in ("actor", "reason", "evidenceReference") + ): + raise BudgetReceiptError("operation reconciliation receipt lacks audit evidence") + if "completionMetadata" in receipt and not isinstance( + receipt["completionMetadata"], dict + ): + raise BudgetReceiptError("operation completion metadata is malformed") + finishes.add(operation_id) + elif event_type == "policyExtended": + if ( + receipt.get("dimension") not in DEFAULT_POLICY + or isinstance(receipt.get("newLimit"), bool) + or not isinstance(receipt.get("newLimit"), (int, float)) + or not math.isfinite(receipt["newLimit"]) + or receipt.get("newLimit", 0) <= receipt.get("oldLimit", -1) + or not receipt.get("actor") + or not receipt.get("reason") + or not receipt.get("evidenceReference") + ): + raise BudgetReceiptError("policy-extension receipt is malformed") + elif event_type == "observationDeadlineSet": + if not all(isinstance(receipt.get(key), str) and receipt[key] for key in ( + "testId", "deadlineAt", "approvalReference", "actor", "reason", + )): + raise BudgetReceiptError("observation deadline receipt is malformed") + elif event_type == "legacyUsageReconciled": + if not all(isinstance(receipt.get(key), str) and receipt[key] for key in ( + "actor", "reason", "evidenceReference", "consumedSha256", + )): + raise BudgetReceiptError("legacy reconciliation receipt is malformed") + previous = receipt["sha256"] + if any(operation_id not in state["runIds"] for operation_id in ( + event.get("runId") for event in starts.values() + )): + raise BudgetReceiptError("operation receipt references an unlinked workflow run") + linked_events = [ + event["runId"] for event in state["receipts"] + if event.get("eventType") == "runLinked" + ] + if len(set(linked_events)) != len(linked_events) or set(linked_events) != set(state["runIds"]): + raise BudgetReceiptError("campaign run linkage receipts do not match the linked run list") + legacy = state.get("legacyUsage") + if ( + not isinstance(legacy, dict) + or legacy.get("status") not in {"known", "unknown"} + or not isinstance(legacy.get("baseline"), dict) + ): + raise BudgetReceiptError("campaign legacy usage metadata is malformed") + if not isinstance(state.get("inventoryRuleIds"), list) or not all( + isinstance(value, str) and value for value in state["inventoryRuleIds"] + ) or len(set(state["inventoryRuleIds"])) != len(state["inventoryRuleIds"]): + raise BudgetReceiptError("campaign rule inventory is malformed") + if not isinstance(state.get("runIds"), list) or not all( + isinstance(value, str) and value for value in state["runIds"] + ) or len(set(state["runIds"])) != len(state["runIds"]): + raise BudgetReceiptError("campaign linked run IDs are malformed") + if state["campaignRunId"] not in state["runIds"]: + raise BudgetReceiptError("campaign storage run is not linked to the campaign") + baseline = legacy["baseline"] + if not isinstance(baseline, dict): + raise BudgetReceiptError("campaign legacy baseline is malformed") + if legacy["status"] == "unknown" and baseline: + raise BudgetReceiptError("unknown legacy usage cannot include inferred baseline counters") + if legacy["status"] == "known" and baseline and set(baseline) != _LEGACY_KEYS: + raise BudgetReceiptError("reconciled legacy baseline does not cover every usage dimension") + if baseline: + for name in ("activeExecutionSeconds", "conversionPasses", "packageBuilds", + "providerHttpAttempts", "ingestionSubmissions"): + value = baseline[name] + if ( + isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value < 0 + ): + raise BudgetReceiptError(f"legacy baseline {name} is invalid") + if name != "activeExecutionSeconds" and not float(value).is_integer(): + raise BudgetReceiptError(f"legacy baseline {name} must be a whole number") + for name in ("ruleRevisions", "ruleActiveSeconds"): + values = baseline[name] + if ( + not isinstance(values, dict) + or set(values) != set(state["inventoryRuleIds"]) + or any( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + or (name == "ruleRevisions" and not float(value).is_integer()) + for value in values.values() + ) + ): + raise BudgetReceiptError(f"legacy baseline {name} is invalid") + return state + + +def _read_state( + path: Path, + *, + campaign_id: str, + solution: str | Path, + inventory_sha256: str | None = None, +) -> dict[str, Any]: + if path.is_symlink() or not path.is_file(): + raise BudgetReceiptError(f"campaign budget file does not exist: {path}") + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise BudgetReceiptError(f"campaign budget file is unreadable: {path}: {exc}") from exc + return _validate_state( + state, campaign_id=campaign_id, solution=solution, + inventory_sha256=inventory_sha256, + ) + + +def _write_state(path: Path, state: dict[str, Any]) -> None: + _validate_state( + state, + campaign_id=state["campaignId"], + solution=state["solutionPath"], + inventory_sha256=state["inventorySha256"], + ) + _atomic_json(path, state) + + +def _event_index(state: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: + starts: dict[str, dict[str, Any]] = {} + finishes: dict[str, dict[str, Any]] = {} + for event in state["receipts"]: + if event.get("eventType") == "operationStarted": + starts[event["operationId"]] = event + elif event.get("eventType") in {"operationFinished", "operationReconciled"}: + finishes[event["operationId"]] = event + return starts, finishes + + +def _usage(state: dict[str, Any]) -> dict[str, Any]: + baseline = state["legacyUsage"]["baseline"] + result: dict[str, Any] = { + "activeExecutionSeconds": float(baseline.get("activeExecutionSeconds", 0)), + "conversionPasses": int(baseline.get("conversionPasses", 0)), + "packageBuilds": int(baseline.get("packageBuilds", 0)), + "providerHttpAttempts": int(baseline.get("providerHttpAttempts", 0)), + "ingestionSubmissions": int(baseline.get("ingestionSubmissions", 0)), + "ruleRevisions": dict(baseline.get("ruleRevisions", {})), + "ruleActiveSeconds": dict(baseline.get("ruleActiveSeconds", {})), + } + starts, finishes = _event_index(state) + for event in state["receipts"]: + if event.get("eventType") != "operationStarted": + continue + dimension = event["dimension"] + item_id = event.get("itemId") + if dimension == "conversionPass": + result["conversionPasses"] += 1 + elif dimension == "packageBuild": + result["packageBuilds"] += 1 + elif dimension == "providerHttpAttempt": + result["providerHttpAttempts"] += 1 + elif dimension == "ingestionSubmission": + result["ingestionSubmissions"] += 1 + elif dimension == "ruleRevision": + result["ruleRevisions"][item_id] = result["ruleRevisions"].get(item_id, 0) + 1 + finish = finishes.get(event["operationId"]) + if finish and event.get("active") is True: + elapsed = float(finish["elapsedSeconds"]) + result["activeExecutionSeconds"] += elapsed + if dimension == "ruleRevision": + result["ruleActiveSeconds"][item_id] = ( + result["ruleActiveSeconds"].get(item_id, 0.0) + elapsed + ) + result["pendingOperations"] = sorted(set(starts) - set(finishes)) + return result + + +def _read_locked( + path: Path, + campaign_id: str, + solution: str | Path, + inventory_sha256: str | None = None, +) -> dict[str, Any]: + return _read_state( + path, campaign_id=campaign_id, solution=solution, + inventory_sha256=inventory_sha256, + ) + + +def create_or_link_campaign( + solution: str | Path, + campaign_id: str, + *, + inventory_sha256: str, + run_id: str, + rule_ids: list[str], + legacy_usage_unknown: bool = False, +) -> dict[str, Any]: + """Create the explicit campaign or link a replacement/resumed workflow run.""" + campaign_id = _safe_campaign_id(campaign_id) + if not SHA256_PATTERN.fullmatch(inventory_sha256): + raise ValueError("frozen inventory SHA-256 must be lowercase hexadecimal") + if not run_id or not isinstance(run_id, str): + raise ValueError("a workflow run ID is required to link a campaign") + if len(set(rule_ids)) != len(rule_ids) or not all(isinstance(value, str) for value in rule_ids): + raise ValueError("campaign rule IDs must be unique strings") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id, create_run_id=run_id) as path: + if path.exists(): + state = _read_locked(path, campaign_id, root, inventory_sha256) + if state["inventoryRuleIds"] != sorted(rule_ids): + raise BudgetReceiptError("campaign frozen rule IDs do not match this run") + else: + if any(child.name != ".budget.lock" for child in path.parent.iterdir()): + raise BudgetReceiptError( + "campaign directory contains unrelated files; refusing to overwrite them" + ) + state = { + "schemaVersion": BUDGET_SCHEMA_VERSION, + "campaignId": campaign_id, + "campaignRunId": run_id, + "solutionPath": str(root), + "inventorySha256": inventory_sha256, + "inventoryRuleIds": sorted(rule_ids), + "createdAt": _now(), + "policy": dict(DEFAULT_POLICY), + "legacyUsage": { + "status": "unknown" if legacy_usage_unknown else "known", + "reason": ( + "pre-existing run has unmeasured budget history" + if legacy_usage_unknown else None + ), + "baseline": {}, + "reconciliation": None, + }, + "runIds": [run_id], + "receipts": [], + } + _append_receipt(state, { + "eventType": "runLinked", + "runId": run_id, + }) + _write_state(path, state) + if run_id not in state["runIds"]: + state["runIds"].append(run_id) + _append_receipt(state, { + "eventType": "runLinked", + "runId": run_id, + }) + _write_state(path, state) + return budget_summary(root, campaign_id, expected_inventory=inventory_sha256) + + +def validate_campaign_binding( + solution: str | Path, + campaign_id: str, + *, + inventory_sha256: str, + rule_ids: list[str], +) -> bool: + """Validate an existing campaign before workflow initialization creates run artifacts.""" + campaign_id = _safe_campaign_id(campaign_id) + if not SHA256_PATTERN.fullmatch(inventory_sha256): + raise ValueError("frozen inventory SHA-256 must be lowercase hexadecimal") + if len(set(rule_ids)) != len(rule_ids) or not all( + isinstance(value, str) for value in rule_ids + ): + raise ValueError("campaign rule IDs must be unique strings") + root = Path(solution).expanduser().resolve() + runs = list_runs(root) + try: + directory = _campaign_directory( + root, + campaign_id, + create_run_id=runs[0] if runs else None, + ) + except BudgetReceiptError as exc: + if not runs and str(exc) == f"campaign budget does not exist: {campaign_id}": + return False + raise + path = directory / "budget.json" + if not path.exists(): + return False + state = _read_state( + path, + campaign_id=campaign_id, + solution=root, + inventory_sha256=inventory_sha256, + ) + if state["inventoryRuleIds"] != sorted(rule_ids): + raise BudgetReceiptError("campaign frozen rule IDs do not match this run") + return True + + +def _check_legacy_usage(state: dict[str, Any]) -> None: + if state["legacyUsage"]["status"] != "known": + raise BudgetUnknown( + "campaign has pre-existing unmeasured usage; reconcile it with authoritative " + "evidence before dispatching new work" + ) + + +def _check_pending(state: dict[str, Any]) -> None: + usage = _usage(state) + if usage["pendingOperations"]: + raise BudgetUnknown( + "campaign has an unresolved operation receipt; reconcile its outcome before new work" + ) + + +def _check_limit( + state: dict[str, Any], + dimension: str, + item_id: str | None, + *, + amount: float = 1, +) -> None: + policy = state["policy"] + usage = _usage(state) + if dimension == "conversionPass": + used, limit = usage["conversionPasses"], policy["conversionPasses"] + elif dimension == "packageBuild": + used, limit = usage["packageBuilds"], policy["packageBuilds"] + elif dimension == "providerHttpAttempt": + used, limit = usage["providerHttpAttempts"], policy["providerHttpAttempts"] + elif dimension == "ingestionSubmission": + used, limit = 0, policy["ingestionSubmissionsPerApproval"] + elif dimension == "ruleRevision": + if not item_id: + raise ValueError("rule revision receipts require a rule ID") + used, limit = usage["ruleRevisions"].get(item_id, 0), policy["ruleRevisions"] + elif dimension == "alertObservationPoll": + used, limit = 0, None + elif dimension in {"workflowDiscovery", "workflowValidation", "workflowRefresh"}: + used, limit = 0, None + else: + raise ValueError(f"unsupported budget operation dimension: {dimension}") + if limit is not None and used + amount > limit: + raise BudgetExhausted( + f"campaign budget exhausted for {dimension}" + + (f" ({item_id})" if item_id else "") + + f": {used:g} used, limit {limit:g}" + ) + active_used = usage["activeExecutionSeconds"] + active_limit = policy["activeExecutionSeconds"] + if active_used >= active_limit: + raise BudgetExhausted( + f"campaign active execution budget exhausted: " + f"{active_used:g} seconds used, limit {active_limit:g}" + ) + if dimension == "ruleRevision" and item_id: + rule_active = usage["ruleActiveSeconds"].get(item_id, 0) + if rule_active >= policy["ruleActiveSeconds"]: + raise BudgetExhausted( + f"rule active execution budget exhausted for {item_id}: " + f"{rule_active:g} seconds used, limit {policy['ruleActiveSeconds']:g}" + ) + + +def ensure_work_available( + solution: str | Path, + campaign_id: str, + *, + inventory_sha256: str, +) -> None: + """Fail closed before local work when history, an operation, or active time is unresolved.""" + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root, inventory_sha256) + _check_legacy_usage(state) + _check_pending(state) + usage = _usage(state) + if usage["activeExecutionSeconds"] >= state["policy"]["activeExecutionSeconds"]: + raise BudgetExhausted( + "campaign active execution budget is exhausted; no new work may start" + ) + + +def begin_operation( + solution: str | Path, + campaign_id: str, + *, + inventory_sha256: str, + run_id: str, + dimension: str, + item_id: str | None = None, + approval_reference: str | None = None, + attempt_marker: str | None = None, + test_id: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Persist an attempt before dispatch. Failed and later-reconciled attempts count.""" + if dimension not in _DIMENSIONS: + raise ValueError(f"unsupported budget operation dimension: {dimension}") + metadata = metadata or {} + if not isinstance(metadata, dict): + raise ValueError("operation metadata must be an object") + if dimension == "ingestionSubmission": + if not all(isinstance(value, str) and value.strip() for value in ( + item_id, approval_reference, attempt_marker, + )): + raise ValueError( + "ingestion submissions require an exact pair/fixture key, fresh approval " + "reference, and unique attempt marker" + ) + if dimension == "ruleRevision": + if not isinstance(item_id, str) or not item_id: + raise ValueError("rule revisions require a frozen source rule ID") + for name in ("inputSha256", "evidenceSha256", "proposedChangeSha256"): + value = metadata.get(name) + if not isinstance(value, str) or not SHA256_PATTERN.fullmatch(value): + raise ValueError( + "rule revisions require input, evidence, and proposed-change SHA-256 fingerprints" + ) + if dimension == "alertObservationPoll": + if not test_id: + raise ValueError("alert observation polls require an approved test ID") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root, inventory_sha256) + if dimension == "ruleRevision" and item_id not in state["inventoryRuleIds"]: + raise BudgetReceiptError("rule revision is outside the frozen campaign inventory") + _check_legacy_usage(state) + _check_pending(state) + _check_limit(state, dimension, item_id) + if dimension == "ingestionSubmission": + approval_scope_key = hashlib.sha256(_canonical({ + "itemId": item_id, + "approvalReference": approval_reference, + })).hexdigest() + marker_key = hashlib.sha256(attempt_marker.encode("utf-8")).hexdigest() + if any( + event.get("eventType") == "operationStarted" + and event.get("dimension") == dimension + and ( + event.get("approvalScopeKey") == approval_scope_key + or event.get("markerKey") == marker_key + ) + for event in state["receipts"] + ): + raise BudgetExhausted( + "this approved ingestion pair/fixture attempt already has a submission receipt" + ) + else: + approval_scope_key = None + marker_key = None + if dimension == "alertObservationPoll": + deadline = next(( + event for event in reversed(state["receipts"]) + if event.get("eventType") == "observationDeadlineSet" + and event.get("testId") == test_id + ), None) + if deadline is None: + raise BudgetUnknown("no approved schedule-aware observation deadline is recorded") + if datetime.now(timezone.utc) > datetime.fromisoformat( + deadline["deadlineAt"].replace("Z", "+00:00") + ): + raise BudgetExhausted( + f"approved alert observation deadline passed for test {test_id}" + ) + approval_reference = deadline["approvalReference"] + operation_id = str(uuid.uuid4()) + fields = { + "eventType": "operationStarted", + "operationId": operation_id, + "runId": run_id, + "dimension": dimension, + "itemId": item_id, + "active": True, + "approvalReference": approval_reference, + "attemptMarker": attempt_marker, + "approvalScopeKey": approval_scope_key, + "markerKey": marker_key, + "testId": test_id, + "metadata": metadata, + } + _append_receipt(state, fields) + _write_state(path, state) + return { + "operationId": operation_id, + "campaignId": campaign_id, + "dimension": dimension, + } + + +def finish_operation( + solution: str | Path, + campaign_id: str, + operation_id: str, + *, + status: str, + elapsed_seconds: float, + evidence_reference: str | None = None, + completion_metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Close a receipt with a measured duration; a retry is never implicit.""" + if status not in {"passed", "failed", "cancelled", "unknown"}: + raise ValueError("operation status must be passed, failed, cancelled, or unknown") + if ( + isinstance(elapsed_seconds, bool) + or not isinstance(elapsed_seconds, (int, float)) + or not math.isfinite(elapsed_seconds) + or elapsed_seconds < 0 + ): + raise ValueError("measured elapsed seconds must be a finite non-negative number") + if status == "unknown" and not ( + isinstance(evidence_reference, str) and evidence_reference.strip() + ): + raise ValueError("unknown operation outcomes require a receipt or telemetry reference") + if completion_metadata is not None and not isinstance(completion_metadata, dict): + raise ValueError("operation completion metadata must be an object") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root) + starts, finishes = _event_index(state) + if operation_id not in starts: + raise BudgetReceiptError("operation receipt does not exist in this campaign") + if operation_id in finishes: + raise BudgetReceiptError("operation receipt has already been closed") + start = starts[operation_id] + active_before = _usage(state)["activeExecutionSeconds"] + fields = { + "eventType": "operationFinished", + "operationId": operation_id, + "runId": start["runId"], + "status": status, + "elapsedSeconds": float(elapsed_seconds), + "evidenceReference": evidence_reference, + "completionMetadata": completion_metadata or {}, + } + if start.get("active") is True: + overrun = max( + 0.0, + active_before + float(elapsed_seconds) - state["policy"]["activeExecutionSeconds"], + ) + if overrun: + fields["overrunSeconds"] = overrun + _append_receipt(state, fields) + _write_state(path, state) + return budget_summary(root, campaign_id) + + +@contextmanager +def measured_operation( + solution: str | Path, + campaign_id: str, + *, + inventory_sha256: str, + run_id: str, + dimension: str, + item_id: str | None = None, + metadata: dict[str, Any] | None = None, + completion_metadata: dict[str, Any] | None = None, +) -> Iterator[str]: + """Measure synchronous toolkit work with a durable start and terminal receipt.""" + import time + + receipt = begin_operation( + solution, campaign_id, + inventory_sha256=inventory_sha256, + run_id=run_id, + dimension=dimension, + item_id=item_id, + metadata=metadata, + ) + started = time.monotonic() + try: + yield receipt["operationId"] + except BaseException as exc: + status = "cancelled" if isinstance(exc, (KeyboardInterrupt, SystemExit)) else "failed" + finish_operation( + solution, campaign_id, receipt["operationId"], + status=status, elapsed_seconds=max(0.0, time.monotonic() - started), + completion_metadata=completion_metadata, + ) + raise + else: + finish_operation( + solution, campaign_id, receipt["operationId"], + status="passed", elapsed_seconds=max(0.0, time.monotonic() - started), + completion_metadata=completion_metadata, + ) + + +def reconcile_pending_operation( + solution: str | Path, + campaign_id: str, + operation_id: str, + *, + status: str, + elapsed_seconds: float, + actor: str, + reason: str, + evidence_reference: str, +) -> dict[str, Any]: + if status not in {"passed", "failed", "cancelled", "unknown"}: + raise ValueError("reconciled status must be passed, failed, cancelled, or unknown") + if ( + isinstance(elapsed_seconds, bool) + or not isinstance(elapsed_seconds, (int, float)) + or not math.isfinite(elapsed_seconds) + or elapsed_seconds < 0 + ): + raise ValueError("reconciled elapsed seconds must be finite and non-negative") + if not all(isinstance(value, str) and value.strip() for value in ( + actor, reason, evidence_reference, + )): + raise ValueError("reconciliation requires actor, reason, and evidence reference") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root) + starts, finishes = _event_index(state) + if operation_id not in starts or operation_id in finishes: + raise BudgetReceiptError("only an unresolved operation receipt can be reconciled") + _append_receipt(state, { + "eventType": "operationReconciled", + "operationId": operation_id, + "runId": starts[operation_id]["runId"], + "status": status, + "elapsedSeconds": float(elapsed_seconds), + "actor": actor, + "reason": reason, + "evidenceReference": evidence_reference, + }) + _write_state(path, state) + return budget_summary(root, campaign_id) + + +def reconcile_legacy_usage( + solution: str | Path, + campaign_id: str, + *, + consumed: dict[str, Any], + actor: str, + reason: str, + evidence_reference: str, +) -> dict[str, Any]: + """Record an owner-reviewed usage baseline; this code never reconstructs history.""" + if set(consumed) != _LEGACY_KEYS: + raise ValueError("legacy reconciliation must provide every usage dimension") + if not all(isinstance(value, str) and value.strip() for value in ( + actor, reason, evidence_reference, + )): + raise ValueError("legacy reconciliation requires actor, reason, and evidence reference") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root) + if state["legacyUsage"]["status"] != "unknown": + raise BudgetReceiptError("legacy usage has already been reconciled") + for name in ("activeExecutionSeconds", "conversionPasses", "packageBuilds", + "providerHttpAttempts", "ingestionSubmissions"): + value = consumed[name] + if ( + isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value < 0 + ): + raise ValueError(f"legacy usage {name} must be a measured non-negative number") + if name != "activeExecutionSeconds" and not float(value).is_integer(): + raise ValueError(f"legacy usage {name} must be a whole-number attempt count") + for name in ("ruleRevisions", "ruleActiveSeconds"): + values = consumed[name] + if not isinstance(values, dict) or set(values) != set(state["inventoryRuleIds"]): + raise ValueError(f"legacy usage {name} must cover the exact frozen rule inventory") + for value in values.values(): + if ( + isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(value) or value < 0 + ): + raise ValueError(f"legacy usage {name} contains an invalid value") + baseline = json.loads(json.dumps(consumed)) + state["legacyUsage"] = { + "status": "known", + "reason": reason, + "baseline": baseline, + "reconciliation": { + "actor": actor, + "reason": reason, + "evidenceReference": evidence_reference, + "createdAt": _now(), + }, + } + _append_receipt(state, { + "eventType": "legacyUsageReconciled", + "actor": actor, + "reason": reason, + "evidenceReference": evidence_reference, + "consumedSha256": hashlib.sha256(_canonical(baseline)).hexdigest(), + }) + _write_state(path, state) + return budget_summary(root, campaign_id) + + +def extend_budget( + solution: str | Path, + campaign_id: str, + *, + dimension: str, + new_limit: int | float, + actor: str, + reason: str, + evidence_reference: str, +) -> dict[str, Any]: + if dimension not in DEFAULT_POLICY: + raise ValueError(f"unknown budget dimension: {dimension}") + if ( + isinstance(new_limit, bool) or not isinstance(new_limit, (int, float)) + or not math.isfinite(new_limit) or new_limit < 0 + ): + raise ValueError("new budget limit must be a finite non-negative number") + if dimension not in {"activeExecutionSeconds", "ruleActiveSeconds"} and not float( + new_limit + ).is_integer(): + raise ValueError("attempt and revision ceilings must be whole numbers") + if not all(isinstance(value, str) and value.strip() for value in ( + actor, reason, evidence_reference, + )): + raise ValueError("budget extension requires actor, reason, and owner-scope reference") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root) + old_limit = state["policy"][dimension] + if new_limit <= old_limit: + raise ValueError("budget extensions may only raise a ceiling") + state["policy"][dimension] = new_limit + _append_receipt(state, { + "eventType": "policyExtended", + "dimension": dimension, + "oldLimit": old_limit, + "newLimit": new_limit, + "actor": actor, + "reason": reason, + "evidenceReference": evidence_reference, + }) + _write_state(path, state) + return budget_summary(root, campaign_id) + + +def set_observation_deadline( + solution: str | Path, + campaign_id: str, + *, + test_id: str, + deadline_at: str, + approval_reference: str, + actor: str, + reason: str, +) -> dict[str, Any]: + if not all(isinstance(value, str) and value.strip() for value in ( + test_id, approval_reference, actor, reason, + )): + raise ValueError("observation deadline requires test, approval, actor, and reason") + try: + deadline = datetime.fromisoformat(deadline_at.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("deadline must be an ISO-8601 timestamp with a timezone") from exc + if deadline.tzinfo is None: + raise ValueError("deadline must include a timezone") + root = Path(solution).expanduser().resolve() + with _campaign_lock(root, campaign_id) as path: + state = _read_locked(path, campaign_id, root) + if any( + event.get("eventType") == "observationDeadlineSet" + and event.get("testId") == test_id + for event in state["receipts"] + ): + raise BudgetReceiptError("test observation deadline is immutable once recorded") + _append_receipt(state, { + "eventType": "observationDeadlineSet", + "testId": test_id, + "deadlineAt": deadline.astimezone(timezone.utc).isoformat(), + "approvalReference": approval_reference, + "actor": actor, + "reason": reason, + }) + _write_state(path, state) + return budget_summary(root, campaign_id) + + +def budget_summary( + solution: str | Path, + campaign_id: str, + *, + expected_inventory: str | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + path = _budget_path(root, campaign_id) + state = _read_state( + path, campaign_id=campaign_id, solution=root, + inventory_sha256=expected_inventory, + ) + usage = _usage(state) + limits = state["policy"] + remaining = { + "activeExecutionSeconds": max( + 0.0, limits["activeExecutionSeconds"] - usage["activeExecutionSeconds"], + ), + "conversionPasses": max(0, limits["conversionPasses"] - usage["conversionPasses"]), + "packageBuilds": max(0, limits["packageBuilds"] - usage["packageBuilds"]), + "providerHttpAttempts": max( + 0, limits["providerHttpAttempts"] - usage["providerHttpAttempts"], + ), + "ingestionSubmissionsPerApproval": limits["ingestionSubmissionsPerApproval"], + "ruleRevisions": { + rule_id: max(0, limits["ruleRevisions"] - usage["ruleRevisions"].get(rule_id, 0)) + for rule_id in state["inventoryRuleIds"] + }, + "ruleActiveSeconds": { + rule_id: max( + 0.0, + limits["ruleActiveSeconds"] - usage["ruleActiveSeconds"].get(rule_id, 0), + ) + for rule_id in state["inventoryRuleIds"] + }, + } + exhausted = [] + for dimension, used_key, policy_key in ( + ("activeExecutionSeconds", "activeExecutionSeconds", "activeExecutionSeconds"), + ("conversionPass", "conversionPasses", "conversionPasses"), + ("packageBuild", "packageBuilds", "packageBuilds"), + ("providerHttpAttempt", "providerHttpAttempts", "providerHttpAttempts"), + ): + if usage[used_key] >= limits[policy_key]: + exhausted.append(dimension) + if state["legacyUsage"]["status"] != "known": + enforced = "blocked-unknown-history" + else: + enforced = "partial-workflow-and-provider-integration" + deadlines = { + event["testId"]: event["deadlineAt"] + for event in state["receipts"] + if event.get("eventType") == "observationDeadlineSet" + } + latest = state["receipts"][-1]["sha256"] if state["receipts"] else _ZERO_HASH + return { + "schemaVersion": BUDGET_SCHEMA_VERSION, + "campaignId": state["campaignId"], + "campaignRunId": state["campaignRunId"], + "solutionPath": state["solutionPath"], + "inventorySha256": state["inventorySha256"], + "linkedRunIds": list(state["runIds"]), + "policy": dict(limits), + "usage": usage, + "remaining": remaining, + "exhaustedDimensions": exhausted, + "legacyUsageStatus": state["legacyUsage"]["status"], + "legacyUsageReason": state["legacyUsage"].get("reason"), + "enforcement": enforced, + "activeTimeCoverage": { + "measuredOperations": [ + "YOLO local discovery", + "YOLO full-solution conversion", + "YOLO structural validation", + "YOLO V3.1 package invocation", + "explicit alert-observation receipt API calls", + ], + "notIntegrated": [ + "manual workflow stages and report/refresh bookkeeping", + "provider HTTP calls, deployment, ingestion, and cleanup adapters", + ], + "inFlightWorkIsNotPreempted": True, + }, + "providerReceiptIntegration": { + "providerHttpAttempts": "not wired to provider adapters; adapters must call begin/finish_operation per actual request including retries", + "ingestionSubmissions": "not wired to ingestion adapters; adapters must use fresh approval references and attempt markers", + "ruleRevisions": "receipt API available; no revision/rewrite orchestration exists in the current workflow", + "observation": "deadline API available; no polling provider is dispatched by this workflow", + }, + "observationDeadlines": deadlines, + "receiptCount": len(state["receipts"]), + "receiptHeadSha256": latest, + } + + +def package_history(solution: str | Path, campaign_id: str) -> list[dict[str, Any]]: + """Return hash-validated package attempts and their terminal receipts.""" + root = Path(solution).expanduser().resolve() + state = _read_state( + _budget_path(root, campaign_id), campaign_id=campaign_id, solution=root, + ) + starts, finishes = _event_index(state) + return [ + { + "start": start, + "finish": finishes.get(operation_id), + } + for operation_id, start in starts.items() + if start.get("dimension") == "packageBuild" + ] diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/catalog.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/catalog.py new file mode 100644 index 00000000000..7fa68017895 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/catalog.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import re +from typing import Any + +# Native Defender tables expose Timestamp in Advanced Hunting even when their +# Log Analytics connector copy exposes TimeGenerated. +NATIVE_XDR_TABLES = frozenset( + { + "AlertEvidence", + "AlertInfo", + "CloudAppEvents", + "DeviceEvents", + "DeviceFileCertificateInfo", + "DeviceFileEvents", + "DeviceImageLoadEvents", + "DeviceInfo", + "DeviceLogonEvents", + "DeviceNetworkEvents", + "DeviceNetworkInfo", + "DeviceProcessEvents", + "DeviceRegistryEvents", + "DeviceTvmSecureConfigurationAssessment", + "DeviceTvmSoftwareInventory", + "DeviceTvmSoftwareVulnerabilities", + "EmailAttachmentInfo", + "EmailEvents", + "EmailPostDeliveryEvents", + "EmailUrlInfo", + "IdentityDirectoryEvents", + "IdentityLogonEvents", + "IdentityQueryEvents", + "UrlClickEvents", + } +) + +# These tables were verified to resolve in Advanced Hunting with their Sentinel +# name and time-column conventions. The catalog is intentionally extensible; +# runtime validation remains the final authority. +PASSTHROUGH_TABLES: dict[str, dict[str, Any]] = { + "AzureActivity": { + "columnRenames": {}, + "notes": "Sentinel workload table; preserve TimeGenerated and source schema.", + }, + "IdentityInfo": { + "columnRenames": { + "AccountUPN": "AccountUpn", + "AccountCreationTime": "CreatedDateTime", + "UserType": "TenantMembershipType", + }, + "notes": "Identity profile table with verified Sentinel-to-AH field names.", + }, + "Event": { + "columnRenames": {}, + "notes": "Sentinel workload table; availability depends on the unified tenant.", + }, + "SecurityEvent": { + "columnRenames": {}, + "notes": "Sentinel workload table; preserve TimeGenerated and source schema.", + }, + "WindowsEvent": { + "columnRenames": {}, + "notes": "Sentinel workload table; preserve TimeGenerated and source schema.", + }, +} + + +def referenced_catalog_tables(query: str) -> set[str]: + import re + + known = NATIVE_XDR_TABLES | PASSTHROUGH_TABLES.keys() + return { + table + for table in known + if re.search(rf"(? set[str]: + return set( + re.findall(r"(? list[dict[str, str | None]]: + """Return conservative table/alias bindings found at KQL table positions. + + This deliberately recognizes only common table sources. It is evidence + gathering, not a KQL parser; callers must treat unrecognized shapes as + unresolved rather than guessing an alias or column owner. + """ + token_pattern = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|[A-Za-z_][A-Za-z0-9_]*|[^\s]" + ) + tokens = [ + match.group(0) + for match in token_pattern.finditer(query) + if not match.group(0).startswith(("//", "/*", "'", '"', "@'", '@"')) + ] + known = {table.casefold(): table for table in NATIVE_XDR_TABLES | PASSTHROUGH_TABLES.keys()} + results: list[dict[str, str | None]] = [] + seen: set[tuple[str, str | None]] = set() + for index, token in enumerate(tokens): + lowered = token.casefold() + is_custom = re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*_CL", token, re.IGNORECASE) + is_simple_base = ( + index == 0 + and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token) + and (index + 1 == len(tokens) or tokens[index + 1].casefold() == "as" or tokens[index + 1] == "|") + ) + if lowered in known or is_custom or is_simple_base: + previous = tokens[index - 1].casefold() if index else "" + at_start = index == 0 or previous in {"=", "(", ",", ";", "join", "union", "lookup", "from"} + if not at_start: + continue + canonical = known.get(lowered, token) + alias: str | None = None + following = index + 1 + if following < len(tokens) and tokens[following].casefold() == "as": + if following + 1 < len(tokens) and re.fullmatch(r"[A-Za-z_]\w*", tokens[following + 1]): + alias = tokens[following + 1] + elif following < len(tokens) and re.fullmatch(r"[A-Za-z_]\w*", tokens[following]): + if tokens[following].casefold() not in { + "where", "join", "union", "lookup", "on", "project", "extend", + "summarize", "as", "by", "kind", + }: + alias = tokens[following] + binding = (canonical.casefold(), alias.casefold() if alias else None) + if binding not in seen: + results.append({"table": canonical, "alias": alias}) + seen.add(binding) + return results + + +def query_has_ambiguous_table_scope(query: str) -> bool: + references = query_table_references(query) + if len(references) != 1: + return True + lexical = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|[A-Za-z_][A-Za-z0-9_]*" + ) + tokens = [ + match.group(0).casefold() + for match in lexical.finditer(query) + if not match.group(0).startswith(("//", "/*", "'", '"', "@'", '@"')) + ] + if any( + token in {"join", "union", "lookup"} + and (index == 0 or tokens[index - 1] == "|") + for index, token in enumerate(tokens) + ): + return True + table = re.escape(str(references[0]["table"])) + code = query.strip() + while code.startswith("//") or code.startswith("/*"): + if code.startswith("//"): + newline = code.find("\n") + code = code[newline + 1:] if newline >= 0 else "" + else: + close = code.find("*/", 2) + code = code[close + 2:] if close >= 0 else "" + code = code.lstrip() + return not bool(re.match( + rf"(?is)^\s*{table}\s+(?:as\s+[A-Za-z_]\w*\s*)?(?:\||$)", + code, + ) or re.match(rf"(?is)^\s*{table}\s*(?:\||$)", code)) + + +def configured_column_renames(query: str, config: dict[str, Any]) -> tuple[dict[str, str], bool]: + """Resolve configured column renames only when the query scope is singular.""" + configured = config.get("columnMappings") or {} + if not configured: + return {}, False + if query_has_ambiguous_table_scope(query): + return {}, True + + references = query_table_references(query) + if len(references) != 1: + return {}, True + table = references[0]["table"].casefold() + if all(isinstance(value, str) for value in configured.values()): + return ({str(key): str(value) for key, value in configured.items()}, False) + + selected: dict[str, str] = {} + for name, value in configured.items(): + if isinstance(value, dict) and table is not None and str(name).casefold() == table: + selected.update({str(key): str(target) for key, target in value.items()}) + return selected, False + + +def query_has_mixed_time_semantics(query: str) -> bool: + tables = referenced_catalog_tables(query) + return bool(tables & NATIVE_XDR_TABLES) and bool( + tables & PASSTHROUGH_TABLES.keys() + ) + + +def query_column_renames(query: str) -> dict[str, str]: + renames: dict[str, str] = {} + tables = referenced_catalog_tables(query) + if tables & NATIVE_XDR_TABLES and not tables & PASSTHROUGH_TABLES.keys(): + renames["TimeGenerated"] = "Timestamp" + for table in tables & PASSTHROUGH_TABLES.keys(): + renames.update(PASSTHROUGH_TABLES[table]["columnRenames"]) + return renames diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py new file mode 100644 index 00000000000..5260806082e --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py @@ -0,0 +1,1625 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import yaml + +from .converter import ( + configure_logging, + convert_solution, + inspect_solution, + runtime_validation_plan, + validate_solution, +) +from .artifacts import migrate_reports, select_run, reset_run_selection, list_runs +from .evidence import export_evidence +from .budget import ( + DEFAULT_POLICY, + budget_summary, + extend_budget, + reconcile_legacy_usage, + reconcile_pending_operation, + set_observation_deadline, +) +from .onboarding import configure_workspace, doctor, setup +from .deployment import deploy_solution, setup_deployment_authentication +from .deployment_planning import persist_deployment_plan, record_planning_consent +from .graph_readiness import preflight_deployment +from .analytic_deployment import deploy_analytic_rules +from .arm_acceptance import ( + execute_arm_template_acceptance_batch, + plan_arm_template_acceptance_batch, +) +from .alert_parity import ( + abort_alert_parity, + complete_alert_parity, + start_alert_parity, + start_alert_parity_batch, +) +from .runtime import ( + record_runtime_validation, + validate_advanced_hunting, + validate_sentinel_sdk, +) +from .runtime_consent import ( + SOURCE_PROVIDERS, + confirm_primary_lab_setting, + persist_runtime_query_plan, + record_runtime_query_consent, + revoke_runtime_query_consent, + runtime_query_consent_status, +) +from .solution_report import build_solution_report +from .target_context import diagnose_target, repair_target, require_locked_target +from .target_config import resolve_qualification_target +from .workflow import ( + STAGES, + WORKFLOW_PROFILES, + complete_workflow_stage, + initialize_workflow, + next_workflow_stage, + read_authoring_contract, + package_workflow_v3_1, + plan_new_package_release, + plan_release_notes_refresh, + plan_same_version_content_rebuild, + recover_conversion_evidence, + refresh_yolo_authoring, + run_yolo_authoring, + start_workflow_stage, + verify_same_version_content_rebuild, + workflow_status, +) +from .packaging_selection import ( + apply_package_selection, + create_package_selection_plan, +) +from .coordinator import ( + acknowledge_outbox, + claim as coordinator_claim, + enroll as coordinator_enroll, + heartbeat as coordinator_heartbeat, + reconcile as coordinator_reconcile, + record_event as coordinator_record_event, + status as coordinator_status, +) + +SUPPORTED_PYTHON_MIN = (3, 11) +SUPPORTED_PYTHON_MAX_EXCLUSIVE = (3, 13) + + +def _print(value: dict) -> None: + print(json.dumps(value, indent=2)) + + +def _key_values(values: list[str] | None) -> dict[str, str]: + result: dict[str, str] = {} + for value in values or []: + key, separator, item = value.partition("=") + if not separator or not key.strip() or not item.strip(): + raise ValueError(f"expected NAME=VALUE, received: {value}") + if key in result: + raise ValueError(f"duplicate artifact name: {key}") + result[key] = item + return result + + +def _package_batch_members(values: list[str]) -> list[dict[str, str]]: + members: list[dict[str, str]] = [] + for value in values: + solution_path, separator, run_id = value.rpartition("::") + if not separator or not solution_path.strip() or not run_id.strip(): + raise ValueError( + f"expected --member SOLUTION_PATH::RUN_ID, received: {value}" + ) + members.append( + {"solution": solution_path.strip(), "runId": run_id.strip()} + ) + return members + + +def _require_explicit_authoring_run(solution: str, run_id: str | None) -> dict[str, Any]: + state = workflow_status(solution) + if ( + state.get("context", {}).get("workflowProfile") == "authoring" + and not run_id + ): + raise ValueError( + "Authoring Advanced Hunting requires an explicit --run-id; " + "no run is inferred" + ) + return state + + +def main(argv: list[str] | None = None) -> int: + if not ( + SUPPORTED_PYTHON_MIN + <= sys.version_info[:2] + < SUPPORTED_PYTHON_MAX_EXCLUSIVE + ): + print( + "sentinel-xdr-migration requires Python 3.11 or 3.12. " + "Install or select a supported interpreter; do not modify toolkit " + "source to compensate for the local Python version.", + file=sys.stderr, + ) + return 2 + + parser = argparse.ArgumentParser( + description="Convert Microsoft Sentinel analytic rules into XDR Custom Detection YAML." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + migration = subparsers.add_parser("migrate-reports", help="Plan a non-destructive copy of legacy reports into solution Logs.") + migration.add_argument("--solution", required=True) + migration.add_argument("--apply", action="store_true", help="Copy after reviewing the dry-run; originals are preserved.") + evidence = subparsers.add_parser("export-evidence", help="Explicitly export immutable, curated local PR evidence; no cloud writes.") + evidence.add_argument("--solution", required=True) + evidence.add_argument( + "--include-authoring-contract", + action="store_true", + help="Include the selected run's validated, immutable YOLO contract projection.", + ) + for command in ( + "inspect", + "convert", + "validate", + "validation-plan", + "validate-advanced-hunting", + "validate-sentinel-sdk", + ): + subparser = subparsers.add_parser(command) + subparser.add_argument("--solution", required=True) + if command == "convert": + subparser.add_argument("--overwrite", action="store_true") + subparser.add_argument("--config") + subparser.add_argument( + "--rule-id", action="append", + help="Convert exactly one source template GUID; does not establish solution readiness.", + ) + if command == "validate-advanced-hunting": + subparser.add_argument( + "--provider-gap-evidence", + help="JSON evidence for a complete Graph fallback after a Triage MCP provider failure.", + ) + if command == "validate-sentinel-sdk": + subparser.add_argument( + "--request-timeout-seconds", + type=int, + default=90, + help="Maximum server/network timeout per Logs query (1-600 seconds).", + ) + subparser.add_argument( + "--family-timeout-seconds", + type=int, + default=1800, + help="Maximum elapsed budget for this complete source-query family (1-21600 seconds).", + ) + subparser.add_argument( + "--provider-gap-evidence", + help="JSON evidence for the complete Azure Monitor SDK fallback after a Triage MCP provider failure.", + ) + setup_parser = subparsers.add_parser( + "setup", help="Initialize the toolkit and optionally launch guided authentication." + ) + setup_parser.add_argument( + "--non-interactive", + action="store_true", + help="Inspect and initialize without opening authentication prompts.", + ) + setup_parser.add_argument("--tenant-id") + setup_parser.add_argument("--workspace-id") + setup_parser.add_argument( + "--hunting-client-id", + help="Public delegated Graph client ID for Advanced Hunting; the built-in client remains the default.", + ) + setup_parser.add_argument( + "--azure-config-dir", + help="Explicitly select and persist an isolated Azure CLI profile for this target.", + ) + setup_parser.add_argument( + "--hunting-auth-method", + choices=["device-code", "browser"], + default="device-code", + help="Interactive authentication method for Microsoft Graph.", + ) + setup_parser.add_argument("--skip-sentinel-auth", action="store_true") + setup_parser.add_argument("--skip-hunting-auth", action="store_true") + doctor_parser = subparsers.add_parser( + "doctor", help="Report setup and runtime readiness." + ) + doctor_parser.add_argument( + "--solution", + help="Also inspect this solution's local .env Qualification target.", + ) + workspace_config = subparsers.add_parser( + "configure-workspace", + help="Persist one approved workspace for reuse across solution workflows.", + ) + workspace_config.add_argument("--workspace-resource-id", required=True) + workspace_config.add_argument("--workspace-customer-id") + workspace_config.add_argument("--tenant-id") + workspace_config.add_argument("--subscription-id") + target_diagnostics = subparsers.add_parser( + "qualification-diagnose", + help="Diagnose the one locked Qualification target without broad discovery.", + ) + target_diagnostics.add_argument("--solution", required=True) + target_repair = subparsers.add_parser( + "qualification-repair-target", + help="Apply the exact target correction proposed by current diagnostics.", + ) + target_repair.add_argument("--solution", required=True) + target_repair.add_argument("--approve-target-update", action="store_true") + deployment_setup = subparsers.add_parser( + "setup-deployment", + help="Authenticate for Microsoft Graph Custom Detection deployment.", + ) + deployment_setup.add_argument("--tenant-id") + deployment_setup.add_argument("--solution", required=True) + deployment_setup.add_argument( + "--custom-detection-client-id", + help="Public delegated Graph client ID for Custom Detection; the built-in client remains the default.", + ) + deployment_setup.add_argument( + "--azure-config-dir", + help="Explicitly select and persist an isolated Azure CLI profile for this target.", + ) + app_registration_auth = subparsers.add_parser( + "setup-app-registration-auth", + help="Explicitly authenticate with the least-privilege app-registration bootstrap scope.", + ) + app_registration_auth.add_argument("--solution", required=True) + app_registration_auth.add_argument( + "--auth-method", + choices=["device-code", "browser"], + default="device-code", + ) + app_registration_auth.add_argument( + "--bootstrap-client-id", + help="Existing public Graph client ID already able to request the bootstrap scopes; defaults to the built-in client.", + ) + app_readback_auth = subparsers.add_parser( + "setup-app-readback-auth", + help="Set up the separate browser-only, GET-only delegated app verification credential.", + ) + app_readback_auth.add_argument("--solution", required=True) + app_readback_auth.add_argument( + "--readback-client-id", + required=True, + help=( + "Existing approved public Graph client ID; readback uses a separate " + "auth purpose, record, and token cache." + ), + ) + app_readback = subparsers.add_parser( + "verify-delegated-app-readback", + help="Read-only reconciliation for the exact approved owned application; performs GET requests only.", + ) + app_readback.add_argument("--solution", required=True) + app_readback_scope_diagnostic = subparsers.add_parser( + "diagnose-app-readback-scopes", + help="Report required and extra cached readback-token scope names; makes no Microsoft Graph resource requests.", + ) + app_readback_scope_diagnostic.add_argument("--solution", required=True) + app_registration_plan = subparsers.add_parser( + "plan-delegated-app-registration", + help="Show the exact single-tenant delegated desktop-app plan; performs no cloud calls.", + ) + app_registration_plan.add_argument("--solution", required=True) + app_registration_plan.add_argument( + "--app-display-name", + help="Exact application display name to bind into the reviewed plan; defaults to the legacy name.", + ) + app_registration = subparsers.add_parser( + "register-delegated-app", + help="Create at most one explicitly approved delegated Graph desktop app; grants no consent.", + ) + app_registration.add_argument("--solution", required=True) + app_registration.add_argument( + "--app-display-name", + help="Exact application display name bound into the reviewed plan; defaults to the legacy name.", + ) + app_registration.add_argument("--expected-plan-sha256", required=True) + app_registration.add_argument( + "--approve-exact-app-registration", + action="store_true", + help="Approve only the application registration bound to the supplied plan hash.", + ) + app_permission_plan = subparsers.add_parser( + "plan-delegated-app-permission-update", + help="Show the exact manual permission-addition plan; performs no cloud calls.", + ) + app_permission_plan.add_argument("--solution", required=True) + app_permission_verify = subparsers.add_parser( + "verify-delegated-app-permission-update", + help="Verify the reviewed manual permission update; performs GET requests only.", + ) + app_permission_verify.add_argument("--solution", required=True) + app_permission_verify.add_argument("--expected-plan-sha256", required=True) + deployment_setup.add_argument( + "--auth-method", + choices=["azure-cli", "device-code", "browser"], + default="azure-cli", + ) + deployment = subparsers.add_parser( + "deploy", + help="Create or update generated Custom Detections through Microsoft Graph beta.", + ) + deployment.add_argument("--solution", required=True) + deployment_preflight = subparsers.add_parser( + "preflight-deployment", + help=( + "Run local Graph readiness plus independent read-only DCR, ingestion, " + "query, and deployment permission diagnostics." + ), + ) + deployment_preflight.add_argument("--solution", required=True) + deployment_preflight.add_argument( + "--auth-method", + choices=["azure-cli", "device-code", "browser"], + help=( + "Use this existing cached credential method only; authentication " + "is never started by preflight." + ), + ) + deployment_plan = subparsers.add_parser( + "plan-deployment", + help="Persist a deterministic local Qualification deployment-scope plan; performs no cloud calls.", + ) + deployment_plan.add_argument("--solution", required=True) + runtime_query_plan = subparsers.add_parser( + "plan-runtime-query-consent", + help="Create the exact, local-only read-only query inventory and consent plan for the selected workflow profile.", + ) + runtime_query_plan.add_argument("--solution", required=True) + runtime_query_plan.add_argument( + "--source-provider", + choices=sorted(SOURCE_PROVIDERS), + default="azure-monitor-sdk", + help="Exact Sentinel source-query provider route to bind; does not approve execution.", + ) + primary_setting = subparsers.add_parser( + "confirm-primary-lab-setting", + help="Explicitly attest the primary workspace setting before shared lab read approval.", + ) + primary_setting.add_argument("--solution", required=True) + primary_setting.add_argument("--actor", required=True) + primary_setting.add_argument("--primary-setting-name", required=True) + primary_setting.add_argument( + "--confirm-primary-setting", + action="store_true", + help="Confirm the named setting; this is user attestation, not service verification.", + ) + runtime_query_consent = subparsers.add_parser( + "review-runtime-query-consent", + help=( + "Record explicit query-family approval for the selected workflow profile, " + "revise, or decline after reviewing the current inventory. Approval never " + "authorizes authentication or writes." + ), + ) + runtime_query_consent.add_argument("--solution", required=True) + runtime_query_consent.add_argument("--expected-plan-sha256", required=True) + runtime_query_consent.add_argument("--actor", required=True) + runtime_query_consent.add_argument( + "--source-provider", + choices=sorted(SOURCE_PROVIDERS), + default="azure-monitor-sdk", + ) + runtime_query_consent.add_argument( + "--approve-family", + action="append", + choices=["sentinelSourceQueryFamily", "defenderXdrQueryFamily"], + default=[], + ) + runtime_query_consent.add_argument("--decline", action="store_true") + runtime_query_consent.add_argument( + "--revise-plan", + action="store_true", + help="Return to plan review without recording consent.", + ) + runtime_query_consent.add_argument("--reason") + revoke_runtime_consent = subparsers.add_parser( + "revoke-lab-runtime-query-consent", + help="Revoke shared lab runtime-query consent for the currently locked lab.", + ) + revoke_runtime_consent.add_argument("--solution", required=True) + revoke_runtime_consent.add_argument("--actor", required=True) + revoke_runtime_consent.add_argument("--reason", required=True) + runtime_query_check = subparsers.add_parser( + "check-runtime-query-consent", + help="Check an exact approved family/provider binding before external provider dispatch.", + ) + runtime_query_check.add_argument("--solution", required=True) + runtime_query_check.add_argument( + "--family", + required=True, + choices=["sentinelSourceQueryFamily", "defenderXdrQueryFamily"], + ) + runtime_query_check.add_argument( + "--provider", + required=True, + choices=["azure-monitor-sdk", "triage-mcp", "log-analytics-cli", "graph"], + ) + runtime_query_check.add_argument( + "--source-provider", + choices=sorted(SOURCE_PROVIDERS), + default="azure-monitor-sdk", + ) + planning_consent = subparsers.add_parser( + "review-deployment-plan", + help="Record or decline component-specific planning consent bound to the current plan hash; authorizes no writes.", + ) + planning_consent.add_argument("--solution", required=True) + planning_consent.add_argument("--expected-plan-sha256", required=True) + planning_consent.add_argument("--actor", required=True) + planning_consent.add_argument( + "--approve-component", + action="append", + choices=[ + "sentinelAlertRules", + "xdrCustomDetections", + "fullPackageAcceptance", + "mockTelemetryIngestion", + "fallbackDcr", + "fallbackDce", + "customTableCreate", + ], + default=[], + ) + planning_consent.add_argument("--decline", action="store_true") + planning_consent.add_argument("--reason") + planning_consent.add_argument("--max-additional-custom-detections", type=int) + planning_consent.add_argument( + "--package-flag", + action="append", + help="Explicit package selection as NAME=true|false; repeat for every package flag.", + ) + planning_consent.add_argument( + "--dce-selected", + action="store_true", + help="Explicitly select the conditional DCE component for this plan.", + ) + analytic_deployment = subparsers.add_parser( + "deploy-analytic-rules", + help="Create or update source Sentinel analytic rules through Azure Resource Manager.", + ) + analytic_deployment.add_argument("--solution", required=True) + analytic_deployment.add_argument("--workspace-resource-id") + arm_acceptance_plan = subparsers.add_parser( + "plan-arm-template-acceptance-batch", + help="Plan exact post-validation ARM template deployments for every solution in the approved same-lab workstream.", + ) + arm_acceptance_plan.add_argument("--member", action="append", required=True) + arm_acceptance_plan.add_argument( + "--validation-receipt", + action="append", + required=True, + help="Current build-and-validate-report.md for the corresponding --member; repeat in matching order.", + ) + arm_acceptance_plan.add_argument("--shared-decisions", required=True) + arm_acceptance = subparsers.add_parser( + "deploy-arm-template-acceptance-batch", + help="Submit the exact final V3.1 mainTemplates; accept only recorded ARM provisioningState Succeeded.", + ) + arm_acceptance.add_argument("--member", action="append", required=True) + arm_acceptance.add_argument( + "--validation-receipt", + action="append", + required=True, + help="Same current build-and-validate-report.md for the corresponding --member; repeat in matching order.", + ) + arm_acceptance.add_argument("--shared-decisions", required=True) + arm_acceptance.add_argument("--expected-batch-plan-sha256", required=True) + arm_acceptance.add_argument("--actor", required=True) + arm_acceptance.add_argument("--confirm-exact-scope-write", required=True) + parity_start = subparsers.add_parser( + "start-alert-parity", + help="Enable disabled AR/CD pairs, ingest a marked mock payload, and emit capture queries.", + ) + parity_start.add_argument("--solution", required=True) + parity_start.add_argument("--workspace-resource-id") + parity_start.add_argument("--contract", required=True) + parity_start.add_argument("--payload", required=True) + parity_start.add_argument("--scenario-marker", required=True) + parity_start.add_argument( + "--expected-match-key", + action="append", + dest="expected_match_keys", + help="Exact malicious alert match key; repeat for multiple expected alerts. Defaults to the scenario marker.", + ) + parity_start.add_argument( + "--detection", + action="append", + dest="detections", + help="Detection filename or stem to validate; repeat to select multiple.", + ) + parity_complete = subparsers.add_parser( + "complete-alert-parity", + help="Compare normalized AR/CD alerts strictly and disable every test rule.", + ) + parity_complete.add_argument("--solution", required=True) + parity_complete.add_argument("--results", required=True) + parity_batch = subparsers.add_parser( + "start-alert-parity-batch", + help="Enable all reviewed AR/CD pairs and ingest one fixture per detection.", + ) + parity_batch.add_argument("--solution", required=True) + parity_batch.add_argument("--workspace-resource-id") + parity_batch.add_argument("--plan", required=True) + parity_abort = subparsers.add_parser( + "abort-alert-parity", + help="Disable every rule associated with an active parity run.", + ) + parity_abort.add_argument("--solution", required=True) + report_parser = subparsers.add_parser( + "solution-report", + help="Build one consolidated per-rule AR/CD migration report.", + ) + report_parser.add_argument("--solution", required=True) + report_parser.add_argument( + "--ingestion-report", + action="append", + dest="ingestion_reports", + help="Ingestion report JSON to include; repeat for multiple streams.", + ) + record_parser = subparsers.add_parser( + "record-runtime-validation", + help="Normalize and report results produced by an external runtime provider.", + ) + record_parser.add_argument("--solution", required=True) + record_parser.add_argument( + "--provider", + required=True, + choices=["triage-mcp", "azure-monitor-sdk", "log-analytics-cli"], + ) + record_parser.add_argument( + "--source-provider", + choices=sorted(SOURCE_PROVIDERS), + default="azure-monitor-sdk", + help="Exact Sentinel route bound by the runtime-query consent plan.", + ) + record_parser.add_argument("--results", required=True) + record_parser.add_argument( + "--provider-gap-evidence", + help="JSON evidence for a complete Log Analytics fallback after a Triage MCP provider failure.", + ) + package_v3_1 = subparsers.add_parser( + "package-v3-1", + help="Package with V3.1; initialized YOLO runs enforce workflow and fresh-evidence gates.", + ) + package_v3_1.add_argument("--solution", required=True) + package_v3_1.add_argument( + "--toolkit-root", + help=( + "Repository root containing the V3.1 packaging toolkit. Defaults to the " + "solution's repository root." + ), + ) + package_v3_1.add_argument( + "--version-bump", + required=True, + choices=["none", "patch", "minor", "major"], + ) + package_v3_1.add_argument( + "--target-version", + help="Explicit canonical V3.1 solution version (>=3.1.0 and <4.0.0); use with --version-bump none.", + ) + package_v3_1.add_argument( + "--legacy-baseline-evidence", + help=( + "Receipt-bound package-input comparison JSON for a legacy same-version " + "rebuild plan that predates per-file V3.1 receipts." + ), + ) + selection_plan = subparsers.add_parser( + "package-selection-plan", + help="Plan evidence-based Analytic Rule and Custom Detection package eligibility.", + ) + selection_plan.add_argument("--solution", required=True) + selection_apply = subparsers.add_parser( + "package-selection-apply", + help="Apply a fresh, hash-bound package selection to Solution_*.json.", + ) + selection_apply.add_argument("--solution", required=True) + selection_apply.add_argument("--expected-plan-sha256", required=True) + package_release = subparsers.add_parser( + "workflow-plan-package-release", + help="Explicitly select a new V3.1 release action for changed package inputs.", + ) + package_release.add_argument("--solution", required=True) + package_release.add_argument("--version-bump", choices=["patch", "minor", "major"]) + package_release.add_argument( + "--target-version", + help="Explicit canonical V3.1 solution version (>=3.1.0 and <4.0.0), instead of a version bump.", + ) + package_release.add_argument( + "--expected-package-input-sha256", + required=True, + help="Exact current package-input SHA-256 reported by workflow-status.", + ) + notes_refresh = subparsers.add_parser( + "workflow-plan-release-notes-refresh", + help="Authorize one exact ReleaseNotes-only refresh without incrementing the package version.", + ) + notes_refresh.add_argument("--solution", required=True) + notes_refresh.add_argument("--release-date", required=True) + notes_refresh.add_argument("--change-history", required=True) + notes_refresh.add_argument( + "--expected-package-input-sha256", + required=True, + help="Current hash of the already successfully packaged inputs.", + ) + same_version_rebuild = subparsers.add_parser( + "workflow-plan-same-version-content-rebuild", + help=( + "Explicitly approve one exact-input Authoring rebuild at the current " + "solution version after content changes." + ), + ) + same_version_rebuild.add_argument("--solution", required=True) + same_version_rebuild.add_argument( + "--target-version", + required=True, + help="Exact current solution version to preserve; no version bump is performed.", + ) + same_version_rebuild.add_argument( + "--expected-package-input-sha256", + required=True, + help="Exact current package-input SHA-256 reported by workflow-status.", + ) + same_version_rebuild.add_argument( + "--changed-input", + action="append", + required=True, + help=( + "Exact current non-ReleaseNotes package-input path in the approved " + "change scope; repeat once per changed input." + ), + ) + same_version_rebuild.add_argument( + "--legacy-baseline-evidence", + help=( + "Receipt-bound package-input comparison JSON used only when the prior " + "successful V3.1 receipt has no per-file manifest." + ), + ) + same_version_verify = subparsers.add_parser( + "workflow-verify-same-version-content-rebuild", + help="Read-only verification of the exact scope and receipt for a same-version rebuild.", + ) + same_version_verify.add_argument("--solution", required=True) + same_version_verify.add_argument( + "--legacy-baseline-evidence", + help="Receipt-bound comparison JSON for a legacy rebuild plan without a saved evidence binding.", + ) + workflow_init = subparsers.add_parser( + "workflow-init", + help="Create or resume the gated end-to-end migration workflow state.", + ) + workflow_init.add_argument("--solution", required=True) + workflow_init.add_argument("--tenant-id") + workflow_init.add_argument("--subscription-id") + workflow_init.add_argument("--workspace-resource-id") + workflow_init.add_argument("--workspace-customer-id") + workflow_init.add_argument( + "--confirm-configured-target", + action="store_true", + help="Confirm the tenant/workspace shown by doctor for a new Qualification run.", + ) + workflow_init.add_argument( + "--version-bump", + choices=["none", "patch", "minor", "major"], + ) + workflow_init.add_argument( + "--target-version", + help="Persist an explicit canonical V3.1 solution version (>=3.1.0 and <4.0.0); use with --version-bump none.", + ) + workflow_init.add_argument( + "--workflow-profile", + required=True, + choices=WORKFLOW_PROFILES, + help="Explicitly select authoring or qualification before initialization.", + ) + workflow_init.add_argument( + "--yolo", + action="store_true", + help="Opt into full-inventory, resumable local authoring orchestration for a new run.", + ) + workflow_init.add_argument( + "--campaign-id", + help="Explicit durable budget campaign identity; required for every new YOLO campaign.", + ) + workflow_yolo = subparsers.add_parser( + "workflow-yolo", + help="Run the existing local discovery, full-solution converter, and structural validator under an initialized YOLO contract.", + ) + workflow_yolo.add_argument("--solution", required=True) + workflow_yolo_refresh = subparsers.add_parser( + "workflow-yolo-refresh", + help="Import current hash-bound provider reports without rerunning conversion or validation.", + ) + workflow_yolo_refresh.add_argument("--solution", required=True) + workflow_status_parser = subparsers.add_parser( + "workflow-status", + help="Show the persisted end-to-end migration workflow state.", + ) + workflow_status_parser.add_argument("--solution", required=True) + workflow_status_parser.add_argument( + "--source-provider", + choices=sorted(SOURCE_PROVIDERS), + help="Override the Sentinel source route; otherwise use the persisted plan route.", + ) + workflow_next = subparsers.add_parser( + "workflow-next", + help="Return the next stage whose dependencies have passed.", + ) + workflow_next.add_argument("--solution", required=True) + workflow_recover_conversion = subparsers.add_parser( + "workflow-recover-conversion-evidence", + help=( + "Revalidate and bind a legacy full-solution conversion manifest " + "for offline Authoring in the selected run." + ), + ) + workflow_recover_conversion.add_argument("--solution", required=True) + workflow_start = subparsers.add_parser( + "workflow-start-stage", + help="Start or retry one gated workflow stage.", + ) + workflow_start.add_argument("--solution", required=True) + workflow_start.add_argument("--stage", required=True, choices=STAGES) + workflow_complete = subparsers.add_parser( + "workflow-complete-stage", + help="Persist the terminal result and evidence for a running stage.", + ) + workflow_complete.add_argument("--solution", required=True) + workflow_complete.add_argument("--stage", required=True, choices=STAGES) + workflow_complete.add_argument( + "--status", + required=True, + choices=["passed", "failed", "blocked"], + ) + workflow_complete.add_argument("--message") + workflow_complete.add_argument( + "--artifact", + action="append", + help="Stage artifact as NAME=VALUE; repeat for multiple artifacts.", + ) + workflow_complete.add_argument( + "--evidence", + action="append", + help="Evidence path or reference; repeat for multiple values.", + ) + coordinator_enroll_parser = subparsers.add_parser( + "coordinator-enroll", + help="Enroll a selected persisted run with an accountable owner; shadow mode only.", + ) + coordinator_enroll_parser.add_argument("--solution", required=True) + coordinator_enroll_parser.add_argument("--owner", required=True) + coordinator_enroll_parser.add_argument( + "--shared-dependency", + action="append", + help="Optional exact prerequisite as ACTION=KEY:PURPOSE; repeat as needed.", + ) + coordinator_enroll_parser.add_argument( + "--registry", help="Override the ignored local registry path." + ) + for command, help_text in ( + ("coordinator-reconcile", "Refresh coordination decisions from workflow-status and workflow-next."), + ("coordinator-status", "Show the current durable coordination view and watchdog exceptions."), + ): + coordinator_parser = subparsers.add_parser(command, help=help_text) + coordinator_parser.add_argument("--solution", required=True) + coordinator_parser.add_argument("--idle-seconds", type=int, default=900) + coordinator_parser.add_argument("--registry", help="Override the ignored local registry path.") + coordinator_event = subparsers.add_parser( + "coordinator-event", + help="Append one idempotent coordinator event; approvals are never granted here.", + ) + coordinator_event.add_argument("--solution", required=True) + coordinator_event.add_argument("--event-id", required=True) + coordinator_event.add_argument( + "--kind", + required=True, + choices=[ + "executor-completed", + "approval-requested", + "shared-dependency-completed", + "claim-resolved", + "owner-handoff", + ], + ) + coordinator_event.add_argument("--action-id") + coordinator_event.add_argument("--purpose") + coordinator_event.add_argument("--target-sha256") + coordinator_event.add_argument("--evidence") + coordinator_event.add_argument("--actor") + coordinator_event.add_argument("--new-owner") + coordinator_event.add_argument("--claim-id") + coordinator_event.add_argument("--outcome", choices=["not-started", "completed", "unknown"]) + coordinator_event.add_argument("--reference") + coordinator_event.add_argument("--registry") + coordinator_claim_parser = subparsers.add_parser( + "coordinator-claim", + help="Atomically claim one ready local action; does not dispatch it.", + ) + coordinator_claim_parser.add_argument("--solution", required=True) + coordinator_claim_parser.add_argument("--action-id", required=True) + coordinator_claim_parser.add_argument("--executor", required=True) + coordinator_claim_parser.add_argument("--claim-key", required=True) + coordinator_claim_parser.add_argument("--lease-seconds", type=float, default=300) + coordinator_claim_parser.add_argument("--registry") + coordinator_heartbeat_parser = subparsers.add_parser( + "coordinator-heartbeat", + help="Extend a live claim after rechecking its exact evidence and target.", + ) + coordinator_heartbeat_parser.add_argument("--solution", required=True) + coordinator_heartbeat_parser.add_argument("--claim-id", required=True) + coordinator_heartbeat_parser.add_argument("--executor", required=True) + coordinator_heartbeat_parser.add_argument("--lease-seconds", type=float, default=300) + coordinator_heartbeat_parser.add_argument("--registry") + coordinator_ack_parser = subparsers.add_parser( + "coordinator-ack", + help="Acknowledge outbox delivery only; this is not user approval.", + ) + coordinator_ack_parser.add_argument("--solution", required=True) + coordinator_ack_parser.add_argument("--delivery-id", required=True) + coordinator_ack_parser.add_argument("--actor", required=True) + coordinator_ack_parser.add_argument("--registry") + budget_status = subparsers.add_parser( + "workflow-budget", + help="Show configured ceilings, measured receipts, unknown usage, and remaining campaign allowance.", + ) + budget_status.add_argument("--solution", required=True) + budget_status.add_argument("--campaign-id", required=True) + budget_extend = subparsers.add_parser( + "workflow-budget-extend", + help="Raise one campaign ceiling with an append-only owner-scope audit receipt.", + ) + budget_extend.add_argument("--solution", required=True) + budget_extend.add_argument("--campaign-id", required=True) + budget_extend.add_argument("--dimension", required=True, choices=sorted(DEFAULT_POLICY)) + budget_extend.add_argument("--new-limit", required=True, type=float) + budget_extend.add_argument("--actor", required=True) + budget_extend.add_argument("--reason", required=True) + budget_extend.add_argument("--reference", required=True) + budget_reconcile = subparsers.add_parser( + "workflow-budget-reconcile", + help="Reconcile one interrupted operation using its authoritative measured outcome.", + ) + budget_reconcile.add_argument("--solution", required=True) + budget_reconcile.add_argument("--campaign-id", required=True) + budget_reconcile.add_argument("--operation-id", required=True) + budget_reconcile.add_argument( + "--status", required=True, choices=["passed", "failed", "cancelled", "unknown"], + ) + budget_reconcile.add_argument("--elapsed-seconds", required=True, type=float) + budget_reconcile.add_argument("--actor", required=True) + budget_reconcile.add_argument("--reason", required=True) + budget_reconcile.add_argument("--reference", required=True) + budget_legacy = subparsers.add_parser( + "workflow-budget-reconcile-legacy", + help="Record an owner-reviewed baseline for a pre-budget run; no history is reconstructed.", + ) + budget_legacy.add_argument("--solution", required=True) + budget_legacy.add_argument("--campaign-id", required=True) + budget_legacy.add_argument("--baseline", required=True, help="JSON file containing every measured legacy dimension.") + budget_legacy.add_argument("--actor", required=True) + budget_legacy.add_argument("--reason", required=True) + budget_legacy.add_argument("--reference", required=True) + budget_deadline = subparsers.add_parser( + "workflow-budget-deadline", + help="Persist the approved schedule-aware alert-observation deadline for one test.", + ) + budget_deadline.add_argument("--solution", required=True) + budget_deadline.add_argument("--campaign-id", required=True) + budget_deadline.add_argument("--test-id", required=True) + budget_deadline.add_argument("--deadline-at", required=True) + budget_deadline.add_argument("--approval-reference", required=True) + budget_deadline.add_argument("--actor", required=True) + budget_deadline.add_argument("--reason", required=True) + + runs_parser = subparsers.add_parser("workflow-runs", help="List run IDs without selecting or modifying state.") + runs_parser.add_argument("--solution", required=True) + for subparser in subparsers.choices.values(): + if any(action.dest == "solution" for action in subparser._actions): + subparser.add_argument("--run-id", help="Select an existing run; required when multiple runs exist.") + for action in subparsers.choices["workflow-plan-package-release"]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact existing workflow run to update." + break + for action in subparsers.choices["workflow-recover-conversion-evidence"]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact existing Authoring run to revalidate." + break + for action in subparsers.choices["workflow-verify-same-version-content-rebuild"]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact workflow run whose rebuild scope is being verified." + break + for action in subparsers.choices["workflow-plan-release-notes-refresh"]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact existing Authoring run to update." + break + for action in subparsers.choices[ + "workflow-plan-same-version-content-rebuild" + ]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact existing Authoring run to update." + break + for name in ("package-selection-plan", "package-selection-apply"): + for action in subparsers.choices[name]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact existing workflow run." + break + for action in subparsers.choices["diagnose-app-readback-scopes"]._actions: + if action.dest == "run_id": + action.required = True + action.help = "Select the exact approved Cisco qualification run." + break + subparsers.choices["workflow-init"].add_argument( + "--new-run", action="store_true", help="Create an isolated new workflow instead of resuming." + ) + args = parser.parse_args(argv) + if args.command == "convert" and args.rule_id and len(args.rule_id) != 1: + parser.error("--rule-id accepts exactly one GUID; repeated selectors are not supported") + selection = None + try: + isolated_permission_commands = { + "plan-delegated-app-permission-update", + "verify-delegated-app-permission-update", + } + if getattr(args, "solution", None) and args.command not in isolated_permission_commands: + selection = select_run(args.solution, args.run_id) + # Read-only status and migration planning must not initialize or change artifacts. + if args.command not in {"migrate-reports", "workflow-status", "workflow-next", "workflow-init", "workflow-runs", "workflow-verify-same-version-content-rebuild", "export-evidence", "preflight-deployment", "plan-deployment", "review-deployment-plan", "plan-runtime-query-consent", "confirm-primary-lab-setting", "review-runtime-query-consent", "revoke-lab-runtime-query-consent", "check-runtime-query-consent", "coordinator-enroll", "coordinator-status", "coordinator-reconcile", "coordinator-event", "coordinator-claim", "coordinator-heartbeat", "coordinator-ack", *isolated_permission_commands} and not ( + args.command == "convert" and args.rule_id + ): + configure_logging(getattr(args, "solution", None)) + if args.command == "coordinator-enroll": + result = coordinator_enroll( + args.solution, + owner=args.owner, + required_shared_dependencies=args.shared_dependency, + registry_path=args.registry, + ) + elif args.command == "coordinator-reconcile": + result = coordinator_reconcile( + args.solution, + registry_path=args.registry, + idle_seconds=args.idle_seconds, + ) + elif args.command == "coordinator-status": + result = coordinator_status( + args.solution, + registry_path=args.registry, + idle_seconds=args.idle_seconds, + ) + elif args.command == "coordinator-event": + result = coordinator_record_event( + args.solution, + event_id=args.event_id, + kind=args.kind, + action_id=args.action_id, + purpose=args.purpose, + target_sha256=args.target_sha256, + evidence=args.evidence, + actor=args.actor, + new_owner=args.new_owner, + claim_id=args.claim_id, + outcome=args.outcome, + reference=args.reference, + registry_path=args.registry, + ) + elif args.command == "coordinator-claim": + result = coordinator_claim( + args.solution, + action_id=args.action_id, + executor=args.executor, + claim_key=args.claim_key, + lease_seconds=args.lease_seconds, + registry_path=args.registry, + ) + elif args.command == "coordinator-heartbeat": + result = coordinator_heartbeat( + args.solution, + claim_id=args.claim_id, + executor=args.executor, + lease_seconds=args.lease_seconds, + registry_path=args.registry, + ) + elif args.command == "coordinator-ack": + result = acknowledge_outbox( + args.solution, + delivery_id=args.delivery_id, + actor=args.actor, + registry_path=args.registry, + ) + elif args.command == "workflow-runs": + result = {"runs": list_runs(args.solution)} + elif args.command == "export-evidence": + if args.include_authoring_contract and not args.run_id: + raise ValueError( + "--include-authoring-contract requires an explicit --run-id" + ) + authoring_contract = ( + read_authoring_contract(args.solution) + if args.include_authoring_contract else None + ) + result = export_evidence( + args.solution, + authoring_contract=authoring_contract, + ) + elif args.command == "migrate-reports": + result = migrate_reports(args.solution, apply=args.apply) + elif args.command == "setup": + result = setup( + interactive=not args.non_interactive, + tenant_id=args.tenant_id, + workspace_id=args.workspace_id, + skip_sentinel_auth=args.skip_sentinel_auth, + skip_hunting_auth=args.skip_hunting_auth, + hunting_auth_method=args.hunting_auth_method, + hunting_client_id=args.hunting_client_id, + azure_config_dir=args.azure_config_dir, + ) + elif args.command == "doctor": + result = doctor(solution=args.solution) + elif args.command == "configure-workspace": + result = configure_workspace( + args.workspace_resource_id, + workspace_customer_id=args.workspace_customer_id, + tenant_id=args.tenant_id, + subscription_id=args.subscription_id, + ) + elif args.command == "qualification-diagnose": + result = diagnose_target(args.solution) + elif args.command == "qualification-repair-target": + result = repair_target( + args.solution, + approve_target_update=args.approve_target_update, + ) + elif args.command == "setup-deployment": + target = require_locked_target( + args.solution, + tenant_id=args.tenant_id, + ) + result = setup_deployment_authentication( + tenant_id=target["tenantId"], + auth_method=args.auth_method, + graph_client_id=args.custom_detection_client_id, + azure_config_dir=args.azure_config_dir, + ) + elif args.command == "setup-app-registration-auth": + target = require_locked_target(args.solution, materialize_missing=False) + from .delegated_app_registration import ( + setup_app_registration_authentication, + ) + + result = setup_app_registration_authentication( + tenant_id=target["tenantId"], + auth_method=args.auth_method, + bootstrap_client_id=args.bootstrap_client_id, + ) + elif args.command == "setup-app-readback-auth": + target = require_locked_target(args.solution, materialize_missing=False) + from .delegated_app_registration import ( + setup_app_readback_authentication, + ) + + result = setup_app_readback_authentication( + tenant_id=target["tenantId"], + readback_client_id=args.readback_client_id, + ) + elif args.command == "verify-delegated-app-readback": + from .delegated_app_registration import verify_delegated_app_readback + + result = verify_delegated_app_readback(args.solution) + elif args.command == "diagnose-app-readback-scopes": + from .delegated_app_registration import diagnose_app_readback_scopes + + result = diagnose_app_readback_scopes( + args.solution, + run_id=args.run_id, + ) + elif args.command == "plan-delegated-app-registration": + from .delegated_app_registration import plan_delegated_app_registration + + result = plan_delegated_app_registration( + args.solution, + app_display_name=args.app_display_name, + ) + elif args.command == "register-delegated-app": + from .delegated_app_registration import register_delegated_app + + result = register_delegated_app( + args.solution, + expected_plan_sha256=args.expected_plan_sha256, + approve_exact_app_registration=args.approve_exact_app_registration, + app_display_name=args.app_display_name, + ) + elif args.command == "plan-delegated-app-permission-update": + from .delegated_app_registration import ( + plan_delegated_app_permission_update, + ) + + result = plan_delegated_app_permission_update(args.solution) + elif args.command == "verify-delegated-app-permission-update": + from .delegated_app_registration import ( + verify_delegated_app_permission_update, + ) + + result = verify_delegated_app_permission_update( + args.solution, + expected_plan_sha256=args.expected_plan_sha256, + ) + elif args.command == "deploy": + result = deploy_solution(args.solution) + elif args.command == "preflight-deployment": + result = preflight_deployment( + args.solution, + auth_method=args.auth_method, + ) + elif args.command == "plan-deployment": + result = persist_deployment_plan(args.solution) + elif args.command == "plan-runtime-query-consent": + _require_explicit_authoring_run(args.solution, args.run_id) + result = persist_runtime_query_plan( + args.solution, + source_provider=args.source_provider, + ) + elif args.command == "confirm-primary-lab-setting": + result = confirm_primary_lab_setting( + args.solution, + actor=args.actor, + primary_setting_name=args.primary_setting_name, + confirmed=args.confirm_primary_setting, + ) + elif args.command == "review-runtime-query-consent": + _require_explicit_authoring_run(args.solution, args.run_id) + result = record_runtime_query_consent( + args.solution, + expected_plan_sha256=args.expected_plan_sha256, + actor=args.actor, + approved_families=args.approve_family, + declined=args.decline, + revised=args.revise_plan, + reason=args.reason, + source_provider=args.source_provider, + ) + elif args.command == "revoke-lab-runtime-query-consent": + result = revoke_runtime_query_consent( + args.solution, + actor=args.actor, + reason=args.reason, + ) + elif args.command == "check-runtime-query-consent": + from .runtime_consent import require_runtime_query_consent + + result = require_runtime_query_consent( + args.solution, + family=args.family, + provider=args.provider, + source_provider=args.source_provider, + ) + elif args.command == "review-deployment-plan": + package_flags: dict[str, bool] = {} + for name, value in _key_values(args.package_flag).items(): + if value.casefold() not in {"true", "false"}: + raise ValueError("--package-flag values must be true or false") + package_flags[name] = value.casefold() == "true" + result = record_planning_consent( + args.solution, + expected_plan_sha256=args.expected_plan_sha256, + actor=args.actor, + approved_components=args.approve_component, + declined=args.decline, + reason=args.reason, + max_additional_custom_detections=args.max_additional_custom_detections, + package_flags=package_flags, + dce_selected=args.dce_selected, + ) + elif args.command == "deploy-analytic-rules": + result = deploy_analytic_rules( + args.solution, + workspace_resource_id=args.workspace_resource_id, + ) + elif args.command == "plan-arm-template-acceptance-batch": + result = plan_arm_template_acceptance_batch( + _package_batch_members(args.member), + shared_decisions_path=args.shared_decisions, + validation_receipts=args.validation_receipt, + ) + elif args.command == "deploy-arm-template-acceptance-batch": + result = execute_arm_template_acceptance_batch( + _package_batch_members(args.member), + expected_batch_plan_sha256=args.expected_batch_plan_sha256, + shared_decisions_path=args.shared_decisions, + validation_receipts=args.validation_receipt, + actor=args.actor, + confirmation=args.confirm_exact_scope_write, + ) + elif args.command == "start-alert-parity": + result = start_alert_parity( + args.solution, + workspace_resource_id=args.workspace_resource_id, + contract=args.contract, + payload=args.payload, + scenario_marker=args.scenario_marker, + expected_match_keys=args.expected_match_keys, + detections=args.detections, + ) + elif args.command == "complete-alert-parity": + result = complete_alert_parity( + args.solution, + results_path=args.results, + ) + elif args.command == "start-alert-parity-batch": + result = start_alert_parity_batch( + args.solution, + workspace_resource_id=args.workspace_resource_id, + plan_path=args.plan, + ) + elif args.command == "abort-alert-parity": + result = abort_alert_parity(args.solution) + elif args.command == "solution-report": + result = build_solution_report( + args.solution, + ingestion_reports=args.ingestion_reports, + ) + elif args.command == "package-v3-1": + try: + current_workflow = workflow_status(args.solution) + except ValueError as exc: + if "workflow state does not exist" not in str(exc): + raise + current_workflow = None + if current_workflow: + result = package_workflow_v3_1( + args.solution, + requested_version_bump=args.version_bump, + requested_target_version=args.target_version, + toolkit_root=args.toolkit_root, + legacy_baseline_evidence=args.legacy_baseline_evidence, + ) + else: + raise ValueError( + "V3.1 package builds require a persisted workflow and campaign budget; " + "run workflow-init before packaging" + ) + elif args.command == "package-selection-plan": + result = create_package_selection_plan(args.solution, args.run_id) + elif args.command == "package-selection-apply": + result = apply_package_selection( + args.solution, + args.run_id, + expected_plan_sha256=args.expected_plan_sha256, + ) + elif args.command == "workflow-plan-package-release": + if not args.run_id: + raise ValueError( + "workflow-plan-package-release requires an explicit --run-id" + ) + result = plan_new_package_release( + args.solution, + version_bump=args.version_bump, + target_version=args.target_version, + expected_package_input_sha256=args.expected_package_input_sha256, + ) + elif args.command == "workflow-plan-release-notes-refresh": + result = plan_release_notes_refresh( + args.solution, + release_date=args.release_date, + change_history=args.change_history, + expected_package_input_sha256=args.expected_package_input_sha256, + ) + elif args.command == "workflow-plan-same-version-content-rebuild": + result = plan_same_version_content_rebuild( + args.solution, + target_version=args.target_version, + changed_inputs=args.changed_input, + expected_package_input_sha256=args.expected_package_input_sha256, + legacy_baseline_evidence=args.legacy_baseline_evidence, + ) + elif args.command == "workflow-verify-same-version-content-rebuild": + result = verify_same_version_content_rebuild( + args.solution, + legacy_baseline_evidence=args.legacy_baseline_evidence, + ) + elif args.command == "inspect": + result = inspect_solution(args.solution) + elif args.command == "convert": + result = convert_solution( + args.solution, overwrite=args.overwrite, config_path=args.config, + rule_id=args.rule_id[0] if args.rule_id else None, + ) + if args.rule_id: + configure_logging(args.solution) + elif args.command == "validate": + result = validate_solution(args.solution) + elif args.command == "validate-advanced-hunting": + try: + state = workflow_status(args.solution) + except ValueError as exc: + if "workflow state does not exist" not in str(exc): + raise + state = None + profile = ( + (state.get("context") or {}).get("workflowProfile") + if isinstance(state, dict) + else None + ) + if profile == "authoring" and not args.run_id: + raise ValueError( + "Authoring Advanced Hunting requires an explicit --run-id; " + "no run is inferred" + ) + target = ( + require_locked_target(args.solution) + if profile == "qualification" + else None + ) + authoring_tenant_id = None + if profile == "authoring": + authoring_tenant_id = (state.get("context") or {}).get("tenantId") + if not isinstance(authoring_tenant_id, str) or not authoring_tenant_id: + raise ValueError( + "Authoring Advanced Hunting requires the explicit tenant " + "stored in the selected workflow run" + ) + from .runtime_consent import require_runtime_query_consent + + require_runtime_query_consent( + args.solution, + family="defenderXdrQueryFamily", + provider="graph", + allow_legacy=True, + ) + result = validate_advanced_hunting( + args.solution, + tenant_id=( + target["tenantId"] if target else authoring_tenant_id + ), + provider_gap_evidence=args.provider_gap_evidence, + ) + elif args.command == "validate-sentinel-sdk": + from .runtime_consent import require_runtime_query_consent + + require_runtime_query_consent( + args.solution, + family="sentinelSourceQueryFamily", + provider="azure-monitor-sdk", + allow_legacy=True, + ) + target = require_locked_target(args.solution) + result = validate_sentinel_sdk( + args.solution, + target=target, + request_timeout_seconds=args.request_timeout_seconds, + family_timeout_seconds=args.family_timeout_seconds, + provider_gap_evidence=args.provider_gap_evidence, + ) + elif args.command == "record-runtime-validation": + result = record_runtime_validation( + args.solution, + provider=args.provider, + results_path=args.results, + provider_gap_evidence=args.provider_gap_evidence, + source_provider=args.source_provider, + ) + elif args.command == "workflow-init": + if args.new_run and args.run_id: + raise ValueError("--new-run and --run-id cannot be combined") + if ( + args.confirm_configured_target + and args.workflow_profile != "qualification" + ): + raise ValueError( + "--confirm-configured-target is only valid for Qualification" + ) + locked_context = None + if not args.new_run: + try: + current = workflow_status(args.solution) + except ValueError as exc: + if "workflow state does not exist" not in str(exc): + raise + else: + context = current.get("context") or {} + if ( + context.get("workflowProfile") == "qualification" + and context.get("targetContextLocked") is True + ): + locked_context = context + target = None + if args.workflow_profile == "qualification": + target = resolve_qualification_target( + args.solution, + tenant_id=args.tenant_id, + subscription_id=args.subscription_id, + workspace_resource_id=args.workspace_resource_id, + workspace_customer_id=args.workspace_customer_id, + confirm_configured_target=args.confirm_configured_target, + locked_context=locked_context, + ) + result = initialize_workflow( + args.solution, + tenant_id=target["tenantId"] if target else args.tenant_id, + subscription_id=( + target["subscriptionId"] if target else args.subscription_id + ), + workspace_resource_id=( + target["workspaceResourceId"] if target else args.workspace_resource_id + ), + workspace_customer_id=( + target["workspaceCustomerId"] if target else args.workspace_customer_id + ), + version_bump=args.version_bump, + target_version=args.target_version, + workflow_profile=args.workflow_profile, + yolo=True if args.yolo else None, + campaign_id=args.campaign_id, + new_run=args.new_run, + ) + elif args.command == "workflow-budget": + result = budget_summary(args.solution, args.campaign_id) + elif args.command == "workflow-budget-extend": + result = extend_budget( + args.solution, + args.campaign_id, + dimension=args.dimension, + new_limit=args.new_limit, + actor=args.actor, + reason=args.reason, + evidence_reference=args.reference, + ) + elif args.command == "workflow-budget-reconcile": + result = reconcile_pending_operation( + args.solution, + args.campaign_id, + args.operation_id, + status=args.status, + elapsed_seconds=args.elapsed_seconds, + actor=args.actor, + reason=args.reason, + evidence_reference=args.reference, + ) + elif args.command == "workflow-budget-reconcile-legacy": + baseline_path = Path(args.baseline).expanduser().resolve() + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + result = reconcile_legacy_usage( + args.solution, + args.campaign_id, + consumed=baseline, + actor=args.actor, + reason=args.reason, + evidence_reference=args.reference, + ) + elif args.command == "workflow-budget-deadline": + result = set_observation_deadline( + args.solution, + args.campaign_id, + test_id=args.test_id, + deadline_at=args.deadline_at, + approval_reference=args.approval_reference, + actor=args.actor, + reason=args.reason, + ) + elif args.command == "workflow-status": + result = workflow_status(args.solution) + result["runtimeQueryConsent"] = runtime_query_consent_status( + args.solution, + source_provider=getattr(args, "source_provider", None), + ) + elif args.command == "workflow-next": + result = next_workflow_stage(args.solution) + elif args.command == "workflow-recover-conversion-evidence": + result = recover_conversion_evidence(args.solution) + elif args.command == "workflow-start-stage": + result = start_workflow_stage(args.solution, args.stage) + elif args.command == "workflow-complete-stage": + result = complete_workflow_stage( + args.solution, + args.stage, + status=args.status, + message=args.message, + artifacts=_key_values(args.artifact), + evidence=args.evidence, + ) + elif args.command == "workflow-yolo": + if not args.run_id: + raise ValueError("workflow-yolo requires an explicit --run-id") + result = run_yolo_authoring(args.solution) + elif args.command == "workflow-yolo-refresh": + if not args.run_id: + raise ValueError("workflow-yolo-refresh requires an explicit --run-id") + result = refresh_yolo_authoring(args.solution) + else: + result = runtime_validation_plan(args.solution) + except (OSError, RuntimeError, ValueError, yaml.YAMLError) as exc: + print(str(exc), file=sys.stderr) + return 2 + finally: + if selection is not None: + reset_run_selection(selection) + + _print(result) + if args.command == "validate" and result["invalid"]: + return 1 + if args.command == "validate-advanced-hunting" and ( + result["invalid"] or result["blocked"] + ): + return 1 + if args.command == "validate-sentinel-sdk" and ( + result["invalid"] or result["blocked"] or not result.get("familyComplete", False) + ): + return 1 + if args.command == "record-runtime-validation" and ( + result["invalid"] or result["blocked"] + ): + return 1 + if args.command in ("deploy", "deploy-analytic-rules") and result["failed"]: + return 1 + if args.command == "plan-arm-template-acceptance-batch" and result.get( + "status" + ) != "ready": + return 1 + if args.command == "deploy-arm-template-acceptance-batch" and result.get( + "status" + ) != "accepted": + return 1 + if args.command == "complete-alert-parity" and result["status"] != "passed": + return 1 + if args.command == "abort-alert-parity" and result["status"] != "aborted": + return 1 + if ( + args.command == "verify-delegated-app-readback" + and result.get("status") != "readback-verified-private-config-completed" + ): + return 1 + if ( + args.command == "diagnose-app-readback-scopes" + and result.get("status") != "required-scopes-present" + ): + return 1 + if ( + args.command == "verify-delegated-app-permission-update" + and result.get("status") != "manual-permission-update-verified" + ): + return 1 + if ( + args.command == "package-selection-plan" + and result.get("status") != "ready" + ): + return 1 + if ( + args.command == "package-selection-apply" + and result.get("status") != "applied" + ): + return 1 + if args.command == "convert" and (result["needsReview"] or result["conflicts"]): + return 1 + if ( + args.command == "workflow-complete-stage" + and result["status"] != "passed" + ): + return 1 + if args.command == "workflow-yolo" and ( + result["status"] != "active" + or result.get("validationStatus") == "blocked" + ): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/columns.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/columns.py new file mode 100644 index 00000000000..e9e975aa3d6 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/columns.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import re + +_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*" +_ASSIGNING_OPERATORS = ( + "distinct", + "extend", + "mv-apply", + "mv-expand", + "summarize", + "project", + "project-rename", + "project-keep", + "parse", + "parse-where", +) + + +def _strip_comments(query: str) -> str: + return re.sub(r"//[^\n]*", "", query) + + +def _split_top_level(value: str) -> list[str]: + parts: list[str] = [] + buffer: list[str] = [] + depth = 0 + quote: str | None = None + escaped = False + for character in value: + if quote: + buffer.append(character) + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + continue + if character in {"'", '"'}: + quote = character + buffer.append(character) + elif character in "([{": + depth += 1 + buffer.append(character) + elif character in ")]}": + depth = max(0, depth - 1) + buffer.append(character) + elif character == "," and depth == 0: + if "".join(buffer).strip(): + parts.append("".join(buffer).strip()) + buffer = [] + else: + buffer.append(character) + if "".join(buffer).strip(): + parts.append("".join(buffer).strip()) + return parts + + +def has_output_projection(query: str) -> bool: + return bool( + re.search( + r"\|\s*(?:project(?!-away)\b|project-(?:keep|rename|reorder)\b|summarize\b|distinct\b|make-series\b)", + _strip_comments(query), + re.IGNORECASE, + ) + ) + + +def projected_columns(query: str) -> set[str] | None: + """Return an intentionally permissive approximation of final output columns. + + None means the query does not constrain its output, so source-table columns + must not be rejected merely because they are not explicitly named. + """ + cleaned = _strip_comments(query) + if not has_output_projection(cleaned): + return None + + columns: set[str] = set() + operators = "|".join(re.escape(value) for value in _ASSIGNING_OPERATORS) + for match in re.finditer(rf"\|\s*({operators})\b([^|]*)", cleaned, re.IGNORECASE): + operator = match.group(1).lower() + operand = match.group(2) + segments = re.split(r"\bby\b", operand, maxsplit=1, flags=re.IGNORECASE) + if operator not in {"summarize"}: + segments = [operand] + for segment in segments: + if operator in {"parse", "parse-where"}: + columns.update( + found.group(1) + for found in re.finditer( + rf"\b({_IDENTIFIER})\s*:\s*(?:string|int|long|real|datetime|bool|guid|dynamic|decimal|timespan)\b", + segment, + re.IGNORECASE, + ) + ) + continue + for item in _split_top_level(segment): + assignment = re.match(rf"^\s*({_IDENTIFIER})\s*=(?!=)", item) + if assignment: + columns.add(assignment.group(1)) + elif re.fullmatch(rf"\s*{_IDENTIFIER}\s*", item): + columns.add(item.strip()) + return columns diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/content_paths.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/content_paths.py new file mode 100644 index 00000000000..bebda005f40 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/content_paths.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +import re +from pathlib import Path, PurePosixPath, PureWindowsPath + + +def content_path(root: Path, relative: str | Path) -> Path: + """Resolve a portable content reference without leaving its content root.""" + value = str(relative).replace("\\", "/") + reference = PurePosixPath(value) + if ( + reference.is_absolute() + or PureWindowsPath(value).drive + or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value) + or ".." in reference.parts + or not reference.parts + ): + raise ValueError(f"content path must be relative and contained: {relative}") + path = root.joinpath(*reference.parts) + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError(f"content path escapes its root: {relative}") + for parent in (path, *path.parents): + if parent == root.parent: + break + if parent.is_symlink(): + raise ValueError(f"symlink content paths are not supported: {parent}") + return path + + +def yaml_files(root: Path) -> list[Path]: + if root.is_symlink(): + raise ValueError(f"symlink content paths are not supported: {root}") + files: list[Path] = [] + for directory, directories, names in os.walk(root, followlinks=False): + directories[:] = [name for name in directories if name.lower() not in {"logs", "reports", "evidence"}] + for name in directories: + content_path(root, (Path(directory) / name).relative_to(root)) + for name in names: + path = Path(directory) / name + if path.suffix.lower() in {".yaml", ".yml"}: + files.append(content_path(root, path.relative_to(root))) + return sorted(files, key=lambda path: path.relative_to(root).as_posix()) + + +def detection_reference(requested: str, available: list[str]) -> str: + """Allow legacy basename/stem selectors only when they are unambiguous.""" + requested = requested.replace("\\", "/") + if requested in available: + return requested + matches = [ + name for name in available + if requested in {PurePosixPath(name).name, PurePosixPath(name).stem} + ] + if len(matches) > 1: + raise ValueError( + f"ambiguous detection {requested!r}; use its relative path: {', '.join(matches)}" + ) + if not matches: + raise ValueError(f"unknown detection: {requested}") + return matches[0] diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py new file mode 100644 index 00000000000..adb73b919f7 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py @@ -0,0 +1,2254 @@ +from __future__ import annotations + +import hashlib +import json +import logging +import re +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .artifacts import conversion_scope, existing_artifact_path, report_directory, portable_artifact, write_json_artifact +from jsonschema import Draft202012Validator + +from . import __version__ +from .catalog import ( + NATIVE_XDR_TABLES, + configured_column_renames, + query_column_renames, + query_has_ambiguous_table_scope, + query_has_mixed_time_semantics, + query_table_references, +) +from .columns import projected_columns +from .content_paths import content_path, yaml_files +from .durations import iso_duration +from .parser_bindings import normalize_parser_bindings +from .report import write_transformation_report + +SCHEMA_VERSION = "1.0.0" +XDR_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "schema" / "xdr-detection.schema.json" +INITIAL_XDR_VERSION = "3.1.0" +XDR_VERSION_PATTERN = r"3\.[1-9][0-9]*\.(?:0|[1-9][0-9]*)" +SOURCE_ID_PATTERN = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}" +PREFIXED_SOURCE_ID_PATTERN = rf"xdr-{SOURCE_ID_PATTERN}" +GRAPH_DETECTION_ID_PATTERN = r"[A-Za-z][A-Za-z0-9_-]{0,99}" +DETECTION_API_VERSION = "2026-06-01-preview" +ENTITY_CONFIRMATION_PREFIX = "Entity confirmation required:" +BEST_EFFORT_ENTITY_REVIEW_PREFIX = "Best-effort entity mapping requires review:" +NONBLOCKING_ENTITY_REVIEW_PREFIXES = ( + ENTITY_CONFIRMATION_PREFIX, + BEST_EFFORT_ENTITY_REVIEW_PREFIX, +) +REQUIRED_ASSET_COLLECTIONS = frozenset({"hosts", "accounts", "mailboxes", "ips"}) +SUPPORTED_SEVERITIES = frozenset({"informational", "low", "medium", "high"}) +MITRE_CATALOG_PATH = ( + Path(__file__).resolve().parents[3] + / ".script/tests/detectionTemplateSchemaValidation/Models/ModelValidationAttributes/KillChainTechniquesHelper.cs" +) +SEARCH_REVIEW_REASON = "search queries should be replaced with explicit tables" +BLOCKING_KQL_PATTERNS = { + r"\bworkspace\s*\(": "cross-workspace queries require manual redesign", +} +REVIEW_KQL_PATTERNS = { + r"\bexternaldata\s*\(": "externaldata availability must be verified by runtime validation", + r"\bunion\s+isfuzzy\s*=\s*true\b": "isfuzzy unions can still fail semantic binding in Advanced Hunting", + r"\b_[Ii]m_[A-Za-z0-9_]+\s*\(": "ASIM parser availability must be verified in Advanced Hunting", +} + +ENTITY_MAP: dict[str, tuple[str, dict[str, str | None]]] = { + "Host": ( + "hosts", + { + "HostName": "nameColumn", + "NetBiosName": "netBiosNameColumn", + "NTDomain": "ntDomainColumn", + "DnsDomain": "dnsDomainColumn", + "DeviceId": "deviceIdColumn", + }, + ), + "Account": ( + "accounts", + { + "Name": "nameColumn", + "NTDomain": "ntDomainColumn", + "DnsDomain": "dnsDomainColumn", + "UPNSuffix": "upnSuffixColumn", + "Upn": "upnColumn", + "Sid": "sidColumn", + "AadUserId": "aadUserIdColumn", + }, + ), + "IP": ("ips", {"Address": "addressColumn"}), + "URL": ("urls", {"Url": "addressColumn"}), + "AzureResource": ("azureResources", {"ResourceId": "resourceIdColumn"}), + "CloudApplication": ("cloudApplications", {"AppId": "appIdColumn", "Name": "nameColumn"}), + "Mailbox": ("mailboxes", {"MailboxPrimaryAddress": "primaryAddressColumn"}), + "DNS": ("dns", {"DomainName": "domainNameColumn"}), + "File": ("files", {"Name": "nameColumn"}), + "FileHash": ("files", {}), + "MailCluster": ("mailClusters", {"Query": "queryColumn", "Source": "sourceColumn"}), + "RegistryValue": ("registryValues", {"Name": "valueNameColumn"}), + "SecurityGroup": ( + "securityGroups", + { + "DistinguishedName": "distinguishedNameColumn", + "SID": "sidColumn", + "ObjectGuid": "objectIdColumn", + }, + ), + # Sentinel process identifiers are not the hashes accepted by Graph. + "Process": ("processes", {}), + "MailMessage": ( + "mailMessages", + { + "NetworkMessageId": "networkMessageIdColumn", + "Recipient": "recipientColumn", + "Sender": "senderColumn", + "P1Sender": "senderColumn", + "P2Sender": "senderColumn", + "Subject": "subjectColumn", + }, + ), +} + +ACCOUNT_IDENTITIES = ( + frozenset({"upnColumn"}), + frozenset({"aadUserIdColumn"}), + frozenset({"sidColumn"}), + frozenset({"nameColumn", "upnSuffixColumn"}), + frozenset({"nameColumn", "ntDomainColumn"}), + frozenset({"nameColumn", "dnsDomainColumn"}), +) +ACCOUNT_UPN_ALIASES = frozenset( + { + "accountupn", + "caller", + "initiatingprocessaccountupn", + "targetuserupn", + "userprincipalname", + } +) +INFERRED_ENTITY_ALIASES: dict[str, tuple[str, ...]] = { + "accountUpn": ( + "AccountUpn", + "AccountUPN", + "UserPrincipalName", + "InitiatingProcessAccountUpn", + "TargetUserUpn", + ), + "accountId": ("AadUserId", "AccountObjectId", "UserObjectId", "EntraUserId"), + "accountSid": ("AccountSid", "UserSid"), + "deviceId": ("DeviceId",), + "hostName": ("DeviceName", "HostName", "DvcHostname", "Computer"), + "ip": ( + "CallerIpAddress", + "DestinationIP", + "IPAddress", + "IpAddress", + "LocalIP", + "RemoteIP", + "SourceIP", + ), + "url": ("RemoteUrl", "URL", "Url"), + "resource": ("_ResourceId", "AzureResourceId", "ResourceId"), +} + + +class XdrYamlDumper(yaml.SafeDumper): + pass + + +def _represent_string(dumper: yaml.SafeDumper, value: str) -> yaml.ScalarNode: + style = "|" if "\n" in value else None + return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) + + +XdrYamlDumper.add_representer(str, _represent_string) + + +@dataclass(frozen=True) +class ConversionResult: + source: Path + output: Path + display_name: str + status: str + review_required: bool + review_reasons: tuple[str, ...] + warnings: tuple[str, ...] + errors: tuple[str, ...] + informational: tuple[str, ...] = () + tactic_count: int = 0 + + def as_dict(self) -> dict[str, Any]: + return { + "source": str(self.source), + "output": str(self.output), + "displayName": self.display_name, + "status": self.status, + "reviewRequired": self.review_required, + "reviewReasons": list(self.review_reasons), + "warnings": list(self.warnings), + "errors": list(self.errors), + "informational": list(self.informational), + "directGraphDeploymentReady": ( + self.status == "converted" and not self.review_required and self.tactic_count <= 1 + ), + } + + +def configure_logging(solution: str | Path | None = None) -> None: + handlers: list[logging.Handler] = [logging.StreamHandler()] + if solution: + log_directory = report_directory(solution, create=True) + class PublicFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + return portable_artifact(solution, super().format(record), destination=log_directory) + + handler = logging.FileHandler( + log_directory / "sentinel-xdr-migration.log", encoding="utf-8" + ) + handler.setFormatter(PublicFormatter("%(asctime)s %(levelname)s %(message)s")) + handlers.append(handler) + logger = logging.getLogger() + for previous in list(logger.handlers): + if getattr(previous, "_sentinel_xdr_handler", False): + logger.removeHandler(previous) + previous.close() + logger.setLevel(logging.INFO) + for handler in handlers: + handler._sentinel_xdr_handler = True + if handler.formatter is None: + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + logger.addHandler(handler) + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug[:72] or "custom-detection" + + +def solution_paths(solution: str | Path) -> tuple[Path, Path, Path]: + root = Path(solution).expanduser().resolve() + analytic = root / "Analytic Rules" + output = root / "XDR Detections" + if not root.is_dir(): + raise ValueError(f"solution path does not exist: {root}") + if not analytic.is_dir(): + legacy_analytic = root / "Analytics Rules" + if legacy_analytic.is_dir(): + analytic = legacy_analytic + else: + raise ValueError( + f"solution has no 'Analytic Rules' or 'Analytics Rules' folder: {root}" + ) + return root, analytic, output + + +def load_config(output_dir: Path, explicit: str | Path | None = None) -> dict[str, Any]: + path = ( + Path(explicit).expanduser().resolve() + if explicit + else existing_artifact_path(output_dir.parent, "migration-config.yaml") + ) + if not path.exists(): + return {} + with path.open(encoding="utf-8") as handle: + value = yaml.safe_load(handle) or {} + if not isinstance(value, dict): + raise ValueError(f"migration config must contain a YAML object: {path}") + return value + + +def analytic_rule_files(solution: str | Path) -> list[Path]: + _, analytic, _ = solution_paths(solution) + return yaml_files(analytic) + + +def xdr_detection_files(output: Path) -> list[Path]: + return list( + path + for path in yaml_files(output) + if path.name.lower() not in {"migration-config.yaml", "migration-config.yml"} + ) + + +def inspect_solution(solution: str | Path) -> dict[str, Any]: + root, analytic, output = solution_paths(solution) + files = analytic_rule_files(root) + return { + "solution": str(root), + "analyticRulesDirectory": str(analytic), + "xdrDetectionsDirectory": str(output), + "analyticRuleCount": len(files), + "analyticRules": [path.relative_to(analytic).as_posix() for path in files], + "existingXdrDetectionCount": len(xdr_detection_files(output)) if output.exists() else 0, + } + + +def _replace_code_segment(segment: str, pattern: re.Pattern[str], mappings: dict[str, str]) -> str: + pieces = re.split(r"""('(?:''|[^'])*'|"(?:\\"|[^"])*")""", segment) + for index in range(0, len(pieces), 2): + pieces[index] = pattern.sub(lambda match: mappings[match.group(0)], pieces[index]) + return "".join(pieces) + + +def _token_replace(query: str, mappings: dict[str, str]) -> str: + if not mappings: + return query + pattern = re.compile( + r"\b(?:" + "|".join(re.escape(source) for source in sorted(mappings, key=len, reverse=True)) + r")\b" + ) + output = [] + for line in query.splitlines(keepends=True): + code, separator, comment = line.partition("//") + output.append(_replace_code_segment(code, pattern, mappings)) + if separator: + output.append(separator + comment) + return "".join(output) + + +def _rewrite_single_table_reference(query: str, mappings: dict[str, str]) -> tuple[str, bool]: + if not mappings: + return query, False + references = query_table_references(query) + if not references: + return query, False + table = str(references[0]["table"]) + target = next( + (value for name, value in mappings.items() if str(name).casefold() == table.casefold()), + None, + ) + if target is None: + return query, False + if query_has_ambiguous_table_scope(query): + return query, True + + token_pattern = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|[A-Za-z_][A-Za-z0-9_]*|[^\s]" + ) + for match in token_pattern.finditer(query): + token = match.group(0) + if token.startswith(("//", "/*", "'", '"', "@'", '@"')): + continue + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token): + if token.casefold() != table.casefold(): + return query, True + return query[:match.start()] + str(target) + query[match.end():], False + return query, True + + +def convert_query(query: str, config: dict[str, Any]) -> tuple[str, list[str], list[str]]: + warnings: list[str] = [] + errors: list[str] = [] + converted = query.strip() + + converted, table_mapping_deferred = _rewrite_single_table_reference( + converted, + {str(k): str(v) for k, v in (config.get("tableMappings") or {}).items()}, + ) + function_mappings = { + str(k): str(v) for k, v in (config.get("functionMappings") or {}).items() + } + converted = _token_replace(converted, function_mappings) + + catalog_mappings = query_column_renames(converted) + ambiguous_scope = query_has_ambiguous_table_scope(query) + column_mappings: dict[str, str] = {} if ambiguous_scope else catalog_mappings + configured_mappings, ambiguous_scope = configured_column_renames(query, config) + column_mappings.update(configured_mappings) + converted = _token_replace(converted, column_mappings) + converted = "\n".join(line.rstrip() for line in converted.splitlines()) + converted = re.sub(r";\s*\Z", "", converted) + + for pattern, message in BLOCKING_KQL_PATTERNS.items(): + if re.search(pattern, converted, re.IGNORECASE): + errors.append(message) + for pattern, message in REVIEW_KQL_PATTERNS.items(): + if re.search(pattern, converted, re.IGNORECASE): + warnings.append(message) + if _has_search_operator(converted): + warnings.append(SEARCH_REVIEW_REASON) + if query_has_mixed_time_semantics(converted): + warnings.append( + "query mixes native Defender and Sentinel workload tables; " + "time columns were preserved and require runtime validation" + ) + if ambiguous_scope: + warnings.append( + "configured column mappings were not applied because table/alias scope is ambiguous" + ) + elif query_has_ambiguous_table_scope(query) and catalog_mappings: + warnings.append( + "catalog column mappings were not applied because table/alias scope is ambiguous" + ) + if table_mapping_deferred: + warnings.append( + "configured table mapping was not applied because table/alias scope is ambiguous" + ) + + return converted, warnings, errors + + +def convert_query_with_mapping_evidence( + query: str, + config: dict[str, Any], + *, + source_metadata: dict[str, Any] | None = None, + target_metadata: dict[str, Any] | None = None, + runtime_evidence: dict[str, Any] | None = None, +) -> tuple[str, list[str], list[str], dict[str, Any]]: + """Convert with the existing engine and return separate hash-bound evidence.""" + from .mapping_evidence import build_mapping_evidence + + target_query, warnings, errors = convert_query(query, config) + evidence = build_mapping_evidence( + query, + target_query, + source_metadata=source_metadata, + target_metadata=target_metadata, + config=config, + runtime_evidence=runtime_evidence, + ) + return target_query, warnings, errors, evidence + + +def _valid_account(fields: dict[str, str]) -> bool: + present = set(fields) + return any(required <= present for required in ACCOUNT_IDENTITIES) + + +def _find_column(columns: set[str] | None, aliases: tuple[str, ...]) -> str | None: + if columns is None: + return None + lookup = {column.lower(): column for column in columns} + return next((lookup[alias.lower()] for alias in aliases if alias.lower() in lookup), None) + + +def _repair_account( + fields: dict[str, str], + source_fields: dict[str, str], + columns: set[str] | None, + warnings: list[str], +) -> dict[str, str]: + if _valid_account(fields): + return fields + name_column = fields.get("nameColumn") or source_fields.get("Name") + if name_column and name_column.lower() in ACCOUNT_UPN_ALIASES: + warnings.append( + f"Account.Name column `{name_column}` was mapped as a complete UPN identity" + ) + return {"upnColumn": name_column} + if "nameColumn" in fields: + for target, aliases in ( + ("upnSuffixColumn", ("UPNSuffix", "AccountUPNSuffix", "UserDomain")), + ("ntDomainColumn", ("NTDomain", "AccountNTDomain")), + ("dnsDomainColumn", ("DnsDomain", "AccountDnsDomain")), + ): + column = _find_column(columns, aliases) + if column: + warnings.append( + f"Account mapping was completed with projected column `{column}`" + ) + return {**fields, target: column} + for target, aliases in ( + ("upnColumn", INFERRED_ENTITY_ALIASES["accountUpn"]), + ("aadUserIdColumn", INFERRED_ENTITY_ALIASES["accountId"]), + ("sidColumn", INFERRED_ENTITY_ALIASES["accountSid"]), + ): + column = _find_column(columns, aliases) + if column: + warnings.append( + f"Account mapping was repaired from projected column `{column}`" + ) + return {target: column} + if name_column and (columns is None or name_column in columns): + warnings.append( + f"{ENTITY_CONFIRMATION_PREFIX} `{name_column}` was provisionally mapped " + "to Account.upnColumn. Confirm that the source always emits a complete " + "UPN; otherwise project an Entra user ID, SID, or account name plus domain" + ) + return {"upnColumn": name_column} + warnings.append("Account mapping is incomplete and was removed") + return {} + + +def _infer_entities( + columns: set[str] | None, +) -> tuple[dict[str, list[dict[str, Any]]], list[str]]: + if columns is None: + return {}, [] + output: dict[str, list[dict[str, Any]]] = {} + for collection, identifier, field, aliases in ( + ("accounts", "account1", "upnColumn", INFERRED_ENTITY_ALIASES["accountUpn"]), + ("hosts", "host1", "deviceIdColumn", INFERRED_ENTITY_ALIASES["deviceId"]), + ("ips", "ip1", "addressColumn", INFERRED_ENTITY_ALIASES["ip"]), + ("urls", "url1", "addressColumn", INFERRED_ENTITY_ALIASES["url"]), + ( + "azureResources", + "azureResource1", + "resourceIdColumn", + INFERRED_ENTITY_ALIASES["resource"], + ), + ): + column = _find_column(columns, aliases) + if column: + output[collection] = [{"id": identifier, field: column}] + host_name = _find_column(columns, INFERRED_ENTITY_ALIASES["hostName"]) + if host_name: + host = output.setdefault("hosts", [{"id": "host1"}])[0] + host["nameColumn"] = host_name + warnings = ( + ["No valid source mapping survived; inferred entities from projected columns"] + if output + else [] + ) + return output, warnings + + +def _tokens(query: str) -> list[str]: + """Tokenize only for conservative local proofs, never to rewrite KQL.""" + tokens = re.findall( + r"//[^\n]*|/\*[\s\S]*?\*/|```[\s\S]*?```" + r"|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"" + r"|[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?[smhd]?|>=|<=|==|!=|[^\s]", + query, + ) + return [ + "'" + token[1:-1] + "'" if token.startswith('"') else token + for token in tokens + if not token.startswith(("//", "/*")) + ] + + +def _has_search_operator(query: str) -> bool: + """Recognize search at query boundaries, not in comments or literal text.""" + tokens = _tokens(query) + for index, token in enumerate(tokens): + if token.lower() != "search": + continue + if index and tokens[index - 1] not in {"|", ";", "(", "{", "="}: + continue + # A bare table/column named search is not an operator with a predicate. + if index + 1 >= len(tokens): + continue + operand = tokens[index + 1] + if re.fullmatch( + r"and|or|between|matches|has(?:_cs|_any|_all|prefix(?:_cs)?|suffix(?:_cs)?)?" + r"|(?:contains|startswith|endswith)(?:_cs)?", + operand, + re.IGNORECASE, + ): + continue + if re.match(r"[\w'\"@`(*\[]", operand): + return True + return False + + +def _pipeline_parts(query: str) -> list[list[str]] | None: + """Split a single-table pipeline, rejecting branches and local definitions.""" + tokens = _tokens(query) + parts: list[list[str]] = [[]] + depth = 0 + for token in tokens: + if token == ";": + return None + if token == "|": + if depth: + return None + parts.append([]) + else: + parts[-1].append(token) + if token in {"(", "[", "{"}: + depth += 1 + elif token in {")", "]", "}"}: + depth -= 1 + if depth < 0: + return None + if depth or len(parts[0]) != 1 or not re.fullmatch(r"\w+", parts[0][0]): + return None + return parts + + +def _expression_items(tokens: list[str]) -> list[list[str]]: + items: list[list[str]] = [[]] + depth = 0 + for token in tokens: + if token == "," and not depth: + items.append([]) + continue + items[-1].append(token) + if token in {"(", "[", "{"}: + depth += 1 + elif token in {")", "]", "}"}: + depth -= 1 + return items + + +def _summarize_groups(part: list[str]) -> list[list[str]] | None: + depth = 0 + for index, token in enumerate(part): + if token == "by" and not depth: + return _expression_items(part[index + 1:]) + if token in {"(", "[", "{"}: + depth += 1 + elif token in {")", "]", "}"}: + depth -= 1 + return None + + +def _native_pipeline_table(query: str) -> str | None: + """Recognize a native surface without claiming column or time equivalence.""" + parts = _pipeline_parts(query) + if not parts or parts[0][0] not in NATIVE_XDR_TABLES: + return None + if any(not part or part[0] not in {"where", "extend", "project", "summarize"} for part in parts[1:]): + return None + return parts[0][0] + + +def _linear_facts( + query: str, *, allow_aggregation: bool = False, +) -> tuple[list[list[str]], set[str] | None, dict[str, list[str]]] | None: + """Prove local column facts; aggregation is opt-in for identity proofs only.""" + parts = _pipeline_parts(query) + if not parts: + return None + columns: set[str] | None = None + assignments: dict[str, list[str]] = {} + for part in parts[1:]: + if part and part[0] == "summarize" and allow_aggregation: + groups = _summarize_groups(part) + if not groups: + return None + preserved = {item[0] for item in groups if len(item) == 1} + if columns is not None and not preserved <= columns: + return None + # Only bare grouping keys preserve identity. Aggregate outputs and + # computed keys cannot establish an equivalent source identity. + columns = preserved + assignments = {key: value for key, value in assignments.items() if key in preserved} + continue + if not part or part[0] not in {"where", "extend", "project"}: + return None + if part[0] == "where": + continue + items = _expression_items(part[1:]) + projected: set[str] = set() + for item in items: + if not item or not re.fullmatch(r"[A-Za-z_]\w*", item[0]): + return None + if len(item) != 1 and (len(item) < 3 or item[1] != "="): + return None + if len(item) == 1 and columns is not None and item[0] not in columns: + return None + projected.add(item[0]) + if len(item) > 1: + assignments[item[0]] = item[2:] + if part[0] == "project": + columns = projected + assignments = {key: value for key, value in assignments.items() if key in columns} + elif columns is not None: + columns.update(projected) + return parts, columns, assignments + + +def _host_full_name_preserved( + source_fields: dict[str, str], fields: dict[str, str], query: str, +) -> bool: + full = source_fields.get("FullName") + name = fields.get("nameColumn") + domain = fields.get("dnsDomainColumn") + facts = _linear_facts(query, allow_aggregation=True) + if not full or not name or not domain or not facts: + return False + parts, columns, assignments = facts + if columns is not None and not {full, name, domain} <= columns: + return False + for part in parts[1:]: + if part[0] == "summarize" and [full] not in (_summarize_groups(part) or []): + return False + if part[0] == "project" and [full] not in _expression_items(part[1:]): + return False + # Reject reassigned identities, including assignments later projected away. + for column in (full, name, domain): + count = sum( + part[index:index + 2] == [column, "="] + for part in parts[1:] for index in range(len(part) - 1) + ) + if count != (0 if column == full else 1): + return False + expected_name = _tokens( + f"iff({full} has '.', substring({full}, 0, indexof({full}, '.')), {full})" + ) + expected_domain = _tokens( + f"iff({full} has '.', substring({full}, indexof({full}, '.') + 1), '')" + ) + return assignments.get(name) == expected_name and assignments.get(domain) == expected_domain + + +SCHEDULE_REFERENCE = "https://learn.microsoft.com/en-us/defender-xdr/custom-detection-rules#lookback" +NATIVE_LOOKBACK_SECONDS = {3600: 14400, 10800: 43200, 43200: 172800, 86400: 2592000} + + +def _duration_seconds(value: Any) -> int | None: + duration, error = iso_duration(value) + if error: + return None + match = re.fullmatch(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?", duration) + if not match or not any(match.groups()): + return None + seconds = sum(int(part or 0) * scale for part, scale in zip(match.groups(), (86400, 3600, 60, 1))) + return seconds if seconds > 0 else None + + +def _event_window(query: str, time_column: str) -> tuple[int | None, str | None]: + parts = _pipeline_parts(query) + if not parts or any( + not part or part[0] not in {"where", "extend", "project", "summarize"} + for part in parts[1:] + ): + return None, "query shape or time filters cannot be proven by the conservative assessor" + if any( + part[index:index + 2] == [time_column, "="] + for part in parts[1:] for index in range(len(part) - 1) + ): + return None, "event time is reassigned" + windows: list[int] = [] + time_tokens = {"Timestamp", "TimeGenerated", "ago", "now", "datetime", "ingestion_time"} + for part in parts[1:]: + if part[0] == "where" and time_tokens.intersection(part): + if not ( + len(part) == 7 and part[1] == time_column and part[2] in {">", ">="} + and part[3:5] == ["ago", "("] and part[-1] == ")" + ): + return None, "time filter or ingestion-time basis is ambiguous" + seconds = _duration_seconds(part[5]) + if seconds is None: + return None, "time filter duration is unknown" + windows.append(seconds) + elif part[0] != "where" and {"ago", "now", "datetime", "ingestion_time"}.intersection(part): + return None, "time expressions outside a simple event-time predicate require review" + if not windows: + return None, "no explicit event-time bound proves equivalence to Defender ingestion-time evaluation" + return min(windows), None + + +def _replace_event_time_column(query: str, source_column: str, target_column: str) -> str | None: + parts = _pipeline_parts(query) + if not parts or any( + not part or part[0] not in {"where", "extend", "project", "summarize"} + for part in parts[1:] + ): + return None + if any( + part[index:index + 2] == [source_column, "="] + for part in parts[1:] for index in range(len(part) - 1) + ): + return None + + token_pattern = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|```[\s\S]*?```" + r"|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"" + r"|[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?[smhd]?|>=|<=|==|!=|[^\s]" + ) + spans = [ + (match.start(), match.end()) + for match in token_pattern.finditer(query) + if match.group(0) == source_column + ] + if not spans: + return query + result = query + for start, end in reversed(spans): + result = result[:start] + target_column + result[end:] + return result + + +def _format_kql_duration(seconds: int) -> str: + for scale, suffix in ((86400, "d"), (3600, "h"), (60, "m")): + if seconds % scale == 0: + return f"{seconds // scale}{suffix}" + return f"{seconds}s" + + +def constrain_event_window( + doc: dict[str, Any], source_query: str, target_query: str, frequency: str, +) -> tuple[str, dict[str, Any] | None, str | None]: + source_period = _duration_seconds(doc.get("queryPeriod")) + source_frequency = _duration_seconds(doc.get("queryFrequency")) + target_frequency = _duration_seconds(frequency) + source_table = _native_pipeline_table(source_query) + target_table = _native_pipeline_table(target_query) + native_tokens = set(_tokens(source_query)) | set(_tokens(target_query)) + service_window = ( + NATIVE_LOOKBACK_SECONDS.get(target_frequency or 0) + if native_tokens.intersection(NATIVE_XDR_TABLES) + else None + ) + if ( + str(doc.get("kind") or "").lower() == "nrt" + or source_period is None + or source_frequency is None + or source_frequency != target_frequency + or service_window is None + or source_period >= service_window + ): + return target_query, None, None + if source_table is None or target_table is None or source_table != target_table: + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + "but source and target table inputs cannot be proven equivalent" + ) + + source_time_column = "TimeGenerated" if "TimeGenerated" in _tokens(source_query) else "Timestamp" + if not any(column in _tokens(source_query) for column in ("Timestamp", "TimeGenerated")): + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + "but the source query does not establish an event-time column" + ) + bound_query = target_query + if source_time_column == "TimeGenerated": + bound_query = _replace_event_time_column( + bound_query, "TimeGenerated", "Timestamp", + ) + if bound_query is None: + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + "but TimeGenerated cannot be safely bound to the target Timestamp column" + ) + + source_bound, source_issue = _event_window(source_query, source_time_column) + if source_issue and source_issue != "no explicit event-time bound proves equivalence to Defender ingestion-time evaluation": + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + f"but the source event-time window is ambiguous: {source_issue}" + ) + effective_window = min(source_period, source_bound) if source_bound is not None else source_period + + target_bound, target_issue = _event_window(bound_query, "Timestamp") + if target_issue and target_issue != "no explicit event-time bound proves equivalence to Defender ingestion-time evaluation": + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + f"but the target event-time window is ambiguous: {target_issue}" + ) + if target_bound is not None and target_bound < effective_window: + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + "but the existing target event-time filter is narrower than the source window" + ) + + injected = target_bound is None or target_bound > effective_window + if injected: + token_pattern = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|```[\s\S]*?```" + r"|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"" + r"|[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?[smhd]?|>=|<=|==|!=|[^\s]" + ) + first_table = next( + ( + match for match in token_pattern.finditer(bound_query) + if match.group(0) == target_table + ), + None, + ) + if first_table is None: + return target_query, None, ( + "Original Sentinel lookback is shorter than the native XDR service window, " + "but the target input location could not be identified safely" + ) + span = _format_kql_duration(effective_window) + bound_query = ( + bound_query[:first_table.end()] + + f" | where Timestamp >= ago({span})" + + bound_query[first_table.end():] + ) + + metadata = { + "status": "constrained", + "originalSentinelLookbackSeconds": source_period, + "effectiveEventWindowSeconds": effective_window, + "xdrServiceWindowSeconds": service_window, + "sourceTimeColumn": source_time_column, + "targetTimeColumn": "Timestamp", + "filterInjected": injected, + "rationale": ( + "A conservative single-native-table pipeline proof established the source and target " + "input and time-column binding. The target Timestamp filter is placed immediately " + "after the table input and before all filters, projections, and aggregations." + ), + "lateIngestionRisk": ( + "Late-ingestion risk: events ingested after their Timestamp falls outside this " + "constrained event-time window can be missed; runtime qualification is required." + ), + } + return bound_query, metadata, None + + +def assess_schedule( + doc: dict[str, Any], source_query: str, target_query: str, frequency: str, +) -> tuple[list[str], list[str]]: + source_frequency = _duration_seconds(doc.get("queryFrequency")) + source_period = _duration_seconds(doc.get("queryPeriod")) + target_frequency = _duration_seconds(frequency) + source_table = _native_pipeline_table(source_query) + target_table = _native_pipeline_table(target_query) + native = target_table is not None + target_period = NATIVE_LOOKBACK_SECONDS.get(target_frequency) if native else None + summary = ( + f"Schedule assessment: source frequency={doc.get('queryFrequency') or 'unknown'}, " + f"lookback={doc.get('queryPeriod') or 'unknown'}; target frequency={frequency}, " + f"native lookback={str(target_period) + 's' if target_period else 'unknown/unconfigured'}" + ) + reason = None + if str(doc.get("kind") or "").lower() == "nrt": + reason = "source NRT timing is not preserved by the emitted scheduled frequency" + elif source_frequency is None or source_period is None: + reason = "source frequency or lookback is unknown" + elif source_frequency != target_frequency: + reason = "source and target frequencies differ" + elif not native or source_table is None: + reason = "mixed, Sentinel-only, or unknown query surface; no custom lookback is configured in the payload" + elif source_table != target_table: + reason = "source and target native tables differ; time-basis equivalence is unverified" + elif target_period is None: + reason = "target frequency has no documented native fixed lookback" + elif source_period > target_period: + reason = "source and native lookbacks differ; an event-time filter alone does not configure the target ingestion lookback" + if reason: + return [f"{summary}; {reason}. See {SCHEDULE_REFERENCE}"], [] + source_time = "TimeGenerated" if "TimeGenerated" in _tokens(source_query) else "Timestamp" + source_bound, source_issue = _event_window(source_query, source_time) + target_bound, target_issue = _event_window(target_query, "Timestamp") + narrower_source_period = source_period < target_period + if ( + narrower_source_period + and source_issue == "no explicit event-time bound proves equivalence to Defender ingestion-time evaluation" + and target_bound is not None + ): + source_bound = source_period + source_issue = None + if source_issue or target_issue: + reason = source_issue or target_issue + if source_period != target_period: + reason = f"source and native service windows differ; {reason}" + return [f"{summary}; {reason}. See {SCHEDULE_REFERENCE}"], [] + source_effective = min(source_period, source_bound) + target_effective = min(target_period, target_bound) + summary += f"; effective event windows source={source_effective}s, target={target_effective}s" + if source_effective != target_effective: + return [f"{summary}; effective lookbacks differ. See {SCHEDULE_REFERENCE}"], [] + if narrower_source_period: + message = ( + f"{summary}; event-time window constrained to original Sentinel lookback. " + f"The XDR service window remains {target_period}s and is not identical to " + "the Sentinel service window; late-ingestion risk remains and cadence/service-window " + f"parity is not established. See {SCHEDULE_REFERENCE}" + ) + return [message], [message] + return [], [ + f"{summary}; frequency and explicit event-time window are preserved. " + f"Ingestion delays, initial runs and alert deduplication still require runtime qualification. See {SCHEDULE_REFERENCE}" + ] + + +def convert_entities( + doc: dict[str, Any], + query: str, + column_mappings: dict[str, str] | None = None, + *, + informational: list[str] | None = None, + defer_unmapped_entity_warnings: bool = False, +) -> tuple[dict[str, list[dict[str, Any]]], list[str]]: + output: dict[str, list[dict[str, Any]]] = {} + warnings: list[str] = [] + counters: dict[str, int] = {} + columns = projected_columns(query) + facts = _linear_facts(query) + if facts and facts[1] is not None: + columns = facts[1] + information = informational if informational is not None else [] + + for entity in doc.get("entityMappings") or []: + entity_type = str(entity.get("entityType") or "") + if entity_type not in ENTITY_MAP: + if not defer_unmapped_entity_warnings: + warnings.append(f"converter cannot map {entity_type or 'Unknown entity'} from the source identifiers") + continue + collection, identifiers = ENTITY_MAP[entity_type] + fields: dict[str, str] = {} + source_fields: dict[str, str] = {} + unsupported: list[str] = [] + for mapping in entity.get("fieldMappings") or []: + identifier = str(mapping.get("identifier") or "") + source_column = str(mapping.get("columnName") or "") + column = (column_mappings or {}).get(source_column, source_column) + if identifier and column: + source_fields[identifier] = column + target = identifiers.get(identifier) + if target and column and (columns is None or column in columns): + if target in fields and fields[target] != column: + warnings.append( + f"converter cannot map {entity_type}.{identifier} column `{column}` " + f"without replacing conflicting {target} column `{fields[target]}`" + ) + else: + fields[target] = column + elif target and column: + warnings.append( + f"{entity_type}.{identifier} column `{column}` is not produced by the query" + ) + elif identifier: + unsupported.append(identifier) + if entity_type == "FileHash": + algorithm_column = source_fields.get("Algorithm", "") + value_column = source_fields.get("Value", "") + algorithm = facts[2].get(algorithm_column) if facts else None + target = {"'SHA1'": "sha1Column", "'SHA256'": "sha256Column"}.get( + algorithm[0].upper() if algorithm and len(algorithm) == 1 else "" + ) + if target and value_column and columns is not None and value_column in columns: + fields[target] = value_column + unsupported = [value for value in unsupported if value not in {"Algorithm", "Value"}] + information.append( + f"FileHash.Algorithm `{algorithm_column}` is the query-proven constant " + f"{algorithm[0]}; FileHash.Value `{value_column}` is represented by files.{target}" + ) + else: + warnings.append( + "converter cannot map FileHash.Algorithm/Value: requires a query-proven " + "constant SHA1 or SHA256 algorithm and an available hash value column; " + "MD5, unknown algorithms and linked file/process references are not inferred" + ) + unsupported = [value for value in unsupported if value not in {"Algorithm", "Value"}] + if entity_type == "Account" and not _valid_account(fields): + fields = _repair_account(fields, source_fields, columns, warnings) + for identifier in unsupported: + if entity_type == "Host" and identifier == "FullName" and _host_full_name_preserved(source_fields, fields, query): + information.append( + f"Host.FullName `{source_fields['FullName']}` is safely represented by retained " + f"nameColumn `{fields['nameColumn']}` and dnsDomainColumn `{fields['dnsDomainColumn']}`; " + "the query explicitly decomposes that same full name. No fullNameColumn is emitted" + ) + elif ( + entity_type == "Host" + and identifier == "FullName" + and source_fields.get(identifier) + and (columns is None or source_fields[identifier] in columns) + ): + full_name_column = source_fields[identifier] + candidate = fields.get("nameColumn") + if not candidate: + candidate = full_name_column + fields["nameColumn"] = candidate + warnings.append( + f"{BEST_EFFORT_ENTITY_REVIEW_PREFIX} source Host.FullName column " + f"`{full_name_column}` is provisionally represented by hosts.nameColumn " + f"`{candidate}`; confirm that this column is the intended host identifier. " + "The original value is also retained in customDetails when the query output " + "binding is available; this assumption does not prove entity parity" + ) + elif entity_type == "Account" and identifier == "FullName" and source_fields.get(identifier) in ( + fields.get("upnColumn"), fields.get("nameColumn") if _valid_account(fields) else None, + ): + information.append( + f"Account.FullName `{source_fields[identifier]}` is retained by an equivalent account mapping" + ) + else: + if not defer_unmapped_entity_warnings: + warnings.append( + f"converter cannot map {entity_type}.{identifier} to a documented Custom Detection field; " + "this source identifier is not represented" + ) + if fields: + counters[collection] = counters.get(collection, 0) + 1 + identifier = collection[:-1] if collection.endswith("s") else collection + output.setdefault(collection, []).append( + {"id": f"{identifier}{counters[collection]}", **fields} + ) + if not output: + inferred, inference_warnings = _infer_entities(columns) + output.update(inferred) + warnings.extend(inference_warnings) + return output, warnings + + +def _techniques(values: list[str] | None) -> list[dict[str, Any]]: + grouped: dict[str, set[str]] = {} + for raw in values or []: + value = str(raw).strip() + if not value: + continue + base = value.split(".", 1)[0] + grouped.setdefault(base, set()) + if "." in value: + grouped[base].add(value) + result = [] + for base, subtechniques in grouped.items(): + item: dict[str, Any] = {"technique": base} + if subtechniques: + item["subTechniques"] = sorted(subtechniques) + result.append(item) + return result + + +def _mitre_catalog() -> dict[str, set[str]]: + """Reuse the Sentinel validator's base-technique compatibility, including for subtechniques.""" + text = MITRE_CATALOG_PATH.read_text(encoding="utf-8-sig") + catalog = { + tactic: set(re.findall(r'"(T\d{4})"', techniques)) + for tactic, techniques in re.findall( + r'\{\s*Tactic\.(\w+),\s*new List\(\)\s*\{([^}]*)\}', text + ) + if tactic != "Unknown" + } + if not catalog or any(not techniques for techniques in catalog.values()): + raise ValueError("MITRE compatibility catalog is missing or malformed") + return catalog + + +def _classification_payload( + tactics: Any, techniques: Any, override: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(tactics, list) or not all(isinstance(value, str) and value for value in tactics): + return [], ["source tactics must be an array of nonempty tactic identifiers"], [] + unclassified = [{"tactic": tactic} for tactic in tactics] + try: + catalog = _mitre_catalog() + except (OSError, ValueError) as exc: + return unclassified, [f"Cannot resolve MITRE classification: {exc}"], [] + if not isinstance(techniques, list) or not all( + isinstance(value, str) and re.fullmatch(r"T\d{4}(?:\.\d{3})?", value) + for value in techniques + ): + return unclassified, ["source relevantTechniques must be an array of technique IDs (Tdddd or Tdddd.ddd)"], [] + for tactic in tactics: + if tactic not in catalog: + errors.append(f"unsupported source MITRE tactic: {tactic!r}") + for technique in techniques: + if not any(technique.split(".")[0] in catalog.get(tactic, set()) for tactic in tactics): + errors.append(f"MITRE technique {technique!r} has no documented compatible source tactic") + + selected = override.get("tactic") + selected_techniques = override.get("techniques") + apply_override = False + if "tactic" in override or "techniques" in override: + if not isinstance(selected, str) or selected not in tactics or selected not in catalog: + errors.append("configured tactic override must name an existing supported source tactic; source tactics cannot be removed or replaced") + elif not isinstance(selected_techniques, list) or not all( + isinstance(value, str) and re.fullmatch(r"T\d{4}(?:\.\d{3})?", value) + and value.split(".")[0] in catalog[selected] + for value in selected_techniques + ): + errors.append(f"configured techniques override must contain documented compatible technique IDs for {selected}") + else: + apply_override = True + warnings.append( + f"Legacy tactic override updates techniques only for {selected}; " + "all source tactics and their order are preserved, not narrowed or reordered." + ) + payload = [] + for tactic in tactics: + relevant = ( + selected_techniques if apply_override and tactic == selected else + [value for value in techniques if value.split(".")[0] in catalog.get(tactic, set())] + ) + item: dict[str, Any] = {"tactic": tactic} + if relevant: + item["techniques"] = _techniques(relevant) + payload.append(item) + return payload, errors, warnings + + +def _custom_detail_binding(query: str, column: str) -> str: + facts = _linear_facts(query) + if facts: + _, columns, assignments = facts + if columns is not None: + return "available" if column in columns else "missing" + if column in assignments: + return "available" + grouped = _linear_facts(query, allow_aggregation=True) + if grouped and grouped[1] is not None and column in grouped[1]: + return "available" + parts = _pipeline_parts(query) + if parts: + tail = list(parts[1:]) + while tail and tail[-1] and tail[-1][0] == "where": + tail.pop() + if tail and tail[-1] and tail[-1][0] == "project": + items = _expression_items(tail[-1][1:]) + if all( + item and re.fullmatch(r"[A-Za-z_]\w*", item[0]) + and (len(item) == 1 or (len(item) >= 3 and item[1] == "=")) + for item in items + ): + return "available" if column in {item[0] for item in items} else "missing" + if tail and tail[-1][:3] == ["project", "-", "away"]: + removed = { + token for token in tail[-1][3:] + if re.fullmatch(r"[A-Za-z_]\w*", token) + } + if column in removed: + return "missing" + if tail and tail[-1][:3] == ["project", "-", "rename"]: + items = _expression_items(tail[-1][3:]) + if any( + len(item) >= 3 and item[1] == "=" and item[2] == column + for item in items + ): + return "missing" + tokens = _tokens(query) + if any( + tokens[index:index + 3] in (["project", "-", "away"], ["project", "-", "rename"]) + for index in range(len(tokens) - 2) + ): + return "unproven-destructive-projection" + columns = projected_columns(query) + if columns is not None and column not in columns and "*" not in tokens: + return "missing" + return "runtime-binding-pending" + + +def _entity_identifier_preserved( + entity_type: str, identifier: str, column: str, source_fields: dict[str, str], + mappings: dict[str, Any], query: str, +) -> bool: + collection, identifiers = ENTITY_MAP.get(entity_type, ("", {})) + candidates = mappings.get(collection) or [] + if not isinstance(candidates, list): + return False + candidates = [item for item in candidates if isinstance(item, dict)] + target = identifiers.get(identifier) + if target and any(item.get(target) == column for item in candidates): + return True + if entity_type == "Host" and identifier == "FullName": + return any(_host_full_name_preserved(source_fields, item, query) for item in candidates) + if entity_type == "Account": + if identifier == "FullName": + return any( + item.get("upnColumn") == column or + (item.get("nameColumn") == column and _valid_account(item)) + for item in candidates + ) + if identifier == "Name" and column.lower() in ACCOUNT_UPN_ALIASES: + return any(item.get("upnColumn") == column for item in candidates) + if entity_type == "FileHash" and identifier in {"Algorithm", "Value"}: + facts = _linear_facts(query) + algorithm = facts[2].get(source_fields.get("Algorithm", "")) if facts else None + target = {"'SHA1'": "sha1Column", "'SHA256'": "sha256Column"}.get( + algorithm[0].upper() if algorithm and len(algorithm) == 1 else "" + ) + return bool(target) and any( + item.get(target) == source_fields.get("Value") for item in candidates + ) + return False + + +def _unproven_custom_detail_warning(key: str, column: str) -> str: + return ( + f"customDetails {key!r} column `{column}` has an unproven output binding " + "after a destructive projection; the explicit binding is retained, but " + "static analysis cannot establish its output and runtime output-schema " + "verification is required" + ) + + +def _supplemental_custom_details( + doc: dict[str, Any], override: dict[str, Any], mappings: dict[str, Any], + query: str, column_mappings: dict[str, str], +) -> tuple[Any, list[dict[str, Any]], list[str], list[str], list[str]]: + details: dict[str, Any] = {} + evidence: list[dict[str, Any]] = [] + warnings: list[str] = [] + information: list[str] = [] + errors: list[str] = [] + for origin, container in (("source", doc), ("configured override", override)): + if "customDetails" not in container: + continue + value = container["customDetails"] + if not isinstance(value, dict): + errors.append(f"{origin} customDetails must be an object of nonempty key-to-query-column strings") + if origin == "source": + return deepcopy(value), evidence, warnings, information, errors + continue + for key, source_column in value.items(): + if not isinstance(key, str) or not key or not isinstance(source_column, str) or not source_column: + errors.append(f"{origin} customDetails contains an invalid key or query-column value: {key!r}") + column = deepcopy(source_column) + else: + column = column_mappings.get(source_column, source_column) + if key in details and details[key] != column: + errors.append(f"customDetails key {key!r} conflicts with the existing source binding; no value was overwritten") + else: + details[key] = column + for key, column in details.items(): + if not isinstance(column, str) or not column: + continue + binding = _custom_detail_binding(query, column) + if binding == "missing": + errors.append( + f"customDetails {key!r} column `{column}` is missing from the final query output; " + "explicit binding retained for correction" + ) + elif binding == "unproven-destructive-projection": + warnings.append(_unproven_custom_detail_warning(key, column)) + elif binding != "available": + warnings.append(f"customDetails {key!r} column `{column}` requires runtime output-schema binding verification") + + for index, entity in enumerate(doc.get("entityMappings") or [], start=1): + entity_type = str(entity.get("entityType") or "UnknownEntity") + source_fields = { + str(field.get("identifier") or ""): column_mappings.get( + str(field.get("columnName") or ""), str(field.get("columnName") or ""), + ) + for field in entity.get("fieldMappings") or [] + } + for field in entity.get("fieldMappings") or []: + identifier = str(field.get("identifier") or "") + source_column = str(field.get("columnName") or "") + column = column_mappings.get(source_column, source_column) + if not identifier or not column: + continue + if _entity_identifier_preserved(entity_type, identifier, column, source_fields, mappings, query): + continue + binding = _custom_detail_binding(query, column) + record: dict[str, Any] = { + "entityType": entity_type, "entityIndex": index, "identifier": identifier, + "sourceColumn": source_column, "column": column, "status": binding, + } + evidence.append(record) + if binding == "missing": + record["status"] = "not-preserved" + record["reason"] = binding + warnings.append( + f"{entity_type}.{identifier} value is not preserved: column `{column}` " + "is missing from the final query output. No invalid customDetails binding was added and KQL was not rewritten" + ) + if entity_type == "Host" and identifier == "FullName": + warnings.append( + f"{BEST_EFFORT_ENTITY_REVIEW_PREFIX} Host.FullName column `{column}` " + "is not available in the query output, so no supported host-name " + "mapping or supplemental customDetails binding can be made" + ) + continue + if binding == "unproven-destructive-projection": + record["reason"] = binding + warnings.append( + f"{entity_type}.{identifier} output column `{column}` cannot be proven " + "after a destructive projection; no supplemental customDetails binding " + "was added and KQL was not rewritten. Runtime output-schema verification is required" + ) + continue + key = next((key for key, value in details.items() if value == column and isinstance(key, str)), None) + if key is None: + base = re.sub(r"[^A-Za-z0-9]", "", entity_type + identifier) or "EntityDetail" + key = base + suffix = 2 + while key in details: + key = f"{base}_{suffix}" + suffix += 1 + details[key] = column + record["customDetailKey"] = key + if binding == "runtime-binding-pending": + warnings.append( + f"{entity_type}.{identifier} value is retained as customDetails.{key} from query column " + f"`{column}`, but its target output binding requires runtime-schema verification; " + "custom details do not restore entity identity or correlation" + ) + warnings.append( + f"Supplemental customDetails.{key} column `{column}` requires runtime output-schema binding verification" + ) + else: + information.append( + f"{entity_type}.{identifier} mismatch is informational because its original value is " + f"preserved as customDetails.{key} from query column `{column}`; supplemental details " + "do not restore entity identity, correlation, or alert parity" + ) + if len(details) > 20: + errors.append( + f"customDetails contains {len(details)} pairs, exceeding the documented 20-pair limit; " + "no details were truncated. Review the source and supplemental details" + ) + if sum(len(key.encode("utf-8")) for key in details if isinstance(key, str)) > 4096: + errors.append("customDetails keys alone exceed the documented combined 4 KB per-alert limit; no keys were truncated") + if details: + warnings.append( + "Custom-detail values must remain within the documented combined 4 KB per-alert limit; " + "runtime values are unverified and the service drops all custom details if exceeded" + ) + return details, evidence, warnings, information, errors + + +def custom_detail_schema_errors(document: dict[str, Any], schema: list[dict[str, Any]]) -> list[str]: + alert = ((document.get("properties") or {}).get("detectionAction") or {}).get("alertTemplate") or {} + details = alert.get("customDetails") or {} + if not isinstance(details, dict): + return ["customDetails must be an object"] + if not isinstance(schema, list): + return ["customDetails binding verification requires a runtime output schema"] + columns = { + str(item.get("name") or item.get("Name") or "") + for item in schema if isinstance(item, dict) + } + return [ + f"customDetails {key!r} column `{column}` is absent from the runtime output schema" + for key, column in details.items() if not isinstance(column, str) or column not in columns + ] + + +def custom_detail_binding_warnings(document: dict[str, Any]) -> list[str]: + properties = document.get("properties") or {} + query = ((properties.get("queryCondition") or {}).get("queryText") or "") + alert = ((properties.get("detectionAction") or {}).get("alertTemplate") or {}) + details = alert.get("customDetails") + if not isinstance(details, dict): + return [] + return [ + _unproven_custom_detail_warning(key, column) + for key, column in details.items() + if isinstance(key, str) + and isinstance(column, str) + and _custom_detail_binding(query, column) == "unproven-destructive-projection" + ] + + +def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any]) -> dict[str, Any]: + with source.open(encoding="utf-8-sig") as handle: + doc = yaml.safe_load(handle) or {} + if not isinstance(doc, dict): + raise ValueError(f"analytic rule must contain a YAML object: {source}") + + source_query = str(doc.get("query") or "") + converted_query, query_warnings, errors = convert_query(source_query, config) + if "requiredDataConnectors" in doc: + with XDR_SCHEMA_PATH.open(encoding="utf-8") as handle: + connector_schema = json.load(handle)["properties"]["requiredDataConnectors"] + errors.extend( + f"requiredDataConnectors{''.join(f'[{part!r}]' for part in error.path)}: {error.message}" + for error in Draft202012Validator(connector_schema).iter_errors(doc["requiredDataConnectors"]) + ) + warnings = list(query_warnings) + informational: list[str] = [] + review_reasons = list(query_warnings) + try: + converted_query, parser_warnings = normalize_parser_bindings(converted_query, solution_root) + warnings.extend(parser_warnings) + except ValueError as exc: + errors.append(str(exc)) + is_nrt = str(doc.get("kind") or "").lower() == "nrt" + frequency, frequency_warning = iso_duration( + doc.get("queryFrequency") or ("PT1H" if is_nrt else None) + ) + if frequency_warning: + warnings.append(frequency_warning) + review_reasons.append(frequency_warning) + query_period = str(doc.get("queryPeriod") or "").strip() + converted_query, event_window, event_window_review = constrain_event_window( + doc, source_query, converted_query, frequency, + ) + if event_window_review: + review_reasons.append(event_window_review) + review_scope = { + "sourceDocumentSha256": hashlib.sha256( + yaml.safe_dump(doc, sort_keys=True, allow_unicode=True).encode("utf-8") + ).hexdigest(), + "targetQuerySha256": hashlib.sha256(converted_query.encode("utf-8")).hexdigest(), + } + schedule_warnings, schedule_information = assess_schedule( + doc, source_query, converted_query, frequency, + ) + warnings.extend(schedule_warnings) + informational.extend(schedule_information) + source_id = str(doc.get("id") or "") + rule_override = (config.get("ruleOverrides") or {}).get(source_id) or {} + + entity_information: list[str] = [] + column_mappings = ( + {} if query_has_ambiguous_table_scope(source_query) else query_column_renames(converted_query) + ) + configured_mappings, _ = configured_column_renames(source_query, config) + column_mappings.update(configured_mappings) + mappings, mapping_warnings = convert_entities( + doc, + converted_query, + column_mappings, + informational=entity_information, + defer_unmapped_entity_warnings=True, + ) + configured_entity_mappings = rule_override.get("entityMappings") + if configured_entity_mappings is not None: + if not isinstance(configured_entity_mappings, dict): + errors.append( + "configured ruleOverrides entityMappings must contain an object" + ) + else: + mappings = configured_entity_mappings + mapping_warnings.append( + "entity mappings were resolved by the configured per-rule override" + ) + if any( + warning.startswith(BEST_EFFORT_ENTITY_REVIEW_PREFIX) + for warning in mapping_warnings + ): + mapping_warnings = [ + warning for warning in mapping_warnings + if not warning.startswith(BEST_EFFORT_ENTITY_REVIEW_PREFIX) + ] + mapping_warnings.append( + f"{BEST_EFFORT_ENTITY_REVIEW_PREFIX} the configured per-rule " + "entityMappings override is retained; confirm its Host mapping " + "against the source Host.FullName value, which is preserved in " + "customDetails when its query output binding is available" + ) + else: + informational.extend(entity_information) + entity_review_reasons = [ + warning + for warning in mapping_warnings + if warning.startswith(NONBLOCKING_ENTITY_REVIEW_PREFIXES) + ] + if any(reason.startswith(ENTITY_CONFIRMATION_PREFIX) for reason in entity_review_reasons) and REQUIRED_ASSET_COLLECTIONS.intersection( + mappings + ) - {"accounts"}: + mappings.pop("accounts", None) + mapping_warnings = [ + warning + for warning in mapping_warnings + if not warning.startswith(ENTITY_CONFIRMATION_PREFIX) + ] + mapping_warnings.append( + "Unconfirmed generic Account.Name mapping was omitted because another " + "required Host, Mailbox, or IP asset is available" + ) + entity_review_reasons = [ + reason for reason in entity_review_reasons + if not reason.startswith(ENTITY_CONFIRMATION_PREFIX) + ] + warnings.extend(mapping_warnings) + review_reasons.extend(entity_review_reasons) + custom_details, supplemental_evidence, detail_warnings, detail_information, detail_errors = _supplemental_custom_details( + doc, rule_override, mappings, converted_query, column_mappings, + ) + warnings.extend(detail_warnings) + informational.extend(detail_information) + errors.extend(detail_errors) + review_reasons.extend( + warning for warning in detail_warnings + if warning.startswith(BEST_EFFORT_ENTITY_REVIEW_PREFIX) + ) + + source_tactics = deepcopy(doc.get("tactics", [])) + source_relevant = deepcopy(doc.get("relevantTechniques", [])) + tactic_payload, classification_errors, classification_warnings = _classification_payload( + source_tactics, source_relevant, rule_override, + ) + errors.extend(classification_errors) + warnings.extend(classification_warnings) + if len(tactic_payload) > 1: + informational.append( + "All source MITRE tactics are preserved in source order in authored YAML. " + "V3.1 ARM packaging carries only the first tactic and its compatible techniques; " + "this classification loss is not validated parity. Direct Graph deployment requires a single tactic." + ) + if not mappings: + errors.append("no supported entity mappings were produced") + elif not REQUIRED_ASSET_COLLECTIONS.intersection(mappings): + errors.append("a Host, Account, Mailbox, or IP mapping is required") + + if not source_id: + errors.append("source analytic rule has no id") + elif not re.fullmatch(SOURCE_ID_PATTERN, source_id): + errors.append("source analytic rule id must be a full hyphenated GUID") + + accepted_review_reasons: set[str] = set() + review_decisions = rule_override.get("reviewDecisions") or [] + if "acceptedReviewReasons" in rule_override: + errors.append( + "acceptedReviewReasons is unscoped and no longer clears review findings; " + "use hash-scoped reviewDecisions" + ) + if not isinstance(review_decisions, list): + errors.append("configured reviewDecisions must be an array of hash-scoped accept/reject decisions") + review_decisions = [] + recorded_review_decisions: list[dict[str, Any]] = [] + seen_review_decisions: set[str] = set() + for decision in review_decisions: + if not isinstance(decision, dict): + errors.append("each reviewDecision must be an object") + continue + reason = decision.get("reason") + choice = decision.get("decision") + source_hash = decision.get("sourceDocumentSha256") + query_hash = decision.get("targetQuerySha256") + if not isinstance(reason, str) or not reason or choice not in {"accepted", "rejected"}: + errors.append("each reviewDecision requires an exact reason and decision accepted or rejected") + continue + if reason in seen_review_decisions: + errors.append(f"duplicate reviewDecision for {reason!r}") + continue + seen_review_decisions.add(reason) + if not ( + isinstance(source_hash, str) and re.fullmatch(r"[0-9a-f]{64}", source_hash) + and isinstance(query_hash, str) and re.fullmatch(r"[0-9a-f]{64}", query_hash) + ): + errors.append(f"reviewDecision for {reason!r} requires source-document and target-query SHA-256 hashes") + continue + if source_hash != review_scope["sourceDocumentSha256"] or query_hash != review_scope["targetQuerySha256"]: + errors.append(f"reviewDecision for {reason!r} is stale; source document or target query hash changed") + continue + if reason not in review_reasons: + errors.append(f"reviewDecision reason does not match a current review finding: {reason}") + continue + recorded_review_decisions.append({ + "reason": reason, + "decision": choice, + "sourceDocumentSha256": source_hash, + "targetQuerySha256": query_hash, + }) + if choice == "accepted": + accepted_review_reasons.add(reason) + review_reasons = [ + reason for reason in review_reasons if reason not in accepted_review_reasons + ] + + display_name = str(doc.get("name") or source.stem) + detection_id = f"xdr-{source_id}" + severity = str(doc.get("severity") or "Medium").lower() + if severity not in SUPPORTED_SEVERITIES: + warnings.append(f"unsupported severity {severity!r}; changed to medium") + severity = "medium" + + alert: dict[str, Any] = { + "title": display_name[:120], + "description": str(doc.get("description") or display_name).strip()[:600], + "severity": severity, + "entityMappings": mappings, + } + if tactic_payload: + alert["tactics"] = tactic_payload + if custom_details or "customDetails" in doc or "customDetails" in rule_override: + alert["customDetails"] = custom_details + + blocking_review_reasons = [ + reason + for reason in review_reasons + if not reason.startswith(NONBLOCKING_ENTITY_REVIEW_PREFIXES) + ] + status = "needsReview" if errors or blocking_review_reasons else "converted" + relative_source = source.relative_to(solution_root).as_posix() + return { + "schemaVersion": SCHEMA_VERSION, + "version": INITIAL_XDR_VERSION, + "kind": "CustomDetection", + "resourceType": "Microsoft.Security/detectionRules", + "apiVersion": DETECTION_API_VERSION, + **( + {"requiredDataConnectors": deepcopy(doc["requiredDataConnectors"])} + if "requiredDataConnectors" in doc + else {} + ), + "contentProvenance": { + "source": { + "platform": "Microsoft Sentinel", + "kind": "AnalyticsRule", + "id": source_id, + "path": relative_source, + "version": str(doc.get("version") or ""), + "querySha256": hashlib.sha256(source_query.encode("utf-8")).hexdigest(), + "schedule": { + "queryFrequency": str(doc.get("queryFrequency") or ""), + "queryPeriod": query_period, + }, + }, + "conversion": { + "tool": "sentinel-to-xdr-migration", + "version": __version__, + "status": status, + "reviewRequired": bool(errors or review_reasons), + "reviewReasons": review_reasons, + "requiredWorkloads": ["sentinel"], + "warnings": warnings, + "informational": informational, + "errors": errors, + "originalTactics": source_tactics, + "originalTechniques": source_relevant, + "reviewScope": review_scope, + **({"reviewDecisions": recorded_review_decisions} if recorded_review_decisions else {}), + **({"eventTimeWindow": event_window} if event_window else {}), + **({"supplementalEntityDetails": supplemental_evidence} if supplemental_evidence else {}), + **( + {"acceptedReviewReasons": sorted(accepted_review_reasons)} + if accepted_review_reasons + else {} + ), + }, + }, + "properties": { + "id": detection_id, + "displayName": display_name[:120], + "status": "disabled", + "queryCondition": {"queryText": converted_query}, + "schedule": {"frequency": frequency}, + "detectionAction": {"alertTemplate": alert}, + }, + } + + +def xdr_version_error(version: Any) -> str | None: + if isinstance(version, str) and re.fullmatch(XDR_VERSION_PATTERN, version): + return None + return ( + f"XDR top-level version {version!r} must be a major.minor.patch release " + ">= 3.1.0 and < 4.0.0. For older files without version, reconvert with " + "--overwrite (initial version 3.1.0), or explicitly author the XDR version. " + "Do not change contentProvenance.source.version." + ) + + +def is_sentinel_derived(document: dict[str, Any]) -> bool: + provenance = document.get("contentProvenance") or {} + source = provenance.get("source") or {} + conversion = provenance.get("conversion") or {} + return ( + source.get("platform") == "Microsoft Sentinel" + and source.get("kind") == "AnalyticsRule" + ) or conversion.get("tool") == "sentinel-to-xdr-migration" + + +def _existing_identity_error( + document: Any, source_id: str, source_path: str, source_name: str, +) -> str | None: + if not isinstance(document, dict): + return "existing output is not an XDR document; resolve the output identity conflict" + provenance = document.get("contentProvenance") or {} + if not isinstance(provenance, dict): + return "existing output has malformed provenance; resolve the output identity conflict" + source = provenance.get("source") or {} + conversion = provenance.get("conversion") or {} + properties = document.get("properties") or {} + if not all(isinstance(value, dict) for value in (source, conversion, properties)): + return "existing output has malformed identity fields; resolve the output identity conflict" + if ( + document.get("kind") != "CustomDetection" + or document.get("resourceType") != "Microsoft.Security/detectionRules" + or source.get("platform") != "Microsoft Sentinel" + or source.get("kind") != "AnalyticsRule" + or source.get("id") != source_id + or str(source.get("path") or "").replace("\\", "/") != source_path + ): + return ( + "existing XDR output belongs to a different source rule or has conflicting " + "provenance; resolve the output identity conflict before reconverting" + ) + detection_id = properties.get("id") + if isinstance(detection_id, str) and detection_id.casefold() in { + source_id.casefold(), + f"xdr-{source_id}".casefold(), + }: + return None + legacy_ids = { + f"xdr-{slugify(name)}-{source_id[:8]}" + for name in (str(properties.get("displayName") or ""), source_name) + if name + } + if ( + conversion.get("tool") == "sentinel-to-xdr-migration" + and isinstance(detection_id, str) + and detection_id in legacy_ids + ): + return None + return ( + f"existing Custom Detection id {detection_id!r} is not the source GUID, its " + "canonical xdr- ID, or a generated legacy name ID; reconcile this custom identity before " + "reconversion, even with --overwrite" + ) + + +def validate_document(document: dict[str, Any]) -> list[str]: + with XDR_SCHEMA_PATH.open(encoding="utf-8") as handle: + schema = json.load(handle) + errors = [ + f"schema: {error.message}" + for error in Draft202012Validator(schema).iter_errors(document) + ] + version_error = xdr_version_error(document.get("version")) + if version_error: + errors.append(version_error) + if document.get("schemaVersion") != SCHEMA_VERSION: + errors.append(f"schemaVersion must be {SCHEMA_VERSION}") + if document.get("kind") != "CustomDetection": + errors.append("kind must be CustomDetection") + if document.get("resourceType") != "Microsoft.Security/detectionRules": + errors.append("resourceType must be Microsoft.Security/detectionRules") + provenance = document.get("contentProvenance") or {} + source = provenance.get("source") or {} + conversion = provenance.get("conversion") or {} + properties = document.get("properties") or {} + detection_id = properties.get("id") + id_is_guid = isinstance(detection_id, str) and re.fullmatch(SOURCE_ID_PATTERN, detection_id) + id_is_prefixed_guid = isinstance(detection_id, str) and re.fullmatch( + PREFIXED_SOURCE_ID_PATTERN, detection_id, + ) + if not id_is_guid and not id_is_prefixed_guid and not ( + isinstance(detection_id, str) and re.fullmatch(GRAPH_DETECTION_ID_PATTERN, detection_id) + ): + errors.append( + "properties.id must be a Graph-compatible identifier beginning with a letter " + "and containing at most 100 letters, digits, dashes, or underscores" + ) + if is_sentinel_derived(document): + if not isinstance(source.get("id"), str) or not re.fullmatch(SOURCE_ID_PATTERN, source["id"]): + errors.append("contentProvenance.source.id must be a full hyphenated Sentinel template GUID") + expected_id = f"xdr-{source.get('id')}" + if detection_id != expected_id: + errors.append( + "Sentinel-derived properties.id must be the canonical xdr- prefix plus " + "contentProvenance.source.id; reconvert with --overwrite to update local " + "artifacts only, not deployed rule migration" + ) + for field in ("platform", "kind", "id", "path", "querySha256"): + if not source.get(field): + errors.append(f"contentProvenance.source.{field} is required") + if conversion.get("status") not in {"converted", "needsReview"}: + errors.append("contentProvenance.conversion.status is invalid") + if conversion.get("errors"): + errors.extend(f"conversion: {value}" for value in conversion["errors"]) + if properties.get("status") != "disabled": + errors.append("properties.status must be disabled") + query = ((properties.get("queryCondition") or {}).get("queryText") or "") + if not query: + errors.append("properties.queryCondition.queryText is required") + alert = ((properties.get("detectionAction") or {}).get("alertTemplate") or {}) + mappings = alert.get("entityMappings") or {} + custom_details = alert.get("customDetails") + if isinstance(custom_details, dict): + if sum(len(key.encode("utf-8")) for key in custom_details if isinstance(key, str)) > 4096: + errors.append("customDetails keys alone exceed the documented combined 4 KB per-alert limit") + for key, column in custom_details.items(): + if isinstance(column, str) and _custom_detail_binding(query, column) == "missing": + errors.append( + f"customDetails {key!r} column `{column}` is missing from the final query output" + ) + if not mappings: + errors.append("alertTemplate.entityMappings is required") + if not REQUIRED_ASSET_COLLECTIONS.intersection(mappings): + errors.append("at least one Host, Account, Mailbox, or IP mapping is required") + tactics = alert.get("tactics") or [] + if isinstance(tactics, list) and tactics: + try: + catalog = _mitre_catalog() + except (OSError, ValueError) as exc: + errors.append(f"Cannot validate MITRE classification: {exc}") + else: + for item in tactics: + if not isinstance(item, dict): + continue + tactic = item.get("tactic") + if not isinstance(tactic, str) or tactic not in catalog: + errors.append(f"unsupported MITRE tactic: {tactic!r}") + continue + technique_items = item.get("techniques") or [] + if not isinstance(technique_items, list): + continue + for technique in technique_items: + if not isinstance(technique, dict): + continue + base = technique.get("technique") + if not isinstance(base, str) or base not in catalog[tactic]: + errors.append(f"MITRE technique {base!r} is not documented for tactic {tactic}") + subtechniques = technique.get("subTechniques") or [] + if not isinstance(subtechniques, list): + continue + for sub in subtechniques: + if not isinstance(sub, str) or not re.fullmatch(r"T\d{4}\.\d{3}", sub) or sub.split(".")[0] != base: + errors.append(f"MITRE subtechnique {sub!r} must belong to technique {base!r}") + return errors + + +def convert_solution( + solution: str | Path, + *, + overwrite: bool = False, + config_path: str | Path | None = None, + rule_id: str | None = None, +) -> dict[str, Any]: + root, analytic, output = solution_paths(solution) + sources = analytic_rule_files(root) + existing_outputs = xdr_detection_files(output) + existing_by_source: dict[str, list[Path]] = {} + existing_by_id: dict[str, list[Path]] = {} + existing_documents: dict[Path, Any] = {} + identified_outputs: set[Path] = set() + for path in existing_outputs: + try: + existing = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + existing_documents[path] = existing + provenance = (existing.get("contentProvenance") or {}).get("source") or {} + detection_id = str((existing.get("properties") or {}).get("id") or "") + if detection_id: + existing_by_id.setdefault(detection_id.casefold(), []).append(path) + for key in (str(provenance.get("id") or "").casefold(), str(provenance.get("path") or "").replace("\\", "/")): + if key: + existing_by_source.setdefault(key, []).append(path) + identified_outputs.add(path) + except (AttributeError, OSError, yaml.YAMLError): + continue + config = load_config(output, config_path) + results: list[ConversionResult] = [] + changed_target_queries: list[str] = [] + excluded_rule_ids = config.get("excludedRuleIds") or {} + if not isinstance(excluded_rule_ids, dict): + raise ValueError("excludedRuleIds must contain an object of rule IDs and reasons") + + source_documents: dict[Path, dict[str, Any]] = {} + sources_by_id: dict[str, list[Path]] = {} + for source in sources: + with source.open(encoding="utf-8-sig") as handle: + source_document = yaml.safe_load(handle) or {} + if not isinstance(source_document, dict): + raise ValueError(f"analytic rule must contain a YAML object: {source}") + source_documents[source] = source_document + source_id = str(source_document.get("id") or "") + if source_id: + sources_by_id.setdefault(source_id.casefold(), []).append(source) + + selected_sources = sources + if rule_id is not None: + if not isinstance(rule_id, str) or not re.fullmatch(SOURCE_ID_PATTERN, rule_id): + raise ValueError("--rule-id must be a full hyphenated Sentinel template GUID") + selected_sources = sources_by_id.get(rule_id.casefold(), []) + if len(selected_sources) != 1: + raise ValueError( + f"--rule-id {rule_id!r} must match exactly one source analytic rule; " + f"found {len(selected_sources)} (unknown or duplicate source ID)" + ) + if any(str(key).casefold() == rule_id.casefold() for key in excluded_rule_ids): + raise ValueError("--rule-id selects an excluded rule; reconcile excludedRuleIds explicitly") + + # Selection is checked before creating Logs, output directories, or removing exclusions. + reports = report_directory(root, create=True) + output.mkdir(parents=True, exist_ok=True) + for source in selected_sources: + target = content_path(output, source.relative_to(analytic)) + source_document = source_documents[source] + source_id = str(source_document.get("id") or "") + source_path = source.relative_to(root).as_posix() + identity_error = None + if len(sources_by_id.get(source_id.casefold(), [])) > 1: + identity_error = f"duplicate source analytic rule id {source_id!r}; reconcile source identities before conversion" + id_collisions = set(existing_by_id.get(source_id.casefold(), [])) - {target} + if id_collisions: + identity_error = ( + f"Custom Detection id {source_id!r} is already used by another output; " + "review and explicitly relocate or reconcile it before conversion " + "(even with --overwrite): " + + ", ".join(path.relative_to(root).as_posix() for path in sorted(id_collisions)) + ) + existing_document = existing_documents.get(target) + existing_properties = ( + existing_document.get("properties") or {} + if isinstance(existing_document, dict) else {} + ) + previous_target_query = ( + existing_properties.get("queryCondition", {}).get("queryText") + if isinstance(existing_properties, dict) + and isinstance(existing_properties.get("queryCondition"), dict) + else None + ) + if isinstance(existing_document, dict): + existing_properties = existing_document.get("properties") or {} + previous_id = str(existing_properties.get("id") or "") if isinstance(existing_properties, dict) else "" + if previous_id and len(existing_by_id.get(previous_id.casefold(), [])) > 1: + identity_error = f"duplicate existing Custom Detection id {previous_id!r}; reconcile output identities before conversion" + if not identity_error and target.exists(): + identity_error = _existing_identity_error( + existing_document, source_id, source_path, + str(source_document.get("name") or source.stem), + ) + if identity_error: + results.append(ConversionResult( + source, target, str(source_document.get("name") or source.stem), + "conflict", True, (identity_error,), (), (identity_error,), + )) + continue + alternate_outputs = set( + existing_by_source.get(source_id.casefold(), []) + + existing_by_source.get(source_path, []) + ) - {target} + # A legacy flattened file must not become a second detection for this source. + legacy_target = output / source.name + if ( + target != legacy_target + and legacy_target.exists() + and legacy_target not in identified_outputs + ): + alternate_outputs.add(legacy_target) + if alternate_outputs: + error = ( + "existing detection at a different path; review and explicitly relocate " + "or reconcile it before conversion (even with --overwrite): " + + ", ".join(path.relative_to(root).as_posix() for path in sorted(alternate_outputs)) + ) + results.append(ConversionResult( + source, target, str(source_document.get("name") or source.stem), + "conflict", True, (error,), (), (error,), + )) + continue + exclusion_reason = excluded_rule_ids.get(source_id) + if exclusion_reason is not None: + if target.exists(): + if not overwrite: + results.append( + ConversionResult( + source, + target, + str(source_document.get("name") or source.stem), + "conflict", + True, + ("excluded output exists; rerun with --overwrite to remove it",), + (), + ("excluded output exists; rerun with --overwrite to remove it",), + ) + ) + continue + target.unlink() + results.append( + ConversionResult( + source, + target, + str(source_document.get("name") or source.stem), + "excluded", + False, + (), + (f"excluded from Custom Detections: {exclusion_reason}",), + (), + ) + ) + continue + document = build_xdr_document(source, root, config) + if target.exists(): + if isinstance(existing_document, dict) and "version" in existing_document: + error = xdr_version_error(existing_document["version"]) + if error: + results.append( + ConversionResult( + source, target, document["properties"]["displayName"], + "conflict", True, (error,), (), (error,), + ) + ) + continue + document["version"] = existing_document["version"] + previous_id = existing_document["properties"]["id"] + identity_change = ( + (existing_document.get("contentProvenance") or {}).get("conversion") or {} + ).get("identityChange") + canonical_id = document["properties"]["id"] + if previous_id != canonical_id and not isinstance(identity_change, dict): + identity_change = { + "previousId": previous_id, + "currentId": canonical_id, + "scope": "local-artifact-only", + } + elif isinstance(identity_change, dict) and identity_change.get("currentId") in { + source_id, canonical_id, + }: + identity_change = { + **identity_change, + "currentId": canonical_id, + "scope": "local-artifact-only", + } + if isinstance(identity_change, dict) and identity_change.get("currentId") == canonical_id: + conversion = document["contentProvenance"]["conversion"] + conversion["identityChange"] = identity_change + conversion["warnings"].append( + f"Local detection ID changed from {identity_change.get('previousId')!r} " + f"to canonical Graph-compatible ID {canonical_id!r} for source " + f"{source_id!r}. No cloud rules " + "were updated, deleted, or migrated. Review existing deployed rules " + "and reconcile their identities before any separately approved deployment." + ) + rendered = yaml.dump( + document, + Dumper=XdrYamlDumper, + sort_keys=False, + allow_unicode=False, + width=120, + ) + if target.exists() and not overwrite: + existing = target.read_text(encoding="utf-8") + if existing != rendered: + results.append( + ConversionResult( + source, + target, + document["properties"]["displayName"], + "conflict", + True, + ("output exists with different content; rerun with --overwrite",), + (), + ("output exists with different content; rerun with --overwrite",), + ) + ) + continue + if not target.exists() or target.read_text(encoding="utf-8") != rendered: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(rendered, encoding="utf-8", newline="\n") + current_target_query = document["properties"]["queryCondition"]["queryText"] + if previous_target_query != current_target_query: + changed_target_queries.append(target.relative_to(root).as_posix()) + conversion = document["contentProvenance"]["conversion"] + results.append( + ConversionResult( + source, + target, + document["properties"]["displayName"], + conversion["status"], + bool(conversion["reviewRequired"]), + tuple(conversion["reviewReasons"]), + tuple(conversion["warnings"]), + tuple(conversion["errors"]), + tuple(conversion.get("informational") or []), + len(document["properties"]["detectionAction"]["alertTemplate"].get("tactics") or []), + ) + ) + + reports = report_directory(root, create=True) + transformation_report = reports / "transformation-report.html" + manifest_path = reports / "manifest.json" + summary = { + "solution": str(root), + "scope": { + "kind": "rule" if rule_id is not None else "solution", + "ruleIds": ( + [source_documents[source]["id"] for source in selected_sources] + if rule_id is not None else [] + ), + "sourceTotal": len(sources), + "selectedTotal": len(selected_sources), + }, + "outputDirectory": str(output), + "reportDirectory": str(reports), + "manifest": str(manifest_path), + "transformationReport": str(transformation_report), + "total": len(results), + "converted": sum(result.status == "converted" for result in results), + "excluded": sum(result.status == "excluded" for result in results), + "needsReview": sum(result.status == "needsReview" for result in results), + "reviewRequired": sum(result.review_required for result in results), + "deploymentReady": sum( + result.status == "converted" and not result.review_required and result.tactic_count <= 1 + for result in results + ), + "conflicts": sum(result.status == "conflict" for result in results), + "results": [ + { + **result.as_dict(), + "sourceRelativePath": result.source.relative_to(root).as_posix(), + "outputRelativePath": result.output.relative_to(root).as_posix(), + } + for result in results + ], + } + write_json_artifact(root, manifest_path, summary) + if rule_id is not None: + from .workflow import invalidate_scoped_completion + + invalidate_scoped_completion(root) + elif changed_target_queries: + from .workflow import invalidate_runtime_completion + + invalidate_runtime_completion(root, changed_target_queries) + write_transformation_report(portable_artifact(root, summary), transformation_report) + return summary + + +def validate_solution(solution: str | Path) -> dict[str, Any]: + root, _, output = solution_paths(solution) + files = xdr_detection_files(output) if output.exists() else [] + results = [] + results_by_id: dict[str, list[dict[str, Any]]] = {} + for path in files: + detection_id = None + warnings = [] + try: + with path.open(encoding="utf-8-sig") as handle: + document = yaml.safe_load(handle) or {} + errors = validate_document(document) + warnings = custom_detail_binding_warnings(document) + detection_id = (document.get("properties") or {}).get("id") + except (OSError, yaml.YAMLError, ValueError) as exc: + errors = [str(exc)] + result = { + "file": str(path), + "valid": not errors, + "errors": errors, + "warnings": warnings, + } + results.append(result) + if isinstance(detection_id, str) and detection_id: + results_by_id.setdefault(detection_id.casefold(), []).append(result) + for detection_id, duplicates in results_by_id.items(): + if len(duplicates) > 1: + for result in duplicates: + result["errors"].append(f"duplicate Custom Detection id {detection_id!r}") + result["valid"] = False + return { + "solution": str(root), + "conversionScope": conversion_scope(root), + "total": len(results), + "valid": sum(item["valid"] for item in results), + "invalid": sum(not item["valid"] for item in results), + "results": results, + } + + +def runtime_validation_plan(solution: str | Path) -> dict[str, Any]: + root, _, output = solution_paths(solution) + plan = [] + for path in xdr_detection_files(output): + with path.open(encoding="utf-8-sig") as handle: + document = yaml.safe_load(handle) or {} + source_path = content_path(root, document["contentProvenance"]["source"]["path"]) + with source_path.open(encoding="utf-8-sig") as handle: + source = yaml.safe_load(handle) or {} + plan.append( + { + "detection": path.relative_to(output).as_posix(), + "sourceRule": str(source_path), + "sentinelQuery": str(source.get("query") or ""), + "advancedHuntingQuery": document["properties"]["queryCondition"]["queryText"], + "sentinelQuerySha256": hashlib.sha256( + str(source.get("query") or "").encode("utf-8") + ).hexdigest(), + "advancedHuntingQuerySha256": hashlib.sha256( + document["properties"]["queryCondition"]["queryText"].encode("utf-8") + ).hexdigest(), + "customDetailBindings": deepcopy( + document["properties"]["detectionAction"]["alertTemplate"].get("customDetails") or {} + ), + } + ) + return { + "solution": str(root), + "instructions": ( + "Run sentinelQuery and advancedHuntingQuery through Microsoft Sentinel and " + "the configured runtime providers. Include sentinelQuerySha256 as querySha256 " + "for every imported Sentinel result. The Advanced Hunting validator records " + "the exact target-query hash itself. Missing or stale query hashes cannot " + "qualify current KQL. Record execution errors and compare output entities. " + "Verify every customDetailBindings value against the returned Advanced Hunting output schema; " + "query success alone does not prove these bindings. Check the combined custom-detail runtime " + "value size against the documented 4 KB per-alert limit." + ), + "rules": plan, + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/coordinator.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/coordinator.py new file mode 100644 index 00000000000..47fdf36f1e6 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/coordinator.py @@ -0,0 +1,1575 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +try: + import fcntl +except ImportError: + fcntl = None + import msvcrt + +from .artifacts import existing_artifact_path, report_directory, using_run +from .converter import runtime_validation_plan +from .runtime import current_runtime_evidence +from .workflow import next_workflow_stage, workflow_status + +REGISTRY_NAME = "registry.json" +DEFAULT_IDLE_SECONDS = 900 +ENVIRONMENT_WRITE_STAGES = {"deployment", "mockIngestion", "alertParity"} +AUTH_PURPOSES = { + "validation:sentinel-runtime": "sentinelRuntime", + "validation:advanced-hunting": "advancedHunting", + "deployment": "customDetection", +} + + +def default_registry_path() -> Path: + repository_root = Path(__file__).resolve().parents[3] + return repository_root / "Tools" / "ignored" / "sentinel-xdr-coordinator" / REGISTRY_NAME + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _timestamp(value: datetime | None = None) -> str: + return (value or _now()).isoformat() + + +def _parse_timestamp(value: str) -> datetime: + parsed = datetime.fromisoformat(value) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _canonical(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _safe_registry(path: str | Path | None) -> Path: + return Path(path).expanduser().resolve() if path else default_registry_path() + + +@contextmanager +def _registry_lock(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + lock_path = path.with_suffix(path.suffix + ".lock") + with lock_path.open("a+b") as handle: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + else: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + else: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + + +def _atomic_json(path: Path, document: dict[str, Any]) -> None: + content = (json.dumps(document, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", prefix=f".{path.name}.", suffix=".tmp", + dir=path.parent, delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + temporary = None + if fcntl is not None: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _empty_registry() -> dict[str, Any]: + return { + "schemaVersion": "1.0.0", + "revision": 0, + "updatedAt": _timestamp(), + "solutions": {}, + "events": [], + "outbox": [], + "sharedDependencies": {}, + } + + +def _read_registry(path: Path) -> dict[str, Any]: + if not path.exists(): + return _empty_registry() + document = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(document, dict) + or document.get("schemaVersion") != "1.0.0" + or not isinstance(document.get("solutions"), dict) + or not isinstance(document.get("events"), list) + or not isinstance(document.get("outbox"), list) + or not isinstance(document.get("sharedDependencies"), dict) + ): + raise ValueError(f"invalid coordinator registry: {path}") + return document + + +def _write_registry(path: Path, document: dict[str, Any]) -> None: + document["revision"] = int(document.get("revision", 0)) + 1 + document["updatedAt"] = _timestamp() + _atomic_json(path, document) + + +def _entry_key(solution: Path, run_id: str) -> str: + return _sha256(_canonical({"solution": str(solution), "runId": run_id})) + + +def _record_event( + registry: dict[str, Any], + *, + event_id: str, + kind: str, + payload: dict[str, Any], + scope: str | None = None, +) -> tuple[dict[str, Any], bool]: + if not event_id.strip() or len(event_id) > 200: + raise ValueError("event ID must be non-empty and at most 200 characters") + event_hash = _sha256(_canonical({"kind": kind, "payload": payload})) + for existing in registry["events"]: + if existing["eventId"] != event_id or existing.get("scope") != scope: + continue + if existing["eventSha256"] != event_hash: + raise ValueError(f"event ID was already used with different content: {event_id}") + return existing, False + event = { + "eventId": event_id, + "scope": scope, + "eventSha256": event_hash, + "kind": kind, + "payload": payload, + "createdAt": _timestamp(), + "consumedAt": None, + } + registry["events"].append(event) + return event, True + + +def _append_system_event( + registry: dict[str, Any], event_id: str, kind: str, payload: dict[str, Any] +) -> None: + _record_event( + registry, + event_id=event_id, + kind=kind, + payload=payload, + scope=payload.get("entryId"), + ) + + +def _authoritative_claim_completion( + claim: dict[str, Any] | None, + action: dict[str, Any] | None, + snapshot: dict[str, Any], + *, + action_id: str, + target_sha256: str | None, + evidence_sha256: str | None, +) -> bool: + if ( + not claim + or claim.get("status") not in {"active", "expired-unknown"} + or claim.get("actionId") != action_id + or not action + or action.get("actionId") != action_id + or action.get("actionSha256") != claim.get("actionSha256") + or action.get("targetSha256") != claim.get("targetSha256") + or action.get("stage") != claim.get("stage") + or target_sha256 != claim.get("targetSha256") + or target_sha256 != snapshot["target"]["sha256"] + or not evidence_sha256 + ): + return False + stage = (snapshot["status"].get("stages") or {}).get(action.get("stage"), {}) + if stage.get("status") not in {"passed", "failed", "blocked"}: + return False + return any( + evidence.get("sha256") == evidence_sha256 + for evidence in snapshot["stageEvidence"].get(action.get("stage"), []) + ) + + +def _claim_stage_is_officially_complete( + claim: dict[str, Any], snapshot: dict[str, Any] +) -> bool: + if claim.get("targetSha256") != snapshot["target"]["sha256"]: + return False + stage = (snapshot["status"].get("stages") or {}).get(claim.get("stage"), {}) + return stage.get("status") in {"passed", "failed", "blocked"} + + +def _hash_local_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _file_evidence(root: Path, refs: list[Any]) -> list[dict[str, str]]: + evidence: dict[str, str] = {} + for raw in refs: + if not isinstance(raw, str) or not raw: + continue + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = root / candidate + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + except (OSError, ValueError): + continue + if resolved.is_file(): + evidence[resolved.relative_to(root).as_posix()] = _hash_local_file(resolved) + return [ + {"path": item, "sha256": digest} + for item, digest in sorted(evidence.items()) + ] + + +def _target_snapshot(solution: Path, state: dict[str, Any]) -> dict[str, Any]: + context = state.get("context") or {} + fields = ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + target = {field: context.get(field) for field in fields if context.get(field)} + target_path = existing_artifact_path(solution, "qualification-target.json") + if target_path.is_file(): + try: + stored_target = json.loads(target_path.read_text(encoding="utf-8-sig")) + if isinstance(stored_target, dict): + file_target = { + field: stored_target.get(field) + for field in fields + if stored_target.get(field) + } + if target and target != file_target: + raise ValueError( + "qualification-target.json does not match the persisted workflow target" + ) + if not target and file_target: + raise ValueError( + "qualification target sidecar exists without a persisted workflow target" + ) + target = file_target + except (OSError, json.JSONDecodeError): + raise ValueError("locked Qualification target is unreadable") + return { + "values": target, + "sha256": _sha256(_canonical(target)) if target else None, + "path": ( + str(target_path) + if target_path.is_file() + else None + ), + "fileSha256": _hash_local_file(target_path) if target_path.is_file() else None, + } + + +def _workflow_snapshot(solution: str | Path) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + status_before = workflow_status(root) + run_id = status_before.get("runId") + if not isinstance(run_id, str) or not run_id: + raise ValueError("selected workflow has no persisted run ID") + state_path = Path(status_before["statePath"]).resolve() + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + if state.get("runId") != run_id: + raise ValueError("workflow state run ID does not match the selected run") + state_sha256 = _hash_local_file(state_path) + next_action = next_workflow_stage(root) + if next_action.get("runId") != run_id: + raise ValueError("workflow-next returned a different run ID") + status = workflow_status(root) + compared_fields = ("runId", "workflowStatus", "context", "stages") + if ( + _hash_local_file(state_path) != state_sha256 + or any(status_before.get(field) != status.get(field) for field in compared_fields) + ): + raise ValueError( + "workflow state changed while taking the coordinator snapshot; reconcile again" + ) + target = _target_snapshot(root, state) + stage_evidence = {} + for name, stage in (status.get("stages") or {}).items(): + stage_evidence[name] = _file_evidence( + root, + list(stage.get("artifacts", {}).values()) + + list(stage.get("evidence", [])), + ) + return { + "solution": str(root), + "runId": run_id, + "statePath": str(state_path), + "stateSha256": state_sha256, + "workflowStatus": status.get("workflowStatus"), + "profile": (status.get("context") or {}).get("workflowProfile"), + "status": status, + "next": next_action, + "target": target, + "stageEvidence": stage_evidence, + } + + +def _required_shared_dependencies(entry: dict[str, Any], action_id: str) -> list[dict[str, Any]]: + requirements = entry.get("requiredSharedDependencies") or {} + return requirements.get(action_id, []) + + +def _dependency_key(key: str, purpose: str, target_sha256: str | None) -> str: + return _sha256(_canonical({ + "key": key, + "purpose": purpose, + "targetSha256": target_sha256, + })) + + +def _apply_shared_dependencies( + registry: dict[str, Any], + entry: dict[str, Any], + action: dict[str, Any], +) -> None: + required = _required_shared_dependencies(entry, action["actionId"]) + observed = [] + for requirement in required: + key = requirement["key"] + purpose = requirement["purpose"] + dep_id = _dependency_key(key, purpose, action.get("targetSha256")) + dependency = registry["sharedDependencies"].get(dep_id) + evidence_path = ( + Path(dependency["solution"]) / dependency["evidencePath"] + if dependency + and dependency.get("solution") + and dependency.get("evidencePath") + else None + ) + try: + evidence_current = bool( + evidence_path + and evidence_path.is_file() + and _hash_local_file(evidence_path) == dependency.get("evidenceSha256") + ) + except OSError: + evidence_current = False + reported = bool( + dependency + and dependency.get("status") == "reported" + and evidence_current + ) + observed.append({ + **requirement, + "targetSha256": action.get("targetSha256"), + "status": "reported" if reported else "missing", + "evidenceSha256": dependency.get("evidenceSha256") if dependency else None, + }) + if not reported and action["status"] not in {"complete", "blocked"}: + action["status"] = "blocked" + action["reason"] = ( + f"required shared dependency is not complete for purpose {purpose}: {key}" + ) + action["sharedPrerequisites"] = observed + + +def _runtime_actions(root: Path, snapshot: dict[str, Any]) -> list[dict[str, Any]]: + stages = snapshot["status"].get("stages") or {} + if ( + snapshot["profile"] not in {"authoring", "qualification"} + or stages.get("conversion", {}).get("status") != "passed" + or stages.get("validation", {}).get("status") in {"passed", "notRequired"} + ): + return [] + try: + plan = runtime_validation_plan(root) + evidence = current_runtime_evidence(root) + except (OSError, ValueError, KeyError, TypeError) as exc: + return [{ + "actionId": "validation:runtime-evidence", + "stage": "validation", + "purpose": "runtimeEvidence", + "status": "blocked", + "reason": f"current exact-query plan is unavailable: {type(exc).__name__}", + "prerequisites": [{"stage": "conversion", "status": "passed"}], + "evidence": [], + "targetSha256": snapshot["target"]["sha256"], + }] + if not plan.get("rules"): + return [] + query_hashes = { + provider: _sha256(_canonical(sorted( + rule.get(field) + for rule in plan["rules"] + if isinstance(rule.get(field), str) + ))) + for provider, field in ( + ("sentinelRuntime", "sentinelQuerySha256"), + ("advancedHunting", "advancedHuntingQuerySha256"), + ) + } + report_root = report_directory(root) + source_paths = [ + report_root / f"runtime-validation.{provider}.json" + for provider in ("triage-mcp", "azure-monitor-sdk", "log-analytics-cli") + ] + source_report = next((path for path in source_paths if path.is_file()), None) + graph_report = report_root / "runtime-validation.graph.json" + source_passed = evidence.get("sourceStatus") in {"passed", "source-waived"} + graph_passed = bool(evidence.get("graphReport")) + common = { + "stage": "validation", + "targetSha256": snapshot["target"]["sha256"], + "prerequisites": [{ + "stage": "conversion", + "status": "passed", + "evidence": snapshot["stageEvidence"].get("conversion", []), + }], + } + return [ + { + **common, + "actionId": "validation:sentinel-runtime", + "purpose": "sentinelRuntime", + "authPurpose": None, + "querySetSha256": query_hashes["sentinelRuntime"], + "status": ( + "complete" if source_passed + else "blocked" if source_report + else "approval-needed" + ), + "result": evidence.get("sourceStatus") if source_passed else None, + "reason": ( + "Current source-family evidence is not passing; inspect the persisted report before any retry." + if source_report and not source_passed else + "Explicit user authorization is required before read-only source queries." + if not source_passed else None + ), + "evidence": _file_evidence(root, [str(source_report)] if source_report else []), + }, + { + **common, + "actionId": "validation:advanced-hunting", + "purpose": "advancedHunting", + "authPurpose": "advancedHunting", + "querySetSha256": query_hashes["advancedHunting"], + "status": ( + "complete" if graph_passed + else "blocked" if graph_report.is_file() + else "approval-needed" + ), + "reason": ( + "Current Advanced Hunting evidence is not passing; inspect the persisted report before any retry." + if graph_report.is_file() and not graph_passed else + "Explicit user authorization is required before Advanced Hunting queries." + if not graph_passed else None + ), + "evidence": _file_evidence(root, [str(graph_report)] if graph_report.is_file() else []), + }, + ] + + +def _derive_actions( + registry: dict[str, Any], + entry: dict[str, Any], + snapshot: dict[str, Any], +) -> list[dict[str, Any]]: + next_info = snapshot["next"] + status = snapshot["status"] + actions: list[dict[str, Any]] = [] + next_stage = next_info.get("next") + if next_stage: + stage_status = (status.get("stages") or {}).get(next_stage, {}) + needs_approval = next_stage in ENVIRONMENT_WRITE_STAGES + release_action_needed = ( + next_stage == "packaging" + and bool(next_info.get("releaseActionRequired")) + ) + if next_stage in ENVIRONMENT_WRITE_STAGES and not snapshot["target"]["sha256"]: + action_status = "blocked" + reason = "qualification target is not available in the persisted workflow state" + elif stage_status.get("status") == "running": + action_status = "running" + reason = "existing workflow state reports this stage running" + elif release_action_needed: + action_status = "approval-needed" + freshness = next_info.get("packageFreshness") or {} + reason = ( + "an explicit package-version action is required for the current input hash " + f"{freshness.get('currentPackageInputSha256') or 'unavailable'}" + ) + else: + action_status = "approval-needed" if needs_approval else "ready" + reason = ( + "read-only preflight and exact-scope user approval are still required; no live command is dispatched here" + if needs_approval else None + ) + action = { + "actionId": next_stage, + "stage": next_stage, + "status": action_status, + "purpose": AUTH_PURPOSES.get(next_stage), + "reason": reason, + "prerequisites": [{ + "workflowNextBlockedBy": next_info.get("blockedBy") or [], + "workflowStatus": status.get("workflowStatus"), + "releaseActionRequired": next_info.get("releaseActionRequired", False), + "packageFreshness": next_info.get("packageFreshness"), + "passedStages": { + name: { + "status": stage.get("status"), + "evidence": snapshot["stageEvidence"].get(name, []), + } + for name, stage in (status.get("stages") or {}).items() + if stage.get("status") == "passed" + }, + }], + "evidence": snapshot["stageEvidence"].get(next_stage, []), + "targetSha256": snapshot["target"]["sha256"], + "target": snapshot["target"]["values"], + "stateSha256": snapshot["stateSha256"], + } + if next_stage == "deployment": + action["recommendedCommands"] = ["preflight-deployment"] + action["serviceSettingsVerifiedByCoordinator"] = False + action["authorizationGrantedByCoordinator"] = False + elif release_action_needed: + action["recommendedCommands"] = ["workflow-plan-package-release"] + action["packageInputSha256"] = ( + (next_info.get("packageFreshness") or {}).get("currentPackageInputSha256") + ) + if next_stage == "validation": + actions.extend(_runtime_actions(Path(snapshot["solution"]), snapshot)) + actions.append(action) + elif status.get("workflowStatus") == "completed": + actions.append({ + "actionId": "workflow", + "stage": None, + "status": "complete", + "purpose": None, + "reason": None, + "prerequisites": [], + "evidence": [], + "targetSha256": snapshot["target"]["sha256"], + "target": snapshot["target"]["values"], + "stateSha256": snapshot["stateSha256"], + }) + else: + actions.append({ + "actionId": "workflow", + "stage": None, + "status": "blocked", + "purpose": None, + "reason": "the existing workflow reports no stage eligible to start", + "prerequisites": [{ + "workflowStatus": status.get("workflowStatus"), + "blockedBy": next_info.get("blockedBy") or [], + }], + "evidence": [], + "targetSha256": snapshot["target"]["sha256"], + "target": snapshot["target"]["values"], + "stateSha256": snapshot["stateSha256"], + }) + owner = entry.get("owner") + for action in actions: + action.setdefault("authPurpose", AUTH_PURPOSES.get(action["actionId"])) + action.setdefault("querySetSha256", None) + action["prerequisitesSha256"] = _sha256(_canonical(action["prerequisites"])) + action["evidenceSha256"] = _sha256(_canonical(action["evidence"])) + action["sharedPrerequisites"] = [] + _apply_shared_dependencies(registry, entry, action) + action["actionSha256"] = _sha256(_canonical({ + "runId": snapshot["runId"], + "actionId": action["actionId"], + "stage": action["stage"], + "targetSha256": action["targetSha256"], + "prerequisitesSha256": action["prerequisitesSha256"], + "evidenceSha256": action["evidenceSha256"], + "querySetSha256": action["querySetSha256"], + "purpose": action["purpose"], + "authPurpose": action["authPurpose"], + "sharedPrerequisites": action["sharedPrerequisites"], + "owner": entry.get("owner"), + })) + if not owner: + action["status"] = "blocked" + action["reason"] = "an accountable owner is required before coordination" + return actions + + +def _expire_claim_if_needed(registry: dict[str, Any], entry: dict[str, Any]) -> None: + claim = entry.get("claim") + if not claim or claim.get("status") != "active": + return + if _parse_timestamp(claim["expiresAt"]) > _now(): + return + claim["status"] = "expired-unknown" + claim["expiredAt"] = _timestamp() + claim["outcome"] = "unknown" + entry["updatedAt"] = _timestamp() + _append_system_event( + registry, + f"claim-expired:{claim['claimId']}", + "claim-expired-unknown", + { + "entryId": entry["entryId"], + "claimId": claim["claimId"], + "actionId": claim["actionId"], + "targetSha256": claim["targetSha256"], + }, + ) + + +def _watchdog( + registry: dict[str, Any], + entry: dict[str, Any], + actions: list[dict[str, Any]], + idle_seconds: int, +) -> list[dict[str, str]]: + now = _now() + warnings: list[dict[str, str]] = [] + if not entry.get("owner"): + warnings.append({"type": "missing-owner", "message": "work item has no accountable owner"}) + claim = entry.get("claim") or {} + for action in actions: + if ( + action["status"] == "ready" + and _parse_timestamp(entry.get("decisionAt") or entry["createdAt"]) + < now - timedelta(seconds=idle_seconds) + and claim.get("status") != "active" + ): + warnings.append({ + "type": "ready-idle", + "message": f"ready action has no active executor: {action['actionId']}", + }) + if action["status"] == "running": + claim = entry.get("claim") + if not claim or claim.get("status") != "active": + warnings.append({ + "type": "running-without-claim", + "message": f"workflow stage is running without a tracked claim: {action['actionId']}", + }) + for event in registry["events"]: + if ( + event.get("kind") == "executor-completed" + and event.get("payload", {}).get("entryId") == entry["entryId"] + and event.get("consumedAt") is None + ): + warnings.append({ + "type": "unconsumed-handoff", + "message": f"completion handoff has not been matched to workflow state: {event['eventId']}", + }) + pending = [ + item for item in registry["outbox"] + if item.get("entryId") == entry["entryId"] and item.get("status") == "pending" + ] + for item in pending: + if _parse_timestamp(item["createdAt"]) < now - timedelta(seconds=idle_seconds): + warnings.append({ + "type": ( + "undelivered-approval" + if item.get("kind") == "approval-request" + else "undelivered-handoff" + ), + "message": f"outbox item remains unacknowledged: {item['deliveryId']}", + }) + claim = entry.get("claim") + if claim and ( + claim.get("status") == "expired-unknown" + or ( + claim.get("status") == "active" + and _parse_timestamp(claim["expiresAt"]) <= now + ) + ): + warnings.append({ + "type": "expired-claim-unknown", + "message": "executor lease expired with unknown outcome; dispatch remains blocked", + }) + return warnings + + +def _publish_decision( + registry: dict[str, Any], entry: dict[str, Any], actions: list[dict[str, Any]] +) -> None: + decision = { + "actions": actions, + "owner": entry.get("owner"), + "targetSha256": entry.get("targetSha256"), + } + decision_hash = _sha256(_canonical(decision)) + if decision_hash == entry.get("decisionSha256"): + return + generation = int(entry.get("decisionGeneration", 0)) + 1 + entry["decisionGeneration"] = generation + entry["decisionSha256"] = decision_hash + entry["decisionAt"] = _timestamp() + entry["actions"] = actions + entry["updatedAt"] = _timestamp() + current_actions = {item["actionId"]: item for item in actions} + for item in registry["outbox"]: + if item.get("entryId") != entry["entryId"] or item.get("status") != "pending": + continue + previous_action = item.get("action") or {} + current_action = current_actions.get(previous_action.get("actionId")) + if ( + not current_action + or current_action.get("actionSha256") != previous_action.get("actionSha256") + or current_action.get("targetSha256") != previous_action.get("targetSha256") + or current_action.get("status") != previous_action.get("status") + ): + item["status"] = "superseded" + item["supersededAt"] = _timestamp() + item["supersededReason"] = "current workflow decision, target, or evidence changed" + _append_system_event( + registry, + f"outbox-superseded:{item['deliveryId']}", + "outbox-item-superseded", + { + "entryId": entry["entryId"], + "deliveryId": item["deliveryId"], + "reason": item["supersededReason"], + }, + ) + for action in actions: + if action["status"] in {"ready", "approval-needed", "blocked", "complete"}: + kind = "action-decision" + delivery_id = _sha256(_canonical({ + "entryId": entry["entryId"], + "decisionGeneration": generation, + "actionSha256": action["actionSha256"], + "status": action["status"], + "kind": kind, + })) + if not any(item.get("deliveryId") == delivery_id for item in registry["outbox"]): + registry["outbox"].append({ + "deliveryId": delivery_id, + "entryId": entry["entryId"], + "decisionGeneration": generation, + "kind": kind, + "status": "pending", + "owner": entry.get("owner"), + "action": action, + "createdAt": _timestamp(), + "acknowledgedAt": None, + "acknowledgedBy": None, + }) + _append_system_event( + registry, + f"decision:{entry['entryId']}:{generation}:{decision_hash}", + "decision-created", + { + "entryId": entry["entryId"], + "decisionSha256": decision_hash, + "decisionGeneration": generation, + }, + ) + + +def _load_entry( + registry: dict[str, Any], snapshot: dict[str, Any] +) -> dict[str, Any]: + key = _entry_key(Path(snapshot["solution"]), snapshot["runId"]) + entry = registry["solutions"].get(key) + if not entry: + raise ValueError("workflow is not enrolled; use coordinator-enroll with an accountable owner") + if entry.get("runId") != snapshot["runId"] or entry.get("solution") != snapshot["solution"]: + raise ValueError("coordinator entry identity does not match the selected run") + return entry + + +def _reconcile_locked( + registry: dict[str, Any], + entry: dict[str, Any], + snapshot: dict[str, Any], + *, + idle_seconds: int = DEFAULT_IDLE_SECONDS, +) -> dict[str, Any]: + _expire_claim_if_needed(registry, entry) + entry["targetSha256"] = snapshot["target"]["sha256"] + actions = _derive_actions(registry, entry, snapshot) + claim = entry.get("claim") + if claim and claim.get("status") == "active": + matching = next( + ( + action for action in actions + if action["actionId"] == claim["actionId"] + and action["actionSha256"] == claim["actionSha256"] + and action["targetSha256"] == claim["targetSha256"] + ), + None, + ) + if matching: + matching["status"] = "running" + matching["claimId"] = claim["claimId"] + matching["executor"] = claim["executor"] + elif _claim_stage_is_officially_complete(claim, snapshot): + retry_action = next( + (action for action in actions if action["actionId"] == claim["actionId"]), + None, + ) + if retry_action: + retry_action["status"] = "blocked" + retry_action["reason"] = ( + "the prior claim's terminal workflow result is awaiting an " + "authoritative completion handoff before retry" + ) + retry_action["claimId"] = claim["claimId"] + else: + claim["status"] = "stale" + claim["outcome"] = "stale-target-or-evidence" + elif claim and claim.get("status") == "expired-unknown": + for action in actions: + if action["actionId"] == claim.get("actionId"): + action["status"] = "blocked" + action["reason"] = ( + "executor lease expired with unknown outcome; obtain an authoritative " + "result before any retry" + ) + action["claimId"] = claim["claimId"] + _publish_decision(registry, entry, actions) + entry["observedWorkflowStateSha256"] = snapshot["stateSha256"] + entry["workflowStatus"] = snapshot["workflowStatus"] + entry["nextStage"] = snapshot["next"].get("next") + entry["observedAt"] = _timestamp() + return { + "entryId": entry["entryId"], + "solution": snapshot["solution"], + "runId": snapshot["runId"], + "owner": entry.get("owner"), + "workflowStatus": snapshot["workflowStatus"], + "nextStage": snapshot["next"].get("next"), + "target": snapshot["target"], + "actions": actions, + "outbox": [ + item for item in registry["outbox"] + if item.get("entryId") == entry["entryId"] and item.get("status") == "pending" + ], + "watchdog": _watchdog(registry, entry, actions, idle_seconds), + "registryRevision": registry["revision"], + "dispatchMode": "shadow", + "automaticCommandExecution": False, + } + + +def enroll( + solution: str | Path, + *, + owner: str, + required_shared_dependencies: list[str] | None = None, + registry_path: str | Path | None = None, +) -> dict[str, Any]: + if not owner.strip(): + raise ValueError("an accountable owner is required") + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + key = _entry_key(Path(snapshot["solution"]), snapshot["runId"]) + required: dict[str, list[dict[str, str]]] = {} + for declaration in required_shared_dependencies or []: + action, separator, dependency = declaration.partition("=") + dep_key, purpose_separator, purpose = dependency.partition(":") + if not separator or not action or not purpose_separator or not dep_key or not purpose: + raise ValueError( + "shared dependency must be ACTION=KEY:PURPOSE" + ) + if action not in { + "discovery", "conversion", "validation", "packaging", + "deployment", "mockIngestion", "alertParity", "report", + "validation:sentinel-runtime", "validation:advanced-hunting", + }: + raise ValueError(f"unknown shared-dependency action: {action}") + required.setdefault(action, []).append({"key": dep_key, "purpose": purpose}) + entry = registry["solutions"].get(key) + if entry: + if entry.get("owner") and entry.get("owner") != owner: + raise ValueError( + "work item already has a different accountable owner; use an explicit owner handoff" + ) + if not entry.get("owner"): + entry["owner"] = owner.strip() + _append_system_event( + registry, + f"owner-assigned:{key}:{_sha256(owner.strip().encode())}", + "accountable-owner-assigned", + { + "entryId": key, + "owner": owner.strip(), + "previousOwner": None, + "assignmentMethod": "explicit-enrollment", + }, + ) + if entry.get("requiredSharedDependencies", {}) != required: + raise ValueError("enrollment dependencies are immutable for this run") + else: + entry = { + "entryId": key, + "solution": snapshot["solution"], + "runId": snapshot["runId"], + "owner": owner.strip(), + "createdAt": _timestamp(), + "updatedAt": _timestamp(), + "targetSha256": snapshot["target"]["sha256"], + "requiredSharedDependencies": required, + "claim": None, + "events": [], + } + registry["solutions"][key] = entry + _append_system_event( + registry, + f"enrolled:{key}", + "work-item-enrolled", + { + "entryId": key, + "runId": snapshot["runId"], + "owner": owner.strip(), + "targetSha256": snapshot["target"]["sha256"], + }, + ) + result = _reconcile_locked(registry, entry, snapshot) + _write_registry(registry_file, registry) + result["registryPath"] = str(registry_file) + result["registryRevision"] = registry["revision"] + return result + + +def reconcile( + solution: str | Path, + *, + registry_path: str | Path | None = None, + idle_seconds: int = DEFAULT_IDLE_SECONDS, +) -> dict[str, Any]: + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + entry = _load_entry(registry, snapshot) + result = _reconcile_locked( + registry, entry, snapshot, idle_seconds=idle_seconds + ) + _write_registry(registry_file, registry) + result["registryPath"] = str(registry_file) + result["registryRevision"] = registry["revision"] + return result + + +def status( + solution: str | Path, + *, + registry_path: str | Path | None = None, + idle_seconds: int = DEFAULT_IDLE_SECONDS, +) -> dict[str, Any]: + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + entry = _load_entry(registry, snapshot) + result = _reconcile_locked( + registry, entry, snapshot, idle_seconds=idle_seconds + ) + result["claim"] = dict(entry["claim"]) if entry.get("claim") else None + result["outbox"] = [ + item for item in registry["outbox"] + if item.get("entryId") == entry["entryId"] + ] + _write_registry(registry_file, registry) + result["registryPath"] = str(registry_file) + result["registryRevision"] = registry["revision"] + return result + + +def claim( + solution: str | Path, + *, + action_id: str, + executor: str, + claim_key: str, + lease_seconds: float = 300, + registry_path: str | Path | None = None, +) -> dict[str, Any]: + if lease_seconds <= 0 or lease_seconds > 86400: + raise ValueError("claim lease must be greater than 0 and no more than 86400 seconds") + if not executor.strip() or not claim_key.strip(): + raise ValueError("executor and idempotency claim key are required") + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + entry = _load_entry(registry, snapshot) + _expire_claim_if_needed(registry, entry) + if (entry.get("claim") or {}).get("status") == "expired-unknown": + raise ValueError( + "previous claim has unknown outcome; reconcile it before another claim" + ) + actions = _derive_actions(registry, entry, snapshot) + action = next((item for item in actions if item["actionId"] == action_id), None) + if action is None: + raise ValueError(f"action is not currently offered: {action_id}") + if action["status"] != "ready": + raise ValueError( + f"action is not claimable in shadow mode (status={action['status']}); " + "approval-needed and blocked actions cannot be dispatched" + ) + existing = entry.get("claim") + if existing and existing.get("status") == "active": + if ( + existing.get("executor") == executor + and existing.get("claimKey") == claim_key + and existing.get("actionSha256") == action["actionSha256"] + ): + result = dict(existing) + result["idempotentReplay"] = True + return result + raise ValueError("another executor holds the active atomic claim") + claim_id = _sha256(_canonical({ + "entryId": entry["entryId"], + "actionSha256": action["actionSha256"], + "executor": executor, + "claimKey": claim_key, + })) + now = _now() + claim_record = { + "claimId": claim_id, + "claimKey": claim_key, + "actionId": action_id, + "actionSha256": action["actionSha256"], + "action": dict(action), + "stage": action["stage"], + "targetSha256": action["targetSha256"], + "executor": executor.strip(), + "status": "active", + "claimedAt": _timestamp(now), + "heartbeatAt": _timestamp(now), + "expiresAt": _timestamp(now + timedelta(seconds=lease_seconds)), + "outcome": "pending", + "dispatchMode": "shadow", + } + entry["claim"] = claim_record + _append_system_event( + registry, + f"claim:{claim_id}", + "execution-claim-created", + { + "entryId": entry["entryId"], + "claimId": claim_id, + "actionId": action_id, + "actionSha256": action["actionSha256"], + "targetSha256": action["targetSha256"], + "executor": executor.strip(), + }, + ) + _reconcile_locked(registry, entry, snapshot) + _write_registry(registry_file, registry) + return {**claim_record, "idempotentReplay": False} + + +def heartbeat( + solution: str | Path, + *, + claim_id: str, + executor: str, + lease_seconds: float = 300, + registry_path: str | Path | None = None, +) -> dict[str, Any]: + if lease_seconds <= 0 or lease_seconds > 86400: + raise ValueError("claim lease must be greater than 0 and no more than 86400 seconds") + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + entry = _load_entry(registry, snapshot) + _expire_claim_if_needed(registry, entry) + current = entry.get("claim") + if ( + not current + or current.get("claimId") != claim_id + or current.get("executor") != executor + or current.get("status") != "active" + ): + raise ValueError("claim is not active for this executor") + if current.get("targetSha256") != snapshot["target"]["sha256"]: + current["status"] = "stale" + current["outcome"] = "target-changed" + _write_registry(registry_file, registry) + raise ValueError("claim target changed; no command may be dispatched") + actions = _derive_actions(registry, entry, snapshot) + action = next((item for item in actions if item["actionId"] == current["actionId"]), None) + if not action or action["actionSha256"] != current["actionSha256"]: + current["status"] = "stale" + current["outcome"] = "evidence-or-query-changed" + _write_registry(registry_file, registry) + raise ValueError("claim evidence or query identity changed; no command may be dispatched") + now = _now() + current["heartbeatAt"] = _timestamp(now) + current["expiresAt"] = _timestamp(now + timedelta(seconds=lease_seconds)) + _append_system_event( + registry, + f"heartbeat:{claim_id}:{current['heartbeatAt']}", + "execution-claim-heartbeat", + {"entryId": entry["entryId"], "claimId": claim_id, "executor": executor}, + ) + _write_registry(registry_file, registry) + return dict(current) + + +def record_event( + solution: str | Path, + *, + event_id: str, + kind: str, + action_id: str | None = None, + purpose: str | None = None, + target_sha256: str | None = None, + evidence: str | Path | None = None, + actor: str | None = None, + new_owner: str | None = None, + claim_id: str | None = None, + outcome: str | None = None, + reference: str | None = None, + registry_path: str | Path | None = None, +) -> dict[str, Any]: + allowed = { + "executor-completed", + "approval-requested", + "shared-dependency-completed", + "claim-resolved", + "owner-handoff", + } + if kind not in allowed: + raise ValueError(f"unsupported coordinator event kind: {kind}") + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + entry = _load_entry(registry, snapshot) + evidence_path = None + evidence_sha256 = None + if evidence: + evidence_path = Path(evidence).expanduser() + if not evidence_path.is_absolute(): + evidence_path = Path(snapshot["solution"]) / evidence_path + evidence_path = evidence_path.resolve(strict=True) + try: + evidence_relative = evidence_path.relative_to(Path(snapshot["solution"])) + except ValueError as exc: + raise ValueError("coordinator event evidence must be inside the selected solution") from exc + if not evidence_path.is_file(): + raise ValueError("coordinator event evidence must be a regular file") + evidence_path = evidence_relative + evidence_sha256 = _hash_local_file(Path(snapshot["solution"]) / evidence_path) + current_target = snapshot["target"]["sha256"] + request = { + "actionId": action_id, + "purpose": purpose, + "targetSha256": target_sha256, + "evidencePath": evidence_path.as_posix() if evidence_path else None, + "evidenceSha256": evidence_sha256, + "actor": actor, + "newOwner": new_owner, + "claimId": claim_id, + "outcome": outcome, + "reference": reference, + } + previous = next( + ( + item for item in registry["events"] + if item.get("eventId") == event_id + and item.get("payload", {}).get("entryId") == entry["entryId"] + ), + None, + ) + if previous: + if previous.get("kind") != kind or previous.get("payload", {}).get("_request") != request: + raise ValueError(f"event ID was already used with different content: {event_id}") + return { + "eventId": event_id, + "kind": kind, + "created": False, + "consumed": bool(previous.get("consumedAt")), + "targetSha256": previous.get("payload", {}).get("targetSha256"), + "evidenceSha256": previous.get("payload", {}).get("evidenceSha256"), + "reconciliation": None, + "dispatchMode": "shadow", + "approvalGranted": False, + } + payload: dict[str, Any] = {"entryId": entry["entryId"]} + action: dict[str, Any] | None = None + if kind in {"approval-requested", "executor-completed"}: + if not action_id: + raise ValueError(f"{kind} requires --action-id") + actions = _derive_actions(registry, entry, snapshot) + current_claim = entry.get("claim") + if ( + kind == "executor-completed" + and current_claim + and current_claim.get("actionId") == action_id + and current_claim.get("status") in { + "active", "expired-unknown", "stale" + } + ): + action = dict(current_claim.get("action") or {}) + if not action: + action = next((item for item in actions if item["actionId"] == action_id), None) + if not action: + if kind == "executor-completed": + action = next( + ( + item for item in entry.get("actions", []) + if item.get("actionId") == action_id + and item.get("targetSha256") == target_sha256 + ), + None, + ) + if not action: + if ( + current_claim + and current_claim.get("status") in { + "active", "expired-unknown", "stale" + } + and current_claim.get("actionId") == action_id + and current_claim.get("targetSha256") == target_sha256 + ): + action = dict(current_claim.get("action") or { + "actionId": current_claim["actionId"], + "actionSha256": current_claim["actionSha256"], + "stage": current_claim["stage"], + "targetSha256": current_claim["targetSha256"], + }) + if not action: + raise ValueError(f"action is no longer current: {action_id}") + if target_sha256 != current_target or target_sha256 != action["targetSha256"]: + raise ValueError("event target does not match the current locked target") + if kind == "approval-requested": + if action["status"] != "approval-needed": + raise ValueError("approval request is not needed for the current action") + expected_purpose = action.get("authPurpose") or action.get("purpose") + if purpose != expected_purpose: + raise ValueError("approval purpose does not match the current action purpose") + payload.update({ + "actionId": action_id, + "actionSha256": action["actionSha256"], + "purpose": purpose, + "targetSha256": current_target, + "actor": actor, + }) + else: + if not evidence_sha256: + raise ValueError("executor completion requires hash-bound --evidence") + current_claim = entry.get("claim") + if current_claim and current_claim.get("actionId") == action_id: + if current_claim.get("status") == "stale": + raise ValueError("completion cannot resolve a stale claim binding") + if current_claim.get("status") in {"active", "expired-unknown"}: + if not _authoritative_claim_completion( + current_claim, + action, + snapshot, + action_id=action_id, + target_sha256=target_sha256, + evidence_sha256=evidence_sha256, + ): + raise ValueError( + "completion does not match the claim's exact target, " + "action, and official workflow evidence" + ) + payload.update({ + "actionId": action_id, + "actionSha256": action["actionSha256"], + "targetSha256": current_target, + "evidencePath": evidence_path.as_posix(), + "evidenceSha256": evidence_sha256, + }) + elif kind == "shared-dependency-completed": + if ( + not action_id or not purpose or not target_sha256 + or not evidence_sha256 or not actor + ): + raise ValueError( + "shared dependency event requires --action-id (dependency key), --purpose, " + "--target-sha256, --actor, and hash-bound --evidence" + ) + if target_sha256 != current_target: + raise ValueError("shared dependency target does not match the current target") + requirement = { + "key": action_id, + "purpose": purpose, + "targetSha256": current_target, + } + dep_id = _dependency_key(action_id, purpose, current_target) + payload.update({ + **requirement, + "evidencePath": evidence_path.as_posix(), + "evidenceSha256": evidence_sha256, + "actor": actor, + }) + registry["sharedDependencies"][dep_id] = { + **requirement, + "status": "reported", + "solution": snapshot["solution"], + "evidencePath": evidence_path.as_posix(), + "evidenceSha256": evidence_sha256, + "reportedBy": actor, + "recordedAt": _timestamp(), + "eventId": event_id, + } + elif kind == "owner-handoff": + if not new_owner or not new_owner.strip() or not actor or not reference: + raise ValueError("owner handoff requires --new-owner, current-owner --actor, and --reference") + if entry.get("owner") != actor: + raise ValueError("only the current accountable owner can hand off ownership") + payload.update({ + "previousOwner": entry["owner"], + "owner": new_owner.strip(), + "actor": actor, + "reference": reference, + }) + entry["owner"] = new_owner.strip() + else: + if not claim_id or outcome not in {"not-started", "completed", "unknown"}: + raise ValueError("claim resolution requires --claim-id and a valid --outcome") + if not actor or not reference: + raise ValueError("claim resolution requires accountable --actor and --reference") + if outcome == "not-started" and not evidence_sha256: + raise ValueError( + "confirming a claim was not started requires hash-bound --evidence" + ) + current = entry.get("claim") + if not current or current.get("claimId") != claim_id or current.get("status") != "expired-unknown": + raise ValueError("only an expired unknown claim can be resolved") + payload.update({ + "claimId": claim_id, + "outcome": outcome, + "actor": actor, + "reference": reference, + "targetSha256": current["targetSha256"], + }) + if outcome == "completed": + action = next( + ( + item for item in entry.get("actions", []) + if item.get("actionId") == current.get("actionId") + and item.get("actionSha256") == current.get("actionSha256") + ), + None, + ) + if action is None and current.get("stage"): + action = { + "actionId": current["actionId"], + "actionSha256": current["actionSha256"], + "targetSha256": current["targetSha256"], + "stage": current["stage"], + } + if not _authoritative_claim_completion( + current, + action, + snapshot, + action_id=current.get("actionId"), + target_sha256=target_sha256, + evidence_sha256=evidence_sha256, + ): + raise ValueError( + "claim completion requires exact current target and evidence " + "from an officially completed workflow stage" + ) + payload.update({ + "actionId": current["actionId"], + "actionSha256": current["actionSha256"], + "targetSha256": current_target, + "evidencePath": evidence_path.as_posix(), + "evidenceSha256": evidence_sha256, + }) + current["status"] = "completed" + current["outcome"] = "authoritative-completion-observed" + current["completedAt"] = _timestamp() + current["resolvedBy"] = actor + current["resolutionReference"] = reference + elif outcome == "not-started": + stage = current.get("actionId") + official_stage = (snapshot["status"].get("stages") or {}).get(stage, {}) + if official_stage.get("status") == "running": + raise ValueError( + "workflow state still reports the claimed stage running; unknown outcome remains blocked" + ) + if current.get("targetSha256") != current_target: + raise ValueError("claim target changed; unknown outcome remains blocked") + current["status"] = "resolved-not-started" + current["outcome"] = "not-started" + current["resolvedAt"] = _timestamp() + current["resolvedBy"] = actor + current["resolutionReference"] = reference + else: + current["status"] = "expired-unknown" + current["outcome"] = "unknown" + current["resolutionReference"] = reference + current["resolvedBy"] = actor + payload["_request"] = request + event, created = _record_event( + registry, + event_id=event_id, + kind=kind, + payload=payload, + scope=entry["entryId"], + ) + if kind == "approval-requested" and created and action: + delivery_id = _sha256(_canonical({ + "entryId": entry["entryId"], + "eventId": event_id, + "kind": "approval-request", + })) + registry["outbox"].append({ + "deliveryId": delivery_id, + "entryId": entry["entryId"], + "eventId": event_id, + "kind": "approval-request", + "status": "pending", + "owner": entry.get("owner"), + "action": action, + "purpose": purpose, + "targetSha256": current_target, + "createdAt": _timestamp(), + "acknowledgedAt": None, + "acknowledgedBy": None, + }) + if kind == "executor-completed" and created: + stage_name = action.get("stage") if action else None + stage_state = (snapshot["status"].get("stages") or {}).get(stage_name, {}) + current_actions = _derive_actions(registry, entry, snapshot) + current_action = next( + (item for item in current_actions if item["actionId"] == action_id), + None, + ) + evidence_is_current = bool( + current_action + and evidence_sha256 + and any(item["sha256"] == evidence_sha256 for item in current_action["evidence"]) + ) + stage_complete = stage_state.get("status") in {"passed", "failed", "blocked"} + if stage_complete or evidence_is_current: + event["consumedAt"] = _timestamp() + current_claim = entry.get("claim") + if current_claim and ( + current_claim.get("status") in {"active", "expired-unknown"} + and current_claim.get("actionId") == action_id + and current_claim.get("targetSha256") == target_sha256 + ): + claimed_action = current_claim.get("action") or action + if _authoritative_claim_completion( + current_claim, + claimed_action, + snapshot, + action_id=action_id, + target_sha256=target_sha256, + evidence_sha256=evidence_sha256, + ): + current_claim["status"] = "completed" + current_claim["outcome"] = "authoritative-completion-observed" + current_claim["completedAt"] = _timestamp() + entry.setdefault("events", []).append(event_id) + if kind == "shared-dependency-completed": + # Re-evaluate every registered consumer in this exact purpose and target scope. + for consumer in registry["solutions"].values(): + if not any( + requirement["key"] == action_id and requirement["purpose"] == purpose + for requirements in consumer.get("requiredSharedDependencies", {}).values() + for requirement in requirements + ): + continue + consumer_path = Path(consumer["solution"]) + with using_run(consumer_path, consumer["runId"]): + consumer_snapshot = _workflow_snapshot(consumer_path) + if consumer_snapshot["target"]["sha256"] != current_target: + continue + _reconcile_locked(registry, consumer, consumer_snapshot) + else: + result = _reconcile_locked(registry, entry, snapshot) + _write_registry(registry_file, registry) + return { + "eventId": event_id, + "kind": kind, + "created": created, + "consumed": bool(event.get("consumedAt")), + "targetSha256": current_target, + "evidenceSha256": evidence_sha256, + "reconciliation": result if kind != "shared-dependency-completed" else None, + "dispatchMode": "shadow", + "approvalGranted": False, + } + + +def acknowledge_outbox( + solution: str | Path, + *, + delivery_id: str, + actor: str, + registry_path: str | Path | None = None, +) -> dict[str, Any]: + if not actor.strip(): + raise ValueError("outbox acknowledgement requires an actor") + registry_file = _safe_registry(registry_path) + with _registry_lock(registry_file): + registry = _read_registry(registry_file) + snapshot = _workflow_snapshot(solution) + entry = _load_entry(registry, snapshot) + item = next( + ( + current for current in registry["outbox"] + if current.get("deliveryId") == delivery_id + and current.get("entryId") == entry["entryId"] + ), + None, + ) + if not item: + raise ValueError("outbox delivery ID does not belong to this selected workflow") + if item["status"] == "pending": + item["status"] = "acknowledged" + item["acknowledgedAt"] = _timestamp() + item["acknowledgedBy"] = actor.strip() + _append_system_event( + registry, + f"outbox-ack:{delivery_id}", + "outbox-delivery-acknowledged", + { + "entryId": entry["entryId"], + "deliveryId": delivery_id, + "actor": actor.strip(), + "meaning": "delivery acknowledgement only; not user approval", + }, + ) + _write_registry(registry_file, registry) + return { + "deliveryId": delivery_id, + "status": item["status"], + "acknowledgedBy": item.get("acknowledgedBy"), + "approvalGranted": False, + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dcr_readiness.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dcr_readiness.py new file mode 100644 index 00000000000..6e1185b9eea --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dcr_readiness.py @@ -0,0 +1,908 @@ +from __future__ import annotations + +import fnmatch +import hashlib +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from .artifacts import ( + artifact_path, + resolve_artifact_reference, + write_json_artifact, +) +from .logs_query import ( + azure_cli_credential, + execute_workspace_query, + logs_query_client, +) +from .target_context import resolve_workspace_customer_id, workspace_identity + +ARM_SCOPE = "https://management.azure.com/.default" +ARM_BASE = "https://management.azure.com" +PERMISSIONS_API_VERSION = "2022-04-01" +RESOURCE_API_VERSION = "2023-03-11" +TABLE_API_VERSION = "2022-10-01" +INGESTION_ACTION = "Microsoft.Insights/Telemetry/Write" +DCR_WRITE_ACTION = "Microsoft.Insights/dataCollectionRules/write" +DCE_WRITE_ACTION = "Microsoft.Insights/dataCollectionEndpoints/write" +TABLE_WRITE_ACTION = "Microsoft.OperationalInsights/workspaces/tables/write" +REPORT_NAME = "qualification-dcr-preflight.json" +_UUID_TEXT = re.compile( + r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", + re.IGNORECASE, +) +_EMAIL_TEXT = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE) +_BEARER_TEXT = re.compile(r"(?i)\bBearer\s+\S+") +_JWT_TEXT = re.compile( + r"\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b" +) + + +def _safe_message(value: Any) -> str: + message = str(value) + message = _BEARER_TEXT.sub("Bearer [REDACTED]", message) + message = _JWT_TEXT.sub("[REDACTED_TOKEN]", message) + message = _EMAIL_TEXT.sub("[REDACTED_IDENTITY]", message) + return _UUID_TEXT.sub("[REDACTED_ID]", message)[:1000] + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _ingestion_root() -> Path: + return _repo_root() / "Tools" / "AzureMonitorLogsIngestion" + + +def _ingestion_modules(): + tool_root = str(_ingestion_root()) + if tool_root not in sys.path: + sys.path.insert(0, tool_root) + from azure_monitor_logs_ingestion.contract import load_contract + from azure_monitor_logs_ingestion.registry import ( + destination_schema_blockers, + inspect_managed_resources, + resolve_contract, + ) + + return load_contract, destination_schema_blockers, inspect_managed_resources, resolve_contract + + +def _build_local_plan(solution: Path, target: Mapping[str, str]) -> dict[str, Any]: + root = str(_repo_root()) + if root not in sys.path: + sys.path.insert(0, root) + from Tools.SolutionMigration.resolve_ingestion_contracts import build_plan + from .packaging_selection import current_applied_package_selection + + locked = { + field: str(target[field]) + for field in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + } + selection = current_applied_package_selection(solution) + if selection is None: + return build_plan(solution, locked) + plan = selection["plan"] + selection_path = resolve_artifact_reference(solution, selection["planPath"]) + return build_plan( + solution, + locked, + selection_plan_path=selection_path, + expected_selection_plan_sha256=plan["planSha256"], + ) + + +def _fingerprint(value: Any) -> str: + encoded = json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _safe_error(response: Mapping[str, Any] | None) -> dict[str, str | None]: + error = response.get("error") if isinstance(response, Mapping) else None + if not isinstance(error, Mapping): + return {"code": None, "message": None} + code = error.get("code") + message = error.get("message") + return { + "code": str(code)[:200] if code is not None else None, + "message": _safe_message(message) if message is not None else None, + } + + +def _arm_get(token: str, resource_id: str, api_version: str) -> tuple[int, Any]: + url = ( + f"{ARM_BASE}{resource_id}?api-version=" + f"{urllib.parse.quote(api_version, safe='')}" + ) + request = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + body = response.read() + return int(response.status), json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + try: + body = exc.read() + payload = json.loads(body) if body else {} + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + payload = {} + return int(exc.code), payload + + +def _action_permission( + permission_sets: Any, requested_action: str +) -> dict[str, Any]: + if not isinstance(permission_sets, list): + return {"status": "unknown", "reason": "permissions.value is not an array"} + if not permission_sets: + return {"status": "denied", "matchingAllows": [], "matchingExclusions": []} + + requested = requested_action.casefold() + matching_allows: set[str] = set() + matching_exclusions: set[str] = set() + for index, permission_set in enumerate(permission_sets): + if not isinstance(permission_set, Mapping): + return { + "status": "unknown", + "reason": f"permissions.value[{index}] is not an object", + } + if "condition" in permission_set or "conditionVersion" in permission_set: + return { + "status": "unknown", + "reason": "permission response contains a condition that cannot be evaluated", + } + + allowed: list[str] = [] + excluded: list[str] = [] + for key in ("actions", "dataActions", "notActions", "notDataActions"): + values = permission_set.get(key, []) + if not isinstance(values, list) or any( + not isinstance(value, str) for value in values + ): + return { + "status": "unknown", + "reason": f"permissions.value[{index}].{key} is malformed", + } + normalized = [value.casefold() for value in values] + if key in {"actions", "dataActions"}: + allowed.extend(normalized) + else: + excluded.extend(normalized) + + allows = { + pattern + for pattern in allowed + if fnmatch.fnmatchcase(requested, pattern) + } + denies = { + pattern + for pattern in excluded + if fnmatch.fnmatchcase(requested, pattern) + } + matching_allows.update(allows) + matching_exclusions.update(denies) + if allows and not denies: + return { + "status": "ready", + "matchingAllows": sorted(allows), + "matchingExclusions": [], + } + + return { + "status": "denied", + "matchingAllows": sorted(matching_allows), + "matchingExclusions": sorted(matching_exclusions), + } + + +def _permissions_for_scope( + token: str, scope_id: str +) -> dict[str, Any]: + status_code, response = _arm_get( + token, + f"{scope_id}/providers/Microsoft.Authorization/permissions", + PERMISSIONS_API_VERSION, + ) + if status_code != 200: + return { + "inspectionStatus": ( + "unavailable" if status_code == 401 else "denied" if status_code == 403 else "unknown" + ), + "httpStatus": status_code, + "error": _safe_error(response), + } + if not isinstance(response, Mapping) or not isinstance(response.get("value"), list): + return { + "inspectionStatus": "unknown", + "httpStatus": status_code, + "error": {"code": "MalformedPermissionResponse", "message": "expected value array"}, + } + if response.get("nextLink"): + return { + "inspectionStatus": "unknown", + "httpStatus": status_code, + "error": { + "code": "PartialPermissionResponse", + "message": "permission results have a nextLink and were not treated as complete", + }, + } + return { + "inspectionStatus": "complete", + "httpStatus": status_code, + "permissionSets": response["value"], + } + + +def _check_action( + token: str, scope_id: str, action: str +) -> dict[str, Any]: + inspected = _permissions_for_scope(token, scope_id) + if inspected["inspectionStatus"] != "complete": + return { + "status": "unknown", + "requiredAction": action, + "inspectionStatus": inspected["inspectionStatus"], + "httpStatus": inspected.get("httpStatus"), + "error": inspected.get("error"), + "scope": scope_id, + } + evaluation = _action_permission(inspected["permissionSets"], action) + return { + **evaluation, + "requiredAction": action, + "inspectionStatus": "complete", + "httpStatus": inspected["httpStatus"], + "scope": scope_id, + } + + +def _arm_resource_ids(target: Mapping[str, str], contract: Any) -> tuple[str, str, str]: + identity = workspace_identity(target["workspaceResourceId"]) + prefix = ( + f"/subscriptions/{identity['subscriptionId']}" + f"/resourceGroups/{identity['resourceGroup']}" + "/providers/Microsoft.Insights" + ) + dce_id = f"{prefix}/dataCollectionEndpoints/{contract.resources.dce}" + dcr_id = f"{prefix}/dataCollectionRules/{contract.resources.dcr}" + table_id = ( + f"{target['workspaceResourceId']}/tables/{contract.destination.table}" + ) + return dce_id, dcr_id, table_id + + +def _contract_records(plan: Mapping[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + groups = plan.get("groups") + if not isinstance(groups, list): + return [], [{"status": "unknown", "reason": "local plan groups are malformed"}] + records: list[dict[str, Any]] = [] + blockers: list[dict[str, Any]] = [] + load_contract, _, _, resolve_contract = _ingestion_modules() + ingestion_root = _ingestion_root().resolve() + for group in groups: + if not isinstance(group, Mapping) or not isinstance(group.get("table"), str): + blockers.append({"status": "unknown", "reason": "local plan group is malformed"}) + continue + table = group["table"] + resolution = resolve_contract(table) + if resolution.get("status") == "blocked": + blockers.append( + { + "status": "blocked", + "table": table, + "reason": "; ".join(resolution.get("blockers") or []), + } + ) + continue + relative = Path(str(resolution.get("contractPath") or "")) + contract_path = (ingestion_root / relative).resolve() + if ingestion_root not in contract_path.parents or not contract_path.is_file(): + blockers.append( + { + "status": "unknown", + "table": table, + "reason": "registered contract path is invalid", + } + ) + continue + contract = load_contract(contract_path) + if ( + group.get("schemaFingerprint") != resolution.get("schemaFingerprint") + or group.get("transformFingerprint") != resolution.get("transformFingerprint") + ): + blockers.append( + { + "status": "unknown", + "table": table, + "reason": "local plan fingerprints differ from current registered contract", + } + ) + continue + records.append( + { + "table": table, + "contractPath": relative.as_posix(), + "schemaFingerprint": resolution["schemaFingerprint"], + "transformFingerprint": resolution["transformFingerprint"], + "contractFingerprint": _fingerprint(contract.as_dict()), + "contract": contract, + "ruleIds": group.get("ruleIds") or [], + } + ) + return records, blockers + + +def _check_query_permission(target: Mapping[str, str], credential: Any) -> dict[str, Any]: + client = logs_query_client(credential, retry_total=0) + try: + result = execute_workspace_query( + client, + target["workspaceCustomerId"], + "print SentinelXdrPreflight=1", + request_timeout_seconds=15, + retry_count=0, + ) + if result.get("status") != "passed": + return { + "status": "unknown", + "httpStatus": result.get("statusCode"), + "failureKind": result.get("failureKind") or "partial-response", + "error": ( + _safe_message(result.get("error")) + if result.get("error") is not None + else None + ), + "meansDataVisible": False, + } + return { + "status": "ready", + "detail": "Read-only query succeeded against the exact locked workspace.", + "rowsReturned": result.get("rowCount", 0), + "meansDataVisible": False, + } + except Exception as exc: + status_code = getattr(exc, "status_code", None) + failure_kind = getattr(exc, "failure_kind", None) + return { + "status": "denied" if failure_kind == "authorization" else "unknown", + "httpStatus": status_code, + "failureKind": failure_kind or type(exc).__name__, + "error": _safe_message(exc), + "meansDataVisible": False, + } + finally: + close = getattr(client, "close", None) + if callable(close): + close() + + +def _check_resource( + token: str, resource_id: str, api_version: str +) -> tuple[str, dict[str, Any] | None, dict[str, Any]]: + status_code, response = _arm_get(token, resource_id, api_version) + if status_code == 200 and isinstance(response, Mapping): + actual_id = str(response.get("id") or "") + if actual_id.casefold() != resource_id.casefold(): + return "unknown", None, { + "httpStatus": status_code, + "error": { + "code": "UnexpectedResourceId", + "message": "resource GET did not match the exact selected resource ID", + }, + } + return "present", dict(response), {"httpStatus": status_code} + if status_code == 404: + return "missing", None, {"httpStatus": status_code, "error": _safe_error(response)} + return ( + "denied" if status_code == 403 else "unavailable" if status_code == 401 else "unknown", + None, + {"httpStatus": status_code, "error": _safe_error(response)}, + ) + + +def _destination_schema(table: Any) -> Mapping[str, Any] | None: + if not isinstance(table, Mapping): + return None + properties = table.get("properties") + if not isinstance(properties, Mapping): + return None + schema = properties.get("schema") + return schema if isinstance(schema, Mapping) else None + + +def _check_candidate( + target: Mapping[str, str], + token: str, + record: Mapping[str, Any], +) -> dict[str, Any]: + contract = record["contract"] + dce_id, dcr_id, table_id = _arm_resource_ids(target, contract) + dce_state, dce, dce_evidence = _check_resource(token, dce_id, RESOURCE_API_VERSION) + dcr_state, dcr, dcr_evidence = _check_resource(token, dcr_id, RESOURCE_API_VERSION) + table_state, table, table_evidence = _check_resource(token, table_id, TABLE_API_VERSION) + def _read_status(state: str) -> str: + if state == "present": + return "ready" + if state == "denied": + return "denied" + if state == "unavailable": + return "unavailable" + return "unknown" + + result: dict[str, Any] = { + "table": record["table"], + "contractPath": record["contractPath"], + "schemaFingerprint": record["schemaFingerprint"], + "transformFingerprint": record["transformFingerprint"], + "contractFingerprint": record["contractFingerprint"], + "resourceFingerprint": _fingerprint( + { + "targetWorkspace": target["workspaceResourceId"], + "dceId": dce_id, + "dcrId": dcr_id, + "tableId": table_id, + "dce": dce, + "dcr": dcr, + "destinationSchema": ( + _destination_schema(table) + if table_state == "present" + else None + ), + "states": { + "dce": dce_state, + "dcr": dcr_state, + "table": table_state, + }, + } + ), + "ruleIds": record["ruleIds"], + "candidateProvenance": "registered-contract-resolver", + "candidateSelection": "exact-contract-resource-names-only", + "resources": { + "dce": { + "id": dce_id, + "state": dce_state, + "readAccess": _read_status(dce_state), + **dce_evidence, + }, + "dcr": { + "id": dcr_id, + "state": dcr_state, + "readAccess": _read_status(dcr_state), + **dcr_evidence, + }, + "destinationTable": { + "id": table_id, + "state": table_state, + "readAccess": _read_status(table_state), + **table_evidence, + }, + }, + "resourceReadAccess": { + "status": "ready" + if dce_state == dcr_state == table_state == "present" + else "denied" + if "denied" in {dce_state, dcr_state, table_state} + else "unknown" + if "unknown" in {dce_state, dcr_state, table_state} + or "unavailable" in {dce_state, dcr_state, table_state} + else "unknown", + }, + "tableSchemaCompatibility": {"status": "unknown"}, + "ingestionAuthorization": { + "status": "not-yet-verifiable", + "requiredAction": INGESTION_ACTION, + }, + "resourceProvisioningAuthorization": {"status": "not-needed"}, + "tableCreationAuthorization": { + "status": "not-needed", + "requiredAction": TABLE_WRITE_ACTION, + }, + } + + if table_state == "present": + _, table_blockers, _, _ = _ingestion_modules() + blockers = table_blockers(contract, table or {}) + result["tableSchemaCompatibility"] = { + "status": "compatible" if not blockers else "blocked", + "blockers": blockers, + } + elif table_state == "missing" and contract.destination.kind == "standard": + result["tableSchemaCompatibility"] = { + "status": "blocked", + "blockers": [ + "Registered standard destination table is absent; the selected contract does not plan to create it." + ], + } + + if dce_state == dcr_state == "present": + if table_state == "present" and result["tableSchemaCompatibility"]["status"] == "compatible": + _, _, inspect_resources, _ = _ingestion_modules() + compatibility = inspect_resources( + contract, + target["workspaceResourceId"], + dce, + dcr, + dce_resource_id=dce_id, + dcr_resource_id=dcr_id, + ) + result["existingCandidateCompatibility"] = compatibility + if compatibility["status"] == "compatible-existing": + permissions = _check_action(token, dcr_id, INGESTION_ACTION) + result["ingestionAuthorization"] = { + **permissions, + "status": permissions["status"], + "resource": "existing-compatible-dcr", + "note": ( + "An effective-permissions snapshot is not proof that an upload " + "will be accepted." + ), + } + else: + result["existingCandidateCompatibility"] = compatibility + else: + result["existingCandidateCompatibility"] = { + "status": "blocked", + "blockers": ["destination table is missing or schema-incompatible"], + } + elif dce_state == dcr_state == "missing": + if table_state == "present" and result["tableSchemaCompatibility"]["status"] == "compatible": + result["candidateState"] = "planned-missing-resources" + result["resources"]["plannedScope"] = ( + f"/subscriptions/{target['subscriptionId']}/resourceGroups/" + f"{workspace_identity(target['workspaceResourceId'])['resourceGroup']}" + ) + result["ingestionAuthorization"] = { + "status": "not-yet-verifiable", + "requiredAction": INGESTION_ACTION, + "note": "Effective ingestion authorization can be inspected only after DCR creation.", + } + scope = result["resources"]["plannedScope"] + dcr_permission = _check_action(token, scope, DCR_WRITE_ACTION) + dce_permission = _check_action(token, scope, DCE_WRITE_ACTION) + result["resourceProvisioningAuthorization"] = { + "status": "ready" + if dcr_permission["status"] == dce_permission["status"] == "ready" + else "denied" + if "denied" in {dcr_permission["status"], dce_permission["status"]} + else "unknown", + "scope": scope, + "dcrWrite": dcr_permission, + "dceWrite": dce_permission, + } + else: + result["candidateState"] = "blocked-destination" + result["existingCandidateCompatibility"] = { + "status": "blocked", + "reason": "the destination table is missing or schema-incompatible", + } + elif "denied" in {dce_state, dcr_state}: + result["candidateState"] = "inspection-denied" + result["existingCandidateCompatibility"] = { + "status": "unknown", + "reason": "candidate resource read was denied; absence was not assumed", + } + elif "unavailable" in {dce_state, dcr_state, table_state} or "unknown" in { + dce_state, + dcr_state, + table_state, + }: + result["candidateState"] = "inspection-unknown" + result["existingCandidateCompatibility"] = { + "status": "unknown", + "reason": "candidate resource state could not be established", + } + elif dce_state != dcr_state: + result["candidateState"] = "partial-resource-pair" + result["existingCandidateCompatibility"] = { + "status": "blocked", + "reason": "only one contract-specific DCE/DCR resource exists", + } + + # Native Standard tables, including CommonSecurityLog, are existing + # destinations; this resolver does not plan their creation. + if contract.destination.kind == "custom" and table_state == "missing": + result["tableCreationAuthorization"] = _check_action( + token, + target["workspaceResourceId"], + TABLE_WRITE_ACTION, + ) + return result + + +def _aggregate_status( + records: list[Mapping[str, Any]], + blockers: list[Mapping[str, Any]], + additional_statuses: tuple[str, ...] = (), +) -> str: + statuses: list[str] = list(additional_statuses) + for record in records: + if record.get("status") in {"denied", "blocked", "unknown"}: + statuses.append(str(record["status"])) + for key in ( + "resourceReadAccess", + "tableSchemaCompatibility", + "ingestionAuthorization", + "resourceProvisioningAuthorization", + "tableCreationAuthorization", + ): + value = record.get(key) + if isinstance(value, Mapping): + statuses.append(str(value.get("status"))) + statuses.extend(str(item.get("status")) for item in blockers) + if not statuses: + return "not-applicable" + if "denied" in statuses or "blocked" in statuses: + return "action-required" + if "unknown" in statuses or "not-yet-verifiable" in statuses: + return "unknown" + if all(status in {"ready", "compatible", "not-needed"} for status in statuses): + return "ready" + return "unknown" + + +def preflight_dcr_permissions( + solution: str | Path, + target: Mapping[str, str], + *, + auth_method: str | None, +) -> dict[str, Any]: + """Inspect exact registered ingestion candidates without creating resources or sending data.""" + root = Path(solution).expanduser().resolve() + locked_target = { + key: str(target[key]) + for key in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + } + plan_error = None + try: + plan = _build_local_plan(root, locked_target) + candidates, blockers = _contract_records(plan) + except (OSError, ValueError, RuntimeError, TypeError, KeyError) as exc: + plan = { + "status": "unavailable", + "inventory": {}, + "groups": [], + "rules": [], + "targetSha256": None, + } + candidates = [] + blockers = [ + { + "status": "unknown", + "reason": f"local ingestion contract planning failed: {exc}", + } + ] + plan_error = type(exc).__name__ + target_fingerprint = _fingerprint(locked_target) + report: dict[str, Any] = { + "schemaVersion": "1.0.0", + "status": "unknown", + "checkedAt": datetime.now(timezone.utc).isoformat(), + "target": {**locked_target, "locked": True}, + "targetFingerprint": target_fingerprint, + "plan": { + "status": plan.get("status"), + "inventorySha256": (plan.get("inventory") or {}).get("sha256"), + "targetSha256": plan.get("targetSha256"), + "writesPerformed": False, + "cloudCallsPerformed": False, + }, + "localReadiness": { + "status": "ready" if candidates else "blocked" if blockers else "not-applicable", + "candidateCount": len(candidates), + "blockers": blockers, + "errorType": plan_error, + "reviewRequiredRules": [ + { + "ruleId": item.get("ruleId"), + "status": item.get("status"), + "blockers": item.get("blockers") or [], + } + for item in plan.get("rules") or [] + if isinstance(item, Mapping) and item.get("status") != "ready-for-fixture-review" + ], + }, + "authentication": { + "selectedMethod": auth_method, + "status": "not-attempted", + "interactive": False, + }, + "workspaceQueryPermission": {"status": "not-checked"}, + "contracts": [], + "limitations": [ + "Only registered contract-specific resource candidates are inspected; arbitrary solution-listed DCR discovery is not implemented.", + "A permission snapshot is diagnostic only and is not proof of an accepted upload, data visibility, or alert parity.", + "Azure RBAC conditions or deny assignments not exposed by the permissions response may still affect an operation.", + ], + "writesPerformed": False, + "payloadsSent": 0, + } + target_path = Path(str(target.get("targetPath") or "")) + if not target_path.is_file(): + report["status"] = "blocked" + report["targetVerification"] = { + "status": "blocked", + "reason": "the locked qualification-target.json artifact is missing", + "cloudCallsPerformed": False, + } + report["authentication"]["status"] = "not-attempted" + return report + report["targetVerification"] = { + "status": "pending", + "source": "locked-qualification-target-artifact", + } + report_path = artifact_path(root, REPORT_NAME, create_parent=True) + report["reportPath"] = str(report_path) + + if auth_method is None: + report["targetVerification"]["status"] = "not-checked" + report["authentication"].update( + { + "status": "action-required", + "action": "Select the existing Azure CLI credential with --auth-method azure-cli; preflight never logs in.", + } + ) + report["contracts"] = [ + { + "table": item["table"], + "contractPath": item["contractPath"], + "schemaFingerprint": item["schemaFingerprint"], + "transformFingerprint": item["transformFingerprint"], + "contractFingerprint": item["contractFingerprint"], + "candidateProvenance": "registered-contract-resolver", + "status": "not-checked", + } + for item in candidates + ] + report["status"] = ( + "action-required" + if candidates + else _aggregate_status([], blockers) + ) + write_json_artifact(root, report_path, report) + return report + + if auth_method != "azure-cli": + report["targetVerification"]["status"] = "not-checked" + report["authentication"].update( + { + "status": "unavailable", + "reason": "The selected cached credential provider is not an Azure CLI ARM credential.", + } + ) + report["contracts"] = [ + { + "table": item["table"], + "contractPath": item["contractPath"], + "schemaFingerprint": item["schemaFingerprint"], + "transformFingerprint": item["transformFingerprint"], + "contractFingerprint": item["contractFingerprint"], + "candidateProvenance": "registered-contract-resolver", + "status": "not-checked", + } + for item in candidates + ] + report["status"] = "unknown" + write_json_artifact(root, report_path, report) + return report + + try: + customer_id = resolve_workspace_customer_id( + locked_target["tenantId"], locked_target["workspaceResourceId"] + ) + except Exception as exc: + detail = _safe_message(exc) + mismatch = "match" in detail.casefold() + report["targetVerification"].update( + { + "status": "blocked" if mismatch else "unavailable", + "reason": detail, + "cloudCallsPerformed": True, + } + ) + report["authentication"].update( + { + "status": "unavailable", + "errorType": type(exc).__name__, + "reason": detail, + } + ) + report["status"] = "blocked" if mismatch else "unknown" + report["contracts"] = [] + write_json_artifact(root, report_path, report) + return report + + if customer_id.casefold() != locked_target["workspaceCustomerId"].casefold(): + report["targetVerification"].update( + { + "status": "blocked", + "reason": "workspace customer ID does not match the locked Qualification target", + "cloudCallsPerformed": True, + } + ) + report["authentication"]["status"] = "not-attempted" + report["status"] = "blocked" + write_json_artifact(root, report_path, report) + return report + + report["targetVerification"].update( + { + "status": "confirmed", + "cloudCallsPerformed": True, + } + ) + try: + credential = azure_cli_credential(locked_target) + token = credential.get_token(ARM_SCOPE).token + except Exception as exc: + report["authentication"].update( + { + "status": "unavailable", + "errorType": type(exc).__name__, + "reason": _safe_message(exc), + } + ) + report["status"] = "unknown" + report["contracts"] = [] + write_json_artifact(root, report_path, report) + return report + + report["authentication"]["status"] = "verified" + report["workspaceQueryPermission"] = _check_query_permission( + locked_target, credential + ) + for candidate in candidates: + try: + report["contracts"].append( + _check_candidate(locked_target, token, candidate) + ) + except (OSError, urllib.error.URLError, TimeoutError, ValueError, TypeError) as exc: + report["contracts"].append( + { + "table": candidate["table"], + "contractPath": candidate["contractPath"], + "schemaFingerprint": candidate["schemaFingerprint"], + "transformFingerprint": candidate["transformFingerprint"], + "contractFingerprint": candidate["contractFingerprint"], + "candidateProvenance": "registered-contract-resolver", + "status": "unknown", + "errorType": type(exc).__name__, + "error": _safe_message(exc), + } + ) + if not candidates and not blockers: + report["status"] = "not-applicable" + else: + query_status = str(report["workspaceQueryPermission"].get("status")) + report["status"] = _aggregate_status( + report["contracts"], + blockers, + additional_statuses=(query_status,), + ) + write_json_artifact(root, report_path, report) + return report diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/delegated_app_registration.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/delegated_app_registration.py new file mode 100644 index 00000000000..589568f5969 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/delegated_app_registration.py @@ -0,0 +1,1471 @@ +from __future__ import annotations + +import hashlib +import json +import urllib.parse +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from .artifacts import ( + artifact_path, + existing_artifact_path, + using_run, + write_json_artifact, +) +from .deployment import _graph_request +from .graph_auth import ( + delegated_graph_claim_diagnostics, + delegated_scope_diagnostics, + validate_delegated_graph_claims, +) +from .graph_client_config import ( + configure_graph_client, + ensure_private_state_root, + graph_auth_record_path, + graph_token_cache_name, + read_delegated_app_registration, + read_private_auth_config, + resolve_graph_client_id, + restrict_private_permissions, + verify_graph_auth_binding, + write_delegated_app_registration, + write_graph_auth_metadata, +) +from .onboarding import _load_auth_record, _state_dir +from .target_context import require_locked_target + +GRAPH_RESOURCE_APP_ID = "00000003-0000-0000-c000-000000000000" +CUSTOM_DETECTION_SCOPE_ID = "c34088fb-0649-4714-af0b-bcbfec155897" +CUSTOM_DETECTION_SCOPE = "CustomDetection.ReadWrite.All" +THREAT_HUNTING_SCOPE_ID = "b152eca8-ea73-4a48-8c98-1a6742673d99" +THREAT_HUNTING_SCOPE = "ThreatHunting.Read.All" +APP_DISPLAY_NAME = "Sentinel-XDR-CustomDetection" +APP_REDIRECT_URI = "http://localhost" +APPLICATIONS_ENDPOINT = "https://graph.microsoft.com/v1.0/applications" +READBACK_AUTH_RECORD_NAME = "app-readback-auth-record.json" +READBACK_TOKEN_CACHE_NAME = "sentinel_xdr_migration_app_readback" +READBACK_SCOPES = ( + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/Application.Read.All", +) +OWNED_APPLICATIONS_ENDPOINT = ( + "https://graph.microsoft.com/v1.0/me/ownedObjects/" + "microsoft.graph.application" +) +BOOTSTRAP_SCOPE = "https://graph.microsoft.com/AppRegistration.Create" +OWNED_APP_READ_SCOPE = "https://graph.microsoft.com/User.Read" +BOOTSTRAP_SCOPES = (BOOTSTRAP_SCOPE, OWNED_APP_READ_SCOPE) +BOOTSTRAP_AUTH_RECORD_NAME = "app-registration-auth-record.json" +BOOTSTRAP_TOKEN_CACHE_NAME = "sentinel_xdr_migration_app_registration" + +TokenProvider = Callable[[Path, str], str] +GraphRequest = Callable[..., tuple[int, dict[str, Any]]] + + +def setup_app_registration_authentication( + *, + tenant_id: str, + auth_method: str = "device-code", + bootstrap_client_id: str | None = None, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + if auth_method not in {"device-code", "browser"}: + raise ValueError("app registration bootstrap auth supports device-code or browser only") + try: + from azure.identity import ( + DeviceCodeCredential, + InteractiveBrowserCredential, + TokenCachePersistenceOptions, + ) + except ImportError as exc: + raise RuntimeError("azure-identity is required for app registration bootstrap authentication") from exc + + state_root = _state_dir(state_dir) + ensure_private_state_root(state_root) + selected_client_id = resolve_graph_client_id( + state_root, "appRegistration", bootstrap_client_id + ) + if bootstrap_client_id: + configure_graph_client( + state_root, "appRegistration", selected_client_id + ) + credential_type = ( + InteractiveBrowserCredential + if auth_method == "browser" + else DeviceCodeCredential + ) + kwargs: dict[str, Any] = { + "client_id": selected_client_id, + "tenant_id": tenant_id, + "cache_persistence_options": TokenCachePersistenceOptions( + name=graph_token_cache_name( + "appRegistration", selected_client_id, BOOTSTRAP_TOKEN_CACHE_NAME + ) + ), + } + if auth_method == "device-code": + import sys + + kwargs["prompt_callback"] = lambda uri, code, _expires: print( + f"Open {uri} and enter code {code}", file=sys.stderr, flush=True + ) + credential = credential_type(**kwargs) + record = credential.authenticate(scopes=list(BOOTSTRAP_SCOPES)) + access_token = credential.get_token(*BOOTSTRAP_SCOPES).token + from .graph_readiness import _token_claims + + scope_diagnostics = validate_delegated_graph_claims( + _token_claims(access_token), + tenant_id=getattr(record, "tenant_id", None) or tenant_id, + required_scopes=BOOTSTRAP_SCOPES, + purpose="app-registration bootstrap", + ) + record_path = graph_auth_record_path( + state_root, + "appRegistration", + selected_client_id, + BOOTSTRAP_AUTH_RECORD_NAME, + ) + record_path.write_text(record.serialize(), encoding="utf-8") + restrict_private_permissions(record_path, 0o600) + write_graph_auth_metadata( + record_path, + purpose="appRegistration", + client_id=selected_client_id, + tenant_id=getattr(record, "tenant_id", None) or tenant_id, + ) + return { + "status": "completed", + "tenantId": getattr(record, "tenant_id", None) or tenant_id, + "username": getattr(record, "username", None), + "scopes": list(BOOTSTRAP_SCOPES), + "clientId": selected_client_id, + "authMethod": auth_method, + "appRegistrationPerformed": False, + "adminConsentRequested": False, + "scopeDiagnostics": scope_diagnostics["delegatedScopes"], + } + + +def _bootstrap_token(state_root: Path, tenant_id: str) -> str: + from azure.identity import DeviceCodeCredential, TokenCachePersistenceOptions + + client_id = resolve_graph_client_id(state_root, "appRegistration") + + record_path = graph_auth_record_path( + state_root, + "appRegistration", + client_id, + BOOTSTRAP_AUTH_RECORD_NAME, + ) + record = _load_auth_record(record_path) + if record is None: + raise RuntimeError( + "app registration bootstrap authentication is missing; run " + "`setup-app-registration-auth` explicitly first" + ) + verify_graph_auth_binding( + record_path, + record, + purpose="appRegistration", + client_id=client_id, + tenant_id=tenant_id, + ) + credential = DeviceCodeCredential( + client_id=client_id, + tenant_id=tenant_id, + authentication_record=record, + cache_persistence_options=TokenCachePersistenceOptions( + name=graph_token_cache_name( + "appRegistration", client_id, BOOTSTRAP_TOKEN_CACHE_NAME + ) + ), + disable_automatic_authentication=True, + ) + token = credential.get_token(*BOOTSTRAP_SCOPES).token + from .graph_readiness import _token_claims + + validate_delegated_graph_claims( + _token_claims(token), + tenant_id=tenant_id, + required_scopes=BOOTSTRAP_SCOPES, + purpose="app-registration bootstrap", + ) + return token + + +def setup_app_readback_authentication( + *, + tenant_id: str, + readback_client_id: str, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + """Set up a separate browser-only, read-only app reconciliation credential.""" + if not isinstance(readback_client_id, str) or not readback_client_id.strip(): + raise ValueError("an existing, separately approved readback client ID is required") + if not isinstance(tenant_id, str) or not tenant_id.strip(): + raise ValueError("app readback authentication requires an explicit tenant ID") + state_root = _state_dir(state_dir) + ensure_private_state_root(state_root) + selected_client_id = resolve_graph_client_id( + state_root, "appReadback", readback_client_id + ) + configure_graph_client(state_root, "appReadback", selected_client_id) + try: + from azure.identity import ( + InteractiveBrowserCredential, + TokenCachePersistenceOptions, + ) + except ImportError as exc: + raise RuntimeError( + "azure-identity is required for app readback browser authentication" + ) from exc + credential = InteractiveBrowserCredential( + client_id=selected_client_id, + tenant_id=tenant_id, + cache_persistence_options=TokenCachePersistenceOptions( + name=graph_token_cache_name( + "appReadback", selected_client_id, READBACK_TOKEN_CACHE_NAME + ) + ), + ) + record = credential.authenticate(scopes=list(READBACK_SCOPES)) + access_token = credential.get_token(*READBACK_SCOPES).token + from .graph_readiness import _token_claims + + scope_diagnostics = validate_delegated_graph_claims( + _token_claims(access_token), + tenant_id=getattr(record, "tenant_id", None) or tenant_id, + required_scopes=READBACK_SCOPES, + purpose="app readback", + ) + record_path = graph_auth_record_path( + state_root, + "appReadback", + selected_client_id, + READBACK_AUTH_RECORD_NAME, + ) + record_path.write_text(record.serialize(), encoding="utf-8") + restrict_private_permissions(record_path, 0o600) + write_graph_auth_metadata( + record_path, + purpose="appReadback", + client_id=selected_client_id, + tenant_id=getattr(record, "tenant_id", None) or tenant_id, + ) + return { + "status": "completed", + "purpose": "appReadback", + "tenantId": getattr(record, "tenant_id", None) or tenant_id, + "username": getattr(record, "username", None), + "clientId": selected_client_id, + "authMethod": "browser", + "scopes": list(READBACK_SCOPES), + "graphWritesPermittedByCommand": False, + "scopeDiagnostics": scope_diagnostics["delegatedScopes"], + } + + +def _readback_scope_diagnostic(scope_claim: Any) -> dict[str, Any]: + scopes = delegated_scope_diagnostics(scope_claim, READBACK_SCOPES) + status = ( + "required-scopes-present" + if scopes["status"] == "satisfied" + else "scope-mismatch" + if scopes["status"] == "missing-required" + else "diagnostic-blocked" + ) + return { + "status": status, + "scopeClaimStatus": scopes["claimStatus"], + "expectedScopeNames": scopes["requiredScopeNames"], + "actualScopeNames": scopes["actualScopeNames"], + "missingScopeNames": scopes["missingScopeNames"], + "extraScopeNames": scopes["extraScopeNames"], + } + + +def _acquire_app_readback_token_and_scope_diagnostic( + state_root: Path, + tenant_id: str, +) -> tuple[str, dict[str, Any]]: + from azure.identity import ( + InteractiveBrowserCredential, + TokenCachePersistenceOptions, + ) + + configured_client = read_private_auth_config(state_root).get( + "graphClientIds", {} + ).get("appReadback") + if not isinstance(configured_client, str): + raise RuntimeError( + "a separately approved app readback client ID is not configured; " + "run `setup-app-readback-auth` explicitly first" + ) + client_id = resolve_graph_client_id(state_root, "appReadback") + record_path = graph_auth_record_path( + state_root, + "appReadback", + client_id, + READBACK_AUTH_RECORD_NAME, + ) + record = _load_auth_record(record_path) + if record is None: + raise RuntimeError( + "app readback browser authentication is missing; run " + "`setup-app-readback-auth` explicitly first" + ) + verify_graph_auth_binding( + record_path, + record, + purpose="appReadback", + client_id=client_id, + tenant_id=tenant_id, + ) + credential = InteractiveBrowserCredential( + client_id=client_id, + tenant_id=tenant_id, + authentication_record=record, + cache_persistence_options=TokenCachePersistenceOptions( + name=graph_token_cache_name( + "appReadback", client_id, READBACK_TOKEN_CACHE_NAME + ) + ), + disable_automatic_authentication=True, + ) + token = credential.get_token(*READBACK_SCOPES).token + from .graph_readiness import _token_claims + + claims = _token_claims(token) + if claims is None: + raise RuntimeError("app readback access token claims could not be inspected") + token_info = delegated_graph_claim_diagnostics( + claims, + tenant_id=tenant_id, + required_scopes=READBACK_SCOPES, + ) + if token_info["tenantMatch"] is not True: + raise RuntimeError("app readback token tenant does not match the approved lab tenant") + if token_info["graphAudience"] is not True: + raise RuntimeError("app readback token is not issued for Microsoft Graph") + return token, _readback_scope_diagnostic(claims.get("scp")) + + +def _app_readback_token(state_root: Path, tenant_id: str) -> str: + token, scope_diagnostic = _acquire_app_readback_token_and_scope_diagnostic( + state_root, + tenant_id, + ) + if scope_diagnostic["status"] != "required-scopes-present": + raise RuntimeError( + "app readback token must include the separately approved " + "delegated User.Read and Application.Read.All scopes" + ) + return token + + +def _app_display_name(value: str | None) -> str: + if value is None: + return APP_DISPLAY_NAME + if ( + not isinstance(value, str) + or not value + or value != value.strip() + or len(value) > 256 + ): + raise ValueError("app display name must be 1-256 characters without surrounding whitespace") + return value + + +def _registration_payload(app_display_name: str | None = None) -> dict[str, Any]: + return { + "displayName": _app_display_name(app_display_name), + "signInAudience": "AzureADMyOrg", + "publicClient": {"redirectUris": [APP_REDIRECT_URI]}, + "requiredResourceAccess": [ + { + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + { + "id": CUSTOM_DETECTION_SCOPE_ID, + "type": "Scope", + } + ], + } + ], + } + + +def _approved_dual_purpose_resource_access() -> list[dict[str, Any]]: + return [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + {"id": CUSTOM_DETECTION_SCOPE_ID, "type": "Scope"}, + {"id": THREAT_HUNTING_SCOPE_ID, "type": "Scope"}, + ], + }] + + +def _legacy_custom_detection_resource_access() -> list[dict[str, Any]]: + return _registration_payload()["requiredResourceAccess"] + + +def _manual_permission_update_plan( + tenant_id: str, + registration: dict[str, Any], +) -> dict[str, Any]: + payload = { + "tenantId": tenant_id, + "applicationId": registration["applicationId"], + "objectId": registration["objectId"], + "displayName": registration["displayName"], + "previouslyApprovedRequiredResourceAccess": ( + _legacy_custom_detection_resource_access() + ), + "desiredRequiredResourceAccess": _approved_dual_purpose_resource_access(), + "desiredDelegatedScopeNames": [ + CUSTOM_DETECTION_SCOPE, + THREAT_HUNTING_SCOPE, + ], + "adminConsentRequested": False, + "applicationRolesAdded": False, + "credentialsCreated": False, + "automatedReplacementSupported": False, + "automationBlockReason": ( + "Microsoft Graph's documented application update contract does not " + "specify a server-enforced conditional update for requiredResourceAccess; " + "a fresh GET or local lock cannot prevent another owner from changing " + "the permission collection before PATCH" + ), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + payload["planSha256"] = hashlib.sha256(encoded).hexdigest() + payload["status"] = "manual-readback-required" + payload["writeOperations"] = [] + payload["readOperations"] = [ + "list-current-user-owned-application-object-IDs", + "GET-exact-application-after-manual-update", + ] + payload["manualSteps"] = [ + "if-current-settings-equal-the-previously-approved-set-add-only-delegated-ThreatHunting.Read.All", + "if-settings-already-match-the-desired-set-make-no-change", + "if-settings-differ-from-both-reviewed-sets-stop-for-manual-review", + "preserve-delegated-CustomDetection.ReadWrite.All", + "do-not-add-application-roles-or-credentials-or-grant-consent", + "run-verify-delegated-app-permission-update-with-this-plan-hash", + ] + payload["authorization"] = { + "manualChangeApprovalRequired": True, + "expectedPlanSha256": payload["planSha256"], + "automatedGraphWrites": False, + } + return payload + + +def _plan_sha256( + tenant_id: str, + payload: dict[str, Any], + bootstrap_client_id: str, +) -> str: + encoded = json.dumps( + { + "tenantId": tenant_id.casefold(), + "payload": payload, + "bootstrapScopes": list(BOOTSTRAP_SCOPES), + "bootstrapClientId": bootstrap_client_id, + "adminConsentRequested": False, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def plan_delegated_app_registration( + solution: str | Path, + *, + state_dir: str | Path | None = None, + app_display_name: str | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + target = require_locked_target(root, materialize_missing=False) + state_root = _state_dir(state_dir) + persisted = read_delegated_app_registration(state_root) + selected_name = _app_display_name(app_display_name) + payload = _registration_payload(selected_name) + bootstrap_client_id = resolve_graph_client_id( + state_root, "appRegistration" + ) + plan_hash = _plan_sha256( + target["tenantId"], payload, bootstrap_client_id + ) + registration_status = persisted.get("status") if persisted else None + same_tenant = ( + persisted + and str(persisted.get("tenantId", "")).casefold() + == target["tenantId"].casefold() + ) + if same_tenant and persisted.get("displayName") != selected_name: + status = "reconciliation-required" + elif ( + same_tenant + and registration_status == "configured" + and persisted.get("displayName") == selected_name + ): + status = "already-configured" + elif same_tenant and persisted.get("displayName") == selected_name: + status = "reconciliation-required" + else: + status = "approval-required" + return { + "status": status, + "provider": "microsoft-graph-v1.0", + "tenantId": target["tenantId"], + "targetLocked": True, + "appDisplayName": selected_name, + "planSha256": plan_hash, + "app": payload, + "bootstrap": { + "delegatedScopes": list(BOOTSTRAP_SCOPES), + "clientId": bootstrap_client_id, + "adminConsentRequested": False, + "requiredPermissionDeclaration": { + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "scopeId": CUSTOM_DETECTION_SCOPE_ID, + "scope": CUSTOM_DETECTION_SCOPE, + "type": "Scope", + }, + "declarationIsNotConsent": True, + }, + "writeOperations": ["create-application"], + "readOperations": ["list-current-user-owned-applications-before-and-after-create"], + "maximumApplicationCreates": 1, + "ownedAppListingPageLimit": 10, + "tenantWideDisplayNameUniqueness": ( + "not-verified; no broad directory read permission is requested" + ), + "authorization": { + "exactScopeApprovalRequired": True, + "expectedPlanSha256": plan_hash, + "writeApprovalArgument": "--approve-exact-app-registration", + }, + "recordedRegistration": { + "applicationId": persisted.get("applicationId"), + "tenantId": persisted.get("tenantId"), + "displayName": persisted.get("displayName"), + "status": registration_status, + } if persisted else None, + } + + +def _list_owned_apps( + token: str, + *, + graph_request: GraphRequest = _graph_request, +) -> list[dict[str, Any]]: + url = OWNED_APPLICATIONS_ENDPOINT + "?" + urllib.parse.urlencode( + {"$select": "id,appId,displayName,signInAudience,publicClient,requiredResourceAccess"} + ) + result: list[dict[str, Any]] = [] + pages = 0 + while url: + pages += 1 + if pages > 10: + raise RuntimeError("owned application inventory exceeded the 10-page safety bound") + status, body = graph_request("GET", url, token) + if status != 200: + error = (body.get("error") or {}).get("message") if isinstance(body, dict) else None + raise RuntimeError( + "cannot safely verify ownership and existing app count with the " + f"bootstrap permission (HTTP {status}): {error or 'Graph request failed'}" + ) + values = body.get("value") if isinstance(body, dict) else None + if not isinstance(values, list) or any(not isinstance(item, dict) for item in values): + raise RuntimeError("owned application inventory response is malformed") + result.extend(values) + next_link = body.get("@odata.nextLink") + if next_link is not None and not isinstance(next_link, str): + raise RuntimeError("owned application inventory continuation link is invalid") + if next_link: + parsed = urllib.parse.urlsplit(next_link) + if ( + parsed.scheme != "https" + or parsed.hostname != "graph.microsoft.com" + or parsed.username + or parsed.password + or not parsed.path.startswith("/v1.0/me/ownedObjects/") + ): + raise RuntimeError( + "owned application inventory returned an unsafe continuation URL" + ) + url = next_link or "" + return result + + +def _matches_requested_app( + value: dict[str, Any], + app_display_name: str = APP_DISPLAY_NAME, +) -> bool: + public_client = value.get("publicClient") + if not isinstance(public_client, dict): + return False + redirects = public_client.get("redirectUris") or [] + resources = value.get("requiredResourceAccess") or [] + expected = { + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + {"id": CUSTOM_DETECTION_SCOPE_ID, "type": "Scope"} + ], + } + return ( + value.get("displayName") == app_display_name + and value.get("signInAudience") == "AzureADMyOrg" + and redirects == [APP_REDIRECT_URI] + and resources == [expected] + ) + + +def _require_owned_app_details(owned: list[dict[str, Any]]) -> None: + required = ( + "id", + "appId", + "displayName", + "signInAudience", + "publicClient", + "requiredResourceAccess", + ) + if any( + any(field not in app or app[field] is None for field in required) + for app in owned + ): + raise RuntimeError( + "the bootstrap permission cannot read enough owned-app properties " + "to verify existing app count and exact settings; no app write was attempted" + ) + + +def _list_owned_application_ids( + token: str, + *, + graph_request: GraphRequest = _graph_request, +) -> list[str]: + url = OWNED_APPLICATIONS_ENDPOINT + "?" + urllib.parse.urlencode( + {"$select": "id", "$top": "100"} + ) + object_ids: list[str] = [] + pages = 0 + while url: + pages += 1 + if pages > 10: + raise RuntimeError("owned application inventory exceeded the 10-page safety bound") + status, body = graph_request("GET", url, token) + if status != 200: + error = (body.get("error") or {}).get("message") if isinstance(body, dict) else None + raise RuntimeError( + "cannot verify current-user ownership with the approved readback " + f"permission (HTTP {status}): {error or 'Graph request failed'}" + ) + values = body.get("value") if isinstance(body, dict) else None + if not isinstance(values, list) or any(not isinstance(item, dict) for item in values): + raise RuntimeError("owned application ID inventory response is malformed") + for item in values: + object_id = item.get("id") + if ( + not isinstance(object_id, str) + or not object_id + or ( + item.get("@odata.type") is not None + and item.get("@odata.type") != "#microsoft.graph.application" + ) + ): + raise RuntimeError( + "owned application response has a missing ID or conflicting explicit object type" + ) + object_ids.append(object_id) + if len(object_ids) > 100: + raise RuntimeError("owned application inventory exceeds the 100-item safety bound") + next_link = body.get("@odata.nextLink") + if next_link is not None and not isinstance(next_link, str): + raise RuntimeError("owned application continuation link is invalid") + if next_link: + parsed = urllib.parse.urlsplit(next_link) + if ( + parsed.scheme != "https" + or parsed.hostname != "graph.microsoft.com" + or parsed.username + or parsed.password + or not parsed.path.startswith("/v1.0/me/ownedObjects/") + ): + raise RuntimeError("owned application inventory returned an unsafe continuation URL") + url = next_link or "" + return object_ids + + +def _readback_application_url(object_id: str) -> str: + fields = ( + "id", + "appId", + "displayName", + "signInAudience", + "publicClient", + "requiredResourceAccess", + "passwordCredentials", + "keyCredentials", + ) + return APPLICATIONS_ENDPOINT + "/" + urllib.parse.quote(object_id, safe="") + "?" + urllib.parse.urlencode( + {"$select": ",".join(fields)} + ) + + +def _validate_readback_application( + application: dict[str, Any], + registration: dict[str, Any], +) -> list[str]: + issues = _validate_readback_application_identity(application, registration) + if issues: + return issues + resource_access = application["requiredResourceAccess"] + if resource_access == _approved_dual_purpose_resource_access(): + return [] + if resource_access == _legacy_custom_detection_resource_access(): + return [ + "application permission declaration is update-needed; expected the exact " + "delegated CustomDetection.ReadWrite.All and ThreatHunting.Read.All set" + ] + return [ + "application declares a missing or additional permission; expected the exact " + "delegated CustomDetection.ReadWrite.All and ThreatHunting.Read.All set" + ] + + +def _validate_readback_application_identity( + application: dict[str, Any], + registration: dict[str, Any], +) -> list[str]: + required = ( + "id", + "appId", + "displayName", + "signInAudience", + "publicClient", + "requiredResourceAccess", + "passwordCredentials", + "keyCredentials", + ) + missing = [ + key for key in required + if key not in application or application[key] is None + ] + if missing: + return [f"Graph did not return required settings: {', '.join(missing)}"] + if ( + not isinstance(application.get("id"), str) + or application["id"].casefold() != registration["objectId"].casefold() + ): + return ["Graph returned a different application object ID"] + if ( + not isinstance(application.get("appId"), str) + or application["appId"].casefold() != registration["applicationId"].casefold() + ): + return ["Graph returned a different application/client ID"] + if application.get("displayName") != registration["displayName"]: + return ["application display name does not match the exact approved name"] + if application.get("signInAudience") != "AzureADMyOrg": + return ["application is not restricted to the approved single-tenant audience"] + public_client = application.get("publicClient") + if ( + not isinstance(public_client, dict) + or public_client.get("redirectUris") != [APP_REDIRECT_URI] + ): + return ["application localhost public-client redirect settings do not match"] + if not isinstance(application.get("requiredResourceAccess"), list): + return ["application permission declaration is unavailable"] + if not isinstance(application.get("passwordCredentials"), list): + return ["password-credential inventory is not a list"] + if application["passwordCredentials"]: + return ["application has password credentials; refusing to configure it"] + if not isinstance(application.get("keyCredentials"), list): + return ["key-credential inventory is not a list"] + if application["keyCredentials"]: + return ["application has key credentials; refusing to configure it"] + return [] + + +def _safe_readback_error(_solution: Path, _error: Exception) -> str: + safe_message = ( + "app readback browser authentication is missing; run " + "`setup-app-readback-auth` explicitly first" + ) + try: + message = str(_error).strip() + except Exception: + return "details suppressed to prevent disclosure of credentials or token claims" + if message.startswith(safe_message): + return safe_message + return "details suppressed to prevent disclosure of credentials or token claims" + + +def _require_readback_registration( + state_root: Path, + *, + tenant_id: str, +) -> dict[str, Any]: + registration = read_delegated_app_registration(state_root) + if not isinstance(registration, dict): + raise RuntimeError("private app-registration record is missing") + if str(registration.get("tenantId") or "").casefold() != tenant_id.casefold(): + raise RuntimeError( + "private app-registration record is not bound to the locked tenant" + ) + for key in ("applicationId", "objectId"): + value = registration.get(key) + try: + uuid.UUID(value) if isinstance(value, str) else None + except (ValueError, AttributeError): + value = None + if not isinstance(value, str) or not value.strip(): + raise RuntimeError( + f"private app-registration record has no valid {key}; " + "complete supported app discovery or registration first" + ) + display_name = registration.get("displayName") + if not isinstance(display_name, str) or not display_name.strip(): + raise RuntimeError( + "private app-registration record has no verified display name" + ) + registration["applicationId"] = str(uuid.UUID(registration["applicationId"])) + registration["objectId"] = str(uuid.UUID(registration["objectId"])) + if registration.get("status") not in { + "created-needs-ownership-verification", + "create-outcome-unknown", + "reconciliation-required", + "configured", + }: + raise RuntimeError( + "readback and permission update require an existing recorded app " + "registration; they do not replace the registration workflow" + ) + return registration + + +def diagnose_app_readback_scopes( + solution: str | Path, + *, + run_id: str, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + """Report approved cached-token scope names without calling Microsoft Graph.""" + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + return _diagnose_selected_app_readback_scopes( + root, + run_id=run_id, + state_dir=state_dir, + ) + + +def _diagnose_selected_app_readback_scopes( + root: Path, + *, + run_id: str, + state_dir: str | Path | None, +) -> dict[str, Any]: + state_path = existing_artifact_path(root, "workflow-state.json") + try: + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError( + "the exact selected qualification workflow state is unavailable" + ) from exc + context = state.get("context") if isinstance(state, dict) else None + if ( + not isinstance(state, dict) + or state.get("runId") != run_id + or not isinstance(context, dict) + or context.get("workflowProfile") != "qualification" + or context.get("targetContextLocked") is not True + ): + raise ValueError( + "scope diagnostic requires the exact pinned Qualification run and tenant" + ) + target = require_locked_target(root, materialize_missing=False) + tenant_id = str(target.get("tenantId") or "") + + state_root = _state_dir(state_dir) + _require_readback_registration(state_root, tenant_id=tenant_id) + try: + token, diagnostic = _acquire_app_readback_token_and_scope_diagnostic( + state_root, + tenant_id, + ) + del token + except Exception as exc: + diagnostic = _readback_scope_diagnostic(None) + diagnostic.update( + status="diagnostic-blocked", + scopeClaimStatus="not-available", + ) + diagnostic["reason"] = ( + "cached readback token unavailable or invalid: " + + _safe_readback_error(root, exc) + ) + else: + diagnostic["reason"] = ( + "scope names were checked from the approved cached readback credential; " + "no Microsoft Graph resource request was made" + ) + diagnostic["graphResourceRequests"] = 0 + diagnostic["interactivePromptUsed"] = False + diagnostic["privateConfigurationChanged"] = False + return diagnostic + + +def verify_delegated_app_readback( + solution: str | Path, + *, + state_dir: str | Path | None = None, + token_provider: TokenProvider | None = None, + graph_request: GraphRequest = _graph_request, +) -> dict[str, Any]: + """Verify the approved app using owned-object enumeration and exact GET only.""" + root = Path(solution).expanduser().resolve() + target = require_locked_target(root, materialize_missing=False) + tenant_id = str(target.get("tenantId") or "") + state_root = _state_dir(state_dir) + registration = _require_readback_registration( + state_root, + tenant_id=tenant_id, + ) + application_id = registration["applicationId"] + object_id = registration["objectId"] + display_name = registration["displayName"] + prior_status = registration.get("status") + if token_provider is None: + token_provider = _app_readback_token + try: + token = token_provider(state_root, tenant_id) + except Exception as exc: + return { + "status": "verification-blocked", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "authentication": "missing-or-invalid", + "ownership": "not-run", + "settings": "not-run", + "reason": "readback authentication unavailable: " + + _safe_readback_error(root, exc), + "graphWritesPerformed": False, + "privateConfigurationCompleted": False, + } + try: + owned_ids = _list_owned_application_ids(token, graph_request=graph_request) + except Exception as exc: + return { + "status": "verification-blocked", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "ownership": "unverified", + "settings": "not-read", + "reason": "owned-object readback failed: " + + _safe_readback_error(root, exc), + "graphWritesPerformed": False, + "privateConfigurationCompleted": False, + } + if owned_ids.count(object_id) != 1: + return { + "status": "verification-blocked", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "ownership": ( + "not-owned-or-not-visible" + if object_id not in owned_ids + else "duplicate-owned-object" + ), + "settings": "not-read", + "reason": "the exact approved object ID did not appear exactly once in the current user's owned applications", + "graphWritesPerformed": False, + "privateConfigurationCompleted": False, + } + try: + status, application = graph_request( + "GET", + _readback_application_url(object_id), + token, + ) + except Exception as exc: + return { + "status": "verification-blocked", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "ownership": "verified", + "settings": "not-read", + "reason": "exact application GET failed: " + + _safe_readback_error(root, exc), + "graphWritesPerformed": False, + "privateConfigurationCompleted": False, + } + if status != 200 or not isinstance(application, dict): + return { + "status": "verification-blocked", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "ownership": "verified", + "settings": "unavailable", + "httpStatus": status, + "reason": "exact application settings could not be read", + "graphWritesPerformed": False, + "privateConfigurationCompleted": False, + } + issues = _validate_readback_application(application, registration) + if issues: + return { + "status": "verification-blocked", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "ownership": "verified", + "settings": "mismatch-or-incomplete", + "issues": issues, + "graphWritesPerformed": False, + "privateConfigurationCompleted": False, + } + + snapshot = { + key: application[key] + for key in ( + "id", + "appId", + "displayName", + "signInAudience", + "publicClient", + "requiredResourceAccess", + "passwordCredentials", + "keyCredentials", + ) + } + updated_registration = dict(registration) + updated_registration.setdefault("creationStatus", prior_status) + updated_registration["applicationId"] = application_id + updated_registration["objectId"] = object_id + updated_registration["tenantId"] = tenant_id + updated_registration["status"] = "configured" + updated_registration["readbackVerification"] = { + "status": "passed", + "verifiedAt": datetime.now(timezone.utc).isoformat(), + "tenantId": tenant_id, + "clientId": application_id, + "objectId": object_id, + "ownership": "current-user-owned-object-confirmed", + "settingsSha256": hashlib.sha256( + json.dumps(snapshot, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest(), + "verifiedSettings": [ + "exact-object-and-client-IDs", + "exact-display-name", + "single-tenant-audience", + "localhost-public-client-redirect", + "exact-delegated-CustomDetection.ReadWrite.All-and-ThreatHunting.Read.All-declared", + "no-password-or-key-credentials", + ], + "graphMethods": ["GET"], + "graphWritesPerformed": False, + "adminConsentVerified": False, + } + write_delegated_app_registration( + state_root, + updated_registration, + custom_detection_client_id=application_id, + advanced_hunting_client_id=application_id, + ) + return { + "status": "readback-verified-private-config-completed", + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": display_name, + "ownership": "current-user-owned-object-confirmed", + "settings": "exact-approved-settings-confirmed", + "privateConfigurationCompleted": True, + "preservedCreationStatus": prior_status, + "graphWritesPerformed": False, + "adminConsentVerified": False, + "deploymentOrWriteAuthorizationVerified": False, + } + + +def plan_delegated_app_permission_update( + solution: str | Path, + *, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + target = require_locked_target( + Path(solution).expanduser().resolve(), + materialize_missing=False, + ) + tenant_id = str(target.get("tenantId") or "") + registration = _require_readback_registration( + _state_dir(state_dir), + tenant_id=tenant_id, + ) + return _manual_permission_update_plan(tenant_id, registration) + + +def verify_delegated_app_permission_update( + solution: str | Path, + *, + expected_plan_sha256: str, + state_dir: str | Path | None = None, + token_provider: TokenProvider | None = None, + graph_request: GraphRequest = _graph_request, +) -> dict[str, Any]: + """Verify a separately completed manual permission update with GET only.""" + plan = plan_delegated_app_permission_update(solution, state_dir=state_dir) + if expected_plan_sha256.casefold() != plan["planSha256"]: + raise ValueError("the delegated app permission update plan has changed") + result = verify_delegated_app_readback( + solution, + state_dir=state_dir, + token_provider=token_provider, + graph_request=graph_request, + ) + issues = result.get("issues", []) + if result.get("status") == "readback-verified-private-config-completed": + status = "manual-permission-update-verified" + elif any("update-needed" in issue for issue in issues): + status = "manual-update-required" + else: + status = "verification-blocked" + return { + "status": status, + "planSha256": plan["planSha256"], + "applicationId": plan["applicationId"], + "objectId": plan["objectId"], + "manualPermissionAdditionRequired": status == "manual-update-required", + "automatedUpdatePerformed": False, + "graphWritesPerformed": False, + "adminConsentRequested": False, + "readback": result, + } + + +def register_delegated_app( + solution: str | Path, + *, + expected_plan_sha256: str, + approve_exact_app_registration: bool = False, + state_dir: str | Path | None = None, + app_display_name: str | None = None, + token_provider: TokenProvider | None = None, + graph_request: GraphRequest = _graph_request, +) -> dict[str, Any]: + """Create at most one approved delegated desktop app; never grants consent.""" + if not approve_exact_app_registration: + raise PermissionError( + "app registration requires explicit --approve-exact-app-registration" + ) + selected_name = _app_display_name(app_display_name) + plan = plan_delegated_app_registration( + solution, + state_dir=state_dir, + app_display_name=selected_name, + ) + if expected_plan_sha256.casefold() != plan["planSha256"]: + raise ValueError("the exact delegated app registration plan has changed") + if plan["status"] == "already-configured": + return {"status": "already-configured", "plan": plan} + if plan["status"] == "reconciliation-required": + persisted = read_delegated_app_registration(_state_dir(state_dir)) or {} + application_id = persisted.get("applicationId") + object_id = persisted.get("objectId") + pending_tenant = persisted.get("tenantId") + if not isinstance(application_id, str) or not isinstance(object_id, str): + raise RuntimeError( + "a previous app-create attempt has an unknown outcome; inspect " + "the target tenant before any further write" + ) + if str(pending_tenant or "").casefold() != plan["tenantId"].casefold(): + raise RuntimeError("pending app registration belongs to a different tenant") + + root = Path(solution).expanduser().resolve() + state_root = _state_dir(state_dir) + tenant_id = plan["tenantId"] + if token_provider is None: + token_provider = _bootstrap_token + token = token_provider(state_root, tenant_id) + + owned = _list_owned_apps(token, graph_request=graph_request) + _require_owned_app_details(owned) + matching_name = [ + item for item in owned if item.get("displayName") == selected_name + ] + if len(matching_name) > 1: + raise RuntimeError( + "multiple same-name applications are owned by this user; refusing to create or select one" + ) + if matching_name: + existing = matching_name[0] + if not _matches_requested_app(existing, selected_name): + raise RuntimeError( + "an owned application with the requested name has different settings; " + "refusing to modify or reuse it" + ) + if plan["status"] == "reconciliation-required" and ( + existing.get("appId") != application_id + or existing.get("id") != object_id + ): + raise RuntimeError( + "the owned application does not match the pending registration " + "identifiers; refusing to reconfigure private client state" + ) + application_id = existing.get("appId") + object_id = existing.get("id") + if not isinstance(application_id, str) or not isinstance(object_id, str): + raise RuntimeError("matching owned application is missing application/object identifiers") + response = existing + status = 200 + operation = "reused-exact-owned-app" + else: + if plan["status"] == "reconciliation-required": + raise RuntimeError( + "a previous app-create attempt is not visible in the current user's " + "owned applications; refusing to create a duplicate" + ) + if len(owned) >= 100: + raise RuntimeError("owned application inventory exceeds the 100-item safety bound") + try: + status, response = graph_request( + "POST", + APPLICATIONS_ENDPOINT, + token, + plan["app"], + ) + except Exception as exc: + write_delegated_app_registration( + state_root, + { + "schemaVersion": 1, + "applicationId": None, + "objectId": None, + "tenantId": tenant_id, + "displayName": selected_name, + "status": "create-outcome-unknown", + }, + ) + write_json_artifact( + root, + artifact_path( + root, + "delegated-app-registration.report.json", + create_parent=True, + ), + { + "status": "reconciliation-required", + "tenantId": tenant_id, + "reason": ( + "The application create request outcome is unknown " + f"({type(exc).__name__}); inspect the tenant before retrying." + ), + "adminConsent": "not-requested", + }, + ) + raise RuntimeError( + "application create outcome is unknown; inspect the target tenant " + "before retrying to avoid a duplicate" + ) from exc + if status != 201: + error = (response.get("error") or {}).get("message") if isinstance(response, dict) else None + raise RuntimeError( + "application registration failed; verify the signed-in user has " + f"the supported least-privilege bootstrap permission: {error or f'HTTP {status}'}" + ) + application_id = response.get("appId") + object_id = response.get("id") + pending_registration = { + "schemaVersion": 1, + "applicationId": application_id, + "objectId": object_id, + "tenantId": tenant_id, + "displayName": selected_name, + "redirectUri": APP_REDIRECT_URI, + "delegatedScope": CUSTOM_DETECTION_SCOPE, + "scopeId": CUSTOM_DETECTION_SCOPE_ID, + "permissionDeclarationOnly": True, + "adminConsentGranted": False, + "status": "created-needs-ownership-verification", + } + write_delegated_app_registration(state_root, pending_registration) + if not isinstance(application_id, str) or not isinstance(object_id, str): + write_json_artifact( + root, + artifact_path( + root, + "delegated-app-registration.report.json", + create_parent=True, + ), + { + "status": "reconciliation-required", + "tenantId": tenant_id, + "graphResponseStatus": status, + "reason": ( + "Graph returned a successful create response without both " + "application and object IDs. No retry or corrective write was attempted." + ), + "adminConsent": "not-requested", + }, + ) + raise RuntimeError( + "Graph created an application but omitted identifiers; inspect " + "the tenant before retrying to avoid a duplicate" + ) + if ( + not _matches_requested_app(response, selected_name) + ): + raise RuntimeError( + "created application response did not match the approved configuration; " + "no corrective writes were attempted" + ) + operation = "created" + + if operation == "created": + try: + owned_after_create = _list_owned_apps(token, graph_request=graph_request) + except Exception as exc: + reason = ( + "Application creation succeeded, but ownership could not be " + f"verified: {type(exc).__name__}: {exc}" + ) + write_json_artifact( + root, + artifact_path( + root, + "delegated-app-registration.report.json", + create_parent=True, + ), + { + "status": "reconciliation-required", + "operation": operation, + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": selected_name, + "reason": reason, + "error": reason, + "adminConsent": "not-requested", + }, + ) + return { + "status": "reconciliation-required", + "operation": operation, + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": selected_name, + "ownershipVerified": False, + "reason": reason, + "error": reason, + } + owned_matches = [ + item + for item in owned_after_create + if item.get("id") == object_id + and item.get("@odata.type", "#microsoft.graph.application") + == "#microsoft.graph.application" + and _matches_requested_app(item, selected_name) + ] + if len(owned_matches) != 1: + reason = ( + "The created application did not appear exactly once with the " + "approved display name and settings in the current user's owned applications." + ) + write_json_artifact( + root, + artifact_path( + root, + "delegated-app-registration.report.json", + create_parent=True, + ), + { + "status": "reconciliation-required", + "operation": operation, + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": selected_name, + "reason": reason, + "error": reason, + "adminConsent": "not-requested", + }, + ) + return { + "status": "reconciliation-required", + "operation": operation, + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": selected_name, + "ownershipVerified": False, + "reason": reason, + "error": reason, + } + + registration = { + "schemaVersion": 1, + "applicationId": application_id, + "objectId": object_id, + "tenantId": tenant_id, + "displayName": selected_name, + "redirectUri": APP_REDIRECT_URI, + "delegatedScope": CUSTOM_DETECTION_SCOPE, + "scopeId": CUSTOM_DETECTION_SCOPE_ID, + "permissionDeclarationOnly": True, + "adminConsentGranted": False, + "status": "configured", + } + write_delegated_app_registration( + state_root, + registration, + custom_detection_client_id=application_id, + ) + write_json_artifact( + root, + artifact_path( + root, + "delegated-app-registration.report.json", + create_parent=True, + ), + { + "status": "configured", + "operation": operation, + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": selected_name, + "delegatedPermissionDeclaration": CUSTOM_DETECTION_SCOPE, + "adminConsent": "not-requested", + "applicationPermissionGrant": "not-requested", + "armRoles": "not-requested", + "reportingDoesNotProve": [ + "OAuth consent", + "effective delegated permission", + "Defender XDR role assignment", + "write authorization", + ], + "graphResponseStatus": status, + }, + ) + return { + "status": "configured", + "operation": operation, + "tenantId": tenant_id, + "applicationId": application_id, + "objectId": object_id, + "displayName": selected_name, + "adminConsent": "not-requested", + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/deployment.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/deployment.py new file mode 100644 index 00000000000..61ac541a86c --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/deployment.py @@ -0,0 +1,444 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from .artifacts import artifact_path, write_json_artifact +from .converter import xdr_detection_files +from .graph_readiness import ( + graph_detection_payload_with_projection, +) +from .graph_auth import ( + credential_token_scope, + validate_delegated_graph_claims, +) +from .graph_client_config import ( + azure_cli_credential, + azure_cli_profile, + configure_azure_cli_profile, + configure_graph_client, + configured_azure_cli_profile, + ensure_private_state_root, + graph_auth_record_path, + graph_token_cache_name, + resolve_graph_client_id, + restrict_private_permissions, + verify_graph_auth_binding, + write_graph_auth_metadata, +) +from .onboarding import ( + GRAPH_CLIENT_ID, + _load_auth_record, + _read_json, + _state_dir, +) +from .target_context import require_locked_target + +DEPLOYMENT_SCOPE = "https://graph.microsoft.com/CustomDetection.ReadWrite.All" +DEPLOYMENT_ENDPOINT = ( + "https://graph.microsoft.com/beta/security/rules/detectionRules" +) +DEPLOYMENT_AUTH_RECORD_NAME = "custom-detection-auth-record.json" +DEPLOYMENT_TOKEN_CACHE_NAME = "sentinel_xdr_migration_custom_detection" + + +def _deployment_credential( + state_root: Path, + *, + interactive: bool = False, + auth_method: str = "device-code", + tenant_id: str | None = None, + client_id: str | None = None, +): + from azure.identity import ( + DeviceCodeCredential, + InteractiveBrowserCredential, + TokenCachePersistenceOptions, + ) + + selected_client_id = resolve_graph_client_id( + state_root, "customDetection", client_id + ) + record_path = graph_auth_record_path( + state_root, + "customDetection", + selected_client_id, + DEPLOYMENT_AUTH_RECORD_NAME, + ) + record = _load_auth_record(record_path) + config = _read_json(state_root / "config.json") + if record is not None: + verify_graph_auth_binding( + record_path, + record, + purpose="customDetection", + client_id=selected_client_id, + tenant_id=tenant_id or config.get("tenantId"), + ) + common: dict[str, Any] = { + "client_id": selected_client_id, + "tenant_id": tenant_id + or config.get("tenantId") + or getattr(record, "tenant_id", None), + "authentication_record": record, + "cache_persistence_options": TokenCachePersistenceOptions( + name=graph_token_cache_name( + "customDetection", selected_client_id, DEPLOYMENT_TOKEN_CACHE_NAME + ) + ), + "disable_automatic_authentication": not interactive, + } + if auth_method == "browser": + return InteractiveBrowserCredential(**common) + if auth_method == "device-code": + if interactive: + common["prompt_callback"] = lambda uri, code, _expires: print( + f"Open {uri} and enter code {code}", file=sys.stderr, flush=True + ) + return DeviceCodeCredential(**common) + raise ValueError("auth_method must be 'device-code' or 'browser'") + + +def setup_deployment_authentication( + *, + tenant_id: str | None = None, + auth_method: str = "device-code", + graph_client_id: str | None = None, + azure_config_dir: str | Path | None = None, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + state_root = _state_dir(state_dir) + ensure_private_state_root(state_root) + selected_client_id = resolve_graph_client_id( + state_root, "customDetection", graph_client_id + ) + if auth_method == "azure-cli" and selected_client_id != GRAPH_CLIENT_ID: + raise ValueError( + "Azure CLI authentication uses the Azure CLI client, not the " + "configured custom Graph client; use device-code or browser." + ) + if graph_client_id: + configure_graph_client(state_root, "customDetection", graph_client_id) + if azure_config_dir is not None: + configure_azure_cli_profile(state_root, azure_config_dir) + if auth_method == "azure-cli": + executable = shutil.which("az") + if not executable: + raise RuntimeError("Azure CLI is not installed or not on PATH") + command = [ + executable, + "login", + "--scope", + DEPLOYMENT_SCOPE, + ] + if tenant_id: + command.extend(["--tenant", tenant_id]) + with azure_cli_profile( + azure_config_dir or configured_azure_cli_profile(state_root) + ): + result = subprocess.run(command, check=False) + if result.returncode: + raise RuntimeError("Azure CLI Custom Detection authentication failed") + token = _deployment_token(state_root, require_record=False, tenant_id=tenant_id) + return { + "status": "completed", + "tenantId": tenant_id, + "username": None, + "scope": DEPLOYMENT_SCOPE, + "authMethod": auth_method, + "clientId": None, + "azureConfigDir": configured_azure_cli_profile(state_root), + "tokenAcquired": bool(token), + } + credential = _deployment_credential( + state_root, + interactive=True, + auth_method=auth_method, + tenant_id=tenant_id, + client_id=selected_client_id, + ) + try: + record = credential.authenticate(scopes=[DEPLOYMENT_SCOPE]) + access_token = credential.get_token(DEPLOYMENT_SCOPE).token + scope_diagnostics = _validate_deployment_token( + access_token, + tenant_id=tenant_id or getattr(record, "tenant_id", None) or "", + ) + except Exception as exc: + raise RuntimeError( + f"Custom Detection authentication failed: {exc}" + ) from exc + record_path = graph_auth_record_path( + state_root, + "customDetection", + selected_client_id, + DEPLOYMENT_AUTH_RECORD_NAME, + ) + record_path.write_text( + record.serialize(), encoding="utf-8" + ) + restrict_private_permissions(record_path, 0o600) + write_graph_auth_metadata( + record_path, + purpose="customDetection", + client_id=selected_client_id, + tenant_id=getattr(record, "tenant_id", None) or tenant_id, + ) + return { + "status": "completed", + "tenantId": getattr(record, "tenant_id", None), + "username": getattr(record, "username", None), + "scope": DEPLOYMENT_SCOPE, + "authMethod": auth_method, + "clientId": selected_client_id, + "azureConfigDir": configured_azure_cli_profile(state_root), + "scopeDiagnostics": scope_diagnostics["delegatedScopes"], + } + + +def _validate_deployment_token( + token: str, + *, + tenant_id: str, +) -> dict[str, Any]: + from .graph_readiness import _token_claims + + return validate_delegated_graph_claims( + _token_claims(token), + tenant_id=tenant_id, + required_scopes=(DEPLOYMENT_SCOPE,), + purpose="Custom Detection", + ) + + +def _deployment_token( + state_dir: str | Path | None = None, + *, + require_record: bool = False, + tenant_id: str | None = None, +) -> str: + state_root = _state_dir(state_dir) + client_id = resolve_graph_client_id(state_root, "customDetection") + record_path = graph_auth_record_path( + state_root, "customDetection", client_id, DEPLOYMENT_AUTH_RECORD_NAME + ) + if record_path.exists(): + credential = _deployment_credential( + state_root, tenant_id=tenant_id, client_id=client_id + ) + token = credential.get_token(DEPLOYMENT_SCOPE).token + elif not require_record: + if client_id != GRAPH_CLIENT_ID: + raise RuntimeError( + "the configured custom Graph client has no cached Custom Detection " + "authentication; run setup-deployment with device-code or browser" + ) + try: + token = azure_cli_credential( + tenant_id=tenant_id, state_root=state_root + ).get_token( + credential_token_scope("azure-cli", DEPLOYMENT_SCOPE) + ).token + except Exception: + token = None + else: + token = None + if token is None: + raise RuntimeError( + "Custom Detection deployment authentication is missing; run " + "`sentinel-xdr-migration setup-deployment --auth-method azure-cli` first" + ) + _validate_deployment_token(token, tenant_id=tenant_id or "") + return token + + +def _graph_request( + method: str, + url: str, + token: str, + body: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + request = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8") if body is not None else None, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=90) as response: + content = response.read().decode("utf-8") + return response.status, json.loads(content) if content else {} + except urllib.error.HTTPError as exc: + content = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + parsed = {"error": {"message": content[:4000]}} + return exc.code, parsed + + +def graph_detection_payload(document: dict[str, Any]) -> dict[str, Any]: + payload, _projection = graph_detection_payload_with_projection(document) + return payload + + +def deploy_solution( + solution: str | Path, + *, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + report_path = artifact_path(root, "deployment.graph.json", create_parent=True) + target = require_locked_target(root) + output = root / "XDR Detections" + files = xdr_detection_files(output) + from .packaging_selection import current_applied_package_selection + + applied_selection = current_applied_package_selection(root) + if applied_selection is not None: + selected_paths = { + str(value).replace("\\", "/").casefold() + for value in (applied_selection["plan"].get("selection") or {}).get( + "xdrDetections", [] + ) + } + files_by_path = { + path.relative_to(root).as_posix().casefold(): path for path in files + } + if not selected_paths or not selected_paths.issubset(files_by_path): + raise ValueError("current applied package selection has missing Custom Detection files") + files = [files_by_path[path] for path in sorted(selected_paths)] + if not files: + raise ValueError(f"no generated XDR Detection YAML files found under {output}") + token = _deployment_token(state_dir, tenant_id=target["tenantId"]) + results: list[dict[str, Any]] = [] + for path in files: + try: + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} + payload, tactic_projection = graph_detection_payload_with_projection( + document + ) + rule_id = urllib.parse.quote(payload["id"], safe="") + item_url = f"{DEPLOYMENT_ENDPOINT}/{rule_id}" + status, current = _graph_request("GET", item_url, token) + if status == 404: + response_status, response = _graph_request( + "POST", DEPLOYMENT_ENDPOINT, token, payload + ) + operation = "created" + success = response_status == 201 + elif status == 200: + update = {key: value for key, value in payload.items() if key not in {"id", "@odata.type"}} + response_status, response = _graph_request( + "PATCH", item_url, token, update + ) + operation = "updated" + success = response_status == 200 + else: + response_status, response = status, current + operation = "failed" + success = False + error = response.get("error") if isinstance(response, dict) else None + deployed_status = None + tactic_projection_verified = None + if success: + verify_status, verified = _graph_request("GET", item_url, token) + deployed_status = ( + str(verified.get("status") or "").lower() + if isinstance(verified, dict) + else "" + ) + projected_tactics = ( + ((payload.get("detectionAction") or {}).get("alertTemplate") or {}).get( + "tactics" + ) + or [] + ) + verified_tactics = ( + ( + ((verified.get("detectionAction") or {}).get("alertTemplate") or {}) + ).get("tactics") + or [] + if isinstance(verified, dict) + else [] + ) + tactic_projection_verified = verified_tactics == projected_tactics + if ( + verify_status != 200 + or deployed_status != "disabled" + or not tactic_projection_verified + ): + success = False + operation = "failed" + error = { + "code": "DeploymentVerificationFailed", + "message": ( + "deployed Custom Detection did not match its disabled " + "status and projected tactic payload" + ), + "statusCode": verify_status, + "tacticProjectionVerified": tactic_projection_verified, + } + results.append( + { + "file": str(path), + "id": payload["id"], + "operation": operation if success else "failed", + "statusCode": response_status, + "success": success, + "deployedStatus": deployed_status, + "tacticProjection": tactic_projection, + "tacticProjectionVerified": tactic_projection_verified, + "error": error, + } + ) + except (OSError, ValueError, yaml.YAMLError) as exc: + results.append( + { + "file": str(path), + "id": None, + "operation": "failed", + "statusCode": 0, + "success": False, + "error": {"message": str(exc)}, + } + ) + report = { + "solution": str(root), + "provider": "microsoft-graph-beta", + "target": target, + "timestamp": datetime.now(timezone.utc).isoformat(), + "total": len(results), + "succeeded": sum(result["success"] for result in results), + "failed": sum(not result["success"] for result in results), + "results": results, + } + if applied_selection is not None: + report["qualificationScope"] = { + "kind": "applied-package-selection", + "planSha256": applied_selection["plan"].get("planSha256"), + "outcome": (applied_selection["plan"].get("summary") or {}).get("outcome"), + "selectedDetectionPaths": sorted( + str(value).replace("\\", "/") + for value in (applied_selection["plan"].get("selection") or {}).get( + "xdrDetections", [] + ) + ), + "coverage": applied_selection["plan"].get("summary"), + } + report["reportPath"] = str(report_path) + write_json_artifact(root, report_path, report) + return report diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/deployment_planning.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/deployment_planning.py new file mode 100644 index 00000000000..7d20a2470cf --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/deployment_planning.py @@ -0,0 +1,1836 @@ +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from .artifacts import ( + artifact_path, + existing_artifact_path, + report_directory, + repository_root, + resolve_artifact_reference, + write_json_artifact, +) +from .content_paths import content_path +from .analytic_rule import analytic_rule_payload +from .converter import ( + analytic_rule_files, + build_xdr_document, + load_config, + validate_document, + xdr_detection_files, +) +from .packaging import ( + DATA_EXCLUSIONS, + _solution_data_file, + package_input_identity, + solution_data_version, +) +from .target_context import require_locked_target, workspace_identity + +PLAN_SCHEMA_VERSION = "1.0.0" +PLAN_FILE_NAME = "deployment-scope-plan.json" +CONSENT_FILE_NAME = "deployment-planning-consent.json" +COMPONENTS = { + "sentinelAlertRules", + "xdrCustomDetections", + "fullPackageAcceptance", + "mockTelemetryIngestion", + "fallbackDcr", + "fallbackDce", + "customTableCreate", +} +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_GUID = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$") +_CONTENT_FOLDERS = { + "analyticRules": ("Analytic Rules", "Analytics Rules"), + "playbooks": ("Playbooks",), + "workbooks": ("Workbooks",), + "dataConnectors": ("Data Connectors",), +} +_OTHER_CONTENT_FOLDERS = ( + "Hunting Queries", + "Parsers", + "Watchlists", + "Notebooks", + "Functions", + "Exploration Queries", + "Summary rules", +) +_SKIP_PARTS = {"logs", "reports", "evidence", ".git", "package"} +_MANIFEST_CATEGORIES = { + "analytic rules": "analyticRules", + "analytics rules": "analyticRules", + "playbooks": "playbooks", + "workbooks": "workbooks", + "data connectors": "dataConnectors", + "hunting queries": "other", + "parsers": "other", + "watchlists": "other", + "notebooks": "other", + "functions": "other", + "exploration queries": "other", + "summary rules": "other", +} + + +def _canonical_sha256(value: Any) -> str: + return hashlib.sha256( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + + +def _file_inventory(root: Path) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()): + relative = path.relative_to(root) + if any(part.casefold() in _SKIP_PARTS for part in relative.parts): + continue + if any(part.casefold() == ".env" or part.casefold().startswith(".env.") for part in relative.parts): + continue + if path.is_symlink(): + raise ValueError(f"deployment planning does not follow symlink content: {relative}") + if not path.is_file(): + continue + content_path(root, relative) + entries.append( + { + "path": relative.as_posix(), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + return entries + + +def _count_files(root: Path, folder: str, suffixes: set[str]) -> int: + directory = root / folder + if not directory.exists(): + return 0 + if directory.is_symlink(): + raise ValueError(f"deployment planning does not follow symlink content: {directory}") + count = 0 + for path in directory.rglob("*"): + if any(part.casefold() in _SKIP_PARTS for part in path.relative_to(root).parts): + continue + if path.is_symlink(): + raise ValueError(f"deployment planning does not follow symlink content: {path}") + if path.is_file() and path.suffix.casefold() in suffixes: + count += 1 + return count + + +def _resolve_manifest_reference(root: Path, reference: str) -> Path: + normalized = reference.replace("\\", "/") + candidate = Path(normalized) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError("solution manifest references must be relative and contained") + repository = repository_root(root).resolve() + if candidate.parts and candidate.parts[0].casefold() == "solutions": + path = repository.joinpath(*candidate.parts) + else: + path = content_path(root, normalized) + resolved = path.resolve() + if not resolved.is_relative_to(repository): + raise ValueError("solution manifest reference escapes the repository") + current = resolved + while current != repository and repository in current.parents: + if current.is_symlink(): + raise ValueError("solution manifest reference traverses a symlink") + current = current.parent + return resolved + + +def _playbook_workflows(path: Path) -> list[str]: + try: + document = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + return [] + identities: set[str] = set() + + def visit(node: Any) -> None: + if isinstance(node, dict): + resources = node.get("resources") + if isinstance(resources, list): + for resource in resources: + if not isinstance(resource, dict): + continue + resource_type = str(resource.get("type") or "").split("@", 1)[0] + if resource_type.casefold() == "microsoft.logic/workflows": + name = resource.get("name") + if isinstance(name, str) and name.strip(): + identities.add(name.strip().casefold()) + visit(resource) + elif isinstance(resources, dict): + for key, resource in resources.items(): + if not isinstance(resource, dict): + continue + resource_type = str(resource.get("type") or key).split("@", 1)[0] + if resource_type.casefold() == "microsoft.logic/workflows": + name = resource.get("name") + if isinstance(name, str) and name.strip(): + identities.add(name.strip().casefold()) + visit(resource) + properties = node.get("properties") + if isinstance(properties, dict) and isinstance(properties.get("template"), dict): + visit(properties["template"]) + definition = node.get("definition") + if isinstance(definition, dict) and ( + isinstance(definition.get("triggers"), dict) + or isinstance(definition.get("actions"), dict) + ): + name = (node.get("metadata") or {}).get("title") or path.stem + identities.add(str(name).casefold()) + for key, value in node.items(): + if key not in {"resources", "properties", "definition"}: + visit(value) + elif isinstance(node, list): + for item in node: + visit(item) + + visit(document) + return sorted(identities) + + +def _manifest_content_inventory(root: Path) -> dict[str, Any]: + data_directory = root / "Data" + if not data_directory.is_dir(): + return { + "status": "unavailable", + "manifestPath": None, + "contentCounts": {}, + "manifestReferenceCounts": {}, + "playbookTemplateFiles": 0, + "unlistedFiles": {}, + "missingOrInvalidReferences": [], + } + try: + manifest_path = _solution_data_file(data_directory) + manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + if not isinstance(manifest, dict): + raise ValueError("solution data manifest must contain a JSON object") + except (OSError, ValueError, UnicodeError, json.JSONDecodeError) as exc: + return { + "status": "invalid", + "manifestPath": None, + "contentCounts": {}, + "manifestReferenceCounts": {}, + "playbookTemplateFiles": 0, + "unlistedFiles": {}, + "missingOrInvalidReferences": [str(exc)], + } + + references: dict[str, dict[str, Path]] = {} + missing: list[str] = [] + for manifest_key, category in _MANIFEST_CATEGORIES.items(): + value = next( + ( + items for key, items in manifest.items() + if isinstance(key, str) and key.strip().casefold() == manifest_key + ), + [], + ) + if not isinstance(value, list): + continue + category_refs = references.setdefault(category, {}) + for reference in value: + if not isinstance(reference, str) or not reference.strip(): + missing.append(f"{manifest_key}: invalid empty/non-string reference") + continue + try: + path = _resolve_manifest_reference(root, reference) + if not path.is_file(): + raise ValueError("referenced file is missing") + category_refs[path.as_posix()] = path + except (OSError, RuntimeError, ValueError) as exc: + missing.append(f"{reference}: {exc}") + + playbook_files = references.get("playbooks", {}) + workflow_items: set[tuple[str, str]] = set() + workflow_references: set[tuple[str, str]] = set() + for path_text, path in playbook_files.items(): + for workflow_name in _playbook_workflows(path): + workflow_items.add((path_text, workflow_name)) + workflow_references.add((path_text, workflow_name)) + + folder_by_category = { + "analyticRules": ("Analytic Rules", "Analytics Rules"), + "playbooks": ("Playbooks",), + "workbooks": ("Workbooks",), + "dataConnectors": ("Data Connectors",), + "other": _OTHER_CONTENT_FOLDERS, + } + folder_file_counts: dict[str, int] = {} + unlisted_files: dict[str, list[str]] = {} + for category, folders in folder_by_category.items(): + listed = set(references.get(category, {})) + discovered: set[str] = set() + for folder in folders: + directory = root / folder + if not directory.is_dir(): + continue + for path in directory.rglob("*"): + if any(part.casefold() in _SKIP_PARTS for part in path.relative_to(root).parts): + continue + if path.is_symlink(): + raise ValueError(f"deployment planning does not follow symlink content: {path}") + if path.is_file() and path.suffix.casefold() in {".yaml", ".yml", ".json"}: + discovered.add(path.resolve().as_posix()) + folder_file_counts[category] = len(discovered) + unlisted_files[category] = sorted(discovered - listed) + + manifest_counts = { + category: len(items) + for category, items in references.items() + } + content_counts = { + "analyticRules": len(references.get("analyticRules", {})), + "playbooks": len(workflow_items), + "workbooks": len(references.get("workbooks", {})), + "dataConnectors": len(references.get("dataConnectors", {})), + "other": len(references.get("other", {})), + } + return { + "status": "ready" if not missing else "partial", + "manifestPath": manifest_path.relative_to(repository_root(root)).as_posix(), + "contentCounts": content_counts, + "manifestReferenceCounts": manifest_counts, + "playbookTemplateFiles": len(playbook_files), + "configuredPlaybookTemplateCount": len(playbook_files), + "playbookWorkflows": [ + {"path": path_text, "name": workflow_name} + for path_text, workflow_name in sorted(workflow_references) + ], + "uniquePlaybookWorkflowCount": len(workflow_items), + "folderFileCounts": folder_file_counts, + "unlistedFiles": unlisted_files, + "missingOrInvalidReferences": missing, + } + + +def _source_inventory(root: Path) -> dict[str, Any]: + file_counts: dict[str, int] = {} + for key, folders in _CONTENT_FOLDERS.items(): + file_counts[key] = sum( + _count_files(root, folder, {".yaml", ".yml", ".json"}) + for folder in folders + ) + file_counts["other"] = sum( + _count_files(root, folder, {".yaml", ".yml", ".json"}) + for folder in _OTHER_CONTENT_FOLDERS + ) + rules, duplicate_ids = _source_records(root) + content_inventory = _manifest_content_inventory(root) + counts = { + **content_inventory.get("contentCounts", {}), + "analyticRules": len(rules), + "analyticRuleFiles": file_counts.get("analyticRules", 0), + "playbookFiles": file_counts.get("playbooks", 0), + "playbooks": content_inventory.get("contentCounts", {}).get("playbooks"), + "configuredPlaybookTemplateCount": content_inventory.get("configuredPlaybookTemplateCount", 0), + "uniquePlaybookWorkflowCount": content_inventory.get("uniquePlaybookWorkflowCount", 0), + "workbookFiles": file_counts.get("workbooks", 0), + "workbooks": content_inventory.get("contentCounts", {}).get("workbooks"), + "dataConnectorFiles": file_counts.get("dataConnectors", 0), + "dataConnectors": content_inventory.get("contentCounts", {}).get("dataConnectors"), + "otherFiles": file_counts.get("other", 0), + "solutionDataManifests": int(content_inventory.get("manifestPath") is not None), + "solutionMetadata": int((root / "SolutionMetadata.json").is_file()), + } + return { + "counts": counts, + "fileCounts": file_counts, + "manifestContentInventory": content_inventory, + "analyticRuleFileCount": len(analytic_rule_files(root)), + "analyticRuleCount": len(rules), + "duplicateSourceRuleCount": len(duplicate_ids), + "sourceInventorySha256": _canonical_sha256(_file_inventory(root)), + } + + +def _source_records(root: Path) -> tuple[dict[str, dict[str, Any]], set[str]]: + rules: dict[str, dict[str, Any]] = {} + duplicate_ids: set[str] = set() + for path in analytic_rule_files(root): + try: + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, yaml.YAMLError): + continue + if not isinstance(document, dict): + continue + rule_id = document.get("id") + if not isinstance(rule_id, str) or not _GUID.fullmatch(rule_id): + continue + key = rule_id.casefold() + if key in rules: + duplicate_ids.add(key) + rules[key] = { + "id": rule_id, + "path": path.relative_to(root).as_posix(), + "document": document, + } + return rules, duplicate_ids + + +def _reviewed_current_drafts( + root: Path, + rules: Mapping[str, Mapping[str, Any]], +) -> tuple[list[str], list[str], dict[str, dict[str, Any]]]: + reviewed: dict[str, str] = {} + projections: dict[str, dict[str, Any]] = {} + output_counts: dict[str, int] = {} + output = root / "XDR Detections" + if not output.is_dir(): + return [], [], {} + config = load_config(output) + configured_exclusions = config.get("excludedRuleIds") or {} + if not isinstance(configured_exclusions, dict): + raise ValueError("excludedRuleIds must contain an object of rule IDs and reasons") + exclusions = {str(value).casefold() for value in configured_exclusions} + for path in xdr_detection_files(output): + try: + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + if not isinstance(document, dict) or validate_document(document): + continue + provenance = (document.get("contentProvenance") or {}).get("source") or {} + rule_id = provenance.get("id") + if not isinstance(rule_id, str) or rule_id.casefold() not in rules: + continue + source = rules[rule_id.casefold()] + if rule_id.casefold() in exclusions: + continue + source_query = str((source.get("document") or {}).get("query") or "") + query_sha = hashlib.sha256(source_query.encode("utf-8")).hexdigest() + conversion = (document.get("contentProvenance") or {}).get("conversion") or {} + properties = document.get("properties") or {} + query_text = str((properties.get("queryCondition") or {}).get("queryText") or "") + review_scope = conversion.get("reviewScope") or {} + source_document_sha = hashlib.sha256( + yaml.safe_dump( + source["document"], + sort_keys=True, + allow_unicode=True, + ).encode("utf-8") + ).hexdigest() + if ( + provenance.get("path") != source["path"] + or provenance.get("querySha256") != query_sha + or review_scope.get("sourceDocumentSha256") != source_document_sha + or review_scope.get("targetQuerySha256") + != hashlib.sha256(query_text.encode("utf-8")).hexdigest() + ): + continue + if ( + conversion.get("status") == "converted" + and conversion.get("reviewRequired") is False + and not conversion.get("errors") + and properties.get("status") == "disabled" + ): + key = rule_id.casefold() + output_counts[key] = output_counts.get(key, 0) + 1 + try: + from .graph_readiness import graph_detection_payload_with_projection + + _, projection = graph_detection_payload_with_projection(document) + except (AttributeError, TypeError, ValueError): + continue + reviewed[key] = source["id"] + projections[source["id"]] = projection + except (OSError, UnicodeError, ValueError, yaml.YAMLError, AttributeError): + continue + unique_reviewed = [ + source_id + for key, source_id in reviewed.items() + if output_counts.get(key) == 1 + ] + unique_ids = {value.casefold() for value in unique_reviewed} + return ( + sorted(unique_reviewed), + sorted(exclusions), + { + source_id: projections[source_id] + for source_id in unique_reviewed + if source_id in projections + }, + ) + + +def _eligible_rule_ids( + root: Path, + rules: Mapping[str, Mapping[str, Any]], + duplicate_ids: set[str], + exclusions: set[str], +) -> tuple[list[str], list[str], dict[str, dict[str, Any]]]: + output = root / "XDR Detections" + config = load_config(output) + excluded = {value.casefold() for value in exclusions} + eligible: list[str] = [] + ineligible: list[str] = [] + projections: dict[str, dict[str, Any]] = {} + for key, item in sorted(rules.items()): + if key in duplicate_ids or key in excluded: + ineligible.append(item["id"]) + continue + try: + candidate = build_xdr_document( + root / item["path"], + root, + config, + ) + except (OSError, UnicodeError, ValueError, yaml.YAMLError): + ineligible.append(item["id"]) + continue + conversion = (candidate.get("contentProvenance") or {}).get("conversion") or {} + errors = conversion.get("errors") + if ( + conversion.get("status") == "converted" + and conversion.get("reviewRequired") is False + and not errors + ): + try: + from .graph_readiness import graph_detection_payload_with_projection + + _, projection = graph_detection_payload_with_projection(candidate) + except (AttributeError, TypeError, ValueError): + ineligible.append(item["id"]) + continue + eligible.append(item["id"]) + projections[item["id"]] = projection + else: + ineligible.append(item["id"]) + return eligible, ineligible, projections + + +def _eligible_analytic_rule_ids( + rules: Mapping[str, Mapping[str, Any]], + duplicate_ids: set[str], +) -> tuple[list[str], list[str]]: + eligible: list[str] = [] + ineligible: list[str] = [] + + for key, item in sorted(rules.items()): + try: + payload = analytic_rule_payload(item["document"]) + if ( + key in duplicate_ids + or payload.get("kind") != "Scheduled" + or (payload.get("properties") or {}).get("enabled") is not False + ): + raise ValueError("source AR is duplicate or not deployable as a disabled Scheduled rule") + except (KeyError, TypeError, ValueError): + ineligible.append(item["id"]) + else: + eligible.append(item["id"]) + return eligible, ineligible + + +def _scan_package_resources(template: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + records: dict[tuple[str, str], dict[str, Any]] = {} + imports: list[dict[str, Any]] = [] + + def scan_document(node: Any) -> None: + if isinstance(node, dict): + import_record = node.get("imports") + if isinstance(import_record, list): + for item in import_record: + if isinstance(item, dict): + imports.append( + { + key: item[key] + for key in ("provider", "version", "name") + if isinstance(item.get(key), (str, int, float)) + } + ) + if isinstance(node.get("resources"), (list, dict)): + scan_collection(node["resources"]) + for key, value in node.items(): + if key != "resources": + scan_document(value) + elif isinstance(node, list): + for value in node: + scan_document(value) + + def scan_resource(resource: Any, form: str) -> None: + if not isinstance(resource, dict): + return + raw_type = resource.get("type") + if isinstance(raw_type, str): + resource_type, separator, embedded_version = raw_type.partition("@") + if "/" in resource_type: + api_version = resource.get("apiVersion") + if not isinstance(api_version, str) or not api_version: + api_version = embedded_version if separator else "" + key = (resource_type, api_version) + item = records.setdefault( + key, + { + "type": resource_type, + "apiVersion": api_version, + "typeAtVersion": ( + f"{resource_type}@{api_version}" if api_version else resource_type + ), + "count": 0, + "sourceForms": set(), + "extensionResource": False, + "scopeModel": "resource-scope-not-declared", + }, + ) + item["count"] += 1 + item["sourceForms"].add(form) + if "scope" in resource or "scopeExpression" in resource: + item["extensionResource"] = True + item["scopeModel"] = "generic-arm-extension-resource-scope" + nested = resource.get("resources") + if isinstance(nested, list): + for child in nested: + scan_resource(child, "nested ARM resources array") + elif isinstance(nested, dict): + scan_collection(nested) + properties = resource.get("properties") + if isinstance(properties, dict) and isinstance(properties.get("template"), dict): + scan_document(properties["template"]) + + def scan_collection(resources: Any) -> None: + if isinstance(resources, list): + for resource in resources: + scan_resource(resource, "ARM resources array") + elif isinstance(resources, dict): + if isinstance(resources.get("type"), str): + scan_resource(resources, "ARM resource object") + else: + for key, resource in resources.items(): + if isinstance(resource, dict): + if not resource.get("type") and "/" in str(key): + resource = {**resource, "type": key} + scan_resource(resource, "languageVersion 2 resource dictionary") + + scan_document(template) + inventory = [ + { + **record, + "sourceForms": sorted(record["sourceForms"]), + } + for _, record in sorted(records.items()) + ] + return inventory, imports + + +def _current_package(root: Path, state: Mapping[str, Any]) -> dict[str, Any]: + current_input_sha256 = None + try: + current_input_sha256 = package_input_identity(root).get("sha256") + except (OSError, ValueError, TypeError): + pass + live_artifact_hashes: dict[str, str] = {} + package_file_inventory: list[dict[str, str]] = [] + package_folder = root / "Package" + if package_folder.is_dir() and not package_folder.is_symlink(): + for path in sorted(package_folder.rglob("*"), key=lambda item: item.as_posix()): + if path.is_symlink(): + raise ValueError(f"deployment planning does not follow symlink content: {path}") + if path.is_file(): + package_file_inventory.append( + { + "path": path.relative_to(package_folder).as_posix(), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + matches = ( + [package_folder / "mainTemplate.json"] + if name == "mainTemplate" + else [package_folder / "createUiDefinition.json"] + if name == "createUiDefinition" + else [package_folder / "testParameters.json"] + if name == "testParameters" + else sorted(package_folder.glob("*.zip")) + ) + if len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(): + live_artifact_hashes[name] = hashlib.sha256(matches[0].read_bytes()).hexdigest() + packaging = state.get("packaging") + successful = packaging.get("lastSuccessful") if isinstance(packaging, dict) else None + if not isinstance(successful, dict) or successful.get("packageSucceeded") is not True: + return { + "status": "provisional", + "version": None, + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": {}, + "deploymentParameters": {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + packaging_stage = (state.get("stages") or {}).get("packaging") or {} + stage_artifacts = packaging_stage.get("artifacts") or {} + package_hash = successful.get("packageArtifactsSha256") + report_ref = successful.get("packageReport") + if ( + packaging_stage.get("status") != "passed" + or not isinstance(report_ref, str) + or stage_artifacts.get("packageAttemptId") != successful.get("attemptId") + or not isinstance(stage_artifacts.get("packageReport"), str) + ): + return { + "status": "stale", + "version": successful.get("version"), + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": {}, + "deploymentParameters": {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + try: + report_path = resolve_artifact_reference(root, report_ref) + selected_report_path = resolve_artifact_reference( + root, stage_artifacts["packageReport"] + ) + if selected_report_path.resolve() != report_path.resolve(): + return { + "status": "stale", + "version": successful.get("version"), + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": {}, + "deploymentParameters": {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + report_hash = hashlib.sha256(report_path.read_bytes()).hexdigest() + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + if ( + not isinstance(report, dict) + or report.get("packager") != "V3.1" + or report.get("packageAttemptId") != successful.get("attemptId") + or report.get("packageArtifactsSha256") != package_hash + or report_hash != successful.get("packageReportSha256") + or report_hash != stage_artifacts.get("packageReportSha256") + or current_input_sha256 != successful.get("packageInputSha256") + or current_input_sha256 + != ((report.get("packageInputs") or {}).get("after") or {}).get("sha256") + ): + return { + "status": "stale", + "version": report.get("version") if isinstance(report, dict) else None, + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": report.get("packageArtifactHashes", {}) if isinstance(report, dict) else {}, + "deploymentParameters": report.get("deploymentParameters", {}) if isinstance(report, dict) else {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + artifact_hashes = report.get("packageArtifactHashes") + if not isinstance(artifact_hashes, dict): + return {"status": "stale", "packageInputSha256": current_input_sha256, "packageArtifactHashes": live_artifact_hashes, "packageContentSha256": _canonical_sha256(package_file_inventory)} + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + path_value = report.get(name) + if not isinstance(path_value, str): + return {"status": "stale", "packageInputSha256": current_input_sha256, "packageArtifactHashes": live_artifact_hashes, "packageContentSha256": _canonical_sha256(package_file_inventory)} + path = resolve_artifact_reference(root, path_value) + if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != artifact_hashes.get(name): + return { + "status": "stale", + "version": report.get("version"), + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": artifact_hashes, + "deploymentParameters": report.get("deploymentParameters") or {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + template_path = resolve_artifact_reference(root, report["mainTemplate"]) + template = json.loads(template_path.read_text(encoding="utf-8-sig")) + inventory, imports = _scan_package_resources(template) + return { + "status": "current", + "version": report.get("version"), + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": artifact_hashes, + "deploymentParameters": report.get("deploymentParameters") or {}, + "resourceInventory": inventory, + "bicepExtensibilityImports": imports, + "whatIfSupport": ( + "unsupported-bicep-extensibility-imports" + if imports + else "not-required-for-planning" + ), + } + except (OSError, UnicodeError, ValueError, TypeError, json.JSONDecodeError): + return { + "status": "stale", + "packageInputSha256": current_input_sha256, + "packageArtifactHashes": live_artifact_hashes, + "packageContentSha256": _canonical_sha256(package_file_inventory), + "recordedPackageArtifactHashes": {}, + "deploymentParameters": {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + + +def _read_existing_dcr_preflight(root: Path, target: Mapping[str, Any]) -> dict[str, Any]: + try: + path = existing_artifact_path(root, "qualification-dcr-preflight.json") + report = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, ValueError, json.JSONDecodeError): + return {"status": "not-recorded", "contracts": []} + expected_target = { + key: target[key] + for key in ("tenantId", "subscriptionId", "workspaceResourceId", "workspaceCustomerId") + } + target_fingerprint = _canonical_sha256(expected_target) + if ( + not isinstance(report, dict) + or report.get("targetFingerprint") != target_fingerprint + or (report.get("target") or {}).get("locked") is not True + ): + return {"status": "stale", "contracts": []} + contracts = report.get("contracts") + return { + "status": report.get("status", "unknown"), + "targetFingerprint": report.get("targetFingerprint"), + "plan": report.get("plan") or {}, + "contracts": contracts if isinstance(contracts, list) else [], + "limitations": report.get("limitations") or [], + } + + +def _mock_ingestion_plan(root: Path, state: Mapping[str, Any]) -> dict[str, Any]: + stage = (state.get("stages") or {}).get("mockIngestion") or {} + artifacts = stage.get("artifacts") or {} + selected: dict[str, Any] | None = None + for name in ("mockIngestionPlan", "ingestionPlan"): + reference = artifacts.get(name) + if isinstance(reference, str): + try: + path = resolve_artifact_reference(root, reference) + selected = json.loads(path.read_text(encoding="utf-8-sig")) + break + except (OSError, ValueError, json.JSONDecodeError): + continue + required = { + "destinationKind", + "destinationTable", + "destinationResourceId", + "inputStream", + "outputStream", + "payloadPath", + "payloadSha256", + "recordCount", + "batchCount", + "maxBatchRecordCount", + "estimatedBytes", + "estimatedCost", + "retentionImpact", + "contractFingerprint", + "transformFingerprint", + "schemaFingerprint", + "markerField", + "timestampFields", + "pairRuleIds", + "sharedOncePerPair", + } + if not isinstance(selected, dict) or not required <= set(selected): + return { + "status": "provisional", + "reason": "exact reviewed ingestion plan and payload evidence are not recorded", + "payloads": [], + "benignControlRecordCount": None, + } + if ( + not _SHA256.fullmatch(str(selected.get("payloadSha256") or "")) + or any( + not _SHA256.fullmatch(str(selected.get(key) or "")) + for key in ("contractFingerprint", "transformFingerprint", "schemaFingerprint") + ) + or type(selected.get("recordCount")) is not int + or selected["recordCount"] < 1 + or type(selected.get("batchCount")) is not int + or selected["batchCount"] < 1 + or type(selected.get("maxBatchRecordCount")) is not int + or selected["maxBatchRecordCount"] < 1 + or type(selected.get("estimatedBytes")) is not int + or selected["estimatedBytes"] < 0 + or not isinstance(selected.get("estimatedCost"), dict) + or not isinstance(selected.get("retentionImpact"), (str, dict)) + or not isinstance(selected.get("markerField"), str) + or not selected["markerField"] + or selected.get("destinationKind") not in {"standard", "custom"} + or not isinstance(selected.get("timestampFields"), list) + or not selected["timestampFields"] + or not isinstance(selected.get("pairRuleIds"), list) + or not selected.get("pairRuleIds") + or selected.get("sharedOncePerPair") is not True + or type(selected.get("benignControlRecordCount")) is not int + or selected["benignControlRecordCount"] < 0 + ): + return { + "status": "provisional", + "reason": "ingestion plan evidence is incomplete or malformed", + "payloads": [], + "benignControlRecordCount": None, + } + try: + payload = resolve_artifact_reference(root, selected["payloadPath"]) + selected_run = report_directory(root).resolve(strict=True) + payload.resolve(strict=True).relative_to(selected_run) + payload_bytes = payload.read_bytes() + records = json.loads(payload_bytes.decode("utf-8-sig")) + except (OSError, ValueError, UnicodeError, json.JSONDecodeError): + return { + "status": "provisional", + "reason": "payload is missing or is not confined to the selected run", + "payloads": [], + "benignControlRecordCount": None, + } + if ( + hashlib.sha256(payload_bytes).hexdigest() != selected["payloadSha256"] + or not isinstance(records, list) + or len(records) != selected["recordCount"] + or any(not isinstance(item, dict) for item in records) + ): + return { + "status": "provisional", + "reason": "payload hash or record count does not match the reviewed ingestion plan", + "payloads": [], + "benignControlRecordCount": None, + } + return { + "status": "ready", + "payloads": [ + { + key: selected[key] + for key in sorted(required) + } + ], + "benignControlRecordCount": selected.get("benignControlRecordCount"), + "estimatedBytes": selected.get("estimatedBytes"), + "retentionImpact": selected.get("retentionImpact"), + "sharedOncePerPair": selected.get("sharedOncePerPair") is True, + } + + +def _components( + root: Path, + *, + source_ids: list[str], + sentinel_rule_ids: list[str], + pair_rule_ids: list[str], + additional_rule_ids: list[str], + graph_projections: Mapping[str, Mapping[str, Any]], + package: Mapping[str, Any], + dcr: Mapping[str, Any], + ingestion: Mapping[str, Any], +) -> dict[str, Any]: + contracts = dcr.get("contracts") if isinstance(dcr.get("contracts"), list) else [] + compatible = any( + (item.get("existingCandidateCompatibility") or {}).get("status") == "compatible-existing" + and (item.get("tableSchemaCompatibility") or {}).get("status") == "compatible" + for item in contracts + if isinstance(item, dict) + ) + dcr_determination = bool(contracts) and all( + isinstance(item, dict) + and ( + (item.get("existingCandidateCompatibility") or {}).get("status") + == "compatible-existing" + or item.get("candidateState") in { + "planned-missing-resources", + "blocked-destination", + "inspection-denied", + "inspection-unknown", + "partial-resource-pair", + } + ) + for item in contracts + ) + fallback_dcr_confirmed = ( + bool(contracts) + and dcr_determination + and not compatible + and all( + item.get("candidateState") == "planned-missing-resources" + or ( + item.get("candidateState") == "blocked-destination" + and (item.get("existingCandidateCompatibility") or {}).get("reason") + ) + for item in contracts + if isinstance(item, dict) + ) + ) + custom_missing = [] + custom_incompatible = [] + known_destinations: list[dict[str, Any]] = [] + resource_candidates: list[dict[str, Any]] = [] + for item in contracts: + if not isinstance(item, dict) or not item.get("schemaFingerprint"): + continue + resources = item.get("resources") or {} + table = resources.get("destinationTable") or {} + dcr_resource = resources.get("dcr") or {} + dce_resource = resources.get("dce") or {} + resource_candidates.append({ + "contractPath": item.get("contractPath"), + "contractFingerprint": item.get("contractFingerprint"), + "resourceFingerprint": item.get("resourceFingerprint"), + "candidateState": item.get("candidateState", "unknown"), + "dcr": { + "resourceId": dcr_resource.get("id"), + "state": dcr_resource.get("state", "unknown"), + }, + "dce": { + "resourceId": dce_resource.get("id"), + "state": dce_resource.get("state", "unknown"), + }, + }) + contract_path = item.get("contractPath") + try: + contract = repository_root(root) / "Tools" / "AzureMonitorLogsIngestion" / str(contract_path) + contract_document = yaml.safe_load(contract.read_text(encoding="utf-8-sig")) or {} + except (OSError, ValueError, yaml.YAMLError): + continue + destination = contract_document.get("destination") or {} + if destination.get("kind") not in {"custom", "standard"}: + continue + destination_record = { + "table": item.get("table"), + "tableResourceId": table.get("id"), + "destinationKind": destination.get("kind"), + "contractPath": contract_path, + "schemaFingerprint": item.get("schemaFingerprint"), + "transformFingerprint": item.get("transformFingerprint"), + "contractFingerprint": item.get("contractFingerprint"), + "columnCount": len(destination.get("columns") or []), + "state": table.get("state", "unknown"), + "schemaCompatibility": (item.get("tableSchemaCompatibility") or {}).get("status", "unknown"), + } + known_destinations.append(destination_record) + if destination.get("kind") == "custom" and table.get("state") == "missing": + custom_missing.append(destination_record) + elif ( + destination.get("kind") == "custom" + and table.get("state") == "present" + and destination_record["schemaCompatibility"] != "compatible" + ): + custom_incompatible.append(destination_record) + package_current = package.get("status") == "current" + flags = package.get("deploymentParameters") + return { + "sentinelAlertRules": { + "status": "proposed", + "resourceTypes": ["Microsoft.SecurityInsights/alertRules"], + "disabled": True, + "maximumCount": len(sentinel_rule_ids), + "sourceRuleIds": sentinel_rule_ids, + "eligiblePairs": pair_rule_ids, + }, + "xdrCustomDetections": { + "status": "proposed", + "resourceTypes": ["Microsoft.Security/detectionRules"], + "disabled": True, + "maximumCount": len(source_ids), + "maximumAdditionalCount": len(additional_rule_ids), + "sourceRuleIds": source_ids, + "currentlyReviewedDraftIds": sorted( + set(source_ids) - set(additional_rule_ids) + ), + "maximumTotalIncludesCurrentReviewedDrafts": True, + "eligiblePairs": pair_rule_ids, + "graphProjectionByRule": [ + { + "sourceRuleId": rule_id, + "status": projection.get("status"), + "projectedTactics": projection.get("projectedTactics") or [], + "omittedTactics": projection.get("omittedTactics") or [], + "projectedTechniques": projection.get("projectedTechniques") or [], + "omittedTechniques": projection.get("omittedTechniques") or [], + "classificationParity": projection.get("classificationParity", "not-established"), + } + for rule_id, projection in sorted(graph_projections.items()) + if rule_id in source_ids + ], + }, + "controlledAlertParity": { + "status": "provisional" if pair_rule_ids else "unavailable", + "advanceConsentSupported": False, + "reason": ( + "the scope planner does not bind a finite parity test window or " + "a complete batch fixture plan" + ), + "operation": "temporarily-enable-reviewed-pairs-then-restore-disabled", + "resourceTypes": [ + "Microsoft.SecurityInsights/alertRules", + "Microsoft.Security/detectionRules", + ], + "eligiblePairRuleIds": pair_rule_ids, + "maximumRulePairCount": len(pair_rule_ids), + "maximumEnableWriteRequests": 2 * len(pair_rule_ids), + "maximumRestoreWriteRequests": 2 * len(pair_rule_ids), + "maximumWriteRequestsForOneBoundedRun": 4 * len(pair_rule_ids), + "timeWindow": {"status": "not-recorded", "startsAt": None, "endsAt": None}, + "restoreDisabledOnCompletionOrAbort": True, + "repeatedRunsRequireNewExactScope": True, + }, + "fullPackageAcceptance": { + "status": "available" if package_current else "provisional", + "resourceInventory": package.get("resourceInventory", []), + "resourceTypes": sorted({ + item.get("typeAtVersion", item.get("type")) + for item in package.get("resourceInventory", []) + if isinstance(item, dict) and isinstance(item.get("type"), str) + }), + "availableFlags": flags if isinstance(flags, dict) else {}, + "packageInstallationAndXdrRegistration": True, + "nonRuleStoredContentAutoApproval": False, + }, + "mockTelemetryIngestion": { + "status": ingestion.get("status", "provisional"), + "plan": ingestion, + "purpose": "ingest reviewed local mock telemetry once per AR/CD pair", + "benignControlsAreNotIngested": True, + }, + "fallbackDcr": { + "status": "conditional", + "condition": "no proven compatible existing solution DCR", + "resourceTypes": ["Microsoft.Insights/dataCollectionRules"], + "maximumCount": 1, + "scope": { + "subscriptionId": (package.get("target") or {}).get("subscriptionId"), + "resourceGroup": (package.get("target") or {}).get("resourceGroup"), + }, + "compatibleExistingDcrProven": compatible, + "absenceOfCompatibleDcrProven": fallback_dcr_confirmed, + "resourceCandidates": resource_candidates, + "absenceEvidence": ( + "registered-candidate-check" + if fallback_dcr_confirmed + else "unknown; resolver does not discover arbitrary solution-owned DCRs" + ), + "requiresFreshExactReadinessBeforeProvisioning": True, + }, + "fallbackDce": { + "status": "conditional", + "condition": "DCE selected or required by the chosen ingestion route", + "resourceTypes": ["Microsoft.Insights/dataCollectionEndpoints"], + "maximumCount": 1, + "resourceCandidates": [ + { + key: candidate.get(key) + for key in ("contractPath", "contractFingerprint", "resourceFingerprint", "dce") + } + for candidate in resource_candidates + ], + "scope": { + "subscriptionId": (package.get("target") or {}).get("subscriptionId"), + "resourceGroup": (package.get("target") or {}).get("resourceGroup"), + }, + "requiresExplicitSelection": True, + }, + "customTableCreate": { + "status": ( + "conditional" + if custom_missing + else "blocked-existing-incompatible" + if custom_incompatible + else "not-needed" + if known_destinations and all( + item["destinationKind"] == "standard" or item["state"] == "present" + for item in known_destinations + ) + else "provisional" + ), + "condition": "custom-log destination is contract-proven absent", + "resourceTypes": ["Microsoft.OperationalInsights/workspaces/tables"], + "tables": custom_missing, + "knownDestinations": known_destinations, + "incompatibleExistingTables": custom_incompatible, + "existingIncompatibleTableMayBeAltered": False, + "nativeCommonSecurityLogLookalikeAllowed": False, + }, + "ownedResourceCleanup": { + "status": "unsupported", + "advanceConsentSupported": False, + "reason": ( + "no exact run-owned cleanup inventory or deletion executor is " + "implemented by the current scope planner" + ), + "resourceTypes": [], + "resources": [], + "maximumDeleteCount": 0, + "ownershipEvidence": "not-recorded", + "existingOrUnownedResourcesMayBeDeleted": False, + }, + } + + +def _consent_state( + root: Path, + plan_sha256: str, + *, + components: Mapping[str, Any], + package: Mapping[str, Any], + target: Mapping[str, Any], +) -> dict[str, Any]: + try: + path = existing_artifact_path(root, CONSENT_FILE_NAME) + record = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, ValueError, json.JSONDecodeError): + return {"status": "not-recorded", "approvedComponents": []} + if not isinstance(record, dict) or record.get("schemaVersion") != PLAN_SCHEMA_VERSION: + return {"status": "unknown", "approvedComponents": []} + approved = record.get("approvedComponents") + if record.get("planSha256") != plan_sha256: + return { + "status": "stale", + "approvedComponents": [], + "previousPlanSha256": record.get("planSha256"), + } + if ( + record.get("status") != "approved" + or not isinstance(approved, list) + or not approved + or any(name not in COMPONENTS for name in approved) + ): + return { + "status": "declined" if record.get("status") == "declined" else "not-approved", + "approvedComponents": [], + "actor": record.get("actor"), + "approvedAt": record.get("approvedAt"), + "reason": record.get("reason"), + } + if ( + not isinstance(record.get("actor"), str) + or not record["actor"].strip() + or not isinstance(record.get("approvedAt"), str) + or (record.get("scope") or {}).get("planSha256") != plan_sha256 + ): + return {"status": "unknown", "approvedComponents": []} + fingerprinted_record = { + key: value for key, value in record.items() if key != "approvalSha256" + } + if record.get("approvalSha256") != _canonical_sha256(fingerprinted_record): + return {"status": "unknown", "approvedComponents": []} + if len(set(approved)) != len(approved): + return {"status": "unknown", "approvedComponents": []} + has_pair_component = bool( + {"sentinelAlertRules", "xdrCustomDetections"} & set(approved) + ) + pair_components = {"sentinelAlertRules", "xdrCustomDetections"} & set(approved) + if pair_components and pair_components != {"sentinelAlertRules", "xdrCustomDetections"}: + return {"status": "unknown", "approvedComponents": []} + cap = record.get("maxAdditionalCustomDetections") + eligible_max = ( + components.get("xdrCustomDetections", {}).get("maximumAdditionalCount", 0) + if isinstance(components.get("xdrCustomDetections"), dict) + else 0 + ) + if has_pair_component: + if type(cap) is not int or cap < 0 or cap > eligible_max: + return {"status": "unknown", "approvedComponents": []} + elif cap is not None: + return {"status": "unknown", "approvedComponents": []} + conditional = record.get("conditionalSelections") + if not isinstance(conditional, dict): + return {"status": "unknown", "approvedComponents": []} + if "fallbackDce" in approved and ( + "fallbackDcr" not in approved + or conditional.get("dceSelectedOrRequired") is not True + ): + return {"status": "unknown", "approvedComponents": []} + if "fallbackDcr" in approved and ( + components.get("fallbackDcr", {}).get("status") != "conditional" + or components.get("fallbackDcr", {}).get("compatibleExistingDcrProven") is True + ): + return {"status": "stale", "approvedComponents": []} + if "fullPackageAcceptance" in approved: + expected_flags = ( + components.get("fullPackageAcceptance", {}).get("availableFlags") or {} + ) + package_flags = record.get("packageFlags") + if ( + package.get("status") != "current" + or not isinstance(package_flags, dict) + or set(package_flags) != set(expected_flags) + or any(type(value) is not bool for value in package_flags.values()) + ): + return {"status": "stale", "approvedComponents": []} + elif record.get("packageFlags") != {}: + return {"status": "unknown", "approvedComponents": []} + recorded_scope = record.get("scope") + expected_resource_types = sorted({ + item + for name in approved + for item in (components.get(name, {}).get("resourceTypes") or []) + }) + scope_resource_types = recorded_scope.get("resourceTypes") if isinstance(recorded_scope, dict) else None + if ( + not isinstance(recorded_scope, dict) + or recorded_scope.get("target") != target + or not isinstance(scope_resource_types, list) + or any(not isinstance(item, str) for item in scope_resource_types) + or sorted(scope_resource_types) != expected_resource_types + ): + return {"status": "unknown", "approvedComponents": []} + expected_package_inventory = ( + package.get("resourceInventory") or [] + if "fullPackageAcceptance" in approved + else [] + ) + if recorded_scope.get("packageResourceInventory") != expected_package_inventory: + return {"status": "unknown", "approvedComponents": []} + scoped = [] + for name in approved: + component = components.get(name) + if not isinstance(component, dict): + continue + if name == "fullPackageAcceptance" and package.get("status") != "current": + continue + scoped.append(name) + return { + "status": "approved" if scoped and scoped == approved else "stale", + "executionMode": ( + "both" + if {"sentinelAlertRules", "xdrCustomDetections"} & set(scoped) + and "fullPackageAcceptance" in scoped + else "pairs" + if {"sentinelAlertRules", "xdrCustomDetections"} & set(scoped) + else "package" + if "fullPackageAcceptance" in scoped + else "not-selected" + ), + "approvedComponents": scoped, + "approvedAt": record.get("approvedAt"), + "actor": record.get("actor"), + "planSha256": plan_sha256, + "maxAdditionalCustomDetections": record.get("maxAdditionalCustomDetections"), + "packageFlags": record.get("packageFlags") or {}, + "conditionalSelections": record.get("conditionalSelections") or {}, + "resourceTypes": (record.get("scope") or {}).get("resourceTypes") or [], + } + + +def build_deployment_plan( + solution: str | Path, + *, + target: Mapping[str, Any] | None = None, + dcr_preflight: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + state_path = existing_artifact_path(root, "workflow-state.json") + if not state_path.is_file(): + return { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": "unavailable", + "selectedProfile": "unknown", + "planningConsent": {"status": "not-recorded", "approvedComponents": []}, + "exactScopeWriteApproval": "not-recorded", + "reason": "persisted Qualification workflow state is unavailable", + } + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + context = state.get("context") or {} + selected_profile = ( + context.get("workflowProfile") + if context.get("profileSelectionConfirmed") is True + else "unknown" + ) + if selected_profile != "qualification": + return { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": "not-applicable", + "selectedProfile": selected_profile, + "planningConsent": {"status": "not-recorded", "approvedComponents": []}, + "exactScopeWriteApproval": "not-recorded", + "reason": "advance deployment planning is Qualification-only", + } + locked = target or require_locked_target(root, materialize_missing=False) + workspace = workspace_identity(locked["workspaceResourceId"]) + source = _source_inventory(root) + rules, duplicates = _source_records(root) + reviewed_drafts, excluded_ids, reviewed_projections = _reviewed_current_drafts(root, rules) + eligible_ids, ineligible_ids, eligible_projections = _eligible_rule_ids( + root, rules, duplicates, set(excluded_ids) + ) + sentinel_ids, sentinel_ineligible_ids = _eligible_analytic_rule_ids(rules, duplicates) + from .packaging_selection import ( + _partial_coverage_allowed, + verify_applied_package_selection, + ) + + package_selection = verify_applied_package_selection( + root, str(state.get("runId") or ""), + ) + package_stage = (state.get("stages") or {}).get("packaging") or {} + package_stage_selection = (package_stage.get("artifacts") or {}).get( + "selectionOutcome" + ) + if package_stage_selection == "partial-package-validated" and not package_selection: + raise ValueError("partial Qualification requires its current applied package selection") + qualification_scope = None + if package_selection is not None: + selection_plan = package_selection["plan"] + selection_summary = selection_plan.get("summary") or {} + if ( + selection_summary.get("outcome") == "partial" + and not _partial_coverage_allowed(selection_summary) + ): + raise ValueError("current package selection no longer meets migrated-detection coverage") + selected_source_paths = { + str(value).replace("\\", "/").casefold() + for value in (selection_plan.get("selection") or {}).get( + "analyticRules", [] + ) + } + selected_source_ids = { + str(item.get("id") or "").casefold() + for item in selection_plan.get("analyticRules") or [] + if item.get("included") is True + } + selected_detection_ids = { + str(item.get("sourceId") or "").casefold() + for item in selection_plan.get("xdrDetections") or [] + if item.get("included") is True + } + selected_source_id_by_path = { + str(item.get("path") or "").replace("\\", "/").casefold(): + str(item.get("id") or "").casefold() + for item in selection_plan.get("analyticRules") or [] + if item.get("included") is True + } + if selected_source_ids != { + value for value in selected_source_id_by_path.values() if value + }: + raise ValueError("applied package selection source IDs do not match selected AR paths") + reviewed_drafts = [ + item for item in reviewed_drafts if item.casefold() in selected_detection_ids + ] + eligible_ids = [ + item for item in eligible_ids if item.casefold() in selected_detection_ids + ] + eligible_projections = { + key: value for key, value in eligible_projections.items() + if key.casefold() in selected_detection_ids + } + reviewed_projections = { + key: value for key, value in reviewed_projections.items() + if key.casefold() in selected_detection_ids + } + sentinel_ids = [ + item for item in sentinel_ids if item.casefold() in selected_source_ids + ] + sentinel_ineligible_ids = [ + item for item in sentinel_ineligible_ids + if item.casefold() in selected_source_ids + ] + ineligible_ids = [ + item for item in ineligible_ids + if item.casefold() in selected_detection_ids + ] + qualification_scope = { + "kind": "applied-package-selection", + "planSha256": selection_plan.get("planSha256"), + "outcome": selection_summary.get("outcome"), + "coverage": selection_summary, + "selectedAnalyticRulePaths": sorted(selected_source_paths), + "excludedAnalyticRules": [ + { + "id": item.get("id"), + "path": item.get("path"), + "reasons": item.get("reasons") or [], + } + for item in selection_plan.get("analyticRules") or [] + if item.get("included") is not True + ], + "excludedDetections": [ + { + "id": item.get("id"), + "sourceId": item.get("sourceId"), + "path": item.get("path"), + "reasons": item.get("reasons") or [], + } + for item in selection_plan.get("xdrDetections") or [] + if item.get("included") is not True + ], + } + current_ids = {item.casefold() for item in reviewed_drafts} + additional_ids = [ + item for item in eligible_ids if item.casefold() not in current_ids + ] + all_graph_ids_by_key = { + item.casefold(): item for item in [*reviewed_drafts, *eligible_ids] + } + graph_scope_ids = sorted(all_graph_ids_by_key.values(), key=str.casefold) + sentinel_ids_by_key = {item.casefold(): item for item in sentinel_ids} + pair_ids = sorted( + ( + sentinel_ids_by_key[key] + for key in all_graph_ids_by_key.keys() & sentinel_ids_by_key.keys() + ), + key=str.casefold, + ) + pair_additional_ids = [ + item for item in additional_ids if item.casefold() in sentinel_ids_by_key + ] + graph_projections = {**eligible_projections, **reviewed_projections} + package = _current_package(root, state) + package["target"] = { + "subscriptionId": locked["subscriptionId"], + "resourceGroup": workspace["resourceGroup"], + } + dcr_report = ( + dict(dcr_preflight) + if isinstance(dcr_preflight, Mapping) + else _read_existing_dcr_preflight(root, locked) + ) + ingestion = _mock_ingestion_plan(root, state) + component_details = _components( + root, + source_ids=graph_scope_ids, + sentinel_rule_ids=sentinel_ids, + pair_rule_ids=pair_ids, + additional_rule_ids=additional_ids, + graph_projections=graph_projections, + package=package, + dcr=dcr_report, + ingestion=ingestion, + ) + source_name = root.name + try: + solution_version = package.get("version") or solution_data_version(root) + except (OSError, ValueError): + solution_version = package.get("version") + plan = { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": ( + "provisional" + if ( + package.get("status") != "current" + or ingestion.get("status") != "ready" + or source.get("manifestContentInventory", {}).get("status") != "ready" + ) + else "current" + ), + "selectedProfile": selected_profile, + "qualificationScope": qualification_scope, + "executionMode": "not-selected", + "target": { + "tenantId": locked["tenantId"], + "subscriptionId": locked["subscriptionId"], + "resourceGroup": workspace["resourceGroup"], + "workspaceResourceId": locked["workspaceResourceId"], + "workspaceCustomerId": locked["workspaceCustomerId"], + "solution": source_name, + "version": solution_version, + }, + "sourceInventory": source["counts"], + "sourceFileCounts": source.get("fileCounts", {}), + "manifestContentInventory": source.get("manifestContentInventory", {}), + "sourceInventorySha256": source["sourceInventorySha256"], + "detectionScope": { + "sourceAnalyticRuleCount": source["analyticRuleCount"], + "currentReviewedDraftCount": len(reviewed_drafts), + "currentReviewedDraftIds": reviewed_drafts, + "eligibleSentinelAlertRuleIds": sentinel_ids, + "ineligibleSentinelAlertRuleIds": sentinel_ineligible_ids, + "eligibleMaximumAdditionalCustomDetections": len(additional_ids), + "eligibleAdditionalRuleIds": additional_ids, + "eligibleMaximumTotalCustomDetections": len(graph_scope_ids), + "eligibleMaximumAdditionalDisabledRulePairs": len(pair_additional_ids), + "eligibleAdditionalPairRuleIds": pair_additional_ids, + "eligibleMaximumTotalDisabledRulePairs": len(pair_ids), + "eligiblePairRuleIds": pair_ids, + "maximumTotalDisabledRulePairs": len(pair_ids), + "maximumTotalCustomDetectionsIncludesCurrentDrafts": True, + "excludedRuleIds": excluded_ids, + "ineligibleRuleIds": [ + rule_id + for rule_id in ineligible_ids + if rule_id.casefold() not in current_ids + ], + "ineligibleNewConversionCandidateRuleIds": ineligible_ids, + "currentReviewedDraftsNotEligibleAsNewConversionCandidates": [ + rule_id + for rule_id in reviewed_drafts + if rule_id.casefold() in {item.casefold() for item in ineligible_ids} + ], + "eligibilityNote": ( + "Current reviewed drafts are evaluated from their persisted reviewed " + "disabled Graph payloads and explicit projections. New-conversion " + "candidate failures do not invalidate a matching current reviewed draft." + ), + "duplicateSourceRuleIds": sorted( + rules[item]["id"] for item in duplicates if item in rules + ), + }, + "scopeCaps": { + "maxAdditionalCustomDetections": len(additional_ids), + "maxDcrCount": 1, + "maxDceCount": 1, + "maxCustomTableCount": len(component_details["customTableCreate"]["tables"]), + }, + "packageInputSha256": package.get("packageInputSha256"), + "packageArtifactHashes": package.get("packageArtifactHashes") or {}, + "packageContentSha256": package.get("packageContentSha256"), + "recordedPackageArtifactHashes": package.get("recordedPackageArtifactHashes") or {}, + "packageIdentity": { + "status": package.get("status", "unavailable"), + "version": package.get("version"), + }, + "packageResourceInventory": package.get("resourceInventory") or [], + "bicepExtensibilityImports": package.get("bicepExtensibilityImports") or [], + "whatIfSupport": package.get("whatIfSupport", "not-evaluated"), + "components": component_details, + "packageAcceptancePlan": { + "status": "proposed-not-approved" if package.get("status") == "current" else "provisional", + "executionStatus": "not-started", + "resourceTypes": sorted({ + item["typeAtVersion"] + for item in package.get("resourceInventory") or [] + if isinstance(item, dict) and isinstance(item.get("typeAtVersion"), str) + }), + "identity": { + "status": package.get("status"), + "packageInputSha256": package.get("packageInputSha256"), + "packageArtifactHashes": package.get("packageArtifactHashes") or {}, + "version": package.get("version"), + }, + }, + "dcrEvidence": { + "targetFingerprint": dcr_report.get("targetFingerprint"), + "plan": { + key: (dcr_report.get("plan") or {}).get(key) + for key in ("status", "inventorySha256", "targetSha256") + if key in (dcr_report.get("plan") or {}) + }, + "contracts": [ + { + key: item[key] + for key in ( + "table", + "contractPath", + "schemaFingerprint", + "transformFingerprint", + "contractFingerprint", + "resourceFingerprint", + "candidateSelection", + "candidateState", + "resourceFingerprint", + ) + if key in item + } + for item in (dcr_report.get("contracts") or []) + if isinstance(item, dict) + ], + "resourceCandidates": component_details["fallbackDcr"]["resourceCandidates"], + }, + "dcrResolver": { + "kind": "registered-exact-resource-name-only", + "arbitrarySolutionOwnedDcrDiscovery": False, + "limitations": dcr_report.get("limitations") or [ + "Only registered contract-specific resource candidates are inspected; arbitrary solution-listed DCR discovery is not implemented." + ], + }, + "separateApprovals": { + "planningConsent": "not-recorded", + "exactScopeWriteApproval": "required-immediately-before-each-write", + "permissions": "diagnostic-only", + "technicalReadiness": "diagnostic-only", + "deploymentEvidence": "not-recorded", + "resourceProvisioning": "distinct-write-operation", + "mockIngestion": "distinct-write-operation", + "enablement": "not-authorized", + "cleanup": "not-authorized", + "retentionChanges": "not-authorized", + "roleAssignments": "not-authorized", + }, + } + plan["planSha256"] = _canonical_sha256(plan) + plan["planningConsent"] = _consent_state( + root, + plan["planSha256"], + components=component_details, + package=package, + target=plan["target"], + ) + plan["executionMode"] = plan["planningConsent"].get("executionMode", "not-selected") + plan["packageAcceptancePlan"]["approvalStatus"] = ( + "approved" + if "fullPackageAcceptance" in plan["planningConsent"].get("approvedComponents", []) + else "not-approved" + ) + plan["packageAcceptancePlan"]["planningConsent"] = plan["planningConsent"]["status"] + plan["packageAcceptancePlan"]["selectedFlags"] = ( + plan["planningConsent"].get("packageFlags", {}) + if "fullPackageAcceptance" in plan["planningConsent"].get("approvedComponents", []) + else {} + ) + plan["exactScopeWriteApproval"] = "not-recorded" + return plan + + +def record_planning_consent( + solution: str | Path, + *, + expected_plan_sha256: str, + actor: str, + approved_components: list[str], + declined: bool = False, + reason: str | None = None, + max_additional_custom_detections: int | None = None, + package_flags: Mapping[str, bool] | None = None, + dce_selected: bool = False, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + if not isinstance(actor, str) or not actor.strip(): + raise ValueError("planning consent requires an explicit nonempty actor") + plan = build_deployment_plan(root) + if not _SHA256.fullmatch(expected_plan_sha256) or plan.get("planSha256") != expected_plan_sha256: + raise ValueError("deployment plan changed; rerun preflight and review its current plan hash") + if declined: + if approved_components: + raise ValueError("decline cannot be combined with approved components") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError("declining planning consent requires a reason") + status = "declined" + else: + if not approved_components: + raise ValueError("select at least one component to approve") + if len(set(approved_components)) != len(approved_components): + raise ValueError("planning consent components must be unique") + unknown = set(approved_components) - COMPONENTS + if unknown: + raise ValueError("unsupported planning consent component: " + ", ".join(sorted(unknown))) + components = plan.get("components") or {} + for component in approved_components: + details = components.get(component) or {} + if component in {"sentinelAlertRules", "xdrCustomDetections"} and details.get("maximumCount", 0) < 1: + raise ValueError(f"{component} has no eligible source rules in the current plan") + if component == "fullPackageAcceptance" and details.get("status") != "available": + raise ValueError("full-package planning consent requires a current verified package") + if component == "mockTelemetryIngestion" and details.get("status") != "ready": + raise ValueError("mock-ingestion consent requires a complete reviewed payload and ingestion plan") + if component == "customTableCreate" and details.get("status") != "conditional": + raise ValueError("custom-table creation is not proven necessary by the current exact contract") + if component == "fallbackDce" and not dce_selected: + raise ValueError("DCE consent requires an explicit --dce-selected decision") + if component == "fallbackDce" and "fallbackDcr" not in approved_components: + raise ValueError("conditional DCE consent must be paired with fallback DCR scope") + if ( + component == "fallbackDcr" + and details.get("compatibleExistingDcrProven") is True + ): + raise ValueError("fallback DCR is not in scope while a compatible existing solution DCR is proven") + if {"sentinelAlertRules", "xdrCustomDetections"} & set(approved_components): + if not {"sentinelAlertRules", "xdrCustomDetections"} <= set(approved_components): + raise ValueError( + "disabled Sentinel/XDR pair consent must include both sentinelAlertRules and xdrCustomDetections" + ) + if max_additional_custom_detections is None: + raise ValueError("pair planning consent requires --max-additional-custom-detections") + maximum = (plan.get("detectionScope") or {}).get( + "eligibleMaximumAdditionalCustomDetections", 0 + ) + if ( + type(max_additional_custom_detections) is not int + or not 0 <= max_additional_custom_detections <= maximum + ): + raise ValueError( + "max additional Custom Detections must be an integer within the current eligible bound" + ) + if "fullPackageAcceptance" in approved_components: + expected_flags = (components.get("fullPackageAcceptance") or {}).get("availableFlags") or {} + supplied_flags = dict(package_flags or {}) + if ( + not expected_flags + or set(supplied_flags) != set(expected_flags) + or any(type(value) is not bool for value in supplied_flags.values()) + ): + raise ValueError( + "full-package consent requires an explicit boolean value for every current package selection flag" + ) + else: + supplied_flags = {} + status = "approved" + record = { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": status, + "planSha256": expected_plan_sha256, + "approvedComponents": sorted(approved_components) if not declined else [], + "actor": actor.strip(), + "approvedAt": datetime.now(timezone.utc).isoformat(), + "reason": reason.strip() if isinstance(reason, str) else None, + "maxAdditionalCustomDetections": ( + max_additional_custom_detections if not declined else None + ), + "packageFlags": supplied_flags if not declined else {}, + "executionMode": ( + "both" + if "fullPackageAcceptance" in approved_components + and {"sentinelAlertRules", "xdrCustomDetections"} <= set(approved_components) + else "pairs" + if {"sentinelAlertRules", "xdrCustomDetections"} <= set(approved_components) + else "package" + if "fullPackageAcceptance" in approved_components + else "ingestion-or-provisioning" + ) if not declined else "not-selected", + "conditionalSelections": { + "dceSelectedOrRequired": dce_selected, + "fallbackDcrOnlyWhenNoProvenCompatibleSolutionDcr": True, + "customTableOnlyWhenExactContractProvesAbsent": True, + }, + "scope": { + "target": plan.get("target"), + "resourceTypes": sorted({ + item + for name in approved_components if name in (plan.get("components") or {}) + for item in (plan["components"][name].get("resourceTypes") or []) + }), + "packageResourceInventory": ( + plan.get("packageResourceInventory") or [] + if "fullPackageAcceptance" in approved_components + else [] + ), + "planSha256": expected_plan_sha256, + }, + "environmentWritesAuthorized": False, + } + record["approvalSha256"] = _canonical_sha256(record) + path = artifact_path(root, CONSENT_FILE_NAME, create_parent=True) + write_json_artifact(root, path, record) + return { + "status": status, + "path": str(path), + "planSha256": expected_plan_sha256, + "approvedComponents": record["approvedComponents"], + "environmentWritesAuthorized": False, + } + + +def persist_deployment_plan(solution: str | Path) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + plan = build_deployment_plan(root) + path = artifact_path(root, PLAN_FILE_NAME, create_parent=True) + write_json_artifact(root, path, plan) + return {**plan, "planPath": str(path)} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/durations.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/durations.py new file mode 100644 index 00000000000..52d511606ef --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/durations.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import re +from typing import Any + + +def iso_duration(value: Any, default: str = "PT1H") -> tuple[str, str | None]: + text = str(value or "").strip().lower() + iso = text.upper() + if re.fullmatch( + r"P(?:(?:\d+D)(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?|T(?:\d+H)?(?:\d+M)?(?:\d+S)?)", + iso, + ): + return iso, None + match = re.fullmatch(r"(\d+)\s*([smhd])", text) + if not match: + return default, f"unrecognized queryFrequency {value!r}; defaulted to {default}" + amount, unit = match.groups() + return { + "s": f"PT{amount}S", + "m": f"PT{amount}M", + "h": f"PT{amount}H", + "d": f"P{amount}D", + }[unit], None diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/evidence.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/evidence.py new file mode 100644 index 00000000000..c86fb887c68 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/evidence.py @@ -0,0 +1,617 @@ +"""Explicit, local-only public evidence projection; never a resumable state store.""" +from __future__ import annotations + +import hashlib +import json +import re +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from . import __version__ +from .authoring_contract import AuthoringContract, calculate_coverage, validate_authoring_contract +from .artifacts import RUN_MARKER, report_directory +from .content_paths import content_path +from .workflow import STAGES + +STATUSES = frozenset({ + "passed", "failed", "blocked", "pending", "running", "notRequired", "not-run", + "completed", "inProgress", "in-progress", "not-started", "converted", "excluded", "needsReview", + "conflict", "not-generated", "environment-blocked", "not-recorded", +}) +REPORTS = { + "conversion": "manifest.json", + "structural": "structural-validation.json", + "runtimeGraph": "runtime-validation.graph.json", + "runtimeTriage": "runtime-validation.triage-mcp.json", + "runtimeLogAnalytics": "runtime-validation.log-analytics-cli.json", + "runtimeAzureMonitorSdk": "runtime-validation.azure-monitor-sdk.json", + "packaging": "packaging.v3_1.json", + "queryParity": "mock-query-parity.json", + "alertParity": "alert-parity-report.json", + "solutionReport": "migration-report.json", +} +COUNTS = ( + "total", "converted", "excluded", "needsReview", "reviewRequired", + "deploymentReady", "conflicts", "valid", "invalid", "blocked", "notRun", + "xdrDetectionCount", +) +CONTENT_FOLDERS = {"Analytic Rules", "Analytics Rules", "XDR Detections", "Package", "Data"} +SENSITIVE = re.compile( + r"(?i)(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + r"|password|passwd|secret|token|credential|bearer|tenant|subscription|workspace" + r"|[A-Za-z0-9_-]{48,}|(?:^|/)Users/|(?:^|/)home/)" +) + + +def _object(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def _enum(value: Any, allowed=STATUSES) -> str: + return value if isinstance(value, str) and value in allowed else "unknown" + + +def _count(value: Any) -> int | None: + return value if type(value) is int and 0 <= value <= 2**53 - 1 else None + + +def _version(value: Any) -> str | None: + return value if isinstance(value, str) and re.fullmatch(r"\d{1,6}\.\d{1,6}\.\d{1,6}", value) else None + + +def _hash(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _timestamp(value: Any) -> str: + if not isinstance(value, str) or not re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+00:00)", value + ): + return "unknown (not recorded)" + return value + + +def _public_solution_name(value: Any) -> str | None: + if ( + not isinstance(value, str) + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 .()-]{0,119}", value) + or SENSITIVE.search(value) + or re.search(r"(?i)(?:\bhttps?\b|\bwww\b|\.\.)", value) + ): + return None + return value + + +def _public_authoring_rule_id(value: Any) -> str | None: + if ( + not isinstance(value, str) + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value) + or SENSITIVE.search(value) + or re.search(r"(?i)(?:https?\b|www\b|\.{2})", value) + ): + return None + return value + + +def _authoring_contract_projection(contract: AuthoringContract) -> dict: + results = {result.rule_id: result for result in contract.results} + rules = [] + for index, source_rule in enumerate(sorted(contract.inventory, key=lambda rule: rule.rule_id), start=1): + result = results[source_rule.rule_id] + findings = Counter(finding.kind for finding in result.findings) + digests = { + source_rule.sha256, + *(reference.sha256 for reference in source_rule.evidence_references), + *(reference.sha256 for reference in result.evidence_references), + *(reference.sha256 for reference in result.qualification_evidence_references), + *(reference.sha256 for finding in result.findings for reference in finding.evidence_references), + } + target_sha256 = result.target.sha256 if result.target is not None else None + if target_sha256 is not None: + digests.add(target_sha256) + rule_id = _public_authoring_rule_id(source_rule.rule_id) + rules.append({ + "inventoryIndex": index, + "sourceRuleId": rule_id, + "sourceRuleIdAvailability": "available" if rule_id is not None else "withheld", + "outcome": result.status, + "qualificationStatus": result.qualification_status, + "targetStructurallyValid": result.target_structurally_valid, + "reviewRequired": result.review_required, + "unresolvedErrorCount": result.unresolved_error_count, + "mappingAssumptionCount": len(result.mapping_assumptions), + "assumptionFindingCount": findings["assumption"], + "findingCounts": { + "error": findings["error"], + "warning": findings["warning"], + "assumption": findings["assumption"], + }, + "sourceSha256": source_rule.sha256, + "targetSha256": target_sha256, + "evidenceDigests": sorted(digests), + }) + coverage = calculate_coverage(contract) + return { + "schemaVersion": "1.0.0", + "runId": contract.run_id, + "inventoryComplete": True, + "rules": rules, + "coverage": { + "N": coverage.N, + "M": coverage.M, + "E": coverage.E, + "qualified": coverage.qualified, + "untested": coverage.untested, + "qualificationFailed": coverage.qualification_failed, + "migrationThreshold": { + "successPercent": 80, + "comparison": "greaterThanOrEqual", + "formula": "100*M >= 80*N", + "met": coverage.migration_threshold_met, + }, + "errorStopThreshold": { + "errorPercent": 50, + "comparison": "strictlyGreaterThan", + "formula": "100*E > 50*N", + "reached": coverage.stop_threshold_reached, + }, + "scope": "advisory-only", + "coverageAuthorizesOperations": False, + "packagingAuthorized": False, + "deploymentAuthorized": False, + "ingestionAuthorized": False, + "prCreationAuthorized": False, + }, + "evidenceDigestBinding": "validated-record-only-not-rehashed-or-authenticated", + } + + +class _Snapshot: + def __init__(self, root: Path, run: Path): + self.root, self.run = root, run + self.inputs: dict[Path, bytes | None] = {} + + def read(self, path: Path) -> bytes | None: + path = content_path(self.root, path.relative_to(self.root)) + if path not in self.inputs: + self.inputs[path] = path.read_bytes() if path.is_file() else None + return self.inputs[path] + + def document(self, path: Path) -> tuple[dict, str]: + data = self.read(path) + if data is None: + return {}, "missing" + try: + value = json.loads(data) + except (ValueError, UnicodeError): + return {}, "invalid" + return (value, "available") if isinstance(value, dict) else ({}, "invalid") + + def reference(self, value: Any) -> dict: + # Only content references can contribute variable paths. No arbitrary log, + # provider, URL, external-file, or stage-evidence reference is copied. + if not isinstance(value, str): + return {"availability": "missing", "path": None, "sha256": None} + text = value.replace("\\", "/") + if text.startswith(self.root.as_posix() + "/"): + text = text[len(self.root.as_posix()) + 1:] + parts = text.split("/") + if ( + not re.fullmatch(r"[A-Za-z0-9 _./()-]{1,240}", text) + or SENSITIVE.search(text) + or parts[0] not in CONTENT_FOLDERS + or any(p in {"", ".", ".."} or p.lower() in {"logs", "reports", "evidence"} for p in parts) + ): + return {"availability": "withheld", "path": None, "sha256": None} + try: + path = content_path(self.root, text) + data = self.read(path) + except (OSError, ValueError): + return {"availability": "unsafe", "path": None, "sha256": None} + return { + "availability": "available" if data is not None else "missing", + "path": text, "sha256": _hash(data) if data is not None else None, + } + + def unchanged(self) -> bool: + return all( + (path.read_bytes() if path.is_file() else None) == value + for path, value in self.inputs.items() + ) + + +def _solution_metadata(snapshot: _Snapshot) -> dict: + name = _public_solution_name(snapshot.root.name) + result = { + "name": name, + "nameAvailability": "available" if name else "withheld", + "nameSource": "solution-folder", + "currentVersion": None, + "versionAvailability": "missing", + "versionObservation": "export", + "versionSources": [], + "requestedTargetVersion": None, + "requestedTargetVersionAvailability": "unsupported", + } + metadata_path = snapshot.root / "SolutionMetadata.json" + data_directory = content_path(snapshot.root, "Data") + data_paths = sorted(data_directory.glob("Solution_*.json")) + if len(data_paths) > 1: + result["versionAvailability"] = "ambiguous" + return result + versions = [] + for kind, path in [("solution-metadata", metadata_path), *[("solution-data", path) for path in data_paths]]: + document, availability = snapshot.document(path) + candidates = [value for key, value in document.items() if key.lower() == "version"] + version = _version(candidates[0]) if len(candidates) == 1 else None + if availability == "available" and not candidates: + availability = "not-recorded" + elif availability == "available" and version is None: + availability = "invalid" + result["versionSources"].append({ + "kind": kind, "availability": availability, "version": version, + "sha256": _hash(snapshot.inputs[path]) if snapshot.inputs[path] is not None else None, + }) + if availability not in {"missing", "not-recorded"}: + versions.append(version) + if versions: + if None in versions: + result["versionAvailability"] = "invalid" + elif len(set(versions)) != 1: + result["versionAvailability"] = "conflict" + else: + result.update(currentVersion=versions[0], versionAvailability="available") + return result + + +def _provenance(snapshot: _Snapshot, source: dict, output: dict) -> dict: + result = {"sourceQuery": "unavailable", "historicalValidationBinding": "unsupported"} + if source["availability"] != "available" or output["availability"] != "available": + return result + try: + original = _object(yaml.safe_load(snapshot.read(snapshot.root / source["path"]))) + generated = _object(yaml.safe_load(snapshot.read(snapshot.root / output["path"]))) + provenance = _object(_object(generated.get("contentProvenance")).get("source")) + query = original.get("query") + digest = provenance.get("querySha256") + if isinstance(query, str) and isinstance(digest, str) and re.fullmatch("[0-9a-f]{64}", digest): + result["sourceQuery"] = ( + "matches" if digest == _hash(query.encode("utf-8")) + and provenance.get("path") == source["path"] + and provenance.get("id") == original.get("id") else "mismatch" + ) + result["sourceVersion"] = _version(original.get("version")) + result["outputVersion"] = _version(generated.get("version")) + result["converterVersion"] = _version(_object(_object(generated.get("contentProvenance")).get("conversion")).get("version")) + except (ValueError, yaml.YAMLError, UnicodeError): + result["sourceQuery"] = "invalid" + return result + + +def _item( + snapshot: _Snapshot, + kind: str, + value: Any, + index: int, + *, + provider: Any = None, +) -> dict: + item = _object(value) + status = item.get("status") + if status is None: + for key in ("valid", "passed"): + if type(item.get(key)) is bool: + status = "passed" if item[key] else "failed" + break + result = { + "index": index, "recordedStatus": _enum(status), + "errorCount": len(item["errors"]) if isinstance(item.get("errors"), list) else None, + "errorDetails": "withheld" if any(item.get(key) for key in ("errors", "error", "errorDetails")) else "not-recorded", + } + if kind == "conversion": + source = snapshot.reference(item.get("sourceRelativePath") or item.get("source")) + output = snapshot.reference(item.get("outputRelativePath") or item.get("output")) + result.update(source=source, output=output, provenance=_provenance(snapshot, source, output)) + result["reviewRequired"] = item.get("reviewRequired") if type(item.get("reviewRequired")) is bool else None + elif kind in {"runtimeGraph", "runtimeTriage", "runtimeLogAnalytics", "runtimeAzureMonitorSdk"}: + result["provider"] = _enum( + provider, + {"graph", "triage-mcp", "log-analytics-cli", "azure-monitor-sdk"}, + ) + detection = item.get("detection") + output_reference = ( + f"XDR Detections/{detection}" if isinstance(detection, str) else None + ) + output_ref = snapshot.reference(output_reference) + result["content"] = output_ref + query = None + try: + if output_ref["availability"] == "available": + generated = _object(yaml.safe_load(snapshot.read( + content_path(snapshot.root, output_ref["path"]) + ))) + if kind == "runtimeGraph": + query = _object(_object(generated.get("properties")).get("queryCondition")).get("queryText") + else: + source_path = _object( + _object(generated.get("contentProvenance")).get("source") + ).get("path") + source_ref = snapshot.reference(source_path) + if source_ref["availability"] == "available": + source = _object(yaml.safe_load(snapshot.read( + content_path(snapshot.root, source_ref["path"]) + ))) + query = source.get("query") + except (OSError, ValueError, yaml.YAMLError, UnicodeError): + query = None + expected_hash = _hash(query.encode("utf-8")) if isinstance(query, str) else None + recorded_hash = item.get("querySha256") + result["queryHashBinding"] = { + "surface": ( + "advanced-hunting" if kind == "runtimeGraph" else "sentinel" + ), + "status": ( + "matches" if expected_hash and recorded_hash == expected_hash + else "stale-or-missing" + ), + "recordedQuerySha256": ( + recorded_hash if isinstance(recorded_hash, str) + and re.fullmatch(r"[0-9a-f]{64}", recorded_hash) else None + ), + "currentQuerySha256": expected_hash, + } + elif kind == "solutionReport": + result["source"] = snapshot.reference(item.get("sourceFile")) + result["structuralStatus"] = _enum(_object(item.get("customDetection")).get("structuralStatus")) + result["conversionStatus"] = _enum(_object(item.get("customDetection")).get("conversionStatus")) + for surface in ("analyticRule", "customDetection"): + details = _object(item.get(surface)) + runtime = details.get("runtime") + result[surface] = { + "alertStatus": _enum(details.get("alertStatus")), + "deploymentStatus": _enum(details.get("deploymentStatus"), STATUSES | {"created", "updated", "validated-by-alert-parity"}), + "runtime": [ + {"recordedStatus": _enum(_object(entry).get("status")), + "provider": _enum(_object(entry).get("provider"), {"graph", "triage-mcp", "log-analytics-cli", "azure-monitor-sdk"})} + for entry in runtime + ] if isinstance(runtime, list) else [], + "runtimeAvailability": "available" if isinstance(runtime, list) and runtime else "not-recorded", + } + else: + reference = item.get("file") + if reference is None and isinstance(item.get("detection"), str): + reference = "XDR Detections/" + item["detection"] + result["content"] = snapshot.reference(reference) + return result + + +def _report(snapshot: _Snapshot, kind: str, name: str) -> dict: + path = snapshot.run / name + document, availability = snapshot.document(path) + result = { + "availability": availability, + "path": path.relative_to(snapshot.root).as_posix(), + "sha256": _hash(snapshot.inputs[path]) if snapshot.inputs[path] is not None else None, + "recordedStatus": _enum(document.get("status")), + "verification": "recorded-only-not-attested", + } + if document.get("runId", snapshot.run.name) != snapshot.run.name: + result.update(availability="run-mismatch", recordedStatus="unknown") + return result + result["counts"] = {key: _count(document[key]) for key in COUNTS if key in document} + collection = "rules" if kind == "solutionReport" else "comparisons" if kind in {"queryParity", "alertParity"} else "results" + values = document.get(collection) + result["itemsAvailability"] = "available" if isinstance(values, list) else "missing-or-unsupported" + result["items"] = [ + _item(snapshot, kind, item, index, provider=document.get("provider")) + for index, item in enumerate(values or []) + ] if isinstance(values, list) else [] + if kind == "packaging": + result.update( + packager=_enum(document.get("packager"), {"V3.1", "V4", "V3"}), + version=_version(document.get("version")), + versionBump=_enum(document.get("versionBump"), {"none", "patch", "minor", "major"}), + artifacts={key: snapshot.reference(document.get(key)) for key in ( + "solutionData", "mainTemplate", "createUiDefinition", "testParameters", "zip", + )}, + warningCount=len(document["warnings"]) if isinstance(document.get("warnings"), list) else None, + ) + return result + + +def _markdown(evidence: dict) -> str: + solution = _object(evidence.get("solution")) + exporter = _object(evidence.get("exporter")) + name = _public_solution_name(solution.get("name")) or "unknown" + current_version = _version(solution.get("currentVersion")) or "unknown" + requested_target = _version(solution.get("requestedTargetVersion")) or "unknown (not recorded)" + exported_at = _timestamp(evidence.get("exportedAt")) + header_update = solution.get("versionObservation") == "header-update" + observation = "header update" if header_update else "export" + lines = [ + f"# Migration evidence — {name}", "", f"Solution: **{name}**", + f"Run: `{evidence['runId']}`", + f"Profile: **{evidence['profile']}**", + f"Recorded workflow status: **{evidence['recordedWorkflowStatus']}**", + f"Current solution version at {observation}: **{current_version}** (not a packaging claim)", + f"Requested version action: **{_enum(evidence.get('versionDecision'), {'none', 'patch', 'minor', 'major'})}**", + f"Requested target solution version: **{requested_target}** (not a packaged version)", + f"Evidence exported at (UTC): **{exported_at}**", + f"Evidence exporter: **sentinel-xdr-migration {_version(exporter.get('version')) or 'unknown'}** (export tool, not historical run version)", + f"Exporter Python: **{_version(exporter.get('pythonVersion')) or 'unknown'}**", "", + ] + if header_update: + renderer = _object(evidence.get("headerRenderer")) + lines.extend([ + f"Header updated at (UTC): **{_timestamp(evidence.get('headerUpdatedAt'))}**", + f"Header renderer: **sentinel-xdr-migration {_version(renderer.get('version')) or 'unknown'}**", + "Header-only update: original results, hashes and export identity are preserved;", + "the current solution version above is a new header observation, not an export-time fact.", "", + ]) + lines.extend([ + "**This is a local, curated snapshot, not trusted attestation or a new test run.**", + "Reported passes are not independently verified. Missing evidence is not a pass.", + "Content hashes describe export-time bytes, not proof of what historical tests ran.", "", + "## All workflow stages", "", "| Stage | Recorded status | Attempts |", + "| --- | --- | --- |", + ]) + for stage, value in evidence["stages"].items(): + lines.append(f"| {stage} | {value['recordedStatus']} | {value['attempts']} |") + lines.extend(["", "## Evidence coverage", "", "| Report | Availability | Recorded status | Items | Recorded item outcomes |", "| --- | --- | --- | --- | --- |"]) + for name, value in evidence["reports"].items(): + key = "structuralStatus" if name == "solutionReport" else "recordedStatus" + outcomes = Counter(item.get(key, "unknown") for item in value.get("items", [])) + label = "structural: " if name == "solutionReport" and outcomes else "" + counts = label + ", ".join(f"{status}={count}" for status, count in sorted(outcomes.items())) + bindings = Counter( + _object(item.get("queryHashBinding")).get("status", "not-applicable") + for item in value.get("items", []) + ) + if bindings and name.startswith("runtime"): + counts += "; query hashes: " + ", ".join( + f"{status}={count}" for status, count in sorted(bindings.items()) + ) + lines.append(f"| {name} | {value['availability']} | {value['recordedStatus']} | {len(value.get('items', []))} | {counts or 'not-recorded'} |") + mismatches = sum( + item.get("provenance", {}).get("sourceQuery") == "mismatch" + for item in evidence["reports"]["conversion"].get("items", []) + ) + lines.extend([ + "", "See `evidence.json` for every result (including failures, blocks and not-run),", + "relative paths, hashes, version decisions and provenance mismatches.", "", + f"Source-query provenance mismatches detected: **{mismatches}**.", + "Missing/unsupported provenance remains unverified, even when this count is zero.", "", + ]) + authoring_contract = _object(evidence.get("authoringContract")) + if authoring_contract: + coverage = _object(authoring_contract.get("coverage")) + migration_threshold = _object(coverage.get("migrationThreshold")) + error_threshold = _object(coverage.get("errorStopThreshold")) + lines.extend([ + "## B1 authoring-contract projection", "", + "This is a validated-record projection, not an attestation; evidence digests were not rehashed.", + "Coverage is advisory only and never authorizes packaging, deployment, ingestion, or PR creation.", "", + f"Inventory coverage: **N={coverage.get('N', 0)}, M={coverage.get('M', 0)}, E={coverage.get('E', 0)}**", + f"Migration threshold: **{migration_threshold.get('successPercent', 80)}% success, " + f"{migration_threshold.get('comparison', 'greaterThanOrEqual')}, " + f"{migration_threshold.get('formula', '100*M >= 80*N')}, " + f"met={str(migration_threshold.get('met', False)).lower()}**", + f"Error stop threshold: **>{error_threshold.get('errorPercent', 50)}% errors, " + f"{error_threshold.get('formula', '100*E > 50*N')}, " + f"reached={str(error_threshold.get('reached', False)).lower()}**", "", + "| Source rule ID | Outcome | Qualification | Target valid | Review required | Unresolved errors | Mapping assumptions | Assumption findings | Evidence digests |", + "| --- | --- | --- | --- | --- | ---: | ---: | ---: | --- |", + ]) + for rule in authoring_contract.get("rules", []): + item = _object(rule) + rule_id = item.get("sourceRuleId") or f"withheld (inventory {item.get('inventoryIndex', 'unknown')})" + lines.append( + f"| {rule_id} | {item.get('outcome', 'unknown')} | " + f"{item.get('qualificationStatus', 'unknown')} | " + f"{str(item.get('targetStructurallyValid', False)).lower()} | " + f"{str(item.get('reviewRequired', False)).lower()} | " + f"{item.get('unresolvedErrorCount', 0)} | " + f"{item.get('mappingAssumptionCount', 0)} | " + f"{item.get('assumptionFindingCount', 0)} | " + f"{', '.join(item.get('evidenceDigests', [])) or 'not-recorded'} |" + ) + lines.append("") + lines.extend([ + "## Limitations", "", + "- Raw messages, provider records, alerts, credentials and lab identities are omitted.", + "- Historical commands/tool versions and baseline decisions have no supported persisted contract.", + "- New runtime reports bind results to the exact source or Advanced Hunting query hash; legacy or mismatched hashes are stale/unbound. Hashes do not attest that a provider executed the query.", + "- Unknown fields/statuses are excluded or marked unknown, never interpreted as success.", + "- This directory is never read as workflow state or included in Marketplace packages.", "", + ]) + return "\n".join(lines) + + +def export_evidence( + solution: str | Path, + *, + authoring_contract: Mapping[str, Any] | None = None, +) -> dict: + root = Path(solution).expanduser().resolve() + run = report_directory(root) + if not (run / RUN_MARKER).is_file(): + raise ValueError("evidence export requires an existing run marker") + # Generated run IDs are public opaque identifiers; custom sensitive names fail closed. + if not re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{32}", run.name) and SENSITIVE.search(run.name): + raise ValueError("run ID is not suitable for public evidence") + destination = content_path(root, f"Evidence/{run.name}") + if destination.exists(): + raise ValueError("evidence snapshot already exists; exports never overwrite snapshots") + snapshot = _Snapshot(root, run) + marker, marker_status = snapshot.document(run / RUN_MARKER) + if marker_status != "available" or marker.get("runId") != run.name: + raise ValueError("invalid evidence run identity") + contract_projection = None + if authoring_contract is not None: + validated_contract = validate_authoring_contract(authoring_contract) + if validated_contract.run_id != run.name: + raise ValueError("authoring contract does not match the selected evidence run") + contract_projection = _authoring_contract_projection(validated_contract) + state, state_status = snapshot.document(run / "workflow-state.json") + if state and state.get("runId") != run.name: + raise ValueError("workflow state does not match the selected evidence run") + context = _object(state.get("context")) + evidence = { + "schemaVersion": "1.0.0", "runId": run.name, + "solution": _solution_metadata(snapshot), + "exportedAt": _utc_now(), + "profile": _enum(context.get("workflowProfile"), {"authoring", "qualification"}), + "recordedWorkflowStatus": _enum(state.get("workflowStatus")), + "workflowEvidence": { + "availability": state_status, + "path": (run / "workflow-state.json").relative_to(root).as_posix(), + "sha256": _hash(snapshot.inputs[run / "workflow-state.json"]) if state_status != "missing" else None, + }, + "versionDecision": _enum(context.get("versionBump"), {"none", "patch", "minor", "major"}), + "baselineDecision": {"availability": "unsupported"}, + "historicalCommands": {"availability": "unsupported"}, + "historicalToolVersions": {"availability": "unsupported"}, + "exporter": { + "name": "sentinel-xdr-migration", "version": __version__, + "pythonVersion": ".".join(map(str, sys.version_info[:3])), + "command": ["sentinel-xdr-migration", "export-evidence", "--solution", ".", "--run-id", run.name], + }, + "verification": "recorded-only-not-attested", + "stages": {}, + "reports": {}, + } + for stage in STAGES: + value = _object(_object(state.get("stages")).get(stage)) + evidence["stages"][stage] = { + "recordedStatus": _enum(value.get("status")), + "attempts": _count(value.get("attempts")), + "runtimeStatus": _enum(_object(value.get("artifacts")).get("runtimeStatus")), + "details": "withheld" if value.get("message") else "not-recorded", + } + evidence["reports"] = {kind: _report(snapshot, kind, name) for kind, name in REPORTS.items()} + if contract_projection is not None: + evidence["authoringContract"] = contract_projection + payload = json.dumps(evidence, indent=2, sort_keys=True) + "\n" + summary = _markdown(evidence) + if not snapshot.unchanged(): + raise ValueError("evidence inputs changed during export; retry after the run is stable") + destination.parent.mkdir(exist_ok=True) + destination.mkdir() # Atomic claim: no concurrent or previous snapshot is overwritten. + try: + (destination / "evidence.json").write_text(payload, encoding="utf-8") + (destination / "summary.md").write_text(summary, encoding="utf-8") + except OSError: + for name in ("evidence.json", "summary.md"): + (destination / name).unlink(missing_ok=True) + destination.rmdir() + raise + return {"status": "exported", "runId": run.name, "evidence": f"Evidence/{run.name}/evidence.json", "summary": f"Evidence/{run.name}/summary.md"} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_auth.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_auth.py new file mode 100644 index 00000000000..0b45cdcd44c --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_auth.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import re +from typing import Any, Iterable + +GRAPH_RESOURCE = "https://graph.microsoft.com" +GRAPH_RESOURCE_DEFAULT_SCOPE = f"{GRAPH_RESOURCE}/.default" +GRAPH_AUDIENCE_CLAIM = "00000003-0000-0000-c000-000000000000" +_SCOPE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") + + +def delegated_scope_diagnostics( + scope_claim: Any, + required_scopes: Iterable[str], +) -> dict[str, Any]: + """Compare required Graph scopes to a valid delegated ``scp`` claim.""" + expected_by_key: dict[str, str] = {} + for scope in required_scopes: + if not isinstance(scope, str): + raise ValueError("required delegated scopes must be strings") + name = scope.rsplit("/", 1)[-1] + if not _SCOPE_NAME.fullmatch(name): + raise ValueError("required delegated scope name is invalid") + expected_by_key[name.casefold()] = name + if not expected_by_key: + raise ValueError("at least one required delegated scope must be specified") + + if scope_claim is None: + claim_status = "missing" + actual_by_key: dict[str, str] = {} + elif not isinstance(scope_claim, str) or not scope_claim.strip(): + claim_status = "malformed" + actual_by_key = {} + else: + scope_names = scope_claim.split() + if any(not _SCOPE_NAME.fullmatch(name) for name in scope_names): + claim_status = "malformed" + actual_by_key = {} + else: + claim_status = "valid" + actual_by_key = { + name.casefold(): expected_by_key.get(name.casefold(), name) + for name in scope_names + } + + missing = [ + name for key, name in expected_by_key.items() + if key not in actual_by_key + ] + extra = [ + name for key, name in actual_by_key.items() + if key not in expected_by_key + ] + status = ( + "satisfied" + if claim_status == "valid" and not missing + else "missing-required" + if claim_status == "valid" + else claim_status + ) + return { + "status": status, + "claimStatus": claim_status, + "requiredScopeNames": sorted(expected_by_key.values(), key=str.casefold), + "actualScopeNames": sorted(actual_by_key.values(), key=str.casefold), + "missingScopeNames": sorted(missing, key=str.casefold), + "extraScopeNames": sorted(extra, key=str.casefold), + } + + +def delegated_graph_claim_diagnostics( + claims: dict[str, Any] | None, + *, + tenant_id: str, + required_scopes: Iterable[str], +) -> dict[str, Any]: + """Return safe tenant, audience, and delegated-scope claim diagnostics.""" + if not isinstance(claims, dict): + return { + "tenantMatch": "unknown", + "graphAudience": "unknown", + "delegatedScopes": delegated_scope_diagnostics(None, required_scopes), + } + claim_tenant = claims.get("tid") + tenant_match = ( + claim_tenant.casefold() == tenant_id.casefold() + if isinstance(claim_tenant, str) + else "unknown" + ) + audience = claims.get("aud") + graph_audience = ( + audience.casefold() in { + GRAPH_RESOURCE.casefold(), + GRAPH_AUDIENCE_CLAIM.casefold(), + } + if isinstance(audience, str) + else "unknown" + ) + return { + "tenantMatch": tenant_match, + "graphAudience": graph_audience, + "delegatedScopes": delegated_scope_diagnostics( + claims.get("scp"), required_scopes + ), + } + + +def validate_delegated_graph_claims( + claims: dict[str, Any] | None, + *, + tenant_id: str, + required_scopes: Iterable[str], + purpose: str, +) -> dict[str, Any]: + """Require a tenant-bound Graph audience and all required delegated scopes.""" + diagnostics = delegated_graph_claim_diagnostics( + claims, + tenant_id=tenant_id, + required_scopes=required_scopes, + ) + if diagnostics["tenantMatch"] is not True: + raise RuntimeError(f"{purpose} access token tenant does not match the target") + if diagnostics["graphAudience"] is not True: + raise RuntimeError(f"{purpose} access token is not issued for Microsoft Graph") + if diagnostics["delegatedScopes"]["status"] != "satisfied": + raise RuntimeError( + f"{purpose} access token is missing valid required delegated scopes" + ) + return diagnostics + + +def credential_token_scope(auth_method: str, permission_scope: str) -> str: + """Map an operation's permission scope to the selected credential contract. + + AzureCliCredential requests Azure CLI resource tokens. Its `/.default` + scope maps to the Graph resource URI; other credentials keep the exact + delegated permission scope. + """ + if auth_method == "azure-cli": + return GRAPH_RESOURCE_DEFAULT_SCOPE + return permission_scope diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_client_config.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_client_config.py new file mode 100644 index 00000000000..83b3d22f2e5 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_client_config.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import threading +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +DEFAULT_GRAPH_CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e" +PRIVATE_AUTH_CONFIG_NAME = "private-auth-config.json" + +_CLIENT_KEYS = { + "appRegistration": "appRegistrationClientId", + "appReadback": "appReadbackClientId", + "advancedHunting": "advancedHuntingClientId", + "customDetection": "customDetectionClientId", +} +_PROFILE_LOCK = threading.RLock() + + +def _private_state_root(state_root: str | Path) -> Path: + root = Path(state_root).expanduser().resolve() + if any(part.casefold() in {"logs", ".env"} for part in root.parts): + raise ValueError( + "private Graph authentication state must not be stored under Logs or .env" + ) + return root + + +def ensure_private_state_root(state_root: str | Path) -> Path: + root = _private_state_root(state_root) + root.mkdir(parents=True, exist_ok=True) + restrict_private_permissions(root, 0o700) + return root + + +def restrict_private_permissions(path: str | Path, mode: int) -> None: + target = Path(path) + try: + target.chmod(mode) + except OSError as exc: + if os.name == "posix": + raise RuntimeError( + f"could not restrict private authentication permissions on {target}" + ) from exc + if os.name == "posix" and stat.S_IMODE(target.stat().st_mode) != mode: + raise RuntimeError( + f"private authentication permissions are not restricted on {target}" + ) + + +def _valid_client_id(value: str, *, label: str = "Graph client ID") -> str: + normalized = value.strip().lower() + if not re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + normalized, + ): + raise ValueError(f"{label} must be a GUID") + return normalized + + +def _read_config(state_root: str | Path) -> dict[str, Any]: + root = _private_state_root(state_root) + path = root / PRIVATE_AUTH_CONFIG_NAME + if not path.exists(): + return {"schemaVersion": 1, "graphClientIds": {}} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"private Graph client configuration is unreadable: {exc}") from exc + if not isinstance(value, dict) or value.get("schemaVersion") != 1: + raise ValueError("private Graph client configuration has an unsupported schema") + clients = value.get("graphClientIds", {}) + if not isinstance(clients, dict): + raise ValueError("private Graph client configuration has invalid graphClientIds") + for purpose, client_id in clients.items(): + if purpose not in _CLIENT_KEYS: + raise ValueError(f"unsupported Graph client purpose: {purpose}") + if not isinstance(client_id, str): + raise ValueError(f"Graph client ID for {purpose} must be a string") + _valid_client_id(client_id) + azure_config_dir = value.get("azureConfigDir") + if azure_config_dir is not None: + if not isinstance(azure_config_dir, str) or not Path(azure_config_dir).is_absolute(): + raise ValueError("azureConfigDir must be an absolute path") + profile = Path(azure_config_dir).expanduser().resolve() + if any(part.casefold() in {"logs", ".env"} for part in profile.parts): + raise ValueError("azureConfigDir must not be stored under Logs or .env") + if profile == (Path.home() / ".azure").resolve(): + raise ValueError("azureConfigDir must not replace the default Azure CLI profile") + return value + + +def read_private_auth_config(state_root: str | Path) -> dict[str, Any]: + config = _read_config(state_root) + return { + "schemaVersion": config["schemaVersion"], + "graphClientIds": dict(config.get("graphClientIds", {})), + "azureConfigDir": config.get("azureConfigDir"), + } + + +def _write_config(state_root: str | Path, config: dict[str, Any]) -> Path: + root = ensure_private_state_root(state_root) + path = root / PRIVATE_AUTH_CONFIG_NAME + path.write_text( + json.dumps(config, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + restrict_private_permissions(path, 0o600) + return path + + +def configure_graph_client( + state_root: str | Path, + purpose: str, + client_id: str, +) -> dict[str, str]: + if purpose not in _CLIENT_KEYS: + raise ValueError(f"purpose must be one of {', '.join(sorted(_CLIENT_KEYS))}") + normalized = _valid_client_id(client_id) + config = _read_config(state_root) + clients = config.setdefault("graphClientIds", {}) + clients[purpose] = normalized + path = _write_config(state_root, config) + return {"purpose": purpose, "clientId": normalized, "configPath": str(path)} + + +def configure_azure_cli_profile( + state_root: str | Path, + azure_config_dir: str | Path, +) -> dict[str, str]: + path = Path(azure_config_dir).expanduser() + if not path.is_absolute(): + raise ValueError("azure-config-dir must be an absolute path") + normalized = str(path.resolve()) + protected_profiles = {str((Path.home() / ".azure").resolve())} + current_profile = os.environ.get("AZURE_CONFIG_DIR") + if current_profile: + protected_profiles.add(str(Path(current_profile).expanduser().resolve())) + if normalized in protected_profiles: + raise ValueError( + "azure-config-dir must be a separate profile and must not replace " + "the current Azure CLI profile" + ) + if any(part.casefold() in {"logs", ".env"} for part in path.parts): + raise ValueError("azure-config-dir must not be stored in operational Logs or .env") + config = _read_config(state_root) + config["azureConfigDir"] = normalized + config_path = _write_config(state_root, config) + return {"azureConfigDir": normalized, "configPath": str(config_path)} + + +def read_delegated_app_registration(state_root: str | Path) -> dict[str, Any] | None: + value = _read_config(state_root).get("delegatedAppRegistration") + return dict(value) if isinstance(value, dict) else None + + +def write_delegated_app_registration( + state_root: str | Path, + registration: dict[str, Any], + *, + custom_detection_client_id: str | None = None, + advanced_hunting_client_id: str | None = None, +) -> Path: + config = _read_config(state_root) + config["delegatedAppRegistration"] = registration + if custom_detection_client_id: + clients = config.setdefault("graphClientIds", {}) + clients["customDetection"] = _valid_client_id(custom_detection_client_id) + if advanced_hunting_client_id: + clients = config.setdefault("graphClientIds", {}) + clients["advancedHunting"] = _valid_client_id(advanced_hunting_client_id) + return _write_config(state_root, config) + + +def resolve_graph_client_id( + state_root: str | Path, + purpose: str, + client_id: str | None = None, +) -> str: + if purpose not in _CLIENT_KEYS: + raise ValueError(f"purpose must be one of {', '.join(sorted(_CLIENT_KEYS))}") + if client_id is not None: + return _valid_client_id(client_id) + value = _read_config(state_root).get("graphClientIds", {}).get(purpose) + return _valid_client_id(value) if value else DEFAULT_GRAPH_CLIENT_ID + + +def graph_auth_record_path( + state_root: str | Path, + purpose: str, + client_id: str, + legacy_name: str, +) -> Path: + root = _private_state_root(state_root) + normalized = _valid_client_id(client_id) + base = root / legacy_name + if normalized == DEFAULT_GRAPH_CLIENT_ID: + return base + suffix = hashlib.sha256(f"{purpose}:{normalized}".encode("utf-8")).hexdigest()[:12] + return base.with_name(f"{base.stem}.{suffix}{base.suffix}") + + +def graph_token_cache_name( + purpose: str, + client_id: str, + legacy_name: str, +) -> str: + normalized = _valid_client_id(client_id) + if normalized == DEFAULT_GRAPH_CLIENT_ID: + return legacy_name + suffix = hashlib.sha256(f"{purpose}:{normalized}".encode("utf-8")).hexdigest()[:12] + return f"{legacy_name}_{suffix}" + + +def graph_auth_metadata_path(record_path: str | Path) -> Path: + path = Path(record_path) + return path.with_name(f"{path.name}.metadata.json") + + +def write_graph_auth_metadata( + record_path: str | Path, + *, + purpose: str, + client_id: str, + tenant_id: str | None, +) -> None: + path = Path(record_path) + _private_state_root(path.parent) + payload = { + "schemaVersion": 1, + "purpose": purpose, + "clientId": _valid_client_id(client_id), + "tenantId": tenant_id.strip().lower() if tenant_id else None, + } + metadata = graph_auth_metadata_path(path) + metadata.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + restrict_private_permissions(metadata, 0o600) + + +def verify_graph_auth_binding( + record_path: str | Path, + record: Any, + *, + purpose: str, + client_id: str, + tenant_id: str | None, +) -> None: + path = Path(record_path) + expected_client = _valid_client_id(client_id) + metadata_path = graph_auth_metadata_path(path) + metadata: dict[str, Any] = {} + if metadata_path.exists(): + try: + value = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Graph authentication binding metadata is unreadable: {exc}") from exc + if not isinstance(value, dict) or value.get("schemaVersion") != 1: + raise ValueError("Graph authentication binding metadata has an unsupported schema") + metadata = value + if metadata.get("purpose") != purpose: + raise ValueError("cached Graph authentication purpose does not match the request") + if _valid_client_id(str(metadata.get("clientId") or "")) != expected_client: + raise ValueError("cached Graph authentication client does not match the request") + elif expected_client != DEFAULT_GRAPH_CLIENT_ID: + raise ValueError("custom Graph authentication record has no client binding metadata") + + record_client_id = getattr(record, "client_id", None) + if record_client_id and _valid_client_id(str(record_client_id)) != expected_client: + raise ValueError("cached Graph authentication client does not match the request") + record_tenant = getattr(record, "tenant_id", None) + bound_tenant = metadata.get("tenantId") or record_tenant + if tenant_id: + if not bound_tenant: + raise ValueError( + "cached Graph authentication has no verifiable tenant binding" + ) + if str(tenant_id).casefold() != str(bound_tenant).casefold(): + raise ValueError( + "cached Graph authentication tenant does not match the locked target" + ) + if metadata.get("tenantId") and record_tenant and ( + str(metadata["tenantId"]).casefold() != str(record_tenant).casefold() + ): + raise ValueError("cached Graph authentication record conflicts with its tenant binding") + + +@contextmanager +def azure_cli_profile(azure_config_dir: str | Path | None) -> Iterator[None]: + if azure_config_dir is None: + yield + return + path = Path(azure_config_dir).expanduser() + if not path.is_absolute(): + raise ValueError("AZURE_CONFIG_DIR must be an absolute path") + normalized = str(path.resolve()) + with _PROFILE_LOCK: + previous = os.environ.get("AZURE_CONFIG_DIR") + os.environ["AZURE_CONFIG_DIR"] = normalized + try: + yield + finally: + if previous is None: + os.environ.pop("AZURE_CONFIG_DIR", None) + else: + os.environ["AZURE_CONFIG_DIR"] = previous + + +def configured_azure_cli_profile(state_root: str | Path) -> str | None: + value = _read_config(state_root).get("azureConfigDir") + return str(value) if value else None + + +def azure_cli_credential( + *, + tenant_id: str | None = None, + state_root: str | Path | None = None, + subscription: str | None = None, +): + from azure.identity import AzureCliCredential + + azure_config_dir = ( + configured_azure_cli_profile(state_root) + if state_root is not None + else None + ) + credential = AzureCliCredential( + tenant_id=tenant_id, + subscription=subscription, + ) + + class ProfiledCredential: + def get_token(self, *scopes: str, **kwargs: Any): + with azure_cli_profile(azure_config_dir): + return credential.get_token(*scopes, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(credential, name) + + return ProfiledCredential() diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py new file mode 100644 index 00000000000..1928f640c00 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py @@ -0,0 +1,889 @@ +from __future__ import annotations + +import base64 +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from copy import deepcopy +from pathlib import Path +from typing import Any + +import yaml + +from .converter import ( + PREFIXED_SOURCE_ID_PATTERN, + SOURCE_ID_PATTERN, + is_sentinel_derived, + validate_document, + xdr_detection_files, +) +from .dcr_readiness import preflight_dcr_permissions +from .deployment_planning import build_deployment_plan +from .graph_auth import ( + GRAPH_RESOURCE, + GRAPH_RESOURCE_DEFAULT_SCOPE, + credential_token_scope, + delegated_graph_claim_diagnostics, + delegated_scope_diagnostics, +) +from .onboarding import GRAPH_CLIENT_ID, _state_dir +from .graph_client_config import ( + azure_cli_credential, + azure_cli_profile, + configured_azure_cli_profile, + graph_auth_record_path, + resolve_graph_client_id, +) +from .target_context import require_locked_target + +AZURE_CLI_GRAPH_SCOPE = GRAPH_RESOURCE_DEFAULT_SCOPE +GRAPH_AUDIENCE_CLAIM = "00000003-0000-0000-c000-000000000000" +GRAPH_DETECTION_RULES_ENDPOINT = ( + "https://graph.microsoft.com/beta/security/rules/detectionRules" +) +WRITE_SCOPE = "https://graph.microsoft.com/CustomDetection.ReadWrite.All" +READ_SCOPE = "https://graph.microsoft.com/CustomDetection.Read.All" +AUTH_RECORD_NAME = "custom-detection-auth-record.json" +DEFAULT_READ_BUDGET = 20 +DEFAULT_REQUEST_TIMEOUT_SECONDS = 15 + + +def _detection_id(prepared: dict[str, Any]) -> None: + properties = prepared.get("properties") or {} + detection_id = properties.get("id") + source_id = ( + ((prepared.get("contentProvenance") or {}).get("source") or {}).get("id") + ) + if isinstance(detection_id, str) and re.fullmatch( + SOURCE_ID_PATTERN, detection_id + ): + if is_sentinel_derived(prepared) and ( + not isinstance(source_id, str) + or detection_id.casefold() != source_id.casefold() + ): + return + properties["id"] = ( + f"xdr-{source_id if is_sentinel_derived(prepared) else detection_id}" + ) + elif isinstance(detection_id, str): + match = re.fullmatch(PREFIXED_SOURCE_ID_PATTERN, detection_id) + if match: + guid = detection_id[4:] + if is_sentinel_derived(prepared) and ( + not isinstance(source_id, str) or guid.casefold() != source_id.casefold() + ): + return + properties["id"] = ( + f"xdr-{source_id if is_sentinel_derived(prepared) else guid}" + ) + + +def _technique_ids(tactics: Any) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + if not isinstance(tactics, list): + return result + for item in tactics: + if not isinstance(item, dict): + continue + techniques: list[str] = [] + for technique in item.get("techniques") or []: + if not isinstance(technique, dict): + continue + base = technique.get("technique") + if isinstance(base, str) and base: + techniques.append(base) + subtechniques = technique.get("subTechniques") or [] + if isinstance(subtechniques, list): + techniques.extend( + value + for value in subtechniques + if isinstance(value, str) and value + ) + result.append( + { + "tactic": item.get("tactic"), + "techniques": techniques, + } + ) + return result + + +def project_graph_tactics( + alert_template: dict[str, Any], +) -> dict[str, Any]: + """Project the authored tactics to the V3.1 single-tactic Graph contract. + + The first authored tactic and its complete nested techniques/subtechniques + are retained. This is an API-shape projection, not classification parity. + The caller must keep the authored document and provenance unchanged. + """ + authored = alert_template.get("tactics") + source_tactics = authored if isinstance(authored, list) else [] + projected = deepcopy(source_tactics[:1]) + omitted = deepcopy(source_tactics[1:]) + diagnostic = { + "status": "projected" if omitted else "not-required", + "reason": ( + "Graph Custom Detection deployment uses the V3.1 single-tactic " + "projection: preserve the first authored tactic and all techniques " + "nested under it; omit later tactics and their techniques." + ), + "authoredTactics": [ + item.get("tactic") if isinstance(item, dict) else None + for item in source_tactics + ], + "projectedTactics": [ + item.get("tactic") if isinstance(item, dict) else None + for item in projected + ], + "omittedTactics": [ + item.get("tactic") if isinstance(item, dict) else None + for item in omitted + ], + "projectedTechniques": _technique_ids(projected), + "omittedTechniques": _technique_ids(omitted), + "classificationParity": "not-established", + } + if isinstance(authored, list): + alert_template["tactics"] = projected + return diagnostic + + +def graph_detection_payload_with_projection( + document: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Build a disabled Graph payload and return explicit projection diagnostics.""" + prepared = deepcopy(document) + _detection_id(prepared) + errors = validate_document(prepared) + conversion = (document.get("contentProvenance") or {}).get("conversion") or {} + if conversion.get("status") != "converted" or conversion.get("reviewRequired"): + errors.append("detection must be converted with no review required") + if errors: + raise ValueError("; ".join(errors)) + + properties = prepared["properties"] + alert = ( + (properties.get("detectionAction") or {}).get("alertTemplate") or {} + ) + projection = project_graph_tactics(alert) + result = deepcopy(properties) + result["@odata.type"] = "#microsoft.graph.security.detectionRule" + result["status"] = "disabled" + return result, projection + + +def graph_detection_payload(document: dict[str, Any]) -> dict[str, Any]: + """Compatibility wrapper returning only the projected Graph request body.""" + payload, _ = graph_detection_payload_with_projection(document) + return payload + + +def _token_claims(token: str) -> dict[str, Any] | None: + parts = token.split(".") + if len(parts) < 2: + return None + try: + encoded = parts[1] + encoded += "=" * (-len(encoded) % 4) + value = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii"))) + except (ValueError, UnicodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def _claim_permissions(claims: dict[str, Any] | None) -> dict[str, Any]: + if claims is None: + return { + "status": "unknown", + "grantType": "unknown", + "claimType": "unknown", + "permissions": {}, + } + + scopes_present = "scp" in claims + roles_present = "roles" in claims + scopes = claims.get("scp") + roles = claims.get("roles") + scopes_valid = isinstance(scopes, str) and bool(scopes.strip()) + roles_valid = isinstance(roles, list) and all( + isinstance(value, str) for value in roles + ) + if (scopes_present and not scopes_valid) or (roles_present and not roles_valid): + return { + "status": "unknown", + "grantType": "unknown", + "claimType": "malformed", + "permissions": {}, + "delegatedScopeDiagnostics": delegated_scope_diagnostics( + scopes, (WRITE_SCOPE,) + ), + } + if not scopes_present and not roles_present: + return { + "status": "unknown", + "grantType": "unknown", + "claimType": "missing", + "permissions": {}, + "delegatedScopeDiagnostics": delegated_scope_diagnostics( + None, (WRITE_SCOPE,) + ), + } + + delegated_scope_diagnostics_value = delegated_scope_diagnostics( + scopes if scopes_present else None, + (WRITE_SCOPE,), + ) + if delegated_scope_diagnostics_value["claimStatus"] == "malformed": + return { + "status": "unknown", + "grantType": "unknown", + "claimType": "malformed", + "permissions": {}, + "delegatedScopeDiagnostics": delegated_scope_diagnostics_value, + } + delegated_scopes = { + value.casefold() + for value in delegated_scope_diagnostics_value["actualScopeNames"] + } + application_roles = { + value.casefold() for value in roles + } if roles_valid else set() + required = "customdetection.readwrite.all" + if delegated_scopes and application_roles: + grant_type = "delegated-and-application" + claim_type = "scp+roles" + elif delegated_scopes: + grant_type = "delegated" + claim_type = "scp" + else: + grant_type = "application" + claim_type = "roles" + grant_present = required in delegated_scopes + permissions = { + "customdetection.read.all": ( + "present-in-token" + if "customdetection.read.all" in delegated_scopes + else "not-present-in-token" + ), + required: "present-in-token" if grant_present else "not-present-in-token", + } + return { + "status": "ready" if grant_present else "missing", + "grantType": grant_type, + "claimType": claim_type, + "permissions": permissions, + "delegatedScopeDiagnostics": delegated_scope_diagnostics_value, + } + + +def _graph_get( + url: str, + token: str, + *, + timeout_seconds: int, +) -> tuple[int, dict[str, Any]]: + request = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + content = response.read(1_000_001).decode("utf-8", errors="replace") + if len(content) > 1_000_000: + return response.status, {"error": {"code": "ResponseTooLarge"}} + try: + value = json.loads(content) if content else {} + except json.JSONDecodeError: + value = {"error": {"code": "InvalidJsonResponse"}} + return response.status, value if isinstance(value, dict) else { + "error": {"code": "UnexpectedResponseShape"} + } + except urllib.error.HTTPError as exc: + content = exc.read(65_537).decode("utf-8", errors="replace") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + parsed = {"error": {"code": "HttpError"}} + return exc.code, parsed if isinstance(parsed, dict) else {} + except (TimeoutError, urllib.error.URLError): + return 0, {"error": {"code": "NetworkError"}} + + +def _get_cached_credential( + *, + auth_method: str, + state_dir: str | Path | None, + tenant_id: str, +): + from .deployment import _deployment_credential + + state_root = _state_dir(state_dir) + if auth_method == "azure-cli": + if resolve_graph_client_id( + state_root, "customDetection" + ) != GRAPH_CLIENT_ID: + return None + return azure_cli_credential( + tenant_id=tenant_id, state_root=state_root + ) + if auth_method in {"device-code", "browser"}: + client_id = resolve_graph_client_id(state_root, "customDetection") + record_path = graph_auth_record_path( + state_root, "customDetection", client_id, AUTH_RECORD_NAME + ) + if not record_path.is_file(): + return None + return _deployment_credential( + state_root, + auth_method=auth_method, + tenant_id=tenant_id, + client_id=client_id, + ) + raise ValueError("auth_method must be explicitly set to azure-cli, device-code, or browser") + + +def _token_diagnostics( + claims: dict[str, Any] | None, + target_tenant_id: str, + required_scope: str = WRITE_SCOPE, +) -> dict[str, Any]: + graph_claims = delegated_graph_claim_diagnostics( + claims, + tenant_id=target_tenant_id, + required_scopes=(required_scope,), + ) + if claims is None: + return { + "tenantMatch": "unknown", + "graphAudience": "unknown", + "permissions": { + "status": "unknown", + "delegatedScopes": "unknown", + "applicationRoles": "unknown", + "delegatedScopeDiagnostics": graph_claims["delegatedScopes"], + }, + "delegatedScopeDiagnostics": graph_claims["delegatedScopes"], + "managementPermissionGrant": { + "status": "unknown", + "grantType": "unknown", + "claimType": "unknown", + }, + } + claim_info = _claim_permissions(claims) + permissions = dict(claim_info["permissions"]) + permissions["status"] = claim_info["status"] + permissions["delegatedScopes"] = ( + "present" if claim_info["claimType"] in {"scp", "scp+roles"} + else "malformed" if claim_info["claimType"] == "malformed" + else "not-present" + ) + permissions["delegatedScopeDiagnostics"] = graph_claims["delegatedScopes"] + permissions["applicationRoles"] = ( + "present" if claim_info["claimType"] in {"roles", "scp+roles"} + else "malformed" if claim_info["claimType"] == "malformed" + else "not-present" + ) + return { + "tenantMatch": graph_claims["tenantMatch"], + "graphAudience": graph_claims["graphAudience"], + "permissions": permissions, + "delegatedScopeDiagnostics": graph_claims["delegatedScopes"], + "managementPermissionGrant": { + "status": claim_info["status"], + "grantType": claim_info["grantType"], + "claimType": claim_info["claimType"], + }, + } + + +def preflight_deployment( + solution: str | Path, + *, + auth_method: str | None = None, + state_dir: str | Path | None = None, + max_detections: int = DEFAULT_READ_BUDGET, + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, +) -> dict[str, Any]: + """Run local payload checks and bounded, read-only checks for exact rules. + + Authentication never prompts or falls back to another credential method. + The function does not write reports, create/update rules, or change rule + status. Caller must explicitly select an existing credential method. + """ + if not 1 <= max_detections <= DEFAULT_READ_BUDGET: + raise ValueError(f"max_detections must be between 1 and {DEFAULT_READ_BUDGET}") + if not 1 <= request_timeout_seconds <= 60: + raise ValueError("request_timeout_seconds must be between 1 and 60") + + root = Path(solution).expanduser().resolve() + target = require_locked_target(root, materialize_missing=False) + state_root = _state_dir(state_dir) + client_id = resolve_graph_client_id(state_root, "customDetection") + azure_config_dir = configured_azure_cli_profile(state_root) + with azure_cli_profile(azure_config_dir): + dcr_permission_preflight = preflight_dcr_permissions( + root, + target, + auth_method=auth_method, + ) + output = root / "XDR Detections" + files = xdr_detection_files(output) + from .packaging_selection import current_applied_package_selection + + applied_selection = current_applied_package_selection(root) + qualification_scope = None + if applied_selection is not None: + selected_paths = { + str(value).replace("\\", "/").casefold() + for value in (applied_selection["plan"].get("selection") or {}).get( + "xdrDetections", [] + ) + } + files_by_path = { + path.relative_to(root).as_posix().casefold(): path for path in files + } + if not selected_paths or not selected_paths.issubset(files_by_path): + raise ValueError("current applied package selection has missing Custom Detection files") + files = [ + files_by_path[path] + for path in sorted(selected_paths) + ] + qualification_scope = { + "kind": "applied-package-selection", + "planSha256": applied_selection["plan"].get("planSha256"), + "outcome": (applied_selection["plan"].get("summary") or {}).get("outcome"), + "selectedDetectionPaths": sorted(selected_paths), + "coverage": applied_selection["plan"].get("summary"), + } + report: dict[str, Any] = { + "status": "unknown", + "provider": "microsoft-graph-beta", + "target": { + "tenantId": target["tenantId"], + "locked": True, + }, + "authentication": { + "method": auth_method, + "clientId": client_id if auth_method in {"device-code", "browser"} else None, + "azureConfigDir": azure_config_dir if auth_method == "azure-cli" else None, + "status": "not-attempted", + "requestedScope": None, + "tenantMatch": "unknown", + "graphAudience": "unknown", + "tokenPermissionClaims": {"status": "unknown"}, + "claimInterpretation": ( + "Decoded access-token claims are diagnostic only; they do not " + "prove tenant consent, effective role grants, or write authorization." + ), + }, + "requiredManagementPermission": "CustomDetection.ReadWrite.All", + "managementPermissionGrant": { + "status": "unknown", + "grantType": "unknown", + "claimType": "unknown", + "permission": "CustomDetection.ReadWrite.All", + "credentialRequestScope": None, + "reason": "No access-token permission claim has been inspected.", + }, + "deploymentReadiness": "unknown", + "permissions": { + "readAccess": "unknown", + "writeScopeInToken": "unknown", + "requiredDelegatedScopes": { + "read": "CustomDetection.Read.All", + "write": "CustomDetection.ReadWrite.All", + }, + "consent": "unknown", + "writeRoleGuidance": ( + "Review Defender XDR Unified RBAC Detection tuning > Manage " + "or an applicable supported Entra security role for the target " + "workload. This preflight does not inspect effective role grants." + ), + "effectiveRole": { + "status": "unknown", + "reason": ( + "Effective Defender XDR Unified RBAC and Entra role grants " + "cannot be determined from this Graph permission preflight." + ), + }, + "writeAuthorization": "not-tested", + "documentation": { + "graphCreate": ( + "https://learn.microsoft.com/graph/api/" + "security-detectionrule-post-detectionrules?view=graph-rest-beta" + ), + "graphGet": ( + "https://learn.microsoft.com/graph/api/" + "security-detectionrule-get?view=graph-rest-beta" + ), + "defenderCustomDetectionRoles": ( + "https://learn.microsoft.com/defender-xdr/custom-detection-rules" + ), + }, + }, + "payloadReadiness": [], + "qualificationScope": qualification_scope, + "readChecks": [], + "dcrPermissionPreflight": dcr_permission_preflight, + "deploymentPlanning": build_deployment_plan( + root, + target=target, + dcr_preflight=dcr_permission_preflight, + ), + "readBudget": { + "limit": max_detections, + "used": 0, + "requestTimeoutSeconds": request_timeout_seconds, + }, + } + if not files: + report["status"] = "blocked" + report["deploymentReadiness"] = "blocked" + report["payloadReadiness"].append( + {"status": "blocked", "message": f"no generated detections found under {output}"} + ) + return report + + valid_payloads: list[tuple[str, dict[str, Any]]] = [] + for path in files: + relative = path.relative_to(root).as_posix() + try: + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} + payload, projection = graph_detection_payload_with_projection(document) + valid_payloads.append((relative, payload)) + report["payloadReadiness"].append( + { + "file": relative, + "id": payload["id"], + "status": "ready", + "graphTacticProjection": projection, + } + ) + except (OSError, ValueError, yaml.YAMLError) as exc: + report["payloadReadiness"].append( + { + "file": relative, + "id": None, + "status": "blocked", + "message": str(exc), + } + ) + + if any(item["status"] == "blocked" for item in report["payloadReadiness"]): + report["status"] = "blocked" + report["deploymentReadiness"] = "blocked" + report["authentication"]["status"] = "not-attempted" + return report + + if auth_method is None: + report["authentication"]["status"] = "action-required" + report["authentication"]["action"] = ( + "Choose an existing cached credential method explicitly " + "(azure-cli, device-code, or browser); preflight never starts login." + ) + report["status"] = ( + "blocked" + if any(item["status"] == "blocked" for item in report["payloadReadiness"]) + else "action-required" + ) + report["deploymentReadiness"] = "action-required" + return report + + if auth_method == "azure-cli" and client_id != GRAPH_CLIENT_ID: + report["authentication"]["status"] = "action-required" + report["authentication"]["action"] = ( + "The configured custom Graph client is not used by Azure CLI " + "authentication. Run setup-deployment with device-code or browser." + ) + report["status"] = "action-required" + report["deploymentReadiness"] = "action-required" + return report + + if len(valid_payloads) > max_detections: + report["authentication"]["status"] = "not-attempted" + report["readChecks"].append( + { + "status": "unknown", + "reason": "authored detections exceed the bounded exact-rule read budget", + "detectionCount": len(valid_payloads), + } + ) + report["status"] = "unknown" + report["deploymentReadiness"] = "unknown" + return report + + try: + credential = _get_cached_credential( + auth_method=auth_method, + state_dir=state_dir, + tenant_id=target["tenantId"], + ) + if credential is None: + report["authentication"]["status"] = "action-required" + report["authentication"]["action"] = ( + "No stored Custom Detection authentication record exists. Run the " + "separate setup-deployment command explicitly, then retry." + ) + report["status"] = "action-required" + report["deploymentReadiness"] = "action-required" + return report + try: + write_request_scope = credential_token_scope(auth_method, WRITE_SCOPE) + with azure_cli_profile( + azure_config_dir if auth_method == "azure-cli" else None + ): + access_token = credential.get_token(write_request_scope).token + token_scope = ( + "resource-default" if auth_method == "azure-cli" else "write" + ) + except Exception as write_error: + report["authentication"]["writeScopeAcquisition"] = { + "status": "unavailable", + "errorType": type(write_error).__name__, + "requestedScope": write_request_scope, + } + if auth_method == "azure-cli": + report["authentication"].update( + { + "status": "action-required", + "requestedScope": write_request_scope, + "scopeRequestKind": "graph-resource-default", + "permissionUnderReview": "CustomDetection.ReadWrite.All", + "readScopeAcquisition": { + "status": "not-attempted", + "reason": ( + "AzureCliCredential acquires Graph tokens by resource; " + "retrying with a different Custom Detection permission " + "would request the same Graph resource." + ), + }, + } + ) + report["status"] = "action-required" + report["deploymentReadiness"] = "action-required" + report["authentication"]["action"] = ( + "The cached Azure CLI credential could not acquire a Microsoft " + "Graph resource token. Review the Azure CLI sign-in and its " + "previously consented Graph scopes, then retry explicitly; " + "preflight will not start login or request consent." + ) + return report + try: + read_request_scope = credential_token_scope(auth_method, READ_SCOPE) + with azure_cli_profile( + azure_config_dir if auth_method == "azure-cli" else None + ): + access_token = credential.get_token(read_request_scope).token + token_scope = "read" + except Exception as read_error: + report["authentication"]["status"] = "action-required" + report["authentication"]["readScopeAcquisition"] = { + "status": "unavailable", + "errorType": type(read_error).__name__, + "requestedScope": read_request_scope, + } + report["status"] = "action-required" + report["deploymentReadiness"] = "action-required" + report["authentication"]["action"] = ( + "The cached credential could not acquire either Custom Detection " + "permission scope; review the existing consent and credential " + "state, then retry explicitly. Preflight will not start login." + ) + return report + except ValueError: + raise + except Exception as exc: + report["authentication"]["status"] = "blocked" + report["authentication"]["errorType"] = type(exc).__name__ + report["status"] = "blocked" + report["deploymentReadiness"] = "blocked" + return report + + claims = _token_claims(access_token) + required_scope = READ_SCOPE if token_scope == "read" else WRITE_SCOPE + token_info = _token_diagnostics( + claims, + target["tenantId"], + required_scope=required_scope, + ) + auth_report = report["authentication"] + auth_report.update( + { + "status": "acquired", + "requestedScope": ( + write_request_scope if token_scope in {"write", "resource-default"} + else read_request_scope + ), + "scopeRequestKind": ( + "graph-resource-default" + if token_scope == "resource-default" + else "explicit-permission" + ), + "permissionUnderReview": ( + "CustomDetection.Read.All" + if token_scope == "read" + else "CustomDetection.ReadWrite.All" + ), + "tenantMatch": token_info["tenantMatch"], + "graphAudience": token_info["graphAudience"], + "tokenPermissionClaims": token_info["permissions"], + "delegatedScopeDiagnostics": token_info["delegatedScopeDiagnostics"], + "writeScopeAcquisition": { + "status": ( + "resource-token-acquired" + if token_scope == "resource-default" + else "acquired" + if token_scope == "write" + else "unavailable" + ), + "requestedScope": write_request_scope, + }, + "readScopeAcquisition": { + "status": ( + "acquired" + if token_scope == "read" + else "not-attempted" + if token_scope == "resource-default" + else "not-requested" + ), + "requestedScope": ( + read_request_scope if token_scope == "read" else None + ), + }, + } + ) + grant = token_info["managementPermissionGrant"] + report["managementPermissionGrant"].update( + { + **grant, + "credentialRequestScope": auth_report["requestedScope"], + "reason": ( + "CustomDetection.ReadWrite.All is present in the delegated scp " + "claim; token claims do not prove tenant " + "consent, effective user role, Defender XDR RBAC, or write authorization." + if grant["status"] == "ready" + else "The token does not contain the required delegated " + "CustomDetection.ReadWrite.All scope." + if grant["status"] == "missing" + else "The token permission claims are missing or malformed; " + "the management permission grant cannot be determined." + ), + } + ) + report["permissions"]["writeScopeInToken"] = ( + token_info["permissions"].get("customdetection.readwrite.all", "unknown") + if isinstance(token_info["permissions"], dict) + else "unknown" + ) + if ( + token_info["tenantMatch"] is not True + or token_info["graphAudience"] is not True + or token_info["delegatedScopeDiagnostics"]["status"] != "satisfied" + ): + auth_report["status"] = "blocked" + auth_report["reason"] = ( + "Cached access token tenant, Graph audience, or required delegated " + "scopes could not be confirmed against the locked Qualification target." + ) + report["status"] = "blocked" + report["deploymentReadiness"] = "blocked" + return report + + for relative, payload in valid_payloads: + rule_id = urllib.parse.quote(str(payload["id"]), safe="") + url = f"{GRAPH_DETECTION_RULES_ENDPOINT}/{rule_id}" + status_code, response = _graph_get( + url, + access_token, + timeout_seconds=request_timeout_seconds, + ) + report["readBudget"]["used"] += 1 + error = response.get("error") if isinstance(response, dict) else None + graph_error = error.get("code") if isinstance(error, dict) else None + if status_code == 404: + result = { + "file": relative, + "id": payload["id"], + "status": "not-found", + "httpStatus": status_code, + "exists": False, + "scopeUsed": token_scope, + } + elif status_code == 200: + response_id = response.get("id") if isinstance(response, dict) else None + if isinstance(response_id, str) and response_id.casefold() == str( + payload["id"] + ).casefold(): + result = { + "file": relative, + "id": payload["id"], + "status": "confirmed", + "httpStatus": status_code, + "exists": True, + "scopeUsed": token_scope, + } + else: + result = { + "file": relative, + "id": payload["id"], + "status": "unknown", + "httpStatus": status_code, + "graphErrorCode": ( + "UnexpectedDetectionId" + if response_id + else "DetectionIdMissingFromResponse" + ), + "scopeUsed": token_scope, + } + elif status_code in {401, 403}: + result = { + "file": relative, + "id": payload["id"], + "status": "denied", + "httpStatus": status_code, + "graphErrorCode": graph_error, + "scopeUsed": token_scope, + "permissionOrRole": "not-determined", + } + else: + result = { + "file": relative, + "id": payload["id"], + "status": "unknown", + "httpStatus": status_code, + "graphErrorCode": graph_error, + "scopeUsed": token_scope, + } + report["readChecks"].append(result) + read_states = {item["status"] for item in report["readChecks"]} + if not read_states or read_states == {"confirmed"}: + report["permissions"]["readAccess"] = "confirmed" + elif "denied" in read_states: + report["permissions"]["readAccess"] = "denied" + else: + report["permissions"]["readAccess"] = "unknown" + payloads_ready = all(item["status"] == "ready" for item in report["payloadReadiness"]) + if not payloads_ready or "denied" in read_states: + report["status"] = "blocked" + report["deploymentReadiness"] = "blocked" + elif grant["status"] != "ready": + report["status"] = "action-required" + report["deploymentReadiness"] = "action-required" + elif read_states == {"confirmed"} and token_scope in {"write", "resource-default"}: + report["status"] = "readiness-unconfirmed" + report["deploymentReadiness"] = "readiness-unconfirmed" + else: + report["status"] = "unknown" + report["deploymentReadiness"] = "unknown" + report["readinessLimit"] = ( + "Read checks are read-only. Graph write authorization and effective " + "Defender XDR Unified RBAC cannot be confirmed without an approved write." + ) + return report diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/logs_query.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/logs_query.py new file mode 100644 index 00000000000..ae7c0206063 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/logs_query.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import base64 +import time +import uuid +from datetime import date, datetime, timedelta, timezone +from email.utils import parsedate_to_datetime +from decimal import Decimal +from typing import Any, Callable, Mapping + +from .graph_client_config import azure_cli_credential as profiled_azure_cli_credential +from .onboarding import _state_dir + + +DEFAULT_REQUEST_TIMEOUT_SECONDS = 90 +DEFAULT_FAMILY_TIMEOUT_SECONDS = 30 * 60 +MAX_TRANSIENT_RETRIES = 2 +MAX_SERVER_TIMEOUT_SECONDS = 600 + + +class LogsQueryFailure(RuntimeError): + def __init__( + self, + message: str, + *, + failure_kind: str, + status_code: int = 0, + error_details: str | None = None, + ) -> None: + super().__init__(message) + self.failure_kind = failure_kind + self.status_code = status_code + self.error_details = error_details or message + + +def azure_cli_credential(target: Mapping[str, str]): + """Create a non-interactive credential pinned to the locked target.""" + return profiled_azure_cli_credential( + tenant_id=target["tenantId"], + state_root=_state_dir(), + ) + + +def logs_query_client(credential: Any, *, retry_total: int = 0): + from azure.monitor.query import LogsQueryClient + + return LogsQueryClient(credential, retry_total=retry_total) + + +def _json_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, datetime): + return {"$datetime": value.isoformat()} + if isinstance(value, date): + return {"$date": value.isoformat()} + if isinstance(value, timedelta): + return {"$timedeltaSeconds": value.total_seconds()} + if isinstance(value, Decimal): + return {"$decimal": str(value)} + if isinstance(value, uuid.UUID): + return {"$uuid": str(value)} + if isinstance(value, (bytes, bytearray, memoryview)): + return {"$base64": base64.b64encode(bytes(value)).decode("ascii")} + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + raise TypeError(f"unsupported Azure Monitor result value: {type(value).__name__}") + + +def _table_result(table: Any) -> dict[str, Any]: + table_name = getattr(table, "name", None) + columns = getattr(table, "columns", None) + column_types = getattr(table, "columns_types", None) + rows = getattr(table, "rows", None) + if not isinstance(table_name, str) or not table_name.strip(): + raise ValueError("table name is missing or invalid") + if not isinstance(columns, list) or not columns: + raise ValueError(f"table {table_name!r} has no column names") + if not isinstance(column_types, list) or len(column_types) != len(columns): + raise ValueError(f"table {table_name!r} has missing or mismatched column types") + if any(not isinstance(name, str) or not name.strip() for name in columns): + raise ValueError(f"table {table_name!r} has an invalid column name") + if any(not isinstance(value_type, str) or not value_type.strip() for value_type in column_types): + raise ValueError(f"table {table_name!r} has an invalid column type") + if not isinstance(rows, list): + raise ValueError(f"table {table_name!r} has invalid rows") + schema = [ + {"name": name, "type": value_type} + for name, value_type in zip(columns, column_types) + ] + serialized_rows = [] + for row in rows: + try: + if len(row) != len(columns): + raise ValueError( + f"table {table_name!r} row has {len(row)} values for {len(columns)} columns" + ) + serialized_rows.append([_json_value(value) for value in row]) + except TypeError as exc: + raise ValueError(f"table {table_name!r} has a malformed row") from exc + return { + "name": table_name, + "columns": schema, + "rowCount": len(serialized_rows), + "rows": serialized_rows, + } + + +def _error_details(error: Any) -> str: + if error is None: + return "Azure Monitor returned a partial query result without error details" + message = getattr(error, "message", None) + if message: + return str(message)[:8000] + if isinstance(error, Mapping): + return str(_json_value(error))[:8000] + return str(error)[:8000] + + +def _error_payload(error: Any) -> Any: + if error is None: + return None + if isinstance(error, Mapping): + return _json_value(error) + payload = { + key: getattr(error, key) + for key in ("code", "message", "details", "target") + if getattr(error, key, None) is not None + } + return _json_value(payload or {"message": str(error)}) + + +def _status_code(error: Exception) -> int: + value = getattr(error, "status_code", None) + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _retry_after(error: Exception) -> float | None: + response = getattr(error, "response", None) + headers = getattr(response, "headers", None) + value = headers.get("Retry-After") if headers is not None else None + try: + return min(30.0, max(0.0, float(value))) + except (TypeError, ValueError): + try: + retry_at = parsedate_to_datetime(str(value)) + except (TypeError, ValueError, OverflowError): + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + return min( + 30.0, + max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()), + ) + + +def _failure_kind(error: Exception) -> tuple[str, bool]: + from azure.core.exceptions import ( + ClientAuthenticationError, + ServiceRequestError, + ServiceResponseError, + ) + from azure.identity import CredentialUnavailableError + + if isinstance(error, (ClientAuthenticationError, CredentialUnavailableError)): + return "authentication", False + if isinstance(error, (ServiceRequestError, ServiceResponseError, TimeoutError, OSError)): + return "transport", True + status_code = _status_code(error) + if status_code == 401: + return "authentication", False + if status_code == 403: + return "authorization", False + if status_code == 400: + return "query", False + if status_code in {408, 429, 500, 502, 503, 504}: + return "transient-provider-error", True + return "provider", False + + +def execute_workspace_query( + client: Any, + workspace_id: str, + query: str, + *, + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, + retry_count: int = MAX_TRANSIENT_RETRIES, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, + budget_seconds: int | None = None, +) -> dict[str, Any]: + """Execute the exact KQL text once per attempt, with no shell or time filter.""" + if not isinstance(query, str) or not query.strip(): + raise ValueError("source query must be non-empty text") + if not 1 <= request_timeout_seconds <= MAX_SERVER_TIMEOUT_SECONDS: + raise ValueError("request timeout must be between 1 and 600 seconds") + if not 0 <= retry_count <= MAX_TRANSIENT_RETRIES: + raise ValueError("retry count must be between 0 and 2") + + attempts = 0 + deadline = monotonic() + budget_seconds if budget_seconds is not None else None + while True: + remaining = deadline - monotonic() if deadline is not None else request_timeout_seconds + if remaining < 1: + raise LogsQueryFailure( + "the bounded query request budget expired", + failure_kind="timeout", + ) + effective_timeout = min(request_timeout_seconds, max(1, int(remaining))) + attempts += 1 + try: + response = client.query_workspace( + workspace_id, + query, + timespan=None, + server_timeout=effective_timeout, + connection_timeout=min(10, effective_timeout), + read_timeout=effective_timeout, + ) + except Exception as exc: + kind, retryable = _failure_kind(exc) + status_code = _status_code(exc) + if retryable and attempts <= retry_count: + delay = _retry_after(exc) + delay = delay if delay is not None else min(2**attempts, 8) + remaining = deadline - monotonic() if deadline is not None else None + if remaining is not None and delay >= remaining: + raise LogsQueryFailure( + "the bounded query request budget expired before a retry", + failure_kind="timeout", + status_code=status_code, + error_details=str(exc)[:8000], + ) from exc + sleep(delay) + continue + message = str(exc)[:2000] or type(exc).__name__ + raise LogsQueryFailure( + message, + failure_kind=kind, + status_code=status_code, + error_details=str(exc)[:8000] or type(exc).__name__, + ) from exc + + partial = hasattr(response, "partial_data") + raw_tables = response.partial_data if partial else getattr(response, "tables", None) + if partial and raw_tables is None: + raw_tables = [] + try: + if not isinstance(raw_tables, list): + raise ValueError("query response has no table list") + if not partial and not raw_tables: + raise ValueError("successful query response has no result tables") + tables = [_table_result(table) for table in raw_tables] + except (AttributeError, TypeError, ValueError) as exc: + raise LogsQueryFailure( + f"Azure Monitor SDK returned malformed table data: {exc}", + failure_kind="provider", + error_details=str(exc), + ) from exc + schema = [ + column + for table in tables + for column in table["columns"] + ] + return { + "status": "failed" if partial else "passed", + "statusCode": 206 if partial else 200, + "rowCount": sum(table["rowCount"] for table in tables), + "schema": schema, + "tables": tables, + "attempts": attempts, + "failureKind": "partial-response" if partial else None, + "partial": partial, + "error": _error_details(getattr(response, "partial_error", None)) if partial else None, + "errorDetails": _error_details(getattr(response, "partial_error", None)) if partial else None, + "partialError": _error_payload(getattr(response, "partial_error", None)) if partial else None, + } + + +def verify_azure_cli_target(target: Mapping[str, str]) -> None: + """Reject missing or cross-target Azure CLI identity without signing in.""" + import subprocess + + from .target_context import _json_output, _run_az + + result = _run_az(("account", "show"), runner=subprocess.run) + if result.returncode: + raise LogsQueryFailure( + "Azure CLI is not authenticated; sign in separately to the locked Qualification target", + failure_kind="authentication", + ) + account = _json_output(result) + if not isinstance(account, dict): + raise LogsQueryFailure( + "Azure CLI did not return its active tenant and subscription", + failure_kind="authentication", + ) + try: + tenant = str(uuid.UUID(str(account.get("tenantId") or ""))) + subscription = str(uuid.UUID(str(account.get("id") or ""))) + except (ValueError, AttributeError) as exc: + raise LogsQueryFailure( + "Azure CLI did not return valid active tenant and subscription identifiers", + failure_kind="authentication", + ) from exc + if tenant != target["tenantId"] or subscription != target["subscriptionId"]: + raise LogsQueryFailure( + "Azure CLI tenant or subscription does not match the locked Qualification target", + failure_kind="authentication", + ) + + +def preflight_read_executor( + request: Mapping[str, Any], + *, + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, +) -> dict[str, Any]: + """Adapt the SDK executor to preflight.run_preflight's read-executor contract.""" + if request.get("provider") != "logAnalytics": + raise ValueError("Azure Monitor SDK preflight only supports the logAnalytics provider") + target = request.get("target") + query = request.get("query") + if not isinstance(target, Mapping) or not isinstance(query, str): + raise ValueError("preflight SDK requests require a locked target and KQL query") + verify_azure_cli_target(target) + credential = azure_cli_credential(target) + client = logs_query_client(credential, retry_total=0) + try: + result = execute_workspace_query( + client, + target["workspaceCustomerId"], + query, + request_timeout_seconds=request_timeout_seconds, + retry_count=MAX_TRANSIENT_RETRIES, + ) + except LogsQueryFailure as exc: + return { + "ok": False, + "statusCode": exc.status_code, + "failureKind": exc.failure_kind, + "error": str(exc), + } + finally: + close = getattr(client, "close", None) + if callable(close): + close() + return { + "ok": result["status"] == "passed", + "statusCode": result["statusCode"], + "rowCount": result["rowCount"], + "schema": result["schema"], + "failureKind": result["failureKind"], + "tableAvailable": result["status"] == "passed", + } + + +def query_family( + client: Any, + workspace_id: str, + queries: list[dict[str, str]], + *, + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, + family_timeout_seconds: int = DEFAULT_FAMILY_TIMEOUT_SECONDS, + retry_count: int = MAX_TRANSIENT_RETRIES, + monotonic: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> list[dict[str, Any]]: + if not 1 <= family_timeout_seconds <= 6 * 60 * 60: + raise ValueError("family timeout must be between 1 and 21600 seconds") + deadline = monotonic() + family_timeout_seconds + results: list[dict[str, Any]] = [] + stop_family = False + for item in queries: + started = monotonic() + detection = item["detection"] + query = item["query"] + if stop_family: + results.append({ + "detection": detection, + "status": "not-run", + "valid": False, + "querySha256": item["querySha256"], + "statusCode": 0, + "rowCount": 0, + "schema": [], + "error": "not executed after an earlier provider-level failure", + "errorDetails": None, + }) + continue + remaining = deadline - monotonic() + if remaining < 1: + response = LogsQueryFailure( + "the complete Sentinel query-family time budget expired", + failure_kind="timeout", + ) + stop_family = True + result = { + "status": "not-run", + "valid": False, + "querySha256": item["querySha256"], + "statusCode": 0, + "rowCount": 0, + "schema": [], + "error": str(response), + "errorDetails": str(response), + "failureKind": response.failure_kind, + } + else: + try: + result = execute_workspace_query( + client, + workspace_id, + query, + request_timeout_seconds=min( + request_timeout_seconds, + MAX_SERVER_TIMEOUT_SECONDS, + max(1, int(remaining)), + ), + retry_count=retry_count, + sleep=sleep, + monotonic=monotonic, + budget_seconds=max(1, int(remaining)), + ) + except LogsQueryFailure as exc: + result = { + "status": "failed" if exc.failure_kind == "query" else "blocked", + "valid": False, + "querySha256": item["querySha256"], + "statusCode": exc.status_code, + "rowCount": 0, + "schema": [], + "error": str(exc), + "errorDetails": exc.error_details, + "failureKind": exc.failure_kind, + "attempts": None, + "tables": [], + } + if exc.failure_kind != "query": + stop_family = True + result.update({ + "detection": detection, + "querySha256": item["querySha256"], + "valid": result["status"] == "passed", + "durationMs": max(0, int((monotonic() - started) * 1000)), + }) + results.append(result) + return results diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/mapping_evidence.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/mapping_evidence.py new file mode 100644 index 00000000000..3c0cefc3518 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/mapping_evidence.py @@ -0,0 +1,634 @@ +"""Directional, hash-bound mapping evidence for Sentinel-to-XDR query conversion. + +The module records evidence; it does not infer DCR inverses or execute queries. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .catalog import ( + NATIVE_XDR_TABLES, + PASSTHROUGH_TABLES, + configured_column_renames, + query_column_renames, + query_has_ambiguous_table_scope, + query_table_references, +) + +SCHEMA_VERSION = "1.0.0" +_IDENTIFIERS = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +_SHA256 = re.compile(r"[a-f0-9]{64}\Z") +_CAST = re.compile( + r"(?is)^\s*(?:tostring|toint|tolong|toreal|todouble|todatetime|tobool|" + r"toguid|todecimal)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*$" +) + + +class MappingEvidenceError(ValueError): + """Supplied mapping metadata or evidence is malformed or stale.""" + + +def _canonical(value: Any) -> Any: + if isinstance(value, bytes): + return {"bytesSha256": hashlib.sha256(value).hexdigest()} + if isinstance(value, Mapping): + return {str(key): _canonical(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_canonical(item) for item in value] + if value is None or isinstance(value, (str, int, float, bool)): + return value + raise MappingEvidenceError(f"unsupported mapping metadata value: {type(value).__name__}") + + +def _digest(value: Any) -> str: + encoded = json.dumps( + _canonical(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _evidence_payload( + evidence: Mapping[str, Any], + where: str, + *, + expected_table: str, + expected_columns: Mapping[str, str], +) -> dict[str, Any]: + kind = evidence.get("kind") + path = evidence.get("path") + content = evidence.get("content") + if kind not in {"connector", "dcr", "schema", "table-schema", "catalog"}: + raise MappingEvidenceError(f"{where}.kind must identify connector, DCR, or schema evidence") + if not isinstance(path, str) or not path.strip(): + raise MappingEvidenceError(f"{where}.path is required") + if not isinstance(content, (str, bytes)): + raise MappingEvidenceError(f"{where}.content must contain the supplied evidence bytes or text") + actual = hashlib.sha256(content.encode("utf-8") if isinstance(content, str) else content).hexdigest() + expected = evidence.get("sha256") + if expected is not None and expected != actual: + raise MappingEvidenceError(f"{where}.sha256 is stale or does not match its evidence content") + if kind in {"schema", "table-schema"}: + try: + document = json.loads(content) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise MappingEvidenceError(f"{where} schema content must be a JSON table definition") from exc + if not isinstance(document, Mapping) or str(document.get("table", "")).casefold() != expected_table.casefold(): + raise MappingEvidenceError(f"{where} does not describe table {expected_table!r}") + declared_columns = document.get("columns") + if not isinstance(declared_columns, Mapping): + raise MappingEvidenceError(f"{where} schema content must include a columns object") + normalized_declared = { + str(name).casefold(): str(type_name).casefold() + for name, type_name in declared_columns.items() + } + normalized_expected = { + str(name).casefold(): str(type_name).casefold() + for name, type_name in expected_columns.items() + } + if normalized_declared != normalized_expected: + raise MappingEvidenceError(f"{where} schema content does not match the supplied columns") + return {"path": path, "sha256": actual, "kind": kind} + + +def _schema_columns(value: Any, where: str) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise MappingEvidenceError(f"{where}.columns must be an object") + result: dict[str, str] = {} + for name, type_name in value.items(): + if not isinstance(name, str) or not name: + raise MappingEvidenceError(f"{where}.columns contains an invalid column name") + if not isinstance(type_name, str) or not type_name: + raise MappingEvidenceError(f"{where}.columns[{name!r}] must be a type name") + result[name] = type_name + return result + + +def _field_references(query: str, aliases: set[str]) -> dict[str, set[str]]: + """Collect alias-qualified fields and identifiers outside strings/comments.""" + lexical = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|[A-Za-z_][A-Za-z0-9_]*|[^\s]" + ) + tokens = [ + match.group(0) for match in lexical.finditer(query) + if not match.group(0).startswith(("//", "/*", "'", '"', "@'", '@"')) + ] + result: dict[str, set[str]] = {} + for index in range(len(tokens) - 2): + if ( + tokens[index].casefold() in aliases + and tokens[index + 1] == "." + and _IDENTIFIERS.fullmatch(tokens[index + 2]) + ): + result.setdefault(tokens[index].casefold(), set()).add(tokens[index + 2]) + return result + + +def _query_words(query: str) -> set[str]: + lexical = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|[A-Za-z_][A-Za-z0-9_]*" + ) + return { + match.group(0) + for match in lexical.finditer(query) + if not match.group(0).startswith(("//", "/*", "'", '"', "@'", '@"')) + } + + +def _metadata_fingerprint( + source_metadata: Mapping[str, Any], + target_metadata: Mapping[str, Any], + config: Mapping[str, Any], +) -> str: + return _digest({ + "sourceMetadata": source_metadata, + "targetMetadata": target_metadata, + "config": config, + "catalogSha256": hashlib.sha256(Path(__file__).with_name("catalog.py").read_bytes()).hexdigest(), + }) + + +def _runtime_references( + runtime_evidence: Mapping[str, Any] | None, + source_query_sha256: str, + target_query_sha256: str, +) -> list[dict[str, Any]] | None: + """Verify supplied Sentinel and Graph reports against the exact two query hashes.""" + if not runtime_evidence: + return None + + def report_reference( + key: str, expected_hash: str, providers: set[str], surfaces: set[str] + ) -> dict[str, Any] | None: + report = runtime_evidence.get(key) + if not isinstance(report, Mapping): + return None + path, content = report.get("path"), report.get("content") + if not isinstance(path, str) or not isinstance(content, (str, bytes)): + return None + raw = content.encode("utf-8") if isinstance(content, str) else content + actual_hash = hashlib.sha256(raw).hexdigest() + if report.get("sha256") not in (None, actual_hash): + return None + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if ( + not isinstance(document, Mapping) + or document.get("provider") not in providers + or document.get("querySurface") not in surfaces + or not isinstance(document.get("results"), list) + ): + return None + matching = [ + result for result in document["results"] + if isinstance(result, Mapping) and result.get("querySha256") == expected_hash + ] + if len(matching) != 1 or matching[0].get("status") != "passed": + return None + return {"path": path, "sha256": actual_hash, "kind": "runtimeVerification"} + + source_ref = report_reference( + "sourceReport", + source_query_sha256, + {"triage-mcp", "log-analytics-cli", "azure-monitor-sdk"}, + {"microsoft-sentinel-triage", "log-analytics-workspace"}, + ) + target_ref = report_reference( + "targetReport", + target_query_sha256, + {"graph"}, + {"defender-advanced-hunting"}, + ) + return [source_ref, target_ref] if source_ref and target_ref else None + + +def _status(references: list[dict[str, Any]]) -> str: + if len([ref for ref in references if ref.get("kind") == "runtimeVerification"]) >= 2: + return "runtime-validated" + if any(ref.get("kind") == "catalog" for ref in references): + return "schema-verified" + if len([ + ref for ref in references if ref.get("kind") in {"schema", "table-schema"} + ]) >= 2: + return "schema-verified" + return "inferred" + + +def _mapping_kind(source: str, target: str | None, transform: str | None) -> str: + if target is None: + return "dropped" + if transform: + cast = _CAST.fullmatch(transform) + if cast: + return "cast" + if transform.strip() != source: + return "derived" + return "direct" if source == target else "rename" + + +def _where_predicates(query: str) -> list[dict[str, str]]: + """Capture simple pipeline filters verbatim; never reconstruct their logic.""" + lexical = re.compile( + r"//[^\n]*|/\*[\s\S]*?\*/|@'(?:''|[^'])*'|@\"(?:\"\"|[^\"])*\"" + r"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|[^\s]" + ) + masked = list(query) + for match in lexical.finditer(query): + token = match.group(0) + if token.startswith(("//", "/*", "'", '"', "@'", '@"')): + for position in range(match.start(), match.end()): + if masked[position] != "\n": + masked[position] = " " + clean = "".join(masked) + result: list[dict[str, str]] = [] + for match in re.finditer(r"(?is)\|\s*where\b(.*?)(?=\|\s*[A-Za-z_][A-Za-z0-9_-]*\b|$)", clean): + predicate = query[match.start(1):match.end(1)].strip() + if predicate: + result.append({"predicate": predicate}) + return result + + +def build_mapping_evidence( + source_query: str, + target_query: str | None = None, + *, + source_metadata: Mapping[str, Any] | None = None, + target_metadata: Mapping[str, Any] | None = None, + config: Mapping[str, Any] | None = None, + runtime_evidence: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build directional, query-scoped field evidence from supplied metadata. + + Metadata is keyed by table name. Each table descriptor may include + ``columns``, ``targetTable``, ``columnMappings``, ``droppedColumns``, + ``transformKql``, and an ``evidence`` object containing kind/path/content. + Explicit mapping expressions are recorded, not executed or inverted. + """ + if not isinstance(source_query, str) or not source_query.strip(): + raise MappingEvidenceError("source_query must be non-empty text") + target_query = source_query if target_query is None else target_query + if not isinstance(target_query, str): + raise MappingEvidenceError("target_query must be text") + source_metadata = source_metadata or {} + target_metadata = target_metadata or {} + config = config or {} + for name, value in ( + ("source_metadata", source_metadata), ("target_metadata", target_metadata), ("config", config) + ): + if not isinstance(value, Mapping): + raise MappingEvidenceError(f"{name} must be an object keyed by table or option") + + source_hash = hashlib.sha256(source_query.encode("utf-8")).hexdigest() + target_hash = hashlib.sha256(target_query.encode("utf-8")).hexdigest() + runtime_references = _runtime_references(runtime_evidence, source_hash, target_hash) + target_words = _query_words(target_query) + table_references = query_table_references(source_query) + issues: list[dict[str, str]] = [] + if not table_references: + issues.append({"code": "table-binding-unresolved", "message": "No supported table binding was found."}) + has_ambiguous_scope = query_has_ambiguous_table_scope(source_query) + if has_ambiguous_scope: + issues.append({ + "code": "table-scope-ambiguous", + "message": "Join/union or multiple table inputs prevent unqualified field attribution.", + }) + + mapping_records: list[dict[str, Any]] = [] + catalog_ref = { + "path": "sentinel_xdr_migration/catalog.py", + "sha256": hashlib.sha256(Path(__file__).with_name("catalog.py").read_bytes()).hexdigest(), + "kind": "catalog", + } + known_column_renames = query_column_renames(source_query) + configured, config_ambiguous = configured_column_renames(source_query, dict(config)) + if config_ambiguous: + issues.append({ + "code": "configured-column-map-not-applied", + "message": "Configured column mappings were not attributed because the query has multiple table scopes.", + }) + + aliases = { + str(ref["alias"]).casefold() + for ref in table_references if ref.get("alias") + } + qualified_fields = _field_references(source_query, aliases) + query_words = {word.casefold() for word in _query_words(source_query)} + global_mapping_count = 0 + for reference in table_references: + table = str(reference["table"]) + alias = reference.get("alias") + descriptor = next( + (value for key, value in source_metadata.items() if str(key).casefold() == table.casefold()), + {}, + ) + if not isinstance(descriptor, Mapping): + raise MappingEvidenceError(f"source_metadata[{table!r}] must be an object") + target_table = descriptor.get("targetTable") + configured_table = next( + (value for key, value in (config.get("tableMappings") or {}).items() + if str(key).casefold() == table.casefold()), + None, + ) + if configured_table: + target_table = configured_table + if target_table is not None and not isinstance(target_table, str): + raise MappingEvidenceError(f"target table for {table!r} must be text") + if not target_table and ( + table in NATIVE_XDR_TABLES or table in PASSTHROUGH_TABLES + ): + target_table = table + target_descriptor = next( + (value for key, value in target_metadata.items() + if target_table and str(key).casefold() == str(target_table).casefold()), + {}, + ) + if not isinstance(target_descriptor, Mapping): + raise MappingEvidenceError(f"target_metadata[{target_table!r}] must be an object") + source_columns = _schema_columns(descriptor.get("columns"), f"source_metadata[{table!r}]") + target_columns = _schema_columns(target_descriptor.get("columns"), f"target_metadata[{target_table!r}]") + evidence_references: list[dict[str, Any]] = [] + source_evidence = descriptor.get("evidence") + target_evidence = target_descriptor.get("evidence") + if source_evidence is not None: + if not isinstance(source_evidence, Mapping): + raise MappingEvidenceError(f"source_metadata[{table!r}].evidence must be an object") + evidence_references.append(_evidence_payload( + source_evidence, + f"source_metadata[{table!r}].evidence", + expected_table=table, + expected_columns=source_columns, + )) + if target_evidence is not None: + if not isinstance(target_evidence, Mapping): + raise MappingEvidenceError(f"target_metadata[{target_table!r}].evidence must be an object") + evidence_references.append(_evidence_payload( + target_evidence, + f"target_metadata[{target_table!r}].evidence", + expected_table=str(target_table), + expected_columns=target_columns, + )) + + explicit_mappings = descriptor.get("columnMappings") or {} + if not isinstance(explicit_mappings, Mapping): + raise MappingEvidenceError(f"source_metadata[{table!r}].columnMappings must be an object") + column_candidates: dict[str, Any] = dict(explicit_mappings) + if not has_ambiguous_scope: + column_candidates.update({ + source: target for source, target in configured.items() + if source.casefold() not in {key.casefold() for key in column_candidates} + }) + if table in NATIVE_XDR_TABLES or table in PASSTHROUGH_TABLES: + for source, target in known_column_renames.items(): + if source.casefold() not in {key.casefold() for key in column_candidates}: + column_candidates[source] = target + + aliases_for_scope = [str(ref["alias"]).casefold() for ref in table_references if ref.get("alias")] + alias_ambiguous = bool(alias and aliases_for_scope.count(str(alias).casefold()) > 1) + if alias_ambiguous: + issues.append({ + "code": "alias-binding-ambiguous", + "message": f"Alias {alias!r} is bound to more than one input; no columns were attributed.", + }) + continue + field_scope = ( + qualified_fields.get(str(alias).casefold(), set()) + if has_ambiguous_scope and alias + else set() + ) + if not has_ambiguous_scope and len(table_references) == 1: + field_scope = { + name for name in set(source_columns) | set(column_candidates) | set(descriptor.get("droppedColumns") or []) + if name.casefold() in query_words + } + if has_ambiguous_scope and not alias: + if column_candidates or source_columns: + issues.append({ + "code": "unqualified-field-unresolved", + "message": f"Fields for {table} have no unique query alias; no per-column binding was asserted.", + }) + continue + + dropped = descriptor.get("droppedColumns") or [] + if not isinstance(dropped, (list, tuple)) or any(not isinstance(name, str) for name in dropped): + raise MappingEvidenceError(f"source_metadata[{table!r}].droppedColumns must be a list of names") + for source_column in sorted(field_scope, key=str.casefold): + explicit = next( + (value for key, value in column_candidates.items() if str(key).casefold() == source_column.casefold()), + None, + ) + transform: str | None = None + target_column: str | None = None + target_type: str | None = None + if source_column.casefold() in {name.casefold() for name in dropped}: + target_column = None + elif isinstance(explicit, str): + target_column = explicit + elif isinstance(explicit, Mapping): + target_column = explicit.get("target") + transform_value = explicit.get("transform") or explicit.get("expression") + transform = transform_value if isinstance(transform_value, str) else None + explicit_type = explicit.get("targetType") + target_type = explicit_type if isinstance(explicit_type, str) else None + elif explicit is not None: + raise MappingEvidenceError(f"mapping for {source_column!r} must be text or an object") + elif source_column in target_columns: + target_column = source_column + elif source_column in source_columns and target_table and target_columns: + target_column = None + else: + continue + if target_column is not None and not isinstance(target_column, str): + raise MappingEvidenceError(f"target column for {source_column!r} must be text or null") + if target_column is not None and target_column in target_columns: + target_type = target_type or target_columns[target_column] + elif target_column is not None and source_column in source_columns: + issues.append({ + "code": "target-column-schema-missing", + "message": f"Target column {target_table}.{target_column} is absent from supplied target schema.", + }) + source_type = source_columns.get(source_column) + kind = _mapping_kind(source_column, target_column, transform) + is_catalog = source_column in known_column_renames and ( + table in NATIVE_XDR_TABLES or table in PASSTHROUGH_TABLES + ) + references_for_mapping = list(evidence_references) + if is_catalog: + references_for_mapping.append(catalog_ref) + if runtime_references: + references_for_mapping.extend(runtime_references) + status = _status(references_for_mapping) + if not is_catalog and not ( + source_evidence and source_evidence.get("kind") in {"schema", "table-schema"} + and target_evidence and target_evidence.get("kind") in {"schema", "table-schema"} + ): + status = "inferred" + notes: list[str] = [] + if transform: + notes.append("Supplied forward expression is recorded verbatim and was not executed.") + mapping_records.append({ + "sourceTable": table, + "sourceAlias": alias, + "targetTable": target_table, + "sourceColumn": source_column, + "targetColumn": target_column, + "sourceType": source_type, + "targetType": target_type, + "transform": transform, + "kind": kind, + "status": status, + "appliedToTargetQuery": bool( + target_column + and target_column in target_words + and ( + source_column == target_column + or source_column not in target_words + ) + and (not transform or transform in target_query) + ), + "evidenceReferences": references_for_mapping, + "notes": notes, + }) + global_mapping_count += 1 + + transform_kql = descriptor.get("transformKql") + if transform_kql is not None: + if not isinstance(transform_kql, str): + raise MappingEvidenceError(f"source_metadata[{table!r}].transformKql must be text") + issues.append({ + "code": "forward-dcr-transform-not-inverted", + "message": ( + f"DCR transform for {table} is forward-only evidence; its inverse was not inferred." + ), + }) + + if runtime_evidence and not runtime_references: + issues.append({ + "code": "runtime-evidence-not-current", + "message": "Runtime evidence was not applied because it was absent, failed, or bound to different query hashes.", + }) + filters = _where_predicates(source_query) + target_filters = _where_predicates(target_query) + for index, item in enumerate(filters): + item["sha256"] = hashlib.sha256(item["predicate"].encode("utf-8")).hexdigest() + target_predicate = target_filters[index]["predicate"] if index < len(target_filters) else None + item["targetPredicate"] = target_predicate + item["targetPredicateSha256"] = ( + hashlib.sha256(target_predicate.encode("utf-8")).hexdigest() + if target_predicate is not None else None + ) + item["rewriteStatus"] = ( + "unchanged" if target_predicate == item["predicate"] + else "rewritten-by-converter" if target_predicate is not None + else "not-found-in-target" + ) + + return { + "schemaVersion": SCHEMA_VERSION, + "direction": "sentinel-to-xdr", + "sourceSurface": "sentinel-log-analytics", + "targetSurface": "graph-advanced-hunting", + "sourceQuery": source_query, + "targetQuery": target_query, + "sourceQuerySha256": source_hash, + "targetQuerySha256": target_hash, + "metadataSha256": _metadata_fingerprint(source_metadata, target_metadata, config), + "mappings": mapping_records, + "filters": filters, + "issues": issues, + "queryRewritePerformed": target_query != source_query, + "queryRewriteNotes": ( + ["Target query is supplied by the existing converter; this module did not rewrite KQL."] + if target_query != source_query else [] + ), + "overallStatus": ( + min( + (item["status"] for item in mapping_records), + key={"inferred": 0, "schema-verified": 1, "runtime-validated": 2}.get, + ) + if mapping_records else "inferred" + ), + "mappingCount": global_mapping_count, + } + + +def validate_mapping_evidence_freshness( + record: Mapping[str, Any], + source_query: str, + target_query: str, + *, + source_metadata: Mapping[str, Any] | None = None, + target_metadata: Mapping[str, Any] | None = None, + config: Mapping[str, Any] | None = None, + runtime_evidence: Mapping[str, Any] | None = None, +) -> list[str]: + """Return exact stale-input errors; no hash is trusted without recomputation.""" + errors: list[str] = [] + checks = ( + ("sourceQuerySha256", hashlib.sha256(source_query.encode("utf-8")).hexdigest()), + ("targetQuerySha256", hashlib.sha256(target_query.encode("utf-8")).hexdigest()), + ( + "metadataSha256", + _metadata_fingerprint(source_metadata or {}, target_metadata or {}, config or {}), + ), + ) + for field, actual in checks: + if record.get(field) != actual: + errors.append(f"{field} is stale") + elif not isinstance(record.get(field), str) or not _SHA256.fullmatch(str(record[field])): + errors.append(f"{field} is not lowercase SHA-256") + if record.get("sourceQuery") != source_query: + errors.append("sourceQuery is stale") + if record.get("targetQuery") != target_query: + errors.append("targetQuery is stale") + try: + current = build_mapping_evidence( + source_query, + target_query, + source_metadata=source_metadata, + target_metadata=target_metadata, + config=config, + runtime_evidence=runtime_evidence, + ) + except MappingEvidenceError as exc: + errors.append(f"mapping evidence inputs are invalid: {exc}") + else: + actual_mappings = record.get("mappings") + if not isinstance(actual_mappings, list): + errors.append("mappings are malformed") + else: + expected_by_key = { + (item["sourceTable"], item["sourceAlias"], item["sourceColumn"]): item + for item in current["mappings"] + } + actual_by_key = { + (item.get("sourceTable"), item.get("sourceAlias"), item.get("sourceColumn")): item + for item in actual_mappings if isinstance(item, Mapping) + } + if actual_by_key.keys() != expected_by_key.keys(): + errors.append("mapping scopes are stale") + for key in actual_by_key.keys() & expected_by_key.keys(): + actual_item, expected_item = actual_by_key[key], expected_by_key[key] + if ( + actual_item.get("evidenceReferences") != expected_item.get("evidenceReferences") + or actual_item.get("status") != expected_item.get("status") + ): + errors.append(f"mapping evidence is stale for {key[0]}.{key[2]}") + for index, mapping in enumerate(record.get("mappings", [])): + if not isinstance(mapping, Mapping): + errors.append(f"mappings[{index}] is malformed") + continue + for reference in mapping.get("evidenceReferences", []): + if not isinstance(reference, Mapping) or not _SHA256.fullmatch(str(reference.get("sha256", ""))): + errors.append(f"mappings[{index}] has an invalid evidence hash") + return errors diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/onboarding.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/onboarding.py new file mode 100644 index 00000000000..b64f0b526e8 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/onboarding.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from . import __version__ +from .graph_auth import validate_delegated_graph_claims +from .graph_client_config import ( + DEFAULT_GRAPH_CLIENT_ID, + azure_cli_profile, + configure_graph_client, + configured_azure_cli_profile, + configure_azure_cli_profile, + graph_auth_record_path, + graph_token_cache_name, + ensure_private_state_root, + restrict_private_permissions, + verify_graph_auth_binding, + write_graph_auth_metadata, +) + +LOG_ANALYTICS_RESOURCE = "https://api.loganalytics.io" +GRAPH_SCOPE = "https://graph.microsoft.com/ThreatHunting.Read.All" +GRAPH_CLIENT_ID = DEFAULT_GRAPH_CLIENT_ID +DEFAULT_STATE_DIR = Path.home() / ".sentinel-xdr-migration" +AUTH_RECORD_NAME = "advanced-hunting-auth-record.json" +CONFIG_NAME = "config.json" +SETUP_STATE_NAME = "setup-state.json" +TOKEN_CACHE_NAME = "sentinel_xdr_migration_advanced_hunting" + +CommandRunner = Callable[..., subprocess.CompletedProcess[str]] + + +def _state_dir(state_dir: str | Path | None = None) -> Path: + configured = state_dir or os.getenv("SENTINEL_XDR_MIGRATION_STATE_DIR") + return Path(configured).expanduser() if configured else DEFAULT_STATE_DIR + + +def _run( + command: Sequence[str], runner: CommandRunner = subprocess.run +) -> subprocess.CompletedProcess[str]: + return runner( + list(command), + capture_output=True, + text=True, + check=False, + ) + + +def _command_status( + name: str, + command: Sequence[str], + *, + runner: CommandRunner, +) -> dict[str, Any]: + executable = shutil.which(command[0]) + if executable is None: + return { + "name": name, + "status": "failed", + "detail": f"{command[0]} is not installed or not on PATH", + } + resolved_command = [executable, *command[1:]] + result = _run(resolved_command, runner) + if result.returncode == 0: + return {"name": name, "status": "passed", "detail": None} + detail = (result.stderr or result.stdout).strip() + return { + "name": name, + "status": "failed", + "detail": detail[-1000:] if detail else f"command exited {result.returncode}", + } + + +def _load_auth_record(path: Path): + if not path.exists(): + return None + from azure.identity import AuthenticationRecord + + try: + return AuthenticationRecord.deserialize(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def configure_workspace( + workspace_resource_id: str, + *, + workspace_customer_id: str | None = None, + tenant_id: str | None = None, + subscription_id: str | None = None, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + normalized = workspace_resource_id.strip().rstrip("/") + lowered = normalized.lower() + if not ( + lowered.startswith("/subscriptions/") + and "/resourcegroups/" in lowered + and "/providers/microsoft.operationalinsights/workspaces/" in lowered + ): + raise ValueError("workspace-resource-id must be a full Log Analytics ARM ID") + workspace_subscription_id = normalized.split("/")[2] + if ( + subscription_id + and workspace_subscription_id.lower() != subscription_id.strip().lower() + ): + raise ValueError( + "workspace ARM ID subscription does not match subscription-id" + ) + + state_root = _state_dir(state_dir) + ensure_private_state_root(state_root) + config_path = state_root / CONFIG_NAME + config = _read_json(config_path) + config["workspaceResourceId"] = normalized + if workspace_customer_id: + config["workspaceId"] = workspace_customer_id.strip() + if tenant_id: + config["tenantId"] = tenant_id.strip() + if subscription_id: + config["subscriptionId"] = subscription_id.strip() + config_path.write_text( + json.dumps(config, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + return { + "status": "configured", + "workspaceResourceId": normalized, + "workspaceCustomerId": config.get("workspaceId"), + "tenantId": config.get("tenantId"), + "subscriptionId": config.get("subscriptionId"), + "configPath": str(config_path), + } + + +def _advanced_hunting_status( + state_root: Path, tenant_id: str | None = None, *, probe: bool = False +) -> dict[str, Any]: + if not probe: + return { + "name": "advancedHunting", + "status": "notRun", + "detail": "Graph authentication and access require an explicitly scoped runtime read", + } + try: + from azure.core.exceptions import ClientAuthenticationError + from azure.identity import ( + AuthenticationRequiredError, + DeviceCodeCredential, + TokenCachePersistenceOptions, + ) + except ImportError: + return { + "name": "advancedHunting", + "status": "failed", + "detail": "azure-identity is not installed", + } + from .graph_client_config import resolve_graph_client_id + + client_id = resolve_graph_client_id(state_root, "advancedHunting") + record_path = graph_auth_record_path( + state_root, "advancedHunting", client_id, AUTH_RECORD_NAME + ) + record = _load_auth_record(record_path) + if record is None: + return { + "name": "advancedHunting", + "status": "failed", + "detail": "one-time Microsoft Graph device-code sign-in is required", + } + try: + verify_graph_auth_binding( + record_path, + record, + purpose="advancedHunting", + client_id=client_id, + tenant_id=tenant_id, + ) + except ValueError as exc: + return {"name": "advancedHunting", "status": "failed", "detail": str(exc)} + tenant_id = tenant_id or getattr(record, "tenant_id", None) + credential = DeviceCodeCredential( + client_id=client_id, + tenant_id=tenant_id, + authentication_record=record, + cache_persistence_options=TokenCachePersistenceOptions( + name=graph_token_cache_name( + "advancedHunting", client_id, TOKEN_CACHE_NAME + ) + ), + disable_automatic_authentication=True, + ) + try: + access_token = credential.get_token(GRAPH_SCOPE).token + except (AuthenticationRequiredError, ClientAuthenticationError) as exc: + return { + "name": "advancedHunting", + "status": "failed", + "detail": f"cached sign-in needs renewal: {exc}", + } + from .graph_readiness import _token_claims + + try: + scope_diagnostics = validate_delegated_graph_claims( + _token_claims(access_token), + tenant_id=tenant_id or "", + required_scopes=(GRAPH_SCOPE,), + purpose="Advanced Hunting", + ) + except RuntimeError as exc: + return { + "name": "advancedHunting", + "status": "failed", + "detail": str(exc), + } + return { + "name": "advancedHunting", + "status": "passed", + "detail": "a Graph access token was obtained; provider authorization was not tested", + "scopeDiagnostics": scope_diagnostics["delegatedScopes"], + } + + +def _sentinel_authentication_status( + *, runner: CommandRunner, probe: bool, azure_config_dir: str | None = None +) -> dict[str, Any]: + if not probe: + return { + "name": "sentinelAuthentication", + "status": "notRun", + "detail": "Log Analytics authentication and access require an explicitly scoped runtime read", + } + with azure_cli_profile(azure_config_dir): + result = _command_status( + "sentinelAuthentication", + ( + "az", + "account", + "get-access-token", + "--resource", + LOG_ANALYTICS_RESOURCE, + "--query", + "expiresOn", + "--output", + "tsv", + ), + runner=runner, + ) + result["name"] = "sentinelAuthentication" + if result["status"] == "passed": + result["detail"] = "an access token was obtained; provider authorization was not tested" + return result + + +def doctor( + *, + state_dir: str | Path | None = None, + runner: CommandRunner = subprocess.run, + environment: Mapping[str, str] | None = None, + solution: str | Path | None = None, + probe_authentication: bool = False, +) -> dict[str, Any]: + state_root = _state_dir(state_dir) + env = environment if environment is not None else os.environ + azure_config_dir = configured_azure_cli_profile(state_root) + setup_state = state_root / SETUP_STATE_NAME + config = _read_json(state_root / CONFIG_NAME) + + python_supported = sys.version_info[:2] in {(3, 11), (3, 12)} + python_status = { + "name": "python", + "status": "passed" if python_supported else "failed", + "detail": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + } + azure_cli = _command_status( + "azureCli", + ("az", "version", "--output", "none"), + runner=runner, + ) + sentinel_auth = _sentinel_authentication_status( + runner=runner, + probe=probe_authentication, + azure_config_dir=azure_config_dir, + ) + workspace_id = env.get("LA_WORKSPACE_ID") or config.get("workspaceId") + workspace_resource_id = config.get("workspaceResourceId") + tenant_id = env.get("AZURE_TENANT_ID") or config.get("tenantId") + subscription_id = env.get("AZURE_SUBSCRIPTION_ID") or config.get("subscriptionId") + resolved_subscription_id = subscription_id + missing_binding = [ + name + for name, value in { + "tenantId": tenant_id, + "workspaceResourceId": workspace_resource_id, + "workspaceCustomerId": workspace_id, + }.items() + if not value + ] + if missing_binding: + workspace_status = "unknown" + workspace_detail = "incomplete local target binding; missing " + ", ".join( + missing_binding + ) + else: + try: + from .target_context import build_target, workspace_identity + + derived_subscription = workspace_identity(str(workspace_resource_id))[ + "subscriptionId" + ] + resolved_subscription_id = subscription_id or derived_subscription + build_target( + tenant_id=str(tenant_id), + subscription_id=str(resolved_subscription_id), + workspace_resource_id=str(workspace_resource_id), + workspace_customer_id=str(workspace_id), + ) + except (AttributeError, TypeError, ValueError) as exc: + workspace_status = "failed" + workspace_detail = str(exc) + else: + workspace_status = "passed" + workspace_detail = "complete local workspace binding; access not tested" + workspace = { + "name": "sentinelWorkspace", + "status": workspace_status, + "detail": workspace_detail, + "workspaceResourceId": workspace_resource_id, + "workspaceCustomerId": workspace_id, + "subscriptionId": resolved_subscription_id, + } + hunting = _advanced_hunting_status( + state_root, config.get("tenantId"), probe=probe_authentication + ) + checks = [python_status, azure_cli, sentinel_auth, workspace, hunting] + runtime_ready = False + + actions: list[str] = [] + if azure_cli["status"] != "passed": + actions.append("Run `sentinel-xdr-migration setup` to authenticate Azure CLI.") + if workspace_status != "passed": + actions.append( + "Complete the exact tenant, subscription, workspace ARM ID, and customer-ID binding." + ) + if sentinel_auth["status"] != "passed" or hunting["status"] != "passed": + actions.append( + "Authentication, authorization, table access, and query access remain unverified until an explicitly scoped runtime read." + ) + + result = { + "toolVersion": __version__, + "firstRun": not setup_state.exists(), + "mode": "runtime" if runtime_ready else "offline", + "conversionAvailable": python_supported, + "runtimeValidationAvailable": runtime_ready, + "configuredWorkspaceResourceId": workspace_resource_id, + "configuredWorkspaceCustomerId": workspace_id, + "configuredTenantId": config.get("tenantId"), + "configuredSubscriptionId": resolved_subscription_id, + "checks": checks, + "recommendedActions": actions, + } + if solution is not None: + from .target_config import doctor_solution_target + + result["solutionTarget"] = doctor_solution_target(solution) + return result + + +def _login_advanced_hunting( + state_root: Path, + tenant_id: str | None, + auth_method: str = "device-code", + client_id: str | None = None, +) -> dict[str, Any]: + from azure.identity import ( + DeviceCodeCredential, + InteractiveBrowserCredential, + TokenCachePersistenceOptions, + ) + + ensure_private_state_root(state_root) + from .graph_client_config import resolve_graph_client_id + + selected_client = resolve_graph_client_id( + state_root, "advancedHunting", client_id + ) + record_path = graph_auth_record_path( + state_root, "advancedHunting", selected_client, AUTH_RECORD_NAME + ) + common_kwargs: dict[str, Any] = { + "client_id": selected_client, + "cache_persistence_options": TokenCachePersistenceOptions( + name=graph_token_cache_name( + "advancedHunting", selected_client, TOKEN_CACHE_NAME + ) + ), + } + if tenant_id: + common_kwargs["tenant_id"] = tenant_id + if auth_method == "browser": + credential = InteractiveBrowserCredential(**common_kwargs) + elif auth_method == "device-code": + credential = DeviceCodeCredential( + **common_kwargs, + prompt_callback=lambda uri, code, _expires: print( + f"Open {uri} and enter code {code}", file=sys.stderr, flush=True + ), + ) + else: + raise ValueError( + "hunting_auth_method must be 'device-code' or 'browser'" + ) + record = credential.authenticate(scopes=[GRAPH_SCOPE]) + access_token = credential.get_token(GRAPH_SCOPE).token + from .graph_readiness import _token_claims + + try: + scope_diagnostics = validate_delegated_graph_claims( + _token_claims(access_token), + tenant_id=getattr(record, "tenant_id", None) or tenant_id or "", + required_scopes=(GRAPH_SCOPE,), + purpose="Advanced Hunting", + ) + except RuntimeError as exc: + return { + "name": "advancedHuntingAuthentication", + "status": "failed", + "detail": str(exc), + } + record_path.write_text(record.serialize(), encoding="utf-8") + restrict_private_permissions(record_path, 0o600) + write_graph_auth_metadata( + record_path, + purpose="advancedHunting", + client_id=selected_client, + tenant_id=getattr(record, "tenant_id", None) or tenant_id, + ) + return { + "name": "advancedHuntingAuthentication", + "status": "completed", + "detail": f"authenticated as {getattr(record, 'username', '') or 'user'}", + "scopeDiagnostics": scope_diagnostics["delegatedScopes"], + } + + +def setup( + *, + interactive: bool = False, + tenant_id: str | None = None, + workspace_id: str | None = None, + skip_sentinel_auth: bool = False, + skip_hunting_auth: bool = False, + hunting_auth_method: str = "device-code", + hunting_client_id: str | None = None, + azure_config_dir: str | Path | None = None, + state_dir: str | Path | None = None, + runner: CommandRunner = subprocess.run, +) -> dict[str, Any]: + state_root = _state_dir(state_dir) + ensure_private_state_root(state_root) + actions: list[dict[str, Any]] = [] + + config_path = state_root / CONFIG_NAME + config = _read_json(config_path) + tenant_id = tenant_id or config.get("tenantId") + if tenant_id: + config["tenantId"] = tenant_id + if workspace_id: + config["workspaceId"] = workspace_id + if config: + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + if hunting_client_id: + configure_graph_client( + state_root, "advancedHunting", hunting_client_id + ) + if azure_config_dir is not None: + configure_azure_cli_profile(state_root, azure_config_dir) + if workspace_id: + actions.append( + { + "name": "workspaceConfiguration", + "status": "completed", + "detail": "workspace identifier saved in the local non-secret configuration", + } + ) + + current = doctor( + state_dir=state_root, runner=runner, probe_authentication=True + ) + sentinel_check = next( + check for check in current["checks"] if check["name"] == "sentinelAuthentication" + ) + if not skip_sentinel_auth and sentinel_check["status"] != "passed": + if interactive: + az_executable = shutil.which("az") + if az_executable is None: + actions.append( + { + "name": "sentinelAuthentication", + "status": "failed", + "detail": "Azure CLI is not installed or not on PATH", + } + ) + az_executable = "" + command = [az_executable, "login"] + if tenant_id: + command.extend(["--tenant", tenant_id]) + if az_executable: + with azure_cli_profile( + str(azure_config_dir) + if azure_config_dir is not None + else configured_azure_cli_profile(state_root) + ): + result = subprocess.run(command, check=False) + actions.append( + { + "name": "sentinelAuthentication", + "status": "completed" if result.returncode == 0 else "failed", + "detail": None if result.returncode == 0 else "az login failed", + } + ) + else: + actions.append( + { + "name": "sentinelAuthentication", + "status": "actionRequired", + "detail": "rerun setup interactively to launch `az login`", + } + ) + + hunting_check = next( + check for check in current["checks"] if check["name"] == "advancedHunting" + ) + if not skip_hunting_auth and hunting_check["status"] != "passed": + if interactive: + try: + actions.append( + _login_advanced_hunting( + state_root, + tenant_id, + auth_method=hunting_auth_method, + client_id=hunting_client_id, + ) + ) + except Exception as exc: + actions.append( + { + "name": "advancedHuntingAuthentication", + "status": "failed", + "detail": f"{type(exc).__name__}: {exc}", + } + ) + else: + actions.append( + { + "name": "advancedHuntingAuthentication", + "status": "actionRequired", + "detail": ( + "rerun setup interactively for device-code or browser sign-in" + ), + } + ) + + state = { + "toolVersion": __version__, + "initializedAt": datetime.now(timezone.utc).isoformat(), + "interactiveAuthenticationRequested": interactive, + } + (state_root / SETUP_STATE_NAME).write_text( + json.dumps(state, indent=2) + "\n", encoding="utf-8" + ) + final_status = doctor( + state_dir=state_root, runner=runner, probe_authentication=True + ) + return { + "setupCompleted": True, + "mode": final_status["mode"], + "actions": actions, + "doctor": final_status, + "message": ( + "Runtime validation is ready." + if final_status["runtimeValidationAvailable"] + else "Offline conversion is ready; runtime authentication can be completed later." + ), + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py new file mode 100644 index 00000000000..4be74657e39 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py @@ -0,0 +1,1083 @@ +from __future__ import annotations + +import hashlib +import json +import re +import shutil +import subprocess +import sys +import uuid +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .artifacts import ( + report_directory, + repository_root, + write_json_artifact, + require_solution_conversion_scope, +) +from .content_paths import content_path + + +PACKAGER_NAME = "V3.1" +TACTIC_PROJECTION_PREFIX = "V3.1 TACTIC PROJECTION: " +IDENTITY_PROJECTION_PREFIX = "V3.1 ID PROJECTION: " +DATA_EXCLUSIONS = { + "parameter.json", + "parameters.json", + "system_generated_metadata.json", + "testparameters.json", +} +PACKAGE_OUTPUT_DIRECTORIES = { + "package", + "logs", + "evidence", + ".git", + ".venv", + "__pycache__", +} +V31_TARGET_VERSION_PATTERN = re.compile( + r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z" +) +V31_TOOLKIT_PATHS = ( + "Tools/Create-Azure-Sentinel-Solution/V3/createSolutionV3_1.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/createSolutionLocal.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/get-ccp-details.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/LogAppInsights.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/summaryRules.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.json", + "Tools/Create-Azure-Sentinel-Solution/common/contentDeploymentParameters.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/templating/baseMainTemplate.json", + "Tools/Create-Azure-Sentinel-Solution/common/templating/baseCreateUiDefinition.json", + "Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/customerUsageAttribution.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/AttributionMetadata.cs", + "Tools/Create-Azure-Sentinel-Solution/common/versionPolicy.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/standardLogStreams.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/storageAccountDeploymentTemplate.ps1", + "Tools/Create-Azure-Sentinel-Solution/common/eventGridManagedIdentityDeploymentTemplate.ps1", + "Tools/Create-Azure-Sentinel-Solution/arm-ttk/download-arm-ttk.ps1", + "Tools/Create-Azure-Sentinel-Solution/arm-ttk/run-arm-ttk-in-automation.ps1", + "Tools/SentinelToXDRMigration/kql/rename-parser-bindings.cjs", +) +_NON_PATH_ARRAYS = { + "dependentdomainsolutionids", + "staticdataconnectorids", + "xdr detections", + "custom detections", +} +_ENCODED_CONTENT_ARRAYS = { + "parsers", + "data connectors", + "dataconnectors", + "playbooks", + "workbooks", + "analytic rules", + "analytics rules", + "hunting queries", + "watchlists", + "summary rules", + "summaryrules", + "xdr detections", + "custom detections", + "savedsearches", +} + + +def validate_v31_target_version(version: str | None) -> str: + """Validate an explicitly selected canonical V3.1 solution release version.""" + if not isinstance(version, str) or not V31_TARGET_VERSION_PATTERN.fullmatch(version): + raise ValueError( + "target version must be canonical major.minor.patch in the V3.1 " + "range >= 3.1.0 and < 4.0.0" + ) + major, minor, _patch = version.split(".") + if major != "3" or minor == "0": + raise ValueError( + "target version must be in the V3.1 range >= 3.1.0 and < 4.0.0" + ) + return version + + +def package_input_file_hashes(solution: str | Path) -> dict[str, str]: + """Return the complete package-input file manifest, excluding generated output.""" + root = Path(solution).expanduser().resolve() + files: dict[str, str] = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + parts = relative.parts + if any(part.lower() in PACKAGE_OUTPUT_DIRECTORIES for part in parts): + continue + if any(part.lower() == ".env" or part.lower().startswith(".env.") for part in parts): + continue + if path.is_symlink(): + raise ValueError(f"package input contains an unsafe symlink: {relative.as_posix()}") + if not path.is_file(): + continue + files[relative.as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() + return files + + +def package_input_identity(solution: str | Path) -> dict[str, Any]: + """Hash the complete local solution input tree, excluding generated/runtime output.""" + files = package_input_file_hashes(solution) + canonical = json.dumps( + [{"path": path, "sha256": digest} for path, digest in files.items()], + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return { + "sha256": hashlib.sha256(canonical).hexdigest(), + "fileCount": len(files), + } + + +def solution_data_version(solution: str | Path) -> str | None: + root = Path(solution).expanduser().resolve() + data_directory = root / "Data" + if not data_directory.is_dir(): + return None + try: + value = _value(_read_json(_solution_data_file(data_directory)), "Version") + return str(value or "").strip() or None + except (OSError, ValueError): + return None + + +def version_bump_was_consumed( + before: str | None, + after: str | None, + version_bump: str, +) -> bool: + if not before or not after or before == after: + return False + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", before) + if not match: + return False + major, minor, patch = (int(value) for value in match.groups()) + if version_bump == "patch": + expected = f"{major}.{minor}.{patch + 1}" + elif version_bump == "minor": + expected = f"{major}.{minor + 1}.0" + elif version_bump == "major": + expected = f"{major + 1}.0.0" + else: + return False + return after == expected + + +def _package_artifact_hashes(paths: dict[str, Path]) -> dict[str, str]: + return { + name: hashlib.sha256(path.read_bytes()).hexdigest() + for name, path in sorted(paths.items()) + } + + +def _canonical_digest(value: dict[str, Any]) -> str: + content = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(content).hexdigest() + + +def _deployment_parameters( + template: dict[str, Any], ui: dict[str, Any], test_parameters: dict[str, Any], + *, has_xdr: bool, contract_path: Path | None = None, +) -> dict[str, bool]: + contract_path = contract_path or ( + Path(__file__).resolve().parents[2] / "Create-Azure-Sentinel-Solution" + / "common" / "contentDeploymentParameters.json" + ) + contract = _read_json(contract_path) + parameters = template.get("parameters", {}) + if {"E5Flavor", "RegisterE5Content"} & parameters.keys(): + raise RuntimeError("V3.1 package declares obsolete E5 deployment controls; rebuild it.") + selected = {name: value for name, value in parameters.items() if name.startswith("Deploy")} + outputs = ui.get("parameters", {}).get("outputs", {}) + controls = { + element["name"]: element + for step in ui.get("parameters", {}).get("steps", []) + if step.get("name") == "contentSelection" + for element in step.get("elements", []) + } + if set(selected) != set(controls) or set(selected) != { + name for name in outputs if name.startswith("Deploy") + }: + raise RuntimeError("V3.1 deployment parameters and UI selections disagree.") + if test_parameters != parameters: + raise RuntimeError("V3.1 testParameters.json must match the declared parameter subset.") + for name, value in selected.items(): + expected = contract.get(name) + if ( + expected is None or value.get("type") != "bool" + or value.get("defaultValue") is not expected["defaultValue"] + or controls[name].get("defaultValue") is not expected["defaultValue"] + or controls[name].get("type") != "Microsoft.Common.CheckBox" + or outputs[name] != f"[steps('contentSelection').{name}]" + ): + raise RuntimeError(f"Invalid V3.1 content selection contract for {name}.") + if has_xdr != ("DeployCustomDetection" in selected): + raise RuntimeError("V3.1 Custom Detection declaration does not match the packaged XDR inputs.") + return {name: value["defaultValue"] for name, value in selected.items()} + + +def _solution_data_file(data_directory: Path) -> Path: + solution = data_directory.parent.resolve() + candidates = sorted( + path + for path in data_directory.glob("*.json") + if path.name.lower() not in DATA_EXCLUSIONS + ) + if len(candidates) != 1: + raise ValueError( + f"expected exactly one solution data JSON in {data_directory}, " + f"found {len(candidates)}" + ) + return content_path(solution, candidates[0].relative_to(solution)) + + +def _validate_solution_name(name: Any, solution: Path) -> None: + if ( + not isinstance(name, str) + or not name.strip() + or name in {".", ".."} + or "/" in name + or "\\" in name + or ":" in name + or name.casefold() != solution.name.casefold() + ): + raise ValueError( + "solution Data Name must match the selected solution folder and must not be a path" + ) + + +def _solution_relative_reference(solution: Path, reference: Any, label: str) -> Path: + if not isinstance(reference, str) or not reference.strip(): + raise ValueError(f"solution Data {label} contains an invalid local path reference") + normalized = reference.replace("\\", "/") + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", normalized): + raise ValueError(f"solution Data {label} must use a local solution-relative path") + parts = normalized.split("/") + if normalized.startswith("/") or any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"solution Data {label} path must be relative and contained: {reference!r}") + if parts[0] == "Solutions": + if len(parts) < 3 or parts[1] != solution.name: + raise ValueError( + f"solution Data {label} repository path must name the selected solution" + ) + normalized = "/".join(parts[2:]) + parts = normalized.split("/") + if any(part.lower() in {"logs", "reports", "evidence"} for part in parts): + raise ValueError("solution Data content arrays must never include Logs, Reports or Evidence") + if any(part.lower() == ".env" or part.lower().startswith(".env.") for part in parts): + raise ValueError( + "solution Data content arrays must never include local .env configuration" + ) + candidate = content_path(solution, normalized) + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError(f"referenced solution content does not exist as a local file: {reference}") + return candidate.resolve() + + +def _validate_solution_package_inputs(solution: Path, document: dict[str, Any]) -> None: + name = _value(document, "Name") + _validate_solution_name(name, solution) + + metadata = _value(document, "Metadata") + if metadata is not None: + metadata_path = _solution_relative_reference(solution, metadata, "Metadata") + if not metadata_path.is_relative_to(solution.resolve()): + raise ValueError("solution Data Metadata path resolves outside the selected solution") + + for key, value in list(document.items()): + if isinstance(value, str) and key.casefold() in _ENCODED_CONTENT_ARRAYS: + try: + value = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError( + f"solution Data {key} must be a JSON-encoded content array" + ) from exc + if not isinstance(value, list) or any( + not isinstance(reference, str) or not reference.strip() + for reference in value + ): + raise ValueError(f"solution Data {key} must be a JSON-encoded content array") + document[key] = value + if not isinstance(value, list): + continue + if key.casefold() in {"xdr detections", "custom detections"}: + for reference in value: + if isinstance(reference, str): + parts = reference.replace("\\", "/").split("/") + if any(part.lower() in {"logs", "reports", "evidence"} for part in parts): + raise ValueError( + "solution Data content arrays must never include Logs, Reports or Evidence" + ) + if any( + part.lower() == ".env" or part.lower().startswith(".env.") + for part in parts + ): + raise ValueError( + "solution Data content arrays must never include local .env configuration" + ) + continue + if key.casefold() in _NON_PATH_ARRAYS: + continue + for reference in value: + _solution_relative_reference(solution, reference, key) + + +def _validate_v31_toolkit_root(toolkit_root: str | Path) -> Path: + if isinstance(toolkit_root, str) and not toolkit_root.strip(): + raise ValueError("V3.1 toolkit root must not be empty") + try: + root = Path(toolkit_root).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise ValueError(f"V3.1 toolkit root does not exist: {toolkit_root}") from exc + if not root.is_dir(): + raise ValueError(f"V3.1 toolkit root is not a directory: {root}") + missing = [relative for relative in V31_TOOLKIT_PATHS if not (root / relative).is_file()] + if missing: + raise ValueError( + f"V3.1 toolkit root is incomplete: {root}; missing " + + ", ".join(missing) + ) + return root + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid JSON file {path}: {exc}") from exc + if not isinstance(value, dict): + raise ValueError(f"JSON file must contain an object: {path}") + return value + + +def _value(document: dict[str, Any], name: str) -> Any: + for key, value in document.items(): + if key.lower() == name.lower(): + return value + return None + + +def _xdr_references(solution: Path, document: dict[str, Any]) -> list[Path]: + keys = [ + key for key in ("XDR Detections", "Custom Detections") + if any(name.casefold() == key.casefold() for name in document) + ] + if len(keys) > 1: + raise ValueError("solution data has ambiguous XDR Detection selection arrays") + if not keys: + folder = solution / "XDR Detections" + discovered = [ + path for path in folder.rglob("*") + if path.is_file() and path.suffix.casefold() in {".yaml", ".yml"} + ] if folder.is_dir() else [] + if discovered: + raise ValueError( + "solution data is missing an explicit XDR Detections array; " + "folder discovery is not a package-selection policy" + ) + return [] + values = _value(document, keys[0]) + if not isinstance(values, list): + raise ValueError("solution data XDR Detections must be an array") + + references: list[Path] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str) or not value.strip(): + raise ValueError("solution data contains an invalid XDR Detection reference") + normalized = value.replace("\\", "/") + parts = normalized.split("/") + if ( + not normalized.startswith("XDR Detections/") + or any(part in {"", ".", ".."} for part in parts) + or normalized.startswith("/") + or re.match(r"^[A-Za-z]:", normalized) + ): + raise ValueError("XDR references must be under XDR Detections, never Logs or Reports") + candidate = content_path(solution, normalized) + if not candidate.is_file(): + raise ValueError(f"referenced XDR Detection does not exist: {candidate}") + resolved = candidate.resolve() + if not resolved.is_relative_to(solution.resolve()) or candidate.is_symlink(): + raise ValueError(f"XDR Detection reference resolves outside the solution: {value!r}") + key = resolved.as_posix().casefold() + if key in seen: + raise ValueError(f"duplicate XDR Detection reference: {value!r}") + seen.add(key) + references.append(resolved) + return references + + +def _verify_package_selection( + solution: Path, + selection_plan: dict[str, Any], + template: dict[str, Any], + zip_path: Path, +) -> dict[str, Any]: + from .packaging_selection import _partial_coverage_allowed + + plan_sha = selection_plan.get("planSha256") + if not isinstance(plan_sha, str) or not re.fullmatch(r"[0-9a-f]{64}", plan_sha): + raise ValueError("package selection evidence has no valid plan SHA-256") + expected = selection_plan.get("selection") or {} + summary = selection_plan.get("summary") or {} + if summary.get("outcome") == "partial" and not _partial_coverage_allowed(summary): + raise ValueError( + "partial package selection does not meet the strict over-50% " + "validated migrated-detection coverage policy" + ) + if summary.get("outcome") == "blocked": + raise ValueError("blocked package selection cannot be packaged") + data = _read_json(_solution_data_file(solution / "Data")) + current = { + "analyticRules": _value(data, "Analytic Rules") or _value(data, "Analytics Rules") or [], + "xdrDetections": _value(data, "XDR Detections") or _value(data, "Custom Detections") or [], + } + normalized_expected = { + "analyticRules": [str(value).replace("\\", "/") for value in expected.get("analyticRules", [])], + "xdrDetections": [str(value).replace("\\", "/") for value in expected.get("xdrDetections", [])], + } + if current != normalized_expected: + raise RuntimeError("solution-data selections differ from the applied package selection plan") + + def resources(document: dict[str, Any]) -> list[tuple[dict[str, Any], dict[str, Any]]]: + found: list[tuple[dict[str, Any], dict[str, Any]]] = [] + + def visit(template: Any, inherited_variables: dict[str, Any] | None = None) -> None: + if not isinstance(template, dict): + return + variables = dict(inherited_variables or {}) + template_variables = template.get("variables") + if isinstance(template_variables, dict): + variables.update(template_variables) + entries = template.get("resources") + if isinstance(entries, dict): + entries = [ + ({**entry, "name": entry.get("name", name)} if isinstance(entry, dict) else entry) + for name, entry in entries.items() + ] + if isinstance(entries, list): + for resource in entries: + if not isinstance(resource, dict): + continue + found.append((resource, variables)) + properties = resource.get("properties") + if isinstance(properties, dict): + visit(properties.get("template"), variables) + visit(properties.get("mainTemplate"), variables) + visit(template.get("template"), variables) + visit(template.get("mainTemplate"), variables) + + visit(document) + return found + + def exact_token(value: Any, identifier: str) -> bool: + if not isinstance(value, str): + return False + return re.search( + rf"(? Any: + value = next( + (item for key, item in variables.items() if key.casefold() == name.casefold()), + None, + ) + if field is None or not isinstance(value, dict): + return value + return next( + (item for key, item in value.items() if key.casefold() == field.casefold()), + None, + ) + + def resource_ids(value: Any, variables: dict[str, Any], seen: frozenset[str] = frozenset()) -> set[str]: + if isinstance(value, dict): + return set().union(*(resource_ids(item, variables, seen) for item in value.values())) + if isinstance(value, list): + return set().union(*(resource_ids(item, variables, seen) for item in value)) + if not isinstance(value, str): + return set() + identifiers = {match.group(0).casefold() for match in guid_token.finditer(value)} + for match in variable_reference.finditer(value): + reference = f"{match.group(1)}.{match.group(2) or ''}".casefold() + if reference in seen: + continue + resolved = variable_value(variables, match.group(1), match.group(2)) + identifiers.update( + resource_ids(resolved, variables, seen | {reference}) + ) + return identifiers + + def resource_matches_id( + resource: dict[str, Any], + variables: dict[str, Any], + identifier: str, + ) -> bool: + properties = resource.get("properties") + if not isinstance(properties, dict): + properties = {} + identity_fields = [ + resource.get("name"), + properties.get("id"), + properties.get("alertRuleTemplateName"), + properties.get("contentId"), + ] + if guid_token.fullmatch(identifier): + return any( + identifier.casefold() in resource_ids(value, variables) + for value in identity_fields + ) + return any(exact_token(value, identifier.casefold()) for value in identity_fields) + + def custom_detection_child( + document: dict[str, Any], + packaged_id: str, + ) -> bool: + return any( + str(child.get("type") or "").casefold() + == "microsoft.security/detectionrules" + and str(child.get("name") or "").casefold() == "detectionrule" + and exact_token( + ( + child.get("properties", {}).get("id") + if isinstance(child.get("properties"), dict) + else None + ), + packaged_id, + ) + for child, _ in resources(document) + ) + + def verify_template(document: dict[str, Any], label: str) -> None: + tree = resources(document) + for item in selection_plan.get("analyticRules", []): + identifier = str(item.get("id") or "").casefold() + if not identifier: + raise ValueError("package selection inventory contains an Analytic Rule without an ID") + matching = [ + resource for resource, variables in tree + if str(resource.get("type") or "").casefold() in { + "microsoft.operationalinsights/workspaces/providers/alertrules", + "microsoft.securityinsights/alertruletemplates", + } + and resource_matches_id(resource, variables, identifier) + ] + analytic_content_templates = [ + resource for resource, variables in tree + if str(resource.get("type") or "").casefold() + == "microsoft.operationalinsights/workspaces/providers/contenttemplates" + and isinstance(resource.get("properties"), dict) + and str(resource["properties"].get("contentKind") or "").casefold() + == "analyticsrule" + and resource_matches_id(resource, variables, identifier) + ] + expected_count = 1 if item.get("included") else 0 + if ( + len(matching) != expected_count + or len(analytic_content_templates) != expected_count + ): + raise RuntimeError( + f"{label} must contain exactly {expected_count} matching Analytic Rule " + f"resources and content templates for {item.get('path')}; found " + f"{len(matching)} resource(s), {len(analytic_content_templates)} content template(s)" + ) + for item in selection_plan.get("xdrDetections", []): + source_id = str(item.get("sourceId") or "") + detection_id = str(item.get("id") or "") + if not detection_id: + raise ValueError("package selection inventory contains a Custom Detection without an ID") + packaged_id = ( + f"xdr-{source_id}" + if re.fullmatch( + r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", + source_id, + ) + else detection_id + ).casefold() + installation_resources = [] + for resource, _ in tree: + resource_type = str(resource.get("type") or "").casefold() + properties = resource.get("properties") or {} + if ( + resource_type == "microsoft.resources/deployments" + and isinstance(properties, dict) + and isinstance(properties.get("template"), dict) + and custom_detection_child(properties["template"], packaged_id) + ): + installation_resources.append(resource) + install_names = { + resource["name"] + for resource in installation_resources + if isinstance(resource.get("name"), str) + } + registration_count = 0 + for resource, _ in tree: + resource_type = str(resource.get("type") or "").casefold() + properties = resource.get("properties") or {} + if ( + resource_type == "microsoft.operationalinsights/workspaces/providers/contenttemplates" + and str(properties.get("contentKind") or "").casefold() == "customdetection" + and exact_token(properties.get("contentId"), packaged_id) + and isinstance(properties.get("mainTemplate"), dict) + and custom_detection_child(properties["mainTemplate"], packaged_id) + ): + dependencies = resource.get("dependsOn") or [] + if isinstance(dependencies, str): + dependencies = [dependencies] + dependency_names = [ + name.casefold() + for dependency in dependencies + if isinstance(dependency, str) + for name in deployment_reference.findall(dependency) + ] + linked_installations = [ + name for name in dependency_names if name in { + install_name.casefold() for install_name in install_names + } + ] + if len(linked_installations) == 1: + registration_count += 1 + expected_count = 1 if item.get("included") else 0 + if len(installation_resources) != expected_count or registration_count != expected_count: + raise RuntimeError( + f"{label} must contain exactly {expected_count} Custom Detection installations " + f"and registrations for {item.get('path')}; found " + f"{len(installation_resources)} installation(s), {registration_count} registration(s)" + ) + if not normalized_expected["xdrDetections"]: + parameters = document.get("parameters") or {} + if "DeployCustomDetection" in parameters: + raise RuntimeError( + f"{label} emits Custom Detection controls for an explicitly empty XDR selection" + ) + + verify_template(template, "mainTemplate.json") + try: + with zipfile.ZipFile(zip_path) as archive: + matches = [ + name for name in archive.namelist() + if Path(name).name.casefold() == "maintemplate.json" + ] + if len(matches) != 1: + raise RuntimeError("package ZIP must contain exactly one mainTemplate.json") + embedded = json.loads(archive.read(matches[0])) + excluded_references = [ + ("Analytic Rules", item) + for item in selection_plan.get("analyticRules", []) + if not item.get("included") + ] + [ + ("XDR Detections", item) + for item in selection_plan.get("xdrDetections", []) + if not item.get("included") + ] + text_entries = [] + for member in archive.namelist(): + normalized_member = member.replace("\\", "/") + parts = [part for part in normalized_member.split("/") if part] + for family, item in excluded_references: + path_parts = str(item.get("path") or "").replace("\\", "/").split("/") + for index, part in enumerate(parts): + if ( + part.casefold() == family.casefold() + and [value.casefold() for value in parts[index:]] + == [value.casefold() for value in path_parts] + ): + raise RuntimeError( + f"package ZIP includes explicitly excluded content: {item.get('path')}" + ) + if Path(normalized_member).suffix.casefold() in {".json", ".yaml", ".yml", ".txt"}: + text_entries.append((member, archive.read(member).decode("utf-8", errors="replace"))) + except (OSError, zipfile.BadZipFile, json.JSONDecodeError) as exc: + raise RuntimeError(f"cannot verify the package selection inside ZIP: {exc}") from exc + if not isinstance(embedded, dict): + raise RuntimeError("package ZIP mainTemplate.json must contain an object") + verify_template(embedded, "package ZIP mainTemplate.json") + for family, item in excluded_references: + identifiers = [] + if family == "Analytic Rules": + identifiers.extend((str(item.get("id") or ""), str(item.get("path") or ""))) + else: + source_id = str(item.get("sourceId") or "") + detection_id = str(item.get("id") or "") + identifiers.append( + f"xdr-{source_id}" + if re.fullmatch( + r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", + source_id, + ) + else detection_id + ) + identifiers.append(detection_id) + identifiers.append(str(item.get("path") or "")) + for member, content in text_entries: + searchable = content.replace("\\", "/") + if any(exact_token(searchable, identifier.casefold()) for identifier in identifiers if identifier): + raise RuntimeError( + f"package ZIP contains a reference to explicitly excluded content " + f"{item.get('path')} in {member}" + ) + return { + "planSha256": plan_sha, + "analyticRuleIncluded": len(normalized_expected["analyticRules"]), + "xdrDetectionIncluded": len(normalized_expected["xdrDetections"]), + "outcome": summary.get("outcome"), + "coverageNumerator": summary.get("coverageNumerator"), + "coverageDenominator": summary.get("coverageDenominator"), + "partialPublicationAllowed": summary.get("partialPublicationAllowed"), + "mainTemplateVerified": True, + "zipVerified": True, + } + + +def package_solution_v3_1( + solution: str | Path, + *, + version_bump: str, + target_version: str | None = None, + toolkit_root: str | Path | None = None, + requested_version_bump: str | None = None, + package_attempt_id: str | None = None, + workflow_run_id: str | None = None, + selection_plan: dict[str, Any] | None = None, +) -> dict[str, Any]: + if version_bump not in {"none", "patch", "minor", "major"}: + raise ValueError("version bump must be none, patch, minor, or major") + if target_version is not None: + validate_v31_target_version(target_version) + if version_bump != "none": + raise ValueError("target version cannot be combined with a version bump") + + root = Path(solution).expanduser().resolve() + if not root.is_dir(): + raise ValueError(f"solution folder does not exist: {root}") + require_solution_conversion_scope(root) + applied_selection = None + if workflow_run_id is not None: + from .packaging_selection import verify_applied_package_selection + + applied_selection = verify_applied_package_selection(root, workflow_run_id) + if selection_plan is not None and ( + not applied_selection + or applied_selection["plan"].get("planSha256") + != selection_plan.get("planSha256") + ): + raise ValueError("V3.1 packaging selection is missing, stale, or mismatched") + if selection_plan is None and applied_selection is not None: + raise ValueError( + "an applied evidence-based selection must be passed explicitly to V3.1 packaging" + ) + elif selection_plan is not None: + raise ValueError("V3.1 evidence-based packaging selection requires a bound workflow run ID") + repository = repository_root(root) + selected_toolkit_root = _validate_v31_toolkit_root( + toolkit_root if toolkit_root is not None else repository + ) + reports = report_directory(root, create=True) + data_directory = root / "Data" + if not data_directory.is_dir(): + raise ValueError(f"solution data folder does not exist: {data_directory}") + + data_file = _solution_data_file(data_directory) + before = _read_json(data_file) + _validate_solution_package_inputs(root, before) + xdr_files = _xdr_references(root, before) + script = ( + selected_toolkit_root + / "Tools" + / "Create-Azure-Sentinel-Solution" + / "V3" + / "createSolutionV3_1.ps1" + ) + if not script.is_file(): + raise ValueError(f"V3.1 solution packager does not exist: {script}") + pwsh = shutil.which("pwsh") + if not pwsh: + raise RuntimeError("PowerShell 7 is required to run the V3.1 solution packager.") + + command = [ + pwsh, + "-NoProfile", + "-File", + str(script), + "-SolutionDataFolderPath", + str(data_directory), + "-VersionMode", + "local", + "-VersionBump", + version_bump, + ] + if target_version is not None: + command.extend(["-TargetVersion", target_version]) + if toolkit_root is not None: + command.extend(["-ToolkitRootPath", str(selected_toolkit_root)]) + input_before = package_input_identity(root) + if applied_selection is not None: + current_selection = verify_applied_package_selection(root, workflow_run_id) + if ( + not current_selection + or current_selection["plan"].get("planSha256") + != selection_plan.get("planSha256") + ): + raise ValueError( + "V3.1 package selection changed immediately before the package build" + ) + package_attempt_id = package_attempt_id or str(uuid.uuid4()) + completed = subprocess.run( + command, + cwd=repository, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + output = "\n".join( + value.strip() for value in (completed.stdout, completed.stderr) if value.strip() + ) + if completed.returncode != 0: + raise RuntimeError( + f"V3.1 solution packaging failed with exit code {completed.returncode}: " + f"{output[-4000:]}" + ) + if "Starting Package Creation using V3.1 tool" not in output: + raise RuntimeError("Packaging output did not confirm the V3.1 entry point.") + warnings = [ + line for line in output.splitlines() + if any(label in line for label in ( + "CUSTOMER USAGE ATTRIBUTION:", "V3.1 CONTENT SELECTION:", + )) + ] + for warning in warnings: + print(warning, file=sys.stderr) + informational = [ + line for line in output.splitlines() + if line.startswith(( + "INFORMATION: V3.1 TACTIC SELECTION:", + "INFORMATION: V3.1 ID NORMALIZATION:", + )) + ] + for message in informational: + print(message, file=sys.stderr) + tactic_projection = [] + identity_projection = [] + for line in output.splitlines(): + if line.startswith(TACTIC_PROJECTION_PREFIX): + try: + record = json.loads(line[len(TACTIC_PROJECTION_PREFIX):]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Invalid V3.1 tactic projection evidence: {exc}") from exc + if ( + not isinstance(record, dict) + or not isinstance(record.get("detectionId"), str) + or not record["detectionId"] + or any( + not isinstance(record.get(key), list) + or any(not isinstance(value, str) for value in record[key]) + for key in ("authoredTactics", "packagedTactics", "omittedTactics") + ) + ): + raise RuntimeError("V3.1 tactic projection evidence has an invalid structure.") + tactic_projection.append(record) + elif line.startswith(IDENTITY_PROJECTION_PREFIX): + try: + record = json.loads(line[len(IDENTITY_PROJECTION_PREFIX):]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Invalid V3.1 identity projection evidence: {exc}") from exc + if ( + not isinstance(record, dict) + or not isinstance(record.get("inputId"), str) + or not isinstance(record.get("packagedId"), str) + or not isinstance(record.get("changed"), bool) + or record.get("scope") != "package-projection-only" + ): + raise RuntimeError("V3.1 identity projection evidence has an invalid structure.") + identity_projection.append(record) + attribution_sources = [ + line for line in output.splitlines() if "CUSTOMER USAGE ATTRIBUTION SOURCE:" in line + ] + for source in attribution_sources: + print(source, file=sys.stderr) + + after = _read_json(data_file) + version = str(_value(after, "Version") or "").strip() + if not version: + raise ValueError(f"solution data does not declare a package version: {data_file}") + if target_version is not None and version != target_version: + raise RuntimeError( + "V3.1 packager did not persist the explicitly selected target version: " + f"{version!r} != {target_version!r}" + ) + + package = root / "Package" + main_template = package / "mainTemplate.json" + create_ui = package / "createUiDefinition.json" + test_parameters = package / "testParameters.json" + zip_path = package / f"{version}.zip" + required = (main_template, create_ui, test_parameters, zip_path) + missing = [str(path) for path in required if not path.is_file()] + if missing: + raise RuntimeError( + "V3.1 packaging completed without required artifacts: " + ", ".join(missing) + ) + template_document = _read_json(main_template) + deployment_parameters = _deployment_parameters( + template_document, _read_json(create_ui), _read_json(test_parameters), + has_xdr=bool(xdr_files), + contract_path=( + selected_toolkit_root + / "Tools" / "Create-Azure-Sentinel-Solution" / "common" + / "contentDeploymentParameters.json" + ), + ) + try: + with zipfile.ZipFile(zip_path) as archive: + names = {Path(name).name for name in archive.namelist()} + if any( + part.lower() in {"logs", "reports", "evidence"} + for name in archive.namelist() + for part in name.replace("\\", "/").split("/") + ): + raise RuntimeError("generated package ZIP must not contain Logs, Reports or Evidence") + if any( + part.lower() == ".env" or part.lower().startswith(".env.") + for name in archive.namelist() + for part in name.replace("\\", "/").split("/") + ): + raise RuntimeError( + "generated package ZIP must not contain local .env configuration" + ) + except (OSError, zipfile.BadZipFile) as exc: + raise RuntimeError(f"generated package ZIP is invalid: {zip_path}: {exc}") from exc + missing_entries = {"mainTemplate.json", "createUiDefinition.json"} - names + if missing_entries: + raise RuntimeError( + "generated package ZIP is missing: " + ", ".join(sorted(missing_entries)) + ) + selection_verification = ( + _verify_package_selection(root, selection_plan, template_document, zip_path) + if selection_plan is not None else None + ) + + artifact_paths = { + "mainTemplate": main_template, + "createUiDefinition": create_ui, + "testParameters": test_parameters, + "zip": zip_path, + } + artifact_hashes = _package_artifact_hashes(artifact_paths) + input_after = package_input_identity(root) + input_after_files = package_input_file_hashes(root) + input_after_manifest_identity = hashlib.sha256( + json.dumps( + [ + {"path": name, "sha256": digest} + for name, digest in input_after_files.items() + ], + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + if ( + input_after_manifest_identity != input_after["sha256"] + or len(input_after_files) != input_after["fileCount"] + ): + raise RuntimeError("package inputs changed while the V3.1 receipt manifest was captured") + reports = report_directory(root, create=True) + report_path = reports / "packaging.v3_1.json" + if report_path.exists(): + suffix = package_attempt_id or uuid.uuid4().hex + report_path = reports / f"packaging.v3_1.{suffix}.json" + selection_outcome = ( + "partial-package-validated" + if selection_plan is not None + and selection_plan.get("summary", {}).get("outcome") == "partial" + else "package-validated" + if selection_plan is not None else None + ) + result = { + "status": "passed", + "packager": PACKAGER_NAME, + "warnings": warnings, + "informational": informational, + "tacticProjection": tactic_projection, + "identityProjection": identity_projection, + "attributionSources": attribution_sources, + "solution": str(root), + "toolkitRoot": str(selected_toolkit_root), + "packagerScript": str(script), + "version": version, + "versionBump": version_bump, + "requestedVersionBump": requested_version_bump or version_bump, + "effectiveVersionBump": version_bump, + "requestedTargetVersion": target_version, + "effectiveTargetVersion": target_version, + "packageAttemptId": package_attempt_id, + "workflowRunId": workflow_run_id, + "generatedAt": datetime.now(timezone.utc).isoformat(), + "packageInputs": { + "before": input_before, + "after": { + **input_after, + "files": input_after_files, + }, + }, + "packageArtifactHashes": artifact_hashes, + "packageArtifactsSha256": _canonical_digest(artifact_hashes), + "solutionData": str(data_file), + "xdrDetectionCount": len(xdr_files), + "deploymentParameters": deployment_parameters, + "customDetectionRegistrationSupport": "unverified" if xdr_files else "notApplicable", + "selectionOutcome": selection_outcome, + "selectionPlanSha256": ( + selection_plan.get("planSha256") if selection_plan is not None else None + ), + "selectionPlanPath": ( + applied_selection["planPath"] if applied_selection is not None else None + ), + "selectionReceiptPath": ( + applied_selection["receiptPath"] if applied_selection is not None else None + ), + "selectionSummary": ( + selection_plan.get("summary") if selection_plan is not None else None + ), + "selectionVerification": selection_verification, + "mainTemplate": str(main_template), + "createUiDefinition": str(create_ui), + "testParameters": str(test_parameters), + "zip": str(zip_path), + "packageReport": str(report_path), + "workflowArtifacts": { + "packager": PACKAGER_NAME, + "packageReport": str(report_path), + "mainTemplate": str(main_template), + "createUiDefinition": str(create_ui), + "testParameters": str(test_parameters), + "zip": str(zip_path), + **({ + "selectionPlan": applied_selection["planPath"], + "selectionReceipt": applied_selection["receiptPath"], + "selectionPlanSha256": selection_plan["planSha256"], + "selectionOutcome": selection_outcome, + } if applied_selection is not None and selection_plan is not None else {}), + }, + } + write_json_artifact(root, report_path, result) + return result diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging_selection.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging_selection.py new file mode 100644 index 00000000000..d3e0f6113df --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging_selection.py @@ -0,0 +1,1940 @@ +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +import re +import stat +import sys +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Any + +import yaml + +from .artifacts import ( + RUN_MARKER, + existing_artifact_path, + requested_run_id, + repository_root, + report_directory, + using_run, + portable_artifact, + write_json_artifact, +) +from .content_paths import content_path, detection_reference +from .converter import ( + _duration_seconds, + analytic_rule_files, + custom_detail_schema_errors, + iso_duration, + validate_solution, + xdr_detection_files, +) +from .runtime import current_runtime_query_hash +from .target_context import require_locked_target + + +PLAN_NAME = "package-selection-plan.json" +RECEIPT_NAME = "package-selection-applied.json" +STRUCTURAL_NAME = "package-selection-structural-validation.json" +GUID_PATTERN = re.compile( + r"\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\Z" +) +SOURCE_PROVIDER_SURFACES = { + "triage-mcp": "microsoft-sentinel-triage", + "azure-monitor-sdk": "log-analytics-workspace", + "log-analytics-cli": "log-analytics-workspace", +} +SELECTION_FIELDS = { + "analytic rules": "Analytic Rules", + "analytics rules": "Analytics Rules", + "xdr detections": "XDR Detections", + "custom detections": "Custom Detections", +} +RULE_SEVERITIES = {"Low", "Medium", "High", "Informational"} +RULE_STATUSES = {"Available", "InPreview", "Deprecated"} +RULE_TECHNIQUE_PATTERN = re.compile(r"T\d{4}(?:\.\d{3})?\Z") + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _analytic_rule_structure_errors(document: Any) -> list[str]: + if not isinstance(document, dict): + return ["Analytic Rule YAML must contain an object"] + + errors: list[str] = [] + string_fields = ("id", "name", "description", "severity", "status", "version", "kind") + for field in string_fields: + value = document.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append(f"{field} must be a non-empty string") + if isinstance(document.get("id"), str) and not GUID_PATTERN.fullmatch(document["id"]): + errors.append("id must be a GUID") + if isinstance(document.get("version"), str) and not re.fullmatch( + r"\d+\.\d+\.\d+", document["version"] + ): + errors.append("version must use X.Y.Z semantic versioning") + if isinstance(document.get("severity"), str) and document["severity"] not in RULE_SEVERITIES: + errors.append("severity must be Low, Medium, High, or Informational") + if isinstance(document.get("status"), str) and document["status"] not in RULE_STATUSES: + errors.append("status must be Available, InPreview, or Deprecated") + + kind = document.get("kind") + if not isinstance(kind, str) or kind not in {"Scheduled", "NRT"}: + errors.append("kind must be Scheduled or NRT") + + for field, limit, pattern in ( + ("tactics", 5, None), + ("relevantTechniques", 10, RULE_TECHNIQUE_PATTERN), + ): + value = document.get(field) + if not isinstance(value, list) or not value: + errors.append(f"{field} must be a non-empty array") + continue + if len(value) > limit: + errors.append(f"{field} cannot contain more than {limit} entries") + if any(not isinstance(item, str) or not item.strip() for item in value): + errors.append(f"{field} entries must be non-empty strings") + elif pattern and any(not pattern.fullmatch(item) for item in value): + errors.append(f"{field} contains an invalid technique ID") + + query = document.get("query") + if not isinstance(query, str) or not query.strip(): + errors.append("query must be a non-empty string") + + connectors = document.get("requiredDataConnectors") + if not isinstance(connectors, list) or not connectors: + errors.append("requiredDataConnectors must be a non-empty array") + else: + for index, connector in enumerate(connectors): + if not isinstance(connector, dict): + errors.append(f"requiredDataConnectors[{index}] must be an object") + continue + if not isinstance(connector.get("connectorId"), str) or not connector["connectorId"].strip(): + errors.append(f"requiredDataConnectors[{index}].connectorId must be a non-empty string") + data_types = connector.get("dataTypes") + if ( + not isinstance(data_types, list) + or not data_types + or any(not isinstance(item, str) or not item.strip() for item in data_types) + ): + errors.append(f"requiredDataConnectors[{index}].dataTypes must be a non-empty string array") + + scheduled_fields = ("queryFrequency", "queryPeriod", "triggerOperator", "triggerThreshold") + if kind == "NRT": + for field in scheduled_fields: + if field in document: + errors.append(f"NRT rules must not include {field}") + elif kind == "Scheduled": + frequency = document.get("queryFrequency") + period = document.get("queryPeriod") + for field, value in (("queryFrequency", frequency), ("queryPeriod", period)): + if not isinstance(value, str) or not value.strip(): + errors.append(f"{field} must be a non-empty timespan string") + elif iso_duration(value)[1]: + errors.append(f"{field} must be a recognized timespan") + frequency_seconds = _duration_seconds(frequency) if isinstance(frequency, str) else None + period_seconds = _duration_seconds(period) if isinstance(period, str) else None + if frequency_seconds is not None and frequency_seconds < 300: + errors.append("queryFrequency must be at least 5 minutes") + if period_seconds is not None and period_seconds < 300: + errors.append("queryPeriod must be at least 5 minutes") + if ( + frequency_seconds is not None + and period_seconds is not None + and period_seconds < frequency_seconds + ): + errors.append("queryPeriod cannot be less than queryFrequency") + if period_seconds is not None and period_seconds > 14 * 24 * 60 * 60: + errors.append("queryPeriod cannot exceed 14 days") + + operator = document.get("triggerOperator") + if not isinstance(operator, str) or operator not in {"gt", "lt", "eq"}: + errors.append("triggerOperator must be gt, lt, or eq") + threshold = document.get("triggerThreshold") + if ( + not isinstance(threshold, int) + or isinstance(threshold, bool) + or not 0 <= threshold <= 10_000 + ): + errors.append("triggerThreshold must be an integer between 0 and 10000") + return errors + + +def _canonical_sha256(value: Any) -> str: + return _sha256(json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, + ).encode("utf-8")) + + +def _read_json(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} is missing or invalid: {path}: {exc}") from exc + if not isinstance(value, dict): + raise ValueError(f"{label} must be a JSON object: {path}") + return value + + +def _relative_artifact(root: Path, path: Path) -> str: + try: + return path.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return path.as_posix() + + +def _solution_binding_matches(value: Any, root: Path) -> bool: + if not isinstance(value, str) or not value: + return False + if value == ".": + return True + candidate = Path(value) + if candidate.is_absolute(): + return candidate.resolve() == root.resolve() + return value.casefold() == root.name.casefold() + + +def _runtime_target_fingerprint(target: dict[str, Any]) -> str: + return _canonical_sha256({ + key: target[key] + for key in ( + "tenantId", + "subscriptionId", + "resourceGroup", + "workspaceResourceId", + "workspaceName", + "workspaceCustomerId", + ) + }) + + +def _manifest_reference(value: Any, family: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"conversion manifest has an invalid {family} path") + normalized = value.replace("\\", "/") + parts = normalized.split("/") + if ( + normalized.startswith("/") + or re.match(r"^[A-Za-z]:", normalized) + or "\x00" in normalized + or any(part in {"", ".", ".."} for part in parts) + or parts[0].casefold() != family.casefold() + or Path(parts[-1]).suffix.casefold() not in {".yaml", ".yml"} + ): + raise ValueError(f"conversion manifest has an unsafe {family} path: {value!r}") + return "/".join(parts) + + +def _run_context(solution: str | Path, run_id: str) -> tuple[Path, dict[str, Any]]: + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + run_directory = report_directory(root) + marker = _read_json(run_directory / RUN_MARKER, "migration run marker") + state = _read_json(run_directory / "workflow-state.json", "workflow state") + if marker.get("runId") != run_id or state.get("runId") != run_id: + raise ValueError("selected run ID does not match its current run marker and workflow state") + if not _solution_binding_matches(state.get("solution"), root): + raise ValueError("workflow state is bound to a different solution") + if (state.get("context") or {}).get("profileSelectionConfirmed") is not True: + raise ValueError("package selection requires an explicitly confirmed workflow profile") + return run_directory, state + + +def _data_file(root: Path) -> Path: + paths = sorted((root / "Data").glob("Solution_*.json")) + if len(paths) != 1: + raise ValueError( + f"expected exactly one Data/Solution_*.json file, found {len(paths)}" + ) + return paths[0] + + +def _case_key(document: dict[str, Any], key: str) -> str | None: + matches = [name for name in document if name.casefold() == key.casefold()] + if len(matches) > 1: + raise ValueError(f"solution data has duplicate case-insensitive {key!r} properties") + return matches[0] if matches else None + + +def _content_reference(root: Path, value: Any, family: str) -> tuple[str, Path]: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{family} selection contains an invalid reference") + text = value.replace("\\", "/") + parts = text.split("/") + if ( + text.startswith("/") + or re.match(r"^[A-Za-z]:", text) + or any(part in {"", ".", ".."} for part in parts) + or "\x00" in text + ): + raise ValueError(f"unsafe {family} content reference: {value!r}") + selected_root = root.resolve() + repository = repository_root(selected_root).resolve() + try: + selected_prefix = selected_root.relative_to(repository).parts + except ValueError: + selected_prefix = () + if ( + len(selected_prefix) == 2 + and selected_prefix[0].casefold() == "solutions" + and selected_prefix[1].casefold() == selected_root.name.casefold() + and len(parts) > len(selected_prefix) + and tuple(part.casefold() for part in parts[:len(selected_prefix)]) + == tuple(part.casefold() for part in selected_prefix) + ): + parts = parts[len(selected_prefix):] + if not parts or parts[0].casefold() != family.casefold(): + raise ValueError(f"unsafe {family} content reference: {value!r}") + text = "/".join(parts) + path = content_path(root, text).resolve() + if not path.is_relative_to(root.resolve()) or path.is_symlink() or not path.is_file(): + raise ValueError(f"{family} reference does not resolve to a safe file: {value!r}") + if path.suffix.casefold() not in {".yaml", ".yml"}: + raise ValueError(f"{family} reference must point to a YAML file: {value!r}") + return path.relative_to(root).as_posix(), path + + +def _selection_arrays( + root: Path, + document: dict[str, Any], + *, + allow_missing_xdr: bool = False, +) -> dict[str, list[str]]: + source_keys = [ + key for key in ("Analytic Rules", "Analytics Rules") + if _case_key(document, key) is not None + ] + if len(source_keys) > 1: + raise ValueError("solution data contains ambiguous Analytic Rules selection arrays") + xdr_keys = [ + key for key in ("XDR Detections", "Custom Detections") + if _case_key(document, key) is not None + ] + if len(xdr_keys) > 1: + raise ValueError("solution data contains ambiguous XDR Detection selection arrays") + if ( + not xdr_keys + and not allow_missing_xdr + and xdr_detection_files(root / "XDR Detections") + ): + raise ValueError( + "solution data is missing an explicit XDR Detections array; " + "folder discovery is not a package-selection policy" + ) + + result: dict[str, list[str]] = {"analyticRules": [], "xdrDetections": []} + for family, key, prefix in ( + ("analyticRules", source_keys[0] if source_keys else None, "Analytic Rules"), + ("xdrDetections", xdr_keys[0] if xdr_keys else None, "XDR Detections"), + ): + if key is None: + continue + values = document[key] + if not isinstance(values, list): + raise ValueError(f"solution data {key} must be an array") + paths = [] + seen: set[str] = set() + for value in values: + relative, _ = _content_reference(root, value, prefix) + normalized = relative.casefold() + if normalized in seen: + raise ValueError(f"duplicate {key} content reference: {value!r}") + seen.add(normalized) + paths.append(relative) + result[family] = paths + return result + + +def _data_context_sha256(document: dict[str, Any]) -> str: + context = { + key: value for key, value in document.items() + if key.casefold() not in { + "version", "analytic rules", "analytics rules", + "xdr detections", "custom detections", + } + } + return _canonical_sha256(context) + + +def _load_yaml(path: Path, label: str) -> dict[str, Any]: + try: + value = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"invalid {label} YAML: {path}: {exc}") from exc + if not isinstance(value, dict): + raise ValueError(f"{label} YAML must contain an object: {path}") + return value + + +def _validation_evidence(root: Path, run_directory: Path) -> tuple[dict[str, Any], dict[str, str]]: + with using_run(root, run_directory.name): + validation = validate_solution(root) + results: dict[str, dict[str, Any]] = {} + full_hashes: dict[str, str] = {} + for path in analytic_rule_files(root): + relative = path.relative_to(root).as_posix() + digest = _sha256(path.read_bytes()) + full_hashes[relative] = digest + try: + source = _load_yaml(path, "Analytic Rule") + source_errors = _analytic_rule_structure_errors(source) + except ( + OSError, + ValueError, + yaml.YAMLError, + TypeError, + AttributeError, + OverflowError, + ) as exc: + source_errors = [str(exc)] + results[relative] = { + "status": "failed" if source_errors else "passed", + "errors": source_errors, + "kind": "analyticRule", + } + for path in xdr_detection_files(root / "XDR Detections"): + relative = path.relative_to(root).as_posix() + digest = _sha256(path.read_bytes()) + full_hashes[relative] = digest + results[relative] = {"status": "notRun", "errors": [], "kind": "customDetection"} + for item in validation.get("results", []): + raw_path = Path(str(item.get("file") or "")) + try: + relative = raw_path.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + raise ValueError("structural validator returned a path outside the selected solution") + if relative not in results: + raise ValueError(f"structural validator returned an unknown detection path: {relative}") + if results[relative]["status"] != "notRun": + raise ValueError(f"structural validator returned duplicate results for {relative}") + results[relative] = { + "status": "passed" if item.get("valid") is True else "failed", + "errors": [str(error) for error in item.get("errors", [])], + "kind": "customDetection", + } + report = { + "schemaVersion": "1.0.0", + "validator": "sentinel_xdr_migration.converter.validate_solution", + "runId": run_directory.name, + "total": len(results), + "valid": sum(item["status"] == "passed" for item in results.values()), + "invalid": sum(item["status"] == "failed" for item in results.values()), + "results": [ + { + "file": name, + "fileSha256": full_hashes[name], + "kind": results[name]["kind"], + **results[name], + } + for name in sorted(results) + ], + } + return report, full_hashes + + +def _coverage_summary( + source_inventory: list[dict[str, Any]], + validated_source_ids: set[str] | list[str], +) -> dict[str, Any]: + source_ids = [str(item.get("id") or "") for item in source_inventory] + source_keys = [value.casefold() for value in source_ids] + if ( + not source_ids + or any(not GUID_PATTERN.fullmatch(value) for value in source_ids) + or len(source_keys) != len(set(source_keys)) + ): + denominator = 0 + source_ids = [] + source_keys = [] + validated_by_key = { + value.casefold(): value for value in validated_source_ids + if isinstance(value, str) and GUID_PATTERN.fullmatch(value) + } + if not set(validated_by_key).issubset(set(source_keys)): + raise ValueError("validated migrated detections do not bind to the complete source inventory") + validated_ids = sorted( + validated_by_key[key] for key in validated_by_key + ) + denominator = len(source_ids) + numerator = len(validated_ids) + return { + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": denominator, + "originalSourceRuleIds": sorted(source_ids, key=str.casefold), + "validatedMigratedSourceRuleCount": numerator, + "validatedMigratedSourceRuleIds": validated_ids, + "coverageNumerator": numerator, + "coverageDenominator": denominator, + "coveragePercent": (100.0 * numerator / denominator) if denominator else 0.0, + "partialPublicationAllowed": denominator > 0 and 2 * numerator > denominator, + } + + +def _partial_coverage_allowed(summary: dict[str, Any]) -> bool: + source_ids = summary.get("originalSourceRuleIds") + validated_ids = summary.get("validatedMigratedSourceRuleIds") + denominator = summary.get("originalSourceRuleCount") + numerator = summary.get("validatedMigratedSourceRuleCount") + if ( + not isinstance(source_ids, list) + or not isinstance(validated_ids, list) + or not isinstance(denominator, int) + or isinstance(denominator, bool) + or not isinstance(numerator, int) + or isinstance(numerator, bool) + or denominator <= 0 + or denominator != len(source_ids) + or numerator != len(validated_ids) + or summary.get("coverageNumerator") != numerator + or summary.get("coverageDenominator") != denominator + or summary.get("coveragePolicy") + != "strictly-greater-than-50-percent-of-original-sentinel-rules" + or summary.get("partialPublicationAllowed") is not True + or numerator < 0 + or numerator > denominator + ): + return False + source_keys = [str(value).casefold() for value in source_ids] + validated_keys = [str(value).casefold() for value in validated_ids] + return ( + all(GUID_PATTERN.fullmatch(str(value)) for value in source_ids) + and all(GUID_PATTERN.fullmatch(str(value)) for value in validated_ids) + and len(source_keys) == len(set(source_keys)) + and len(validated_keys) == len(set(validated_keys)) + and set(validated_keys).issubset(set(source_keys)) + and 2 * numerator > denominator + ) + + +def _report_path(run_directory: Path, state: dict[str, Any], name: str) -> Path: + path = run_directory / name + return path + + +def _current_target( + root: Path, + state: dict[str, Any], + *, + materialize_missing: bool = True, +) -> tuple[dict[str, str] | None, str | None]: + profile = (state.get("context") or {}).get("workflowProfile") + if profile != "qualification": + return None, None + run_id = state.get("runId") + if not isinstance(run_id, str) or not run_id: + raise ValueError("qualification package selection requires a bound workflow run ID") + with using_run(root, run_id): + target = require_locked_target(root, materialize_missing=materialize_missing) + return target, _runtime_target_fingerprint(target) + + +def _source_runtime_reference_status( + root: Path, + run_directory: Path, + state: dict[str, Any], + *, + provider: str, + allow_missing: bool = False, +) -> tuple[bool, str]: + artifacts = ( + ((state.get("stages") or {}).get("validation") or {}).get("artifacts") or {} + ) + references = [] + for key in ("sentinelRuntimeJson", "sentinelRuntimeReport"): + value = artifacts.get(key) + if value is None: + continue + if not isinstance(value, str) or not value.strip(): + return False, "source-runtime-report-reference-invalid" + normalized = value.replace("\\", "/") + if "\x00" in normalized or ".." in normalized.split("/"): + return False, "source-runtime-report-reference-unsafe-path" + references.append((key, normalized)) + if not references: + return ( + (True, "source-runtime-report-reference-missing") + if allow_missing + else (False, "source-runtime-report-not-bound-to-validation-stage") + ) + + expected = (run_directory / f"runtime-validation.{provider}.json").resolve() + resolved: list[Path] = [] + for _, value in references: + path = Path(value) + candidates = [path] if path.is_absolute() else [root / path, run_directory / path] + matches = [] + for candidate in candidates: + try: + if candidate.resolve() == expected: + matches.append(candidate.resolve()) + except OSError: + continue + if not matches: + return False, "source-runtime-report-reference-provider-or-path-mismatch" + resolved.append(matches[0]) + if len(set(resolved)) != 1: + return False, "source-runtime-report-reference-conflict" + return True, "provider-runtime-report-reference-current" + + +def _runtime_binding( + root: Path, + run_directory: Path, + state: dict[str, Any], + *, + provider: str, + report: dict[str, Any], + target_fingerprint: str | None, + allow_missing_source_reference: bool = False, +) -> tuple[bool, str]: + profile = (state.get("context") or {}).get("workflowProfile") + family = "defenderXdrQueryFamily" if provider == "graph" else "sentinelSourceQueryFamily" + plan_path = run_directory / "runtime-query-plan.json" + if not plan_path.is_file(): + return False, "provider-runtime-plan-binding-missing" + plan = _read_json(plan_path, "runtime query plan") + plan_target = plan.get("target") or {} + family_plan = (plan.get("families") or {}).get(family) or {} + provider_plan = family_plan.get("provider") or {} + if ( + plan.get("runId") != run_directory.name + or plan.get("workflowProfile") != profile + or not _solution_binding_matches(plan.get("solution"), root) + or plan_target.get("fingerprintSha256") != target_fingerprint + or not isinstance(provider_plan, dict) + or provider_plan.get("provider") != provider + or report.get("provider") != provider + ): + return False, "provider-runtime-plan-binding-stale-or-mismatched" + snapshots = [] + for field in ("runtimeQueryConsent", "runtimeQueryDispatchSnapshot"): + snapshot = report.get(field) + if not isinstance(snapshot, dict): + return False, "provider-runtime-execution-binding-missing" + snapshots.append(snapshot) + for snapshot in snapshots: + binding = snapshot.get("binding") + if ( + snapshot.get("status") not in {"approved", "derived-from-lab-consent"} + or snapshot.get("provider") != provider + or snapshot.get("family") != family + or snapshot.get("planSha256") != plan.get("planSha256") + or not isinstance(binding, dict) + or binding.get("runId") != run_directory.name + or binding.get("workflowProfile") != profile + or not _solution_binding_matches(binding.get("solution"), root) + or not target_fingerprint + or binding.get("targetFingerprintSha256") != target_fingerprint + ): + return False, "provider-runtime-execution-binding-stale-or-mismatched" + if provider != "graph": + reference_ok, reference_reason = _source_runtime_reference_status( + root, + run_directory, + state, + provider=provider, + allow_missing=allow_missing_source_reference, + ) + if not reference_ok: + return False, reference_reason + consent_path = run_directory / "runtime-query-consent.json" + if not consent_path.is_file(): + return False, "source-runtime-target-consent-binding-missing" + consent = _read_json(consent_path, "runtime query consent") + consent_binding = consent.get("binding") or {} + if ( + consent.get("status") not in {"approved", "derived-from-lab-consent"} + or family not in consent.get("approvedFamilies", []) + or consent.get("planSha256") != plan.get("planSha256") + or consent_binding.get("runId") != run_directory.name + or consent_binding.get("workflowProfile") != profile + or not _solution_binding_matches(consent_binding.get("solution"), root) + or consent_binding.get("targetFingerprintSha256") != target_fingerprint + ): + return False, "source-runtime-target-consent-binding-stale-or-mismatched" + return True, "provider-target-binding-current" + + +def _runtime_family( + root: Path, + run_directory: Path, + state: dict[str, Any], + *, + provider: str, + detection_paths: dict[str, Path], + target_fingerprint: str | None, + allow_missing_source_reference: bool = False, +) -> tuple[dict[str, dict[str, Any]], dict[str, Any], list[str]]: + report_path = run_directory / f"runtime-validation.{provider}.json" + expected_surface = ( + "defender-advanced-hunting" + if provider == "graph" + else SOURCE_PROVIDER_SURFACES[provider] + ) + + def unavailable(status: str, reason: str, report_hash: str | None = None): + base = { + "provider": provider, + "querySurface": expected_surface, + "runId": run_directory.name, + "targetFingerprintSha256": target_fingerprint, + "providerBindingStatus": "not-established", + } + if report_hash: + base["reportSha256"] = report_hash + base["evidencePath"] = _relative_artifact( + root, report_path, + ) + return ( + { + name: {**base, "status": status, "reason": reason} + for name in detection_paths + }, + { + **base, + "reportPath": _relative_artifact(root, report_path), + "reportSha256": report_hash, + "status": status, + }, + ) + + evidence: dict[str, dict[str, Any]] = {} + errors: list[str] = [] + if not report_path.is_file(): + missing_evidence, provider_evidence = unavailable( + "missing", "runtime-report-missing", + ) + return missing_evidence, provider_evidence, errors + raw = report_path.read_bytes() + try: + report = json.loads(raw) + except (UnicodeError, json.JSONDecodeError) as exc: + invalid_evidence, provider_evidence = unavailable( + "invalid", "runtime-report-invalid", _sha256(raw), + ) + return invalid_evidence, provider_evidence, [ + f"{provider} runtime report is invalid: {exc}" + ] + if not isinstance(report, dict) or report.get("provider") != provider: + mismatch_evidence, provider_evidence = unavailable( + "invalid", "runtime-provider-mismatch", _sha256(raw), + ) + return mismatch_evidence, provider_evidence, [ + f"{provider} runtime report provider does not match its selected route" + ] + if report.get("querySurface") != expected_surface: + errors.append(f"{provider} runtime report query surface does not match its selected provider") + if report.get("providerFailure"): + errors.append(f"{provider} runtime report records a provider failure") + if report.get("familyComplete") is False: + errors.append(f"{provider} runtime report marks the detection family incomplete") + bound, binding_reason = _runtime_binding( + root, run_directory, state, provider=provider, report=report, + target_fingerprint=target_fingerprint, + allow_missing_source_reference=allow_missing_source_reference, + ) + if not bound: + errors.append(f"{provider} runtime report is not bound to the current run and target") + raw_results = report.get("results") + if not isinstance(raw_results, list): + raw_results = [] + errors.append(f"{provider} runtime report has no results inventory") + by_name: dict[str, dict[str, Any]] = {} + expected_names = set(detection_paths) + for item in raw_results: + if not isinstance(item, dict): + errors.append(f"{provider} runtime report contains a malformed result") + continue + name = str(item.get("detection") or "").replace("\\", "/") + if name.startswith("XDR Detections/"): + name = name[len("XDR Detections/"):] + try: + canonical_name = detection_reference(name, list(expected_names)) + except ValueError: + errors.append(f"{provider} runtime report contains an unknown or ambiguous detection result") + continue + if canonical_name in by_name: + errors.append(f"{provider} runtime report contains duplicate results") + else: + by_name[canonical_name] = item + missing_names = sorted(expected_names - set(by_name)) + if missing_names: + errors.append( + f"{provider} runtime report is missing expected detection results: " + + ", ".join(missing_names) + ) + report_failed = bool( + report.get("providerFailure") + or report.get("familyComplete") is False + or errors + ) + for name, detection_path in detection_paths.items(): + entry = by_name.get(name) + if report_failed: + result = {"status": "blocked", "reason": "runtime-provider-or-inventory-error"} + elif entry is None: + result = {"status": "missing", "reason": "runtime-result-missing"} + else: + try: + expected_query_hash = current_runtime_query_hash( + root, detection_path, + provider="graph" if provider == "graph" else provider, + ) + except (OSError, ValueError, yaml.YAMLError): + expected_query_hash = None + actual_hash = entry.get("querySha256") + status = str(entry.get("status") or "missing") + result = { + "status": status, + "querySha256": actual_hash if isinstance(actual_hash, str) else None, + "expectedQuerySha256": expected_query_hash, + "evidencePath": ( + f"{_relative_artifact(root, report_path)}#results:{name}" + ), + "reportSha256": _sha256(raw), + } + for field in ( + "failureKind", "statusCode", "schemaColumnCount", "tables", + "sourceMissing", "reasonCode", "errorCode", + ): + value = entry.get(field) + if isinstance(value, (str, int, bool, list)): + result[field] = value + if expected_query_hash is None or actual_hash != expected_query_hash: + result.update(status="stale", reason="runtime-query-hash-stale") + elif status != "passed": + result["reason"] = f"runtime-result-{status}" + elif provider == "graph": + schema = entry.get("schema") + if not isinstance(schema, list): + result.update(status="invalid", reason="graph-output-schema-missing") + else: + columns = [] + seen: set[str] = set() + for column in schema: + if not isinstance(column, dict): + columns = [] + break + column_name = column.get("name", column.get("Name")) + column_type = column.get("type", column.get("Type")) + if ( + not isinstance(column_name, str) or not column_name.strip() + or not isinstance(column_type, str) or not column_type.strip() + or column_name.casefold() in seen + ): + columns = [] + break + seen.add(column_name.casefold()) + columns.append(column) + if not columns: + result.update(status="invalid", reason="graph-output-schema-invalid") + else: + document = _load_yaml(detection_path, "Custom Detection") + binding_errors = custom_detail_schema_errors(document, columns) + if binding_errors: + result.update( + status="invalid", + reason="graph-output-schema-binding-invalid", + schemaErrors=[str(error) for error in binding_errors], + ) + else: + result["schemaSha256"] = _canonical_sha256(columns) + result.update({ + "provider": provider, + "querySurface": expected_surface, + "runId": run_directory.name, + "targetFingerprintSha256": target_fingerprint, + "providerBindingStatus": binding_reason, + "reportPath": _relative_artifact(root, report_path), + "reportSha256": _sha256(raw), + "evidencePath": ( + f"{_relative_artifact(root, report_path)}#results:{name}" + ), + }) + evidence[name] = result + return ( + evidence, + { + "provider": provider, + "querySurface": expected_surface, + "reportPath": _relative_artifact(root, report_path), + "reportSha256": _sha256(raw), + "bindingStatus": binding_reason, + "reportStatus": "error" if errors else "available", + "familyComplete": report.get("familyComplete"), + "providerFallback": bool(report.get("providerFallback")), + "providerFailure": { + key: report["providerFailure"][key] + for key in ("failureKind", "statusCode") + if isinstance(report.get("providerFailure"), dict) + and key in report["providerFailure"] + } or None, + }, + errors, + ) + + +def _current_review_report(run_directory: Path, run_id: str) -> tuple[dict[str, Any] | None, Path | None]: + path = run_directory / "migration-report.json" + if not path.is_file(): + return None, None + document = _read_json(path, "current migration review report") + workflow = document.get("workflow") or {} + if workflow.get("runId") != run_id: + return None, path + return document, path + + +def _review_evidence_sha256(report: dict[str, Any] | None) -> str | None: + if report is None: + return None + workflow = report.get("workflow") + workflow = workflow if isinstance(workflow, dict) else {} + rules = report.get("rules") + if not isinstance(rules, list): + return _canonical_sha256({"runId": workflow.get("runId"), "rules": None}) + projection = [] + for rule in rules: + if not isinstance(rule, dict): + projection.append({"invalidReviewRecord": rule}) + continue + analytic = rule.get("analyticRule") + analytic = analytic if isinstance(analytic, dict) else {} + custom = rule.get("customDetection") + custom = custom if isinstance(custom, dict) else {} + projection.append({ + "sourceFilePresent": ( + isinstance(rule.get("sourceFile"), str) + and bool(rule.get("sourceFile").strip()) + ), + "sourceSha256": rule.get("sourceSha256"), + "analyticRuleId": analytic.get("id"), + "conversionStatus": custom.get("conversionStatus"), + "reviewRequired": custom.get("reviewRequired"), + "reviewScope": custom.get("reviewScope"), + "reviewDecisions": custom.get("reviewDecisions"), + }) + projection.sort( + key=lambda item: ( + str(item.get("analyticRuleId") or "").casefold(), + str(item.get("sourceSha256") or ""), + ) + ) + return _canonical_sha256({ + "runId": workflow.get("runId"), + "rules": projection, + }) + + +def _source_provider_route_errors(run_directory: Path, provider: str) -> list[str]: + providers = ("triage-mcp", "azure-monitor-sdk", "log-analytics-cli") + if provider not in providers: + return [f"unknown selected Sentinel runtime provider: {provider}"] + reports = run_directory + available = [ + (name, reports / f"runtime-validation.{name}.json") + for name in providers + if (reports / f"runtime-validation.{name}.json").is_file() + ] + selected_index = next( + (index for index, (name, _) in enumerate(available) if name == provider), + None, + ) + if selected_index is None: + return [] + selected_path = available[selected_index][1] + try: + selected_report = json.loads(selected_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + return [f"{provider} runtime report is invalid"] + if not isinstance(selected_report, dict) or selected_report.get("provider") != provider: + return [f"{provider} runtime report provider does not match"] + if selected_index == 0: + if selected_report.get("providerFallback"): + return [f"{provider} runtime report claims an unverified provider fallback"] + return [] + + triage_path = reports / "runtime-validation.triage-mcp.json" + try: + triage_report = json.loads(triage_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + return ["Sentinel provider fallback has no valid preceding Triage report"] + if ( + not isinstance(triage_report, dict) + or triage_report.get("provider") != "triage-mcp" + or not isinstance(triage_report.get("providerFailure"), dict) + or bool(triage_report.get("providerFallback")) + ): + return [ + "Sentinel provider fallback requires a current Triage provider-failure report" + ] + proof = selected_report.get("providerFallback") + if ( + not isinstance(proof, dict) + or proof.get("mcpProvider") != "triage-mcp" + or proof.get("fallbackProvider") != provider + or proof.get("batchComplete") is not True + or proof.get("permissionsVerified") is not True + or proof.get("kqlError") is not False + ): + return [ + f"{provider} runtime report lacks the canonical verified Triage fallback proof" + ] + return [] + + +def _build_package_selection_plan( + solution: str | Path, + run_id: str, + *, + candidate_lists_override: dict[str, list[str]] | None = None, + xdr_array_was_missing_override: bool | None = None, + xdr_references_bootstrapped_override: bool | None = None, + bootstrap_missing_outputs_override: list[dict[str, str]] | None = None, + persist_structural: bool = False, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + run_directory, state = _run_context(root, run_id) + data_path = _data_file(root) + try: + data = json.loads(data_path.read_bytes()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot parse solution data {data_path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError("solution data must contain a JSON object") + arrays = _selection_arrays(root, data, allow_missing_xdr=True) + xdr_array_was_missing = ( + _case_key(data, "XDR Detections") is None + and _case_key(data, "Custom Detections") is None + ) + if xdr_array_was_missing_override is not None: + xdr_array_was_missing = xdr_array_was_missing_override + candidate_lists = ( + { + name: list(candidate_lists_override[name]) + for name in ("analyticRules", "xdrDetections") + } + if candidate_lists_override is not None + else dict(arrays) + ) + for name in ("analyticRules", "xdrDetections"): + if not isinstance(candidate_lists.get(name), list): + raise ValueError(f"selection plan has an invalid {name} candidate list") + context_hash = _data_context_sha256(data) + + manifest_path = run_directory / "manifest.json" + manifest = _read_json(manifest_path, "current conversion manifest") + if ( + (manifest.get("scope") or {}).get("kind") != "solution" + or not isinstance(manifest.get("results"), list) + or manifest.get("total") != len(manifest.get("results", [])) + ): + raise ValueError("package selection requires a complete full-solution conversion manifest") + manifest_by_source: dict[str, dict[str, Any]] = {} + manifest_by_output: dict[str, dict[str, Any]] = {} + seen_manifest_sources: set[str] = set() + seen_manifest_outputs: set[str] = set() + for record in manifest["results"]: + source = _manifest_reference(record.get("sourceRelativePath"), "Analytic Rules") + output = _manifest_reference(record.get("outputRelativePath"), "XDR Detections") + if source.casefold() in seen_manifest_sources or output.casefold() in seen_manifest_outputs: + raise ValueError("conversion manifest contains duplicate artifact paths") + seen_manifest_sources.add(source.casefold()) + seen_manifest_outputs.add(output.casefold()) + manifest_by_source[source] = record + manifest_by_output[output] = record + + xdr_references_bootstrapped = ( + xdr_references_bootstrapped_override + if xdr_references_bootstrapped_override is not None + else bool(xdr_array_was_missing and candidate_lists_override is None) + ) + bootstrap_missing_outputs: list[dict[str, str]] = ( + deepcopy(bootstrap_missing_outputs_override) + if bootstrap_missing_outputs_override is not None + else [] + ) + if xdr_references_bootstrapped: + if candidate_lists_override is None: + manifest_outputs = sorted(manifest_by_output) + manifest_references: list[str] = [] + for output in manifest_outputs: + relative = output + path = content_path(root, output).resolve() + if not path.is_relative_to(root) or path.is_symlink(): + raise ValueError(f"conversion manifest output is not a safe path: {output}") + if path.is_file(): + manifest_references.append(relative) + else: + source = next( + source + for source, record in manifest_by_source.items() + if record.get("outputRelativePath") == output + ) + bootstrap_missing_outputs.append({ + "sourcePath": source, + "outputPath": output, + "reason": "conversion-output-missing", + }) + candidate_lists["xdrDetections"] = manifest_references + + detection_paths = { + path.relative_to(root / "XDR Detections").as_posix(): path + for path in xdr_detection_files(root / "XDR Detections") + } + structural_report, structural_hashes = _validation_evidence(root, run_directory) + structural_path = run_directory / STRUCTURAL_NAME + structural_bytes = (json.dumps(structural_report, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + structural_hash = _sha256(structural_bytes) + if persist_structural: + write_json_artifact(root, structural_path, structural_report) + if _sha256(structural_path.read_bytes()) != structural_hash: + structural_hash = _sha256(structural_path.read_bytes()) + elif structural_path.is_file() and _sha256(structural_path.read_bytes()) != structural_hash: + raise ValueError("current local structural-validation evidence changed after selection planning") + + target, target_fingerprint = _current_target(root, state) + source_provider = None + source_plan = _read_json(run_directory / "runtime-query-plan.json", "runtime query plan") if (run_directory / "runtime-query-plan.json").is_file() else {} + source_provider_doc = ( + ((source_plan.get("families") or {}).get("sentinelSourceQueryFamily") or {}).get("provider") or {} + ) + source_provider = source_provider_doc.get("provider") if isinstance(source_provider_doc, dict) else None + if source_provider not in SOURCE_PROVIDER_SURFACES: + source_artifacts = ((state.get("stages") or {}).get("validation") or {}).get("artifacts") or {} + for key in ("sentinelRuntimeJson", "sentinelRuntimeReport"): + source_ref = source_artifacts.get(key) + if isinstance(source_ref, str): + name = Path(source_ref.replace("\\", "/")).name + match = re.fullmatch( + r"runtime-validation\.(triage-mcp|azure-monitor-sdk|log-analytics-cli)\.json", + name, + ) + if match: + source_provider = match.group(1) + break + source_runtime, source_provider_evidence, source_errors = _runtime_family( + root, run_directory, state, provider=source_provider or "azure-monitor-sdk", + detection_paths=detection_paths, target_fingerprint=target_fingerprint, + ) + graph_runtime, graph_provider_evidence, graph_errors = _runtime_family( + root, run_directory, state, provider="graph", + detection_paths=detection_paths, target_fingerprint=target_fingerprint, + ) + source_route_errors = _source_provider_route_errors( + run_directory, source_provider or "azure-monitor-sdk", + ) + source_provider_evidence["routeStatus"] = ( + "passed" if not source_route_errors else "failed" + ) + source_provider_evidence["routeErrors"] = source_route_errors + source_errors.extend(source_route_errors) + + review_report, review_path = _current_review_report(run_directory, run_id) + review_by_id: dict[str, dict[str, Any]] = {} + if review_report is not None: + for rule in review_report.get("rules", []): + analytic = rule.get("analyticRule") or {} + source_id = str(analytic.get("id") or "").casefold() + if source_id: + if source_id in review_by_id: + raise ValueError("current migration report contains duplicate source IDs") + review_by_id[source_id] = rule + review_evidence_hash = _review_evidence_sha256(review_report) + + selected_sources = {p.casefold() for p in candidate_lists["analyticRules"]} + selected_detections = { + p[len("XDR Detections/"):].casefold() + for p in candidate_lists["xdrDetections"] + } + all_source_paths = { + path.relative_to(root).as_posix(): path for path in analytic_rule_files(root) + } + current_source_keys = {path.casefold() for path in all_source_paths} + stale_manifest_sources = sorted( + source for source in manifest_by_source + if source.casefold() not in current_source_keys + ) + selection_errors = [ + f"conversion manifest source is not present in the current solution: {source}" + for source in stale_manifest_sources + ] + missing_manifest_sources = sorted( + source for source in all_source_paths + if source.casefold() not in {item.casefold() for item in manifest_by_source} + ) + selection_errors.extend( + f"full-solution conversion manifest omits current source inventory item: {source}" + for source in missing_manifest_sources + ) + source_ids: dict[str, list[str]] = {} + source_records: dict[str, dict[str, Any]] = {} + for relative, path in all_source_paths.items(): + source_doc = _load_yaml(path, "Analytic Rule") + source_id = str(source_doc.get("id") or "") + if not GUID_PATTERN.fullmatch(source_id): + raise ValueError(f"Analytic Rule has an invalid or missing template ID: {relative}") + key = source_id.casefold() + source_ids.setdefault(key, []).append(relative) + source_records[relative] = { + "id": source_id, + "querySha256": None, + "fileSha256": _sha256(path.read_bytes()), + "structuralStatus": "passed", + "structuralReason": None, + "selectedInData": relative.casefold() in selected_sources, + "manifest": manifest_by_source.get(relative), + } + duplicate_sources = [source_id for source_id, paths in source_ids.items() if len(paths) != 1] + if duplicate_sources: + raise ValueError("duplicate source Analytic Rule IDs make the packaging selection ambiguous") + + structural_by_path = {item["file"]: item for item in structural_report["results"]} + for relative, facts in source_records.items(): + structural = structural_by_path.get(relative, {}) + if structural.get("fileSha256") != facts["fileSha256"]: + facts["structuralStatus"] = "stale" + facts["structuralReason"] = "source-structural-validation-file-hash-mismatch" + else: + facts["structuralStatus"] = structural.get("status", "missing") + if structural.get("errors"): + facts["structuralReason"] = "; ".join(structural["errors"]) + for output_name, path in detection_paths.items(): + source_status = source_runtime.get(output_name) or {} + try: + source_records[next( + source for source in source_records + if source.casefold() == str( + (_load_yaml(path, "Custom Detection").get("contentProvenance") or {}) + .get("source", {}).get("path", "") + ).replace("\\", "/").casefold() + )]["querySha256"] = source_status.get("expectedQuerySha256") + except (StopIteration, KeyError, AttributeError): + pass + + analytic_inventory: list[dict[str, Any]] = [] + eligible_source_ids: set[str] = set() + for relative, facts in sorted(source_records.items()): + reasons: list[str] = [] + checks: dict[str, dict[str, Any]] = {} + manifest_record = facts["manifest"] + source_path = all_source_paths[relative] + source_id = facts["id"] + review = review_by_id.get(source_id.casefold()) + conversion_ok = bool( + manifest_record + and manifest_record.get("status") in {"converted", "needsReview"} + and not manifest_record.get("errors") + ) + checks["conversion"] = { + "status": "passed" if conversion_ok else "failed", + "manifestPath": _relative_artifact(root, manifest_path), + } + if not conversion_ok: + reasons.append("conversion-not-currently-converted-or-reviewed") + if review is None or review.get("sourceSha256") != facts["fileSha256"]: + checks["conversionReviewBinding"] = {"status": "stale", "evidencePath": _relative_artifact(root, review_path) if review_path else None} + reasons.append("source-file-or-review-evidence-stale") + else: + source_structural = ( + isinstance(review.get("sourceFile"), str) + and str(review.get("analyticRule", {}).get("id") or "").casefold() == source_id.casefold() + ) + checks["conversionReviewBinding"] = { + "status": "passed" if source_structural else "failed", + "evidencePath": _relative_artifact(root, review_path) if review_path else None, + "reviewEvidenceSha256": review_evidence_hash, + } + if not source_structural: + reasons.append("source-review-evidence-mismatch") + checks["sourceYamlStructure"] = { + "status": facts["structuralStatus"], + "fileSha256": facts["fileSha256"], + "evidencePath": _relative_artifact(root, structural_path), + "evidenceSha256": structural_hash, + **({"reason": facts["structuralReason"]} if facts["structuralReason"] else {}), + } + if facts["structuralStatus"] != "passed": + reasons.append(facts["structuralReason"] or "source-yaml-structure-invalid") + if not facts["selectedInData"]: + reasons.append("not-in-current-analytic-rule-selection") + try: + query_hash = current_runtime_query_hash( + root, + next( + path for path in detection_paths.values() + if str(((_load_yaml(path, "Custom Detection").get("contentProvenance") or {}).get("source") or {}).get("path", "")).replace("\\", "/").casefold() == relative.casefold() + ), + provider=source_provider or "azure-monitor-sdk", + ) + except (StopIteration, OSError, ValueError, yaml.YAMLError): + query_hash = None + facts["querySha256"] = query_hash + runtime_entry = next( + ( + source_runtime.get(name, {}) + for name, path in detection_paths.items() + if str(((_load_yaml(path, "Custom Detection").get("contentProvenance") or {}).get("source") or {}).get("path", "")).replace("\\", "/").casefold() == relative.casefold() + ), + {}, + ) + runtime_ok = ( + runtime_entry.get("status") == "passed" + and runtime_entry.get("expectedQuerySha256") == query_hash + ) + checks["sourceRuntime"] = { + **runtime_entry, + "status": "passed" if runtime_ok else runtime_entry.get("status", "missing"), + } + if not runtime_ok: + reasons.append(runtime_entry.get("reason") or "source-runtime-not-passed-for-current-query") + if not reasons: + eligible_source_ids.add(source_id.casefold()) + analytic_inventory.append({ + "id": source_id, + "path": relative, + "fileSha256": facts["fileSha256"], + "querySha256": query_hash, + "checks": checks, + "evidencePaths": sorted({ + _relative_artifact(root, manifest_path), + *([_relative_artifact(root, review_path)] if review_path else []), + *([runtime_entry["evidencePath"]] if runtime_entry.get("evidencePath") else []), + }), + "eligible": not reasons, + "included": not reasons, + "reasons": reasons, + }) + + xdr_inventory: list[dict[str, Any]] = [] + analytic_by_id = { + item["id"].casefold(): item for item in analytic_inventory + } + for output_name, path in sorted(detection_paths.items()): + document = _load_yaml(path, "Custom Detection") + provenance = (document.get("contentProvenance") or {}).get("source") or {} + source_id = str(provenance.get("id") or "") + source_relative = str(provenance.get("path") or "").replace("\\", "/") + properties = document.get("properties") or {} + detection_id = str(properties.get("id") or "") + record = manifest_by_output.get(f"XDR Detections/{output_name}") + source_matches = source_ids.get(source_id.casefold(), []) if source_id else [] + reasons: list[str] = [] + checks: dict[str, dict[str, Any]] = {} + if len(source_matches) != 1 or source_matches[0].casefold() != source_relative.casefold(): + reasons.append("source-provenance-does-not-resolve-exactly-one-analytic-rule") + checks["sourceProvenance"] = {"status": "failed"} + else: + checks["sourceProvenance"] = {"status": "passed", "sourcePath": source_relative} + if not detection_id: + reasons.append("custom-detection-id-missing") + conversion = (document.get("contentProvenance") or {}).get("conversion") or {} + conversion_ok = bool( + record + and record.get("status") == "converted" + and record.get("reviewRequired") is False + and not record.get("errors") + and conversion.get("status") == "converted" + and conversion.get("reviewRequired") is False + and conversion.get("scope", "solution") != "rule" + ) + checks["conversion"] = { + "status": "passed" if conversion_ok else "failed", + "manifestPath": _relative_artifact(root, manifest_path), + } + if not conversion_ok: + reasons.append("conversion-not-currently-converted-or-reviewed") + review = review_by_id.get(source_id.casefold()) + current_source = all_source_paths.get(source_relative) + current_source_hash = _sha256(current_source.read_bytes()) if current_source else None + current_conversion = ( + (document.get("contentProvenance") or {}).get("conversion") or {} + ) + review_custom = (review.get("customDetection") or {}) if review else {} + current_scope_sha = _canonical_sha256( + current_conversion.get("reviewScope") or {} + ) + reviewed_scope_sha = _canonical_sha256( + review_custom.get("reviewScope") or {} + ) + current_decisions_sha = _canonical_sha256( + current_conversion.get("reviewDecisions") or [] + ) + reviewed_decisions_sha = _canonical_sha256( + review_custom.get("reviewDecisions") or [] + ) + review_binding_ok = bool( + review + and current_source_hash + and review.get("sourceSha256") == current_source_hash + and str((review.get("analyticRule") or {}).get("id") or "").casefold() == source_id.casefold() + and review_custom.get("conversionStatus", current_conversion.get("status")) + == current_conversion.get("status") + and review_custom.get("reviewRequired") + == current_conversion.get("reviewRequired") + and reviewed_scope_sha == current_scope_sha + and reviewed_decisions_sha == current_decisions_sha + ) + review_required = ( + current_conversion.get("reviewRequired") if review_binding_ok else None + ) + checks["review"] = { + "status": ( + "passed" if review_binding_ok and review_required is False + else "unresolved" if review_binding_ok and review_required is True + else "missing-or-stale" + ), + "evidencePath": _relative_artifact(root, review_path) if review_path else None, + "reviewEvidenceSha256": review_evidence_hash, + "reviewScopeSha256": _canonical_sha256( + review_custom.get("reviewScope") or {} + ) if review else None, + "reviewDecisionsSha256": _canonical_sha256( + review_custom.get("reviewDecisions") or [] + ) if review else None, + "currentReviewScopeSha256": current_scope_sha, + "currentReviewDecisionsSha256": current_decisions_sha, + } + if not review_binding_ok: + reasons.append("source-or-review-evidence-stale-or-missing") + elif review_required is True: + reasons.append("unresolved-review-required") + elif review_required is not False: + reasons.append("source-or-review-evidence-stale-or-missing") + structural = structural_by_path.get(f"XDR Detections/{output_name}", {}) + structure_ok = structural.get("status") == "passed" and structural_hashes.get(f"XDR Detections/{output_name}") == _sha256(path.read_bytes()) + checks["currentStructuralValidation"] = { + "status": "passed" if structure_ok else structural.get("status", "missing"), + "fileSha256": _sha256(path.read_bytes()), + "evidencePath": _relative_artifact(root, structural_path), + "evidenceSha256": structural_hash, + } + if not structure_ok: + reasons.append("current-structural-validation-not-passed") + if output_name.casefold() not in selected_detections: + reasons.append("not-in-current-xdr-detection-selection") + runtime_entry = graph_runtime.get(output_name, {}) + graph_ok = runtime_entry.get("status") == "passed" + checks["graphRuntime"] = runtime_entry + if not graph_ok: + reasons.append(runtime_entry.get("reason") or "graph-runtime-not-passed-for-current-query") + source_analytic = analytic_by_id.get(source_id.casefold()) + if source_analytic is None or not source_analytic.get("included"): + reasons.append("source-analytic-rule-is-not-included") + if not reasons: + selected_detections.add(output_name.casefold()) + xdr_inventory.append({ + "id": detection_id, + "sourceId": source_id, + "sourcePath": source_relative or None, + "path": f"XDR Detections/{output_name}", + "fileSha256": _sha256(path.read_bytes()), + "sourceQuerySha256": source_runtime.get(output_name, {}).get("expectedQuerySha256"), + "advancedHuntingQuerySha256": graph_runtime.get(output_name, {}).get("expectedQuerySha256"), + "checks": checks, + "evidencePaths": sorted({ + _relative_artifact(root, manifest_path), + _relative_artifact(root, structural_path), + *([_relative_artifact(root, review_path)] if review_path else []), + *([source_runtime[output_name]["evidencePath"]] if source_runtime.get(output_name, {}).get("evidencePath") else []), + *([graph_runtime[output_name]["evidencePath"]] if graph_runtime.get(output_name, {}).get("evidencePath") else []), + }), + "eligible": not reasons, + "included": not reasons, + "reasons": reasons, + }) + + original_source_inventory = [ + { + "id": facts["id"], + "path": relative, + "fileSha256": facts["fileSha256"], + } + for relative, facts in sorted(source_records.items()) + ] + validated_migrated_source_ids = { + str(item.get("sourceId") or "").casefold() + for item in xdr_inventory + if item.get("included") is True + } + source_id_by_key = { + item["id"].casefold(): item["id"] + for item in original_source_inventory + } + coverage = _coverage_summary( + original_source_inventory, + { + source_id_by_key[key] + for key in validated_migrated_source_ids + if key in source_id_by_key + }, + ) + source_inventory_sha256 = _canonical_sha256(original_source_inventory) + + # A failed source rule remains accounted for independently; only its paired + # Custom Detection is removed by the source-AR closure check above. + result: dict[str, Any] = { + "schemaVersion": "1.0.0", + "kind": "evidence-based-v3.1-package-selection", + "solution": ".", + "runId": run_id, + "workflowProfile": (state.get("context") or {}).get("workflowProfile"), + "targetFingerprintSha256": target_fingerprint, + "providers": { + "sentinel": source_provider_evidence, + "graph": graph_provider_evidence, + }, + "executionErrors": sorted(set(source_errors + graph_errors + selection_errors)), + "input": { + "solutionDataPath": _relative_artifact(root, data_path), + "solutionDataSha256AtPlan": _sha256(data_path.read_bytes()), + "solutionDataContextSha256": context_hash, + "versionAtPlan": next( + (value for key, value in data.items() if key.casefold() == "version"), + None, + ), + "xdrArrayWasMissing": xdr_array_was_missing, + "xdrReferencesBootstrappedFromManifest": xdr_references_bootstrapped, + "bootstrapManifestOutputReferences": ( + list(candidate_lists["xdrDetections"]) + if xdr_references_bootstrapped + else [] + ), + "bootstrapMissingOutputs": bootstrap_missing_outputs, + "candidateReferences": candidate_lists, + "conversionManifestPath": _relative_artifact(root, manifest_path), + "conversionManifestSha256": _sha256(manifest_path.read_bytes()), + "migrationReportPath": _relative_artifact(root, review_path) if review_path else None, + "migrationReviewEvidenceSha256": review_evidence_hash, + "structuralValidationPath": _relative_artifact(root, structural_path), + "structuralValidationSha256": structural_hash, + "originalSourceInventory": original_source_inventory, + "originalSourceInventorySha256": source_inventory_sha256, + }, + "analyticRules": analytic_inventory, + "xdrDetections": xdr_inventory, + "selection": { + "analyticRules": [item["path"] for item in analytic_inventory if item["included"]], + "xdrDetections": [item["path"] for item in xdr_inventory if item["included"]], + }, + "summary": { + "analyticRuleTotal": len(analytic_inventory), + "analyticRuleIncluded": sum(item["included"] for item in analytic_inventory), + "analyticRuleExcluded": sum(not item["included"] for item in analytic_inventory), + "xdrDetectionTotal": len(xdr_inventory), + "xdrDetectionIncluded": sum(item["included"] for item in xdr_inventory), + "xdrDetectionExcluded": sum(not item["included"] for item in xdr_inventory), + **coverage, + "outcome": ( + "blocked" + if bool(source_errors + graph_errors + selection_errors) + or coverage["originalSourceRuleCount"] == 0 + or not coverage["partialPublicationAllowed"] + or not any(item["included"] for item in analytic_inventory) + else "partial" + if any(not item["included"] for item in (*analytic_inventory, *xdr_inventory)) + else "complete" + ), + "eligibilityBlockReason": ( + "empty-or-invalid-original-source-inventory" + if coverage["originalSourceRuleCount"] == 0 + else "no-eligible-analytic-rules" + if not any(item["included"] for item in analytic_inventory) + else "validated-migrated-detection-coverage-not-over-50-percent" + if not coverage["partialPublicationAllowed"] + else None + ), + }, + } + result["planSha256"] = _selection_plan_hash(result) + return result + + +def _selection_plan_hash(plan: dict[str, Any]) -> str: + value = deepcopy(plan) + value.pop("planSha256", None) + value.pop("createdAt", None) + value.get("input", {}).pop("solutionDataSha256AtPlan", None) + value.get("input", {}).pop("versionAtPlan", None) + return _canonical_sha256(value) + + +def _write_selection_receipt(solution: Path, path: Path, receipt: dict[str, Any]) -> None: + payload = ( + json.dumps(portable_artifact(solution, receipt), indent=2) + "\n" + ).encode("utf-8") + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.link(temporary, path) + if path.read_bytes() != payload: + raise RuntimeError("package selection apply receipt changed during publication") + finally: + try: + temporary.unlink() + except OSError: + pass + + +def _file_snapshot(path: Path) -> tuple[bytes, tuple[int, int, int, int, int]]: + if path.is_symlink() or not path.is_file(): + raise RuntimeError(f"solution data must be a regular non-symlink file: {path}") + with path.open("rb") as stream: + before = os.fstat(stream.fileno()) + content = stream.read() + after = os.fstat(stream.fileno()) + current = path.stat() + + def identity(value: os.stat_result) -> tuple[int, int, int, int, int]: + return ( + value.st_dev, + value.st_ino, + value.st_size, + value.st_mtime_ns, + stat.S_IMODE(value.st_mode), + ) + + before_identity = identity(before) + if before_identity != identity(after) or before_identity != identity(current): + raise RuntimeError(f"file changed while reading its package-selection snapshot: {path}") + return content, before_identity + + +def _exchange_paths(left: Path, right: Path) -> None: + if sys.platform == "darwin": + symbol = "renameatx_np" + current_directory = -2 + elif sys.platform.startswith("linux"): + symbol = "renameat2" + current_directory = -100 + else: + raise RuntimeError( + "package-selection apply requires an atomic same-filesystem path exchange; " + f"unsupported platform: {sys.platform}" + ) + library = ctypes.CDLL(None, use_errno=True) + exchange = getattr(library, symbol, None) + if exchange is None: + raise RuntimeError( + "package-selection apply requires an atomic same-filesystem path exchange; " + f"{symbol} is unavailable" + ) + exchange.argtypes = ( + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ) + exchange.restype = ctypes.c_int + # RENAME_SWAP on Darwin and RENAME_EXCHANGE on Linux share flag value 2. + if exchange( + current_directory, + os.fsencode(left), + current_directory, + os.fsencode(right), + 2, + ) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), f"{left} <-> {right}") + + +def _atomic_exchange_if_unchanged( + path: Path, + content: bytes, + expected: tuple[bytes, tuple[int, int, int, int, int]], +) -> tuple[bytes, tuple[int, int, int, int, int]]: + """Atomically exchange staged bytes after the caller excludes external writers. + + The migration writer lock serializes this tool's own updates. Filesystem + exchange is used to detect an edit made before commit, but editors that do + not honor that lock must not modify the solution-data file during apply. + """ + expected_bytes, expected_identity = expected + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, expected_identity[4]) + preserve_temporary = False + try: + with os.fdopen(descriptor, "wb") as stream: + os.chmod(temporary, expected_identity[4]) + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + staged = _file_snapshot(temporary) + _exchange_paths(temporary, path) + displaced = _file_snapshot(temporary) + if displaced != expected or displaced[0] != expected_bytes: + current = _file_snapshot(path) + if current == staged: + _exchange_paths(temporary, path) + restored = _file_snapshot(temporary) + if restored == staged: + raise RuntimeError( + f"solution data changed during package-selection update; " + f"the concurrent edit was restored: {path}" + ) + preserve_temporary = True + else: + preserve_temporary = True + raise RuntimeError( + f"solution data changed during package-selection update; " + f"concurrent file evidence retained at {temporary}" + ) + written = _file_snapshot(path) + if written[0] != content: + raise RuntimeError(f"solution data changed during package-selection commit: {path}") + return written + finally: + if not preserve_temporary: + try: + temporary.unlink() + except OSError: + pass + + +def create_package_selection_plan(solution: str | Path, run_id: str) -> dict[str, Any]: + plan = _build_package_selection_plan( + solution, run_id, persist_structural=True, + ) + with using_run(solution, run_id): + path = report_directory(solution, create=True) / PLAN_NAME + write_json_artifact(solution, path, plan) + return { + "status": ( + "ready" + if not plan["executionErrors"] and plan["summary"]["outcome"] != "blocked" + else "blocked" + ), + "planPath": _relative_artifact(Path(solution).resolve(), path), + **plan, + } + + +def _load_selection_plan(solution: str | Path, run_id: str) -> tuple[Path, dict[str, Any]]: + with using_run(solution, run_id): + path = report_directory(solution) / PLAN_NAME + plan = _read_json(path, "package selection plan") + if plan.get("runId") != run_id or plan.get("planSha256") != _selection_plan_hash(plan): + raise ValueError("package selection plan is invalid or belongs to another run") + return path, plan + + +def apply_package_selection( + solution: str | Path, + run_id: str, + *, + expected_plan_sha256: str, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + from .workflow import _state_writer_lock + + with _state_writer_lock(root): + return _apply_package_selection_unlocked( + root, + run_id, + expected_plan_sha256=expected_plan_sha256, + ) + + +def _apply_package_selection_unlocked( + solution: str | Path, + run_id: str, + *, + expected_plan_sha256: str, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + plan_path, plan = _load_selection_plan(root, run_id) + if plan.get("planSha256") != expected_plan_sha256: + raise ValueError("expected package selection hash does not match the saved plan") + if plan.get("executionErrors"): + raise ValueError("package selection plan contains execution errors and cannot be applied") + if plan.get("summary", {}).get("outcome") == "blocked": + reason = (plan.get("summary") or {}).get("eligibilityBlockReason") + if reason == "validated-migrated-detection-coverage-not-over-50-percent": + raise ValueError( + "validated migrated Custom Detections must represent strictly more " + "than 50% of the original Sentinel rule inventory" + ) + raise ValueError( + "package selection plan has no eligible Analytic Rules and cannot be applied" + ) + current = _build_package_selection_plan(root, run_id) + if current["planSha256"] != plan["planSha256"]: + raise ValueError("package selection plan is stale; regenerate and review it before applying") + + data_path = _data_file(root) + original_data_snapshot = _file_snapshot(data_path) + original_data_bytes = original_data_snapshot[0] + try: + data = json.loads(original_data_bytes) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot parse solution data {data_path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError("solution data must contain a JSON object") + arrays = _selection_arrays(root, data, allow_missing_xdr=True) + xdr_array_missing_now = ( + _case_key(data, "XDR Detections") is None + and _case_key(data, "Custom Detections") is None + ) + if ( + xdr_array_missing_now + != bool(plan["input"].get("xdrArrayWasMissing")) + ): + raise ValueError("solution-data XDR array presence changed after package selection planning") + expected_candidates = plan["input"]["candidateReferences"] + if xdr_array_missing_now: + arrays_match = arrays["analyticRules"] == expected_candidates["analyticRules"] + arrays_match = arrays_match and bool( + plan["input"].get("xdrReferencesBootstrappedFromManifest") + ) + arrays_match = arrays_match and plan["input"].get( + "bootstrapManifestOutputReferences" + ) == expected_candidates["xdrDetections"] + else: + arrays_match = arrays == expected_candidates + if not arrays_match: + raise ValueError("solution-data selections changed after package selection planning") + if _data_context_sha256(data) != plan["input"]["solutionDataContextSha256"]: + raise ValueError("unrelated solution-data content changed after package selection planning") + selected = plan["selection"] + source_key = _case_key(data, "Analytic Rules") or _case_key(data, "Analytics Rules") or "Analytic Rules" + xdr_key = _case_key(data, "XDR Detections") or _case_key(data, "Custom Detections") or "XDR Detections" + data[source_key] = list(selected["analyticRules"]) + data[xdr_key] = list(selected["xdrDetections"]) + encoded = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + current = _build_package_selection_plan(root, run_id) + if current["planSha256"] != plan["planSha256"]: + raise ValueError("package selection evidence changed immediately before solution-data update") + receipt = { + "schemaVersion": "1.0.0", + "runId": run_id, + "selectionPlanPath": _relative_artifact(root, plan_path), + "selectionPlanSha256": plan["planSha256"], + "solutionDataPath": _relative_artifact(root, data_path), + "solutionDataSha256": _sha256(encoded), + "solutionDataContextSha256": _data_context_sha256(data), + "selected": selected, + "selectionSummary": plan["summary"], + "originalSourceInventorySha256": plan["input"]["originalSourceInventorySha256"], + "outcome": "partial-package-validated" if plan["summary"]["outcome"] == "partial" else "package-validated", + } + with using_run(root, run_id): + receipt_path = report_directory(root, create=True) / RECEIPT_NAME + if receipt_path.exists(): + raise ValueError( + "package selection apply receipt already exists; verify or replan before applying again" + ) + applied_data_snapshot = _atomic_exchange_if_unchanged( + data_path, + encoded, + original_data_snapshot, + ) + try: + _write_selection_receipt(root, receipt_path, receipt) + except Exception as exc: + try: + _atomic_exchange_if_unchanged( + data_path, + original_data_bytes, + applied_data_snapshot, + ) + except Exception as rollback_exc: + raise RuntimeError( + "package selection receipt write failed and safe rollback could not be completed; " + "a concurrent solution-data edit was preserved, inspect and replan" + ) from rollback_exc + raise RuntimeError( + "package selection receipt write failed; solution-data update was rolled back " + "under the package-selection writer lock" + ) from exc + try: + current_after_receipt = _file_snapshot(data_path) + except Exception as exc: + raise RuntimeError( + "package selection receipt was published but solution-data freshness could not be " + "verified; inspect the durable receipt and replan" + ) from exc + if current_after_receipt != applied_data_snapshot: + raise RuntimeError( + "package selection receipt was published but a concurrent solution-data edit was " + "preserved; inspect the durable receipt and replan" + ) + return {"status": "applied", "receiptPath": _relative_artifact(root, receipt_path), **receipt} + + +def verify_applied_package_selection( + solution: str | Path, + run_id: str, +) -> dict[str, Any] | None: + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + run_directory = report_directory(root) + receipt_path = run_directory / RECEIPT_NAME + plan_path = run_directory / PLAN_NAME + if not receipt_path.is_file() and not plan_path.is_file(): + return None + if not receipt_path.is_file() or not plan_path.is_file(): + raise ValueError("package selection plan and apply receipt must both exist") + plan_path, plan = _load_selection_plan(root, run_id) + receipt = _read_json(receipt_path, "package selection apply receipt") + if ( + receipt.get("runId") != run_id + or receipt.get("selectionPlanSha256") != plan.get("planSha256") + or receipt.get("solutionDataContextSha256") is None + or receipt.get("selectionSummary") != plan.get("summary") + or receipt.get("originalSourceInventorySha256") + != (plan.get("input") or {}).get("originalSourceInventorySha256") + ): + raise ValueError("package selection apply receipt is stale or mismatched") + data_path = _data_file(root) + data = _read_json(data_path, "solution data") + arrays = _selection_arrays(root, data) + expected = plan.get("selection") or {} + if arrays != expected: + raise ValueError("solution-data package selections changed after evidence-based apply") + if _data_context_sha256(data) != receipt.get("solutionDataContextSha256"): + raise ValueError("unrelated solution-data content changed after package selection apply") + if plan.get("executionErrors"): + raise ValueError("applied package selection contains execution errors") + refreshed = _build_package_selection_plan( + root, + run_id, + candidate_lists_override=plan["input"]["candidateReferences"], + xdr_array_was_missing_override=plan["input"].get("xdrArrayWasMissing"), + xdr_references_bootstrapped_override=plan["input"].get( + "xdrReferencesBootstrappedFromManifest" + ), + bootstrap_missing_outputs_override=plan["input"].get("bootstrapMissingOutputs"), + ) + if refreshed.get("planSha256") != plan.get("planSha256"): + raise ValueError("package selection evidence or artifact hashes changed; replan before packaging") + return { + "plan": plan, + "receipt": receipt, + "planPath": _relative_artifact(root, plan_path), + "receiptPath": _relative_artifact(root, receipt_path), + } + + +def current_applied_package_selection(solution: str | Path) -> dict[str, Any] | None: + root = Path(solution).expanduser().resolve() + run_id = requested_run_id(root) + if not run_id: + state_path = existing_artifact_path(root, "workflow-state.json") + if not state_path.is_file(): + return None + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + run_id = state.get("runId") if isinstance(state, dict) else None + if not isinstance(run_id, str) or not run_id: + return None + return verify_applied_package_selection(root, run_id) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/parser_bindings.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/parser_bindings.py new file mode 100644 index 00000000000..6e06df30d14 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/parser_bindings.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import yaml +from .content_paths import yaml_files + + +KQL_HELPER = Path(__file__).resolve().parents[1] / "kql" / "rename-parser-bindings.cjs" + + +def solution_parser_names(solution: Path) -> list[str]: + names: set[str] = set() + root = solution / "Parsers" + for path in yaml_files(root): + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + if not isinstance(document, dict): + raise ValueError(f"parser must contain a YAML object: {path}") + if document.get("FunctionName") is None and document.get("FunctionAlias") is None: + raise ValueError(f"parser must declare FunctionName or FunctionAlias: {path}") + for field in ("FunctionName", "FunctionAlias"): + value = document.get(field) + if value is not None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"parser {field} must be a nonempty string: {path}") + names.add(value.strip()) + return sorted(names) + + +def normalize_parser_bindings(query: str, solution: Path) -> tuple[str, list[str]]: + names = solution_parser_names(solution) + if not names: + return query, [] + node = shutil.which("node") + if not node: + raise RuntimeError("Node.js is required for scope-aware KQL parser binding normalization.") + if not (KQL_HELPER.parent / "node_modules" / "@kusto" / "language-service-next").is_dir(): + raise RuntimeError( + f"KQL dependency is missing; run npm ci --prefix \"{KQL_HELPER.parent}\"" + ) + completed = subprocess.run( + [node, str(KQL_HELPER)], + input=json.dumps({"query": query, "reservedNames": names}), + text=True, + encoding="utf-8", + capture_output=True, + timeout=60, + check=False, + ) + if completed.returncode: + raise ValueError(completed.stderr.strip() or "KQL parser binding normalization failed.") + result = json.loads(completed.stdout) + warnings = [ + f"Renamed local parser binding {item['from']} to {item['to']} to avoid a saved parser collision" + for item in result["renames"] + ] + return result["query"], warnings diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/preflight.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/preflight.py new file mode 100644 index 00000000000..7b52bec9dc2 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/preflight.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +import sys +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from .artifacts import existing_artifact_path +from .onboarding import _command_status +from .target_context import require_locked_target + + +PREFLIGHT_API_VERSION = "1.0.0" +PREFLIGHT_STATUSES = { + "passed", + "failed", + "unknown", + "notRun", + "notApplicable", +} +TABLE_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +WRITE_COMMAND_PATTERN = re.compile( + r"(?im)^\s*\.(?:set|drop|create|alter|delete|ingest|clear|rename|move)\b" +) +READ_SCOPES = { + "sentinel-source-table-read": ("logAnalytics", "table"), + "sentinel-source-query-read": ("logAnalytics", "query"), + "converted-graph-table-read": ("graphAdvancedHunting", "table"), + "converted-graph-query-read": ("graphAdvancedHunting", "query"), +} + +ReadExecutor = Callable[[Mapping[str, Any]], Mapping[str, Any]] + + +def _record(name: str, status: str, **fields: Any) -> dict[str, Any]: + if status not in PREFLIGHT_STATUSES: + raise ValueError(f"unsupported preflight status: {status}") + return {"name": name, "status": status, **fields} + + +def _target_record(status: str, target: Mapping[str, Any] | None = None, reason=None): + fields: dict[str, Any] = {"reason": reason} + if target is not None: + fields["identity"] = { + key: target[key] + for key in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + } + return _record("lockedQualificationTarget", status, **fields) + + +def _target_failure_reason(error: Exception) -> str: + message = str(error).casefold() + if "does not match" in message or "mismatch" in message: + return "locked-target-identity-mismatch" + if "does not exist" in message: + return "qualification-workflow-state-missing" + if "locked" in message or "missing" in message or "required" in message: + return "locked-target-incomplete" + return "locked-target-invalid" + + +def _validate_read_query(query: str, field: str) -> None: + if not query.strip(): + raise ValueError(f"{field} must not be empty") + if ";" in query or WRITE_COMMAND_PATTERN.search(query): + raise ValueError(f"{field} must contain one read-only KQL query") + + +def _provider_record(provider: str, *, applicable: bool) -> dict[str, Any]: + initial = "notRun" if applicable else "notApplicable" + return { + "provider": provider, + "authentication": _record("authentication", initial), + "authorization": _record("authorization", initial), + "tableAccess": _record("tableAccess", initial), + "queryExecution": _record("queryExecution", initial), + } + + +def _response_status( + response: Mapping[str, Any] | None, *, exception: Exception | None = None +) -> tuple[str, str]: + if exception is not None: + status_code = getattr( + exception, "status_code", getattr(exception, "code", None) + ) + if status_code == 401: + return "failed", "authentication-denied" + if status_code == 403: + return "failed", "authorization-denied" + if status_code == 429: + return "unknown", "throttled" + return "unknown", "provider-unavailable" + + assert response is not None + status_code = response.get("statusCode", response.get("status_code")) + failure_kind = str(response.get("failureKind") or "").casefold() + if status_code == 401: + return "failed", "authentication-denied" + if status_code == 403: + return "failed", "authorization-denied" + if status_code == 429: + return "unknown", "throttled" + if isinstance(status_code, int) and status_code >= 500: + return "unknown", "provider-unavailable" + if ( + response.get("tableAvailable") is False + or failure_kind in {"missing-table", "table-not-found"} + or status_code == 404 + ): + return "failed", "table-unavailable" + if response.get("ok") is True: + return "passed", "read-succeeded" + if failure_kind in {"query", "kql"} or status_code == 400: + return "failed", "query-rejected" + return "unknown", "provider-result-inconclusive" + + +def _run_read( + *, + scope: str, + provider: str, + operation: str, + query: str, + target: Mapping[str, Any], + executor: ReadExecutor, +) -> dict[str, Any]: + request = { + "scope": scope, + "provider": provider, + "operation": operation, + "query": query, + "target": { + key: target[key] + for key in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + }, + } + try: + response = executor(request) + except Exception as exc: + status, reason = _response_status(None, exception=exc) + return { + "status": status, + "reason": reason, + "querySha256": hashlib.sha256(query.encode("utf-8")).hexdigest(), + } + if not isinstance(response, Mapping): + return { + "status": "unknown", + "reason": "provider-result-inconclusive", + "querySha256": hashlib.sha256(query.encode("utf-8")).hexdigest(), + } + status, reason = _response_status(response) + return { + "status": status, + "reason": reason, + "querySha256": hashlib.sha256(query.encode("utf-8")).hexdigest(), + } + + +def _apply_read_result( + provider_record: dict[str, Any], operation: str, result: Mapping[str, Any] +) -> None: + key = "tableAccess" if operation == "table" else "queryExecution" + provider_record[key] = _record(key, result["status"], reason=result["reason"]) + if result["status"] == "passed": + provider_record["authentication"] = _record( + "authentication", "passed", reason="provider-read-succeeded" + ) + provider_record["authorization"] = _record( + "authorization", "passed", reason="provider-read-succeeded" + ) + elif result["reason"] == "authentication-denied": + provider_record["authentication"] = _record( + "authentication", "failed", reason=result["reason"] + ) + elif result["reason"] == "authorization-denied": + provider_record["authorization"] = _record( + "authorization", "failed", reason=result["reason"] + ) + + +def validate_preflight_report(report: Mapping[str, Any]) -> dict[str, Any]: + """Validate this module's integration API record without claiming a W2 schema.""" + required = { + "apiVersion", + "profile", + "overallStatus", + "localTools", + "target", + "providers", + "deploymentPermissions", + "safety", + } + if not isinstance(report, Mapping) or set(report) != required: + raise ValueError("preflight report has an invalid top-level shape") + if report["apiVersion"] != PREFLIGHT_API_VERSION: + raise ValueError("unsupported preflight API version") + if report["profile"] not in {"authoring", "qualification", "unknown"}: + raise ValueError("preflight report has an invalid workflow profile") + local_tools = report["localTools"] + if not isinstance(local_tools, Mapping) or set(local_tools) != { + "python", + "azureCli", + }: + raise ValueError("preflight local tool checks are incomplete") + for value in local_tools.values(): + if not isinstance(value, Mapping) or value.get("status") not in PREFLIGHT_STATUSES: + raise ValueError("preflight local tool has an invalid status") + target = report["target"] + if not isinstance(target, Mapping) or target.get("status") not in PREFLIGHT_STATUSES: + raise ValueError("preflight target has an invalid status") + if report["overallStatus"] not in PREFLIGHT_STATUSES: + raise ValueError("preflight report has an invalid overall status") + providers = report["providers"] + if not isinstance(providers, Mapping) or set(providers) != { + "source", + "converted", + }: + raise ValueError("preflight provider records are incomplete") + expected_providers = { + "source": "logAnalytics", + "converted": "graphAdvancedHunting", + } + for key, provider in providers.items(): + if not isinstance(provider, Mapping) or provider.get("provider") != expected_providers[key]: + raise ValueError("preflight provider family is invalid") + for check in ( + "authentication", + "authorization", + "tableAccess", + "queryExecution", + ): + if ( + check not in provider + or not isinstance(provider[check], Mapping) + or provider[check].get("status") not in PREFLIGHT_STATUSES + ): + raise ValueError("preflight provider check has an invalid status") + deployment = report["deploymentPermissions"] + if not isinstance(deployment, Mapping) or deployment.get("status") not in PREFLIGHT_STATUSES: + raise ValueError("deployment permission check has an invalid status") + if not isinstance(report["safety"], Mapping): + raise ValueError("preflight safety record is invalid") + if report["safety"] != { + "writesPerformed": False, + "workspaceEnumerationPerformed": False, + "newAuthenticationPerformed": False, + }: + raise ValueError("preflight safety record is invalid") + return dict(report) + + +def run_preflight( + solution: str | Path, + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + tenant_id: str | None = None, + subscription_id: str | None = None, + workspace_resource_id: str | None = None, + workspace_customer_id: str | None = None, + source_table: str | None = None, + source_query: str | None = None, + converted_table: str | None = None, + converted_query: str | None = None, + read_executor: ReadExecutor | None = None, +) -> dict[str, Any]: + """Collect local prerequisites and optional, explicitly scoped read evidence. + + Runtime providers are never called directly. Supplying an executor and + individual named read inputs is the only way this API dispatches a read. + """ + root = Path(solution).expanduser().resolve() + profile = "unknown" + target_record = _target_record("unknown", reason="workflow-state-not-found") + target: dict[str, Any] | None = None + try: + state_path = existing_artifact_path(root, "workflow-state.json") + except (OSError, ValueError): + state_path = None + target_record = _target_record("failed", reason="workflow-state-invalid") + if state_path is not None and state_path.is_file(): + try: + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + state = None + target_record = _target_record("failed", reason="workflow-state-invalid") + if isinstance(state, dict): + context = state.get("context") + profile = ( + context.get("workflowProfile", "unknown") + if isinstance(context, dict) + else "unknown" + ) + if profile == "qualification": + try: + target = require_locked_target( + root, + tenant_id=tenant_id, + subscription_id=subscription_id, + workspace_resource_id=workspace_resource_id, + workspace_customer_id=workspace_customer_id, + materialize_missing=False, + ) + except (OSError, ValueError) as exc: + target_record = _target_record( + "failed", reason=_target_failure_reason(exc) + ) + else: + target_record = _target_record("passed", target) + elif profile == "authoring": + target_record = _target_record( + "notApplicable", + reason="authoring-does-not-require-a-workspace", + ) + else: + target_record = _target_record( + "unknown", reason="workflow-profile-is-not-explicit" + ) + + qualification = profile == "qualification" + python_supported = sys.version_info[:2] in {(3, 11), (3, 12)} + azure_cli = ( + _command_status( + "azureCli", ("az", "version", "--output", "none"), runner=runner + ) + if qualification + else _record( + "azureCli", + "notApplicable", + detail="Azure CLI is not required for offline authoring", + ) + ) + local_tools = { + "python": _record( + "python", + "passed" if python_supported else "failed", + version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + requiredVersions=["3.11", "3.12"], + ), + "azureCli": azure_cli, + } + providers = { + "source": _provider_record("logAnalytics", applicable=qualification), + "converted": _provider_record( + "graphAdvancedHunting", applicable=qualification + ), + } + + requested_reads = ( + ( + "source_table", + source_table, + "sentinel-source-table-read", + "logAnalytics", + "table", + "source", + ), + ( + "source_query", + source_query, + "sentinel-source-query-read", + "logAnalytics", + "query", + "source", + ), + ( + "converted_table", + converted_table, + "converted-graph-table-read", + "graphAdvancedHunting", + "table", + "converted", + ), + ( + "converted_query", + converted_query, + "converted-graph-query-read", + "graphAdvancedHunting", + "query", + "converted", + ), + ) + for input_name, requested, scope, provider, operation, provider_key in requested_reads: + if not requested: + continue + if scope not in READ_SCOPES or READ_SCOPES[scope] != (provider, operation): + raise ValueError("preflight read scope does not match its provider") + if operation == "table": + if not TABLE_NAME_PATTERN.fullmatch(requested): + raise ValueError(f"{input_name} must be one exact table name") + query = f"{requested} | take 0" + else: + _validate_read_query(requested, input_name) + query = requested + if read_executor is None or target is None: + result = { + "status": "notRun", + "reason": ( + "explicit-read-executor-not-provided" + if read_executor is None + else "locked-target-not-valid" + ), + "querySha256": hashlib.sha256(query.encode("utf-8")).hexdigest(), + } + else: + result = _run_read( + scope=scope, + provider=provider, + operation=operation, + query=query, + target=target, + executor=read_executor, + ) + _apply_read_result(providers[provider_key], operation, result) + check_key = "tableAccess" if operation == "table" else "queryExecution" + providers[provider_key][check_key]["querySha256"] = result["querySha256"] + + deployment_status = ( + "notApplicable" if profile == "authoring" else "unknown" + ) + deployment_reason = ( + "authoring-does-not-require-deployment-permissions" + if profile == "authoring" + else "not-probed-no-deployment-or-write-checks" + ) + deployment_permissions = _record( + "deploymentPermissions", deployment_status, reason=deployment_reason + ) + + component_statuses = [ + local_tools["python"]["status"], + local_tools["azureCli"]["status"], + target_record["status"], + deployment_permissions["status"], + *( + provider[check]["status"] + for provider in providers.values() + for check in ( + "authentication", + "authorization", + "tableAccess", + "queryExecution", + ) + ), + ] + if "failed" in component_statuses: + overall = "failed" + elif all(status in {"passed", "notApplicable"} for status in component_statuses): + overall = "passed" + elif all(status in {"notRun", "notApplicable"} for status in component_statuses): + overall = "notRun" + else: + overall = "unknown" + + report = { + "apiVersion": PREFLIGHT_API_VERSION, + "profile": profile, + "overallStatus": overall, + "localTools": local_tools, + "target": target_record, + "providers": providers, + "deploymentPermissions": deployment_permissions, + "safety": { + "writesPerformed": False, + "workspaceEnumerationPerformed": False, + "newAuthenticationPerformed": False, + }, + } + return validate_preflight_report(report) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py new file mode 100644 index 00000000000..7a0327d1031 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Any + +from .report_dashboard import STYLE, THEME_SCRIPT + +REPORT_STYLE = STYLE + """ +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 24px 0; } +.card { background: var(--cp-surface); border: 1px solid var(--cp-border); border-radius: 16px; padding: 16px; } +.card strong { display: block; font-size: 2rem; margin-top: 4px; } +.status { display: inline-block; border: 1px solid var(--cp-border); border-radius: .625rem; padding: 4px 8px; font-weight: 600; } +.converted { border-left: 4px solid var(--cp-success); } +.needsReview { border-left: 4px solid var(--cp-accent); } +.conflict { border-left: 4px solid var(--cp-danger); } +""" + + +def _messages(values: list[str], empty: str) -> str: + if not values: + return f'{escape(empty)}' + return "
    " + "".join(f"
  • {escape(value)}
  • " for value in values) + "
" + + +def render_transformation_report(summary: dict[str, Any]) -> str: + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + scope = summary.get("scope") or {} + scope_notice = ( + "Rule-scoped conversion only: " + ", ".join(scope.get("ruleIds") or []) + + ". Unselected drafts were not converted. Counts apply only to the selected rule; " + "this is not full-solution conversion, structural validation, or packaging readiness." + if scope.get("kind") == "rule" + else "Full-solution conversion scope." + ) + rows: list[str] = [] + for result in summary["results"]: + status = str(result["status"]) + review_required = bool(result.get("reviewRequired")) + display_name = str(result.get("displayName") or Path(result["source"]).stem) + source_name = str(result.get("sourceRelativePath") or result["source"]) + output_name = str(result.get("outputRelativePath") or result["output"]) + warnings = list(result.get("warnings") or []) + errors = list(result.get("errors") or []) + informational = list(result.get("informational") or []) + reviews = list(result.get("reviewReasons") or []) + rows.append( + f""" +
+ + + + + + + + """ + ) + + return f""" + + + + + Sentinel to XDR transformation report + + + + +
+

Sentinel to XDR transformation report

+
{escape(str(summary["solution"]))} - Generated {generated_at}
+

{escape(scope_notice)}

+
+
Rules attempted{summary["total"]}
+
Converted{summary["converted"]}
+
Excluded{summary.get("excluded", 0)}
+
Review required{summary.get("reviewRequired", summary["needsReview"])}
+
Conflicts{summary["conflicts"]}
+
Rules with information{sum(bool(result.get("informational")) for result in summary["results"])}
+
+

Information is nonblocking conversion context, separate from warnings, human-review decisions, and errors. It never changes conversion status or readiness.

+
+
{escape(display_name)}
{escape(source_name)} -> {escape(output_name)}
{escape(status)} + {'
Review required' if review_required else ''}
{_messages(errors, "—")}{_messages(reviews, "—")}{_messages(warnings, "—")}{_messages(informational, "—")}
+ + {''.join(rows)} +
RuleStatusErrorsReviewsWarningsInformation — nonblocking
+ + + + +""" + + +def write_transformation_report(summary: dict[str, Any], path: Path) -> None: + path.write_text(render_transformation_report(summary), encoding="utf-8", newline="\n") + + +def render_runtime_validation_report(summary: dict[str, Any]) -> str: + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + fallback = summary.get("providerFallback") + fallback_notice = "" + if fallback: + fallback_notice = ( + '
Recorded Triage MCP provider gap' + f'

{escape(str(fallback.get("reason")))} ' + f'Complete {escape(str(fallback.get("queryFamily")))} batch rerun through ' + f'{escape(str(fallback.get("fallbackProvider")))} for {escape(str(fallback.get("scopeReference")))}. ' + "KQL errors are not fallback reasons.

" + f'

Advertised tool: {escape(str(fallback.get("advertisedTool")))}

' + ) + rows: list[str] = [] + source_whitelist = { + str(item.get("detection") or ""): item + for item in summary.get("sourceWhitelist") or [] + if isinstance(item, dict) + } + for result in summary["results"]: + status = str(result["status"]) + error = str(result.get("error") or "") + whitelist = source_whitelist.get(str(result.get("detection") or "")) + whitelist_text = "" + if whitelist: + label = ( + "source-waived (raw query remains failed)" + if whitelist.get("applied") is True + else "candidate only — not applied" + ) + whitelist_text = ( + f'{escape(label)}
' + f'Table: {escape(str(whitelist.get("table") or "IdentityInfo"))}
' + f'{escape(str(whitelist.get("reason") or ""))}' + ) + rows.append( + f""" + + {escape(str(result["detection"]))} + {escape(status)} + {result.get("rowCount", 0)} + {result.get("schemaColumnCount", 0)} + {whitelist_text or '—'} + {escape(error) if error else 'None'} + + """ + ) + + return f""" + + + + + {escape(str(summary["platform"]))} runtime validation report + + + + +
+

{escape(str(summary["platform"]))} runtime validation

+
{escape(str(summary["solution"]))} - Provider: {escape(str(summary["provider"]))} - Generated {generated_at}
+
+
Rules tested{summary["total"]}
+
Passed{summary["valid"]}
+
Failed{summary["invalid"]}
+
Blocked{summary["blocked"]}
+
Not run{summary.get("notRun", 0)}
+
+ {fallback_notice} +
+ + + {''.join(rows)} +
DetectionRaw statusRowsSchema columnsSource whitelist assessmentRaw error
+
+

Whitelist assessment is derived from the exact source diagnostic and does not rewrite the raw provider result or qualify XDR execution, output schema, technical review, packaging, deployment, ingestion, or parity.

+
+ + +""" + + +def write_runtime_validation_report(summary: dict[str, Any], path: Path) -> None: + path.write_text( + render_runtime_validation_report(summary), encoding="utf-8", newline="\n" + ) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report_dashboard.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report_dashboard.py new file mode 100644 index 00000000000..16cfbc86bdd --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report_dashboard.py @@ -0,0 +1,2457 @@ +"""Offline presentation of recorded migration facts; never changes workflow gates.""" + +from __future__ import annotations + +from collections import Counter +from difflib import unified_diff +from html import escape +import json +import math +import re +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +from .report_review import BULK_SCRIPT, render_bulk_review, review_candidates + + +THEME_SCRIPT = """ + (() => { + const param = new URLSearchParams(window.location.search).get("scoutTheme"); + const theme = + param || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + document.documentElement.setAttribute("data-theme", theme); + })(); +""" + +STYLE = """ +:root { + color-scheme: light; + --cp-bg: #f7f4ef; + --cp-bg-elevated: #fcfbf8; + --cp-surface: #ffffff; + --cp-surface-soft: #f5f5f5; + --cp-border: #dedede; + --cp-border-strong: #919191; + --cp-text: #242424; + --cp-text-muted: #5c5c5c; + --cp-text-soft: #6f6f6f; + --cp-accent: #b11f4b; + --cp-accent-hover: #9a1a41; + --cp-accent-soft: rgba(177, 31, 75, 0.08); + --cp-accent-fg: #ffffff; + --cp-success: #16a34a; + --cp-danger: #dc2626; + --cp-warning: #f59e0b; + --cp-link: #0078d4; + --cp-shadow: 0 18px 48px rgba(0, 0, 0, 0.12); + --cp-overlay: rgba(255, 255, 255, 0.8); + --cp-panel: rgba(255, 255, 255, 0.86); + --cp-panel-strong: rgba(255, 255, 255, 0.96); + --cp-sheen: rgba(255, 255, 255, 0.55); + --cp-highlight: rgba(177, 31, 75, 0.12); +} +html[data-theme="dark"] { + color-scheme: dark; + --cp-bg: #3d3b3a; + --cp-bg-elevated: #343231; + --cp-surface: #292929; + --cp-surface-soft: #2e2e2e; + --cp-border: #474747; + --cp-border-strong: #5f5f5f; + --cp-text: #dedede; + --cp-text-muted: #919191; + --cp-text-soft: #b0b0b0; + --cp-accent: #fd8ea1; + --cp-accent-hover: #fb7b91; + --cp-accent-soft: rgba(253, 142, 161, 0.14); + --cp-accent-fg: #1a1a1a; + --cp-success: #4ade80; + --cp-danger: #f87171; + --cp-warning: #fbbf24; + --cp-link: #4da6ff; + --cp-shadow: 0 18px 48px rgba(0, 0, 0, 0.32); + --cp-overlay: rgba(41, 41, 41, 0.88); + --cp-panel: rgba(41, 41, 41, 0.72); + --cp-panel-strong: rgba(41, 41, 41, 0.96); + --cp-sheen: rgba(255, 255, 255, 0.04); + --cp-highlight: rgba(253, 142, 161, 0.12); +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--cp-bg); color: var(--cp-text); + font-family: "Segoe UI", Aptos, Calibri, -apple-system, BlinkMacSystemFont, sans-serif; } +main { max-width: 1560px; margin: auto; padding: 24px; } +h1 { margin: 8px 0; } h2 { font-size: 1.2rem; } +a { color: var(--cp-link); } .muted { color: var(--cp-text-muted); } +.panel { background: var(--cp-surface); border: 1px solid var(--cp-border); + border-radius: 16px; padding: 20px; margin: 16px 0; } +.stages, .chips, .categories, .metrics { display: flex; flex-wrap: wrap; gap: 8px; } +.stages { list-style: none; padding: 0; } +.stage, .badge { border: 1px solid var(--cp-border); padding: 4px 8px; border-radius: .625rem; } +.badge { display: inline-block; margin: 4px 0; font-size: .85rem; } +.failed { border-left: 4px solid var(--cp-danger); } +.review, .unconfirmed, .blocked { border-left: 4px solid var(--cp-warning); } +.ready, .passed { border-left: 4px solid var(--cp-success); } +.finding-error { border-left: 4px solid var(--cp-danger); } +.finding-review { border-left: 4px solid var(--cp-accent); } +.finding-warning { border-left: 4px solid var(--cp-link); } +.finding-blocked { border-left: 4px solid var(--cp-warning); } +.finding-info { border-left: 4px solid var(--cp-border-strong); } +.metrics span { padding: 8px 12px; background: var(--cp-surface-soft); border-radius: .625rem; } +fieldset { border: 0; padding: 0; margin: 16px 0; } +legend { font-weight: 600; margin-bottom: 8px; } +label.chip { border: 1px solid var(--cp-border); padding: 8px 12px; + border-radius: .625rem; cursor: pointer; background: var(--cp-surface); } +label.chip:has(input:checked) { border-color: var(--cp-accent); background: var(--cp-accent-soft); } +input { accent-color: var(--cp-accent); } +input[type=search] { display: block; margin-top: 8px; width: min(100%, 640px); } +input[type=search], button { font: inherit; padding: 8px 12px; border: 1px solid var(--cp-border-strong); + border-radius: .625rem; background: var(--cp-surface); color: var(--cp-text); } +select { font: inherit; padding: 8px; max-width: 100%; background: var(--cp-surface); color: var(--cp-text); + border: 1px solid var(--cp-border-strong); border-radius: .625rem; } +button:disabled { color: var(--cp-text-muted); cursor: not-allowed; } +.chart-layout { display: flex; flex-wrap: wrap; align-items: center; gap: 24px; } +.donut { width: 220px; max-width: 100%; height: 220px; } +.donut circle { fill: none; stroke-width: 5; } +.donut-track { stroke: var(--cp-border); } +.donut-green { stroke: var(--cp-success); } +.donut-yellow { stroke: var(--cp-warning); } +.donut-red { stroke: var(--cp-danger); } +.donut-gray { stroke: var(--cp-text-muted); } +.donut text { text-anchor: middle; fill: var(--cp-text); font-size: 3px; } +.donut .chart-total { font-size: 6px; font-weight: 600; } +.chart-legend { display: grid; gap: 8px; } +.chart-legend button { text-align: left; } +[data-chart-filter][aria-pressed=true] { outline: 2px solid var(--cp-accent); outline-offset: 2px; } +.swatch { display: inline-block; width: 12px; height: 12px; margin-right: 8px; border-radius: 50%; } +.swatch-green { background: var(--cp-success); } +.swatch-yellow { background: var(--cp-warning); } +.swatch-red { background: var(--cp-danger); } +.swatch-gray { background: var(--cp-text-muted); } +.findings-pie { width: 160px; height: 160px; max-width: 100%; } +.pie-error { fill: var(--cp-danger); } +.pie-attention { fill: var(--cp-warning); } +.pie-info { fill: var(--cp-link); } +.pie-empty { fill: var(--cp-border); } +.swatch-info { background: var(--cp-link); } +[data-findings-chart-filter][aria-pressed=true] { outline: 2px solid var(--cp-accent); outline-offset: 2px; } +button, summary { cursor: pointer; } button:hover { border-color: var(--cp-accent); } +:focus-visible { outline: 3px solid var(--cp-accent); outline-offset: 3px; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; background: var(--cp-surface); } +th, td { text-align: left; vertical-align: top; padding: 12px; border-bottom: 1px solid var(--cp-border); } +thead th { position: sticky; top: 0; background: var(--cp-bg-elevated); z-index: 1; } +th:first-child { min-width: 220px; } td { overflow-wrap: anywhere; } +tbody.rule > tr:first-child > th { font-weight: normal; width: 30%; } +.detail-cell { background: var(--cp-bg-elevated); } +summary { padding: 8px 0; font-weight: 600; } +pre, code { font-family: Consolas, "Courier New", Courier, monospace; } +code { overflow-wrap: anywhere; word-break: normal; } +pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow: auto; + background: var(--cp-surface-soft); border: 1px solid var(--cp-border); padding: 12px; border-radius: .625rem; } +.query-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.issue-list { padding-left: 24px; } .issue-list li { padding: 8px 0; } +.flow-scroll { overflow-x: auto; padding: 8px 0 12px; } +.subway { display: block; width: 100%; min-width: 1160px; height: auto; } +.subway.qualification { min-width: 2110px; } +.subway .route { fill: none; stroke: var(--cp-border-strong); stroke-width: 4; } +.subway .route-prefix.active { stroke: var(--cp-accent); } +.subway .route-authoring.active { stroke: var(--cp-accent); } +.subway .route-qualification { stroke-dasharray: 8 7; } +.subway .route-qualification.active { stroke: var(--cp-accent); stroke-dasharray: none; } +.subway .route-external { stroke-dasharray: 3 8; } +.subway .route-label { fill: var(--cp-text-muted); font-size: 14px; } +.subway .station-marker { fill: var(--cp-surface); stroke: var(--cp-border-strong); stroke-width: 4; } +.subway .station-glyph { fill: var(--cp-text); font-size: 13px; font-weight: 700; text-anchor: middle; } +.subway .station-name { fill: var(--cp-text); font-size: 15px; font-weight: 600; text-anchor: middle; } +.subway .station-status { fill: var(--cp-text-muted); font-size: 12px; text-anchor: middle; } +.subway .branch-heading { fill: var(--cp-text); font-size: 14px; font-weight: 600; } +.subway .flow-link { cursor: pointer; } +.subway .flow-link .station-name { text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; } +.subway .flow-link:hover .station-name, +.subway .flow-link:focus-visible .station-name { text-decoration-style: solid; fill: var(--cp-accent); } +.subway .flow-link:hover .station-marker, +.subway .flow-link:focus-visible .station-marker { stroke: var(--cp-accent); stroke-width: 6; } +.subway .flow-link:focus { outline: none; } +.flow-evidence-entry { border-top: 1px solid var(--cp-border); padding: 12px 0; scroll-margin-top: 16px; } +.flow-evidence-entry h3 { margin: 4px 0 8px; } +.flow-evidence-entry:target { border-left: 4px solid var(--cp-accent); padding-left: 12px; } +.flow-evidence-entry ul { overflow-wrap: anywhere; } +.flow-artifact-links { display: flex; flex-wrap: wrap; gap: 8px 16px; padding-left: 20px; } +.flow-artifact-links a { overflow-wrap: anywhere; } +.subway.compact { min-width: 1160px; } +.subway .flow-station.state-completed .station-marker { stroke: var(--cp-success); } +.subway .flow-station.state-running .station-marker { stroke: var(--cp-accent); } +.subway .flow-station.state-blocked .station-marker, +.subway .flow-station.state-failed .station-marker { stroke: var(--cp-danger); } +.subway .flow-station.state-stale .station-marker { stroke: var(--cp-warning); stroke-dasharray: 5 3; } +.subway .flow-station.state-unknown .station-marker, +.subway .flow-station.state-pending .station-marker, +.subway .flow-station.state-not-required .station-marker { stroke: var(--cp-border-strong); stroke-dasharray: 5 3; } +.subway .state-completed .station-glyph { fill: var(--cp-success); } +.subway .state-running .station-glyph { fill: var(--cp-accent); } +.subway .state-blocked .station-glyph, .subway .state-failed .station-glyph { fill: var(--cp-danger); } +.subway .state-stale .station-glyph { fill: var(--cp-warning); } +.subway .flow-link:hover .station-marker, +.subway .flow-link:focus-visible .station-marker { stroke: var(--cp-accent); stroke-width: 6; } +.flow-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; padding: 0; list-style: none; } +.flow-legend li { display: inline-flex; align-items: center; gap: 6px; } +.flow-key { width: 12px; height: 12px; border: 2px solid var(--cp-border-strong); border-radius: 50%; } +.flow-key.completed { border-color: var(--cp-success); } +.flow-key.running { border-color: var(--cp-accent); } +.flow-key.blocked, .flow-key.failed { border-color: var(--cp-danger); } +.flow-key.stale { border-color: var(--cp-warning); border-style: dashed; } +.flow-key.pending, .flow-key.not-required, .flow-key.unknown { border-style: dashed; } +.flow-mode { display: inline-block; border: 1px solid var(--cp-border-strong); border-radius: .625rem; padding: 4px 8px; } +.planning-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; } +.planning-card { min-width: 0; align-self: start; background: var(--cp-bg-elevated); border: 1px solid var(--cp-border); border-radius: .625rem; padding: 12px; } +.planning-card h3 { margin-top: 0; } +.planning-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); align-items: start; gap: 10px; margin: 16px 0; } +.planning-summary-card { min-width: 0; align-self: start; display: grid; gap: 6px; padding: 12px; background: var(--cp-bg-elevated); border: 1px solid var(--cp-border); border-radius: .625rem; } +.planning-summary-card span { overflow-wrap: anywhere; word-break: normal; } +.planning-layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); align-items: start; gap: 12px; } +.target-data { display: grid; gap: 8px; margin: 8px 0; } +.target-data > div { min-width: 0; } +.target-data dt { font-weight: 600; } +.target-data dd { margin: 2px 0 0; overflow-wrap: anywhere; word-break: normal; } +.planning-inventory { width: 100%; margin-top: 12px; } +.component-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); align-items: start; gap: 10px; } +.component-coverage-summary { margin: 8px 0; padding-left: 20px; overflow-wrap: anywhere; } +.planning-table-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; } +.resource-inventory { width: 100%; min-width: 940px; table-layout: fixed; border-collapse: collapse; } +.resource-inventory th, .resource-inventory td { border-bottom: 1px solid var(--cp-border); padding: 8px; text-align: left; vertical-align: top; } +.resource-inventory th { white-space: nowrap; } +.resource-inventory th:nth-child(1) { width: 34%; } +.resource-inventory th:nth-child(2) { width: 20%; } +.resource-inventory th:nth-child(3) { width: 9%; } +.resource-inventory th:nth-child(4) { width: 24%; } +.resource-inventory th:nth-child(5) { width: 13%; } +.resource-inventory td:first-child code { overflow-wrap: break-word; word-break: normal; } +.resource-inventory td:nth-child(2), .resource-inventory td:nth-child(3), .resource-inventory td:nth-child(5) { white-space: nowrap; } +[hidden] { display: none !important; } +@media(max-width: 760px) { .planning-layout, .planning-summary-grid { grid-template-columns: 1fr; } } +@media(max-width: 760px) { main { padding: 12px; } .panel { padding: 12px; } + .query-pair { grid-template-columns: 1fr; } } +""" + +FILTER_SCRIPT = """ +(() => { + const form = document.getElementById("filters"); + const rows = [...document.querySelectorAll("tbody.rule")]; + const search = document.getElementById("search"); + const count = document.getElementById("result-count"); + const issueLabel = document.getElementById("issue-filter"); + let issue = ""; + let chart = "all"; + let findingChart = "all"; + function apply() { + const status = form.querySelector('input[name="status"]:checked').value; + const finding = form.querySelector('input[name="finding"]:checked').value; + const categories = [...form.querySelectorAll('input[name="category"]:checked')].map(x => x.value); + const text = search.value.trim().toLowerCase(); + let visible = 0; + rows.forEach(row => { + const matchesStatus = status === "all" || + (status === "attention" ? !["ready", "excluded"].includes(row.dataset.status) : row.dataset.status === status); + const matchesCategory = !categories.length || categories.some(x => row.dataset.categories.split("|").includes(x)); + const matchesFinding = finding === "any" || row.dataset.findings.split("|").includes(finding); + const kinds = row.dataset.findings.split("|"); + const matchesFindingChart = findingChart === "all" || + (findingChart === "attention" ? kinds.some(kind => ["warning", "review"].includes(kind)) : kinds.includes(findingChart)); + row.hidden = !(matchesStatus && matchesFinding && matchesCategory && row.dataset.search.includes(text) && + matchesFindingChart && + (chart === "all" || row.dataset.chart === chart) && + (!issue || row.dataset.issues.split("|").includes(issue))); + if (!row.hidden) visible++; + }); + count.textContent = `Showing ${visible} of ${rows.length} rules`; + document.getElementById("empty").hidden = visible !== 0; + issueLabel.textContent = issue ? `Filtering to shared issue ${issue.replace("issue-", "")}` : ""; + document.getElementById("clear-issue").hidden = !issue; + document.querySelectorAll("[data-chart-filter]").forEach(control => control.setAttribute("aria-pressed", String(control.dataset.chartFilter === chart))); + document.getElementById("chart-filter-label").textContent = chart === "all" ? "" : + `Chart filter: ${document.querySelector('button[data-chart-filter="' + chart + '"]').dataset.chartLabel}`; + document.querySelectorAll("[data-findings-chart-filter]").forEach(control => control.setAttribute("aria-pressed", String(control.dataset.findingsChartFilter === findingChart))); + document.getElementById("finding-chart-filter-label").textContent = findingChart === "all" ? "" : + `Findings chart filter: ${document.querySelector('button[data-findings-chart-filter="' + findingChart + '"]').dataset.findingLabel}`; + } + form.addEventListener("input", event => { + if (event.target.name === "finding") form.querySelector('input[name="status"][value="all"]').checked = true; + apply(); + }); + form.addEventListener("submit", event => event.preventDefault()); + document.getElementById("clear").addEventListener("click", () => { + form.reset(); + form.querySelector('input[value="all"]').checked = true; + issue = ""; + chart = "all"; + findingChart = "all"; + apply(); + }); + document.getElementById("clear-issue").addEventListener("click", () => { issue = ""; apply(); }); + document.querySelectorAll("[data-filter-issue]").forEach(button => button.addEventListener("click", () => { + form.reset(); + form.querySelector('input[value="all"]').checked = true; + issue = button.dataset.filterIssue; + chart = "all"; + findingChart = "all"; + apply(); + document.getElementById("result-count").focus(); + })); + document.querySelectorAll("[data-chart-filter]").forEach(control => { + function select() { + form.reset(); + form.querySelector('input[name="status"][value="all"]').checked = true; + issue = ""; + chart = control.dataset.chartFilter; + findingChart = "all"; + apply(); + document.getElementById("result-count").focus(); + } + control.addEventListener("click", select); + if (control.tagName.toLowerCase() === "circle") control.addEventListener("keydown", event => { + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } + }); + }); + document.querySelectorAll("[data-findings-chart-filter]").forEach(control => { + function select() { + form.reset(); + form.querySelector('input[name="status"][value="all"]').checked = true; + issue = ""; + chart = "all"; + findingChart = control.dataset.findingsChartFilter; + apply(); + document.getElementById("result-count").focus(); + } + control.addEventListener("click", select); + if (["path", "circle"].includes(control.tagName.toLowerCase())) control.addEventListener("keydown", event => { + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } + }); + }); + document.querySelectorAll("[data-toggle-detail]").forEach(button => button.addEventListener("click", () => { + const detail = document.getElementById(button.getAttribute("aria-controls")); + detail.hidden = !detail.hidden; + button.setAttribute("aria-expanded", String(!detail.hidden)); + })); + apply(); +})(); +""" + +STATUS_LABELS = { + "attention": "Needs attention", + "all": "All", + "ready": "Ready for validation", + "review": "Needs review", + "failed": "Blocked / Failed", + "notstarted": "Not started", + "unconfirmed": "Readiness unconfirmed", + "excluded": "Excluded", +} + +STAGE_LABELS = { + "setup": "Necessary setup / initial preflight", + "discovery": "Discovery", "conversion": "Conversion", "validation": "Validation", + "packaging": "Packaging", "preflight": "Preflight", "deployment": "Deployment", "mockIngestion": "Mock ingestion", + "alertParity": "Alert parity", "report": "Report", +} + +FINDING_LABELS = { + "error": ("Errors", "Error", "!"), + "review": ("Reviews", "Review — decision required", "?"), + "blocked": ("Blocked prerequisites", "Blocked — not an execution failure", "Ⅱ"), + "warning": ("Warnings", "Warning — nonblocking", "i"), + "info": ("Information", "Information — nonblocking", "ℹ"), +} + + +def _finding_badge(severity: str) -> str: + _, label, icon = FINDING_LABELS[severity] + return f' {label}' + + +def _text(value: Any) -> str: + return escape(str(value)) if value is not None else "Evidence unavailable" + + +def _safe_flow_message(value: Any) -> str: + text = str(value or "") + text = re.sub( + r"(?\"']+", + "[local path omitted]", + text, + ) + return _text(text) + + +def _status_label(value: Any) -> str: + return { + "notRequired": "Not required", "pending": "Not started", + "not-run": "Not run (recorded)", "not-recorded": "Evidence unavailable", + "needsReview": "Needs review", "not-generated": "Not generated", + }.get(str(value), str(value).replace("-", " ").capitalize() if value else "Evidence unavailable") + + +def _category(message: str, stage: str = "") -> str: + text = (stage + " " + message).lower() + if any(word in text for word in ("runtime", "deployment", "parity", "authentication")): + return "Execution" + if any(word in text for word in ("tactic", "technique")): + return "Tactics" + if any(word in text for word in ("entity", "mapping", "account", "upn", "host.", "filehash")): + return "Identity / entities" + if any(word in text for word in ("timestamp", "timegenerated", "lookback", "queryperiod", "schedule")): + return "Time / schedule" + if any(word in text for word in ("query", "table", "union", "search", "kql")): + return "Query compatibility" + return "Other" + + +def _message_key(message: str, stage: str) -> str: + if stage == "structural-validation" and message.startswith("conversion: "): + return message[len("conversion: "):] + return message + + +def _findings(rule: dict[str, Any]) -> list[dict[str, Any]]: + grouped: dict[str, dict[str, Any]] = {} + statuses = [] + for side_key, label in (("analyticRule", "Sentinel"), ("customDetection", "XDR")): + side = rule.get(side_key) or {} + prefix = "analytic-rule" if side_key == "analyticRule" else "custom-detection" + for item in side.get("runtime") or []: + statuses.append((f"{prefix}-runtime-validation", f"{label} runtime", item)) + statuses.append((f"{prefix}-deployment", f"{label} deployment", {"status": side.get("deploymentStatus")})) + custom = rule["customDetection"] + alert_statuses = [custom.get("alertStatus"), (rule.get("analyticRule") or {}).get("alertStatus")] + statuses.extend([ + ("conversion", "Conversion", {"status": custom.get("conversionStatus")}), + ("structural-validation", "Local structural check", {"status": custom.get("structuralStatus")}), + ("query-result-parity", "Query parity", rule.get("queryParity") or {}), + ("alert-parity", "Alert parity", { + "status": "failed" if "failed" in alert_statuses else "blocked" if "blocked" in alert_statuses else custom.get("alertStatus"), + "analyticRuleStatus": alert_statuses[1], "customDetectionStatus": alert_statuses[0], + }), + ]) + + def diagnostic_severity(item: dict[str, Any]) -> str: + matches = [ + value.get("status") for stage, _, value in statuses + if stage == item.get("stage") and ( + not item.get("provider") or not value.get("provider") or item["provider"] == value["provider"] + ) + ] + return "blocked" if "blocked" in matches and not any(value in {"failed", "conflict"} for value in matches) else "error" + + items = [ + (diagnostic_severity(item), item.get("stage", "unknown"), str(item.get("message", "")), item) + for item in rule.get("errors") or [] + ] + for stage, label, value in statuses: + status = value.get("status") + if status not in {"failed", "blocked", "conflict"}: + continue + if any( + raw.get("stage") == stage and ( + not value.get("provider") or not raw.get("provider") or raw["provider"] == value["provider"] + ) + for raw in rule.get("errors") or [] + ): + continue + items.append(("blocked" if status == "blocked" else "error", stage, + f"{label}: {_status_label(status)}", value)) + items += [ + ( + "review", + "conversion-review", + str(message), + { + "message": message, + "reviewScope": custom.get("reviewScope"), + "reviewDecisions": [ + decision for decision in custom.get("reviewDecisions") or [] + if isinstance(decision, dict) and decision.get("reason") == message + ], + }, + ) + for message in rule["customDetection"].get("reviewReasons") or [] + ] + if (custom.get("reviewRequired") or custom.get("conversionStatus") == "needsReview") and not custom.get("reviewReasons"): + items.append(("review", "conversion-review", "Review required; detailed reason not recorded.", + {"reviewRequired": custom.get("reviewRequired"), "conversionStatus": custom.get("conversionStatus")})) + items += [ + ("warning", "conversion-warning", str(message), {"message": message}) + for message in rule.get("warnings") or [] + ] + items += [ + ("info", "conversion-information", str(message), {"message": message}) + for message in rule.get("informational") or [] + ] + for side_key in ("analyticRule", "customDetection"): + for runtime in (rule.get(side_key) or {}).get("runtime") or []: + fallback = runtime.get("providerFallback") + if fallback: + items.append(( + "info", + "runtime-provider-fallback", + "Triage MCP provider gap recorded; complete query family rerun through " + f"{fallback.get('fallbackProvider')}: {fallback.get('reason')}", + {"provider": runtime.get("provider"), "providerFallback": fallback}, + )) + for severity, stage, message, raw in items: + key = _message_key(message, stage) + # Only the inherited conversion/structural pair shares an error identity. + scope = "conversion" if stage in {"conversion", "structural-validation"} else stage + identity = ( + f"{scope}:{key}" if severity in {"error", "blocked"} + else f"information:{key}" if severity == "info" else f"advisory:{key}" + ) + if identity not in grouped: + grouped[identity] = { + "message": key, "severity": severity, "stages": [], + "category": _category(key, stage), "evidence": [], + } + finding = grouped[identity] + if stage not in finding["stages"]: + finding["stages"].append(stage) + finding["evidence"].append({"stage": stage, **raw}) + return sorted(grouped.values(), key=lambda finding: list(FINDING_LABELS).index(finding["severity"])) + + +def _rule_status(rule: dict[str, Any], workflow: dict[str, Any]) -> str: + custom = rule["customDetection"] + sides = [rule.get("analyticRule") or {}, custom] + result_statuses = [ + runtime.get("status") + for side in sides for runtime in side.get("runtime") or [] + ] + [ + side.get(field) for side in sides for field in ("alertStatus", "deploymentStatus") + ] + [ + (rule.get("queryParity") or {}).get("status"), + custom.get("structuralStatus"), + custom.get("conversionStatus"), + ] + if rule.get("errors") or any(value in {"failed", "blocked", "conflict"} for value in result_statuses): + return "failed" + if custom.get("reviewRequired") or custom.get("reviewReasons") or custom.get("conversionStatus") == "needsReview": + return "review" + if custom.get("conversionStatus") == "excluded": + return "excluded" + if custom.get("conversionStatus") in {None, "not-run", "pending"}: + return "notstarted" + conversion = (workflow.get("stages") or {}).get("conversion") or {} + if custom.get("conversionStatus") == "converted" and custom.get("structuralStatus") == "passed" and conversion.get("status") == "passed": + return "ready" + return "unconfirmed" + + +CHART_LABELS = { + "green": "Automated checks passed", + "yellow": "Warnings or review required", + "red": "Errors", + "gray": "Readiness unconfirmed", +} + + +def _chart_bucket(rule: dict[str, Any]) -> str: + kinds = {finding["severity"] for finding in _findings(rule)} + if "error" in kinds: + return "red" + if kinds.intersection({"review", "warning"}): + return "yellow" + custom = rule["customDetection"] + if "blocked" not in kinds and custom.get("conversionStatus") == "converted" and custom.get("structuralStatus") == "passed": + return "green" + return "gray" + + +def _chart_html(rules: list[dict[str, Any]]) -> str: + counts = Counter(_chart_bucket(rule) for rule in rules) + total = len(rules) + offset = 0.0 + arcs, legend = [], [] + for color, label in CHART_LABELS.items(): + count = counts[color] + percentage = count * 100 / total if total else 0 + text = f"{label}: {count} rules ({percentage:.1f}%)" + if count: + arcs.append( + f'{text}' + ) + offset += percentage + legend.append( + f'' + ) + return ( + '

Automated check overview — not deployment readiness

' + '
' + '' + + "".join(arcs) + f'{total}rules' + '
' + "".join(legend) + + '
' + '

Each rule is counted once: errors take priority, then warnings/reviews, then passed automated conversion ' + 'and local structural checks; insufficient evidence or a remaining per-rule prerequisite block is gray. ' + 'Information alone is nonblocking and belongs in green when both automated checks passed. Missing results are never green.

' + '

Green is not semantic approval or deployment readiness. A green rule can still have ' + 'Readiness unconfirmed in the status filter while run-level conversion review remains incomplete. ' + 'Chart selections reset other filters; further filters narrow the selected group.

' + ) + + +def _findings_chart_html(rules: list[dict[str, Any]]) -> str: + counts = Counter(finding["severity"] for rule in rules for finding in _findings(rule)) + buckets = [ + ("error", "Errors", counts["error"], "red"), + ("attention", "Warnings / reviews", counts["warning"] + counts["review"], "yellow"), + ("info", "Informational", counts["info"], "info"), + ] + total = sum(count for _, _, count, _ in buckets) + passed_rules = sum(_chart_bucket(rule) == "green" for rule in rules) + angle = -math.pi / 2 + segments, legend = [], [] + for key, label, count, swatch in buckets: + proportion = count / total if total else 0 + text = f"{label}: {count} findings ({proportion * 100:.1f}%)" + attributes = ( + f'class="pie-{key}" tabindex="0" role="button" aria-pressed="false" ' + f'aria-label="{text}" data-findings-chart-filter="{key}"' + ) + if count == total and total: + segments.append(f'{text}') + elif count: + end = angle + proportion * math.tau + start_x, start_y = 21 + 18 * math.cos(angle), 21 + 18 * math.sin(angle) + end_x, end_y = 21 + 18 * math.cos(end), 21 + 18 * math.sin(end) + path = f"M21 21 L{start_x:.6f} {start_y:.6f} A18 18 0 {int(proportion > .5)} 1 {end_x:.6f} {end_y:.6f} Z" + segments.append(f'{text}') + angle += proportion * math.tau + legend.append( + f'' + ) + if not total: + segments.append('No charted findings') + return ( + '
' + f'

High-level findings — {total} distinct findings

' + '
' + + "".join(segments) + '
' + "".join(legend) + + '
' + f'
{passed_rules} rules passed automated checks with no actionable findings' + '
Separate rule metric; not a pie slice. Informational notes are allowed.
' + '

Pie counts are deduplicated per-rule findings, not unique rules; one rule may contribute several. ' + f'Yellow combines {counts["warning"]} warnings and {counts["review"]} reviews. ' + f'Blocked prerequisite findings ({counts["blocked"]}) are tracked separately, not counted as errors or included in this pie. ' + 'Select a slice or legend to show matching rules; the rule-list count may differ from the finding count.

' + ) + + +def _next_action(status: str, findings: list[dict[str, Any]]) -> str: + if status == "failed": + return "Inspect failed or blocked check evidence; resolve its cause before retrying." + if status == "notstarted": + return "Start conversion after the run prerequisites are satisfied." + if status == "unconfirmed": + return "See the run-level conversion review caution; no per-rule review decision is inferred." + if status == "ready": + return "Proceed to the workflow validation stage; deployment is not approved." + if status == "excluded": + return "Inspect the recorded exclusion before changing scope." + categories = {item["category"] for item in findings if item["severity"] in {"error", "review", "blocked"}} + if "Tactics" in categories: + return "Review invalid or incompatible classification metadata. Authoring preserves all source tactics; regenerate historical single-tactic drafts after review." + if "Identity / entities" in categories: + return "Confirm the output identity mapping against source evidence." + return "Review the recorded reasons and resolve the rule-specific decision." + + +def _is_tactic_review(finding: dict[str, Any]) -> bool: + return finding["severity"] == "review" and "one tactic" in finding["message"].lower() + + +def _tactic_guide() -> str: + return ( + '
Preserve source tactics; review historical single-tactic findings' + '
' + '

Historical finding: authoring now preserves all source tactics in source order, ' + 'with documented compatible techniques. Multiple tactics alone no longer require review. ' + 'Regenerate older drafts explicitly; this report does not clear historical findings.

' + '

V3.1 ARM packaging carries only the first tactic and its compatible techniques, without modifying YAML. ' + 'This classification loss is not validated parity. Direct Graph deployment remains one tactic, ' + 'with multiple compatible techniques and subtechniques under it — not one technique only.

' + '

Do not pair the source lists by position or copy every technique into every tactic. ' + 'Legacy overrides replace techniques only for their named source tactic; they cannot narrow or reorder the source tactics. ' + 'Unknown or incompatible classifications still require correction.

' + '

Verified scope (2026-10-07): an isolated Microsoft Graph beta create request with ' + 'multiple tactics returned HTTP 400: Multiple MITRE tactics are not supported. Specify a single tactic.. ' + 'The Defender UI was confirmed to allow one tactic with multiple techniques; a read-back of a UI-created ' + 'rule also confirmed the nested technique/subtechnique shape. This is not a claim that the multi-tactic ' + 'request succeeded, that ARM was tested, or that every future API version has the same restriction.

' + '

The public Graph beta alertTemplate reference describes tactics as a collection. ' + 'A collection-shaped property alone does not establish acceptance of multiple tactics. ' + 'The mitreTactic reference documents the techniques collection within a tactic.

' + '

The converter preserves the original classification in ' + 'contentProvenance.conversion.originalTactics and originalTechniques. ' + 'Each affected rule below shows source lists, preserved provenance, the current draft, and a rule-specific ' + 'ruleOverrides example. Missing metadata is reported rather than reconstructed. ' + 'No selection is automatically approved, and this report changes no query, configuration, or workflow gate.

' + ) + + +def _tactic_details(rule: dict[str, Any], findings: list[dict[str, Any]]) -> str: + if not any(_is_tactic_review(finding) for finding in findings): + return "" + classification = rule.get("attackClassification") or {} + sections = [] + for label, key in ( + ("Sentinel source tactics (independent list)", "sourceTactics"), + ("Sentinel source techniques (independent list)", "sourceTechniques"), + ("Original tactics preserved in draft provenance", "originalTactics"), + ("Original techniques preserved in draft provenance", "originalTechniques"), + ("Current draft tactics, techniques and subtechniques (exact recorded shape)", "draftTactics"), + ): + value = classification.get(key) + text = json.dumps(value, indent=2, ensure_ascii=False) if value is not None else "Classification metadata not recorded." + sections.append(f"
{label}
{escape(text)}
") + original = classification.get("originalTactics") + draft = classification.get("draftTactics") + fallback = ( + isinstance(original, list) and len(original) > 1 + and isinstance(draft, list) and len(draft) == 1 and isinstance(draft[0], dict) + and draft[0].get("tactic") == original[0] and not draft[0].get("techniques") + ) + rule_id = (rule.get("analyticRule") or {}).get("id") + example = {"ruleOverrides": {str(rule_id or ""): { + "tactic": "", + "techniques": ["", ""], + }}} + return ( + '

Historical tactic finding and retained classification

' + '

Authored YAML preserves all source tactics; deployment contracts differ.

' + '

Source lists are independent; their positions do not establish tactic/technique pairings. ' + 'Source metadata reflects the current file; provenance records the original conversion classification.

' + + "".join(sections) + + ( + '

Unapproved fallback shape: this draft retains the first original tactic ' + f'({_text(original[0])}) with techniques omitted. It matches the converter fallback for an unresolved ' + 'multi-tactic source; it is not an approved classification. Explicitly regenerate to preserve all source tactics ' + 'and their documented compatible techniques/subtechniques.

' if fallback else + '

The draft shape is not treated as proof that a selection was approved. Resolve the recorded review reason explicitly.

' + ) + + '
Rule-specific override example — illustrative, not ready to apply
' + f'
{escape(json.dumps(example, indent=2, ensure_ascii=False))}
' + '

Replace every placeholder with reviewed values and remove unused optional entries. ' + 'Use technique IDs, including dotted subtechnique IDs where appropriate, in the existing ' + 'techniques list. Merge this rule entry into the existing migration configuration; ' + 'do not replace other rules. No tactic or technique is selected by this example. ' + 'An override is optional for technique corrections, not required merely for multiple tactics. ' + 'It does not remove or reorder source tactics. Subsequent workflow checks remain required.

' + ) + + +def _query_html(rule: dict[str, Any]) -> str: + queries = rule.get("queries") or {} + source, converted = queries.get("source"), queries.get("converted") + if source is None and converted is None: + return ( + '

Sentinel and XDR queries

' + '

Source and converted queries were not recorded in this report.

' + ) + pair = ( + '
' + f'

Source Sentinel KQL

{_text(source)}
' + f'

Converted XDR KQL

{_text(converted)}
' + ) + if source is None or converted is None: + diff = "

Comparison unavailable: one query was not recorded.

" + elif source == converted: + diff = "

Query unchanged.

" + else: + lines = unified_diff( + str(source).splitlines(), str(converted).splitlines(), + fromfile="Source Sentinel", tofile="Converted XDR", lineterm="", + ) + diff = "

Unified text diff

" + escape("\n".join(lines)) + "
" + return ( + '

Sentinel and XDR queries

' + '

Current file snapshot at report generation; textual comparison is not proof of semantic equivalence.

' + + pair + diff + "
" + ) + + +def _result_html(rule: dict[str, Any]) -> str: + lines = [] + custom = rule["customDetection"] + lines.append(f"
  • Conversion: {_text(_status_label(custom.get('conversionStatus')))}; " + f"local structural checks: {_text(_status_label(custom.get('structuralStatus')))}
  • ") + for name, side in (("Sentinel", rule.get("analyticRule") or {}), ("XDR", custom)): + for runtime in side.get("runtime") or []: + count = runtime.get("rowCount") + measurement = f"{_text(count)} rows" if count is not None else "Row count unavailable" + lines.append( + f"
  • {name} runtime — {_text(runtime.get('provider'))}: " + f"{_text(_status_label(runtime.get('status')))} · {measurement}
  • " + ) + whitelist = runtime.get("sourceWhitelist") + if isinstance(whitelist, dict): + lines.append( + f"
  • Source table whitelist — {_text(whitelist.get('table'))}: " + f"{_text(whitelist.get('status'))}; raw result " + f"{_text((whitelist.get('rawFailure') or {}).get('status'))}; " + f"{_text(whitelist.get('reason'))}
  • " + ) + for field, label in (("deploymentStatus", "deployment"), ("alertStatus", "alerts")): + value = side.get(field) + if value not in {None, "not-run", "not-recorded"}: + lines.append(f"
  • {name} {label}: {_text(_status_label(value))}
  • ") + parity = rule.get("queryParity") or {} + if parity.get("status") not in {None, "not-run", "not-recorded"}: + lines.append(f"
  • Query parity: {_text(_status_label(parity['status']))}") + for field, label in (("analyticRuleRows", "Sentinel rows"), ("customDetectionRows", "XDR rows"), ("matchKey", "Match key")): + if parity.get(field) is not None: + lines.append(f" · {label}: {_text(parity[field])}") + lines.append("
  • ") + return "

    Recorded checks

      " + "".join(lines) + "
    " + + +def _run_html(report: dict[str, Any]) -> str: + workflow = report.get("workflow") or {} + stages = workflow.get("stages") or {} + profile = workflow.get("profile") + stage_html, evidence = [], [] + for name, stage in stages.items(): + label = STAGE_LABELS.get(name, name) + status = stage.get("status") + runtime_status = ( + (stage.get("artifacts") or {}).get("runtimeStatus") + if isinstance(stage.get("artifacts"), dict) + else None + ) + display_status = ( + "Pending" if status == "pending" and stage.get("attempts") != 0 + else "Source waived — not fully runtime-qualified" + if name == "validation" and runtime_status == "source-waived" + else "Passed — offline Authoring; runtime not run" + if name == "validation" + and runtime_status == "not-run" + and (stage.get("artifacts") or {}).get("validationMode") == "offline-authoring" + and status == "passed" + else _status_label(status) + ) + stage_html.append(f'
  • {_text(label)}: {_text(display_status)}
  • ') + if stage.get("message") or stage.get("evidence"): + evidence_links = _flow_links(report, name) + evidence.append( + f"
    {_text(label)} — recorded context" + f"

    {_safe_flow_message(stage['message']) if stage.get('message') else ''}

    " + + evidence_links + "
    " + ) + if not stages: + stage_html.append("
  • Workflow stage evidence unavailable.
  • ") + runtime_rules = sum( + bool(rule.get("analyticRule", {}).get("runtime") or rule["customDetection"].get("runtime")) + for rule in report["rules"] + ) + runtime_assessment = ( + report.get("runtimeAssessment") + if isinstance(report.get("runtimeAssessment"), dict) + else {} + ) + whitelist_entries = ( + runtime_assessment.get("sourceWhitelist") + if isinstance(runtime_assessment.get("sourceWhitelist"), list) + else [] + ) + whitelist_summary = "" + if whitelist_entries: + whitelist_summary = ( + f"

    Source runtime whitelist: {len(whitelist_entries)} IdentityInfo " + "missing-table result(s) classified; raw query results remain failed. " + "This does not indicate XDR runtime qualification.

    " + ) + workspace = "Configured in run context" if workflow.get("workspaceConfigured") else "Not recorded in run context" + return ( + '

    Run context

    ' + f"

    {_text(str(profile).capitalize() if profile else 'Profile unavailable')}" + f" · Run {_text(workflow.get('runId'))} · {_text(_status_label(workflow.get('status')))}

    " + '
      ' + "".join(stage_html) + "
    " + f"

    Workspace: {workspace}. Runtime evidence available for {runtime_rules} of {len(report['rules'])} rules.

    " + + whitelist_summary + + "

    Shared stage status does not override individual results. Missing evidence is not proof " + "a check was skipped. Optional stages are marked Not required only when recorded by the workflow.

    " + "
    Run messages, environment reasons and historical evidence" + "

    Recorded text may contain historical counts; current artifact counts appear below.

    " + + "".join(evidence) + "
    " + ) + + +FLOW_STATIONS = ( + ("setup", "Necessary setup / initial preflight"), + ("discovery", "Discovery"), + ("conversion", "Conversion"), + ("validation", "Validation"), + ("packaging", "Packaging"), + ("preflight", "Preflight"), + ("validationSuite", "Official validation rerun"), + ("deployment", "Deployment"), + ("mockIngestion", "Mock ingestion"), + ("queryValidation", "Semantic/query validation"), + ("alertParity", "Alert parity"), + ("packageAcceptance", "Full package deployment"), +) +FLOW_DESCRIPTIONS = { + "setup": "Display-only startup summary of persisted local tool/runtime, selected profile, explicit run ID, and locked target metadata. It never runs doctor or authentication. A separate fresh read-only preflight remains immediately before Qualification deployment.", + "discovery": "Identify the source solution and inventory its content. Source/target details appear only when recorded.", + "conversion": "Convert source rules into XDR detection drafts. Mapping choices remain reviewable; untracked subtasks are not marked complete.", + "validation": "Mandatory structural validation and recorded runtime checks. Query execution alone is not alert parity.", + "packaging": "Record the release action and build the V3.1 package. A passed historical build is not current unless package freshness is verified.", + "preflight": "Qualification only: inspect persisted cached-auth, scope, exact GET, DCR and query-permission evidence. Readiness is diagnostic, not write approval.", + "validationSuite": "Qualification prerequisite: current full official local validation is distinct from the canonical source-query/schema validation stage. No current suite result is inferred when absent.", + "deployment": "Qualification only: after validation and fresh exact-scope approval, deploy the approved AR/CD testing pairs disabled in the locked target environment.", + "mockIngestion": "Qualification only: ingest reviewed shared fixtures after a separate exact-scope write approval; prepared hypotheses are not runtime proof.", + "queryValidation": "Qualification only: validate semantic/query behavior against observed telemetry. Query-permission checks and zero-row query success are not semantic matches.", + "alertParity": "Qualification only: compare observed alerts and preserve separate approval and cleanup evidence.", + "packageAcceptance": "Separate later full-package deployment/read-back. Pair deployment does not prove package acceptance; this full-package action is not a canonical tracked stage here.", + "report": "Generate an offline snapshot from recorded workflow and rule evidence.", + "publication": "External to this workflow. A package artifact does not prove publication.", +} + + +def _flow_stage(stage: Any, *, freshness: Any = None) -> dict[str, Any]: + if not isinstance(stage, dict): + stage = {} + raw = stage.get("status") if isinstance(stage.get("status"), str) else None + normalized = { + "passed": ("completed", "Passed"), + "completed": ("completed", "Completed"), + "running": ("running", "In progress"), + "inProgress": ("running", "In progress"), + "blocked": ("blocked", "Blocked"), + "failed": ("failed", "Failed"), + "pending": ("pending", "Pending"), + "notRequired": ("not-required", "Not required"), + "stale": ("stale", "Stale"), + }.get(raw, ("unknown", "Evidence unavailable" if raw is None else "Unknown status")) + display = normalized[1] + artifacts = stage.get("artifacts") if isinstance(stage.get("artifacts"), dict) else {} + freshness_status = None + if isinstance(freshness, dict): + freshness_status = freshness.get("status") if isinstance(freshness.get("status"), str) else None + if raw in {"passed", "completed"}: + if freshness_status == "stale": + normalized = ("stale", "Stale package") + elif freshness_status != "current": + normalized = ("unknown", "Freshness unverified") + if artifacts.get("runtimeStatus") == "source-waived": + display = "Source waived — not fully runtime-qualified" + else: + display = normalized[1] + message = stage.get("message") if isinstance(stage.get("message"), str) else "" + evidence = stage.get("evidence") if isinstance(stage.get("evidence"), list) else [] + return { + "state": normalized[0], + "display": display, + "raw": raw or "", + "freshness": freshness_status or "", + "freshnessReason": ( + freshness.get("reason") + if isinstance(freshness, dict) and isinstance(freshness.get("reason"), str) + else "" + ), + "message": message, + "evidence": [item for item in evidence if isinstance(item, str)], + "updated": stage.get("updatedAt") if isinstance(stage.get("updatedAt"), str) else "", + "timing": " · ".join( + value for value in ( + f"Started: {stage.get('startedAt')}" if isinstance(stage.get("startedAt"), str) else "", + f"Completed: {stage.get('completedAt')}" if isinstance(stage.get("completedAt"), str) else "", + _flow_duration(stage.get("startedAt"), stage.get("completedAt")), + ) if value + ), + "attempts": ( + str(stage.get("attempts")) + if isinstance(stage.get("attempts"), int) and not isinstance(stage.get("attempts"), bool) + else "" + ), + } + + +FLOW_GLYPHS = { + "completed": "OK", "running": ">>", "blocked": "!!", "failed": "X", + "pending": "..", "not-required": "--", "stale": "~", "unknown": "?", +} + + +def _flow_duration(started: Any, completed: Any) -> str: + if not isinstance(started, str) or not isinstance(completed, str): + return "" + try: + start = datetime.fromisoformat(started.replace("Z", "+00:00")) + end = datetime.fromisoformat(completed.replace("Z", "+00:00")) + seconds = (end - start).total_seconds() + except (OverflowError, TypeError, ValueError): + return "" + return f"Elapsed: {seconds:.1f} seconds" if seconds >= 0 else "" + + +def _setup_station_value(report: dict[str, Any]) -> dict[str, Any]: + setup = report.get("startupSetup") + setup = setup if isinstance(setup, dict) else {} + return { + "state": "unknown", + "display": setup.get("display") if isinstance(setup.get("display"), str) else "Setup checks not recorded", + "raw": "not-checked", + "message": ( + "This informational station reads persisted run/profile/target records only. " + "It does not run doctor, inspect authentication caches, authenticate, or probe network access." + ), + "updated": "", + "timing": "", + "attempts": "", + } + + +def _preflight_station_value(preflight: dict[str, Any]) -> dict[str, Any]: + if preflight.get("graphRecorded") is not True: + return { + "state": "unknown", + "display": "Graph preflight not recorded", + "raw": "", + "message": "DCR/workspace permission evidence, when present, is partial and does not replace the Graph preflight.", + } + raw = preflight.get("deploymentReadiness") if isinstance(preflight.get("deploymentReadiness"), str) else "unknown" + states = { + "ready": ("unknown", "Readiness unconfirmed"), + "blocked": ("blocked", "Blocked"), + "action-required": ("blocked", "Action required"), + "readiness-unconfirmed": ("unknown", "Readiness unconfirmed"), + "unknown": ("unknown", "Unknown"), + } + state, display = states.get(raw, ("unknown", "Unknown preflight status")) + return { + "state": state, + "display": display, + "raw": raw, + "message": "Management permission grant, read access, GET checks, and deployment readiness are separate. A read-only GET does not prove effective write authorization; it is not deployment or write approval.", + } + + +def _query_validation_value(report: dict[str, Any]) -> dict[str, Any]: + rules = report.get("rules") if isinstance(report.get("rules"), list) else [] + results = [] + for rule in rules: + if isinstance(rule, dict) and isinstance(rule.get("queryParity"), dict): + results.append(rule["queryParity"]) + if not results: + return {"state": "unknown", "display": "Not run", "raw": "not-run"} + if any(item.get("status") == "failed" for item in results): + return {"state": "failed", "display": "Failed", "raw": "failed"} + if any(item.get("status") == "blocked" for item in results): + return {"state": "blocked", "display": "Blocked", "raw": "blocked"} + if all(item.get("status") == "passed" for item in results): + if any( + not isinstance(item.get("analyticRuleRows"), int) + or not isinstance(item.get("customDetectionRows"), int) + or item.get("analyticRuleRows") <= 0 + or item.get("customDetectionRows") <= 0 + for item in results + ): + return { + "state": "unknown", + "display": "Zero / unknown rows · no semantic pass", + "raw": "passed-without-positive-rows", + } + return {"state": "completed", "display": "Query parity recorded", "raw": "passed"} + return {"state": "unknown", "display": "Not run / incomplete", "raw": "unknown"} + + +def _package_acceptance_value(report: dict[str, Any]) -> dict[str, Any]: + plan = report.get("deploymentPlanning") + plan = plan.get("packageAcceptancePlan") if isinstance(plan, dict) else {} + plan = plan if isinstance(plan, dict) else {} + return { + "state": "unknown", + "display": "Not tracked · not run", + "raw": "not-tracked", + "message": ( + "Full-package deployment and read-back are separate from the V3.1 package build " + "and from direct AR/CD pair results. No full-package execution evidence is recorded." + ), + "plan": plan, + } + + +def _validation_suite_value(report: dict[str, Any]) -> dict[str, Any]: + workflow = report.get("workflow") if isinstance(report.get("workflow"), dict) else {} + stages = workflow.get("stages") if isinstance(workflow.get("stages"), dict) else {} + validation = stages.get("validation") if isinstance(stages.get("validation"), dict) else {} + current_suite = stages.get("validationSuite") + if isinstance(current_suite, dict) and isinstance(current_suite.get("status"), str): + value = _flow_stage(current_suite) + return { + **value, + "canonicalStatus": validation.get("status"), + "message": current_suite.get("message") + if isinstance(current_suite.get("message"), str) + else "Current official local validation status comes from the selected run's validationSuite stage evidence.", + } + return { + "state": "unknown", + "display": "Current full-suite result not recorded", + "raw": "not-recorded", + "message": ( + "The canonical Validation stage records structural/query evidence. " + "Its historical pass does not prove that the current full official local validation rerun passed." + ), + "canonicalStatus": validation.get("status"), + } + + +def _flow_station( + key: str, + title: str, + lines: tuple[str, ...], + x: int, + y: int, + value: dict[str, Any], + *, + branch: bool = False, + compact: bool = False, +) -> str: + label_y = y + (48 if branch else 42 if compact else 45) + line_markup = "".join( + f'{escape(line)}' + for index, line in enumerate(lines) + ) + status_y = label_y + len(lines) * 18 + 6 + return ( + f'' + f'' + f'' + f'' + f'{line_markup}' + f'{escape(value["display"])}' + f'{escape(title)} — {escape(value["display"])}' + ) + + +def _safe_local_href(value: Any) -> str | None: + if not isinstance(value, str) or not value or any(ord(char) < 32 for char in value): + return None + try: + parsed = urlsplit(value) + except ValueError: + return None + if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: + return None + if value.startswith(("/", "\\", "//")) or "\\" in value: + return None + decoded_path = unquote(parsed.path) + segments = decoded_path.split("/") + if ( + "\\" in decoded_path + or any(ord(char) < 32 for char in decoded_path) + or any(segment in {"", ".", ".."} for segment in segments) + ): + return None + if any(":" in segment for segment in segments): + return None + return value + + +def _flow_links(report: dict[str, Any], stage_name: str) -> str: + evidence = report.get("flowEvidence") + references = evidence.get(stage_name) if isinstance(evidence, dict) else None + safe_links = [] + workflow = report.get("workflow") if isinstance(report.get("workflow"), dict) else {} + stages = workflow.get("stages") if isinstance(workflow.get("stages"), dict) else {} + recorded = stages.get(stage_name) if isinstance(stages.get(stage_name), dict) else {} + unavailable = bool(recorded.get("evidence")) + for item in references if isinstance(references, list) else []: + if not isinstance(item, dict): + unavailable = True + continue + if item.get("available") is not True: + unavailable = True + continue + href = _safe_local_href(item.get("href")) + label = item.get("label") + if href is None or not isinstance(label, str) or not label: + unavailable = True + continue + label = label.replace("\\", "/").rsplit("/", 1)[-1] + if label in {"", ".", ".."}: + unavailable = True + continue + safe_links.append( + f'
  • {_text(label)}
  • ' + ) + if not safe_links: + message = ( + "Evidence is recorded but unavailable or non-portable." + if unavailable else "No linked stage evidence is recorded in this snapshot." + ) + return f'

    {message}

    ' + if unavailable: + safe_links.append('
  • Some recorded evidence is unavailable or non-portable.
  • ') + return '" + + +def _flow_rule_detail_link(index: int, name: Any) -> str: + return f'{_text(name)}' + + +def _flow_stage_detail(stage_name: str, report: dict[str, Any]) -> str: + workflow = report.get("workflow") if isinstance(report.get("workflow"), dict) else {} + stages = workflow.get("stages") if isinstance(workflow.get("stages"), dict) else {} + stage = stages.get(stage_name) + preflight = report.get("qualificationPreflight") if isinstance(report.get("qualificationPreflight"), dict) else {} + if stage_name == "setup": + setup_value = _setup_station_value(report) + stage = {"status": "unknown", "message": setup_value["message"]} + elif stage_name == "preflight": + preflight_value = _preflight_station_value(preflight) + stage = { + "status": preflight_value["raw"] or "unknown", + "message": preflight_value["message"], + } + if isinstance(preflight.get("checkedAt"), str): + stage["updatedAt"] = preflight["checkedAt"] + elif stage_name == "validationSuite": + suite_value = _validation_suite_value(report) + stage = { + "status": "unknown", + "message": suite_value["message"], + } + elif stage_name == "queryValidation": + query_value = _query_validation_value(report) + stage = { + "status": query_value["raw"], + "message": "Status is derived only from recorded semantic query-parity evidence; zero or missing row counts are not a pass.", + } + elif stage_name == "packageAcceptance": + package_value = _package_acceptance_value(report) + stage = { + "status": "unknown", + "message": package_value["message"], + } + freshness = workflow.get("packageFreshness") if stage_name == "packaging" else None + value = _flow_stage(stage, freshness=freshness) + if stage_name == "preflight": + value = {**value, **preflight_value} + elif stage_name == "validationSuite": + value = {**value, **_validation_suite_value(report)} + elif stage_name == "queryValidation": + value = {**value, **_query_validation_value(report)} + elif stage_name == "packageAcceptance": + value = {**value, **_package_acceptance_value(report)} + label = { + "validationSuite": "Official full-suite validation rerun", + "deployment": "Direct AR/CD testing-pair deployment", + "queryValidation": "Semantic/query validation", + "packageAcceptance": "Full-package deployment and read-back", + }.get(stage_name, STAGE_LABELS.get(stage_name, "Publication")) + description = FLOW_DESCRIPTIONS.get(stage_name, FLOW_DESCRIPTIONS["publication"]) + stage_status = ( + f'

    Recorded stage status: {_text(value["raw"] or "Evidence unavailable")} ' + f'· {_text(value["display"])}

    ' + ) + freshness_status = "" + if stage_name == "packaging": + fresh_value = ( + freshness.get("status") + if isinstance(freshness, dict) and isinstance(freshness.get("status"), str) + else "Evidence unavailable" + ) + freshness_status = f'

    Package freshness: {_text(fresh_value)}

    ' + if value["freshnessReason"]: + freshness_status += f'

    {_text(value["freshnessReason"])}

    ' + if value["message"]: + message = f'

    Recorded remarks: {_safe_flow_message(value["message"])}

    ' + elif stage is None: + message = '

    No recorded stage status or remarks.

    ' + else: + message = '

    No stage remarks recorded.

    ' + timestamps = [] + if value["updated"]: + timestamps.append(f'Updated: {_text(value["updated"])}') + if value["timing"]: + timestamps.append(_text(value["timing"])) + if value["attempts"]: + timestamps.append(f'Attempts: {_text(value["attempts"])}') + timing = f'

    {" · ".join(timestamps)}

    ' if timestamps else "" + stage_evidence = _flow_links(report, stage_name) + rules = report.get("rules") if isinstance(report.get("rules"), list) else [] + rule_proof = "" + if stage_name == "setup": + setup = report.get("startupSetup") if isinstance(report.get("startupSetup"), dict) else {} + setup_target = setup.get("target") if isinstance(setup.get("target"), dict) else {} + auth = setup.get("authentication") if isinstance(setup.get("authentication"), dict) else {} + consent = ( + report.get("deploymentPlanning", {}).get("deploymentPlan", {}).get("planningConsent", {}) + if isinstance(report.get("deploymentPlanning"), dict) + and isinstance(report.get("deploymentPlanning", {}).get("deploymentPlan"), dict) + else {} + ) + runtime_consent = ( + report.get("workflow", {}).get("runtimeQueryConsent", {}) + if isinstance(report.get("workflow"), dict) + else {} + ) + runtime_families = ( + runtime_consent.get("families", {}) + if isinstance(runtime_consent.get("families"), dict) + else {} + ) + details = [ + f'
  • Explicit migration run ID: {_text(setup.get("runId") or "Not recorded")}
  • ', + f'
  • Confirmed workflow profile: {_text(setup.get("profile") or "Not recorded")}
  • ', + f'
  • Local Python/CLI/tool checks: {_text(setup.get("localToolChecks") or "Not recorded")}
  • ', + f'
  • Locked target record: {_text("Confirmed" if setup.get("targetLocked") else "Not confirmed")} · source: {_text(setup.get("targetSource") or "Not recorded")}
  • ', + f'
  • Cached authentication evidence: {_text(auth.get("status") or "Not checked in this run")}
  • ', + f'
  • Advance read-only runtime-query consent: {_text(runtime_consent.get("status") or "Not recorded")} · scope: {_text(runtime_consent.get("consentScope") or "Not recorded")} · latest decision: {_text((runtime_consent.get("latestDecision") or {}).get("status") or "unanswered")} · run snapshot: {_text((runtime_consent.get("runSnapshot") or {}).get("status") or "not-current")} · Sentinel source: {_text((runtime_families.get("sentinelSourceQueryFamily") or {}).get("status") or "Not recorded")} / {_text((runtime_families.get("sentinelSourceQueryFamily") or {}).get("executionStatus") or "not-run")} · Defender XDR: {_text((runtime_families.get("defenderXdrQueryFamily") or {}).get("status") or "Not recorded")} / {_text((runtime_families.get("defenderXdrQueryFamily") or {}).get("executionStatus") or "not-run")}
  • ', + '
  • Exact-scope write approval: Not recorded. A recorded target or cached auth does not authorize any write.
  • ', + ] + consent_value = consent.get("status") + if isinstance(consent_value, str) and consent_value.casefold().replace("_", "-") not in { + "not-recorded", "unknown", "unrecorded", "not-checked", + }: + details.insert( + 5, + f'
  • Advance planning consent: {_text(consent_value)} · hash: ' + f'{_text(consent.get("planSha256") or "Not recorded")}
  • ', + ) + if setup_target: + details.extend( + f'
  • {_text(key)}: {_text(value)}
  • ' + for key, value in setup_target.items() + ) + if isinstance(auth.get("checkedAt"), str): + details.append( + f'
  • Last recorded scoped preflight: {_text(auth["checkedAt"])}; ' + 'this is later-stage evidence, not an initial setup pass.
  • ' + ) + notes = setup.get("notes") if isinstance(setup.get("notes"), list) else [] + details.extend(f'
  • {_safe_flow_message(note)}
  • ' for note in notes if isinstance(note, str)) + rule_proof = ( + '

    Persisted setup/configuration facts

      ' + + "".join(details) + + '

    The .env target keys are configuration only; they do not prove Graph/ARM authentication, scoped read access, management permission, effective RBAC, or deployment approval. The report does not run doctor, authenticate, or make provider/network calls. Runtime-query consent status may inspect only local cached-account selection to detect stale authentication bindings.

    ' + + ('

    Qualification still requires a fresh read-only preflight immediately before each deployment phase.

    ' if setup.get("profile") == "qualification" else "") + ) + elif stage_name in {"discovery", "conversion"}: + entries = [] + for index, rule in enumerate(rules): + if not isinstance(rule, dict): + continue + custom = rule.get("customDetection") if isinstance(rule.get("customDetection"), dict) else {} + findings = [] + if stage_name == "conversion": + findings.extend(str(item) for item in custom.get("reviewReasons") or []) + findings.extend( + str(item.get("message") or item.get("stage") or "Recorded conversion error") + for item in rule.get("errors") or [] if isinstance(item, dict) + ) + status = custom.get("conversionStatus") or "not recorded" + text = f'Conversion: {_text(status)}' + if findings: + text += " · " + _text("; ".join(findings)) + else: + text = ( + f'Source file: {_text(Path(str(rule.get("sourceFile") or "")).name or "Not recorded")} · ' + f'Conversion: {_text(custom.get("conversionStatus") or "not recorded")}' + ) + entries.append(f'
  • {_flow_rule_detail_link(index, rule.get("name"))} — {text}
  • ') + if entries: + rule_proof = '
    Rule-level evidence
      ' + "".join(entries) + "
    " + elif stage_name == "discovery": + rule_proof = '

    No source-rule inventory is recorded in this report.

    ' + elif stage_name == "validation": + entries = [] + for index, rule in enumerate(rules): + if not isinstance(rule, dict): + continue + custom = rule.get("customDetection") if isinstance(rule.get("customDetection"), dict) else {} + analytic = rule.get("analyticRule") if isinstance(rule.get("analyticRule"), dict) else {} + results = [ + f'Local structure: {_status_label(custom.get("structuralStatus"))}' + ] + for side_name, side in (("Source query", analytic), ("Converted query", custom)): + for runtime in side.get("runtime") or []: + row_count = runtime.get("rowCount") + rows_text = f'{_text(row_count)} rows' if row_count is not None else "row count unavailable" + results.append( + f'{side_name} ({_text(runtime.get("provider") or "provider unavailable")}): ' + f'{_status_label(runtime.get("status"))}, {rows_text}' + ) + entries.append( + f'
  • {_flow_rule_detail_link(index, rule.get("name"))} — {_text("; ".join(results))}
  • ' + ) + rule_proof = ( + '
    Structural and query-provider results
      ' + "".join(entries) + "
    " + if entries else '

    No per-rule structural or query-provider results are recorded.

    ' + ) + elif stage_name in {"deployment", "mockIngestion", "alertParity"}: + if stage_name == "deployment": + entries = [] + for index, rule in enumerate(rules): + if not isinstance(rule, dict): + continue + custom = rule.get("customDetection") if isinstance(rule.get("customDetection"), dict) else {} + analytic = rule.get("analyticRule") if isinstance(rule.get("analyticRule"), dict) else {} + entries.append( + f'
  • {_flow_rule_detail_link(index, rule.get("name"))} — ' + f'Source: {_status_label(analytic.get("deploymentStatus"))}; ' + f'XDR: {_status_label(custom.get("deploymentStatus"))}
  • ' + ) + elif stage_name == "alertParity": + entries = [] + for index, rule in enumerate(rules): + if isinstance(rule, dict): + custom = rule.get("customDetection") if isinstance(rule.get("customDetection"), dict) else {} + entries.append( + f'
  • {_flow_rule_detail_link(index, rule.get("name"))} — ' + f'Strict alert parity: {_status_label(custom.get("alertStatus"))}
  • ' + ) + else: + entries = [] + if entries: + rule_proof = '
    Recorded per-rule outcomes
      ' + "".join(entries) + "
    " + elif stage_name == "mockIngestion": + ingestion = report.get("ingestion") + record_count = len(ingestion) if isinstance(ingestion, list) else 0 + rule_proof = ( + f'

    {record_count} ingestion report(s) were included in this snapshot.

    ' + if record_count else '

    No ingestion reports are recorded in this snapshot.

    ' + ) + elif stage_name == "preflight": + auth = preflight.get("authentication") if isinstance(preflight.get("authentication"), dict) else {} + permissions = preflight.get("permissions") if isinstance(preflight.get("permissions"), dict) else {} + read_checks = preflight.get("readChecks") if isinstance(preflight.get("readChecks"), list) else [] + dcr = preflight.get("dcr") if isinstance(preflight.get("dcr"), dict) else {} + checks = [] + for field, label in ( + ("status", "Overall Graph preflight"), + ("method", "Selected cached-auth method"), + ("selectedMethod", "Selected cached-auth method"), + ("requestedScope", "Requested scope"), + ("tenantMatch", "Tenant match"), + ("graphAudience", "Graph audience"), + ): + if auth.get(field) is not None: + checks.append(f'
  • {label}: {_text(auth[field])}
  • ') + for field, label in ( + ("readAccess", "Read access"), + ("writeScopeInToken", "Write scope in token"), + ("consent", "Consent"), + ("writeAuthorization", "Write authorization"), + ): + if permissions.get(field) is not None: + checks.append(f'
  • {label}: {_text(permissions[field])}
  • ') + grant = preflight.get("managementPermissionGrant") + grant = grant if isinstance(grant, dict) else {} + required_permission = preflight.get("requiredManagementPermission") + if required_permission: + checks.append(f'
  • Required management permission: {_text(required_permission)}
  • ') + if grant: + checks.append( + f'
  • Required write-grant status: {_text(grant.get("status"))}; ' + f'grant type: {_text(grant.get("grantType"))}; claim type: {_text(grant.get("claimType"))}
  • ' + ) + checks.append( + f'
  • Grant evidence permission: {_text(grant.get("permission"))}; ' + f'credential request scope: {_text(grant.get("credentialRequestScope"))}
  • ' + ) + if grant.get("reason"): + checks.append(f'
  • Grant assessment: {_safe_flow_message(grant["reason"])}
  • ') + if preflight.get("deploymentReadiness") is not None: + checks.append(f'
  • Deployment readiness assessment: {_text(preflight.get("deploymentReadiness"))}
  • ') + checks.append(f'
  • Effective management role: {_text(preflight.get("effectiveRoleStatus"))}
  • ') + claims = preflight.get("tokenPermissionClaims") + claims = claims if isinstance(claims, dict) else {} + if claims: + checks.append( + f'
  • Token permission-claim inspection: {_text(claims.get("status"))}; ' + f'claim type: {_text(claims.get("claimType"))}
  • ' + ) + if read_checks: + checks.append( + '
  • Exact-rule GET checks: ' + + "; ".join( + _text(" · ".join( + str(check[key]) for key in ("status", "httpStatus") if check.get(key) is not None + )) for check in read_checks if isinstance(check, dict) + ) + + '
  • ' + ) + if dcr: + checks.extend([ + f'
  • DCR preflight: {_text(dcr.get("status"))}; authentication: {_text(dcr.get("authenticationStatus"))}
  • ', + f'
  • Locked-target verification: {_text(dcr.get("targetVerificationStatus"))}; local contract readiness: {_text(dcr.get("localReadinessStatus"))}
  • ', + f'
  • Workspace query permission: {_text(dcr.get("workspaceQueryPermissionStatus"))}; contracts recorded: {_text(dcr.get("contractCount"))}
  • ', + ]) + if not checks: + rule_proof = '

    No persisted Graph authentication, scope, GET, DCR, or workspace-query preflight details are available.

    ' + else: + checks.extend([ + '
  • Required CustomDetection.ReadWrite.All grant is distinct from read grant, successful GET, effective RBAC, and write authorization.
  • ', + '
  • Preflight is diagnostic only; it does not prove effective write authorization or approve a write.
  • ', + '
  • Query-permission preflight is not semantic query parity, telemetry ingestion, or alert parity.
  • ', + ]) + rule_proof = '

    Recorded read-only checks

      ' + "".join(checks) + "
    " + elif stage_name == "packageAcceptance": + plan = package_value.get("plan") if isinstance(package_value.get("plan"), dict) else {} + plan_types = plan.get("resourceTypes") if isinstance(plan.get("resourceTypes"), list) else [] + plan_identity = plan.get("packageIdentity") if isinstance(plan.get("packageIdentity"), dict) else {} + type_list = ( + '
      ' + "".join(f'
    • {_text(item)}
    • ' for item in plan_types) + '
    ' + if plan_types else '

    No recorded package-acceptance resource list.

    ' + ) + identity_text = " · ".join( + f'{_text(key)}: {_text(value)}' for key, value in plan_identity.items() + ) or "Package identity not recorded in acceptance plan." + rule_proof = ( + f'

    Recorded proposal status: {_text(plan.get("status"))}

    ' + f'

    Proposal package identity: {identity_text}

    ' + f'

    Proposal execution status: {_text(plan.get("executionStatus"))}

    ' + f'

    Proposal approval status: {_text(plan.get("approvalStatus"))}

    ' + + type_list + + '

    Proposal/planning metadata is not final exact-scope approval, execution, or package acceptance evidence.

    ' + ) + elif stage_name == "validationSuite": + canonical_status = suite_value.get("canonicalStatus") + rule_proof = ( + f'

    Recorded canonical structural/query Validation status: {_text(canonical_status)}

    ' + '

    A prior structural or source-query pass does not prove the current full official local validation rerun.

    ' + ) + elif stage_name == "queryValidation": + counts = report.get("summary") if isinstance(report.get("summary"), dict) else {} + rule_proof = ( + f'

    Recorded query parity passed: {_text(counts.get("queryParityPassed"))} of {_text(counts.get("rules"))} rules.

    ' + '

    Missing checks, zero-row results, and query-permission checks are not semantic validation passes.

    ' + ) + if stage_name == "publication": + message = '

    Publication is external and not tracked. No publication link or completion status is available here.

    ' + stage_evidence = '

    No publication evidence is recorded.

    ' + if stage_name == "report" and report.get("generatedAt"): + stage_status = ( + f'

    Report snapshot: Generated {_text(report["generatedAt"])}

    ' + + (f'

    Recorded report-stage status: {_text(value["raw"])} · {_text(value["display"])}

    ' if value["raw"] else "") + ) + return ( + f'
    ' + f'

    {_text(label)} evidence

    ' + + f'

    {_text(description)}

    ' + + stage_status + freshness_status + message + timing + rule_proof + + '

    Evidence artifacts

    ' + stage_evidence + '
    ' + ) + + +def _flow_evidence_html(report: dict[str, Any], show_qualification: bool) -> str: + stages = ["setup", "discovery", "conversion", "validation", "packaging"] + if show_qualification: + stages.extend([ + "validationSuite", "preflight", "deployment", "mockIngestion", + "queryValidation", "alertParity", "packageAcceptance", + ]) + stages.extend(["report", "publication"]) + return ( + '
    ' + '

    Migration evidence

    ' + '

    Each station links here to its recorded evidence. Artifact links are limited to files inside this selected migration run; unavailable or non-portable references are stated without exposing local paths.

    ' + + "".join(_flow_stage_detail(name, report) for name in stages) + + '
    ' + ) + + +def _flow_html(report: dict[str, Any]) -> str: + workflow = report.get("workflow") if isinstance(report.get("workflow"), dict) else {} + stages = workflow.get("stages") if isinstance(workflow.get("stages"), dict) else {} + recorded_profile = workflow.get("profile") + profile = recorded_profile if isinstance(recorded_profile, str) and recorded_profile in {"authoring", "qualification"} else None + mode = profile if workflow.get("profileSelectionConfirmed") is True else None + freshness = workflow.get("packageFreshness") + values = { + name: _flow_stage(stage, freshness=freshness if name == "packaging" else None) + for name, stage in stages.items() + } + for name, _ in FLOW_STATIONS: + values.setdefault(name, _flow_stage(None)) + qualification_evidence = any( + isinstance(stages.get(name), dict) + and ( + ( + stages[name].get("status") is not None + and stages[name].get("status") != "notRequired" + ) + or ( + stages[name].get("attempts") is not None + and stages[name].get("attempts") != 0 + ) + or bool(stages[name].get("evidence")) + or bool(stages[name].get("message")) + ) + for name in ("deployment", "mockIngestion", "alertParity") + ) + qualification_evidence = qualification_evidence or any( + isinstance(rule, dict) + and isinstance(rule.get("queryParity"), dict) + and rule["queryParity"].get("status") not in {None, "not-run", "not-recorded"} + for rule in report.get("rules", []) + ) + planning = report.get("deploymentPlanning") + package_acceptance_plan = ( + planning.get("packageAcceptancePlan") + if isinstance(planning, dict) and isinstance(planning.get("packageAcceptancePlan"), dict) + else {} + ) + qualification_evidence = qualification_evidence or package_acceptance_plan.get("recorded") is True + show_qualification = mode == "qualification" or qualification_evidence + preflight_evidence = bool( + (report.get("qualificationPreflight") or {}).get("graphRecorded") is True + or (report.get("qualificationPreflight") or {}).get("dcrRecorded") is True + ) + show_qualification = show_qualification or preflight_evidence + compact = mode == "authoring" and not show_qualification + setup_value = _setup_station_value(report) + report_value = _flow_stage(stages.get("report")) + if report.get("generatedAt"): + report_value = { + **report_value, + "state": "completed" if report_value["raw"] in {"", "passed"} else report_value["state"], + "display": "Snapshot generated" if report_value["raw"] in {"", "passed"} else report_value["display"], + "message": report_value["message"] or "This offline JSON/HTML snapshot was generated at the timestamp shown above.", + "updated": report_value["updated"] or str(report["generatedAt"]), + } + coordinates = ( + {"setup": 70, "discovery": 250, "conversion": 430, "validation": 610, "packaging": 790, "report": 980, "publication": 1120} + if compact else + {"setup": 80, "discovery": 260, "conversion": 440, "validation": 620, "packaging": 800, "report": 1880, "publication": 2040} + ) + y = 92 if compact else 105 + station_specs = [ + ("setup", "Necessary setup / initial preflight", ("Necessary setup", "initial preflight"), coordinates["setup"], setup_value), + ("discovery", "Discovery", ("Discovery",), coordinates["discovery"], values["discovery"]), + ("conversion", "Conversion", ("Conversion",), coordinates["conversion"], values["conversion"]), + ("validation", "Validation", ("Validation",), coordinates["validation"], values["validation"]), + ("packaging", "Packaging", ("Packaging",), coordinates["packaging"], values["packaging"]), + ] + if show_qualification: + station_specs.extend([ + ("validationSuite", "Official validation rerun", ("Official", "validation rerun"), 930, _validation_suite_value(report)), + ("preflight", "Just-in-time preflight", ("Fresh", "preflight"), 1080, _preflight_station_value( + report.get("qualificationPreflight") + if isinstance(report.get("qualificationPreflight"), dict) else {} + )), + ("deployment", "Direct AR/CD pair deployment", ("Direct AR/CD", "pair deployment"), 1230, values["deployment"]), + ("mockIngestion", "Mock ingestion", ("Shared mock", "ingestion"), 1380, values["mockIngestion"]), + ("queryValidation", "Semantic/query validation", ("Semantic/query", "validation"), 1530, _query_validation_value(report)), + ("alertParity", "Alert parity", ("Alert", "parity"), 1680, values["alertParity"]), + ("packageAcceptance", "Full package deployment", ("Full package", "deployment"), 1830, _package_acceptance_value(report)), + ]) + station_specs.extend([ + ("report", "Report", ("Report", "snapshot"), coordinates["report"], report_value), + ("publication", "External publication (optional)", ("External", "publish (optional)"), coordinates["publication"], { + "state": "unknown", "display": "Not tracked", "raw": "", "freshness": "", + "freshnessReason": "", + "message": "Optional publication is external to the canonical workflow and has no recorded status here. A package artifact does not prove publication.", + "evidence": [], "updated": "", "timing": "", "attempts": "", + }), + ]) + lines = [ + _flow_station( + key, title, title_lines, x, + 288 if key in { + "validationSuite", "preflight", "deployment", "mockIngestion", + "queryValidation", "alertParity", "packageAcceptance", + } else y, + value, + branch=key in { + "validationSuite", "preflight", "deployment", "mockIngestion", + "queryValidation", "alertParity", "packageAcceptance", + }, + compact=compact, + ) + for key, title, title_lines, x, value in station_specs + ] + route_authoring = "active" if mode == "authoring" else "" + route_qualification = "active" if mode == "qualification" else "" + branch_label = ( + "Lab qualification selected" + if mode == "qualification" + else "Lab evidence recorded · authoring" + if mode == "authoring" + else "Lab evidence present · mode unknown" + ) + if mode == "qualification": + mode_label = "Qualification · lab validation" + mode_note = "The selected qualification route follows Packaging, then rejoins at Report. Mandatory structural validation remains on the main route." + elif compact: + mode_label = "Authoring · no lab qualification" + mode_note = "Lab qualification was not selected. The optional branch is omitted; mandatory structural validation and packaging remain on this route." + elif mode == "authoring": + mode_label = "Authoring · qualification evidence recorded" + mode_note = "The authoring profile is recorded, and existing lab-stage evidence remains visible rather than being hidden." + elif show_qualification: + mode_label = "Mode not recorded · qualification evidence present" + mode_note = "The selected mode is unavailable in this legacy report. Qualification-stage evidence is shown without inferring profile selection." + else: + mode_label = "Mode not recorded" + mode_note = "The selected mode is unavailable. No authoring or qualification profile is inferred; statuses remain unknown unless recorded." + workflow_status = workflow.get("status") if isinstance(workflow.get("status"), str) else None + overall_status = _status_label(workflow_status) if workflow_status else "Evidence unavailable" + description = ( + "Necessary setup and initial preflight, discovery, conversion, mandatory validation, packaging and report, followed by optional external publication." + if compact else + "Necessary setup and initial preflight, discovery, conversion, mandatory validation and V3.1 packaging continue to Report. The optional qualification branch illustrates a separate local validation rerun, a fresh read-only preflight, direct AR/CD pairs, fixture ingestion and semantic checks, alert parity, then separately approved full-package deployment before Report. These display-only planned steps do not alter the canonical workflow." + ) + svg_viewbox = "0 0 1160 245" if compact else "0 0 2110 420" + route_markup = ( + f'' + f'' + f'' + ) + if show_qualification: + route_markup += ( + f'' + f'' + ) + return ( + '
    ' + f'

    Migration flow {_text(mode_label)}

    ' + f'

    Recorded workflow status: {_text(overall_status)}

    ' + '

    A snapshot of recorded workflow state, generated at the timestamp above; this diagram does not poll or change stages. Each clickable station opens its stage-specific evidence below.

    ' + f'

    {_text(mode_note)}

    ' + '
    ' + f'' + 'Migration workflow subway schematic' + f'{escape(description)}' + + route_markup + + (f'Authoring route' if compact else "") + + (f'{escape(branch_label)}' if show_qualification else "") + + "".join(lines) + + '
    ' + '

    Tab moves between stations; press Enter to open that station’s recorded evidence. Underlined labels, hover and focus cues identify links. Status words and symbols supplement color.

    ' + '
      ' + '
    • Completed / snapshot
    • ' + '
    • In progress
    • ' + '
    • Blocked or failed
    • ' + '
    • Stale
    • ' + '
    • Pending
    • ' + '
    • Not required
    • ' + '
    • Unknown / not tracked
    • ' + '
    ' + '
    ' + + _flow_evidence_html(report, show_qualification) + ) + + +def _deployment_planning_html(report: dict[str, Any]) -> str: + plan = report.get("deploymentPlanning") + plan = plan if isinstance(plan, dict) else {} + contract = plan.get("deploymentPlan") + contract = contract if isinstance(contract, dict) else {} + profile = plan.get("profile") + selected_profile = contract.get("selectedProfile") + profile_label = selected_profile if isinstance(selected_profile, str) else { + "authoring": "Authoring", + "qualification": "Qualification", + "unknown": "Mode not recorded", + }.get(profile, "Mode not recorded") + inventory = contract.get("packageResourceInventory") + if not isinstance(inventory, list) or not inventory: + inventory = plan.get("resourceInventory") + rows = [] + if isinstance(inventory, list): + for item in inventory: + if not isinstance(item, dict): + continue + if not any( + item.get(name) not in (None, "", [], {}) + for name in ("type", "typeAtVersion", "apiVersion", "count", "sourceForms", "extensionResource") + ): + continue + forms = item.get("sourceForms") + form_text = ", ".join(str(value) for value in forms) if isinstance(forms, list) else "not recorded" + resource_type = item.get("type") or item.get("typeAtVersion") or "Not recorded" + api_version = item.get("apiVersion") + if not api_version and isinstance(item.get("typeAtVersion"), str) and "@" in item["typeAtVersion"]: + resource_type, api_version = item["typeAtVersion"].rsplit("@", 1) + extension = "Yes" if item.get("extensionResource") is True else "No / not recorded" + rows.append( + "" + f'{_text(resource_type)}' + f'{_text(api_version or "Not recorded")}' + f'{_text(item.get("count"))}' + f'{_text(form_text)}' + f'{extension}' + "" + ) + inventory_markup = ( + '
    ' + '

    Package resource inventory

    ' + '

    Candidate package contents are inventory, not a selected or approved deployment scope. Resource type/API columns retain readable widths; scroll this table horizontally on narrow screens.

    ' + '
    ' + '' + + "".join(rows) + + '
    Resource typeAPI versionCountSource formExtension
    ' + if rows else "" + ) + defaults = plan.get("deploymentDefaults") + if isinstance(defaults, list): + default_lines = [ + f'
  • {_safe_flow_message(item.get("name"))}: {_text(item.get("value"))}
  • ' + for item in defaults + if isinstance(item, dict) and isinstance(item.get("value"), bool) + ] + else: + default_lines = [] + template_hash = plan.get("templateSha256") + recorded_hash = plan.get("recordedTemplateSha256") + package_hash = plan.get("packageArtifactsSha256") + input_hash = plan.get("packageInputSha256") + hash_markup = ( + f'

    Current template SHA-256: {_text(template_hash)}

    ' + if isinstance(template_hash, str) and re.fullmatch(r"[0-9a-fA-F]{64}", template_hash) + else '

    Current template SHA-256: unavailable.

    ' + ) + if isinstance(recorded_hash, str) and re.fullmatch(r"[0-9a-fA-F]{64}", recorded_hash): + hash_markup += f'

    Package-recorded template SHA-256: {_text(recorded_hash)}

    ' + if isinstance(package_hash, str) and re.fullmatch(r"[0-9a-fA-F]{64}", package_hash): + hash_markup += f'

    Package artifacts SHA-256: {_text(package_hash)}

    ' + if isinstance(input_hash, str) and re.fullmatch(r"[0-9a-fA-F]{64}", input_hash): + hash_markup += f'

    Package input identity SHA-256: {_text(input_hash)}

    ' + freshness = plan.get("packageFreshness") + identity = plan.get("packageIdentity") + startup = report.get("startupSetup") + startup = startup if isinstance(startup, dict) else {} + target = ( + contract.get("target") + if isinstance(contract.get("target"), dict) and contract.get("target") + else startup.get("target") + ) + target = target if isinstance(target, dict) else {} + target_source = ( + "Hash-bound plan target" + if contract.get("target") + else startup.get("targetSource") or "Not recorded" + ) + target_html = ( + f'

    {_text(target_source)} · ' + f'{"locked" if startup.get("targetLocked") or contract.get("target") else "lock not recorded"}

    ' + + "
    " + "".join( + f'
    {_text(key)}
    {_text(value)}
    ' + for key, value in target.items() + ) + "
    " + if target else '

    No target is recorded in this run; a formal deployment plan is also not recorded.

    ' + ) + source_inventory = contract.get("sourceInventory") + source_inventory_html = ( + "
      " + "".join( + f'
    • {_text(key)}: {_text(value)}
    • ' + for key, value in source_inventory.items() + ) + "
    " + if isinstance(source_inventory, dict) and source_inventory + else ( + f'

    No plan-bound source inventory recorded. This report includes ' + f'{_text(len(report.get("rules", [])))} source rule(s); this count is not an approved deployment scope.

    ' + ) + ) + detection_scope = contract.get("detectionScope") + detection_scope_items = ( + [ + f'{_text(key)}: {_text(value)}' + for key, value in detection_scope.items() + ] + if isinstance(detection_scope, dict) + else [] + ) + id_lists = [] + if isinstance(detection_scope, dict): + for key in ( + "currentReviewedDraftIds", "eligibleAdditionalRuleIds", + "excludedRuleIds", "ineligibleRuleIds", "duplicateSourceRuleIds", + ): + values = detection_scope.get(key) + if isinstance(values, list) and values: + id_lists.append( + f'
    {_text(key)} ({len(values)})
      ' + + "".join(f'
    • {_text(value)}
    • ' for value in values if isinstance(value, str)) + + '
    ' + ) + eligible_ids_html = "".join(id_lists) + caps = contract.get("scopeCaps") + cap_html = ( + "
    " + "".join( + f'
    {_text(key)}
    {_text(value)}
    ' for key, value in caps.items() + ) + "
    " + if isinstance(caps, dict) and caps + else "" + ) + consent = contract.get("planningConsent") + consent = consent if isinstance(consent, dict) else {} + consent_status = consent.get("status") if isinstance(consent.get("status"), str) else "not-recorded" + consent_recorded = consent_status.casefold().replace("_", "-") not in { + "not-recorded", "not-recorded-or-unknown", "unknown", "unrecorded", "not-checked", + } + consent_status_label = "Not recorded" if consent_status == "not-recorded" else consent_status + consent_keys = consent.get("componentKeys") if isinstance(consent.get("componentKeys"), list) else [] + consent_types = consent.get("resourceTypes") if isinstance(consent.get("resourceTypes"), list) else [] + consent_flags = consent.get("selectedFlags") if isinstance(consent.get("selectedFlags"), dict) else {} + consent_max_additional = consent.get("maxAdditionalCustomDetections") + component_markup = [] + components = contract.get("components") + if isinstance(components, dict): + for key, component in components.items(): + if not isinstance(component, dict): + continue + has_component_evidence = any( + value is not None + and value not in ("", [], {}) + and not ( + name in {"status", "planningConsent"} + and isinstance(value, str) + and value.casefold().replace("_", "-") in { + "not-recorded", "unknown", "unrecorded", "not-checked", + } + ) + for name, value in component.items() + ) + if not has_component_evidence: + continue + non_consentable = ( + component.get("consentable") is False + or key in {"controlledAlertParity", "ownedResourceCleanup"} + ) + resource_types = component.get("resourceTypes") + types_html = ( + "
      " + "".join(f'
    • {_text(item)}
    • ' for item in resource_types) + "
    " + if isinstance(resource_types, list) and resource_types else "" + ) + selected_flags = component.get("selectedFlags") + flags_html = ( + "
      " + "".join(f'
    • {_text(name)}: {_text(value)}
    • ' for name, value in selected_flags.items()) + "
    " + if isinstance(selected_flags, dict) and selected_flags + else '

    Selected flags not recorded.

    ' + ) + component_status = component.get("planningConsent") + component_status = component_status if isinstance(component_status, str) else "not-recorded" + component_fields = { + name: value for name, value in component.items() + if name not in {"resourceTypes", "planningConsent"} + } + component_plan_status = component.get("status") or "not-recorded" + component_reason = component.get("reason") + if non_consentable: + status_markup = ( + f'

    Coverage status: {_text(component_plan_status)} · ' + 'not consentable and not deployment approval.

    ' + '

    Advance consent support: No.

    ' + ) + elif component_status.casefold().replace("_", "-") in { + "not-recorded", "unknown", "unrecorded", "not-checked", + }: + status_markup = ( + f'

    Plan status: {_text(component_plan_status)}

    ' + ) + else: + status_markup = ( + f'

    Plan status: {_text(component_plan_status)} · ' + f'Advance consent: {_text(component_status)}

    ' + ) + coverage_fields = ( + ( + "eligiblePairRuleIds", "maximumRulePairCount", + "maximumEnableWriteRequests", "maximumRestoreWriteRequests", + "maximumWriteRequestsForOneBoundedRun", "timeWindow", + "restoreDisabledAfterRun", "abortOnRestoreFailure", + "repeatedRunsAllowed", "maximumRepeatedRuns", + "deleteCap", "maximumDeleteResourceCount", + "resources", "resourceIds", "resourceTypes", "ownershipEvidence", + "existingResourceDeletionAllowed", "unownedResourceDeletionAllowed", + ) + if non_consentable else () + ) + if non_consentable: + coverage_fields += tuple( + name for name in component + if isinstance(name, str) + and any(token in name.casefold() for token in ("restore", "abort", "repeat")) + and name not in coverage_fields + ) + coverage_summary_items = [] + for name in coverage_fields: + if name not in component: + continue + value = component[name] + if name in {"eligiblePairRuleIds", "resources", "resourceTypes"} and isinstance(value, list): + rendered_value = f"{len(value)} recorded" + ( + ": " + ", ".join(str(item) for item in value) + if name == "eligiblePairRuleIds" and value else "" + ) + else: + rendered_value = json.dumps(value, sort_keys=True) if isinstance(value, (dict, list)) else str(value) + coverage_summary_items.append( + f'
  • {_text(name)}: {_text(rendered_value)}
  • ' + ) + coverage_summary = ( + '
      ' + + "".join(coverage_summary_items) + '
    ' + if coverage_summary_items else "" + ) + component_markup.append( + f'

    {_text(key)}

    ' + + status_markup + + (f'

    Recorded reason: {_safe_flow_message(component_reason)}

    ' if isinstance(component_reason, str) and component_reason else "") + + coverage_summary + + types_html + + (flags_html if selected_flags else "") + + (f'
    Recorded component evidence
    {_text(json.dumps(component_fields, indent=2, sort_keys=True))}
    ' if component_fields else "") + + '
    ' + ) + plan_fingerprint = contract.get("planSha256") + plan_fingerprint_html = ( + f'

    Plan SHA-256: {_text(plan_fingerprint)}

    ' + if isinstance(plan_fingerprint, str) and re.fullmatch(r"[0-9a-fA-F]{64}", plan_fingerprint) + else '

    Plan fingerprint: unavailable.

    ' + ) + contract_artifact_hashes = contract.get("packageArtifactHashes") + if isinstance(contract_artifact_hashes, dict): + plan_fingerprint_html += "".join( + f'

    Planned { _text(name) } SHA-256: {_text(value)}

    ' + for name, value in contract_artifact_hashes.items() + if isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{64}", value) + ) + dcr_resolver = contract.get("dcrResolver") + dcr_resolver_html = ( + "
      " + "".join(f'
    • {_text(key)}: {_text(value)}
    • ' for key, value in dcr_resolver.items()) + "
    " + if isinstance(dcr_resolver, dict) and dcr_resolver + else '

    DCR resolver restrictions not recorded.

    ' + ) + package_acceptance = contract.get("packageAcceptancePlan") + if not isinstance(package_acceptance, dict) or not package_acceptance: + package_acceptance = plan.get("packageAcceptancePlan") + package_acceptance = package_acceptance if isinstance(package_acceptance, dict) else {} + package_input_hash = contract.get("packageInputSha256") + if isinstance(package_input_hash, str) and re.fullmatch(r"[0-9a-fA-F]{64}", package_input_hash): + plan_fingerprint_html += f'

    Package input SHA-256: {_text(package_input_hash)}

    ' + recorded_artifact_hashes = contract.get("recordedPackageArtifactHashes") + if isinstance(recorded_artifact_hashes, dict): + plan_fingerprint_html += "".join( + f'

    Recorded { _text(name) } SHA-256: {_text(value)}

    ' + for name, value in recorded_artifact_hashes.items() + if isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{64}", value) + ) + dcr_evidence = contract.get("dcrEvidence") if isinstance(contract.get("dcrEvidence"), dict) else {} + dcr_resolver = contract.get("dcrResolver") if isinstance(contract.get("dcrResolver"), dict) else {} + workflow = report.get("workflow") if isinstance(report.get("workflow"), dict) else {} + workflow_stages = workflow.get("stages") if isinstance(workflow.get("stages"), dict) else {} + validation_stage = workflow_stages.get("validationSuite") + validation_stage = validation_stage if isinstance(validation_stage, dict) else {} + deployment_stage = workflow_stages.get("deployment") + deployment_stage = deployment_stage if isinstance(deployment_stage, dict) else {} + validation_summary = _validation_suite_value(report) + package_acceptance_summary = package_acceptance + target_json = _text(json.dumps(target, sort_keys=True, indent=2)) if target else "" + source_json = _text(json.dumps(source_inventory, sort_keys=True, indent=2)) if isinstance(source_inventory, dict) and source_inventory else "" + detection_json = _text(json.dumps(detection_scope, sort_keys=True, indent=2)) if isinstance(detection_scope, dict) and detection_scope else "" + component_consent_note = ( + "Advance consent applies only to the listed persisted component scope." + if consent_status == "approved" and consent_keys + else "No approved component scope is recorded; conditional resource plans are not consent." + ) + current_package_status = plan.get("packageIdentity") + current_package_status = ( + current_package_status.get("status") + if isinstance(current_package_status, dict) + else plan.get("packageIdentity") + ) + version = plan.get("packageVersion") or ( + current_package_status.get("version") + if isinstance(current_package_status, dict) + else None + ) + if not version: + version = "Not recorded" + deployment_message = deployment_stage.get("message") + deployment_summary = ( + f'

    Current deployment blocker: {_safe_flow_message(deployment_message)}

    ' + if deployment_stage.get("status") in {"blocked", "failed"} and deployment_message + else "" + ) + consent_blocked_stage = next( + ( + name for name, stage in workflow_stages.items() + if isinstance(stage, dict) + and stage.get("status") in {"blocked", "failed"} + and isinstance(stage.get("message"), str) + and "consent" in stage["message"].casefold() + ), + None, + ) + target_summary = ( + target.get("workspaceName") + or target.get("workspaceResourceId") + or target.get("resourceGroup") + or "Not recorded" + ) + if profile_label == "qualification" and contract.get("executionMode") == "pairs": + route_summary = "Qualification · direct AR/CD pairs first" + elif profile_label == "qualification" and contract.get("executionMode") == "package": + route_summary = "Qualification · full-package acceptance selected" + elif profile_label == "qualification" and contract.get("executionMode") == "both": + route_summary = "Qualification · selected components" + else: + route_summary = f'{profile_label} · {contract.get("executionMode") or "selection not recorded"}' + target_fallback_text = ( + "Recorded run target; not bound to a formal deployment plan." + if target and not contract.get("target") + else "Target is hash-bound to the persisted plan." + if target and contract.get("target") + else "Target details are not available in this selected run." + ) + consent_scope_text = ( + " · ".join(_text(value) for value in consent_keys) + if consent_keys + else "No approved component scope recorded" + ) + if consent_status != "approved": + consent_scope_text = "No approved component scope recorded" + consent_summary = ( + f'
    Advance planning consent' + f'{_text(consent_status_label)} · {_text(consent_scope_text)}
    ' + if consent_recorded + else ( + '
    Advance planning consent' + f'Not recorded · blocking at {_text(consent_blocked_stage)} stage
    ' + if consent_blocked_stage else "" + ) + ) + inventory_status = ( + "Plan-bound source inventory recorded" + if isinstance(source_inventory, dict) and source_inventory + else f'{len(report.get("rules", []))} source rule(s) in this report only' + ) + hash_details = ( + f'
    Package hashes and plan provenance' + + hash_markup + plan_fingerprint_html + + ( + f'

    Current package input SHA-256: {_text(plan.get("currentPackageInputSha256"))}

    ' + if isinstance(plan.get("currentPackageInputSha256"), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", plan["currentPackageInputSha256"]) + else '

    Current package input identity: unavailable.

    ' + ) + + (f'

    Package content SHA-256: {_text(contract.get("packageContentSha256"))}

    ' if contract.get("packageContentSha256") else "") + + (f'

    Source inventory SHA-256: {_text(contract.get("sourceInventorySha256"))}

    ' if contract.get("sourceInventorySha256") else "") + + '
    ' + ) + package_default_details = ( + f'
    Recorded package defaults ({len(default_lines)})' + + (f'
      {"".join(default_lines)}
    ' if default_lines else '

    No package defaults recorded.

    ') + + '
    ' + ) + scope_details = ( + f'
    Detailed source/scope inventory' + + (f'
    {source_json}
    ' if source_json else '

    No plan-bound source inventory.

    ') + + (f'
    {detection_json}
    ' if detection_json else '

    No plan-bound detection scope.

    ') + + (f'
    {_text(json.dumps(caps, sort_keys=True, indent=2))}
    ' if isinstance(caps, dict) and caps else "") + + eligible_ids_html + + '
    ' + ) + dcr_details = ( + f'
    Preflight DCR evidence and resolver limits' + + ( + f'

    Resolver: {_text(dcr_resolver.get("kind") or "not recorded")} · ' + f'arbitrary solution-owned DCR discovery: {_text(dcr_resolver.get("arbitrarySolutionOwnedDcrDiscovery", "unknown"))}

    ' + if dcr_resolver else '

    DCR resolver restrictions are not recorded.

    ' + ) + + (f'
    {_text(json.dumps(dcr_evidence, sort_keys=True, indent=2))}
    ' if dcr_evidence else "") + + (f'
    {_text(json.dumps(dcr_resolver, sort_keys=True, indent=2))}
    ' if dcr_resolver else "") + + f'

    Bicep extensibility / what-if: {_text(contract.get("whatIfSupport") or "not-evaluated")}

    ' + + '
    ' + ) + package_acceptance_details = ( + f'
    Full-package acceptance proposal' + + (f'
    {_text(json.dumps(package_acceptance_summary, sort_keys=True, indent=2))}
    ' if package_acceptance_summary else '

    No package acceptance plan recorded.

    ') + + '

    V3.1 package creation is separate from later full-package deployment. AR/CD pair results do not establish package acceptance.

    ' + + '
    ' + ) + return ( + '
    ' + '

    Deployment scope and approval status ' + 'Informational snapshot · no write authorization

    ' + '
    ' + f'
    Profile / route{_text(route_summary)}
    ' + f'
    Recorded target{_text(target_summary)}
    ' + f'
    Formal scope plan{_text(contract.get("status") or "not-recorded")}
    ' + f'
    Current package{_text(version)} · freshness {_text(freshness or "unknown")} · identity {_text(current_package_status or "unknown")}
    ' + f'
    Official local validation{_text(validation_summary.get("display") or "Not recorded")}
    ' + + consent_summary + + '
    Exact-scope write approvalNot recorded. Required immediately before each environment write.
    ' + '
    ' + + deployment_summary + + '

    A missing formal plan is not evidence that no run target exists. The setup station shows only persisted target/profile data; it never runs doctor, authentication, discovery, or network checks.

    ' + + '
    ' + + '
    ' + + '

    Target and run context

    ' + + f'

    {_text(target_fallback_text)}

    ' + + (f'
    {"".join(f"
    {_text(key)}
    {_text(value)}
    " for key, value in target.items())}
    ' if target else '

    No target details are recorded.

    ') + + f'

    Target source: {_text(target_source or "not recorded")}

    ' + + f'

    Plan status: {_text(contract.get("status") or "No formal plan recorded")}

    ' + + (f'

    Planning consent fingerprint: {_text(consent.get("planSha256"))}

    ' if consent.get("planSha256") else "") + + '
    ' + + '
    ' + + '

    Source and selected scope

    ' + + f'

    {_text(inventory_status)}

    ' + + (source_inventory_html if isinstance(source_inventory, dict) and source_inventory else "") + + (f'

    Detection scope: {_text(" · ".join(detection_scope_items))}

    ' if detection_scope_items else "") + + (cap_html if cap_html else "") + + scope_details + + '
    ' + + inventory_markup + + ( + '
    ' + + f'

    {"Component plans and advance consent" if consent_recorded else "Component plans"}

    ' + + (f'

    {_text(component_consent_note)}

    ' if consent_recorded else "") + + '
    ' + "".join(component_markup) + '
    ' + + (f'

    Consent resource types: {_text(", ".join(consent_types))}

    ' if consent_status == "approved" and consent_types else "") + + (f'

    Approved max additional detections: {_text(consent_max_additional)}

    ' if consent_status == "approved" and consent_max_additional is not None else "") + + (f'
    Approved package flags and conditional selections
    {_text(json.dumps({"packageFlags": consent_flags, "conditionalSelections": consent.get("conditionalSelections")}, sort_keys=True, indent=2))}
    ' if consent_status == "approved" else "") + + '
    ' + if component_markup or consent_recorded else "" + ) + + package_default_details + dcr_details + package_acceptance_details + + '

    No cleanup, deletion, retention change, RBAC assignment, enablement, or recurring job is implied by these plans. Exact-scope approval and readiness are separate from advance consent.

    ' + + '
    ' + + hash_details + + '' + ) +def render_dashboard(report: dict[str, Any]) -> str: + workflow = report.get("workflow") or {} + candidates = review_candidates(report) + candidates_by_index = {candidate["index"]: candidate for candidate in candidates} + prepared = [] + groups: dict[tuple[str, str], dict[str, Any]] = {} + for index, rule in enumerate(report["rules"]): + findings = _findings(rule) + status = _rule_status(rule, workflow) + for finding in findings: + message = re.sub( + r"ruleOverrides\.[0-9a-fA-F-]{36}\.", "ruleOverrides..", finding["message"] + ) + key = (finding["severity"], message) + group = groups.setdefault(key, {"id": f"issue-{len(groups) + 1}", "message": message, + "severity": finding["severity"], "rules": set()}) + group["rules"].add(index) + finding["group"] = group + prepared.append((rule, status, findings)) + counts = Counter(status for _, status, _ in prepared) + counts["all"] = len(prepared) + counts["attention"] = sum(status not in {"ready", "excluded"} for _, status, _ in prepared) + finding_counts = Counter(finding["severity"] for _, _, findings in prepared for finding in findings) + affected_counts = Counter(kind for _, _, findings in prepared for kind in {finding["severity"] for finding in findings}) + categories = sorted({finding["category"] for _, _, findings in prepared for finding in findings}) + chips = "".join( + f'' + for key, label in STATUS_LABELS.items() if key != "excluded" or counts[key] + ) + category_html = "".join( + f'' + for category in categories + ) + finding_chips = ( + '' + + "".join( + f'' + for kind, labels in FINDING_LABELS.items() + ) + ) + shared_groups = sorted( + (group for group in groups.values() if len(group["rules"]) > 1), + key=lambda group: (list(FINDING_LABELS).index(group["severity"]), -len(group["rules"])), + ) + has_tactic_review = any(_is_tactic_review(finding) for _, _, findings in prepared for finding in findings) + shared = "" + for kind, labels in FINDING_LABELS.items(): + section = "".join( + f'
  • {_finding_badge(kind)} — ' + + ('Historical single-tactic finding; preserve source tactics ' + if _is_tactic_review(group) else _text(group["message"]) + " ") + + f'
  • ' + for group in shared_groups if group["severity"] == kind + ) + if section: + shared += f'

    {labels[0]}

      {section}
    ' + rows = [] + order = {"failed": 0, "review": 1, "unconfirmed": 2, "notstarted": 3, "ready": 4, "excluded": 5} + for index, (rule, status, findings) in sorted(enumerate(prepared), key=lambda item: (order[item[1][1]], str(item[1][0]["name"]).casefold())): + reasons, advisories, information = [], [], [] + evidence: dict[str, list[str]] = {kind: [] for kind in FINDING_LABELS} + for finding in findings: + group = finding["group"] + if _is_tactic_review(finding): + reason = 'Historical single-tactic finding; preserve source tactics' + elif len(group["rules"]) > 1: + reason = f'{_text(finding["category"])} · Issue {group["id"].split("-")[1]}' + else: + reason = _text(finding["message"]) + kind = finding["severity"] + (advisories if kind == "warning" else information if kind == "info" else reasons).append( + f"
  • {_finding_badge(kind)} {reason}
  • " + ) + evidence[kind].append( + f"
    {_finding_badge(kind)} — {_text(finding['message'])}" + f"

    Observed in: {_text(', '.join(finding['stages']))}

    " + f"
    {escape(json.dumps(finding['evidence'], indent=2, ensure_ascii=False))}
    " + ) + if not reasons: + reasons.append("
  • " + { + "unconfirmed": "Automated conversion passed; semantic review completion is unconfirmed.", + "ready": "Recorded conversion stage and local checks passed.", + "notstarted": "No conversion result recorded.", + "review": "Review required; no detailed reason recorded.", + "failed": "A recorded check failed or is blocked; see evidence.", + "excluded": "Excluded from conversion.", + }[status] + "
  • ") + search = " ".join( + str(value or "") for value in [ + rule["name"], rule.get("sourceFile"), rule.get("outputFile"), + rule.get("analyticRule", {}).get("id"), rule["customDetection"].get("id"), + *(finding["message"] for finding in findings), + ] + ).lower() + detail_id = f"rule-detail-{index}" + recommendation = rule.get("entityRecommendation") + advisory_html = ( + f'
    Warnings ({len(advisories)}) — nonblocking advisories' + f'
      {"".join(advisories)}
    ' if advisories else "" + ) + if information: + advisory_html += ( + f'
    Information ({len(information)}) — nonblocking notes' + f'
      {"".join(information)}
    ' + ) + selection_html = "" + candidate = candidates_by_index.get(index) + if candidate: + selection_html = ( + '
    ' + ) + if not candidate["eligible"]: + selection_html += f'

    {_text(candidate["reason"])}

    ' + evidence_html = "".join( + f'

    {FINDING_LABELS[kind][0]}

    {"".join(values)}
    ' + for kind, values in evidence.items() if values + ) + rows.append( + f'' + f'{_text(rule["name"])}
    ' + f'{STATUS_LABELS[status]}{selection_html}' + f'
      {"".join(reasons)}
    {advisory_html}' + f'{_text(_next_action(status, findings))}' + f'' + f'' + f'

    Source: {_text(rule.get("sourceFile"))}
    ' + f'Output: {_text(rule.get("outputFile"))}
    ' + f'Sentinel ID: {_text(rule.get("analyticRule", {}).get("id"))} · ' + f'XDR ID: {_text(rule["customDetection"].get("id"))}

    ' + + _query_html(rule) + + (f"

    Entity recommendation: {_text(recommendation)}

    " if recommendation else "") + + _result_html(rule) + + _tactic_details(rule, findings) + + "

    Findings and original evidence

    " + + (evidence_html if evidence_html else "

    No rule-level findings recorded.

    ") + + "" + ) + summary = report.get("summary") or {} + scope = summary.get("conversionScope") or {} + scope_notice = ( + '

    Rule-scoped conversion only

    ' + f'

    Selected source ID: {_text(", ".join(scope.get("ruleIds") or []))}. ' + 'Unselected drafts were not converted in this attempt. Local structural passes ' + 'do not establish full-solution conversion or packaging readiness.

    ' + if scope.get("kind") == "rule" else "" + ) + structural = sum(rule["customDetection"].get("structuralStatus") == "passed" for rule, _, _ in prepared) + distinct_errors = sum(finding["severity"] == "error" for _, _, findings in prepared for finding in findings) + occurrences = sum( + len(finding["evidence"]) for _, _, findings in prepared + for finding in findings if finding["severity"] == "error" + ) + return ( + '' + '' + f'{_text(report["solution"])} migration report' + f"
    " + f'

    {_text(report["solution"])} migration report

    ' + f'

    Generated {_text(report.get("generatedAt"))} · Offline report · No environment writes

    ' + + _flow_html(report) + + scope_notice + + _deployment_planning_html(report) + + _findings_chart_html(report["rules"]) + + _run_html(report) + + '

    Current artifact overview

    ' + f'
    {len(prepared)} rules{_text(summary.get("converted"))} automated conversions' + f'{_text(summary.get("needsReview"))} converter review flags{structural} local structural passes' + f'{distinct_errors} distinct rule errors · {occurrences} diagnostic occurrences
    ' + '

    Finding types — separate from readiness

    ' + + "".join( + f'{_finding_badge(kind)} {finding_counts[kind]} findings across {affected_counts[kind]} rules' + for kind in FINDING_LABELS + ) + + '

    Errors are failed checks/executions or recorded hard errors. Reviews are explicit unresolved human decisions. ' + 'Warnings are nonblocking advisories: a time/schedule warning is not automatically a review or failure. ' + 'Information records explicit backend informational notes; it is not a warning and does not by itself require attention. ' + 'Blocked prerequisites are tracked separately from execution failures. Counts are deduplicated per rule; types may overlap.

    ' + '

    Ready for validation means automated checks and the recorded conversion stage passed. ' + 'It does not mean deployment approval or proven semantic equivalence. When conversion review is incomplete, ' + 'automated successes remain Readiness unconfirmed. ' + 'Run-level caution: any historical semantic findings without structured rule mappings remain a run-level caution; ' + 'they do not create per-rule Needs review flags. Warnings do not change readiness.

    ' + + _chart_html(report["rules"]) + + ('
    Shared findings — ' + f'{len(shared_groups)} repeated patterns

    Each explanation appears once here. ' + 'Counts are affected rules, not diagnostic occurrences. Original per-rule evidence is preserved in Details.

    ' + + (_tactic_guide() if has_tactic_review else "") + + f'{shared}
    ' if shared or has_tactic_review else "") + + render_bulk_review(report, candidates) + + '

    Rules

    ' + '
    Status — select one; counts cover the entire run
    ' + + chips + '
    Finding type — counts are affected rules
    ' + + finding_chips + '

    Choosing a finding type shows all statuses; refine status afterward. ' + 'Warnings remain advisories even on a ready rule.

    ' + '' + '
    Issue categories — any selected category
    ' + + category_html + '

    Status, search and categories are combined. ' + 'Multiple categories match any selected category.

    ' + '
    ' + '

    ' + '

    ' + '

    ' + '

    ' + '' + '' + '
    ' + '' + '' + "".join(rows) + + f"
    Actionable issues first; individual results remain authoritative.
    RuleProblem / review reasonNext actionDetails
    " + ) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report_review.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report_review.py new file mode 100644 index 00000000000..ef7e98e9b19 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report_review.py @@ -0,0 +1,346 @@ +"""Local-only review/export controls; persisted migration results are never changed.""" + +from __future__ import annotations + +from collections import Counter +from html import escape +import json +import re +from typing import Any + + +def review_candidates(report: dict[str, Any]) -> list[dict[str, Any]]: + ids = Counter(str((rule.get("analyticRule") or {}).get("id") or "") for rule in report["rules"]) + result = [] + for index, rule in enumerate(report["rules"]): + if not any("one tactic" in str(reason).lower() for reason in rule["customDetection"].get("reviewReasons") or []): + continue + classification = rule.get("attackClassification") or {} + tactics = classification.get("sourceTactics") + techniques = classification.get("sourceTechniques") + source_id = str((rule.get("analyticRule") or {}).get("id") or "") + digest = str(rule.get("sourceSha256") or "") + reason = "" + if not source_id or ids[source_id] != 1: + reason = "Review individually: a unique source rule ID is required." + elif not re.fullmatch(r"[a-f0-9]{64}", digest): + reason = "Regenerate the report first: source file hash is not recorded." + elif not isinstance(tactics, list) or not tactics or not all(isinstance(value, str) and value for value in tactics): + reason = "Review individually: source tactic choices are unavailable." + elif not isinstance(techniques, list) or not techniques or not all( + isinstance(value, str) and re.fullmatch(r"T[0-9]{4}(?:\.[0-9]{3})?", value) + for value in techniques + ): + reason = "Review individually: supported source technique IDs are unavailable." + result.append({ + "index": index, "id": source_id, "name": str(rule["name"]), + "sourceFile": rule.get("sourceFile"), "sourceSha256": digest, + "tactics": tactics if isinstance(tactics, list) else [], + "techniques": techniques if isinstance(techniques, list) else [], + "eligible": not reason, "reason": reason, + }) + return result + + +def render_bulk_review(report: dict[str, Any], candidates: list[dict[str, Any]]) -> str: + if not candidates: + return "" + payload = { + "runId": (report.get("workflow") or {}).get("runId"), + "generatedAt": report.get("generatedAt"), + "solution": report.get("solutionPath") or report["solution"], + "candidates": candidates, + } + return ( + '
    Review and export tactic overrides — no automatic application' + '

    Historical review compatibility: multiple source tactics no longer require selection of one tactic ' + 'for authoring. Regeneration preserves all source tactics in source order. These optional legacy exports correct ' + 'techniques only for the named source tactic; they do not narrow or reorder tactics. ' + 'V3.1 ARM packaging carries only the first tactic; direct Graph deployment still requires a single tactic. ' + 'Neither operation proves classification parity.

    ' + '

    Select eligible rules in the table below, or select the currently visible eligible rules. ' + 'A bulk tactic choice is applied only to rules listing that source tactic. Techniques are never chosen automatically: ' + 'confirm their compatibility and observable behavior individually for every rule. ' + 'Source tactic and technique lists are independent, not a verified compatibility matrix.

    ' + '

    Pending export decisions are separate from recorded results. Nothing here resolves a finding, ' + 'changes a query, accepts warning/review text, or clears a workflow gate. Data loss and timing mismatches cannot be ' + 'bulk accepted as equivalent. This interface only downloads files; no local write service or cloud call is used.

    ' + ' ' + '' + '

    ' + ' ' + ' ' + '' + '

    ' + '
    ' + '

    Preserve existing migration configuration

    ' + '

    Import the existing complete configuration as JSON before merging. JSON is accepted by the migration ' + 'tool as YAML. For an existing YAML file, first create its JSON equivalent using the documented local command. ' + 'Unrelated root fields, rule overrides, and fields within selected rule overrides are retained.

    ' + ' ' + '' + '

    ' + '

    Import a configuration, or explicitly confirm that none exists.

    ' + '' + '

    ' + '
    ' + ) + + +BULK_SCRIPT = """ +(() => { + const root = document.getElementById("bulk-review"); + if (!root) return; + const payload = JSON.parse(root.dataset.reviewPayload); + const candidates = new Map(payload.candidates.filter(r => r.eligible).map(r => [r.id, r])); + const selected = new Set(); + const decisions = new Map(); + const byId = id => document.getElementById(id); + const checks = [...document.querySelectorAll(".review-select:not(:disabled)")]; + let imported = null; + let preview = null; + let importGeneration = 0; + function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } + function own(value, key) { return Object.prototype.hasOwnProperty.call(value, key); } + function invalidate() { + preview = null; + byId("review-preview").hidden = true; + byId("download-config").disabled = true; + byId("download-manifest").disabled = true; + byId("review-feedback").textContent = "Pending decisions only; recorded migration results are unchanged."; + } + function state(id) { + if (!decisions.has(id)) decisions.set(id, {tactic: "", techniques: new Set(), confirmed: false}); + return decisions.get(id); + } + function node(tag, text) { + const element = document.createElement(tag); + if (text !== undefined) element.textContent = text; + return element; + } + function tacticOptions(select, values, chosen) { + select.replaceChildren(new Option("Choose one tactic", "")); + [...new Set(values)].forEach(value => select.add(new Option(value, value))); + select.value = chosen; + } + function render() { + invalidate(); + byId("selection-count").textContent = `${selected.size} selected; ${[...selected].filter(id => state(id).confirmed).length} individually confirmed pending decisions`; + const available = [...selected].flatMap(id => candidates.get(id).tactics); + tacticOptions(byId("bulk-tactic"), available, byId("bulk-tactic").value); + const editor = byId("decision-editor"); + editor.replaceChildren(); + for (const id of selected) { + const rule = candidates.get(id), choice = state(id); + const section = node("fieldset"); + section.dataset.decisionId = id; + section.append(node("legend", `${rule.name} — ${id}`)); + const tacticLabel = node("label", "Source tactic to correct "); + const tactic = node("select"); + tactic.dataset.choice = "tactic"; + tacticOptions(tactic, rule.tactics, choice.tactic); + tacticLabel.append(tactic); + section.append(tacticLabel); + section.append(node("p", "Select compatible source techniques/subtechniques individually; no pairing is inferred.")); + const confirmationLabel = node("label"); + const confirmation = node("input"); + confirmation.type = "checkbox"; + confirmation.dataset.choice = "confirmed"; + confirmation.checked = choice.confirmed; + function resetConfirmation() { + choice.confirmed = false; + confirmation.checked = false; + invalidate(); + byId("selection-count").textContent = `${selected.size} selected; ${[...selected].filter(key => state(key).confirmed).length} individually confirmed pending decisions`; + } + for (const technique of [...new Set(rule.techniques)]) { + const label = node("label"); + label.className = "chip"; + const input = node("input"); + input.type = "checkbox"; + input.dataset.choice = "technique"; + input.value = technique; + input.checked = choice.techniques.has(technique); + input.addEventListener("change", () => { + if (input.checked) choice.techniques.add(technique); else choice.techniques.delete(technique); + resetConfirmation(); + }); + label.append(input, document.createTextNode(` ${technique} `)); + section.append(label); + } + tactic.addEventListener("change", () => { + choice.tactic = tactic.value; + choice.techniques.clear(); + choice.confirmed = false; + render(); + }); + confirmationLabel.append(confirmation, document.createTextNode( + " I individually verified that these techniques/subtechniques belong to this tactic and describe this rule's observable behavior." + )); + const line = node("p"); line.append(confirmationLabel); section.append(line); + confirmation.addEventListener("change", () => { + choice.confirmed = confirmation.checked; + invalidate(); + byId("selection-count").textContent = `${selected.size} selected; ${[...selected].filter(key => state(key).confirmed).length} individually confirmed pending decisions`; + }); + editor.append(section); + } + } + checks.forEach(input => input.addEventListener("change", () => { + if (input.checked) selected.add(input.dataset.reviewId); + else { selected.delete(input.dataset.reviewId); decisions.delete(input.dataset.reviewId); } + render(); + })); + byId("select-visible").addEventListener("click", () => { + checks.filter(input => !input.closest("tbody.rule").hidden).forEach(input => { + input.checked = true; selected.add(input.dataset.reviewId); + }); + render(); + }); + byId("clear-selection").addEventListener("click", () => { + selected.clear(); decisions.clear(); checks.forEach(input => { input.checked = false; }); + byId("bulk-feedback").textContent = ""; render(); + }); + byId("set-bulk-tactic").addEventListener("click", () => { + const tactic = byId("bulk-tactic").value; + if (!tactic || !selected.size) { + byId("bulk-feedback").textContent = "Select rules and a source tactic to correct first."; return; + } + let applied = 0; + const skipped = []; + for (const id of selected) { + const rule = candidates.get(id); + if (!rule.tactics.includes(tactic)) { + decisions.delete(id); + skipped.push(`${rule.name}: ${tactic} is not in the source tactic list; previous pending choice cleared.`); + continue; + } + decisions.set(id, {tactic, techniques: new Set(), confirmed: false}); + applied++; + } + render(); + byId("bulk-feedback").textContent = `Set tactic on ${applied} compatible rules. No techniques or confirmations selected. ${skipped.join(" ")}`; + }); + function validateConfig(value) { + if (!object(value)) throw new Error("The complete configuration must be a JSON object."); + if (own(value, "ruleOverrides")) { + if (!object(value.ruleOverrides)) throw new Error("ruleOverrides must be an object."); + for (const [id, entry] of Object.entries(value.ruleOverrides)) { + if (!object(entry)) throw new Error(`Override ${id} must be an object; import was not accepted.`); + } + } + return value; + } + byId("review-config-file").addEventListener("change", async event => { + const generation = ++importGeneration; + imported = null; + byId("new-config").checked = false; + invalidate(); + const file = event.target.files[0]; + if (!file) { byId("config-status").textContent = "No configuration imported."; return; } + try { + const text = await file.text(); + if (generation !== importGeneration) return; + imported = validateConfig(JSON.parse(text.replace(/^\\uFEFF/, ""))); + byId("config-status").textContent = `Imported ${file.name}; unrelated settings will be preserved.`; + } catch (error) { + if (generation !== importGeneration) return; + byId("config-status").textContent = `Import rejected: ${error.message} Convert YAML to JSON locally first if needed.`; + } + }); + byId("new-config").addEventListener("change", () => { + ++importGeneration; + imported = null; + byId("review-config-file").value = ""; + invalidate(); + byId("config-status").textContent = byId("new-config").checked ? + "Explicitly starting a new configuration; no existing settings will be merged." : + "Import the complete configuration or confirm that none exists."; + }); + byId("preview-decisions").addEventListener("click", () => { + invalidate(); + try { + if (!selected.size) throw new Error("Select at least one eligible rule."); + if (!imported && !byId("new-config").checked) throw new Error("Import the existing configuration or explicitly confirm that none exists."); + const config = imported ? JSON.parse(JSON.stringify(imported)) : {}; + const oldOverrides = own(config, "ruleOverrides") ? config.ruleOverrides : {}; + // Null-prototype dictionaries keep imported or source-provided keys as data. + const overrides = Object.create(null); + for (const [key, value] of Object.entries(oldOverrides)) overrides[key] = value; + const audit = []; + for (const id of selected) { + const rule = candidates.get(id), choice = state(id); + if (!choice.tactic || !rule.tactics.includes(choice.tactic)) throw new Error(`${rule.name}: choose a tactic from this rule's source list.`); + if (!choice.techniques.size) throw new Error(`${rule.name}: explicitly select at least one compatible technique/subtechnique.`); + if ([...choice.techniques].some(value => !rule.techniques.includes(value))) throw new Error(`${rule.name}: technique is not in this source snapshot.`); + if (!choice.confirmed) throw new Error(`${rule.name}: individual compatibility/behavior confirmation is required.`); + const prior = own(oldOverrides, id) ? oldOverrides[id] : {}; + const entry = Object.create(null); + for (const [key, value] of Object.entries(prior)) entry[key] = value; + entry.tactic = choice.tactic; + entry.techniques = [...choice.techniques]; + overrides[id] = entry; + audit.push({ + sourceId: id, sourceFile: rule.sourceFile, sourceSha256: rule.sourceSha256, + sourceTactics: rule.tactics, sourceTechniques: rule.techniques, + selectedTactic: choice.tactic, selectedTechniques: [...choice.techniques], + compatibilityIndividuallyConfirmed: true, + previousSelection: { + tactic: own(prior, "tactic") ? prior.tactic : null, + techniques: own(prior, "techniques") ? prior.techniques : null + } + }); + } + config.ruleOverrides = overrides; + const manifest = { + schemaVersion: "1.0", kind: "PendingReviewedTacticOverrides", + runId: payload.runId, reportGeneratedAt: payload.generatedAt, + exportedAt: new Date().toISOString(), solution: payload.solution, + applied: false, snapshotEnforcement: "Manual comparison required; not enforced by migration tool", + decisions: audit + }; + preview = {config: JSON.stringify(config, null, 2), manifest: JSON.stringify(manifest, null, 2)}; + byId("decision-preview").textContent = JSON.stringify(audit, null, 2); + byId("config-preview").textContent = preview.config; + byId("manifest-preview").textContent = preview.manifest; + byId("review-preview").hidden = false; + byId("download-config").disabled = false; + byId("download-manifest").disabled = false; + byId("review-feedback").textContent = `${audit.length} individually confirmed decisions previewed; pending export/application. Persisted results remain unchanged.`; + } catch (error) { + byId("review-feedback").textContent = error.message; + } + }); + function download(kind, name) { + if (!preview) return; + const url = URL.createObjectURL(new Blob([preview[kind] + "\\n"], {type: "application/json"})); + const link = node("a"); link.href = url; link.download = name; + document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + byId("review-feedback").textContent = "Download requested. Decisions are still pending application; no findings or gates were resolved."; + } + byId("download-config").addEventListener("click", () => download("config", "migration-reviewed-overrides.json")); + byId("download-manifest").addEventListener("click", () => download("manifest", "migration-reviewed-overrides.review.json")); + render(); +})(); +""" diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime.py new file mode 100644 index 00000000000..a5e271c7424 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime.py @@ -0,0 +1,1637 @@ +from __future__ import annotations + +import hashlib +import json +import re +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from pathlib import Path +from typing import Any + +import yaml + +from .artifacts import ( + existing_artifact_path, + report_directory, + portable_artifact, + write_json_artifact, +) +from .converter import custom_detail_schema_errors, xdr_detection_files +from .content_paths import content_path +from .graph_auth import validate_delegated_graph_claims +from .onboarding import ( + AUTH_RECORD_NAME, + CONFIG_NAME, + GRAPH_SCOPE, + TOKEN_CACHE_NAME, + _load_auth_record, + _read_json, + _state_dir, +) +from .graph_client_config import ( + graph_auth_record_path, + graph_token_cache_name, + resolve_graph_client_id, + verify_graph_auth_binding, +) +from .report import write_runtime_validation_report +from .runtime_consent import require_runtime_query_consent +from .source_runtime_waiver import SOURCE_PROVIDERS +from .target_context import require_locked_target + +ADVANCED_HUNTING_ENDPOINT = "https://graph.microsoft.com/v1.0/security/runHuntingQuery" +MAX_GRAPH_IN_FLIGHT_REQUESTS = 5 +MAX_ADVANCED_HUNTING_RETRIES = 3 +ADVANCED_HUNTING_RETRY_BUDGET_SECONDS = 5 * 60 +MAX_ADVANCED_HUNTING_SAMPLE_ROWS = 10 +_GRAPH_REQUEST_SEMAPHORE = threading.BoundedSemaphore(MAX_GRAPH_IN_FLIGHT_REQUESTS) +PROVIDER_PLATFORMS = { + "graph": "Microsoft Defender XDR Advanced Hunting", + "triage-mcp": "Microsoft Sentinel Triage MCP", + "log-analytics-cli": "Microsoft Sentinel Log Analytics", + "azure-monitor-sdk": "Microsoft Sentinel Log Analytics (Azure Monitor Query SDK)", +} +PROVIDER_QUERY_SURFACES = { + "graph": "defender-advanced-hunting", + "triage-mcp": "microsoft-sentinel-triage", + "log-analytics-cli": "log-analytics-workspace", + "azure-monitor-sdk": "log-analytics-workspace", +} +SUPPORTED_EXTERNAL_PROVIDERS = frozenset( + provider for provider in PROVIDER_PLATFORMS if provider != "graph" +) +PROVIDER_GAP_CODES = frozenset({ + "tool-unavailable", + "authentication", + "authorization", + "timeout", + "transient-provider-error", + "batch-provider-failure", +}) + + +def _detection_files(output: Path) -> list[Path]: + return xdr_detection_files(output) + + +def _effective_sdk_target( + solution: Path, + supplied_target: dict[str, str], +) -> dict[str, str]: + workflow_state = existing_artifact_path(solution, "workflow-state.json") + if not workflow_state.is_file(): + return supplied_target + locked = require_locked_target(solution, materialize_missing=False) + fields = ( + "tenantId", + "subscriptionId", + "resourceGroup", + "workspaceResourceId", + "workspaceCustomerId", + ) + mismatches = [ + field + for field in fields + if not isinstance(supplied_target.get(field), str) + or supplied_target[field].strip().casefold() + != str(locked[field]).strip().casefold() + ] + if mismatches: + raise ValueError( + "SDK target conflicts with the locked Qualification target: " + + ", ".join(mismatches) + ) + return {field: str(locked[field]) for field in fields} + + +def _workflow_profile(root: Path) -> str | None: + workflow_state = existing_artifact_path(root, "workflow-state.json") + if not workflow_state.is_file(): + return None + try: + state = json.loads(workflow_state.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError): + return "unknown" + profile = (state.get("context") or {}).get("workflowProfile") + return profile if isinstance(profile, str) else "unknown" + + +def _assert_graph_auth_tenant_scope( + state_root: Path, + *, + locked_tenant_id: str, + requested_tenant_id: str | None, +) -> dict[str, Any]: + if ( + requested_tenant_id + and requested_tenant_id.strip().casefold() + != locked_tenant_id.casefold() + ): + raise ValueError("requested Graph tenant does not match the selected workflow tenant") + graph_client_id = resolve_graph_client_id(state_root, "advancedHunting") + record_path = graph_auth_record_path( + state_root, + "advancedHunting", + graph_client_id, + AUTH_RECORD_NAME, + ) + record = _load_auth_record(record_path) + record_tenant = getattr(record, "tenant_id", None) if record is not None else None + config = _read_json(state_root / CONFIG_NAME) + configured_tenant = config.get("tenantId") + if not isinstance(record_tenant, str) or not record_tenant.strip(): + raise ValueError( + "Graph authentication record has no tenant identity for the selected workflow tenant" + ) + if record_tenant.strip().casefold() != locked_tenant_id.casefold(): + raise ValueError( + "Graph authentication record tenant does not match the selected workflow tenant" + ) + if ( + configured_tenant + and str(configured_tenant).strip().casefold() != locked_tenant_id.casefold() + ): + raise ValueError( + "Graph authentication configuration tenant does not match the selected workflow tenant" + ) + return config + + +def _assert_graph_auth_scope( + state_root: Path, + *, + locked_target: dict[str, str], + requested_tenant_id: str | None, +) -> None: + config = _assert_graph_auth_tenant_scope( + state_root, + locked_tenant_id=locked_target["tenantId"], + requested_tenant_id=requested_tenant_id, + ) + expected_config = { + "subscriptionId": locked_target["subscriptionId"], + "workspaceResourceId": locked_target["workspaceResourceId"], + "workspaceCustomerId": locked_target["workspaceCustomerId"], + } + for field, expected in expected_config.items(): + configured = config.get(field) + if not configured: + continue + actual = str(configured).strip().rstrip("/") + expected_value = str(expected).strip().rstrip("/") + if actual.casefold() != expected_value.casefold(): + raise ValueError( + f"Graph authentication configuration {field} does not match " + "the locked Qualification target" + ) + + +def _same_execution_scope( + dispatch_snapshot: dict[str, Any], + current_consent: dict[str, Any], +) -> bool: + family = dispatch_snapshot.get("family") + if ( + dispatch_snapshot.get("status") != "approved" + or family not in {"sentinelSourceQueryFamily", "defenderXdrQueryFamily"} + or dispatch_snapshot.get("provider") != current_consent.get("provider") + or family != current_consent.get("family") + ): + return False + dispatch_binding = dispatch_snapshot.get("binding") + current_binding = current_consent.get("binding") + if not isinstance(dispatch_binding, dict) or not isinstance(current_binding, dict): + return False + identity_fields = ( + "solution", + "workflowProfile", + "runId", + "targetFingerprintSha256", + ) + if any( + dispatch_binding.get(field) != current_binding.get(field) + for field in identity_fields + ): + return False + dispatch_families = dispatch_binding.get("families") + current_families = current_binding.get("families") + return ( + isinstance(dispatch_families, dict) + and isinstance(current_families, dict) + and dispatch_families.get(family) == current_families.get(family) + ) + + +def _runtime_consent_for_import( + root: Path, + *, + provider: str, + source_provider: str, +) -> dict[str, Any] | None: + workflow_state = existing_artifact_path(root, "workflow-state.json") + if workflow_state.is_file(): + state = json.loads(workflow_state.read_text(encoding="utf-8-sig")) + if (state.get("context") or {}).get("workflowProfile") == "authoring": + return None + return require_runtime_query_consent( + root, + family="sentinelSourceQueryFamily", + provider=provider, + source_provider=source_provider, + allow_legacy=True, + ) + + +def _write_runtime_artifacts( + root: Path, + provider: str, + results: list[dict[str, Any]], + *, + table_availability: dict[str, Any] | None = None, + provider_fallback: dict[str, Any] | None = None, + provider_failure: dict[str, Any] | None = None, + family_complete: bool | None = None, + runtime_query_consent: dict[str, Any] | None = None, + runtime_query_dispatch_snapshot: dict[str, Any] | None = None, +) -> dict[str, Any]: + output = report_directory(root, create=True) + summary = { + "solution": str(root), + "platform": PROVIDER_PLATFORMS[provider], + "querySurface": PROVIDER_QUERY_SURFACES[provider], + "provider": provider, + "total": len(results), + "valid": sum(item["status"] == "passed" for item in results), + "invalid": sum(item["status"] == "failed" for item in results), + "blocked": sum(item["status"] == "blocked" for item in results), + "notRun": sum(item["status"] == "not-run" for item in results), + "tableAvailability": table_availability or {}, + "providerFallback": provider_fallback, + "runtimeQueryConsent": { + "status": ( + "not-applicable-authoring" + if runtime_query_consent is None + and _workflow_profile(root) == "authoring" + else "not-required-legacy-workflow" + if runtime_query_consent is None + else "approved" + ), + "family": runtime_query_consent.get("family") + if runtime_query_consent + else None, + "provider": runtime_query_consent.get("provider") + if runtime_query_consent + else None, + "workflowProfile": runtime_query_consent.get("workflowProfile") + if runtime_query_consent + else _workflow_profile(root), + "planSha256": runtime_query_consent.get("planSha256") + if runtime_query_consent + else None, + "consentScope": runtime_query_consent.get("consentScope") + if runtime_query_consent + else None, + "labConsentSha256": runtime_query_consent.get("labConsentSha256") + if runtime_query_consent + else None, + "authoringConsentSha256": runtime_query_consent.get( + "authoringConsentSha256" + ) + if runtime_query_consent + else None, + "queryHashesAreConsentBindings": ( + runtime_query_consent.get("consentScope") + == "one-run-authoring-query-only" + if runtime_query_consent + else False + ), + "runSnapshotPath": runtime_query_consent.get("runSnapshotPath") + if runtime_query_consent + else None, + "authenticationClassification": runtime_query_consent.get( + "authenticationClassification" + ) + if runtime_query_consent + else None, + "binding": runtime_query_consent.get("binding") + if runtime_query_consent + else None, + "interactiveAuthenticationAuthorized": False, + "environmentWritesAuthorized": False, + }, + "runtimeQueryDispatchSnapshot": ( + runtime_query_dispatch_snapshot + if runtime_query_dispatch_snapshot is not None + else runtime_query_consent + ), + "results": results, + } + if family_complete is not None: + summary["familyComplete"] = family_complete + if provider_failure: + summary["providerFailure"] = provider_failure + if provider in SOURCE_PROVIDERS: + from .source_runtime_waiver import missing_schema_whitelist_match + + detection_paths = { + path.relative_to(root / "XDR Detections").as_posix(): path + for path in _detection_files(root / "XDR Detections") + } + expected_query_hashes = { + name: current_runtime_query_hash(root, path, provider=provider) + for name, path in detection_paths.items() + } + classified = [] + hash_bound_names: set[str] = set() + for item in results: + if not isinstance(item, dict): + continue + name = str(item.get("detection") or "").replace("\\", "/") + match = missing_schema_whitelist_match(item, provider=provider) + if match: + expected_hash = expected_query_hashes.get(name) + hash_matches = ( + expected_hash is not None + and item.get("querySha256") == expected_hash + ) + assessment = { + "detection": item.get("detection"), + "querySha256": item.get("querySha256"), + **match, + "status": "candidate-blocked", + "applied": False, + } + if hash_matches: + hash_bound_names.add(name) + else: + assessment["blockedReason"] = ( + "The failed result query hash does not match the current " + "source query hash." + ) + classified.append(assessment) + result_names = [ + str(item.get("detection") or "").replace("\\", "/") + for item in results + if isinstance(item, dict) + ] + inventory_matches = ( + len(result_names) == len(expected_query_hashes) + and len(result_names) == len(set(result_names)) + and set(result_names) == set(expected_query_hashes) + ) + family_can_apply = ( + bool(hash_bound_names) + and provider_failure is None + and provider_fallback is None + and family_complete is True + and inventory_matches + and all(isinstance(item, dict) for item in results) + and all( + ( + item.get("status") == "passed" + and item.get("querySha256") + == expected_query_hashes.get( + str(item.get("detection") or "").replace("\\", "/") + ) + ) + or ( + item.get("status") == "failed" + and str(item.get("detection") or "").replace("\\", "/") + in hash_bound_names + and item.get("querySha256") + == expected_query_hashes.get( + str(item.get("detection") or "").replace("\\", "/") + ) + ) + for item in results + ) + ) + for item in classified: + name = str(item.get("detection") or "").replace("\\", "/") + item["applied"] = family_can_apply and name in hash_bound_names + if item["applied"]: + item["status"] = "source-waived" + elif "blockedReason" not in item: + item["blockedReason"] = ( + "The complete source family contains another failure or is not confirmed complete." + ) + summary["sourceWhitelist"] = classified + json_path = output / f"runtime-validation.{provider}.json" + html_path = output / f"runtime-validation.{provider}.html" + summary["jsonReport"] = str(json_path) + summary["htmlReport"] = str(html_path) + write_json_artifact(root, json_path, summary) + write_runtime_validation_report(portable_artifact(root, summary), html_path) + from .workflow import invalidate_runtime_completion + + invalidate_runtime_completion( + root, + [], + reason=( + f"new {provider} provider evidence was recorded after offline Authoring completion." + ), + ) + return summary + + +def _query_sha256(query: str) -> str: + return hashlib.sha256(query.encode("utf-8")).hexdigest() + + +def _source_query(root: Path, detection: dict[str, Any]) -> str: + source = ((detection.get("contentProvenance") or {}).get("source") or {}) + source_reference = source.get("path") + if not isinstance(source_reference, str) or not source_reference: + raise ValueError("Custom Detection provenance must identify its source Analytic Rule") + source_path = content_path(root, source_reference) + with source_path.open(encoding="utf-8-sig") as handle: + document = yaml.safe_load(handle) or {} + if not isinstance(document, dict) or not isinstance(document.get("query"), str): + raise ValueError(f"source Analytic Rule has no query: {source_reference}") + return document["query"] + + +def current_runtime_query_hash( + root: Path, + detection_path: Path, + *, + provider: str, +) -> str: + with detection_path.open(encoding="utf-8-sig") as handle: + document = yaml.safe_load(handle) or {} + if provider == "graph": + query = (((document.get("properties") or {}).get("queryCondition") or {}).get("queryText")) + if not isinstance(query, str): + raise ValueError("Custom Detection has no Advanced Hunting query") + return _query_sha256(query) + return _query_sha256(_source_query(root, document)) + + +def current_runtime_evidence(solution: str | Path) -> dict[str, Any]: + """Require complete, exact-query evidence for both sides of the validation family.""" + root = Path(solution).expanduser().resolve() + output = root / "XDR Detections" + paths = { + path.relative_to(output).as_posix(): path + for path in _detection_files(output) + } + if not paths: + return {"passed": False, "issues": ["no converted detections are available for runtime validation"]} + + issues: list[str] = [] + current_query_failures: list[str] = [] + graph_hashes = { + name: current_runtime_query_hash(root, path, provider="graph") + for name, path in paths.items() + } + source_reports = [] + for provider in ("triage-mcp", "azure-monitor-sdk", "log-analytics-cli"): + report_path = report_directory(root) / f"runtime-validation.{provider}.json" + if report_path.is_file(): + source_reports.append((provider, report_path)) + graph_path = report_directory(root) / "runtime-validation.graph.json" + + def inspect_family( + provider: str, + report_path: Path, + expected_hashes: dict[str, str], + *, + require_actual_schema: bool = False, + ) -> tuple[bool, list[str], bool, list[dict[str, Any]]]: + if not report_path.is_file(): + return False, [f"{provider} runtime report is missing"], False, [] + try: + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError): + return False, [f"{provider} runtime report is invalid"], False, [] + if not isinstance(report, dict) or report.get("provider") != provider: + return False, [f"{provider} runtime report provider does not match"], False, [] + entries = report.get("results") + if not isinstance(entries, list): + return False, [f"{provider} runtime report has no results list"], False, [] + from .source_runtime_waiver import missing_schema_whitelist_match + + def candidate_assessments() -> list[dict[str, Any]]: + if provider not in SOURCE_PROVIDERS: + return [] + candidates = [] + for candidate in entries: + if not isinstance(candidate, dict): + continue + candidate_name = str(candidate.get("detection") or "").replace("\\", "/") + expected_hash = expected_hashes.get(candidate_name) + if expected_hash is None: + continue + match = missing_schema_whitelist_match( + candidate, + provider=provider, + expected_query_sha256=expected_hash, + ) + if match: + candidates.append({ + "detection": candidate_name, + "querySha256": expected_hash, + **match, + "status": "candidate-blocked", + "applied": False, + "blockedReason": ( + "The source report has a provider, surface, family-completion, " + "fallback, or inventory failure." + ), + }) + return candidates + + if ( + report.get("querySurface") not in ( + None, + PROVIDER_QUERY_SURFACES.get(provider), + ) + or report.get("providerFailure") + or report.get("familyComplete") is False + ): + return False, [ + f"{provider} runtime report has a provider, surface, or family-completion failure" + ], False, candidate_assessments() + by_name: dict[str, dict[str, Any]] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + name = str(entry.get("detection") or "").replace("\\", "/") + if name in by_name: + return False, [f"{provider} runtime report has duplicate results for {name}"], False, candidate_assessments() + by_name[name] = entry + if set(by_name) != set(expected_hashes): + return False, [ + f"{provider} runtime report is not a complete detection family " + f"(missing={sorted(set(expected_hashes) - set(by_name))}, " + f"unknown={sorted(set(by_name) - set(expected_hashes))})" + ], False, candidate_assessments() + source_whitelist: list[dict[str, Any]] = [] + + failures = [] + for name, expected_hash in expected_hashes.items(): + entry = by_name[name] + hash_matches = entry.get("querySha256") == expected_hash + source_match = ( + missing_schema_whitelist_match( + entry, + provider=provider, + expected_query_sha256=expected_hash, + ) + if provider in SOURCE_PROVIDERS + and not report.get("providerFallback") + else None + ) + if source_match: + source_whitelist.append({ + "detection": name, + "querySha256": expected_hash, + **source_match, + }) + if ( + provider == "graph" + and require_actual_schema + and entry.get("status") == "passed" + and hash_matches + ): + schema = entry.get("schema") + if not isinstance(schema, list): + failures.append( + "graph output schema is missing for " + f"{name}; schemaColumnCount is not sufficient evidence" + ) + else: + schema_columns = [] + malformed_schema = False + seen_schema_names: set[str] = set() + for column in schema: + if not isinstance(column, dict): + malformed_schema = True + break + column_name = column.get("name", column.get("Name")) + column_type = column.get("type", column.get("Type")) + if ( + not isinstance(column_name, str) + or not column_name.strip() + or not isinstance(column_type, str) + or not column_type.strip() + or column_name.casefold() in seen_schema_names + ): + malformed_schema = True + break + seen_schema_names.add(column_name.casefold()) + schema_columns.append(column) + if not schema_columns or malformed_schema: + failures.append( + "graph output schema for " + f"{name} must contain usable, uniquely named columns with types" + ) + continue + try: + document = yaml.safe_load( + paths[name].read_text(encoding="utf-8-sig") + ) or {} + except (OSError, yaml.YAMLError) as exc: + failures.append( + f"cannot verify current XDR output bindings for {name}: {exc}" + ) + else: + binding_errors = custom_detail_schema_errors(document, schema) + failures.extend( + f"graph output schema for {name}: {error}" + for error in binding_errors + ) + if entry.get("status") == "failed" and hash_matches: + if source_match: + continue + current_query_failures.append( + f"{provider} query validation failed for {name}: " + f"{entry.get('error') or 'provider reported a KQL/runtime failure'}" + ) + if entry.get("status") != "passed": + failures.append(f"{provider} validation for {name} is {entry.get('status')!r}") + if not hash_matches: + failures.append( + f"{provider} validation for {name} is stale or unbound to the exact current query" + ) + waived_failure = bool(source_whitelist) and not failures + for item in source_whitelist: + item["applied"] = waived_failure + if not waived_failure: + item["status"] = "candidate-blocked" + item["blockedReason"] = ( + "The complete source query family has another failed, blocked, " + "not-run, stale, or incomplete result." + ) + return not failures and not waived_failure, failures, waived_failure, source_whitelist + + source_passed = False + source_waiver_only_blocker = False + source_whitelist: list[dict[str, Any]] = [] + source_provider_used = None + source_issues: list[str] = [] + available_source_reports = [ + (provider, report_path) + for provider, report_path in source_reports + if report_path.is_file() + ] + for index, (provider, report_path) in enumerate(available_source_reports): + try: + selected_report = json.loads(report_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + break + unverified_fallback_claim = ( + index == 0 + and isinstance(selected_report, dict) + and bool(selected_report.get("providerFallback")) + ) + if index: + previous_provider, previous_path = available_source_reports[0] + try: + previous_report = json.loads(previous_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + break + if ( + previous_provider != "triage-mcp" + or not isinstance(previous_report, dict) + or not isinstance(previous_report.get("providerFailure"), dict) + ): + break + proof = selected_report.get("providerFallback") if isinstance(selected_report, dict) else None + if ( + not isinstance(proof, dict) + or proof.get("mcpProvider") != "triage-mcp" + or proof.get("fallbackProvider") != provider + or proof.get("batchComplete") is not True + or proof.get("permissionsVerified") is not True + or proof.get("kqlError") is not False + ): + continue + current_failures_before = len(current_query_failures) + passed, family_issues, waiver_only, current_whitelist = inspect_family( + provider, + report_path, + { + name: current_runtime_query_hash(root, path, provider=provider) + for name, path in paths.items() + }, + ) + if passed and not unverified_fallback_claim: + source_passed = True + source_provider_used = provider + source_whitelist = current_whitelist + break + if unverified_fallback_claim: + source_issues.extend(family_issues) + source_issues.append( + f"{provider} runtime report claims an unverified provider fallback" + ) + if len(current_query_failures) > current_failures_before: + break + break + if waiver_only: + source_whitelist = current_whitelist + source_waiver_only_blocker = True + source_provider_used = provider + break + if current_whitelist: + source_whitelist = current_whitelist + source_issues.extend(family_issues) + if len(current_query_failures) > current_failures_before: + break + try: + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError): + break + if ( + provider != "triage-mcp" + or not isinstance(report, dict) + or not isinstance(report.get("providerFailure"), dict) + ): + break + if not source_passed and not source_waiver_only_blocker: + issues.extend(source_issues or ["no Sentinel runtime validation report is available"]) + + graph_passed, graph_issues, _, _ = inspect_family( + "graph", + graph_path, + graph_hashes, + require_actual_schema=bool(source_whitelist), + ) + if not graph_passed: + issues.extend(graph_issues) + source_waived = source_waiver_only_blocker + source_waiver_only_blocker = source_waiver_only_blocker and graph_passed + if source_waiver_only_blocker: + issues.append( + "Sentinel source IdentityInfo query remains failed in raw evidence; " + "the built-in source table whitelist is not runtime success" + ) + return { + "passed": source_passed and graph_passed, + "sentinelProvider": source_provider_used, + "sourceStatus": ( + "source-waived" + if source_waived + else "passed" + if source_passed + else "blocked" + ), + "sourceWhitelist": source_whitelist, + "sourceWaiverOnlyBlocker": source_waiver_only_blocker, + "currentQueryFailures": current_query_failures, + "issues": issues, + "sentinelReport": ( + str(report_directory(root) / f"runtime-validation.{source_provider_used}.json") + if source_provider_used else None + ), + "graphReport": str(graph_path) if graph_passed else None, + } + + +def _load_provider_fallback( + path: str | Path | None, + *, + query_family: str, + fallback_provider: str, +) -> dict[str, Any] | None: + if path is None: + return None + evidence = json.loads(Path(path).expanduser().read_text(encoding="utf-8")) + if not isinstance(evidence, dict): + raise ValueError("provider-gap evidence must be a JSON object") + if ( + evidence.get("mcpProvider") != "triage-mcp" + or evidence.get("queryFamily") != query_family + or evidence.get("fallbackProvider") != fallback_provider + or evidence.get("reasonCode") not in PROVIDER_GAP_CODES + or evidence.get("batchComplete") is not True + or evidence.get("scopeVerified") is not True + or evidence.get("permissionsVerified") is not True + or evidence.get("kqlError") is not False + ): + raise ValueError( + "provider-gap evidence must record an official Triage provider failure, " + "the complete query-family fallback, exact scope and permissions, and no KQL error" + ) + tool_name = evidence.get("advertisedTool") + if not isinstance(tool_name, str) or not tool_name.strip(): + raise ValueError("provider-gap evidence requires the advertised Triage tool name or 'unavailable'") + scope_reference = evidence.get("scopeReference") + if not isinstance(scope_reference, str) or not scope_reference.strip() or len(scope_reference) > 500: + raise ValueError("provider-gap evidence requires the exact scoped target reference") + reason = evidence.get("reason") + if not isinstance(reason, str) or not reason.strip() or len(reason) > 500: + raise ValueError("provider-gap evidence requires a concise reason of at most 500 characters") + return { + "mcpProvider": "triage-mcp", + "advertisedTool": tool_name.strip(), + "queryFamily": query_family, + "fallbackProvider": fallback_provider, + "scopeReference": scope_reference.strip(), + "reasonCode": evidence["reasonCode"], + "reason": reason.strip(), + "batchComplete": True, + "scopeVerified": True, + "permissionsVerified": True, + "kqlError": False, + } + + +def _advanced_hunting_token( + state_root: Path, + *, + tenant_id: str | None = None, +) -> str: + from azure.identity import DeviceCodeCredential, TokenCachePersistenceOptions + + client_id = resolve_graph_client_id(state_root, "advancedHunting") + record_path = graph_auth_record_path( + state_root, "advancedHunting", client_id, AUTH_RECORD_NAME + ) + record = _load_auth_record(record_path) + if record is None: + raise RuntimeError( + "Advanced Hunting authentication is missing; run " + "`sentinel-xdr-migration setup` first" + ) + config = _read_json(state_root / CONFIG_NAME) + configured_tenant = config.get("tenantId") or getattr(record, "tenant_id", None) + try: + verify_graph_auth_binding( + record_path, + record, + purpose="advancedHunting", + client_id=client_id, + tenant_id=tenant_id or configured_tenant, + ) + except ValueError as exc: + raise RuntimeError(str(exc)) from exc + if tenant_id and configured_tenant and tenant_id.lower() != configured_tenant.lower(): + raise RuntimeError( + "Advanced Hunting authentication tenant does not match the locked target" + ) + credential = DeviceCodeCredential( + client_id=client_id, + tenant_id=tenant_id or configured_tenant, + authentication_record=record, + cache_persistence_options=TokenCachePersistenceOptions( + name=graph_token_cache_name( + "advancedHunting", client_id, TOKEN_CACHE_NAME + ) + ), + disable_automatic_authentication=True, + ) + token = credential.get_token(GRAPH_SCOPE).token + from .graph_readiness import _token_claims + + validate_delegated_graph_claims( + _token_claims(token), + tenant_id=tenant_id or configured_tenant or "", + required_scopes=(GRAPH_SCOPE,), + purpose="Advanced Hunting", + ) + return token + + +def _retry_after_seconds(value: str | None) -> float | None: + if not value: + return None + value = value.strip() + if value.isdigit(): + return float(value) + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError, OverflowError): + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + now = datetime.fromtimestamp(time.time(), timezone.utc) + return max(0.0, (retry_at - now).total_seconds()) + + +def _http_retry_after(exc: urllib.error.HTTPError) -> float | None: + headers = getattr(exc, "headers", None) + value = headers.get("Retry-After") if headers is not None else None + return _retry_after_seconds(value) + + +def _hunting_query_response_error(body: Any) -> str | None: + if not isinstance(body, dict): + return "Graph Advanced Hunting returned a JSON value instead of an object" + provider_error = body.get("error") + if provider_error: + if isinstance(provider_error, dict): + message = provider_error.get("message") + if isinstance(message, str) and message.strip(): + return f"Graph Advanced Hunting returned an error: {message[:2000]}" + return "Graph Advanced Hunting returned an error object" + schema = body.get("schema", body.get("Schema")) + if not isinstance(schema, list) or not schema: + return "Graph Advanced Hunting response is missing its non-empty output schema" + seen_names: set[str] = set() + for column in schema: + if not isinstance(column, dict): + return "Graph Advanced Hunting output schema contains a non-object column" + name = column.get("name", column.get("Name")) + column_type = column.get("type", column.get("Type")) + if ( + not isinstance(name, str) + or not name.strip() + or not isinstance(column_type, str) + or not column_type.strip() + ): + return "Graph Advanced Hunting output schema columns require actual names and types" + if name.casefold() in seen_names: + return "Graph Advanced Hunting output schema contains duplicate column names" + seen_names.add(name.casefold()) + rows = body.get("results", body.get("Results")) + if not isinstance(rows, list): + return "Graph Advanced Hunting response is missing its results array" + if any(not isinstance(row, dict) for row in rows): + return "Graph Advanced Hunting results contain a non-object row" + if any( + not isinstance(name, str) or name.casefold() not in seen_names + for row in rows + for name in row + ): + return "Graph Advanced Hunting result columns do not match its declared schema" + return None + + +def run_advanced_hunting_query( + query: str, + *, + state_dir: str | Path | None = None, + timeout: int = 90, + retries: int = 2, + _token: str | None = None, +) -> dict[str, Any]: + """Run one Graph Advanced Hunting read within this process's provider bounds. + + The in-process semaphore limits actual Graph requests to five at a time; + imported Log Analytics/MCP results are external and are not governed here. + Transient attempts share a five-minute wall-clock budget, including request + durations and retry waits. The public retry default remains two. + """ + token = _token or _advanced_hunting_token(_state_dir(state_dir)) + request = urllib.request.Request( + ADVANCED_HUNTING_ENDPOINT, + data=json.dumps({"Query": query}).encode("utf-8"), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + result: dict[str, Any] = {} + retry_limit = max(0, min(retries, MAX_ADVANCED_HUNTING_RETRIES)) + deadline = time.monotonic() + ADVANCED_HUNTING_RETRY_BUDGET_SECONDS + for attempt in range(retry_limit + 1): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + acquired = _GRAPH_REQUEST_SEMAPHORE.acquire(timeout=remaining) + if not acquired: + result = { + "ok": False, + "statusCode": 0, + "rowCount": 0, + "schema": [], + "error": "Timed out waiting for an available Graph request slot", + "errorDetails": "The Advanced Hunting retry budget expired while waiting for a request slot", + "failureKind": "provider", + } + return result + try: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + with urllib.request.urlopen(request, timeout=min(timeout, remaining)) as response: + status_code = response.status + try: + response_text = response.read().decode("utf-8") + except UnicodeDecodeError as exc: + return { + "ok": False, + "statusCode": status_code, + "rowCount": 0, + "schema": [], + "error": "Graph Advanced Hunting returned non-UTF-8 JSON", + "errorDetails": str(exc), + "failureKind": "provider", + } + try: + body = json.loads(response_text) + except json.JSONDecodeError as exc: + return { + "ok": False, + "statusCode": status_code, + "rowCount": 0, + "schema": [], + "error": "Graph Advanced Hunting returned invalid JSON", + "errorDetails": f"{exc}: {response_text[:8000]}", + "failureKind": "provider", + } + response_error = _hunting_query_response_error(body) + if response_error: + return { + "ok": False, + "statusCode": status_code, + "rowCount": 0, + "schema": [], + "error": response_error, + "errorDetails": response_text[:8000], + "failureKind": "provider", + } + rows = body.get("results", body.get("Results")) + schema = body.get("schema", body.get("Schema")) + return { + "ok": True, + "statusCode": status_code, + "rowCount": len(rows), + "schema": schema, + "sampleRows": rows[:MAX_ADVANCED_HUNTING_SAMPLE_ROWS], + "sampleRowsTruncated": len(rows) > MAX_ADVANCED_HUNTING_SAMPLE_ROWS, + "error": None, + "errorDetails": None, + } + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(body) + error = parsed.get("error") or {} + message = error.get("message") or body + except json.JSONDecodeError: + message = body + result = { + "ok": False, + "statusCode": exc.code, + "rowCount": 0, + "schema": [], + "error": message[:2000], + "errorDetails": body[:8000], + "failureKind": "query" if exc.code == 400 else "provider", + } + if exc.code not in {429, 502, 503, 504} or attempt == retry_limit: + return result + retry_after = _http_retry_after(exc) + except (TimeoutError, urllib.error.URLError) as exc: + result = { + "ok": False, + "statusCode": 0, + "rowCount": 0, + "schema": [], + "error": str(exc), + "errorDetails": str(exc), + "failureKind": "provider", + } + if attempt == retry_limit: + return result + retry_after = None + finally: + _GRAPH_REQUEST_SEMAPHORE.release() + + remaining = deadline - time.monotonic() + delay = retry_after if retry_after is not None else 2 * (attempt + 1) + if delay >= remaining: + return result + if delay: + time.sleep(delay) + if result: + return result + return { + "ok": False, + "statusCode": 0, + "rowCount": 0, + "schema": [], + "error": "Advanced Hunting retry budget expired before a request could complete", + "errorDetails": "The five-minute request and retry budget was exhausted", + "failureKind": "provider", + } + + +def _not_run_result( + path: Path, output: Path, query: str, *, status_code: int = 0 +) -> dict[str, Any]: + return { + "detection": path.relative_to(output).as_posix(), + "status": "not-run", + "valid": False, + "querySha256": _query_sha256(query), + "statusCode": status_code, + "rowCount": 0, + "schemaColumnCount": 0, + "error": None, + "errorDetails": None, + } + + +def validate_advanced_hunting( + solution: str | Path, + *, + state_dir: str | Path | None = None, + tenant_id: str | None = None, + provider_gap_evidence: str | Path | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + workflow_state = existing_artifact_path(root, "workflow-state.json") + profile = _workflow_profile(root) + locked_target = None + if workflow_state.is_file(): + if profile == "qualification": + locked_target = require_locked_target(root, materialize_missing=False) + elif profile == "authoring": + try: + state = json.loads(workflow_state.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError( + "selected Authoring workflow state is unavailable" + ) from exc + context = state.get("context") if isinstance(state, dict) else None + configured_tenant = ( + context.get("tenantId") if isinstance(context, dict) else None + ) + if not isinstance(configured_tenant, str) or not configured_tenant: + raise ValueError( + "Authoring Advanced Hunting requires the explicit tenant in " + "the selected workflow run" + ) + locked_target = {"tenantId": configured_tenant} + if tenant_id and tenant_id.strip().casefold() != configured_tenant.casefold(): + raise ValueError( + "requested Graph tenant does not match the selected Authoring run" + ) + tenant_id = configured_tenant + else: + raise ValueError( + "Advanced Hunting requires a recognized selected workflow profile" + ) + if ( + locked_target is not None + and tenant_id + and tenant_id.strip().casefold() != locked_target["tenantId"].casefold() + ): + raise ValueError("requested Graph tenant does not match the locked Qualification tenant") + effective_state_dir = Path(_state_dir(state_dir)).expanduser().resolve() + if locked_target is not None: + if profile == "authoring": + _assert_graph_auth_tenant_scope( + effective_state_dir, + locked_tenant_id=locked_target["tenantId"], + requested_tenant_id=tenant_id, + ) + else: + _assert_graph_auth_scope( + effective_state_dir, + locked_target=locked_target, + requested_tenant_id=tenant_id, + ) + consent = require_runtime_query_consent( + root, + family="defenderXdrQueryFamily", + provider="graph", + allow_legacy=True, + ) + provider_fallback = _load_provider_fallback( + provider_gap_evidence, + query_family="advanced-hunting", + fallback_provider="graph", + ) + output = root / "XDR Detections" + results: list[dict[str, Any]] = [] + token = _advanced_hunting_token( + effective_state_dir, + tenant_id=locked_target["tenantId"] if locked_target is not None else tenant_id, + ) + detections: list[tuple[Path, dict[str, Any], str]] = [] + for path in _detection_files(output): + with path.open(encoding="utf-8-sig") as handle: + document = yaml.safe_load(handle) or {} + query = document["properties"]["queryCondition"]["queryText"] + detections.append((path, document, query)) + + provider_failure: dict[str, Any] | None = None + for path, document, query in detections: + if provider_failure: + results.append(_not_run_result(path, output, query)) + continue + runtime = run_advanced_hunting_query(query, _token=token) + if runtime.get("failureKind") == "provider": + provider_failure = { + "provider": "graph", + "statusCode": runtime["statusCode"], + "error": runtime["error"], + "errorDetails": runtime["errorDetails"], + } + results.append( + _not_run_result( + path, output, query, status_code=runtime["statusCode"] + ) + ) + continue + binding_errors = custom_detail_schema_errors(document, runtime["schema"]) if runtime["ok"] else [] + results.append( + { + "detection": path.relative_to(output).as_posix(), + "status": "passed" if runtime["ok"] and not binding_errors else "failed", + "valid": runtime["ok"] and not binding_errors, + "querySha256": _query_sha256(query), + "statusCode": runtime["statusCode"], + "rowCount": runtime["rowCount"], + "schemaColumnCount": len(runtime["schema"]), + "schema": runtime["schema"], + "sampleRows": runtime.get("sampleRows", []), + "sampleRowsTruncated": runtime.get("sampleRowsTruncated", False), + "error": "; ".join(binding_errors) if binding_errors else runtime["error"], + "errorDetails": runtime["errorDetails"], + } + ) + return _write_runtime_artifacts( + root, + "graph", + results, + provider_fallback=provider_fallback, + provider_failure=provider_failure, + runtime_query_consent=consent, + ) + + +def validate_sentinel_sdk( + solution: str | Path, + *, + target: dict[str, str], + request_timeout_seconds: int = 90, + family_timeout_seconds: int = 30 * 60, + provider_gap_evidence: str | Path | None = None, + _credential_factory=None, + _client_factory=None, + _scope_verifier=None, + _monotonic=None, + _sleep=None, +) -> dict[str, Any]: + """Run the complete current Sentinel source-query family with Azure Monitor SDK.""" + from .content_paths import content_path + from .converter import analytic_rule_files, solution_paths, xdr_detection_files + from .logs_query import ( + DEFAULT_FAMILY_TIMEOUT_SECONDS, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + azure_cli_credential, + execute_workspace_query, + logs_query_client, + query_family, + verify_azure_cli_target, + ) + + root, analytic, output = solution_paths(solution) + provider = "azure-monitor-sdk" + effective_target = _effective_sdk_target(root, target) + consent = require_runtime_query_consent( + root, + family="sentinelSourceQueryFamily", + provider=provider, + allow_legacy=True, + ) + provider_fallback = _load_provider_fallback( + provider_gap_evidence, + query_family="sentinel", + fallback_provider=provider, + ) + sources = analytic_rule_files(root) + if not sources: + raise ValueError("complete Sentinel source-query inventory is empty; no SDK validation report was written") + + source_by_path: dict[str, tuple[Path, str, str]] = {} + for source_path in sources: + relative = source_path.relative_to(analytic).as_posix() + with source_path.open(encoding="utf-8-sig") as handle: + source = yaml.safe_load(handle) + if not isinstance(source, dict) or not isinstance(source.get("query"), str) or not source["query"].strip(): + raise ValueError(f"source Analytic Rule has no non-empty query: {source_path}") + query = source["query"] + source_by_path[relative] = (source_path, query, _query_sha256(query)) + + detections: dict[str, str] = {} + for detection_path in xdr_detection_files(output): + with detection_path.open(encoding="utf-8-sig") as handle: + detection = yaml.safe_load(handle) + source_ref = ((detection or {}).get("contentProvenance") or {}).get("source") or {} + source_path_ref = source_ref.get("path") + if not isinstance(source_path_ref, str): + raise ValueError(f"Custom Detection provenance is missing its source path: {detection_path}") + source_path = content_path(root, source_path_ref) + if not source_path.is_file(): + raise ValueError(f"Custom Detection source Analytic Rule is missing: {source_path_ref}") + relative = source_path.relative_to(analytic).as_posix() + if relative not in source_by_path: + raise ValueError(f"Custom Detection provenance is outside the complete source inventory: {source_path_ref}") + if relative in detections.values(): + raise ValueError(f"multiple Custom Detections refer to the same source Analytic Rule: {source_path_ref}") + current_hash = source_by_path[relative][2] + if source_ref.get("querySha256") != current_hash: + raise ValueError(f"Custom Detection provenance has a stale source-query hash: {source_path_ref}") + detections[detection_path.relative_to(output).as_posix()] = relative + + if set(detections.values()) != set(source_by_path): + raise ValueError( + "SDK validation requires the complete source inventory to map one-to-one to " + "generated detections " + f"(missing={sorted(set(source_by_path) - set(detections.values()))}, " + f"extra={sorted(set(detections.values()) - set(source_by_path))})" + ) + queries = [ + { + "detection": detection, + "query": source_by_path[source_relative][1], + "querySha256": source_by_path[source_relative][2], + } + for detection, source_relative in sorted(detections.items()) + ] + + results: list[dict[str, Any]] + provider_failure = None + scope_verifier = _scope_verifier or verify_azure_cli_target + try: + scope_verifier(effective_target) + credential_factory = _credential_factory or azure_cli_credential + client_factory = _client_factory or logs_query_client + credential = credential_factory(effective_target) + client = client_factory(credential, retry_total=0) + try: + results = query_family( + client, + effective_target["workspaceCustomerId"], + queries, + request_timeout_seconds=request_timeout_seconds, + family_timeout_seconds=( + family_timeout_seconds + if family_timeout_seconds is not None + else DEFAULT_FAMILY_TIMEOUT_SECONDS + ), + monotonic=_monotonic or time.monotonic, + sleep=_sleep or time.sleep, + ) + provider_failure_result = next( + ( + item for item in results + if item.get("failureKind") not in { + None, "query", "partial-response", + } + ), + None, + ) + if provider_failure_result: + provider_failure = { + "failureKind": provider_failure_result.get("failureKind"), + "statusCode": provider_failure_result.get("statusCode", 0), + "error": provider_failure_result.get("error"), + "errorDetails": provider_failure_result.get("errorDetails"), + } + finally: + close = getattr(client, "close", None) + if callable(close): + close() + except Exception as exc: + if isinstance(exc, (ValueError, TypeError)): + raise + failure_kind = getattr(exc, "failure_kind", "provider") + provider_failure = { + "failureKind": failure_kind, + "statusCode": int(getattr(exc, "status_code", 0) or 0), + "error": str(exc)[:2000], + "errorDetails": str(getattr(exc, "error_details", exc))[:8000], + } + results = [ + { + "detection": item["detection"], + "status": "not-run", + "valid": False, + "querySha256": item["querySha256"], + "statusCode": provider_failure["statusCode"], + "rowCount": 0, + "schema": [], + "error": "query was not dispatched because provider initialization or target authentication failed", + "errorDetails": provider_failure["error"], + } + for item in queries + ] + family_complete = len(results) == len(queries) and all( + item.get("status") != "not-run" for item in results + ) + report = record_runtime_validation( + root, + provider=provider, + results_data=results, + provider_gap_evidence=provider_gap_evidence, + provider_failure=provider_failure, + family_complete=family_complete, + runtime_query_consent=consent, + ) + return report + + +def record_runtime_validation( + solution: str | Path, + *, + provider: str, + results_path: str | Path | None = None, + results_data: list[dict[str, Any]] | None = None, + provider_gap_evidence: str | Path | None = None, + provider_failure: dict[str, Any] | None = None, + family_complete: bool | None = None, + source_provider: str = "azure-monitor-sdk", + runtime_query_consent: dict[str, Any] | None = None, +) -> dict[str, Any]: + if provider not in SUPPORTED_EXTERNAL_PROVIDERS: + raise ValueError( + f"unsupported external runtime provider {provider!r}; " + f"expected one of {sorted(SUPPORTED_EXTERNAL_PROVIDERS)}" + ) + root = Path(solution).expanduser().resolve() + consent = _runtime_consent_for_import( + root, + provider=provider, + source_provider=source_provider, + ) + if runtime_query_consent is not None and ( + consent is None + or not _same_execution_scope(runtime_query_consent, consent) + ): + raise ValueError( + "runtime-query dispatch snapshot no longer matches the current " + "target, query inventory, provider route, or authentication scope" + ) + dispatch_snapshot = runtime_query_consent or consent + provider_fallback = _load_provider_fallback( + provider_gap_evidence, + query_family="sentinel", + fallback_provider=provider, + ) + if provider_fallback and provider != provider_fallback["fallbackProvider"]: + raise ValueError("Sentinel MCP fallback results must be recorded under the approved fallback provider") + output = root / "XDR Detections" + detection_paths = {path.relative_to(output).as_posix(): path for path in _detection_files(output)} + expected = set(detection_paths) + if (results_path is None) == (results_data is None): + raise ValueError("provide exactly one of results_path or results_data") + raw = ( + json.loads(Path(results_path).expanduser().read_text(encoding="utf-8")) + if results_path is not None else results_data + ) + entries = raw.get("results") if isinstance(raw, dict) else raw + if not isinstance(entries, list): + raise ValueError("runtime results must be a JSON list or an object containing results") + + preflight_names = [] + for entry in entries: + if not isinstance(entry, dict): + raise ValueError("each runtime result must be a JSON object") + detection = str(entry.get("detection") or "").strip().replace("\\", "/") + if not detection: + raise ValueError("each runtime result requires a detection filename") + preflight_names.append(detection) + if len(preflight_names) != len(set(preflight_names)): + raise ValueError("duplicate runtime result for a detection") + preflight_set = set(preflight_names) + if preflight_set != expected: + details = [] + missing = sorted(expected - preflight_set) + unknown = sorted(preflight_set - expected) + if missing: + details.append(f"missing detections: {', '.join(missing)}") + if unknown: + details.append(f"unknown detections: {', '.join(unknown)}") + raise ValueError("; ".join(details)) + + expected_query_hashes = { + name: current_runtime_query_hash(root, path, provider=provider) + for name, path in detection_paths.items() + } + normalized: list[dict[str, Any]] = [] + names: set[str] = set() + for entry in entries: + if not isinstance(entry, dict): + raise ValueError("each runtime result must be a JSON object") + detection = str(entry.get("detection") or "").strip() + if not detection: + raise ValueError("each runtime result requires a detection filename") + detection = detection.replace("\\", "/") + if detection in names: + raise ValueError(f"duplicate runtime result for {detection}") + names.add(detection) + status = str(entry.get("status") or "").strip().lower() + if not status: + status = "passed" if entry.get("valid") is True else "failed" + if status not in {"passed", "failed", "blocked", "not-run"}: + raise ValueError(f"invalid runtime status {status!r} for {detection}") + error = entry.get("error") + schema = entry.get("schema") + query_hash = entry.get("querySha256") + if status == "passed" and detection in expected_query_hashes: + if not isinstance(query_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", query_hash): + status = "blocked" + error = ( + "passed Sentinel validation must include the query hash (SHA-256) of the exact " + "source query that was executed" + ) + elif query_hash != expected_query_hashes[detection]: + status = "blocked" + error = ( + "Sentinel validation query hash is stale; rerun the complete Sentinel " + "query family against the current source queries" + ) + if status == "passed" and provider == "graph" and detection in detection_paths: + document = yaml.safe_load(detection_paths[detection].read_text(encoding="utf-8-sig")) or {} + details = ( + ((document.get("properties") or {}).get("detectionAction") or {}).get("alertTemplate") or {} + ).get("customDetails") + if details: + if not isinstance(schema, list): + status = "blocked" + error = "customDetails binding verification requires the returned target-query output schema, not just schemaColumnCount" + else: + binding_errors = custom_detail_schema_errors(document, schema) + if binding_errors: + status = "failed" + error = "; ".join(binding_errors) + normalized_entry = { + "detection": detection, + "status": status, + "valid": status == "passed", + "querySha256": query_hash, + "statusCode": int(entry.get("statusCode") or 0), + "rowCount": int(entry.get("rowCount") or 0), + "schemaColumnCount": len(schema) if isinstance(schema, list) else int(entry.get("schemaColumnCount") or 0), + "error": error, + "errorDetails": entry.get("errorDetails"), + } + for field in ( + "tables", + "partialError", + "failureKind", + "attempts", + "durationMs", + "schema", + ): + if field in entry: + normalized_entry[field] = entry[field] + sample_rows = entry.get("sampleRows") + if isinstance(sample_rows, list): + normalized_entry["sampleRows"] = sample_rows[:MAX_ADVANCED_HUNTING_SAMPLE_ROWS] + normalized_entry["sampleRowsTruncated"] = ( + entry.get("sampleRowsTruncated") is True + or len(sample_rows) > MAX_ADVANCED_HUNTING_SAMPLE_ROWS + ) + if entry.get("partial") is True: + normalized_entry["partial"] = True + normalized.append(normalized_entry) + + missing = sorted(expected - names) + unknown = sorted(names - expected) + if missing or unknown: + details = [] + if missing: + details.append(f"missing detections: {', '.join(missing)}") + if unknown: + details.append(f"unknown detections: {', '.join(unknown)}") + raise ValueError("; ".join(details)) + + fallback_rejection = None + if provider_fallback: + failed_results = [ + item for item in normalized if item["status"] != "passed" + ] + if failed_results or family_complete is False: + reasons = [ + ( + f"{item['detection']} is {item['status']}: " + f"{item.get('error') or item.get('errorDetails') or 'no query diagnostics returned'}" + ) + for item in failed_results + ] + if family_complete is False: + reasons.append("the query family is incomplete") + fallback_rejection = ( + "provider fallback is not allowed unless every fallback query completed " + "successfully; " + "; ".join(reasons) + ) + + report = _write_runtime_artifacts( + root, + provider, + normalized, + provider_fallback=provider_fallback, + provider_failure=provider_failure, + family_complete=family_complete, + runtime_query_consent=consent, + runtime_query_dispatch_snapshot=dispatch_snapshot, + ) + if fallback_rejection: + raise ValueError( + f"{fallback_rejection}; attempt diagnostics were persisted to {report['jsonReport']}" + ) + return report diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime_consent.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime_consent.py new file mode 100644 index 00000000000..5886c1c6779 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime_consent.py @@ -0,0 +1,1665 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import uuid +from collections import Counter +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator, Mapping + +import yaml + +from .artifacts import ( + artifact_path, + existing_artifact_path, + write_json_artifact, +) +from .content_paths import content_path +from .converter import analytic_rule_files, xdr_detection_files +from .graph_client_config import ( + configured_azure_cli_profile, + graph_auth_record_path, + resolve_graph_client_id, +) +from .onboarding import ( + AUTH_RECORD_NAME, + CONFIG_NAME, + _read_json, + _state_dir, +) +from .target_context import require_locked_target + +PLAN_FILE_NAME = "runtime-query-plan.json" +CONSENT_FILE_NAME = "runtime-query-consent.json" +AUTHORING_CONSENT_FILE_NAME = "authoring-runtime-query-consent.json" +PLAN_SCHEMA_VERSION = "2.0.0" +LAB_CONSENT_SCHEMA_VERSION = "1.0.0" +AUTHORING_CONSENT_SCHEMA_VERSION = "1.0.0" +LAB_ACCESS_DIRECTORY = "lab-runtime-access" +PRIMARY_SETTING_FILE = "primary-setting.json" +LAB_CONSENT_FILE = "runtime-query-consent.json" +LAB_DECISION_FILE = "runtime-query-decision.json" +FAMILIES = {"sentinelSourceQueryFamily", "defenderXdrQueryFamily"} +SOURCE_FAMILY = "sentinelSourceQueryFamily" +SOURCE_PROVIDERS = { + "azure-monitor-sdk", + "triage-mcp", + "log-analytics-cli", +} +_SOURCE_PROVIDER_NAMES = { + "azure-monitor-sdk": "Azure Monitor Logs Query SDK", + "triage-mcp": "Microsoft Sentinel Triage MCP", + "log-analytics-cli": "Microsoft Sentinel Log Analytics CLI", +} +_LAB_IDENTITY_FIELDS = ( + "tenantId", + "subscriptionId", + "resourceGroup", + "workspaceResourceId", + "workspaceCustomerId", +) +_OPERATION_FAMILIES = { + "sentinelSourceQueryFamily": { + "operationId": "sentinel-source-kql-read", + "providerSurface": "Azure Monitor Logs query / Sentinel source KQL", + "httpMethod": "POST", + "operationSemantics": "read-only query execution", + "resourceMutation": False, + }, + "defenderXdrQueryFamily": { + "operationId": "defender-advanced-hunting-query-read", + "providerSurface": "Microsoft Defender XDR Advanced Hunting", + "httpMethod": "POST", + "operationSemantics": "read-only query execution", + "resourceMutation": False, + }, +} +_LAB_SCOPE_POLICY = { + "crossSolution": True, + "crossRun": True, + "sourceQueryHashesAreSnapshotsOnly": True, + "xdrQueryHashesAreSnapshotsOnly": True, + "querySnapshotsAreNotConsentBindings": True, +} +_LAB_AUTHORIZATION_BOUNDARY = { + "interactiveAuthenticationAuthorized": False, + "adminConsentAuthorized": False, + "permissionChangesAuthorized": False, + "roleAssignmentsAuthorized": False, + "environmentWritesAuthorized": False, + "packageOrDeploymentAuthorized": False, + "ingestionOrParityAuthorized": False, + "ruleEnablementOrCleanupAuthorized": False, +} + + +def _canonical_sha256(value: Any) -> str: + return hashlib.sha256( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + + +def _runtime_query_plan_sha256(plan: Mapping[str, Any]) -> str: + """Hash plan identity while keeping consent-decision summaries mutable.""" + identity = dict(plan) + identity.pop("planSha256", None) + consent = identity.get("labReadConsent") + if isinstance(consent, Mapping): + stable_consent = dict(consent) + for field in ( + "status", + "approvedFamilies", + "requiresExplicitApproval", + "recordedAt", + "latestDecision", + ): + stable_consent.pop(field, None) + identity["labReadConsent"] = stable_consent + return _canonical_sha256(identity) + + +def _lab_access_directory() -> Path: + return _state_dir() / LAB_ACCESS_DIRECTORY + + +def _lab_identity(target: Mapping[str, Any]) -> dict[str, str]: + return {key: str(target[key]) for key in _LAB_IDENTITY_FIELDS} + + +def _lab_fingerprint(target: Mapping[str, Any]) -> str: + return _canonical_sha256(_lab_identity(target)) + + +def _lab_paths(target: Mapping[str, Any]) -> tuple[Path, Path]: + fingerprint = _lab_fingerprint(target) + directory = _lab_access_directory() + return ( + directory / f"{fingerprint}.{PRIMARY_SETTING_FILE}", + directory / f"{fingerprint}.{LAB_CONSENT_FILE}", + ) + + +def _lab_decision_path(target: Mapping[str, Any]) -> Path: + return _lab_access_directory() / f"{_lab_fingerprint(target)}.{LAB_DECISION_FILE}" + + +@contextmanager +def _lab_lock(target: Mapping[str, Any]) -> Iterator[None]: + directory = _lab_access_directory() + directory.mkdir(parents=True, exist_ok=True) + lock_path = directory / f"{_lab_fingerprint(target)}.lock" + with lock_path.open("a+b") as handle: + if os.name == "nt": + import msvcrt + + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _atomic_write_json(path: Path, record: Mapping[str, Any]) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + payload = json.dumps(record, indent=2, ensure_ascii=False) + "\n" + try: + with temporary.open("x", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def confirm_primary_lab_setting( + solution: str | Path, + *, + actor: str, + primary_setting_name: str, + confirmed: bool, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + state, run_id = _run_context(root) + if (state.get("context") or {}).get("workflowProfile") != "qualification": + raise ValueError("primary lab-setting confirmation is Qualification-only") + target = require_locked_target(root, materialize_missing=False) + if confirmed is not True: + raise ValueError("primary lab setting requires explicit --confirm-primary-setting") + if not isinstance(actor, str) or not actor.strip(): + raise ValueError("primary-setting confirmation requires an explicit actor") + if ( + not isinstance(primary_setting_name, str) + or not primary_setting_name.strip() + or len(primary_setting_name.strip()) > 128 + ): + raise ValueError("primary-setting name must be nonempty text of at most 128 characters") + observed_primary_setting = _load_primary_setting(target) + observed_primary_version = ( + observed_primary_setting.get("recordSha256") + if observed_primary_setting is not None + else None + ) + with _lab_lock(target): + current_target = require_locked_target(root, materialize_missing=False) + if _lab_identity(current_target) != _lab_identity(target): + raise ValueError("locked lab target changed during primary-setting confirmation; retry") + current_state, current_run_id = _run_context(root) + if current_run_id != run_id or current_state.get("context", {}).get("workflowProfile") != "qualification": + raise ValueError("Qualification run changed during primary-setting confirmation; retry") + target = current_target + current_primary_setting = _load_primary_setting(target) + current_primary_version = ( + current_primary_setting.get("recordSha256") + if current_primary_setting is not None + else None + ) + if current_primary_version != observed_primary_version: + raise ValueError( + "primary lab setting changed during confirmation; review the current setting and retry" + ) + setting_path, _ = _lab_paths(target) + setting = { + "schemaVersion": LAB_CONSENT_SCHEMA_VERSION, + "status": "confirmed", + "solution": root.name, + "runId": run_id, + "target": _lab_identity(target), + "targetFingerprintSha256": _lab_fingerprint(target), + "primarySetting": { + "name": primary_setting_name.strip(), + "value": target["workspaceResourceId"], + "confirmed": True, + "confirmationType": "explicit-user-attestation", + "serviceVerified": False, + }, + "actor": actor.strip(), + "confirmedAt": datetime.now(timezone.utc).isoformat(), + } + setting["recordSha256"] = _canonical_sha256(setting) + _atomic_write_json(setting_path, setting) + return { + "status": "confirmed", + "path": str(setting_path), + "target": setting["target"], + "primarySetting": setting["primarySetting"], + "serviceVerified": False, + } + + +def _load_primary_setting(target: Mapping[str, Any]) -> dict[str, Any] | None: + setting_path, _ = _lab_paths(target) + try: + record = json.loads(setting_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(record, dict) or record.get("status") != "confirmed": + return None + if ( + record.get("schemaVersion") != LAB_CONSENT_SCHEMA_VERSION + or record.get("target") != _lab_identity(target) + or record.get("targetFingerprintSha256") != _lab_fingerprint(target) + or not isinstance(record.get("actor"), str) + or not record["actor"].strip() + or not isinstance(record.get("confirmedAt"), str) + ): + return None + primary = record.get("primarySetting") + if ( + not isinstance(primary, dict) + or primary.get("confirmed") is not True + or primary.get("serviceVerified") is not False + or primary.get("confirmationType") != "explicit-user-attestation" + or primary.get("value") != target.get("workspaceResourceId") + or not isinstance(primary.get("name"), str) + or not primary["name"].strip() + ): + return None + fingerprinted = {key: value for key, value in record.items() if key != "recordSha256"} + if record.get("recordSha256") != _canonical_sha256(fingerprinted): + return None + return record + + +def _read_lab_decision(target: Mapping[str, Any]) -> dict[str, Any] | None: + try: + record = json.loads(_lab_decision_path(target).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(record, dict): + return None + unsigned = {key: value for key, value in record.items() if key != "decisionSha256"} + if ( + record.get("schemaVersion") != LAB_CONSENT_SCHEMA_VERSION + or record.get("decisionType") != "non-authorizing-runtime-query-decision" + or record.get("status") not in {"declined", "revised"} + or not isinstance(record.get("actor"), str) + or not record["actor"].strip() + or not isinstance(record.get("reason"), str) + or not record["reason"].strip() + or not isinstance(record.get("binding"), dict) + or record.get("authorizesRuntimeQueries") is not False + or record.get("revokesExistingGrant") is not False + or record.get("decisionSha256") != _canonical_sha256(unsigned) + ): + return None + return record + + +def _lab_decision_summary( + target: Mapping[str, Any], + run_binding: Mapping[str, Any], + primary_setting: Mapping[str, Any] | None, +) -> dict[str, Any]: + record = _read_lab_decision(target) + if record is None: + return {"status": "unanswered"} + expected_lab_binding = ( + _lab_binding(target, primary_setting) + if primary_setting is not None + else None + ) + if ( + record["binding"] != run_binding + or record.get("labBinding") != expected_lab_binding + ): + return {"status": "prior-decision-not-current"} + return { + "status": record["status"], + "actor": record["actor"], + "reason": record["reason"], + "recordedAt": record["recordedAt"], + "authorizesRuntimeQueries": False, + "revokesExistingGrant": False, + } + + +def _lab_binding( + target: Mapping[str, Any], + primary_setting: Mapping[str, Any], +) -> dict[str, Any]: + primary = primary_setting["primarySetting"] + return { + "labFingerprintSha256": _lab_fingerprint(target), + "target": _lab_identity(target), + "confirmedPrimarySetting": { + "name": primary["name"], + "value": primary["value"], + "confirmationType": primary["confirmationType"], + "serviceVerified": False, + }, + "operationFamilies": { + name: _OPERATION_FAMILIES[name] + for name in sorted(FAMILIES) + }, + "readOnlyOnly": True, + "authenticationOrPermissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + } + + +def _query_sha256(query: str) -> str: + return hashlib.sha256(query.encode("utf-8")).hexdigest() + + +def _run_context(solution: Path) -> tuple[dict[str, Any], str]: + state_path = existing_artifact_path(solution, "workflow-state.json") + if not state_path.is_file(): + raise ValueError("workflow state does not exist; runtime query consent is unavailable") + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + context = state.get("context") or {} + if context.get("workflowProfile") not in {"authoring", "qualification"}: + raise ValueError("runtime query consent requires an explicit workflow profile") + run_id = state.get("runId") + if not isinstance(run_id, str) or not run_id: + raise ValueError("workflow state has no run ID") + return state, run_id + + +def _authoring_tenant_id(state: Mapping[str, Any]) -> str: + context = state.get("context") or {} + tenant_id = context.get("tenantId") if isinstance(context, Mapping) else None + try: + normalized = str(uuid.UUID(tenant_id)) if isinstance(tenant_id, str) else "" + except ValueError: + normalized = "" + if not normalized: + raise ValueError( + "Authoring Advanced Hunting requires an explicit tenant ID in the " + "selected workflow run; no target discovery is performed" + ) + return normalized + + +def _build_authoring_runtime_query_plan( + root: Path, + state: Mapping[str, Any], + run_id: str, + *, + source_provider: str, +) -> dict[str, Any]: + tenant_id = _authoring_tenant_id(state) + xdr_rules, _ = _xdr_inventory(root) + target_fingerprint = _canonical_sha256({"tenantId": tenant_id}) + source_provider_identity = { + "provider": source_provider, + "name": _SOURCE_PROVIDER_NAMES[source_provider], + "route": "qualification-only", + "authentication": { + "classification": "qualification-only", + "cachedAuthConfigured": False, + "interactiveSignInPermittedByThisConsent": False, + }, + } + graph_provider_identity = { + "provider": "graph", + "name": "Microsoft Defender XDR Advanced Hunting", + "route": "defender-xdr-query-family", + "authentication": _authentication_requirement( + "graph", + {"tenantId": tenant_id}, + ), + } + families = { + SOURCE_FAMILY: { + "provider": source_provider_identity, + "ruleCount": 0, + "queryCount": 0, + "queries": [], + "status": "qualification-only", + }, + "defenderXdrQueryFamily": { + "provider": graph_provider_identity, + "ruleCount": len(xdr_rules), + "queryCount": len(xdr_rules), + "queries": xdr_rules, + "status": "ready" if xdr_rules else "incomplete", + }, + } + for family in families.values(): + family["familySha256"] = _canonical_sha256(family) + plan = { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": "ready" if xdr_rules else "incomplete", + "solution": root.name, + "solutionPath": ".", + "runId": run_id, + "workflowProfile": "authoring", + "target": { + "tenantId": tenant_id, + "fingerprintSha256": target_fingerprint, + "workspaceSelected": False, + }, + "labReadConsent": { + "scope": { + "scopeType": "one-run-authoring-query-only", + "tenantId": tenant_id, + "targetFingerprintSha256": target_fingerprint, + "crossSolution": False, + "crossRun": False, + }, + "status": "requires-explicit-approval", + "approvedFamilies": [], + "requiresExplicitApproval": True, + "reusableAcrossSolutionsAndRuns": False, + "queryHashesAreConsentBindings": True, + "operationFamilies": { + "defenderXdrQueryFamily": _OPERATION_FAMILIES[ + "defenderXdrQueryFamily" + ] + }, + "preselectedFamilies": [], + }, + "families": families, + "consentChoices": { + "availableFamilies": ["defenderXdrQueryFamily"], + "preselectedFamilies": [], + "noResponseMeans": "not-approved-and-not-run", + "declineOrReviseBlocksOfflineAuthoring": False, + }, + "safety": { + "providerQueriesPerformed": False, + "newAuthenticationPerformed": False, + "workspaceDiscoveryPerformed": False, + "cloudWritesPerformed": False, + }, + "authorizationBoundary": { + "readOnlyRuntimeQueriesOnly": True, + "interactiveAuthenticationAuthorized": False, + "permissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + "sourceSdkValidationAuthorized": False, + "packageOrDeploymentAuthorized": False, + "ingestionOrParityAuthorized": False, + "ruleEnablementOrCleanupAuthorized": False, + }, + } + authoring_record, authoring_status = _load_authoring_consent(root, plan) + plan["labReadConsent"].update( + _authoring_consent_summary(authoring_record, authoring_status) + ) + plan["planSha256"] = _runtime_query_plan_sha256(plan) + return plan + + +def _authentication_requirement(provider: str, target: Mapping[str, Any]) -> dict[str, Any]: + if provider == "graph": + state_dir = _state_dir() + client_id = resolve_graph_client_id(state_dir, "advancedHunting") + auth_record = graph_auth_record_path( + state_dir, + "advancedHunting", + client_id, + AUTH_RECORD_NAME, + ) + record_present = auth_record.is_file() + record_valid = False + record_tenant = None + if record_present: + try: + from azure.identity import AuthenticationRecord + + record = AuthenticationRecord.deserialize( + auth_record.read_text(encoding="utf-8") + ) + record_tenant = getattr(record, "tenant_id", None) + record_valid = bool(record_tenant) + except (ImportError, OSError, ValueError): + pass + configured_tenant = _read_json(state_dir / CONFIG_NAME).get("tenantId") + if configured_tenant is None: + configured_tenant = record_tenant + tenant_matches = ( + configured_tenant.casefold() == str(target["tenantId"]).casefold() + if isinstance(configured_tenant, str) + else None + ) + cached = record_valid and tenant_matches is not False + return { + "purpose": "advancedHunting", + "clientId": client_id, + "classification": ( + "cached-auth-configured-unverified" + if cached + else "separate-interactive-sign-in-required" + ), + "cachedAuthConfigured": cached, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "local Graph authentication-record/config presence only; token validity is not tested", + } + if provider == "azure-monitor-sdk": + cli = shutil.which("az") + azure_config_dir = configured_azure_cli_profile(_state_dir()) + cached = False + if cli and azure_config_dir: + try: + environment = os.environ.copy() + environment["AZURE_CONFIG_DIR"] = azure_config_dir + result = subprocess.run( + [cli, "account", "show", "--output", "json"], + capture_output=True, + text=True, + check=False, + timeout=10, + env=environment, + ) + if result.returncode == 0: + account = json.loads(result.stdout) + cached = ( + isinstance(account, dict) + and str(account.get("tenantId", "")).casefold() + == str(target["tenantId"]).casefold() + ) + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + cached = False + return { + "classification": ( + "existing-cached-auth-available" + if cached + else "separate-interactive-sign-in-required" + ), + "cachedAuthConfigured": cached, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "local az account show result for the locked tenant; no login is started", + } + return { + "classification": "existing-provider-session-required-unverified", + "cachedAuthConfigured": None, + "interactiveSignInPermittedByThisConsent": False, + "evidence": ( + "authentication is managed outside this CLI; dispatch must use an " + "existing provider session, and any sign-in needs a separate immediate approval" + ), + } + + +def _query_entry( + *, rule_id: Any, path: str, query: str, kind: str = "rule-query" +) -> dict[str, Any]: + return { + "ruleId": str(rule_id).strip() if rule_id is not None else None, + "path": path, + "kind": kind, + "queryText": query, + "querySha256": _query_sha256(query), + } + + +def _source_inventory(root: Path) -> list[dict[str, Any]]: + inventory = [] + for path in analytic_rule_files(root): + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + if not isinstance(document, dict) or not isinstance(document.get("query"), str): + raise ValueError(f"source Analytic Rule has no query: {path}") + inventory.append( + _query_entry( + rule_id=document.get("id"), + path=path.relative_to(root).as_posix(), + query=document["query"], + ) + ) + return sorted(inventory, key=lambda item: item["path"]) + + +def _xdr_inventory(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + output = root / "XDR Detections" + rules: list[dict[str, Any]] = [] + for path in xdr_detection_files(output): + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + query = ( + (((document or {}).get("properties") or {}).get("queryCondition") or {}) + .get("queryText") + ) + if not isinstance(document, dict) or not isinstance(query, str) or not query.strip(): + raise ValueError(f"Custom Detection has no Advanced Hunting query: {path}") + relative = path.relative_to(output).as_posix() + rule = _query_entry( + rule_id=document.get("id"), + path=relative, + query=query, + ) + rules.append(rule) + return ( + sorted(rules, key=lambda item: item["path"]), + [], + ) + + +def build_runtime_query_plan( + solution: str | Path, + *, + source_provider: str = "azure-monitor-sdk", +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + if source_provider not in SOURCE_PROVIDERS: + raise ValueError(f"unsupported Sentinel source provider: {source_provider}") + state, run_id = _run_context(root) + profile = (state.get("context") or {}).get("workflowProfile") + if profile == "authoring": + return _build_authoring_runtime_query_plan( + root, + state, + run_id, + source_provider=source_provider, + ) + target = require_locked_target(root, materialize_missing=False) + primary_setting_record = _load_primary_setting(target) + target_fingerprint = _canonical_sha256( + { + key: target[key] + for key in ( + "tenantId", + "subscriptionId", + "resourceGroup", + "workspaceResourceId", + "workspaceName", + "workspaceCustomerId", + ) + } + ) + source_provider_identity = { + "provider": source_provider, + "name": _SOURCE_PROVIDER_NAMES[source_provider], + "route": "sentinel-source-query-family", + "authentication": _authentication_requirement(source_provider, target), + } + graph_provider_identity = { + "provider": "graph", + "name": "Microsoft Defender XDR Advanced Hunting", + "route": "defender-xdr-query-family", + "authentication": _authentication_requirement("graph", target), + } + source_queries = _source_inventory(root) + xdr_rules, _ = _xdr_inventory(root) + xdr_queries = xdr_rules + if primary_setting_record is None: + lab_consent_status = "requires-explicit-primary-setting-confirmation" + lab_consent_record = None + lab_approved = set() + else: + lab_consent_record, lab_consent_status = _load_lab_consent( + target, + primary_setting_record, + ) + lab_approved = ( + _approved_families( + lab_consent_record, + binding=_lab_binding(target, primary_setting_record), + ) + if lab_consent_record is not None + else set() + ) or set() + families = { + "sentinelSourceQueryFamily": { + "provider": source_provider_identity, + "ruleCount": len(source_queries), + "queryCount": len(source_queries), + "queries": source_queries, + }, + "defenderXdrQueryFamily": { + "provider": graph_provider_identity, + "ruleCount": len(xdr_rules), + "queryCount": len(xdr_queries), + "queries": xdr_queries, + }, + } + for family in families.values(): + family["status"] = "ready" if family["ruleCount"] else "incomplete" + family["familySha256"] = _canonical_sha256(family) + plan = { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": "ready" if source_queries or xdr_queries else "incomplete", + "solution": root.name, + "solutionPath": ".", + "runId": run_id, + "workflowProfile": "qualification", + "target": { + **{ + key: target[key] + for key in ( + "tenantId", + "subscriptionId", + "resourceGroup", + "workspaceResourceId", + "workspaceName", + "workspaceCustomerId", + ) + }, + "fingerprintSha256": target_fingerprint, + }, + "labReadConsent": { + "scope": ( + _lab_binding(target, primary_setting_record) + if primary_setting_record is not None + else None + ), + "status": lab_consent_status, + "approvedFamilies": sorted(lab_approved), + "requiresExplicitApproval": not bool(lab_approved), + "recordedAt": ( + lab_consent_record.get("recordedAt") + if lab_consent_record is not None + else None + ), + "reusableAcrossSolutionsAndRuns": True, + "queryHashesAreRunSnapshotsNotLabConsentBindings": True, + "operationFamilies": { + name: _OPERATION_FAMILIES[name] + for name in sorted(FAMILIES) + }, + "preselectedFamilies": [], + }, + "families": families, + "consentChoices": { + "availableFamilies": list(sorted(FAMILIES)), + "preselectedFamilies": [], + "noResponseMeans": "not-approved-and-not-run", + "declineOrReviseBlocksOfflineAuthoring": False, + }, + "safety": { + "providerQueriesPerformed": False, + "newAuthenticationPerformed": False, + "cloudWritesPerformed": False, + }, + "authorizationBoundary": { + "readOnlyRuntimeQueriesOnly": True, + "interactiveAuthenticationAuthorized": False, + "permissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + "packageOrDeploymentAuthorized": False, + "ingestionOrParityAuthorized": False, + "ruleEnablementOrCleanupAuthorized": False, + }, + } + plan["labReadConsent"]["latestDecision"] = _lab_decision_summary( + target, + _decision_binding(plan), + primary_setting_record, + ) + plan["planSha256"] = _runtime_query_plan_sha256(plan) + return plan + + +def persist_runtime_query_plan( + solution: str | Path, + *, + source_provider: str = "azure-monitor-sdk", +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + plan = build_runtime_query_plan(root, source_provider=source_provider) + path = artifact_path(root, PLAN_FILE_NAME, create_parent=True) + write_json_artifact(root, path, plan) + return {**plan, "planPath": str(path)} + + +def _current_binding(plan: Mapping[str, Any]) -> dict[str, Any]: + return { + "solution": plan["solution"], + "workflowProfile": plan["workflowProfile"], + "runId": plan["runId"], + "targetFingerprintSha256": plan["target"]["fingerprintSha256"], + "planSha256": plan.get("planSha256"), + "families": { + name: { + "familySha256": family["familySha256"], + "ruleCount": family["ruleCount"], + "queryCount": family["queryCount"], + "provider": family["provider"], + } + for name, family in sorted(plan["families"].items()) + }, + } + + +def _decision_binding(plan: Mapping[str, Any]) -> dict[str, Any]: + binding = _current_binding(plan) + binding.pop("planSha256", None) + return binding + + +def _valid_authoring_consent(record: Any) -> bool: + if not isinstance(record, dict): + return False + unsigned = { + key: value for key, value in record.items() if key != "consentSha256" + } + approved = record.get("approvedFamilies") + return bool( + record.get("schemaVersion") == AUTHORING_CONSENT_SCHEMA_VERSION + and record.get("consentType") == "authoring-run-bound-runtime-query" + and record.get("status") in {"approved", "declined", "revised"} + and isinstance(record.get("actor"), str) + and record["actor"].strip() + and isinstance(record.get("recordedAt"), str) + and isinstance(approved, list) + and all(isinstance(item, str) for item in approved) + and len(set(approved)) == len(approved) + and ( + (record.get("status") == "approved" + and approved == ["defenderXdrQueryFamily"]) + or (record.get("status") != "approved" and not approved) + ) + and record.get("scopePolicy") == { + "crossSolution": False, + "crossRun": False, + "queryHashesAreConsentBindings": True, + } + and record.get("authorizationBoundary") + == { + "readOnlyAdvancedHuntingOnly": True, + "interactiveAuthenticationAuthorized": False, + "sourceSdkValidationAuthorized": False, + "permissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + "packageOrDeploymentAuthorized": False, + "ingestionOrParityAuthorized": False, + "ruleEnablementOrCleanupAuthorized": False, + } + and isinstance(record.get("binding"), dict) + and record.get("authorizesRuntimeQueries") + is (record.get("status") == "approved") + and record.get("consentSha256") == _canonical_sha256(unsigned) + ) + + +def _load_authoring_consent( + root: Path, + plan: Mapping[str, Any], +) -> tuple[dict[str, Any] | None, str]: + path = existing_artifact_path(root, AUTHORING_CONSENT_FILE_NAME) + try: + record = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None, "not-approved" + except (OSError, json.JSONDecodeError): + return None, "invalid" + if not _valid_authoring_consent(record): + return None, "invalid" + if record.get("binding") != _decision_binding(plan): + return record, "stale" + return record, str(record["status"]) + + +def _authoring_consent_summary( + record: Mapping[str, Any] | None, + status: str, +) -> dict[str, Any]: + approved = ( + ["defenderXdrQueryFamily"] + if status == "approved" and record is not None + else [] + ) + decision = ( + { + "status": record["status"], + "actor": record["actor"], + "reason": record.get("reason"), + "recordedAt": record["recordedAt"], + "authorizesRuntimeQueries": status == "approved", + } + if record is not None + else {"status": "unanswered", "authorizesRuntimeQueries": False} + ) + return { + "status": status, + "approvedFamilies": approved, + "requiresExplicitApproval": not bool(approved), + "recordedAt": ( + record.get("recordedAt") if status == "approved" and record else None + ), + "reusableAcrossSolutionsAndRuns": False, + "latestDecision": decision, + } + + +def _valid_lab_record(record: Any) -> bool: + if not isinstance(record, dict): + return False + fingerprinted = { + key: value for key, value in record.items() if key != "consentSha256" + } + approved = record.get("approvedFamilies") + return bool( + record.get("schemaVersion") == LAB_CONSENT_SCHEMA_VERSION + and record.get("consentType") == "shared-lab-read-only-runtime-query" + and record.get("status") in {"approved", "declined", "revoked"} + and isinstance(record.get("actor"), str) + and record["actor"].strip() + and isinstance(record.get("recordedAt"), str) + and isinstance(approved, list) + and all(isinstance(item, str) and item in FAMILIES for item in approved) + and len(set(approved)) == len(approved) + and record.get("scopePolicy") == _LAB_SCOPE_POLICY + and record.get("authorizationBoundary") == _LAB_AUTHORIZATION_BOUNDARY + and isinstance(record.get("binding"), dict) + and ( + (record.get("status") == "approved" and bool(approved)) + or (record.get("status") != "approved" and not approved) + ) + and record.get("consentSha256") == _canonical_sha256(fingerprinted) + ) + + +def _load_lab_consent( + target: Mapping[str, Any], + primary_setting: Mapping[str, Any], +) -> tuple[dict[str, Any] | None, str | None]: + _, consent_path = _lab_paths(target) + try: + record = json.loads(consent_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None, "not-approved" + except (OSError, json.JSONDecodeError): + return None, "invalid" + if not _valid_lab_record(record): + return None, "invalid" + if record.get("binding") != _lab_binding(target, primary_setting): + return None, "stale" + if record.get("status") != "approved": + return None, str(record.get("status")) + return record, "approved" + + +def revoke_runtime_query_consent( + solution: str | Path, + *, + actor: str, + reason: str, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + if not isinstance(actor, str) or not actor.strip(): + raise ValueError("revoking lab runtime-query consent requires an explicit actor") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError("revoking lab runtime-query consent requires a reason") + target = require_locked_target(root, materialize_missing=False) + with _lab_lock(target): + current_target = require_locked_target(root, materialize_missing=False) + if _lab_identity(current_target) != _lab_identity(target): + raise ValueError("locked lab target changed during consent revocation; retry") + target = current_target + _, consent_path = _lab_paths(target) + setting = _load_primary_setting(target) + binding = ( + _lab_binding(target, setting) + if setting is not None + else { + "labFingerprintSha256": _lab_fingerprint(target), + "target": _lab_identity(target), + "primarySettingConfirmed": False, + } + ) + record = { + "schemaVersion": LAB_CONSENT_SCHEMA_VERSION, + "status": "revoked", + "consentType": "shared-lab-read-only-runtime-query", + "actor": actor.strip(), + "recordedAt": datetime.now(timezone.utc).isoformat(), + "reason": reason.strip(), + "approvedFamilies": [], + "binding": binding, + "scopePolicy": _LAB_SCOPE_POLICY, + "authorizationBoundary": _LAB_AUTHORIZATION_BOUNDARY, + } + record["consentSha256"] = _canonical_sha256(record) + _atomic_write_json(consent_path, record) + try: + _lab_decision_path(target).unlink() + except FileNotFoundError: + pass + return {"status": "revoked", "path": str(consent_path)} + + +def _approved_families( + record: Mapping[str, Any], + *, + binding: Mapping[str, Any], +) -> set[str] | None: + approved = record.get("approvedFamilies") + if ( + not _valid_lab_record(record) + or record.get("status") != "approved" + or record.get("binding") != binding + ): + return None + return set(approved) + + +def _family_execution_status( + root: Path, + family: Mapping[str, Any], +) -> dict[str, Any]: + provider = family["provider"]["provider"] + report_provider = { + "azure-monitor-sdk": "azure-monitor-sdk", + "triage-mcp": "triage-mcp", + "log-analytics-cli": "log-analytics-cli", + "graph": "graph", + }[provider] + path = existing_artifact_path( + root, + f"runtime-validation.{report_provider}.json", + ) + if not path.is_file(): + return {"executionStatus": "not-run"} + try: + report = json.loads(path.read_text(encoding="utf-8-sig")) + results = report.get("results") + if report.get("provider") != report_provider or not isinstance(results, list): + return {"executionStatus": "stale-or-invalid-report"} + expected = Counter( + entry["querySha256"] + for entry in family["queries"] + if entry["kind"] == "rule-query" + ) + actual = Counter( + item.get("querySha256") + for item in results + if isinstance(item, dict) and isinstance(item.get("querySha256"), str) + ) + if actual != expected: + return {"executionStatus": "stale-or-incomplete"} + counts = { + status: sum(item.get("status") == status for item in results) + for status in ("passed", "failed", "blocked", "not-run") + } + return { + "executionStatus": ( + "recorded" if counts["not-run"] == 0 else "incomplete" + ), + "results": counts, + "runtimeReport": path.name, + } + except (OSError, json.JSONDecodeError, KeyError, TypeError): + return {"executionStatus": "stale-or-invalid-report"} + + +def record_runtime_query_consent( + solution: str | Path, + *, + expected_plan_sha256: str, + actor: str, + approved_families: list[str], + declined: bool = False, + revised: bool = False, + reason: str | None = None, + source_provider: str = "azure-monitor-sdk", +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + if not isinstance(actor, str) or not actor.strip(): + raise ValueError("runtime query consent requires an explicit nonempty actor") + if sum((declined, revised)) > 1: + raise ValueError("decline and revise cannot be combined") + if declined or revised: + if approved_families: + raise ValueError("decline or revise cannot be combined with approved families") + if declined and (not isinstance(reason, str) or not reason.strip()): + raise ValueError("declining runtime query consent requires a reason") + elif not approved_families: + raise ValueError("select at least one query family; no selection is not consent") + if len(set(approved_families)) != len(approved_families): + raise ValueError("runtime query consent families must be unique") + unknown = set(approved_families) - FAMILIES + if unknown: + raise ValueError("unsupported runtime query family: " + ", ".join(sorted(unknown))) + + plan = build_runtime_query_plan(root, source_provider=source_provider) + if not isinstance(expected_plan_sha256, str) or expected_plan_sha256 != plan["planSha256"]: + raise ValueError( + "runtime query plan changed; review the current inventory and plan hash before selecting again" + ) + for family in approved_families: + if plan["families"][family]["status"] != "ready": + raise ValueError(f"{family} has no complete current query inventory") + if plan["workflowProfile"] == "authoring": + if approved_families and approved_families != ["defenderXdrQueryFamily"]: + raise ValueError( + "Authoring consent is limited to its exact Defender XDR query family" + ) + current_state, current_run_id = _run_context(root) + if ( + current_run_id != plan["runId"] + or (current_state.get("context") or {}).get("workflowProfile") + != "authoring" + ): + raise ValueError("the selected Authoring run changed during consent review") + current_plan = build_runtime_query_plan( + root, + source_provider=source_provider, + ) + if current_plan["planSha256"] != expected_plan_sha256: + raise ValueError( + "runtime query plan changed during consent review; prepare and review the current plan" + ) + record = { + "schemaVersion": AUTHORING_CONSENT_SCHEMA_VERSION, + "status": "revised" if revised else "declined" if declined else "approved", + "consentType": "authoring-run-bound-runtime-query", + "actor": actor.strip(), + "recordedAt": datetime.now(timezone.utc).isoformat(), + "reason": reason.strip() if isinstance(reason, str) else None, + "approvedFamilies": ( + ["defenderXdrQueryFamily"] + if not declined and not revised + else [] + ), + "planSha256": current_plan["planSha256"], + "binding": _decision_binding(current_plan), + "scopePolicy": { + "crossSolution": False, + "crossRun": False, + "queryHashesAreConsentBindings": True, + }, + "authorizationBoundary": { + "readOnlyAdvancedHuntingOnly": True, + "interactiveAuthenticationAuthorized": False, + "sourceSdkValidationAuthorized": False, + "permissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + "packageOrDeploymentAuthorized": False, + "ingestionOrParityAuthorized": False, + "ruleEnablementOrCleanupAuthorized": False, + }, + "authorizesRuntimeQueries": not (declined or revised), + } + record["consentSha256"] = _canonical_sha256(record) + path = artifact_path( + root, + AUTHORING_CONSENT_FILE_NAME, + create_parent=True, + ) + _atomic_write_json(path, record) + return { + "status": record["status"], + "consentRecorded": record["status"] == "approved", + "decisionRecorded": record["status"] != "approved", + "path": str(path), + "planSha256": current_plan["planSha256"], + "approvedFamilies": record["approvedFamilies"], + "consentScope": "one-run-authoring-query-only", + "crossSolution": False, + "crossRun": False, + "environmentWritesAuthorized": False, + } + target = require_locked_target(root, materialize_missing=False) + status = "revised" if revised else "declined" if declined else "approved" + with _lab_lock(target): + current_target = require_locked_target(root, materialize_missing=False) + if _lab_identity(current_target) != _lab_identity(target): + raise ValueError("locked lab target changed during consent review; prepare a new plan") + target = current_target + current_plan = build_runtime_query_plan(root, source_provider=source_provider) + if current_plan["planSha256"] != expected_plan_sha256: + raise ValueError( + "runtime query plan changed during consent review; prepare and review the current plan" + ) + for family in approved_families: + if current_plan["families"][family]["status"] != "ready": + raise ValueError(f"{family} has no complete current query inventory") + primary_setting = _load_primary_setting(target) + if primary_setting is None: + raise ValueError( + "the lab primary setting has not been explicitly confirmed; run " + "confirm-primary-lab-setting before requesting shared lab read consent" + ) + _, consent_path = _lab_paths(target) + existing_record, _ = _load_lab_consent(target, primary_setting) + if existing_record is not None and not _valid_lab_record(existing_record): + raise ValueError("shared lab consent record failed integrity validation") + recorded_at = datetime.now(timezone.utc).isoformat() + if status in {"declined", "revised"}: + decision = { + "schemaVersion": LAB_CONSENT_SCHEMA_VERSION, + "decisionType": "non-authorizing-runtime-query-decision", + "status": status, + "actor": actor.strip(), + "reason": ( + reason.strip() + if isinstance(reason, str) and reason.strip() + else "Plan review deferred." + ), + "recordedAt": recorded_at, + "binding": _decision_binding(current_plan), + "labBinding": _lab_binding(target, primary_setting), + "planSha256": current_plan["planSha256"], + "offeredFamilies": sorted( + family + for family in FAMILIES + if current_plan["families"][family]["status"] == "ready" + ), + "authorizesRuntimeQueries": False, + "revokesExistingGrant": False, + } + decision["decisionSha256"] = _canonical_sha256(decision) + decision_path = _lab_decision_path(target) + _atomic_write_json(decision_path, decision) + return { + "status": status, + "decisionRecorded": True, + "consentRecorded": False, + "approvedFamilies": [], + "existingGrantPreserved": bool(existing_record), + "decisionPath": str(decision_path), + "planSha256": current_plan["planSha256"], + } + approved_to_store = sorted( + (set(existing_record["approvedFamilies"]) if existing_record else set()) + | set(approved_families) + ) + record = { + "schemaVersion": LAB_CONSENT_SCHEMA_VERSION, + "status": "approved", + "consentType": "shared-lab-read-only-runtime-query", + "actor": actor.strip(), + "recordedAt": recorded_at, + "reason": reason.strip() if isinstance(reason, str) else None, + "approvedFamilies": approved_to_store, + "binding": _lab_binding(target, primary_setting), + "scopePolicy": _LAB_SCOPE_POLICY, + "authorizationBoundary": _LAB_AUTHORIZATION_BOUNDARY, + } + record["consentSha256"] = _canonical_sha256(record) + _atomic_write_json(consent_path, record) + try: + _lab_decision_path(target).unlink() + except FileNotFoundError: + pass + return { + "status": "approved", + "consentRecorded": True, + "path": str(consent_path), + "planSha256": current_plan["planSha256"], + "approvedFamilies": record["approvedFamilies"], + "crossSolution": True, + "crossRun": True, + } + + +def runtime_query_consent_status( + solution: str | Path, + *, + source_provider: str | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + try: + if source_provider is None: + plan_path = existing_artifact_path(root, PLAN_FILE_NAME) + if plan_path.is_file(): + persisted_plan = json.loads(plan_path.read_text(encoding="utf-8-sig")) + persisted_route = ( + (((persisted_plan.get("families") or {}).get(SOURCE_FAMILY) or {}).get("provider") or {}).get("provider") + ) + source_provider = ( + persisted_route + if persisted_route in SOURCE_PROVIDERS + else "azure-monitor-sdk" + ) + else: + source_provider = "azure-monitor-sdk" + plan = build_runtime_query_plan(root, source_provider=source_provider) + if plan["workflowProfile"] == "authoring": + record, status = _load_authoring_consent(root, plan) + families = {} + for name, family in plan["families"].items(): + family_status = family["status"] + if name == "defenderXdrQueryFamily" and status == "approved": + family_status = "approved" + families[name] = { + "status": family_status, + **( + _family_execution_status(root, family) + if name == "defenderXdrQueryFamily" + else {"executionStatus": "qualification-only"} + ), + } + current_binding = _current_binding(plan) + try: + snapshot = json.loads( + existing_artifact_path(root, CONSENT_FILE_NAME).read_text( + encoding="utf-8-sig" + ) + ) + except (OSError, json.JSONDecodeError): + snapshot = None + snapshot_current = bool( + isinstance(snapshot, dict) + and snapshot.get("binding") == current_binding + and snapshot.get("workflowProfile") == "authoring" + and snapshot.get("status") == "approved" + ) + return { + "status": status, + "workflowProfile": "authoring", + "consentScope": "one-run-authoring-query-only", + "approvedFamilies": ( + ["defenderXdrQueryFamily"] if status == "approved" else [] + ), + "families": families, + "runSnapshot": { + "status": "current" if snapshot_current else "not-current", + "binding": current_binding if snapshot_current else None, + }, + "latestDecision": plan["labReadConsent"]["latestDecision"], + "crossSolution": False, + "crossRun": False, + "targetTenantId": plan["target"]["tenantId"], + "authorizingConsentSha256": ( + record.get("consentSha256") + if status == "approved" and record + else None + ), + } + target = require_locked_target(root, materialize_missing=False) + setting = _load_primary_setting(target) + if setting is None: + lab_status, record, approved = ( + "requires-primary-setting-confirmation", + None, + set(), + ) + else: + record, lab_status = _load_lab_consent(target, setting) + approved = ( + _approved_families( + record, + binding=_lab_binding(target, setting), + ) + if record is not None + else set() + ) or set() + run_snapshot = _current_binding(plan) + snapshot_path = existing_artifact_path(root, CONSENT_FILE_NAME) + try: + snapshot = json.loads(snapshot_path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError): + snapshot = None + if ( + not isinstance(snapshot, dict) + or snapshot.get("binding") != run_snapshot + or snapshot.get("labConsentSha256") + != (record.get("consentSha256") if record is not None else None) + ): + snapshot = None + return { + "status": lab_status, + "approvedFamilies": sorted(approved), + "consentScope": "lab-wide-across-solutions-and-runs", + "labConsentPath": str(_lab_paths(target)[1]), + "latestDecision": plan["labReadConsent"]["latestDecision"], + "labConsentRecordedAt": ( + record.get("recordedAt") if record is not None else None + ), + "runSnapshot": { + "status": "current" if snapshot is not None else "not-current", + "binding": run_snapshot, + "planSha256": plan["planSha256"], + "queryHashesAreConsentBindings": False, + }, + "families": { + name: { + "status": "approved" if name in approved else "not-approved", + **( + _family_execution_status( + root, + plan["families"][name], + ) + if name in approved + else {"executionStatus": "not-run"} + ), + } + for name in sorted(FAMILIES) + }, + "planSha256": plan["planSha256"], + "stale": lab_status == "stale", + } + except ValueError as exc: + if "only available for Qualification" in str(exc): + return { + "status": "notApplicable", + "approvedFamilies": [], + "families": { + name: { + "status": "notApplicable", + "executionStatus": "not-run", + } + for name in sorted(FAMILIES) + }, + "planSha256": None, + "stale": False, + "reason": "runtime query consent is Qualification-only", + } + return { + "status": "unavailable", + "approvedFamilies": [], + "families": { + name: { + "status": "unavailable", + "executionStatus": "not-run", + } + for name in sorted(FAMILIES) + }, + "planSha256": None, + "stale": False, + "reason": str(exc), + } + except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc: + return { + "status": "unavailable", + "approvedFamilies": [], + "families": { + name: { + "status": "unavailable", + "executionStatus": "not-run", + } + for name in sorted(FAMILIES) + }, + "planSha256": None, + "stale": False, + "reason": str(exc), + } + + +def require_runtime_query_consent( + solution: str | Path, + *, + family: str, + provider: str, + source_provider: str = "azure-monitor-sdk", + allow_legacy: bool = False, +) -> dict[str, Any] | None: + root = Path(solution).expanduser().resolve() + try: + state, run_id = _run_context(root) + except ValueError as exc: + if allow_legacy and "workflow state does not exist" in str(exc): + return None + raise + route = provider + plan = build_runtime_query_plan( + root, + source_provider=source_provider, + ) + if family not in FAMILIES: + raise ValueError(f"unsupported runtime query family: {family}") + if plan["families"][family]["status"] != "ready": + raise ValueError( + f"{family} has no complete current query inventory; runtime execution is blocked" + ) + if plan["workflowProfile"] == "authoring": + if family != "defenderXdrQueryFamily" or route != "graph": + raise ValueError( + "Authoring runtime validation is limited to explicitly approved " + "Defender XDR Advanced Hunting queries" + ) + authentication = plan["families"][family]["provider"]["authentication"] + if authentication.get("classification") == "separate-interactive-sign-in-required": + raise ValueError( + "Advanced Hunting has no existing cached authentication for the " + "explicit Authoring tenant; runtime-query consent does not " + "authorize interactive sign-in" + ) + record, authoring_status = _load_authoring_consent(root, plan) + if ( + authoring_status != "approved" + or record is None + or record.get("approvedFamilies") != [family] + or record.get("authorizesRuntimeQueries") is not True + ): + raise ValueError( + "one-run Authoring Advanced Hunting consent is not current " + f"(status: {authoring_status}); review the exact current query plan" + ) + binding = _current_binding(plan) + run_snapshot = { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": "approved", + "consentType": "run-bound-authoring-runtime-query-authorization-snapshot", + "workflowProfile": "authoring", + "family": family, + "provider": route, + "authoringConsentSha256": record["consentSha256"], + "authoringConsentActor": record["actor"], + "authoringConsentRecordedAt": record["recordedAt"], + "approvedFamilies": [family], + "binding": binding, + "planSha256": plan["planSha256"], + "queryHashesAreConsentBindings": True, + "interactiveAuthenticationAuthorized": False, + "sourceSdkValidationAuthorized": False, + "permissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + "providerExecutionSucceeded": False, + "recordedAt": datetime.now(timezone.utc).isoformat(), + } + snapshot_path = artifact_path(root, CONSENT_FILE_NAME, create_parent=True) + write_json_artifact(root, snapshot_path, run_snapshot) + return { + "status": "approved", + "family": family, + "provider": route, + "workflowProfile": "authoring", + "planSha256": plan["planSha256"], + "binding": binding, + "consentScope": "one-run-authoring-query-only", + "authoringConsentSha256": record["consentSha256"], + "runSnapshotPath": str(snapshot_path), + "authenticationClassification": authentication.get("classification"), + "interactiveAuthenticationAuthorized": False, + "environmentWritesAuthorized": False, + } + target = require_locked_target(root, materialize_missing=False) + primary_setting = _load_primary_setting(target) + if primary_setting is None: + raise ValueError( + "the primary lab setting has not been explicitly confirmed; " + "runtime queries are not approved" + ) + record, lab_status = _load_lab_consent(target, primary_setting) + approved = ( + _approved_families( + record, + binding=_lab_binding(target, primary_setting), + ) + if record is not None + else None + ) + if approved is None: + raise ValueError( + "shared lab read-only runtime-query consent is not approved " + f"(status: {lab_status}); review the current bounded plan and obtain " + "explicit lab approval" + ) + if family not in approved: + raise ValueError(f"runtime-query consent does not approve {family}") + planned_provider = plan["families"][family]["provider"]["provider"] + if route != planned_provider: + raise ValueError( + f"runtime-query consent is bound to provider {planned_provider}, not {route}; " + "refresh the plan and obtain renewed explicit consent" + ) + authentication = plan["families"][family]["provider"]["authentication"] + if authentication.get("classification") == "separate-interactive-sign-in-required": + raise ValueError( + f"{route} has no existing cached authentication for the locked tenant; " + "runtime-query consent does not authorize interactive sign-in" + ) + binding = _current_binding(plan) + run_snapshot = { + "schemaVersion": PLAN_SCHEMA_VERSION, + "status": "derived-from-lab-consent", + "consentType": "run-bound-runtime-query-authorization-snapshot", + "labConsentSha256": record["consentSha256"], + "labConsentRecordedAt": record["recordedAt"], + "labConsentActor": record["actor"], + "approvedFamilies": sorted(approved), + "binding": binding, + "planSha256": plan["planSha256"], + "querySnapshotsAreNotConsentBindings": True, + "interactiveAuthenticationAuthorized": False, + "adminConsentAuthorized": False, + "permissionChangesAuthorized": False, + "environmentWritesAuthorized": False, + "providerExecutionSucceeded": False, + "recordedAt": datetime.now(timezone.utc).isoformat(), + } + snapshot_path = artifact_path(root, CONSENT_FILE_NAME, create_parent=True) + write_json_artifact(root, snapshot_path, run_snapshot) + return { + "status": "approved", + "family": family, + "provider": route, + "planSha256": plan["planSha256"], + "binding": binding, + "consentScope": "lab-wide-across-solutions-and-runs", + "labConsentSha256": record["consentSha256"], + "runSnapshotPath": str(snapshot_path), + "authenticationClassification": authentication.get("classification"), + "interactiveAuthenticationAuthorized": False, + "environmentWritesAuthorized": False, + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime_report_references.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime_report_references.py new file mode 100644 index 00000000000..207a5ac10b8 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/runtime_report_references.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Any + +import yaml + +from .artifacts import portable_artifact, report_directory, using_run +from .converter import xdr_detection_files +from .packaging_selection import ( + SOURCE_PROVIDER_SURFACES, + _current_target, + _read_json, + _runtime_binding, + _runtime_family, + _run_context, + _source_runtime_reference_status, +) +from .workflow import ( + _load, + _migrate_workflow_state, + _state_writer_lock, + _write_unlocked, +) + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _atomic_json(path: Path, payload: dict[str, Any]) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8") as stream: + json.dump(payload, stream, indent=2, ensure_ascii=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _typed_sdk_schema_errors(report: dict[str, Any]) -> list[str]: + results = report.get("results") + if not isinstance(results, list): + return ["SDK report has no complete results list"] + errors = [] + for index, result in enumerate(results): + if not isinstance(result, dict): + errors.append(f"SDK result {index} is malformed") + continue + tables = result.get("tables") + if not isinstance(tables, list) or not tables: + errors.append(f"SDK result {index} has no typed table schema") + continue + column_count = 0 + for table_index, table in enumerate(tables): + columns = table.get("columns") if isinstance(table, dict) else None + if ( + not isinstance(table, dict) + or not isinstance(table.get("name"), str) + or not table["name"].strip() + or not isinstance(table.get("rowCount"), int) + or isinstance(table.get("rowCount"), bool) + or table["rowCount"] < 0 + or not isinstance(columns, list) + or not columns + ): + errors.append( + f"SDK result {index} table {table_index} has an invalid typed schema" + ) + continue + seen: set[str] = set() + for column in columns: + if not isinstance(column, dict): + errors.append( + f"SDK result {index} table {table_index} contains a malformed column" + ) + break + name = column.get("name") + kind = column.get("type") + if ( + not isinstance(name, str) + or not name.strip() + or not isinstance(kind, str) + or not kind.strip() + or name.casefold() in seen + ): + errors.append( + f"SDK result {index} table {table_index} has invalid or duplicate columns" + ) + break + seen.add(name.casefold()) + column_count += 1 + if result.get("schemaColumnCount") != column_count: + errors.append(f"SDK result {index} schemaColumnCount does not match its typed tables") + return errors + + +def _validate_complete_runtime_evidence( + root: Path, + run_directory: Path, + state: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], str]: + target, target_fingerprint = _current_target( + root, state, materialize_missing=False, + ) + if not target or not target_fingerprint: + raise ValueError("runtime-report reference repair requires the current locked Qualification target") + + plan_path = run_directory / "runtime-query-plan.json" + if not plan_path.is_file(): + raise ValueError("runtime query plan is missing; current execution bindings cannot be established") + plan = _read_json(plan_path, "runtime query plan") + source_family = (plan.get("families") or {}).get("sentinelSourceQueryFamily") or {} + source_provider = (source_family.get("provider") or {}).get("provider") + if source_provider != "azure-monitor-sdk": + raise ValueError( + f"current plan routes source queries through {source_provider!r}, not azure-monitor-sdk" + ) + + detection_paths = { + path.relative_to(root / "XDR Detections").as_posix(): path + for path in xdr_detection_files(root / "XDR Detections") + } + if not detection_paths: + raise ValueError("current detection inventory is empty") + + sdk_path = run_directory / "runtime-validation.azure-monitor-sdk.json" + if not sdk_path.is_file(): + raise ValueError("current Azure Monitor SDK runtime report is missing") + consent_path = run_directory / "runtime-query-consent.json" + if not consent_path.is_file(): + raise ValueError("current source execution consent evidence is missing") + sdk_report = _read_json(sdk_path, "Azure Monitor SDK runtime report") + source_bound, source_reason = _runtime_binding( + root, + run_directory, + state, + provider="azure-monitor-sdk", + report=sdk_report, + target_fingerprint=target_fingerprint, + allow_missing_source_reference=True, + ) + if not source_bound: + raise ValueError(f"SDK report execution binding is not current: {source_reason}") + + sdk_evidence, sdk_provider, sdk_errors = _runtime_family( + root, + run_directory, + state, + provider="azure-monitor-sdk", + detection_paths=detection_paths, + target_fingerprint=target_fingerprint, + allow_missing_source_reference=True, + ) + if sdk_errors: + raise ValueError("SDK runtime evidence is incomplete or invalid: " + "; ".join(sdk_errors)) + if not sdk_evidence or any(item.get("status") != "passed" for item in sdk_evidence.values()): + failures = sorted( + f"{name}: {item.get('status')} ({item.get('reason') or 'no passing result'})" + for name, item in sdk_evidence.items() + if item.get("status") != "passed" + ) + raise ValueError( + "SDK runtime family contains a failure, blocked, missing, or stale result" + + (": " + "; ".join(failures) if failures else "") + ) + if sdk_report.get("familyComplete") is False: + raise ValueError("SDK runtime report marks the source query family incomplete") + expected_count = len(detection_paths) + if ( + sdk_report.get("total") != expected_count + or sdk_report.get("valid") != expected_count + or sdk_report.get("invalid") != 0 + or sdk_report.get("blocked") != 0 + or sdk_report.get("notRun") != 0 + ): + raise ValueError("SDK summary counts do not prove complete current-family success") + schema_errors = _typed_sdk_schema_errors(sdk_report) + if schema_errors: + raise ValueError("SDK typed schema evidence is incomplete: " + "; ".join(schema_errors)) + for index, item in enumerate(sdk_report.get("results", [])): + if item.get("status") != "passed" or item.get("valid") is not True or item.get("statusCode") != 200: + raise ValueError(f"SDK result {index} is not a successful HTTP 200 query result") + + graph_evidence, _, graph_errors = _runtime_family( + root, + run_directory, + state, + provider="graph", + detection_paths=detection_paths, + target_fingerprint=target_fingerprint, + ) + if graph_errors: + raise ValueError("Graph runtime evidence is incomplete or invalid: " + "; ".join(graph_errors)) + if not graph_evidence or any(item.get("status") != "passed" for item in graph_evidence.values()): + failures = sorted( + f"{name}: {item.get('status')} ({item.get('reason') or 'no passing result'})" + for name, item in graph_evidence.items() + if item.get("status") != "passed" + ) + raise ValueError( + "Graph runtime family contains a failure, blocked, missing, or stale result" + + (": " + "; ".join(failures) if failures else "") + ) + graph_path = run_directory / "runtime-validation.graph.json" + if not graph_path.is_file(): + raise ValueError("current Graph runtime report is missing") + graph_report = _read_json(graph_path, "Graph runtime report") + if ( + graph_report.get("total") != expected_count + or graph_report.get("valid") != expected_count + or graph_report.get("invalid") != 0 + or graph_report.get("blocked") != 0 + or graph_report.get("notRun") != 0 + ): + raise ValueError("Graph summary counts do not prove complete current-family success") + for index, item in enumerate(graph_report.get("results", [])): + if item.get("status") != "passed" or item.get("valid") is not True or item.get("statusCode") != 200: + raise ValueError(f"Graph result {index} is not a successful HTTP 200 query result") + return sdk_report, sdk_provider, target_fingerprint + + +def _build_reference_repair_plan(solution: str | Path, run_id: str) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + run_directory, state = _run_context(root, run_id) + state_path, state = _load(root) + validation = (state.get("stages") or {}).get("validation") or {} + if validation.get("status") != "passed": + artifacts = validation.get("artifacts") or {} + source_path = run_directory / "runtime-validation.azure-monitor-sdk.json" + canonical_ref = artifacts.get("sentinelRuntimeJson") + reference_ok, reference_reason = _source_runtime_reference_status( + root, + run_directory, + state, + provider="azure-monitor-sdk", + ) + if ( + canonical_ref + and reference_ok + and source_path.is_file() + ): + original_state = state_path.read_bytes() + result = { + "schemaVersion": "1.0.0", + "solution": root.name, + "runId": run_id, + "workflowStageStatus": validation.get("status"), + "action": "already-current-noop", + "eligible": False, + "noChange": True, + "reason": ( + "canonical source report reference is already correct; " + f"validation stage remains unchanged ({validation.get('status')!r})" + ), + "sourceProvider": "azure-monitor-sdk", + "sourceReportPath": source_path.relative_to(root).as_posix(), + "sourceReportSha256": _sha256(source_path.read_bytes()), + "validationStatePath": state_path.relative_to(root).as_posix(), + "validationStateSha256": _sha256(original_state), + "existingCanonicalReference": canonical_ref, + "existingLegacyReference": artifacts.get("sentinelRuntimeReport"), + "desiredCanonicalReference": canonical_ref, + "stageStatusPreserved": validation.get("status"), + "stateArtifactsPreserved": sorted(artifacts), + } + result["planSha256"] = hashlib.sha256( + json.dumps(result, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + ).hexdigest() + return result + raise ValueError( + f"validation stage is {validation.get('status')!r}; " + f"repair only augments an already-passed stage " + f"({reference_reason})" + ) + sdk_report, sdk_provider, target_fingerprint = _validate_complete_runtime_evidence( + root, run_directory, state + ) + artifacts = validation.get("artifacts") or {} + reference_ok, reference_reason = _source_runtime_reference_status( + root, + run_directory, + state, + provider="azure-monitor-sdk", + allow_missing=True, + ) + if not reference_ok: + raise ValueError(f"source runtime report reference is conflicting or invalid: {reference_reason}") + + source_path = run_directory / "runtime-validation.azure-monitor-sdk.json" + graph_path = run_directory / "runtime-validation.graph.json" + consent_path = run_directory / "runtime-query-consent.json" + plan_path = run_directory / "runtime-query-plan.json" + source_hash = _sha256(source_path.read_bytes()) + original_state = state_path.read_bytes() + legacy_ref = artifacts.get("sentinelRuntimeReport") + canonical_ref = artifacts.get("sentinelRuntimeJson") + expected_ref = (source_path.relative_to(root)).as_posix() + already_current = canonical_ref == expected_ref + if canonical_ref is None and reference_reason == "provider-runtime-report-reference-current": + already_current = True + if canonical_ref is None: + action = "add-canonical-reference" + elif already_current: + action = "already-current" + else: + action = "normalize-canonical-reference" + result = { + "schemaVersion": "1.0.0", + "solution": root.name, + "runId": run_id, + "workflowStageStatus": validation.get("status"), + "action": action, + "eligible": True, + "reason": "complete current source and Graph evidence is bound to this run and target", + "targetFingerprintSha256": target_fingerprint, + "sourceProvider": sdk_provider.get("provider"), + "sourceReportPath": source_path.relative_to(root).as_posix(), + "sourceReportSha256": source_hash, + "runtimeQueryPlanSha256": _sha256(plan_path.read_bytes()), + "runtimeQueryConsentSha256": _sha256(consent_path.read_bytes()), + "graphRuntimeReportPath": graph_path.relative_to(root).as_posix(), + "graphRuntimeReportSha256": _sha256(graph_path.read_bytes()), + "sourceRuntimeReportSha256": source_hash, + "validationStatePath": state_path.relative_to(root).as_posix(), + "validationStateSha256": _sha256(original_state), + "existingCanonicalReference": canonical_ref, + "existingLegacyReference": legacy_ref, + "desiredCanonicalReference": expected_ref, + "stageStatusPreserved": validation.get("status"), + "stateArtifactsPreserved": sorted(artifacts), + } + result["planSha256"] = hashlib.sha256( + json.dumps(result, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + ).hexdigest() + return result + + +def plan_runtime_report_reference_repair(solution: str | Path, run_id: str) -> dict[str, Any]: + try: + return _build_reference_repair_plan(solution, run_id) + except (OSError, RuntimeError, ValueError, KeyError, TypeError, yaml.YAMLError) as exc: + return { + "schemaVersion": "1.0.0", + "solution": Path(solution).expanduser().resolve().name, + "runId": run_id, + "eligible": False, + "action": "rejected", + "reason": str(exc), + } + + +def _receipt_from_pending(root: Path, pending: dict[str, Any]) -> dict[str, Any]: + return { + "schemaVersion": "1.0.0", + "action": "canonical-reference-added", + "runId": pending["runId"], + "planSha256": pending["planSha256"], + "backupPath": pending["backupPath"], + "receiptPath": pending["receiptPath"], + "originalWorkflowStateSha256": pending["originalWorkflowStateSha256"], + "currentWorkflowStateSha256": pending["currentWorkflowStateSha256"], + "sourceReportPath": pending["sourceReportPath"], + "sourceReportSha256": pending["sourceReportSha256"], + "runtimeQueryPlanSha256": pending["runtimeQueryPlanSha256"], + "runtimeQueryConsentSha256": pending["runtimeQueryConsentSha256"], + "graphRuntimeReportPath": pending["graphRuntimeReportPath"], + "graphRuntimeReportSha256": pending["graphRuntimeReportSha256"], + "targetFingerprintSha256": pending["targetFingerprintSha256"], + "legacyReferencePreserved": pending["legacyReferencePreserved"], + "canonicalReference": pending["canonicalReference"], + "workflowStageStatusPreserved": pending["workflowStageStatusPreserved"], + } + + +def _pending_evidence_matches_plan( + pending: dict[str, Any], + plan: dict[str, Any], +) -> bool: + return all( + pending.get(pending_key) == plan.get(plan_key) + for pending_key, plan_key in ( + ("sourceReportPath", "sourceReportPath"), + ("sourceReportSha256", "sourceReportSha256"), + ("runtimeQueryPlanSha256", "runtimeQueryPlanSha256"), + ("runtimeQueryConsentSha256", "runtimeQueryConsentSha256"), + ("graphRuntimeReportPath", "graphRuntimeReportPath"), + ("graphRuntimeReportSha256", "graphRuntimeReportSha256"), + ("targetFingerprintSha256", "targetFingerprintSha256"), + ("canonicalReference", "desiredCanonicalReference"), + ("workflowStageStatusPreserved", "stageStatusPreserved"), + ) + ) + + +def _state_matches_pending_postimage( + root: Path, + state_path: Path, + backup_path: Path, + pending: dict[str, Any], +) -> bool: + current_bytes = state_path.read_bytes() + current_hash = _sha256(current_bytes) + if pending.get("currentWorkflowStateSha256"): + return current_hash == pending["currentWorkflowStateSha256"] + original_bytes = backup_path.read_bytes() + if _sha256(original_bytes) != pending["originalWorkflowStateSha256"]: + return False + original = json.loads(original_bytes) + expected = _migrate_workflow_state(deepcopy(original)) + context = expected.get("context") + if isinstance(context, dict): + context.setdefault("profileSelectionConfirmed", False) + expected["runId"] = state_path.parent.name + expected["updatedAt"] = json.loads(current_bytes).get("updatedAt") + artifacts = expected["stages"]["validation"].setdefault("artifacts", {}) + artifacts["sentinelRuntimeJson"] = pending["canonicalReference"] + return json.loads(current_bytes) == portable_artifact(root, expected) + + +def _finish_pending_transaction( + root: Path, + pending_path: Path, + receipt_path: Path, + pending: dict[str, Any], +) -> dict[str, Any]: + if pending.get("status") not in {"prepared", "state-written"}: + raise ValueError("runtime-reference transaction has an unknown recovery state") + receipt = _receipt_from_pending(root, pending) + if receipt_path.exists(): + existing = _read_json(receipt_path, "runtime-report reference repair receipt") + if existing != receipt: + raise ValueError("existing repair receipt conflicts with pending transaction") + else: + _atomic_json(receipt_path, receipt) + pending_path.unlink() + return receipt + + +def apply_runtime_report_reference_repair( + solution: str | Path, + run_id: str, + *, + expected_plan_sha256: str, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + from .workflow import _state_writer_lock + + with _state_writer_lock(root) as locked_state_path: + plan = _build_reference_repair_plan(root, run_id) + state_path, state = _load(root) + if state_path != locked_state_path: + raise ValueError("selected workflow state changed while acquiring the writer lock") + artifacts = ( + ((state.get("stages") or {}).get("validation") or {}).get("artifacts") or {} + ) + run_directory = report_directory(root) + repair_dir = run_directory / "runtime-report-reference-repair" + if plan.get("action") == "already-current-noop": + if repair_dir.is_dir(): + for candidate_path in repair_dir.glob("pending.*.json"): + candidate = _read_json( + candidate_path, + "runtime-report reference transaction", + ) + if candidate.get("runId") == run_id: + raise ValueError( + "pending runtime-reference transaction cannot be recovered " + "because current validation evidence is not passed" + ) + return { + "runId": run_id, + "action": "already-current-noop", + "eligible": False, + "noChange": True, + "workflowStageStatusPreserved": plan.get("stageStatusPreserved"), + "reason": plan.get("reason"), + } + if not plan.get("eligible"): + raise ValueError(plan.get("reason") or "runtime-report reference repair is ineligible") + pending_sha = expected_plan_sha256 + pending_path = repair_dir / f"pending.{pending_sha}.json" + receipt_path = repair_dir / f"receipt.{pending_sha}.json" + pending = ( + _read_json(pending_path, "runtime-report reference transaction") + if pending_path.is_file() + else None + ) + if pending is None and plan.get("action") == "already-current": + pending_paths = ( + sorted(repair_dir.glob("pending.*.json")) + if repair_dir.is_dir() + else [] + ) + if plan.get("planSha256") == expected_plan_sha256: + candidates = [] + for candidate_path in pending_paths: + candidate = _read_json( + candidate_path, + "runtime-report reference transaction", + ) + if ( + candidate.get("runId") == run_id + and _pending_evidence_matches_plan(candidate, plan) + and plan.get("workflowStageStatus") == "passed" + ): + candidates.append((candidate_path, candidate)) + if len(candidates) > 1: + raise ValueError( + "multiple matching pending runtime-reference transactions exist" + ) + if candidates: + pending_path, pending = candidates[0] + pending_sha = pending["planSha256"] + receipt_path = repair_dir / f"receipt.{pending_sha}.json" + elif any( + _read_json(path, "runtime-report reference transaction").get("runId") + == run_id + for path in pending_paths + ): + raise ValueError( + "pending runtime-reference transaction no longer matches current evidence" + ) + elif any( + _read_json(path, "runtime-report reference transaction").get("runId") + == run_id + for path in pending_paths + ): + raise ValueError( + "pending runtime-reference transaction exists; refresh the plan before recovery" + ) + if pending is not None: + original_hash = pending.get("originalWorkflowStateSha256") + if not isinstance(original_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", original_hash): + raise ValueError("pending runtime-reference transaction has an invalid preimage hash") + expected_backup = ( + repair_dir + / f"workflow-state.{original_hash}.json" + ) + if ( + pending.get("planSha256") != pending_sha + or pending_path.name != f"pending.{pending_sha}.json" + or not _pending_evidence_matches_plan(pending, plan) + or plan.get("workflowStageStatus") != "passed" + or pending.get("runId") != run_id + or pending.get("backupPath") + != expected_backup.relative_to(root).as_posix() + or pending.get("receiptPath") + != receipt_path.relative_to(root).as_posix() + ): + raise ValueError( + "pending runtime-reference transaction is stale or no longer eligible" + ) + backup_path = root / pending["backupPath"] + current_hash = _sha256(state_path.read_bytes()) + is_preimage = ( + current_hash == pending.get("originalWorkflowStateSha256") + and pending.get("status") == "prepared" + and not pending.get("currentWorkflowStateSha256") + ) + is_postimage = _state_matches_pending_postimage( + root, state_path, backup_path, pending, + ) + if not is_preimage and not is_postimage: + raise ValueError( + "pending runtime-reference transaction conflicts with current workflow state" + ) + if is_preimage: + artifacts = state["stages"]["validation"].setdefault("artifacts", {}) + artifacts["sentinelRuntimeJson"] = pending["canonicalReference"] + _write_unlocked(state_path, state) + current_hash = _sha256(state_path.read_bytes()) + pending["currentWorkflowStateSha256"] = current_hash + pending["status"] = "state-written" + _atomic_json(pending_path, pending) + elif not pending.get("currentWorkflowStateSha256"): + pending["currentWorkflowStateSha256"] = current_hash + pending["status"] = "state-written" + _atomic_json(pending_path, pending) + if pending.get("currentWorkflowStateSha256") != current_hash: + raise ValueError("workflow state changed during runtime-reference recovery") + return _finish_pending_transaction( + root, pending_path, receipt_path, pending, + ) + + if plan.get("action") == "already-current": + if artifacts.get("sentinelRuntimeJson") != plan.get("desiredCanonicalReference"): + raise ValueError("current canonical runtime report reference changed") + if receipt_path.is_file(): + receipt = _read_json(receipt_path, "runtime-report reference repair receipt") + if ( + receipt.get("currentWorkflowStateSha256") + == _sha256(state_path.read_bytes()) + and receipt.get("sourceReportSha256") == plan.get("sourceReportSha256") + and receipt.get("runtimeQueryPlanSha256") + == plan.get("runtimeQueryPlanSha256") + and receipt.get("runtimeQueryConsentSha256") + == plan.get("runtimeQueryConsentSha256") + and receipt.get("graphRuntimeReportSha256") + == plan.get("graphRuntimeReportSha256") + ): + return receipt + return { + "runId": run_id, + "action": "already-current", + "eligible": True, + "noChange": True, + "reason": "canonical runtime report reference already matches current evidence", + } + if plan["planSha256"] != expected_plan_sha256: + raise ValueError("runtime-report reference repair plan is stale; dry-run again") + if _sha256(state_path.read_bytes()) != plan["validationStateSha256"]: + raise ValueError("workflow state changed since repair planning") + source_path = root / plan["sourceReportPath"] + if _sha256(source_path.read_bytes()) != plan["sourceReportSha256"]: + raise ValueError("SDK report changed since repair planning") + + repair_dir.mkdir(exist_ok=True) + backup_path = repair_dir / f"workflow-state.{plan['validationStateSha256']}.json" + if backup_path.exists(): + if _sha256(backup_path.read_bytes()) != plan["validationStateSha256"]: + raise ValueError("existing workflow-state backup conflicts with this preimage") + else: + with backup_path.open("xb") as backup: + backup.write(state_path.read_bytes()) + backup.flush() + os.fsync(backup.fileno()) + + pending = { + "schemaVersion": "1.0.0", + "status": "prepared", + "runId": run_id, + "planSha256": plan["planSha256"], + "backupPath": backup_path.relative_to(root).as_posix(), + "receiptPath": receipt_path.relative_to(root).as_posix(), + "originalWorkflowStateSha256": plan["validationStateSha256"], + "currentWorkflowStateSha256": None, + "sourceReportPath": plan["sourceReportPath"], + "sourceReportSha256": plan["sourceReportSha256"], + "runtimeQueryPlanSha256": plan["runtimeQueryPlanSha256"], + "runtimeQueryConsentSha256": plan["runtimeQueryConsentSha256"], + "graphRuntimeReportPath": plan["graphRuntimeReportPath"], + "graphRuntimeReportSha256": plan["graphRuntimeReportSha256"], + "targetFingerprintSha256": plan["targetFingerprintSha256"], + "legacyReferencePreserved": plan["existingLegacyReference"], + "canonicalReference": plan["desiredCanonicalReference"], + "workflowStageStatusPreserved": plan["stageStatusPreserved"], + } + _atomic_json(pending_path, pending) + try: + artifacts = state["stages"]["validation"].setdefault("artifacts", {}) + artifacts["sentinelRuntimeJson"] = plan["desiredCanonicalReference"] + _write_unlocked(state_path, state) + pending["currentWorkflowStateSha256"] = _sha256(state_path.read_bytes()) + pending["status"] = "state-written" + _atomic_json(pending_path, pending) + return _finish_pending_transaction( + root, pending_path, receipt_path, pending, + ) + except OSError as exc: + raise RuntimeError( + "runtime-report reference transaction is pending recovery; " + f"preserved backup={backup_path} transaction={pending_path}" + ) from exc diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/solution_report.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/solution_report.py new file mode 100644 index 00000000000..39bc05fd7b1 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/solution_report.py @@ -0,0 +1,2013 @@ +from __future__ import annotations + +import json +import hashlib +import os +import re +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.parse import quote, unquote, urlsplit + +import yaml + +from .artifacts import existing_artifact_path, existing_artifacts, report_directory, resolve_artifact_reference, portable_artifact, write_json_artifact +from .converter import ( + analytic_rule_files, + custom_detail_binding_warnings, + solution_paths, + validate_document, +) +from .content_paths import content_path +from .deployment_planning import _consent_state as _validated_planning_consent +from .report_dashboard import render_dashboard +from .runtime_consent import runtime_query_consent_status +from .workflow import _package_freshness + +JSON_NAME = "migration-report.json" +HTML_NAME = "migration-report.html" +FLOW_STAGE_NAMES = ( + "setup", "discovery", "conversion", "validation", "packaging", "preflight", + "validationSuite", "deployment", "mockIngestion", "queryValidation", + "alertParity", "packageAcceptance", "report", +) + + +def _local_flow_evidence_link(root: Path, reports: Path, reference: Any) -> dict[str, Any]: + unavailable = {"available": False, "label": "Evidence reference unavailable or non-portable"} + if not isinstance(reference, str) or not reference: + return unavailable + try: + candidate = resolve_artifact_reference(root, reference) + if candidate.is_symlink(): + return unavailable + resolved = candidate.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + return unavailable + try: + run_root = reports.resolve(strict=True) + relative = resolved.relative_to(run_root) + lexical = Path(os.path.abspath(candidate)) + lexical_relative = lexical.relative_to(run_root) + except (OSError, RuntimeError, ValueError): + return unavailable + current = run_root + for part in lexical_relative.parts: + current = current / part + if current.is_symlink(): + return unavailable + if not resolved.is_file(): + return unavailable + href = quote(relative.as_posix(), safe="/-._~") + parsed = urlsplit(href) + decoded = unquote(href) + if ( + parsed.scheme or parsed.netloc or parsed.query or parsed.fragment + or href.startswith(("/", "\\")) or "\\" in href + or any(part in {"", ".", ".."} for part in PurePosixPath(href).parts) + or any(":" in part for part in PurePosixPath(decoded).parts) + ): + return unavailable + return {"available": True, "label": resolved.name, "href": href} + + +def _build_flow_evidence(root: Path, reports: Path, workflow: dict[str, Any], artifacts: dict[str, Any], package: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + result = {name: [] for name in FLOW_STAGE_NAMES} + + def add(stage: str, reference: Any) -> None: + link = _local_flow_evidence_link(root, reports, reference) + if link not in result[stage]: + result[stage].append(link) + if stage == "deployment" and isinstance(reference, str) and "preflight" in Path(reference.replace("\\", "/")).name.casefold(): + preflight_link = _local_flow_evidence_link(root, reports, reference) + if preflight_link not in result["preflight"]: + result["preflight"].append(preflight_link) + + for name, stage in (workflow.get("stages") or {}).items(): + if name in result and isinstance(stage, dict): + for reference in stage.get("evidence") or []: + add(name, reference) + if isinstance(reference, str): + base = Path(reference.replace("\\", "/")).name.casefold() + if "preflight" in base: + add("preflight", reference) + if ( + ("full" in base or "official" in base or "local" in base) + and ("validation" in base or "suite" in base) + ): + add("validationSuite", reference) + for reference in (artifacts.get("transformationReport"),): + if reference: + add("conversion", reference) + for runtime in artifacts.get("runtimeReports") or []: + if isinstance(runtime, dict): + for field in ("jsonReport", "htmlReport"): + if runtime.get(field): + add("validation", runtime[field]) + for field in ("deploymentReport", "analyticDeploymentReport"): + if artifacts.get(field): + add("deployment", artifacts[field]) + for field in ("queryParityReport",): + if artifacts.get(field): + add("queryValidation", artifacts[field]) + if artifacts.get("alertParityReport"): + add("alertParity", artifacts["alertParityReport"]) + if artifacts.get("packagingReport"): + add("packaging", artifacts["packagingReport"]) + for reference in artifacts.get("ingestionReports") or []: + add("mockIngestion", reference) + if artifacts.get("qualificationDcrPreflight"): + add("preflight", artifacts["qualificationDcrPreflight"]) + if artifacts.get("qualificationTarget"): + add("setup", artifacts["qualificationTarget"]) + for field in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + if package.get(field): + add("packaging", package[field]) + return result + + +def _package_resource_inventory(template: Any) -> list[dict[str, Any]]: + records: dict[tuple[str, str], dict[str, Any]] = {} + + def scan_document(node: Any, *, extension_resource: bool = False) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key == "resources": + scan_collection(value, extension_resource=False) + elif key == "extensionResources": + scan_collection(value, extension_resource=True) + else: + scan_document(value, extension_resource=extension_resource) + elif isinstance(node, list): + for value in node: + scan_document(value, extension_resource=extension_resource) + + def scan_resource(resource: Any, container: str, *, extension_resource: bool = False) -> None: + if not isinstance(resource, dict): + return + original_type = resource.get("type") + if isinstance(original_type, str): + resource_type, separator, embedded_version = original_type.partition("@") + if "/" in resource_type: + api_version = resource.get("apiVersion") + if not isinstance(api_version, str) or not api_version: + api_version = embedded_version if separator else "" + key = (resource_type, api_version) + record = records.setdefault(key, { + "type": resource_type, + "originalType": original_type, + "apiVersion": api_version, + "count": 0, + "sourceForms": set(), + "extensionResource": False, + }) + record["count"] += 1 + record["sourceForms"].add(container) + record["extensionResource"] = ( + record["extensionResource"] + or extension_resource + or resource.get("extensionResource") is True + ) + for key, value in resource.items(): + if key == "resources": + scan_collection(value, extension_resource=False) + elif key == "extensionResources": + scan_collection(value, extension_resource=True) + else: + scan_document(value, extension_resource=extension_resource) + + def scan_collection(resources: Any, *, extension_resource: bool = False) -> None: + if isinstance(resources, list): + for resource in resources: + scan_resource( + resource, + "extension resources collection" if extension_resource else "nested ARM resources array", + extension_resource=extension_resource, + ) + elif isinstance(resources, dict): + if isinstance(resources.get("type"), str): + scan_resource( + resources, + "extension resource object" if extension_resource else "ARM resource object", + extension_resource=extension_resource, + ) + else: + for resource in resources.values(): + scan_resource( + resource, + "languageVersion 2 extension resource dictionary" + if extension_resource else "languageVersion 2 resource dictionary", + extension_resource=extension_resource, + ) + + scan_document(template) + return [ + { + **record, + "sourceForms": sorted(record["sourceForms"]), + } + for _, record in sorted(records.items()) + ] + + +def _read_local_package_template(root: Path, package: dict[str, Any]) -> tuple[dict[str, Any], str | None]: + reference = package.get("mainTemplate") + if not isinstance(reference, str) or not reference: + return {}, None + try: + candidate = resolve_artifact_reference(root, reference) + if candidate.is_symlink(): + return {}, None + path = candidate.resolve(strict=True) + package_candidate = root / "Package" + if package_candidate.is_symlink(): + return {}, None + package_root = package_candidate.resolve(strict=True) + path.relative_to(package_root) + lexical = Path(os.path.abspath(candidate)) + lexical_relative = lexical.relative_to(package_candidate.absolute()) + current = package_candidate.absolute() + for part in lexical_relative.parts: + current = current / part + if current.is_symlink(): + return {}, None + if not path.is_file() or path.stat().st_size > 10_000_000: + return {}, None + content = path.read_bytes() + document = json.loads(content.decode("utf-8-sig")) + except (OSError, RuntimeError, UnicodeError, ValueError, json.JSONDecodeError): + return {}, None + return ( + document if isinstance(document, dict) else {}, + hashlib.sha256(content).hexdigest(), + ) + + +def _safe_plan_value(value: Any, depth: int = 0) -> Any: + if depth > 5: + return None + if isinstance(value, dict): + result = {} + for key, item in list(value.items())[:100]: + if not isinstance(key, str): + continue + lowered = key.casefold() + if any(term in lowered for term in ( + "path", "token", "secret", "password", "authorization", + )): + continue + cleaned = _safe_plan_value(item, depth + 1) + if cleaned is not None: + result[key[:120]] = cleaned + return result + if isinstance(value, list): + return [ + cleaned for item in value[:200] + if (cleaned := _safe_plan_value(item, depth + 1)) is not None + ] + if isinstance(value, str): + return value[:2000] + if isinstance(value, (bool, int, float)): + return value + return None + + +def _deployment_planning_input(root: Path, raw_workflow: dict[str, Any]) -> dict[str, Any]: + stages = raw_workflow.get("stages") + stages = stages if isinstance(stages, dict) else {} + context = raw_workflow.get("context") + context = context if isinstance(context, dict) else {} + for candidate in ( + raw_workflow.get("deploymentPlanning"), + context.get("deploymentPlanning"), + (stages.get("preflight") or {}).get("artifacts", {}).get("deploymentPlanning") + if isinstance(stages.get("preflight"), dict) + and isinstance(stages.get("preflight", {}).get("artifacts"), dict) + else None, + (stages.get("deployment") or {}).get("artifacts", {}).get("deploymentPlanning") + if isinstance(stages.get("deployment"), dict) + and isinstance(stages.get("deployment", {}).get("artifacts"), dict) + else None, + ): + if isinstance(candidate, dict) and candidate: + return candidate + + reports = report_directory(root) + persisted = _read_run_json(root, reports, reports / "deployment-scope-plan.json") + if persisted: + return persisted + + for stage_name in ("preflight", "deployment"): + stage = stages.get(stage_name) + if not isinstance(stage, dict): + continue + for reference in stage.get("evidence") or []: + if not isinstance(reference, str): + continue + if stage_name == "preflight" or "preflight" in Path(reference.replace("\\", "/")).name.casefold(): + document = _read_run_json(root, reports, reference) + candidate = document.get("deploymentPlanning") + if isinstance(candidate, dict) and candidate: + return candidate + return {} + + +def _planning_summary( + root: Path, + package: dict[str, Any], + workflow: dict[str, Any], + raw_workflow: dict[str, Any], +) -> dict[str, Any]: + template, template_sha256 = _read_local_package_template(root, package) + hashes = package.get("packageArtifactHashes") + recorded_template_hash = hashes.get("mainTemplate") if isinstance(hashes, dict) else None + hash_matches = ( + isinstance(template_sha256, str) + and isinstance(recorded_template_hash, str) + and template_sha256 == recorded_template_hash + ) + freshness = workflow.get("packageFreshness") + freshness_status = ( + freshness.get("status") + if isinstance(freshness, dict) and isinstance(freshness.get("status"), str) + else "unverified" + ) + freshness_current_input_hash = ( + freshness.get("currentPackageInputSha256") + if isinstance(freshness, dict) + and isinstance(freshness.get("currentPackageInputSha256"), str) + else None + ) + if freshness_status == "stale" or ( + isinstance(template_sha256, str) + and isinstance(recorded_template_hash, str) + and not hash_matches + ): + identity_status = "stale" + elif hash_matches and freshness_status == "current": + identity_status = "verified current" + elif template_sha256: + identity_status = "unverified" + else: + identity_status = "unavailable" + context_profile = workflow.get("profile") + mode = ( + context_profile + if context_profile in {"authoring", "qualification"} + and workflow.get("profileSelectionConfirmed") is True + else "unknown" + ) + parameters = package.get("deploymentParameters") + safe_defaults = ( + [ + {"name": str(name), "value": value} + for name, value in parameters.items() + if isinstance(name, str) and isinstance(value, (str, bool, int, float)) + ] + if isinstance(parameters, dict) + else [] + ) + raw_stages = raw_workflow.get("stages") + deployment_stage = raw_stages.get("deployment") if isinstance(raw_stages, dict) else None + stage_artifacts = deployment_stage.get("artifacts") if isinstance(deployment_stage, dict) else None + plan = _deployment_planning_input(root, raw_workflow) + raw_package_plan = ( + plan.get("packageAcceptancePlan") + if isinstance(plan.get("packageAcceptancePlan"), dict) + else stage_artifacts.get("packageAcceptancePlan") if isinstance(stage_artifacts, dict) else None + ) + package_plan = raw_package_plan if isinstance(raw_package_plan, dict) else {} + resource_types = package_plan.get("resourceTypes") + safe_types = sorted({ + str(item).split("@", 1)[0] + for item in resource_types + if isinstance(item, str) and "/" in item.split("@", 1)[0] + }) if isinstance(resource_types, list) else [] + plan_identity = package_plan.get("packageIdentity") + safe_plan_identity = ( + { + key: plan_identity[key] + for key in ("status", "sha256", "packageArtifactsSha256") + if isinstance(plan_identity.get(key), (str, bool, int)) + } + if isinstance(plan_identity, dict) + else {} + ) + plan_sha = plan.get("planSha256") if isinstance(plan.get("planSha256"), str) else None + raw_consent = plan.get("planningConsent") + raw_consent = raw_consent if isinstance(raw_consent, dict) else {} + consent_status = raw_consent.get("status") + consent_plan_sha = raw_consent.get("planSha256") + consent_components = raw_consent.get("approvedComponents") + if not isinstance(consent_components, list): + consent_components = raw_consent.get("componentKeys") + consent_types = raw_consent.get("resourceTypes") + selected_flags = raw_consent.get("packageFlags") + if not isinstance(selected_flags, dict): + selected_flags = raw_consent.get("selectedFlags") + consent_component_keys = ( + [ + value for value in consent_components + if isinstance(value, str) and value + ] + if isinstance(consent_components, list) + else [] + ) + consent_resource_types = ( + [ + value for value in consent_types + if isinstance(value, str) and "/" in value.split("@", 1)[0] + ] + if isinstance(consent_types, list) + else [] + ) + raw_components = plan.get("components") + raw_components = raw_components if isinstance(raw_components, dict) else {} + current_component_keys = { + key for key, value in raw_components.items() + if isinstance(key, str) and isinstance(value, dict) + } + expected_consent_types = sorted({ + resource_type + for component_key in consent_component_keys + for resource_type in ( + raw_components.get(component_key, {}).get("resourceTypes") or [] + if isinstance(raw_components.get(component_key), dict) + else [] + ) + if isinstance(resource_type, str) + }) + consent_flags_valid = isinstance(selected_flags, dict) and any( + isinstance(key, str) and isinstance(value, (str, bool, int)) + for key, value in selected_flags.items() + ) + if consent_status == "approved": + if not ( + isinstance(plan_sha, str) + and re.fullmatch(r"[0-9a-fA-F]{64}", plan_sha) + and isinstance(consent_plan_sha, str) + and re.fullmatch(r"[0-9a-fA-F]{64}", consent_plan_sha) + and plan_sha.casefold() == consent_plan_sha.casefold() + and bool(consent_component_keys) + and set(consent_component_keys).issubset(current_component_keys) + and expected_consent_types == sorted(consent_resource_types) + and ( + isinstance(target := plan.get("target"), dict) + and all( + isinstance(target.get(key), str) and target.get(key) + for key in ( + "tenantId", "subscriptionId", "resourceGroup", + "workspaceResourceId", "solution", "version", + ) + ) + ) + and (bool(consent_resource_types) or consent_flags_valid or consent_component_keys) + ): + consent_status = "stale" + elif consent_status not in { + "declined", "stale", "not-recorded", "not-approved", "unknown", + }: + consent_status = "not-recorded" + components = raw_components + target = plan.get("target") if isinstance(plan.get("target"), dict) else {} + source_inventory = plan.get("sourceInventory") if isinstance(plan.get("sourceInventory"), dict) else {} + detection_scope = plan.get("detectionScope") if isinstance(plan.get("detectionScope"), dict) else {} + scope_caps = plan.get("scopeCaps") if isinstance(plan.get("scopeCaps"), dict) else {} + planned_resource_inventory = plan.get("packageResourceInventory") + planned_resource_inventory = ( + _safe_plan_value(planned_resource_inventory) + if isinstance(planned_resource_inventory, list) + else [] + ) + package_acceptance = plan.get("packageAcceptancePlan") + package_acceptance = ( + _safe_plan_value(package_acceptance) + if isinstance(package_acceptance, dict) + else {} + ) + artifact_hashes = plan.get("packageArtifactHashes") if isinstance(plan.get("packageArtifactHashes"), dict) else {} + recorded_artifact_hashes = ( + plan.get("recordedPackageArtifactHashes") + if isinstance(plan.get("recordedPackageArtifactHashes"), dict) + else {} + ) + contract_input_hash = plan.get("packageInputSha256") + current_input_hash = ( + package.get("packageInputs", {}).get("after", {}).get("sha256") + if isinstance(package.get("packageInputs"), dict) + and isinstance(package.get("packageInputs", {}).get("after"), dict) + else None + ) + input_identity_matches = ( + isinstance(contract_input_hash, str) + and isinstance(current_input_hash, str) + and contract_input_hash.casefold() == current_input_hash.casefold() + ) + package_report_hashes = package.get("packageArtifactHashes") + required_package_hashes = {"mainTemplate", "createUiDefinition", "testParameters", "zip"} + valid_current_hashes = ( + required_package_hashes.issubset(artifact_hashes) + and all( + isinstance(artifact_hashes.get(key), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", artifact_hashes[key]) + for key in required_package_hashes + ) + and required_package_hashes.issubset(recorded_artifact_hashes) + and required_package_hashes.issubset(package_report_hashes or {}) + ) if isinstance(package_report_hashes, dict) else False + artifact_identity_matches = bool( + valid_current_hashes + and all( + artifact_hashes.get(key) == recorded_artifact_hashes.get(key) + == package_report_hashes.get(key) + for key in required_package_hashes + ) + and isinstance(template_sha256, str) + and template_sha256 == artifact_hashes.get("mainTemplate") + ) + hash_pattern = r"[0-9a-fA-F]{64}" + report_input_hash_valid = ( + isinstance(current_input_hash, str) + and re.fullmatch(hash_pattern, current_input_hash) is not None + ) + freshness_input_hash_valid = ( + isinstance(freshness_current_input_hash, str) + and re.fullmatch(hash_pattern, freshness_current_input_hash) is not None + ) + known_input_mismatch = bool( + report_input_hash_valid + and freshness_input_hash_valid + and current_input_hash.casefold() != freshness_current_input_hash.casefold() + ) + report_template_hash = ( + package_report_hashes.get("mainTemplate") + if isinstance(package_report_hashes, dict) + else None + ) + template_hash_mismatch = bool( + isinstance(template_sha256, str) + and isinstance(report_template_hash, str) + and re.fullmatch(hash_pattern, report_template_hash) + and template_sha256.casefold() != report_template_hash.casefold() + ) + if freshness_status == "stale" or known_input_mismatch or template_hash_mismatch: + package_evidence_status = "stale" + elif ( + freshness_status == "current" + and report_input_hash_valid + and freshness_input_hash_valid + and current_input_hash.casefold() == freshness_current_input_hash.casefold() + and isinstance(template_sha256, str) + and isinstance(report_template_hash, str) + and re.fullmatch(hash_pattern, report_template_hash) + and template_sha256.casefold() == report_template_hash.casefold() + ): + package_evidence_status = "current" + else: + package_evidence_status = "unknown" + + if plan and isinstance(plan_sha, str): + consent_from_artifact = _validated_planning_consent( + root, + plan_sha, + components=raw_components, + package={ + "status": ( + "current" if package_evidence_status == "current" + else package_evidence_status + ), + "resourceInventory": ( + plan.get("packageResourceInventory") + if isinstance(plan.get("packageResourceInventory"), list) + else [] + ), + }, + target=plan.get("target") if isinstance(plan.get("target"), dict) else {}, + ) + if isinstance(consent_from_artifact, dict): + raw_consent = consent_from_artifact + consent_status = consent_from_artifact.get("status") + consent_plan_sha = consent_from_artifact.get("planSha256") + consent_components = consent_from_artifact.get("approvedComponents") + consent_component_keys = ( + [ + value for value in consent_components + if isinstance(value, str) and value + ] + if isinstance(consent_components, list) + else [] + ) + consent_resource_types = ( + [ + value for value in consent_from_artifact.get("resourceTypes", []) + if isinstance(value, str) and "/" in value.split("@", 1)[0] + ] + if isinstance(consent_from_artifact.get("resourceTypes"), list) + else [] + ) + selected_flags = consent_from_artifact.get("packageFlags") + if not isinstance(selected_flags, dict): + selected_flags = {} + expected_consent_types = sorted({ + resource_type + for component_key in consent_component_keys + for resource_type in ( + raw_components.get(component_key, {}).get("resourceTypes") or [] + if isinstance(raw_components.get(component_key), dict) + else [] + ) + if isinstance(resource_type, str) + }) + if consent_status == "approved" and not ( + isinstance(plan_sha, str) + and re.fullmatch(r"[0-9a-fA-F]{64}", plan_sha) + and isinstance(consent_plan_sha, str) + and re.fullmatch(r"[0-9a-fA-F]{64}", consent_plan_sha) + and plan_sha.casefold() == consent_plan_sha.casefold() + and bool(consent_component_keys) + and set(consent_component_keys).issubset(current_component_keys) + and expected_consent_types == sorted(consent_resource_types) + and isinstance(plan.get("target"), dict) + ): + consent_status = "unknown" + + safe_flags = ( + { + key: value + for key, value in selected_flags.items() + if isinstance(key, str) and isinstance(value, (str, bool, int)) + } + if isinstance(selected_flags, dict) + else {} + ) + contract_status = plan.get("status") if isinstance(plan.get("status"), str) else "not-recorded" + if contract_status not in { + "current", "provisional", "stale", "unavailable", "not-applicable", + "not-recorded", "unknown", + }: + contract_status = "unknown" + contract_input_hash_valid = ( + isinstance(contract_input_hash, str) + and re.fullmatch(hash_pattern, contract_input_hash) is not None + ) + plan_package_identity_status = ( + plan.get("packageIdentity", {}).get("status") + if isinstance(plan.get("packageIdentity"), dict) + and isinstance(plan["packageIdentity"].get("status"), str) + else None + ) + if plan and package_evidence_status == "stale": + contract_status = "stale" + elif plan and package_evidence_status == "unknown" and contract_status not in { + "not-applicable", "unavailable", "not-recorded", + }: + contract_status = "unknown" + elif contract_status == "current": + if not ( + report_input_hash_valid + and freshness_input_hash_valid + and contract_input_hash_valid + and isinstance(artifact_hashes, dict) + and isinstance(recorded_artifact_hashes, dict) + and isinstance(package_report_hashes, dict) + ): + contract_status = "unknown" + elif ( + not input_identity_matches + or contract_input_hash.casefold() != freshness_current_input_hash.casefold() + ): + contract_status = "stale" + elif not valid_current_hashes: + contract_status = "unknown" + elif not artifact_identity_matches: + contract_status = "stale" + if plan_package_identity_status == "stale": + contract_status = "stale" + elif plan_package_identity_status not in {None, "current", "verified current", "stale"}: + if contract_status == "current": + contract_status = "unknown" + recorded_profile = plan.get("selectedProfile") + if ( + mode in {"authoring", "qualification"} + and isinstance(recorded_profile, str) + and recorded_profile != mode + ): + contract_status = "stale" + if consent_status == "approved" and ( + contract_status not in {"current", "provisional"} + or package_evidence_status != "current" + ): + consent_status = ( + "stale" + if contract_status == "stale" or package_evidence_status == "stale" + else "unknown" + ) + + if package_evidence_status == "stale" or plan_package_identity_status == "stale": + final_package_identity_status = "stale" + elif ( + package_evidence_status == "unknown" + or plan_package_identity_status == "unknown" + or contract_status == "unknown" + ): + final_package_identity_status = "unknown" + elif package_evidence_status == "current" and hash_matches: + final_package_identity_status = "verified current" + elif identity_status == "stale": + final_package_identity_status = "stale" + else: + final_package_identity_status = "unknown" + + safe_components = {} + for key, item in components.items(): + if not isinstance(key, str) or not isinstance(item, dict): + continue + safe_component = _safe_plan_value(item) + if not isinstance(safe_component, dict): + continue + raw_component_consent = item.get("planningConsent") + raw_component_consent = ( + raw_component_consent.get("status") + if isinstance(raw_component_consent, dict) + else raw_component_consent + ) + if item.get("advanceConsentSupported") is False: + component_consent_status = "not-supported" + elif consent_status in {"stale", "unknown"} and ( + key in consent_component_keys or raw_component_consent == "approved" + ): + component_consent_status = consent_status + elif consent_status == "approved" and key in consent_component_keys: + component_consent_status = "approved" + elif raw_component_consent in { + "unknown", "stale", "declined", "not-approved", "not-recorded", + }: + component_consent_status = raw_component_consent + else: + component_consent_status = "not-recorded" + safe_component["planningConsent"] = component_consent_status + safe_components[key] = safe_component + + package_acceptance_approval = package_acceptance.get("approvalStatus") + if ( + consent_status == "approved" + and "fullPackageAcceptance" in consent_component_keys + ): + package_acceptance["approvalStatus"] = "approved" + package_acceptance["selectedFlags"] = safe_flags + elif consent_status == "approved": + package_acceptance["approvalStatus"] = "not-approved" + elif package_acceptance_approval == "approved": + if contract_status == "stale" or package_evidence_status == "stale": + package_acceptance["approvalStatus"] = "stale" + elif ( + contract_status not in {"current", "provisional"} + or package_evidence_status != "current" + ): + package_acceptance["approvalStatus"] = "unknown" + elif consent_status in {"not-recorded", "not-approved", "declined"}: + package_acceptance["approvalStatus"] = consent_status + package_acceptance_identity = package_acceptance.get("packageIdentity") + if isinstance(package_acceptance_identity, dict): + package_acceptance_identity["status"] = final_package_identity_status + elif plan_package_identity_status is not None: + package_acceptance["packageIdentity"] = { + "status": final_package_identity_status, + } + safe_plan_identity = { + **safe_plan_identity, + "status": final_package_identity_status, + } + projected_freshness_status = package_evidence_status + planning_contract = { + "schemaVersion": plan.get("schemaVersion") if isinstance(plan.get("schemaVersion"), str) else None, + "status": contract_status, + "selectedProfile": plan.get("selectedProfile") if isinstance(plan.get("selectedProfile"), str) else mode, + "executionMode": ( + raw_consent.get("executionMode") + if consent_status == "approved" + and raw_consent.get("executionMode") in { + "pairs", "package", "both", "ingestion-or-provisioning", + } + else plan.get("executionMode") + if isinstance(plan.get("executionMode"), str) + and plan.get("executionMode") in { + "pairs", "package", "both", "ingestion-or-provisioning", "not-selected", + } + else "not-selected" + ), + "target": { + key: target.get(key) + for key in ( + "tenantId", "subscriptionId", "resourceGroup", "workspaceResourceId", + "workspaceCustomerId", "solution", "version", + ) + if isinstance(target.get(key), str) + }, + "planSha256": plan_sha, + "packageInputSha256": contract_input_hash if isinstance(contract_input_hash, str) else None, + "packageArtifactHashes": { + key: artifact_hashes.get(key) + for key in ("mainTemplate", "createUiDefinition", "testParameters", "zip") + if isinstance(artifact_hashes.get(key), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", artifact_hashes[key]) + }, + "recordedPackageArtifactHashes": { + key: recorded_artifact_hashes.get(key) + for key in ("mainTemplate", "createUiDefinition", "testParameters", "zip") + if isinstance(recorded_artifact_hashes.get(key), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", recorded_artifact_hashes[key]) + }, + "packageContentSha256": ( + plan.get("packageContentSha256") + if isinstance(plan.get("packageContentSha256"), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", plan["packageContentSha256"]) + else None + ), + "packageIdentity": { + "version": ( + plan.get("packageIdentity", {}).get("version") + if isinstance(plan.get("packageIdentity"), dict) + and isinstance(plan["packageIdentity"].get("version"), str) + else package.get("version") if isinstance(package.get("version"), str) else None + ), + "status": final_package_identity_status, + }, + "sourceInventory": { + key: value for key, value in source_inventory.items() + if isinstance(key, str) and isinstance(value, (str, int, bool)) + }, + "sourceInventorySha256": ( + plan.get("sourceInventorySha256") + if isinstance(plan.get("sourceInventorySha256"), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", plan["sourceInventorySha256"]) + else None + ), + "detectionScope": _safe_plan_value(detection_scope), + "scopeCaps": { + key: value for key, value in scope_caps.items() + if isinstance(key, str) and isinstance(value, (str, bool, int)) + }, + "packageResourceInventory": planned_resource_inventory, + "bicepExtensibilityImports": _safe_plan_value(plan.get("bicepExtensibilityImports") or []), + "whatIfSupport": ( + plan.get("whatIfSupport") + if isinstance(plan.get("whatIfSupport"), str) + else "not-evaluated" + ), + "components": safe_components, + "planningConsent": { + "status": consent_status or "not-recorded", + "approvedAt": raw_consent.get("approvedAt") if isinstance(raw_consent.get("approvedAt"), str) else None, + "actor": raw_consent.get("actor") if isinstance(raw_consent.get("actor"), str) else None, + "planSha256": consent_plan_sha if isinstance(consent_plan_sha, str) else None, + "componentKeys": [ + key for key in consent_component_keys + ], + "resourceTypes": consent_resource_types, + "selectedFlags": safe_flags, + "maxAdditionalCustomDetections": ( + raw_consent.get("maxAdditionalCustomDetections") + if type(raw_consent.get("maxAdditionalCustomDetections")) is int + else None + ), + "conditionalSelections": _safe_plan_value( + raw_consent.get("conditionalSelections") + if isinstance(raw_consent.get("conditionalSelections"), dict) + else {} + ), + }, + "exactScopeWriteApproval": "not-recorded", + "readiness": plan.get("readiness") if isinstance(plan.get("readiness"), str) else "not-evaluated", + "deploymentEvidence": plan.get("deploymentEvidence") if isinstance(plan.get("deploymentEvidence"), str) else "not-recorded", + "dcrEvidence": _safe_plan_value(plan.get("dcrEvidence") or {}), + "dcrResolver": _safe_plan_value(plan.get("dcrResolver") or {}), + "separateApprovals": _safe_plan_value(plan.get("separateApprovals") or {}), + "packageAcceptancePlan": package_acceptance, + } + return { + "profile": mode, + "executionMode": planning_contract["executionMode"], + "planningConsent": planning_contract["planningConsent"]["status"], + "exactScopeWriteApproval": planning_contract["exactScopeWriteApproval"], + "deploymentPlan": planning_contract, + "packageFreshness": projected_freshness_status, + "packageIdentity": final_package_identity_status, + "packageVersion": ( + plan.get("packageIdentity", {}).get("version") + if isinstance(plan.get("packageIdentity"), dict) + and isinstance(plan["packageIdentity"].get("version"), str) + else package.get("version") if isinstance(package.get("version"), str) else None + ), + "selectedPackageReport": package.get("packageReport") if isinstance(package.get("packageReport"), str) else None, + "selectedPackageReportStatus": package.get("status") if isinstance(package.get("status"), str) else "not-recorded", + "templateSha256": template_sha256, + "recordedTemplateSha256": recorded_template_hash, + "packageArtifactsSha256": package.get("packageArtifactsSha256"), + "packageInputSha256": ( + current_input_hash if isinstance(current_input_hash, str) else None + ), + "currentPackageInputSha256": ( + freshness_current_input_hash + if freshness_input_hash_valid + else None + ), + "resourceInventory": _package_resource_inventory(template), + "resourceInventoryAvailable": bool(template_sha256 and template), + "deploymentDefaults": safe_defaults, + "packageAcceptancePlan": { + "recorded": bool(package_plan), + **( + package_acceptance + if package_acceptance + else { + "status": ( + package_plan.get("status") + if isinstance(package_plan.get("status"), str) + else "proposed-not-approved" if package_plan else "not-recorded" + ), + "resourceTypes": safe_types, + "packageIdentity": safe_plan_identity, + "executionStatus": ( + package_plan.get("executionStatus") + if isinstance(package_plan.get("executionStatus"), str) + else "not-recorded" + ), + "approvalStatus": ( + package_plan.get("approvalStatus") + if isinstance(package_plan.get("approvalStatus"), str) + else "not-recorded" + ), + "planningConsent": ( + package_plan.get("planningConsent") + if isinstance(package_plan.get("planningConsent"), str) + else "not-recorded" + ), + "executionMode": ( + package_plan.get("executionMode") + if isinstance(package_plan.get("executionMode"), str) + else "not-recorded" + ), + } + ), + }, + "additionalQualificationResources": [], + "additionalQualificationPlanStatus": "recorded in component plan" if safe_components else "not-recorded", + } + + +def _preflight_projection(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + authentication = value.get("authentication") + permissions = value.get("permissions") + read_checks = value.get("readChecks") + dcr = value.get("dcrPermissionPreflight") + if not isinstance(dcr, dict) and "workspaceQueryPermission" in value: + dcr = value + dcr = dcr if isinstance(dcr, dict) else {} + dcr_auth = dcr.get("authentication") + contracts = dcr.get("contracts") + return { + "status": value.get("status") if isinstance(value.get("status"), str) else "unknown", + "graphRecorded": isinstance(authentication, dict) and isinstance(read_checks, list), + "dcrRecorded": isinstance(value.get("dcrPermissionPreflight"), dict) + or isinstance(value.get("workspaceQueryPermission"), dict), + "checkedAt": value.get("checkedAt") if isinstance(value.get("checkedAt"), str) else value.get("generatedAt") if isinstance(value.get("generatedAt"), str) else None, + "authentication": { + key: authentication.get(key) + for key in ( + "status", "method", "selectedMethod", "requestedScope", + "tenantMatch", "graphAudience", + ) + if isinstance(authentication, dict) and isinstance(authentication.get(key), (str, int, bool)) + }, + "permissions": { + key: permissions.get(key) + for key in ("readAccess", "writeScopeInToken", "consent", "writeAuthorization") + if isinstance(permissions, dict) and isinstance(permissions.get(key), (str, int, bool)) + }, + "requiredManagementPermission": ( + value.get("requiredManagementPermission") + if isinstance(value.get("requiredManagementPermission"), str) + else None + ), + "tokenPermissionClaims": { + key: value.get("authentication", {}).get("tokenPermissionClaims", {}).get(key) + for key in ("status", "claimType") + if isinstance(value.get("authentication"), dict) + and isinstance(value["authentication"].get("tokenPermissionClaims"), dict) + and isinstance(value["authentication"]["tokenPermissionClaims"].get(key), (str, int, bool)) + }, + "managementPermissionGrant": { + key: grant.get(key) + for key in ("status", "grantType", "claimType", "permission", "credentialRequestScope", "reason") + if isinstance(grant, dict) and isinstance(grant.get(key), (str, int, bool)) + } if isinstance((grant := value.get("managementPermissionGrant")), dict) else {}, + "deploymentReadiness": ( + value.get("deploymentReadiness") + if isinstance(value.get("deploymentReadiness"), str) + else "unknown" + ), + "effectiveRoleStatus": ( + permissions.get("effectiveRole", {}).get("status") + if isinstance(permissions, dict) + and isinstance(permissions.get("effectiveRole"), dict) + and isinstance(permissions["effectiveRole"].get("status"), str) + else "unknown" + ), + "readChecks": [ + { + key: check.get(key) + for key in ("status", "httpStatus") + if isinstance(check, dict) and isinstance(check.get(key), (str, int, bool)) + } + for check in read_checks if isinstance(check, dict) + ] if isinstance(read_checks, list) else [], + "dcr": { + "status": dcr.get("status") if isinstance(dcr.get("status"), str) else "unknown", + "authenticationStatus": ( + dcr_auth.get("status") if isinstance(dcr_auth, dict) and isinstance(dcr_auth.get("status"), str) else "unknown" + ), + "targetVerificationStatus": ( + dcr.get("targetVerification", {}).get("status") + if isinstance(dcr.get("targetVerification"), dict) + and isinstance(dcr["targetVerification"].get("status"), str) + else "unknown" + ), + "localReadinessStatus": ( + dcr.get("localReadiness", {}).get("status") + if isinstance(dcr.get("localReadiness"), dict) + and isinstance(dcr["localReadiness"].get("status"), str) + else "unknown" + ), + "workspaceQueryPermissionStatus": ( + dcr.get("workspaceQueryPermission", {}).get("status") + if isinstance(dcr.get("workspaceQueryPermission"), dict) + and isinstance(dcr["workspaceQueryPermission"].get("status"), str) + else "unknown" + ), + "resourcePlanStatus": "not-recorded", + "resourceTypes": [], + "contractCount": len(contracts) if isinstance(contracts, list) else 0, + "contractStatuses": [ + item.get("status") for item in contracts + if isinstance(item, dict) and isinstance(item.get("status"), str) + ] if isinstance(contracts, list) else [], + }, + } + + +def _read_run_json(root: Path, reports: Path, reference: Any) -> dict[str, Any]: + link = _local_flow_evidence_link(root, reports, str(reference) if isinstance(reference, Path) else reference) + if link.get("available") is not True or not str(link.get("label", "")).lower().endswith(".json"): + return {} + try: + path = reports / PurePosixPath(unquote(link["href"])) + if path.stat().st_size > 2_000_000: + return {} + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def _selected_packaging_report( + root: Path, + workflow: dict[str, Any], +) -> tuple[dict[str, Any], str | None]: + reports = report_directory(root) + stages = workflow.get("stages") + stages = stages if isinstance(stages, dict) else {} + packaging_stage = stages.get("packaging") + packaging_stage = packaging_stage if isinstance(packaging_stage, dict) else {} + artifacts = packaging_stage.get("artifacts") + artifacts = artifacts if isinstance(artifacts, dict) else {} + + selected_reference = artifacts.get("packageReport") + if "packaging" in stages: + package_state = workflow.get("packaging") + successful = ( + package_state.get("lastSuccessful") + if isinstance(package_state, dict) + else None + ) + if not ( + packaging_stage.get("status") == "passed" + and isinstance(successful, dict) + and successful.get("packageSucceeded") is True + and isinstance(successful.get("attemptId"), str) + and artifacts.get("packageAttemptId") == successful.get("attemptId") + and isinstance(selected_reference, str) + and selected_reference + and selected_reference == successful.get("packageReport") + ): + return {}, None + try: + selected_path = resolve_artifact_reference(root, selected_reference) + successful_path = resolve_artifact_reference( + root, successful["packageReport"] + ) + if selected_path.resolve() != successful_path.resolve(): + return {}, None + document = _read_run_json(root, reports, selected_reference) + report_sha256 = hashlib.sha256(selected_path.read_bytes()).hexdigest() + except (OSError, RuntimeError, ValueError, KeyError): + return {}, None + report_inputs = document.get("packageInputs") + report_after = ( + report_inputs.get("after") + if isinstance(report_inputs, dict) + else None + ) + recorded_hashes = document.get("packageArtifactHashes") + if not ( + document.get("packager") == "V3.1" + and document.get("packageAttemptId") == successful.get("attemptId") + and document.get("workflowRunId") == workflow.get("runId") + and report_sha256 == successful.get("packageReportSha256") + and report_sha256 == artifacts.get("packageReportSha256") + and document.get("packageArtifactsSha256") + == successful.get("packageArtifactsSha256") + and isinstance(report_after, dict) + and report_after.get("sha256") == successful.get("packageInputSha256") + and isinstance(recorded_hashes, dict) + and all( + isinstance(recorded_hashes.get(name), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", recorded_hashes[name]) + for name in ( + "mainTemplate", "createUiDefinition", + "testParameters", "zip", + ) + ) + ): + return {}, None + return document, selected_reference + + if isinstance(selected_reference, str) and selected_reference: + return _read_run_json(root, reports, selected_reference), selected_reference + + for reference in packaging_stage.get("evidence") or []: + if ( + isinstance(reference, str) + and Path(reference.replace("\\", "/")).name.casefold().startswith("packaging.v3_1.") + ): + document = _read_run_json(root, reports, reference) + if document: + return document, reference + + if "packaging" in stages: + return {}, None + + legacy_path = existing_artifact_path(root, "packaging.v3_1.json") + document = _read_run_json(root, reports, legacy_path) + if document: + return document, str(legacy_path) + return {}, None + + +def _qualification_preflight( + root: Path, + reports: Path, + workflow: dict[str, Any], +) -> dict[str, Any]: + dcr_reference = "qualification-dcr-preflight.json" + dcr_file = reports / dcr_reference + dcr_document = _read_run_json(root, reports, dcr_reference) + graph = {} + for stage in (workflow.get("stages") or {}).values(): + if not isinstance(stage, dict): + continue + for reference in stage.get("evidence") or []: + if isinstance(reference, str) and "preflight" in Path(reference.replace("\\", "/")).name.casefold(): + candidate = _read_run_json(root, reports, reference) + if isinstance(candidate.get("authentication"), dict) and isinstance(candidate.get("readChecks"), list): + graph = candidate + break + if graph: + break + dcr = dcr_document.get("dcrPermissionPreflight") if isinstance(dcr_document.get("dcrPermissionPreflight"), dict) else dcr_document + projected = _preflight_projection(graph) if graph else {} + if dcr: + projected["dcr"] = _preflight_projection( + {"dcrPermissionPreflight": dcr} + )["dcr"] + if not projected: + projected = _preflight_projection({}) + projected["graphRecorded"] = bool(graph) + projected["dcrRecorded"] = bool(dcr) + return projected + + +def _startup_setup_summary( + root: Path, + reports: Path, + workflow: dict[str, Any], + preflight: dict[str, Any], + planning: dict[str, Any], +) -> dict[str, Any]: + context = workflow.get("context") if isinstance(workflow.get("context"), dict) else {} + target_document = _read_run_json( + root, reports, reports / "qualification-target.json" + ) + context_target = context.get("target") if isinstance(context.get("target"), dict) else context + recorded_target = target_document if target_document else context_target + target = { + key: recorded_target.get(key) + for key in ( + "tenantId", "subscriptionId", "resourceGroup", "workspaceResourceId", + "workspaceCustomerId", "workspaceName", + ) + if isinstance(recorded_target.get(key), str) + } + target_locked = ( + target_document.get("locked") is True + or context.get("targetContextLocked") is True + or context.get("lockedTargetConfirmed") is True + ) + profile = context.get("workflowProfile") + profile_confirmed = ( + profile in {"authoring", "qualification"} + and context.get("profileSelectionConfirmed") is True + ) + consent = ( + planning.get("deploymentPlan", {}).get("planningConsent", {}) + if isinstance(planning.get("deploymentPlan"), dict) + else {} + ) + preflight_auth = ( + preflight.get("authentication") + if isinstance(preflight.get("authentication"), dict) + else {} + ) + preflight_at = preflight.get("checkedAt") + if preflight.get("graphRecorded") is True: + auth_state = "Recorded from a scoped preflight; not rechecked by report generation" + else: + auth_state = "Not checked in this selected-run snapshot" + checks = [] + if not target: + checks.append("No locked target details are recorded in workflow metadata or qualification-target.json.") + elif not target_locked: + checks.append("Target identifiers are recorded, but a locked-target confirmation is not recorded.") + if not profile_confirmed: + checks.append("Workflow profile selection is not confirmed in persisted run state.") + if not preflight.get("graphRecorded"): + checks.append("Cached authentication, scoped read access, and management permissions were not checked by this report.") + return { + "state": "unknown", + "display": ( + "Target/profile recorded; runtime checks unknown" + if target and profile_confirmed + else "Setup evidence incomplete" + ), + "runId": workflow.get("runId") if isinstance(workflow.get("runId"), str) else None, + "profile": profile if profile_confirmed else "not-recorded", + "profileConfirmed": profile_confirmed, + "target": target, + "targetSource": ( + "qualification-target.json" + if target_document + else "workflow-state metadata" + if target + else "not-recorded" + ), + "targetLocked": target_locked, + "localToolChecks": "not-recorded", + "authentication": { + "status": auth_state, + "method": preflight_auth.get("method") or preflight_auth.get("selectedMethod"), + "checkedAt": preflight_at, + }, + "planningConsentStatus": ( + consent.get("status") + if isinstance(consent, dict) and isinstance(consent.get("status"), str) + else "not-recorded" + ), + "planningConsentPlanSha256": ( + consent.get("planSha256") + if isinstance(consent, dict) and isinstance(consent.get("planSha256"), str) + else None + ), + "exactScopeWriteApproval": "not-recorded", + "notes": checks, + } + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + value = json.loads(path.read_text(encoding="utf-8-sig")) + return value if isinstance(value, dict) else {} + + +def _partial_selection_parity_matches( + selection_plan: dict[str, Any], + parity: dict[str, Any], + applied_plan: dict[str, Any] | None, + *, + stage_passed: bool, +) -> tuple[bool, str]: + current_plan_sha = ( + applied_plan.get("planSha256") if isinstance(applied_plan, dict) else None + ) + expected_paths = sorted( + Path(str(value).replace("\\", "/")) + .relative_to("XDR Detections").as_posix() + for value in (selection_plan.get("selection") or {}).get( + "xdrDetections", [] + ) + ) + expected_sources = sorted({ + str(item.get("sourceId") or "").casefold() + for item in selection_plan.get("xdrDetections") or [] + if isinstance(item, dict) + and item.get("included") is True + and item.get("sourceId") + }) + parity_paths = sorted( + str(value).replace("\\", "/") + for value in parity.get("testedDetectionPaths") or [] + ) + parity_sources = sorted({ + str(value).casefold() + for value in parity.get("testedSourceIds") or [] + }) + matches = bool( + stage_passed + and parity.get("status") == "passed" + and parity.get("qualificationScopeVerified") is True + and isinstance(current_plan_sha, str) + and bool(current_plan_sha) + and isinstance(selection_plan.get("planSha256"), str) + and bool(selection_plan.get("planSha256")) + and current_plan_sha == selection_plan.get("planSha256") + and parity.get("selectionPlanSha256") == current_plan_sha + and parity_paths == expected_paths + and parity_sources == expected_sources + and (parity.get("qualificationScope") or {}).get("selectionComplete") is True + ) + return ( + matches, + "parity evidence matches the exact current applied selection" + if matches + else "parity evidence does not cover the exact current applied selection", + ) + + +def _error( + stage: str, + message: Any, + *, + provider: str | None = None, + status_code: Any = None, + code: Any = None, + details: Any = None, +) -> dict[str, Any]: + return { + "stage": stage, + "provider": provider, + "statusCode": status_code, + "code": code, + "message": str(message), + "details": details, + } + + +def _entity_recommendation(source: dict[str, Any], conversion_notes: list[str]) -> str | None: + if not any( + "mapping" in value.lower() or "entity confirmation required" in value.lower() + for value in conversion_notes + ): + return None + for entity in source.get("entityMappings") or []: + if entity.get("entityType") != "Account": + continue + for field in entity.get("fieldMappings") or []: + if field.get("identifier") == "Name": + column = str(field.get("columnName") or "the projected user column") + return ( + f"Verify whether `{column}` is always a complete UPN. If confirmed, " + f"map it to Account.upnColumn. Otherwise project an Entra user ID, SID, " + "or account name plus domain. Do not infer identity semantics from the " + "column name alone." + ) + return None + + +def _current_source_whitelist( + root: Path, + runtime_reports: list[dict[str, Any]], +) -> tuple[dict[tuple[str, str], dict[str, Any]], dict[str, Any]]: + from .runtime import current_runtime_evidence + + try: + evidence = current_runtime_evidence(root) + except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError) as exc: + return {}, { + "passed": False, + "sourceStatus": "unavailable", + "sourceWhitelist": [], + "runtimeUnavailable": str(exc), + } + reports_by_provider = { + str(report.get("provider") or ""): report for report in runtime_reports + } + assessments: dict[tuple[str, str], dict[str, Any]] = {} + for item in evidence.get("sourceWhitelist") or []: + provider = str(evidence.get("sentinelProvider") or "") + report = reports_by_provider.get(provider) or {} + assessments[(provider, str(item.get("detection") or ""))] = { + **item, + "provider": provider, + "surface": report.get("querySurface"), + "sourceExecutionPassed": False, + "runtimeQualified": bool(evidence.get("passed")), + } + return assessments, evidence + + +def build_solution_report( + solution: str | Path, + *, + ingestion_reports: list[str | Path] | None = None, +) -> dict[str, Any]: + root, analytic, output = solution_paths(solution) + reports = report_directory(root, create=True) + manifest = _read_json(existing_artifact_path(root, "manifest.json")) + workflow = _read_json(existing_artifact_path(root, "workflow-state.json")) + runtime_consent = runtime_query_consent_status(root) + manifest_by_source = {} + for item in manifest.get("results") or []: + reference = str(item.get("sourceRelativePath") or item.get("source") or "").replace("\\", "/") + root_prefix = root.as_posix() + "/" + if reference.startswith(root_prefix): + reference = reference[len(root_prefix):] + manifest_by_source[reference] = item + runtime_reports = [ + _read_json(path) + for path in existing_artifacts(root, "runtime-validation.*.json") + ] + current_source_whitelist, runtime_evidence = _current_source_whitelist( + root, runtime_reports + ) + deployment = _read_json(existing_artifact_path(root, "deployment.graph.json")) + analytic_deployment = _read_json( + existing_artifact_path(root, "deployment.sentinel.json") + ) + parity = _read_json(existing_artifact_path(root, "alert-parity-report.json")) + query_parity = _read_json(existing_artifact_path(root, "mock-query-parity.json")) + packaging, selected_package_reference = _selected_packaging_report(root, workflow) + deployment_by_id = { + str(item.get("id")): item for item in deployment.get("results") or [] + } + analytic_deployment_by_id = { + str(item.get("id")): item + for item in analytic_deployment.get("results") or [] + } + parity_by_detection = { + str(item.get("detection")).replace("\\", "/"): item for item in parity.get("comparisons") or [] + } + query_parity_by_detection = { + str(item.get("detection")).replace("\\", "/"): item + for item in query_parity.get("comparisons") or [] + } + tactic_projection_by_id = { + str(item.get("detectionId")): item + for item in packaging.get("tacticProjection") or [] + if isinstance(item, dict) and item.get("detectionId") + } + identity_projection_by_id = { + str(item.get("packagedId")): item + for item in packaging.get("identityProjection") or [] + if isinstance(item, dict) and item.get("packagedId") + } + ingestion = [ + _read_json(resolve_artifact_reference(root, path)) + for path in ingestion_reports or [] + ] + + rules: list[dict[str, Any]] = [] + for source_path in analytic_rule_files(root): + source = yaml.safe_load(source_path.read_text(encoding="utf-8-sig")) or {} + conversion = manifest_by_source.get(source_path.relative_to(root).as_posix()) or {} + detection_name = source_path.relative_to(analytic).as_posix() + detection_path = content_path(output, detection_name) + detection: dict[str, Any] = {} + structural_errors: list[str] = [] + structural_warnings: list[str] = [] + if detection_path.exists(): + try: + detection = ( + yaml.safe_load(detection_path.read_text(encoding="utf-8-sig")) or {} + ) + structural_errors = validate_document(detection) + structural_warnings = custom_detail_binding_warnings(detection) + except (OSError, ValueError, yaml.YAMLError) as exc: + structural_errors = [str(exc)] + properties = detection.get("properties") or {} + detection_id = str(properties.get("id") or "") + tactic_projection = tactic_projection_by_id.get(detection_id) + identity_projection = identity_projection_by_id.get(detection_id) + analytic_runtime: list[dict[str, Any]] = [] + custom_runtime: list[dict[str, Any]] = [] + rule_warnings = list(conversion.get("warnings") or []) + [ + value + for value in structural_warnings + if value not in (conversion.get("warnings") or []) + ] + errors: list[dict[str, Any]] = [] + for value in conversion.get("errors") or []: + errors.append(_error("conversion", value, provider="local-cli")) + for value in structural_errors: + errors.append(_error("structural-validation", value, provider="local-cli")) + for report in runtime_reports: + provider = str(report.get("provider") or "unknown") + item = next( + ( + candidate + for candidate in report.get("results") or [] + if str(candidate.get("detection") or "").replace("\\", "/") == detection_name + ), + None, + ) + if not item: + continue + platform = str(report.get("platform") or "unknown") + is_sentinel = "Sentinel" in platform + expected_query = ( + str(source.get("query") or "") + if is_sentinel + else str( + (((properties.get("queryCondition") or {}).get("queryText")) or "") + ) + ) + expected_query_hash = hashlib.sha256( + expected_query.encode("utf-8") + ).hexdigest() + recorded_query_hash = item.get("querySha256") + query_hash_matches = recorded_query_hash == expected_query_hash + runtime_status = item.get("status") + stale_query_message = None + if runtime_status == "passed" and not query_hash_matches: + runtime_status = "blocked" + stale_query_message = ( + "runtime success is stale or unbound: the exact current query was not " + "validated; rerun this complete query family before claiming validation" + ) + runtime_item = { + "provider": provider, + "platform": platform, + "status": runtime_status, + "recordedStatus": item.get("status"), + "querySha256": recorded_query_hash, + "queryHashStatus": "matches" if query_hash_matches else "stale-or-missing", + "rowCount": item.get("rowCount"), + "providerFallback": report.get("providerFallback"), + } + source_whitelist = ( + current_source_whitelist.get((provider, detection_name)) + if is_sentinel + else None + ) + if source_whitelist: + runtime_item["sourceWhitelist"] = source_whitelist + rule_warnings.append( + f"Source runtime whitelist classified the raw failed query for " + f"{source_whitelist['table']}: {source_whitelist['reason']} " + "The source query remains failed; this does not qualify XDR runtime, " + "schema bindings, validation review gates, or alert parity." + ) + (analytic_runtime if is_sentinel else custom_runtime).append(runtime_item) + if stale_query_message or item.get("status") in {"failed", "blocked"}: + errors.append( + _error( + ( + "analytic-rule-runtime-validation" + if is_sentinel + else "custom-detection-runtime-validation" + ), + stale_query_message or item.get("error") or item.get("status"), + provider=provider, + status_code=item.get("statusCode"), + details=item.get("errorDetails"), + ) + ) + deployed = deployment_by_id.get(detection_id) or {} + analytic_deployed = analytic_deployment_by_id.get(str(source.get("id"))) or {} + if analytic_deployed and not analytic_deployed.get("success"): + arm_error = analytic_deployed.get("error") or {} + errors.append( + _error( + "analytic-rule-deployment", + arm_error.get("message") or arm_error, + provider=str( + analytic_deployment.get("provider") + or "azure-resource-manager" + ), + status_code=analytic_deployed.get("statusCode"), + code=arm_error.get("code"), + details=arm_error, + ) + ) + if deployed and not deployed.get("success"): + graph_error = deployed.get("error") or {} + errors.append( + _error( + "custom-detection-deployment", + graph_error.get("message") or graph_error, + provider=str(deployment.get("provider") or "microsoft-graph"), + status_code=deployed.get("statusCode"), + code=graph_error.get("code"), + details=graph_error, + ) + ) + parity_item = parity_by_detection.get(detection_name) or {} + query_parity_item = query_parity_by_detection.get(detection_name) or {} + if parity_item and not parity_item.get("passed"): + errors.append( + _error( + "alert-parity", + "Analytic Rule and Custom Detection alerts are not strictly equal", + provider="sentinel-and-defender", + details=parity_item, + ) + ) + if query_parity_item and not query_parity_item.get("passed"): + errors.append( + _error( + "query-result-parity", + "Analytic Rule and Custom Detection query results are not equal", + provider="sentinel-and-defender", + details=query_parity_item, + ) + ) + conversion_notes = [ + str(value) + for value in [ + *(conversion.get("errors") or []), + *(conversion.get("reviewReasons") or []), + ] + ] + rules.append( + { + "name": source.get("name") or source_path.stem, + "sourceFile": str(source_path), + "sourceSha256": hashlib.sha256(source_path.read_bytes()).hexdigest(), + "outputFile": str(detection_path), + "queries": { + "source": source.get("query"), + "converted": (properties.get("queryCondition") or {}).get("queryText"), + }, + "attackClassification": { + "sourceTactics": source.get("tactics"), + "sourceTechniques": source.get("relevantTechniques"), + "draftTactics": ( + (properties.get("detectionAction") or {}).get("alertTemplate") or {} + ).get("tactics"), + "originalTactics": ( + (detection.get("contentProvenance") or {}).get("conversion") or {} + ).get("originalTactics"), + "originalTechniques": ( + (detection.get("contentProvenance") or {}).get("conversion") or {} + ).get("originalTechniques"), + }, + "analyticRule": { + "id": source.get("id"), + "sourceStatus": source.get("status"), + "deploymentStatus": ( + "validated-by-alert-parity" + if parity_item + else analytic_deployed.get("operation") or "not-recorded" + ), + "alertStatus": ( + "passed" + if parity_item.get("passed") + else "failed" + if parity_item + else "not-run" + ), + "runtime": analytic_runtime, + }, + "customDetection": { + "id": detection_id or None, + "conversionStatus": conversion.get("status") or "not-run", + "reviewRequired": bool(conversion.get("reviewRequired")), + "reviewReasons": list(conversion.get("reviewReasons") or []), + "reviewScope": ( + (detection.get("contentProvenance") or {}).get("conversion") or {} + ).get("reviewScope"), + "reviewDecisions": ( + (detection.get("contentProvenance") or {}).get("conversion") or {} + ).get("reviewDecisions") or [], + "structuralStatus": ( + "passed" + if detection and not structural_errors + else "failed" + if detection + else "not-generated" + ), + "deploymentStatus": ( + deployed.get("operation") or "not-run" + ), + "runtime": custom_runtime, + "alertStatus": ( + "passed" + if parity_item.get("passed") + else "failed" + if parity_item + else "not-run" + ), + }, + "packagingProjection": tactic_projection, + "packagingIdentity": identity_projection, + "entityRecommendation": _entity_recommendation( + source, conversion_notes + ), + "queryParity": { + "status": ( + "passed" + if query_parity_item.get("passed") + else "failed" + if query_parity_item + else "not-run" + ), + "matchKey": query_parity_item.get("matchKey"), + "analyticRuleRows": query_parity_item.get("analyticRuleRows"), + "customDetectionRows": query_parity_item.get( + "customDetectionRows" + ), + }, + "warnings": rule_warnings, + "informational": list( + conversion.get("informational") or [] + if "informational" in conversion + else ( + (detection.get("contentProvenance") or {}).get("conversion") or {} + ).get("informational") or [] + ) + ([ + "V3.1 ARM packaging retains authored tactic " + f"{', '.join(tactic_projection.get('packagedTactics') or []) or '(none)'} " + "and omits " + f"{', '.join(tactic_projection.get('omittedTactics') or []) or '(none)'}; " + "authored YAML is unchanged and this projection is not parity" + ] if tactic_projection else []) + ([ + "V3.1 ARM packaging normalized Custom Detection ID " + f"{identity_projection.get('inputId')!r} to {identity_projection.get('packagedId')!r}; " + "source/provenance YAML remains unchanged and deployed IDs are not migrated" + ] if identity_projection and identity_projection.get("changed") else []), + "errors": errors, + } + ) + + report = { + "solution": root.name, + "solutionPath": str(root), + "generatedAt": datetime.now(timezone.utc).isoformat(), + "workflow": { + "runId": workflow.get("runId"), + "profile": (workflow.get("context") or {}).get("workflowProfile"), + "profileSelectionConfirmed": (workflow.get("context") or {}).get("profileSelectionConfirmed"), + "status": workflow.get("workflowStatus"), + "workspaceConfigured": bool( + (workflow.get("context") or {}).get("workspaceResourceId") + ), + "stages": { + name: { + key: stage.get(key) + for key in ( + "status", "attempts", "message", "evidence", "updatedAt", + "startedAt", "completedAt", "artifacts", + ) + if stage.get(key) is not None + } + for name, stage in (workflow.get("stages") or {}).items() + }, + "packageFreshness": _package_freshness(root, workflow), + "runtimeQueryConsent": runtime_consent, + }, + "runtimeAssessment": { + "validationStageStatus": ( + ((workflow.get("stages") or {}).get("validation") or {}).get("status") + ), + "sourceStatus": runtime_evidence.get("sourceStatus", "not-run"), + "sourceWhitelist": [ + { + **item, + "provider": runtime_evidence.get("sentinelProvider"), + "runtimeQualified": bool(runtime_evidence.get("passed")), + } + for item in runtime_evidence.get("sourceWhitelist") or [] + ], + "fullyRuntimeQualified": ( + bool(runtime_evidence.get("passed")) + if runtime_reports + else None + ), + "runtimeUnavailable": runtime_evidence.get("runtimeUnavailable"), + }, + "summary": { + "conversionScope": manifest.get("scope") or {}, + "rules": len(rules), + "converted": sum( + item["customDetection"]["conversionStatus"] == "converted" + for item in rules + ), + "needsReview": sum( + item["customDetection"]["reviewRequired"] + for item in rules + ), + "deploymentReady": sum( + item["customDetection"]["conversionStatus"] == "converted" + and not item["customDetection"]["reviewRequired"] + and item["customDetection"]["structuralStatus"] == "passed" + and len(item["attackClassification"].get("draftTactics") or []) <= 1 + for item in rules + ), + "strictParityPassed": sum( + item["customDetection"]["alertStatus"] == "passed" for item in rules + ), + "analyticRuntimePassed": sum( + any( + runtime["status"] == "passed" + for runtime in item["analyticRule"]["runtime"] + ) + for item in rules + ), + "customRuntimePassed": sum( + any( + runtime["status"] == "passed" + for runtime in item["customDetection"]["runtime"] + ) + for item in rules + ), + "queryParityPassed": sum( + item["queryParity"]["status"] == "passed" for item in rules + ), + "errors": sum(len(item["errors"]) for item in rules), + "informational": sum(len(item["informational"]) for item in rules), + "rulesWithInformation": sum(bool(item["informational"]) for item in rules), + "packagedTacticOmissions": sum( + len((item.get("packagingProjection") or {}).get("omittedTactics") or []) + for item in rules + ), + "packageIdentityNormalizations": sum( + bool((item.get("packagingIdentity") or {}).get("changed")) + for item in rules + ), + "providerFallbackRules": sum( + any(runtime.get("providerFallback") for side in ( + item["analyticRule"], item["customDetection"], + ) for runtime in side["runtime"]) + for item in rules + ), + }, + "rules": rules, + "ingestion": ingestion, + "artifacts": { + "transformationReport": manifest.get("transformationReport"), + "runtimeReports": [ + { + "provider": item.get("provider"), + "providerFallback": item.get("providerFallback"), + "jsonReport": item.get("jsonReport"), + "htmlReport": item.get("htmlReport"), + } + for item in runtime_reports + ], + "deploymentReport": deployment.get("reportPath"), + "analyticDeploymentReport": analytic_deployment.get("reportPath"), + "queryParityReport": query_parity.get("reportPath"), + "alertParityReport": parity.get("reportPath"), + "packagingReport": packaging.get("packageReport"), + "selectedPackagingEvidence": selected_package_reference, + "qualificationDcrPreflight": ( + str(reports / "qualification-dcr-preflight.json") + if (reports / "qualification-dcr-preflight.json").is_file() + else None + ), + "ingestionReports": [ + item.get("reportPath") for item in ingestion if item.get("reportPath") + ], + }, + } + if packaging.get("selectionOutcome") == "partial-package-validated": + selection_plan = _read_run_json( + root, + report_directory(root), + packaging.get("selectionPlanPath"), + ) + selection_summary = selection_plan.get("summary") or {} + if selection_summary.get("outcome") == "partial": + profile = (workflow.get("context") or {}).get("workflowProfile") + stages = workflow.get("stages") or {} + parity_passed = (stages.get("alertParity") or {}).get("status") == "passed" + qualification_progression_passed = bool( + profile == "qualification" + and workflow.get("workflowStatus") in {"completed", "partialCompleted"} + and all( + (stages.get(name) or {}).get("status") == "passed" + for name in ( + "packaging", "deployment", "mockIngestion", "alertParity" + ) + ) + ) + parity_scope_matches = False + parity_scope_reason = "current selected-scope parity evidence is unavailable" + try: + from .packaging_selection import current_applied_package_selection + + applied = current_applied_package_selection(root) + applied_plan = applied.get("plan") if isinstance(applied, dict) else None + parity_scope_matches, parity_scope_reason = ( + _partial_selection_parity_matches( + selection_plan, + parity, + applied_plan, + stage_passed=( + parity_passed and qualification_progression_passed + ), + ) + ) + except (OSError, RuntimeError, TypeError, ValueError): + parity_scope_matches = False + report["partialSolution"] = { + "status": ( + "selected-subset-qualified" + if profile == "qualification" and parity_scope_matches + else "partial-package-validated" + ), + "profile": profile, + "fullInventoryQualification": "not-qualified", + "fullInventoryParity": "not-proven", + "selectedParityScope": ( + "exact-current-selection" + if parity_scope_matches else "not-proven" + ), + "selectedParityScopeReason": parity_scope_reason, + "qualificationScopeVerified": parity_scope_matches, + "selectionPlanSha256": selection_plan.get("planSha256"), + "selectionSummary": selection_summary, + "selectedDetections": [ + { + "id": item.get("id"), + "sourceId": item.get("sourceId"), + "path": item.get("path"), + "evidencePaths": item.get("evidencePaths") or [], + } + for item in selection_plan.get("xdrDetections") or [] + if item.get("included") is True + ], + "excludedAnalyticRules": [ + { + "id": item.get("id"), + "path": item.get("path"), + "reasons": item.get("reasons") or [], + } + for item in selection_plan.get("analyticRules") or [] + if item.get("included") is not True + ], + "excludedDetections": [ + { + "id": item.get("id"), + "sourceId": item.get("sourceId"), + "path": item.get("path"), + "reasons": item.get("reasons") or [], + } + for item in selection_plan.get("xdrDetections") or [] + if item.get("included") is not True + ], + } + json_path = reports / JSON_NAME + html_path = reports / HTML_NAME + report["jsonReport"] = str(json_path) + report["htmlReport"] = str(html_path) + report["deploymentPlanning"] = _planning_summary( + root, packaging, report["workflow"], workflow + ) + report["qualificationPreflight"] = _qualification_preflight( + root, reports, workflow + ) + report["startupSetup"] = _startup_setup_summary( + root, reports, workflow, report["qualificationPreflight"], + report["deploymentPlanning"], + ) + target_reference = reports / "qualification-target.json" + target_link = _local_flow_evidence_link(root, reports, str(target_reference)) + report["artifacts"]["qualificationTarget"] = ( + str(target_reference) if target_link.get("available") is True else None + ) + report["flowEvidence"] = _build_flow_evidence( + root, reports, report["workflow"], report["artifacts"], packaging + ) + write_json_artifact(root, json_path, report) + report["flowEvidence"]["report"].append( + _local_flow_evidence_link(root, reports, json_path) + ) + write_json_artifact(root, json_path, report) + html_path.write_text(render_solution_report(portable_artifact(root, report)), encoding="utf-8", newline="\n") + return report + + +def render_solution_report(report: dict[str, Any]) -> str: + return render_dashboard(report) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/source_runtime_waiver.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/source_runtime_waiver.py new file mode 100644 index 00000000000..da92be80ae2 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/source_runtime_waiver.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import re +from collections.abc import Mapping +from typing import Any + + +SOURCE_PROVIDERS = { + "triage-mcp": "microsoft-sentinel-triage", + "azure-monitor-sdk": "log-analytics-workspace", + "log-analytics-cli": "log-analytics-workspace", +} + +# This is deliberately a finite, global source-runtime whitelist. +MISSING_SCHEMA_TABLES = ("IdentityInfo",) +_WHITELIST_CASEFOLDED = frozenset(table.casefold() for table in MISSING_SCHEMA_TABLES) +_SEMANTIC_CODE = re.compile(r"\bSEM\d{4,}\b", re.IGNORECASE) +_TABLE_DIAGNOSTIC = re.compile( + r"\bfailed\s+to\s+resolve\s+" + r"(?:table\s+or\s+column\s+expression\s+named|table)\s+" + r"['\"`]?([A-Za-z_][A-Za-z0-9_]*)['\"`]?", + re.IGNORECASE, +) +_IDENTITYINFO_MISSING_MESSAGE = re.compile( + r"^\s*(?:SEM0100\s*:\s*)?" + r"(?:['\"`][^'\"`]+['\"`]\s+operator\s*:\s*)?" + r"Failed\s+to\s+resolve\s+" + r"(?:table\s+or\s+column\s+expression\s+named|table)\s+" + r"['\"`]?IdentityInfo['\"`]?\s*\.?\s*$", + re.IGNORECASE, +) +_AUTH_FAILURE = re.compile( + r"\b(authentication|authorization|unauthorized|forbidden|tenant mismatch)\b", + re.IGNORECASE, +) +_ALLOWED_ERROR_CODES = frozenset({"badargumenterror", "semanticerror", "sem0100"}) +_GENERIC_ERROR_MESSAGES = frozenset({ + "query execution failed.", + "query failed.", + "the query failed.", +}) + + +def _collect_diagnostics(value: Any) -> tuple[list[str], list[str], list[str]]: + messages: list[str] = [] + codes: list[str] = [] + structured_messages: list[str] = [] + visited: set[int] = set() + + def visit(item: Any, key: str | None = None) -> None: + if isinstance(item, str): + if key and key.casefold() in {"code", "errorcode", "failurecode"}: + codes.append(item) + stripped = item.strip() + if stripped.startswith(("{", "[")): + try: + decoded = json.loads(stripped) + except (json.JSONDecodeError, RecursionError): + pass + else: + if not isinstance(decoded, str): + visit(decoded) + return + if key and key.casefold() in {"message", "description"}: + structured_messages.append(item) + messages.append(item) + return + if isinstance(item, Mapping): + identity = id(item) + if identity in visited: + return + visited.add(identity) + for child_key, child in item.items(): + visit(child, str(child_key)) + return + if isinstance(item, (list, tuple)): + identity = id(item) + if identity in visited: + return + visited.add(identity) + for child in item: + visit(child) + + visit(value) + return messages, codes, structured_messages + + +def missing_schema_whitelist_match( + entry: Mapping[str, Any], + *, + provider: str, + expected_query_sha256: str | None = None, +) -> dict[str, Any] | None: + """Classify only a complete, exact Sentinel table-resolution failure. + + This is a derived assessment. It never edits the raw provider result or + turns a failed source query into a successful execution. + """ + if provider not in SOURCE_PROVIDERS: + return None + if ( + entry.get("status") != "failed" + or entry.get("statusCode") != 400 + or entry.get("failureKind") != "query" + or entry.get("partial") is True + or entry.get("partialError") + or ( + expected_query_sha256 is not None + and entry.get("querySha256") != expected_query_sha256 + ) + ): + return None + + messages, explicit_codes, structured_messages = _collect_diagnostics( + {"error": entry.get("error"), "errorDetails": entry.get("errorDetails")} + ) + diagnostic = "\n".join(dict.fromkeys(message for message in messages if message)) + semantic_codes = {code.upper() for code in _SEMANTIC_CODE.findall(diagnostic)} + normalized_codes = {code.casefold() for code in explicit_codes} + if semantic_codes != {"SEM0100"}: + return None + if any(code not in _ALLOWED_ERROR_CODES for code in normalized_codes): + return None + code_text = {code.casefold() for code in explicit_codes} + if any( + message.strip().casefold() not in _GENERIC_ERROR_MESSAGES + and message.strip().casefold() not in code_text + and not _IDENTITYINFO_MISSING_MESSAGE.fullmatch(message) + for message in messages + ): + return None + if any( + not _IDENTITYINFO_MISSING_MESSAGE.fullmatch(message) + and message.strip().casefold() not in _GENERIC_ERROR_MESSAGES + and message.strip().casefold() not in code_text + for message in structured_messages + ): + return None + if _AUTH_FAILURE.search(diagnostic): + return None + + resolved_tables = { + table.casefold() for table in _TABLE_DIAGNOSTIC.findall(diagnostic) + } + if not resolved_tables or resolved_tables != _WHITELIST_CASEFOLDED: + return None + table = next( + canonical + for canonical in MISSING_SCHEMA_TABLES + if canonical.casefold() in resolved_tables + ) + return { + "status": "source-waived", + "table": table, + "reason": ( + f"Sentinel source query failed with HTTP 400 SEM0100 because " + f"the source surface could not resolve table {table}." + ), + "rawFailure": { + "status": entry["status"], + "statusCode": entry["statusCode"], + "failureKind": entry["failureKind"], + "querySha256": entry.get("querySha256"), + "error": entry.get("error"), + "errorDetails": entry.get("errorDetails"), + }, + "sourceExecutionPassed": False, + "runtimeQualified": False, + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/target_config.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/target_config.py new file mode 100644 index 00000000000..79366f6fe55 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/target_config.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import re +import uuid +from pathlib import Path +from typing import Any + +from .target_context import resolve_workspace_customer_id, workspace_identity + +ENV_FILE_NAME = ".env" +ENV_KEYS = { + "AZURE_TENANT_ID": "tenantId", + "SENTINEL_WORKSPACE_RESOURCE_ID": "workspaceResourceId", +} + + +def _normalize_env_value(key: str, value: str, line_number: int) -> str: + if not value or value != value.strip(): + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: {key} must have a non-empty value " + "without surrounding whitespace" + ) + if any(character.isspace() for character in value) or any( + character in value for character in ("$", "`", ";", "#", "'", '"', "\\") + ): + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: {key} contains unsupported syntax; " + "enter the literal identifier only (no quotes, expansion, or commands)" + ) + if key == "AZURE_TENANT_ID": + if not re.fullmatch( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + value, + ): + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: AZURE_TENANT_ID must be a tenant GUID; " + "replace the placeholder in the example file" + ) + return str(uuid.UUID(value)) + try: + workspace = workspace_identity(value) + except ValueError as exc: + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: SENTINEL_WORKSPACE_RESOURCE_ID must be " + "a full Log Analytics workspace ARM ID; replace the placeholder in " + "the example file" + ) from exc + parts = value.split("/") + if not re.fullmatch( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + parts[2], + ) or any( + not re.fullmatch(r"[A-Za-z0-9_.()\-]+", part) + for part in (workspace["resourceGroup"], workspace["workspaceName"]) + ): + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: SENTINEL_WORKSPACE_RESOURCE_ID contains " + "an invalid subscription, resource-group, or workspace segment" + ) + if workspace["workspaceResourceId"] != value: + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: SENTINEL_WORKSPACE_RESOURCE_ID must not " + "have surrounding whitespace or a trailing slash" + ) + return value + + +def read_solution_target_config(solution: str | Path) -> dict[str, str]: + root = Path(solution).expanduser().resolve() + path = root / ENV_FILE_NAME + if path.is_symlink(): + raise ValueError("solution .env must not be a symlink") + if not path.exists(): + return {} + try: + lines = path.read_text(encoding="utf-8-sig").splitlines() + except (OSError, UnicodeError) as exc: + raise ValueError("cannot read solution .env as UTF-8 text") from exc + + values: dict[str, str] = {} + for line_number, original in enumerate(lines, start=1): + if not original.strip() or original.lstrip().startswith("#"): + continue + key, separator, value = original.partition("=") + if not separator or key not in ENV_KEYS: + raise ValueError( + f"{ENV_FILE_NAME}:{line_number}: unsupported entry; use only " + "AZURE_TENANT_ID and SENTINEL_WORKSPACE_RESOURCE_ID assignments" + ) + if key in values: + raise ValueError(f"{ENV_FILE_NAME}:{line_number}: duplicate {key}") + values[key] = _normalize_env_value(key, value, line_number) + return {ENV_KEYS[key]: value for key, value in values.items()} + + +def _same_value(name: str, left: str, right: str) -> bool: + if name == "workspaceResourceId": + return left.rstrip("/").casefold() == right.rstrip("/").casefold() + try: + return str(uuid.UUID(left)) == str(uuid.UUID(right)) + except ValueError: + return left.casefold() == right.casefold() + + +def resolve_qualification_target( + solution: str | Path, + *, + tenant_id: str | None = None, + subscription_id: str | None = None, + workspace_resource_id: str | None = None, + workspace_customer_id: str | None = None, + confirm_configured_target: bool = False, + locked_context: dict[str, Any] | None = None, +) -> dict[str, str]: + configured = read_solution_target_config(solution) + locked = locked_context or {} + locked_target = { + name: str(locked[name]) + for name in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + if locked.get(name) + } + + supplied = { + "tenantId": tenant_id, + "subscriptionId": subscription_id, + "workspaceResourceId": workspace_resource_id, + "workspaceCustomerId": workspace_customer_id, + } + for name in ("tenantId", "workspaceResourceId"): + configured_value = configured.get(name) + if configured_value and supplied[name] and not _same_value( + name, configured_value, supplied[name] + ): + raise ValueError( + f"command-line {name} conflicts with solution .env; reconcile the " + "values explicitly before initializing" + ) + locked_value = locked_target.get(name) + if locked_value and configured_value and not _same_value( + name, locked_value, configured_value + ): + raise ValueError( + f"solution .env {name} conflicts with the locked Qualification run; " + "the run target is authoritative and cannot be changed by editing .env" + ) + + explicit_target = bool(tenant_id and workspace_resource_id) + if ( + configured + and not locked_target + and not confirm_configured_target + and not explicit_target + ): + raise ValueError( + "solution .env contains a Qualification target. Review it with " + "`sentinel-xdr-migration doctor --solution ` and pass " + "`--confirm-configured-target`, or explicitly supply both " + "`--tenant-id` and `--workspace-resource-id`" + ) + if confirm_configured_target and not configured and not locked_target: + raise ValueError( + "--confirm-configured-target requires a configured solution .env" + ) + + resolved: dict[str, str] = {} + for name in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ): + value = supplied[name] or locked_target.get(name) + if name in {"tenantId", "workspaceResourceId"}: + value = value or configured.get(name) + if value: + resolved[name] = str(value) + + if "workspaceResourceId" in resolved: + workspace = workspace_identity(resolved["workspaceResourceId"]) + derived_subscription = workspace["subscriptionId"] + if resolved.get("subscriptionId") and not _same_value( + "subscriptionId", resolved["subscriptionId"], derived_subscription + ): + raise ValueError( + "workspace ARM ID subscription does not match subscription-id" + ) + resolved["subscriptionId"] = derived_subscription + + for name, value in locked_target.items(): + current = resolved.get(name) + if current and not _same_value(name, value, current): + raise ValueError( + f"{name} does not match the locked Qualification target; " + "the existing run cannot be retargeted" + ) + resolved[name] = value + + missing = [ + name + for name in ("tenantId", "subscriptionId", "workspaceResourceId") + if not resolved.get(name) + ] + if missing: + raise ValueError( + "Qualification requires AZURE_TENANT_ID and " + "SENTINEL_WORKSPACE_RESOURCE_ID in solution .env, or explicit " + "tenant/workspace command-line values; missing " + + ", ".join(missing) + ) + + if not resolved.get("workspaceCustomerId"): + resolved["workspaceCustomerId"] = resolve_workspace_customer_id( + resolved["tenantId"], + resolved["workspaceResourceId"], + ) + return resolved + + +def doctor_solution_target(solution: str | Path) -> dict[str, Any]: + target = read_solution_target_config(solution) + config_path = Path(solution).expanduser().resolve() / ENV_FILE_NAME + if not target: + return {"status": "notConfigured", "configPath": str(config_path)} + workspace = target.get("workspaceResourceId") + return { + "status": "configured", + "configPath": str(config_path), + "tenantId": target.get("tenantId"), + "workspaceResourceId": workspace, + "subscriptionId": ( + workspace_identity(workspace)["subscriptionId"] if workspace else None + ), + "workspaceCustomerId": "resolved only after explicit target confirmation", + } diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/target_context.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/target_context.py new file mode 100644 index 00000000000..b72b2de0c26 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/target_context.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Sequence + +from .artifacts import artifact_path, existing_artifact_path, write_json_artifact +from .graph_client_config import azure_cli_profile, configured_azure_cli_profile +from .onboarding import CONFIG_NAME, _read_json, _state_dir, configure_workspace + + +TARGET_FILE_NAME = "qualification-target.json" +DIAGNOSTICS_FILE_NAME = "qualification-diagnostics.json" +REPAIR_FILE_NAME = "qualification-repair.json" +TARGET_SCHEMA_VERSION = "1.0.0" + +CommandRunner = Callable[..., subprocess.CompletedProcess[str]] + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _guid(value: str, field: str) -> str: + try: + return str(uuid.UUID(value.strip())) + except (AttributeError, ValueError) as exc: + raise ValueError(f"{field} must be a GUID") from exc + + +def workspace_identity(workspace_resource_id: str) -> dict[str, str]: + normalized = workspace_resource_id.strip().rstrip("/") + parts = normalized.split("/") + if ( + len(parts) != 9 + or parts[1].lower() != "subscriptions" + or parts[3].lower() != "resourcegroups" + or parts[5].lower() != "providers" + or parts[6].lower() != "microsoft.operationalinsights" + or parts[7].lower() != "workspaces" + or not parts[8] + ): + raise ValueError( + "workspace-resource-id must be a full Log Analytics workspace ARM ID" + ) + return { + "workspaceResourceId": normalized, + "subscriptionId": _guid(parts[2], "workspace subscription ID"), + "resourceGroup": parts[4], + "workspaceName": parts[8], + } + + +def build_target( + *, + tenant_id: str, + subscription_id: str, + workspace_resource_id: str, + workspace_customer_id: str, +) -> dict[str, Any]: + workspace = workspace_identity(workspace_resource_id) + normalized_subscription = _guid(subscription_id, "subscription ID") + if workspace["subscriptionId"] != normalized_subscription: + raise ValueError( + "workspace ARM ID subscription does not match subscription-id" + ) + return { + "schemaVersion": TARGET_SCHEMA_VERSION, + "locked": True, + "tenantId": _guid(tenant_id, "tenant ID"), + "subscriptionId": normalized_subscription, + "workspaceResourceId": workspace["workspaceResourceId"], + "workspaceCustomerId": _guid( + workspace_customer_id, "workspace customer ID" + ), + "resourceGroup": workspace["resourceGroup"], + "workspaceName": workspace["workspaceName"], + } + + +def _target_from_context(context: dict[str, Any]) -> dict[str, Any]: + required = ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + missing = [name for name in required if not context.get(name)] + if missing or context.get("targetContextLocked") is not True: + raise ValueError( + "qualification target is not locked; initialize or repair it with " + f"all required identifiers ({', '.join(required)})" + ) + return build_target( + tenant_id=context["tenantId"], + subscription_id=context["subscriptionId"], + workspace_resource_id=context["workspaceResourceId"], + workspace_customer_id=context["workspaceCustomerId"], + ) + + +def write_target(solution: str | Path, target: dict[str, Any]) -> Path: + path = artifact_path(solution, TARGET_FILE_NAME, create_parent=True) + write_json_artifact(solution, path, target) + return path + + +def require_locked_target( + solution: str | Path, + *, + tenant_id: str | None = None, + subscription_id: str | None = None, + workspace_resource_id: str | None = None, + workspace_customer_id: str | None = None, + materialize_missing: bool = True, +) -> dict[str, Any]: + state_path = existing_artifact_path(solution, "workflow-state.json") + if not state_path.is_file(): + raise ValueError("qualification workflow state does not exist") + state = json.loads(state_path.read_text(encoding="utf-8-sig")) + context = state.get("context") or {} + if context.get("workflowProfile") != "qualification": + raise ValueError("live operations require a qualification workflow") + target = _target_from_context(context) + supplied = { + "tenantId": tenant_id, + "subscriptionId": subscription_id, + "workspaceResourceId": ( + workspace_resource_id.strip().rstrip("/") + if workspace_resource_id + else None + ), + "workspaceCustomerId": workspace_customer_id, + } + normalizers = { + "tenantId": lambda value: _guid(value, "tenant ID"), + "subscriptionId": lambda value: _guid(value, "subscription ID"), + "workspaceResourceId": lambda value: workspace_identity(value)[ + "workspaceResourceId" + ], + "workspaceCustomerId": lambda value: _guid( + value, "workspace customer ID" + ), + } + for name, value in supplied.items(): + if value is None: + continue + normalized = normalizers[name](value) + expected = target[name] + comparison = ( + normalized.lower() == expected.lower() + if name == "workspaceResourceId" + else normalized == expected + ) + if not comparison: + raise ValueError( + f"{name} does not match the locked qualification target" + ) + target_path = existing_artifact_path(solution, TARGET_FILE_NAME) + if target_path.is_file(): + recorded = json.loads(target_path.read_text(encoding="utf-8-sig")) + if ( + not isinstance(recorded, dict) + or recorded.get("schemaVersion") != TARGET_SCHEMA_VERSION + or recorded.get("locked") is not True + ): + raise ValueError( + "qualification target artifact is missing its supported locked-target identity" + ) + for field in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ): + if str(recorded.get(field) or "").lower() != str(target[field]).lower(): + raise ValueError( + "qualification target artifact does not match workflow state" + ) + elif materialize_missing: + write_target(solution, target) + target["targetPath"] = str(target_path) + return target + + +def _az_executable() -> str: + executable = shutil.which("az.cmd" if os.name == "nt" else "az") + if not executable: + executable = shutil.which("az") + if not executable: + raise RuntimeError("Azure CLI is not installed or available on PATH") + return executable + + +def _run_az( + arguments: Sequence[str], + *, + runner: CommandRunner = subprocess.run, +) -> subprocess.CompletedProcess[str]: + with azure_cli_profile(configured_azure_cli_profile(_state_dir())): + return runner( + [_az_executable(), *arguments, "--output", "json", "--only-show-errors"], + capture_output=True, + text=True, + check=False, + ) + + +def _json_output(result: subprocess.CompletedProcess[str]) -> Any: + if result.returncode: + raise RuntimeError((result.stderr or result.stdout or "").strip()) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("Azure CLI returned invalid JSON") from exc + + +def resolve_workspace_customer_id( + tenant_id: str, + workspace_resource_id: str, + *, + runner: CommandRunner = subprocess.run, +) -> str: + """Read only the explicitly selected workspace after checking Azure CLI scope.""" + tenant = _guid(tenant_id, "tenant ID") + workspace = workspace_identity(workspace_resource_id) + account_result = _run_az(("account", "show"), runner=runner) + if account_result.returncode: + raise RuntimeError( + "Azure CLI authentication scope could not be verified. Sign in to the " + "approved tenant, then retry Qualification target resolution." + ) + account = _json_output(account_result) + if not isinstance(account, dict): + raise RuntimeError( + "Azure CLI did not return a valid authenticated tenant and subscription" + ) + try: + active_tenant = _guid(account.get("tenantId", ""), "authenticated tenant ID") + active_subscription = _guid( + account.get("id", ""), "authenticated subscription ID" + ) + except (AttributeError, ValueError) as exc: + raise RuntimeError( + "Azure CLI did not return a valid authenticated tenant and subscription" + ) from exc + if active_tenant != tenant: + raise RuntimeError( + "authenticated Azure tenant does not match AZURE_TENANT_ID; sign in " + "to the explicitly selected tenant before retrying" + ) + if active_subscription != workspace["subscriptionId"]: + raise RuntimeError( + "active Azure subscription does not match the subscription derived " + "from the selected workspace ARM ID" + ) + + result = _run_az( + ( + "resource", + "show", + "--ids", + workspace["workspaceResourceId"], + "--api-version", + "2023-09-01", + ), + runner=runner, + ) + if result.returncode: + raise RuntimeError( + "the exact selected Log Analytics workspace could not be read. Verify " + "the ARM ID and current account access; no workspace discovery was run" + ) + resource = _json_output(result) + if not isinstance(resource, dict): + raise RuntimeError( + "Azure CLI did not return the exact selected workspace ARM resource" + ) + actual_id = str(resource.get("id") or "") + if not actual_id or not _same_arm_id(actual_id, workspace["workspaceResourceId"]): + raise RuntimeError( + "Azure CLI did not return the exact selected workspace ARM resource" + ) + properties = resource.get("properties") + customer_id = ( + properties.get("customerId") if isinstance(properties, dict) else None + ) + return _guid(str(customer_id or ""), "workspace customer ID") + + +def _same_arm_id(left: str, right: str) -> bool: + return left.rstrip("/").casefold() == right.rstrip("/").casefold() + + +def diagnose_target( + solution: str | Path, + *, + runner: CommandRunner = subprocess.run, +) -> dict[str, Any]: + target = require_locked_target(solution) + account = _json_output(_run_az(("account", "show"), runner=runner)) + if _guid(account.get("tenantId", ""), "authenticated tenant ID") != target["tenantId"]: + status = "tenant-mismatch" + detail = "authenticated Azure tenant does not match the locked target" + proposed = None + elif _guid(account.get("id", ""), "authenticated subscription ID") != target[ + "subscriptionId" + ]: + status = "subscription-mismatch" + detail = "active Azure subscription does not match the locked target" + proposed = None + else: + resource = _run_az( + ( + "resource", + "show", + "--ids", + target["workspaceResourceId"], + "--api-version", + "2023-09-01", + ), + runner=runner, + ) + if resource.returncode == 0: + workspace = _json_output(resource) + actual_customer = str( + ((workspace.get("properties") or {}).get("customerId") or "") + ).strip() + if not actual_customer: + status = "blocked" + detail = "the exact workspace response did not include its customer ID" + elif _guid(actual_customer, "workspace customer ID") != target[ + "workspaceCustomerId" + ]: + status = "workspace-identity-mismatch" + detail = ( + "the exact ARM resource customer ID does not match the locked target" + ) + else: + status = "ready" + detail = "the exact locked workspace ARM resource is accessible" + proposed = None + else: + escaped_customer = target["workspaceCustomerId"].replace("'", "''") + query = ( + "resources " + "| where type =~ 'microsoft.operationalinsights/workspaces' " + f"| where tostring(properties.customerId) =~ '{escaped_customer}' " + "| project id, name, resourceGroup, subscriptionId, " + "customerId=tostring(properties.customerId)" + ) + graph = _json_output( + _run_az( + ( + "graph", + "query", + "--subscriptions", + target["subscriptionId"], + "--first", + "2", + "-q", + query, + ), + runner=runner, + ) + ) + rows = graph.get("data") if isinstance(graph, dict) else None + rows = rows if isinstance(rows, list) else [] + if len(rows) == 1: + row = rows[0] + proposed = build_target( + tenant_id=target["tenantId"], + subscription_id=target["subscriptionId"], + workspace_resource_id=str(row.get("id") or ""), + workspace_customer_id=target["workspaceCustomerId"], + ) + status = ( + "repair-available" + if proposed["workspaceResourceId"].lower() + != target["workspaceResourceId"].lower() + else "blocked" + ) + detail = ( + "the locked ARM path is stale; one exact customer-ID match " + "was found in the locked subscription" + if status == "repair-available" + else "the exact workspace exists but the ARM read remains blocked" + ) + else: + proposed = None + status = "blocked" + detail = ( + "the locked workspace ARM path failed and the exact customer-ID " + f"lookup returned {len(rows)} matches" + ) + report = { + "checkedAt": _utc_now(), + "status": status, + "detail": detail, + "target": target, + "proposedTarget": proposed, + "writesPerformed": False, + } + path = artifact_path(solution, DIAGNOSTICS_FILE_NAME, create_parent=True) + report["reportPath"] = str(path) + write_json_artifact(solution, path, report) + return report + + +def repair_target( + solution: str | Path, + *, + approve_target_update: bool, + state_dir: str | Path | None = None, +) -> dict[str, Any]: + if not approve_target_update: + raise ValueError("--approve-target-update is required") + diagnostics_path = existing_artifact_path(solution, DIAGNOSTICS_FILE_NAME) + if not diagnostics_path.is_file(): + raise ValueError("qualification diagnostics do not exist") + diagnostics = json.loads(diagnostics_path.read_text(encoding="utf-8-sig")) + if diagnostics.get("status") != "repair-available": + raise ValueError("diagnostics do not contain an approved repair candidate") + current = require_locked_target(solution) + recorded = diagnostics.get("target") or {} + for field in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ): + if str(current[field]).lower() != str(recorded.get(field) or "").lower(): + raise ValueError("qualification target changed after diagnostics; rerun diagnosis") + proposed = diagnostics.get("proposedTarget") or {} + repaired = build_target( + tenant_id=proposed.get("tenantId", ""), + subscription_id=proposed.get("subscriptionId", ""), + workspace_resource_id=proposed.get("workspaceResourceId", ""), + workspace_customer_id=proposed.get("workspaceCustomerId", ""), + ) + for field in ("tenantId", "subscriptionId", "workspaceCustomerId"): + if repaired[field] != current[field]: + raise ValueError(f"repair cannot change locked {field}") + + from .workflow import _load, _write + + state_path, state = _load(solution) + context = state["context"] + context.update( + { + "tenantId": repaired["tenantId"], + "subscriptionId": repaired["subscriptionId"], + "workspaceResourceId": repaired["workspaceResourceId"], + "workspaceCustomerId": repaired["workspaceCustomerId"], + "targetContextLocked": True, + } + ) + deployment = state["stages"]["deployment"] + if deployment["status"] == "blocked": + deployment.update( + { + "status": "pending", + "startedAt": None, + "completedAt": None, + "message": None, + "artifacts": {}, + "evidence": [], + } + ) + state["workflowStatus"] = "inProgress" + _write(state_path, state) + target_path = write_target(solution, repaired) + configure_workspace( + repaired["workspaceResourceId"], + workspace_customer_id=repaired["workspaceCustomerId"], + tenant_id=repaired["tenantId"], + subscription_id=repaired["subscriptionId"], + state_dir=state_dir, + ) + report = { + "repairedAt": _utc_now(), + "status": "repaired", + "previousTarget": current, + "target": repaired, + "targetPath": str(target_path), + "workflowStatePath": str(state_path), + "writesPerformed": ["local-config", "workflow-state", "target-context"], + } + path = artifact_path(solution, REPAIR_FILE_NAME, create_parent=True) + report["reportPath"] = str(path) + write_json_artifact(solution, path, report) + return report diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py new file mode 100644 index 00000000000..5be24213c8c --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py @@ -0,0 +1,5637 @@ +from __future__ import annotations + +import json +import hashlib +import os +import re +import shutil +import tempfile +import uuid +from datetime import datetime, timezone +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator +import yaml + +from .artifacts import ( + artifact_path, existing_artifact_path, report_directory, resolve_artifact_reference, + create_run, requested_run_id, solution_for_artifact, using_run, RUN_MARKER, + conversion_scope, require_solution_conversion_scope, portable_artifact, +) +from .budget import ( + BudgetError, + BudgetReceiptError, + budget_summary, + create_or_link_campaign, + ensure_work_available, + measured_operation, + package_history, + validate_campaign_binding, +) +from .authoring_contract import ( + AUTHORING_SCHEMA_VERSION, + AuthoringContractError, + calculate_inventory_sha256, + seal_authoring_contract, + validate_authoring_contract, +) +from .content_paths import content_path +from .converter import ( + BEST_EFFORT_ENTITY_REVIEW_PREFIX, + SOURCE_ID_PATTERN, + analytic_rule_files, + inspect_solution, + convert_solution, + validate_document, + validate_solution, + solution_paths, + xdr_detection_files, +) +from .target_context import build_target, write_target +from .packaging import ( + package_input_file_hashes, + package_input_identity, + package_solution_v3_1, + solution_data_version, + validate_v31_target_version, + version_bump_was_consumed, +) +from .packaging_selection import ( + _partial_coverage_allowed, + verify_applied_package_selection, +) +from .source_runtime_waiver import SOURCE_PROVIDERS, missing_schema_whitelist_match + +STATE_FILE_NAME = "workflow-state.json" +SCHEMA_VERSION = "1.2.0" +WORKFLOW_PROFILES = ("authoring", "qualification") +STAGES = ( + "discovery", + "conversion", + "validation", + "packaging", + "deployment", + "mockIngestion", + "alertParity", + "report", +) +QUALIFICATION_DEPENDENCIES = { + "discovery": (), + "conversion": ("discovery",), + "validation": ("conversion",), + "packaging": ("validation",), + "deployment": ("packaging",), + "mockIngestion": ("deployment",), + "alertParity": ("mockIngestion",), + "report": ("alertParity",), +} +AUTHORING_DEPENDENCIES = { + "discovery": (), + "conversion": ("discovery",), + "validation": ("conversion",), + "packaging": ("validation",), + "report": ("packaging",), +} +AUTHORING_OPTIONAL_STAGES = {"deployment", "mockIngestion", "alertParity"} +TERMINAL_STATUSES = {"passed", "failed", "blocked"} +AUTHORING_CONTRACT_DIRECTORY = "authoring-contracts" +AUTHORING_LOCK_NAME = ".workflow-state.lock" +AUTHORING_CONTRACT_VERSION = "1.0.0" +_HELD_RUN_LOCKS: ContextVar[frozenset[str]] = ContextVar( + "held_yolo_workflow_locks", default=frozenset() +) + +try: + import fcntl +except ImportError: + fcntl = None + import msvcrt + + +@contextmanager +def _run_writer_lock(run_directory: Path): + """Serialize workflow-state and authoring-record writers; OS releases locks on exit.""" + run_directory.mkdir(parents=True, exist_ok=True) + key = str(run_directory.resolve()) + held = _HELD_RUN_LOCKS.get() + if key in held: + yield + return + lock_path = run_directory / AUTHORING_LOCK_NAME + with lock_path.open("a+b") as handle: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + else: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + token = _HELD_RUN_LOCKS.set(held | {key}) + try: + yield + finally: + _HELD_RUN_LOCKS.reset(token) + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + else: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + + +def _atomic_write(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", prefix=f".{path.name}.", suffix=".tmp", + dir=path.parent, delete=False, + ) as handle: + temporary = Path(handle.name) + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _atomic_json(path: Path, value: Any) -> bytes: + content = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + _atomic_write(path, content) + return content + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _version_action_was_consumed( + before: str | None, + after: str | None, + version_bump: str, + target_version: str | None, +) -> bool: + if target_version is not None: + return bool(before and after == target_version and before != after) + return version_bump_was_consumed(before, after, version_bump) + + +def _paths(solution: str | Path) -> tuple[Path, Path]: + root = Path(solution).expanduser().resolve() + if not root.is_dir(): + raise ValueError(f"solution folder does not exist: {root}") + return root, artifact_path(root, STATE_FILE_NAME) + + +def _schema() -> dict[str, Any]: + path = Path(__file__).resolve().parents[1] / "schema" / "workflow-state.schema.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def _validate(state: dict[str, Any]) -> None: + errors = sorted( + Draft202012Validator(_schema()).iter_errors(state), + key=lambda error: list(error.absolute_path), + ) + if errors: + details = "; ".join(error.message for error in errors) + raise ValueError(f"invalid workflow state: {details}") + + +def _migrate_workflow_state(state: dict[str, Any]) -> dict[str, Any]: + """Add additive packaging lifecycle fields when reading pre-1.2 workflow state.""" + packaging = state.get("packaging") + if isinstance(packaging, dict): + packaging.setdefault("releaseActions", []) + packaging.setdefault("pendingReleaseAction", None) + return state + + +def _write_unlocked(path: Path, state: dict[str, Any]) -> None: + root = solution_for_artifact(path) + destination = artifact_path(root, STATE_FILE_NAME, create_parent=True) + if path != destination: + raise ValueError("workflow state path does not belong to the selected run") + state["runId"] = destination.parent.name + state["updatedAt"] = _utc_now() + _validate(state) + _atomic_json(path, portable_artifact(root, state)) + + +def _write(path: Path, state: dict[str, Any]) -> None: + with _run_writer_lock(path.parent): + _write_unlocked(path, state) + + +@contextmanager +def _state_writer_lock(solution: str | Path): + root, path = _paths(solution) + with _run_writer_lock(path.parent): + yield path + + +def _load(solution: str | Path) -> tuple[Path, dict[str, Any]]: + root, _ = _paths(solution) + path = existing_artifact_path(root, STATE_FILE_NAME) + if not path.is_file(): + raise ValueError( + f"workflow state does not exist: {path}; run workflow-init first" + ) + state = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(state, dict): + raise ValueError(f"workflow state must be a JSON object: {path}") + if state.get("schemaVersion") == "1.1.0": + state["schemaVersion"] = SCHEMA_VERSION + _migrate_workflow_state(state) + elif state.get("schemaVersion") == SCHEMA_VERSION: + _migrate_workflow_state(state) + context = state.get("context") + if isinstance(context, dict): + context.setdefault("profileSelectionConfirmed", False) + _validate(state) + if (path.parent / RUN_MARKER).is_file() and state.get("runId") != path.parent.name: + raise ValueError("workflow state runId does not match its run directory") + return artifact_path(root, STATE_FILE_NAME), state + + +def _stage_result(name: str, *, required: bool = True) -> dict[str, Any]: + return { + "name": name, + "status": "pending" if required else "notRequired", + "attempts": 0, + "startedAt": None, + "completedAt": None, + "message": None if required else "not required for the authoring workflow", + "artifacts": {}, + "evidence": [], + } + + +def _source_inventory(solution: str | Path) -> list[dict[str, Any]]: + root, analytic, _ = solution_paths(solution) + files = analytic_rule_files(root) + if not files: + raise ValueError("YOLO requires a non-empty complete Analytic Rules inventory") + inventory: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for path in files: + source_bytes = path.read_bytes() + try: + document = yaml.safe_load(source_bytes.decode("utf-8-sig")) or {} + except (UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"cannot freeze source rule {path}: {exc}") from exc + if not isinstance(document, dict): + raise ValueError(f"source rule must be a YAML object: {path}") + rule_id = document.get("id") + query = document.get("query") + if not isinstance(rule_id, str) or not re.fullmatch(SOURCE_ID_PATTERN, rule_id): + raise ValueError(f"source rule has no valid full GUID identity: {path}") + if rule_id.casefold() in seen_ids: + raise ValueError(f"duplicate source rule identity in complete inventory: {rule_id}") + if not isinstance(query, str) or not query.strip(): + raise ValueError(f"source rule has no non-empty query: {path}") + seen_ids.add(rule_id.casefold()) + inventory.append({ + "ruleId": rule_id, + "path": path.relative_to(root).as_posix(), + "sha256": hashlib.sha256(source_bytes).hexdigest(), + "querySha256": hashlib.sha256(query.encode("utf-8")).hexdigest(), + "evidenceReferences": [{ + "path": path.relative_to(root).as_posix(), + "sha256": hashlib.sha256(source_bytes).hexdigest(), + "kind": "source", + }], + }) + inventory.sort(key=lambda item: item["path"]) + return inventory + + +def _budget_source_inventory( + solution: str | Path, + *, + yolo: bool, +) -> tuple[str, list[str]]: + root = Path(solution).expanduser().resolve() + if yolo: + inventory = _source_inventory(root) + return ( + calculate_inventory_sha256(inventory), + [rule["ruleId"] for rule in inventory], + ) + has_rule_folder = any( + (root / name).exists() or (root / name).is_symlink() + for name in ("Analytic Rules", "Analytics Rules") + ) + files = analytic_rule_files(root) if has_rule_folder else [] + rows = [] + rule_ids: set[str] = set() + for path in files: + content = path.read_bytes() + rows.append({ + "path": path.relative_to(root).as_posix(), + "sha256": hashlib.sha256(content).hexdigest(), + }) + try: + document = yaml.safe_load(content.decode("utf-8-sig")) or {} + except (UnicodeError, yaml.YAMLError): + document = {} + if ( + isinstance(document, dict) + and isinstance(document.get("id"), str) + and re.fullmatch(SOURCE_ID_PATTERN, document["id"]) + ): + rule_ids.add(document["id"]) + digest = hashlib.sha256( + json.dumps(rows, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + return digest, sorted(rule_ids) + + +def _preflight_campaign_binding( + solution: Path, + campaign_id: str | None, + *, + yolo: bool, + inventory: list[dict[str, Any]] | None = None, +) -> tuple[str, list[str]] | None: + if campaign_id is None: + return None + if yolo: + frozen = inventory if inventory is not None else _source_inventory(solution) + inventory_hash = calculate_inventory_sha256(frozen) + rule_ids = [rule["ruleId"] for rule in frozen] + else: + inventory_hash, rule_ids = _budget_source_inventory(solution, yolo=False) + validate_campaign_binding( + solution, + campaign_id, + inventory_sha256=inventory_hash, + rule_ids=rule_ids, + ) + return inventory_hash, rule_ids + + +def _initial_rule_result(rule: dict[str, Any]) -> dict[str, Any]: + return { + "ruleId": rule["ruleId"], + "sourceSha256": rule["sha256"], + "status": "unprocessed", + "conversionStatus": "unprocessed", + "validationStatus": "notRun", + "deploymentDisposition": "normal", + "target": None, + "reviewRequired": False, + "mappingAssumptions": [], + "findings": [], + "qualificationStatus": "untested", + "qualificationEvidenceReferences": [], + "evidenceReferences": [], + } + + +def _contract_core( + root: Path, + run_id: str, + inventory: list[dict[str, Any]], + *, + revision: int, + previous_sha256: str | None, + results: list[dict[str, Any]] | None = None, + environment_errors: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + inventory_sha256 = calculate_inventory_sha256(inventory) + return { + "schemaVersion": AUTHORING_SCHEMA_VERSION, + "runId": run_id, + "snapshot": { + "revision": revision, + "previousSha256": previous_sha256, + }, + "source": { + "identity": root.name, + "sha256": inventory_sha256, + }, + "inventory": { + "complete": True, + "sha256": inventory_sha256, + "rules": inventory, + }, + "results": results or [_initial_rule_result(rule) for rule in inventory], + "environmentErrors": environment_errors or [], + "entityValueParityPolicy": { + "assertion": "value-preserved-in-actual-corresponding-alert", + "comparison": "value-only", + "entityTypeIdentifierAndCustomFieldMayDiffer": True, + "requiredForQualification": True, + }, + } + + +def _persist_contract_snapshot( + run_directory: Path, + state: dict[str, Any], + core_record: dict[str, Any], +) -> tuple[dict[str, Any], str]: + try: + record = seal_authoring_contract(core_record) + except AuthoringContractError as exc: + raise ValueError(f"authoring contract is invalid: {exc}") from exc + content = (json.dumps(record, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + digest = hashlib.sha256(content).hexdigest() + revision = record["snapshot"]["revision"] + relative = f"{AUTHORING_CONTRACT_DIRECTORY}/{revision:06d}-{digest}.json" + destination = content_path(run_directory, relative) + if destination.exists(): + if destination.read_bytes() != content: + raise ValueError("authoring contract snapshot hash collision or conflicting artifact") + else: + destination.parent.mkdir(parents=True, exist_ok=True) + _atomic_write(destination, content) + previous_authoring = state.get("authoring") or {} + status = previous_authoring.get("status", "active") + state["authoring"] = { + "schemaVersion": AUTHORING_CONTRACT_VERSION, + "mode": "yolo", + "status": status, + "inventorySha256": record["inventory"]["sha256"], + "currentContract": { + "path": relative, + "sha256": digest, + "revision": revision, + }, + "stopReason": previous_authoring.get("stopReason"), + } + return record, digest + + +def _verify_source_inventory( + solution: Path, + contract_document: dict[str, Any], +) -> list[dict[str, Any]]: + current = _source_inventory(solution) + current_hash = calculate_inventory_sha256(current) + if current_hash != contract_document["inventory"]["sha256"]: + raise ValueError( + "frozen YOLO source inventory is stale or changed; preserve edits and start a new run" + ) + return current + + +def _load_contract_snapshot( + run_directory: Path, + reference: dict[str, Any], +) -> tuple[dict[str, Any], bytes]: + relative = reference["path"] + snapshot_path = content_path(run_directory, relative) + if snapshot_path.is_symlink() or not snapshot_path.is_file(): + raise ValueError("referenced authoring-contract snapshot is missing or unsafe") + content = snapshot_path.read_bytes() + digest = hashlib.sha256(content).hexdigest() + if digest != reference["sha256"] or snapshot_path.name != ( + f"{reference['revision']:06d}-{digest}.json" + ): + raise ValueError("authoring-contract snapshot hash or revision is stale") + try: + document = json.loads(content) + typed = validate_authoring_contract(document) + except (UnicodeError, json.JSONDecodeError, AuthoringContractError) as exc: + raise ValueError(f"referenced authoring-contract snapshot is invalid: {exc}") from exc + if ( + typed.run_id != run_directory.name + or typed.revision != reference["revision"] + or typed.inventory_sha256 != reference.get("inventorySha256", typed.inventory_sha256) + ): + raise ValueError("authoring-contract snapshot identity does not match workflow state") + return document, content + + +def _recover_yolo_state( + solution: Path, + state_path: Path, + state: dict[str, Any], +) -> dict[str, Any]: + """Roll forward one atomically stored contract snapshot after an interrupted state write.""" + authoring = state.get("authoring") + if not isinstance(authoring, dict) or authoring.get("mode") != "yolo": + return state + run_directory = state_path.parent + current = authoring.get("currentContract") + if not isinstance(current, dict): + raise ValueError("YOLO workflow state has no current authoring-contract reference") + current_document, _ = _load_contract_snapshot(run_directory, current) + inventory = _verify_source_inventory(solution, current_document) + if calculate_inventory_sha256(inventory) != authoring.get("inventorySha256"): + raise ValueError("workflow inventory hash does not match its frozen contract") + + history = content_path(run_directory, AUTHORING_CONTRACT_DIRECTORY) + candidates: list[tuple[dict[str, Any], dict[str, Any]]] = [] + if history.exists(): + if history.is_symlink() or not history.is_dir(): + raise ValueError("authoring-contract history path is unsafe") + for snapshot_path in sorted(history.glob("*.json")): + if snapshot_path.is_symlink(): + raise ValueError("authoring-contract history contains an unsafe symlink") + try: + candidate_bytes = snapshot_path.read_bytes() + candidate_hash = hashlib.sha256(candidate_bytes).hexdigest() + candidate = json.loads(candidate_bytes) + typed = validate_authoring_contract(candidate) + except (OSError, UnicodeError, json.JSONDecodeError, AuthoringContractError) as exc: + raise ValueError(f"invalid authoring-contract history snapshot: {snapshot_path.name}") from exc + if snapshot_path.name != f"{typed.revision:06d}-{candidate_hash}.json": + raise ValueError(f"authoring-contract history filename is stale: {snapshot_path.name}") + if typed.run_id != run_directory.name or typed.inventory_sha256 != authoring["inventorySha256"]: + raise ValueError("authoring-contract history contains a conflicting run or inventory") + if ( + typed.revision == current["revision"] + 1 + and typed.previous_sha256 == current["sha256"] + ): + candidates.append((candidate, { + "path": snapshot_path.relative_to(run_directory).as_posix(), + "sha256": candidate_hash, + "revision": typed.revision, + })) + if len(candidates) > 1: + raise ValueError("multiple authoring-contract recovery successors conflict") + if not candidates: + return state + + recovered, reference = candidates[0] + authoring["currentContract"] = reference + coverage = recovered["coverage"] + if coverage["stopThresholdReached"] and authoring["status"] == "active": + authoring["status"] = "stopped" + authoring["stopReason"] = ( + f"Durable rule-error threshold reached: {coverage['E']}/{coverage['N']} " + "(100*E > 50*N)." + ) + state["workflowStatus"] = "blocked" + _write_unlocked(state_path, state) + return state + + +def _reference_for_file(root: Path, path: Path, kind: str) -> dict[str, str]: + return { + "path": path.relative_to(root).as_posix(), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "kind": kind, + } + + +def _runtime_observations( + root: Path, + run_directory: Path, + inventory: list[dict[str, Any]], + results: list[dict[str, Any]], +) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]], bool]: + """Consume existing provider reports only; never invent provider or query outcomes.""" + _, _, output = solution_paths(root) + output_prefix = output.relative_to(root).as_posix() + "/" + result_by_id = {item["ruleId"]: item for item in results} + detection_to_rule: dict[str, str] = {} + expected_hashes: dict[str, dict[str, str]] = {} + for rule in inventory: + result = result_by_id[rule["ruleId"]] + target = result.get("target") + if not target: + continue + target_path = target["path"] + if not target_path.startswith(output_prefix): + continue + detection = target_path[len(output_prefix):] + if detection in detection_to_rule: + raise ValueError(f"duplicate generated target path in YOLO contract: {detection}") + detection_to_rule[detection] = rule["ruleId"] + expected_hashes[detection] = { + "triage-mcp": rule["querySha256"], + "azure-monitor-sdk": rule["querySha256"], + "log-analytics-cli": rule["querySha256"], + "graph": target.get("querySha256") or "", + } + + runtime_findings: dict[str, list[dict[str, Any]]] = { + rule["ruleId"]: [] for rule in inventory + } + environment_errors: list[dict[str, Any]] = [] + environment_keys: set[tuple[str, str]] = set() + all_reports_passed = bool(expected_hashes) + source_reports = [ + ("triage-mcp", run_directory / "runtime-validation.triage-mcp.json"), + ("azure-monitor-sdk", run_directory / "runtime-validation.azure-monitor-sdk.json"), + ("log-analytics-cli", run_directory / "runtime-validation.log-analytics-cli.json"), + ] + available_source_reports = [ + (provider, path) for provider, path in source_reports if path.is_file() + ] + selected_reports: list[tuple[str, Path]] = [] + if available_source_reports: + selected = available_source_reports[0] + if selected[0] == "triage-mcp": + try: + candidate = json.loads(selected[1].read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"existing {selected[0]} runtime report is invalid: {exc}") from exc + if isinstance(candidate, dict) and isinstance(candidate.get("providerFailure"), dict): + for fallback in available_source_reports[1:]: + fallback_document = json.loads( + fallback[1].read_text(encoding="utf-8-sig") + ) + proof = ( + fallback_document.get("providerFallback") + if isinstance(fallback_document, dict) else None + ) or {} + if ( + proof.get("mcpProvider") == "triage-mcp" + and proof.get("fallbackProvider") == fallback[0] + and proof.get("batchComplete") is True + and proof.get("scopeVerified") is True + and proof.get("permissionsVerified") is True + and proof.get("kqlError") is False + ): + selected = fallback + break + selected_reports.append(selected) + if not selected_reports: + all_reports_passed = False + selected_reports.append( + ("graph", run_directory / "runtime-validation.graph.json") + ) + for provider, report_path in selected_reports: + if not report_path.is_file(): + all_reports_passed = False + continue + try: + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"existing {provider} runtime report is invalid: {exc}") from exc + if not isinstance(report, dict) or report.get("provider") != provider: + raise ValueError(f"existing {provider} runtime report has a mismatched provider") + reference = _reference_for_file(root, report_path, "runtimeVerification") + shared_failure = False + for field in ("providerFailure",): + failure = report.get(field) + if isinstance(failure, dict): + shared_failure = True + code = str(failure.get("code") or failure.get("failureKind") or "providerFailure") + message = str(failure.get("error") or failure.get("message") or "Provider failure recorded") + if (provider, code) not in environment_keys: + environment_errors.append({ + "code": f"{provider}:{code}", + "message": message, + "evidenceReferences": [reference], + }) + environment_keys.add((provider, code)) + availability = report.get("tableAvailability") + if isinstance(availability, dict): + for table, item in availability.items(): + if isinstance(item, dict) and item.get("available") is False: + shared_failure = True + code = f"tableUnavailable:{table}" + if (provider, code) not in environment_keys: + environment_errors.append({ + "code": f"{provider}:{code}", + "message": str(item.get("error") or f"Provider report marks {table} unavailable"), + "evidenceReferences": [reference], + }) + environment_keys.add((provider, code)) + entries = report.get("results") + if not isinstance(entries, list): + raise ValueError(f"existing {provider} runtime report has no results list") + names: set[str] = set() + waived_source_failures: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + for entry in entries: + if not isinstance(entry, dict): + raise ValueError(f"existing {provider} runtime report contains a malformed result") + detection = str(entry.get("detection") or "").replace("\\", "/") + if detection in names: + raise ValueError(f"existing {provider} runtime report duplicates {detection}") + names.add(detection) + rule_id = detection_to_rule.get(detection) + if rule_id is None: + raise ValueError(f"existing {provider} runtime report contains unknown detection {detection}") + expected = expected_hashes[detection][provider] + query_hash = entry.get("querySha256") + if not expected or query_hash != expected: + raise ValueError( + f"stale {provider} query hash for {detection}; refresh current results before continuing" + ) + status = str(entry.get("status") or "").lower() + if status == "passed": + continue + all_reports_passed = False + if status == "not-run": + continue + if status not in {"failed", "blocked"}: + raise ValueError(f"invalid {provider} result status {status!r} for {detection}") + if status == "blocked" and shared_failure: + continue + source_whitelist_match = None + if ( + status == "failed" + and provider in SOURCE_PROVIDERS + and report.get("querySurface") in ( + None, + SOURCE_PROVIDERS[provider], + ) + and not report.get("providerFallback") + and report.get("familyComplete") is not False + and not shared_failure + ): + source_whitelist_match = missing_schema_whitelist_match( + entry, + provider=provider, + expected_query_sha256=expected, + ) + if source_whitelist_match: + waived_source_failures.append( + (rule_id, entry, source_whitelist_match) + ) + continue + message = str(entry.get("error") or f"{provider} reported {status}") + runtime_findings[rule_id].append({ + "findingId": f"runtime-{provider}-{len(runtime_findings[rule_id]) + 1}", + "kind": "error", + "message": message, + "unresolved": True, + "evidenceReferences": [reference], + }) + if names != set(detection_to_rule): + all_reports_passed = False + if report.get("familyComplete") is False: + all_reports_passed = False + family_is_complete = ( + names == set(detection_to_rule) + and report.get("familyComplete") is not False + and not shared_failure + and not report.get("providerFallback") + and report.get("querySurface") + in (None, SOURCE_PROVIDERS.get(provider)) + ) + for rule_id, entry, match in waived_source_failures: + if family_is_complete: + finding_id = ( + f"runtime-whitelist-{provider}-" + f"{len(runtime_findings[rule_id]) + 1}" + ) + runtime_findings[rule_id].append({ + "findingId": finding_id, + "kind": "error", + "message": ( + f"Source query is source-waived for {match['table']}: " + f"{match['reason']} Raw provider status remains " + f"{entry.get('status')!r}: {entry.get('error')!s}" + ), + "unresolved": False, + "evidenceReferences": [reference], + }) + else: + runtime_findings[rule_id].append({ + "findingId": ( + f"runtime-{provider}-" + f"{len(runtime_findings[rule_id]) + 1}" + ), + "kind": "error", + "message": str( + entry.get("error") + or f"{provider} reported {entry.get('status')}" + ), + "unresolved": True, + "evidenceReferences": [reference], + }) + if not expected_hashes: + all_reports_passed = False + return runtime_findings, environment_errors, all_reports_passed + + +def _conversion_contract_results( + root: Path, + run_directory: Path, + inventory: list[dict[str, Any]], + conversion: dict[str, Any], + validation: dict[str, Any], + previous_record: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + _, analytic, output = solution_paths(root) + scope = conversion.get("scope") or {} + if ( + scope.get("kind") != "solution" + or scope.get("sourceTotal") != len(inventory) + or scope.get("selectedTotal") != len(inventory) + or len(conversion.get("results") or []) != len(inventory) + ): + raise ValueError("conversion results do not cover the frozen full source inventory") + conversion_by_path: dict[str, dict[str, Any]] = {} + for item in conversion["results"]: + source_path = str(item.get("sourceRelativePath") or "").replace("\\", "/") + if source_path in conversion_by_path: + raise ValueError(f"conversion results duplicate source path: {source_path}") + conversion_by_path[source_path] = item + expected_sources = {rule["path"] for rule in inventory} + if set(conversion_by_path) != expected_sources: + raise ValueError( + "conversion result paths do not match the frozen inventory " + f"(missing={sorted(expected_sources - set(conversion_by_path))}, " + f"extra={sorted(set(conversion_by_path) - expected_sources)})" + ) + + validation_by_path: dict[str, dict[str, Any]] = {} + for item in validation.get("results") or []: + try: + target_path = Path(item["file"]).expanduser().resolve() + relative = target_path.relative_to(root).as_posix() + except (KeyError, OSError, ValueError) as exc: + raise ValueError("structural validation returned an invalid target path") from exc + if relative in validation_by_path: + raise ValueError(f"structural validation duplicates target path: {relative}") + validation_by_path[relative] = item + + manifest_path = run_directory / "manifest.json" + if not manifest_path.is_file(): + raise ValueError("full conversion did not persist its current manifest") + manifest_reference = _reference_for_file(root, manifest_path, "other") + previous_results = { + item["ruleId"]: item for item in previous_record.get("results", []) + } + results: list[dict[str, Any]] = [] + output_source_prefix = output.relative_to(root).as_posix() + "/" + analytic_prefix = analytic.relative_to(root).as_posix() + "/" + for rule in inventory: + source_path = rule["path"] + prior = previous_results.get(rule["ruleId"], {}) + item = conversion_by_path[source_path] + output_path = str(item.get("outputRelativePath") or "").replace("\\", "/") + source_suffix = source_path[len(analytic_prefix):] if source_path.startswith(analytic_prefix) else None + expected_output = output_source_prefix + source_suffix if source_suffix is not None else None + if not expected_output or output_path != expected_output: + raise ValueError( + f"conversion output path conflicts with source inventory for {rule['ruleId']}" + ) + conversion_status = item.get("status") + if conversion_status not in {"converted", "needsReview", "conflict", "excluded"}: + raise ValueError(f"unsupported conversion result for {rule['ruleId']}: {conversion_status!r}") + error_messages = [str(value) for value in item.get("errors") or [] if str(value).strip()] + review_messages = [ + str(value) for value in item.get("reviewReasons") or [] if str(value).strip() + ] + warning_messages = [ + str(value) for value in item.get("warnings") or [] if str(value).strip() + ] + mapping_assumptions = list(prior.get("mappingAssumptions") or []) + mapping_assumptions.extend( + reason for reason in review_messages + if reason.startswith(BEST_EFFORT_ENTITY_REVIEW_PREFIX) + and reason not in mapping_assumptions + ) + if conversion_status == "needsReview" and not error_messages: + error_messages.extend(review_messages or ["Conversion is blocked pending technical review"]) + elif conversion_status == "conflict" and not error_messages: + error_messages.append("Conversion reported an artifact or identity conflict") + elif conversion_status == "excluded": + error_messages.append("Source rule was excluded; full-solution scope is not complete") + + target_path = content_path(root, output_path) + target: dict[str, Any] | None = None + structural = validation_by_path.get(output_path) + target_document: dict[str, Any] | None = None + if conversion_status in {"converted", "needsReview"} and target_path.is_file(): + target_bytes = target_path.read_bytes() + target_digest = hashlib.sha256(target_bytes).hexdigest() + previous_target = prior.get("target") + if previous_target and previous_target.get("sha256") != target_digest: + raise ValueError( + f"generated target changed since its prior YOLO result: {output_path}; " + "preserve the edit and reconcile in a new run" + ) + try: + target_document = yaml.safe_load(target_bytes.decode("utf-8-sig")) or {} + except (UnicodeError, yaml.YAMLError) as exc: + error_messages.append(f"generated target YAML is invalid: {exc}") + if not isinstance(target_document, dict): + error_messages.append("generated target must be a YAML object") + target_document = None + provenance = ( + ((target_document.get("contentProvenance") or {}).get("source") or {}) + if target_document else {} + ) + if ( + provenance.get("path") != source_path + or str(provenance.get("id") or "").casefold() != rule["ruleId"].casefold() + ): + error_messages.append("generated target provenance conflicts with frozen source identity") + query_text = ( + ((target_document.get("properties") or {}).get("queryCondition") or {}).get("queryText") + if target_document else None + ) + if not isinstance(query_text, str) or not query_text.strip(): + error_messages.append("generated target has no Advanced Hunting query") + query_hash = None + else: + query_hash = hashlib.sha256(query_text.encode("utf-8")).hexdigest() + structurally_valid = bool(structural and structural.get("valid") is True) + target = { + "path": output_path, + "sha256": target_digest, + "querySha256": query_hash, + "structurallyValid": structurally_valid, + } + if not structural: + error_messages.append("generated target has no structural validation result") + elif not structurally_valid: + error_messages.extend(str(value) for value in structural.get("errors") or []) + else: + if conversion_status in {"converted", "needsReview"}: + error_messages.append("conversion target is missing") + elif target_path.exists() and target_path.is_symlink(): + raise ValueError(f"conversion target path is an unsafe symlink: {output_path}") + + errors = list(dict.fromkeys(message for message in error_messages if message.strip())) + valid_target = target is not None and target["structurallyValid"] + status = "migrated" if conversion_status == "converted" and valid_target and not errors else "blocked" + disposition = "normal" if status == "migrated" else "authoringBlocked" + finding_records: list[dict[str, Any]] = [] + for index, message in enumerate(errors, 1): + finding_records.append({ + "findingId": f"authoring-error-{index}", + "kind": "error", + "message": message, + "unresolved": True, + "evidenceReferences": [manifest_reference], + }) + for index, message in enumerate(warning_messages, 1): + finding_records.append({ + "findingId": f"conversion-warning-{index}", + "kind": "warning", + "message": message, + "unresolved": False, + "evidenceReferences": [manifest_reference], + }) + if conversion_status == "converted" and item.get("reviewRequired") is True: + for index, message in enumerate(review_messages, 1): + finding_records.append({ + "findingId": f"review-pending-{index}", + "kind": "warning", + "message": message, + "unresolved": False, + "evidenceReferences": [manifest_reference], + }) + target_references = ( + [{ + "path": target["path"], + "sha256": target["sha256"], + "kind": "generatedTarget", + }] + if target else [] + ) + results.append({ + "ruleId": rule["ruleId"], + "sourceSha256": rule["sha256"], + "status": status, + "conversionStatus": conversion_status, + "validationStatus": ( + "passed" if structural and structural.get("valid") is True + else "failed" if structural else "notRun" + ), + "deploymentDisposition": disposition, + "target": target, + "reviewRequired": bool(item.get("reviewRequired")), + "mappingAssumptions": mapping_assumptions, + "findings": finding_records, + "qualificationStatus": "untested", + "qualificationEvidenceReferences": [], + "evidenceReferences": [manifest_reference, *target_references], + }) + + runtime_findings, environment_errors, _ = _runtime_observations( + root, run_directory, inventory, results + ) + for result in results: + additional = runtime_findings[result["ruleId"]] + if not additional: + continue + result["findings"].extend(additional) + result["status"] = "blocked" + result["deploymentDisposition"] = "authoringBlocked" + for finding in additional: + for reference in finding["evidenceReferences"]: + if reference not in result["evidenceReferences"]: + result["evidenceReferences"].append(reference) + return results, environment_errors + + +def _current_yolo_record( + solution: Path, + state_path: Path, + state: dict[str, Any], +) -> tuple[dict[str, Any], bytes]: + authoring = state.get("authoring") + if not isinstance(authoring, dict) or authoring.get("mode") != "yolo": + raise ValueError("this workflow was not explicitly initialized in YOLO mode") + reference = authoring["currentContract"] + document, content = _load_contract_snapshot(state_path.parent, reference) + if document["inventory"]["sha256"] != authoring["inventorySha256"]: + raise ValueError("workflow state and authoring contract inventory hashes differ") + _verify_source_inventory(solution, document) + _verify_target_hashes(solution, document) + return document, content + + +def read_authoring_contract(solution: str | Path) -> dict[str, Any]: + """Read only the exact content-addressed contract referenced by selected workflow state.""" + root = Path(solution).expanduser().resolve() + path, state = _load(root) + if not state.get("authoring"): + raise ValueError("selected workflow has no explicit YOLO authoring contract") + document, _ = _load_contract_snapshot(path.parent, state["authoring"]["currentContract"]) + if document["runId"] != state.get("runId"): + raise ValueError("authoring contract is bound to a different workflow run") + return document + + +def _verify_target_hashes(solution: Path, document: dict[str, Any]) -> None: + for result in document["results"]: + target = result.get("target") + if target is None: + continue + path = content_path(solution, target["path"]) + if path.is_symlink() or not path.is_file(): + raise ValueError(f"previously recorded target is missing or unsafe: {target['path']}") + if hashlib.sha256(path.read_bytes()).hexdigest() != target["sha256"]: + raise ValueError( + f"generated target hash is stale: {target['path']}; preserve user edits and start a new run" + ) + + +def _validate_current_yolo_targets( + solution: Path, + record: dict[str, Any], +) -> None: + root = solution.expanduser().resolve() + inventory = record.get("inventory", {}).get("rules") or [] + results = record.get("results") or [] + results_by_id = { + item.get("ruleId"): item + for item in results + if isinstance(item, dict) + } + inventory_by_id = { + item.get("ruleId"): item + for item in inventory + if isinstance(item, dict) + } + if ( + not inventory_by_id + or len(inventory_by_id) != len(inventory) + or set(results_by_id) != set(inventory_by_id) + or len(results_by_id) != len(results) + ): + raise ValueError( + "source-waived validation requires current YOLO results for the complete source inventory" + ) + + _verify_target_hashes(root, record) + expected_outputs: set[str] = set() + for rule_id, rule in inventory_by_id.items(): + source_path = str(rule.get("path") or "").replace("\\", "/") + source_parts = Path(source_path).parts + if ( + len(source_parts) < 2 + or source_parts[0].casefold() not in {"analytic rules", "analytics rules"} + ): + raise ValueError( + "source-waived validation requires safe full-inventory YOLO source paths" + ) + expected_output = ( + Path("XDR Detections") / Path(*source_parts[1:]) + ).as_posix() + expected_outputs.add(expected_output) + result = results_by_id[rule_id] + target = result.get("target") + if not isinstance(target, dict) or target.get("path") != expected_output: + raise ValueError( + "source-waived validation requires a current generated target for every YOLO inventory rule" + ) + target_path = content_path(root, expected_output) + try: + document = yaml.safe_load(target_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError( + f"current YOLO target is unreadable or invalid: {expected_output}" + ) from exc + if not isinstance(document, dict): + raise ValueError(f"current YOLO target is not a YAML object: {expected_output}") + provenance = document.get("contentProvenance") or {} + source = provenance.get("source") or {} + conversion = provenance.get("conversion") or {} + if ( + source.get("path") != source_path + or source.get("id") != rule_id + or source.get("querySha256") != rule.get("querySha256") + or conversion.get("status") != "converted" + or conversion.get("reviewRequired") is not False + or conversion.get("errors") not in ([], None) + ): + raise ValueError( + "source-waived validation is blocked by current YOLO target provenance, " + "conversion errors, or review requirements" + ) + structural_errors = validate_document(document) + if structural_errors: + raise ValueError( + "source-waived validation is blocked by current YOLO target technical errors: " + + "; ".join(structural_errors) + ) + + current_outputs = { + path.relative_to(root).as_posix() + for path in xdr_detection_files(root / "XDR Detections") + } + if current_outputs != expected_outputs: + raise ValueError( + "source-waived validation requires current YOLO targets to match the complete source inventory" + ) + + +def _refresh_yolo_record_unlocked( + solution: Path, + state_path: Path, + state: dict[str, Any], + previous_record: dict[str, Any], +) -> tuple[dict[str, Any], bool, dict[str, Any]]: + _verify_target_hashes(solution, previous_record) + inventory = previous_record["inventory"]["rules"] + results = json.loads(json.dumps(previous_record["results"])) + for result in results: + result["findings"] = [ + finding for finding in result["findings"] + if not finding["findingId"].startswith("runtime-") + ] + result["evidenceReferences"] = [ + reference for reference in result["evidenceReferences"] + if reference["kind"] != "runtimeVerification" + ] + unresolved = any( + finding["kind"] == "error" and finding["unresolved"] + for finding in result["findings"] + ) + if ( + result.get("conversionStatus") == "converted" + and result.get("validationStatus") == "passed" + and (result.get("target") or {}).get("structurallyValid") is True + and not unresolved + ): + result["status"] = "migrated" + result["deploymentDisposition"] = "normal" + elif result["status"] != "suppressed": + result["status"] = "blocked" if unresolved or result.get("target") else "unprocessed" + result["deploymentDisposition"] = ( + "authoringBlocked" if result["status"] == "blocked" else "normal" + ) + + runtime_findings, environment_errors, _ = _runtime_observations( + solution, state_path.parent, inventory, results + ) + for result in results: + findings = runtime_findings[result["ruleId"]] + if findings: + result["findings"].extend(findings) + if any( + finding.get("kind") == "error" + and finding.get("unresolved") is True + for finding in findings + ): + result["status"] = "blocked" + result["deploymentDisposition"] = "authoringBlocked" + for finding in findings: + for reference in finding["evidenceReferences"]: + if reference not in result["evidenceReferences"]: + result["evidenceReferences"].append(reference) + + current_reference = state["authoring"]["currentContract"] + if ( + results == previous_record["results"] + and environment_errors == previous_record["environmentErrors"] + ): + return previous_record, False, environment_errors + core = _contract_core( + solution, + state_path.parent.name, + inventory, + revision=current_reference["revision"] + 1, + previous_sha256=current_reference["sha256"], + results=results, + environment_errors=environment_errors, + ) + record, digest = _persist_contract_snapshot(state_path.parent, state, core) + coverage = record["coverage"] + authoring = state["authoring"] + if coverage["stopThresholdReached"] and authoring["status"] == "active": + authoring["status"] = "stopped" + authoring["stopReason"] = ( + f"Durable rule-error threshold reached: {coverage['E']}/{coverage['N']} " + "(100*E > 50*N)." + ) + state["workflowStatus"] = "blocked" + elif authoring["status"] == "stopped": + state["workflowStatus"] = "blocked" + _write_unlocked(state_path, state) + return record, True, environment_errors + + +def refresh_yolo_authoring(solution: str | Path) -> dict[str, Any]: + """Import current hash-bound provider reports without rerunning conversion or validation.""" + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as state_path: + path, state = _load(root) + if path != state_path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + with using_run(root, state["runId"]): + state = _recover_yolo_state(root, path, state) + if state["authoring"]["status"] == "conflicted": + raise ValueError("conflicted YOLO runs cannot be refreshed") + previous, _ = _current_yolo_record(root, path, state) + record, updated, environment_errors = _refresh_yolo_record_unlocked( + root, path, state, previous + ) + state = _load(root)[1] + if state["authoring"]["status"] == "active": + state = _complete_runtime_pending_stage(root, path, state, record) + return { + "runId": state["runId"], + "status": state["authoring"]["status"], + "authoringContract": state["authoring"]["currentContract"], + "coverage": record["coverage"], + "environmentErrorCount": len(environment_errors), + "updated": updated, + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + } + + +def _mark_yolo_conflicted( + state_path: Path, + state: dict[str, Any], + reason: str, +) -> None: + state["authoring"]["status"] = "conflicted" + state["authoring"]["stopReason"] = reason + state["workflowStatus"] = "blocked" + _write_unlocked(state_path, state) + + +def _read_full_conversion_manifest( + root: Path, + run_directory: Path, + inventory: list[dict[str, Any]], + expected_sha256: str | None = None, +) -> dict[str, Any]: + manifest_path = run_directory / "manifest.json" + if not manifest_path.is_file() or manifest_path.is_symlink(): + raise ValueError("full-solution conversion manifest is missing or unsafe") + content = manifest_path.read_bytes() + digest = hashlib.sha256(content).hexdigest() + if expected_sha256 and digest != expected_sha256: + raise ValueError("conversion manifest hash changed after its workflow stage") + try: + document = json.loads(content) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"full-solution conversion manifest is invalid: {exc}") from exc + if not isinstance(document, dict): + raise ValueError("full-solution conversion manifest must be a JSON object") + scope = document.get("scope") or {} + results = document.get("results") + if ( + not isinstance(scope, dict) + or scope.get("kind") != "solution" + or scope.get("sourceTotal") != len(inventory) + or scope.get("selectedTotal") != len(inventory) + or not isinstance(results, list) + or len(results) != len(inventory) + ): + raise ValueError("conversion manifest does not cover the frozen complete source inventory") + return document + + +def _validate_clean_full_conversion_manifest( + root: Path, + inventory: list[dict[str, Any]], + manifest: dict[str, Any], +) -> set[str]: + total = len(inventory) + if any( + type(manifest.get(field)) is not int or manifest[field] != expected + for field, expected in ( + ("total", total), + ("converted", total), + ("excluded", 0), + ("needsReview", 0), + ("reviewRequired", 0), + ("conflicts", 0), + ) + ): + raise ValueError( + "conversion manifest is not a complete, technically clean full-solution result" + ) + _, analytic, _ = solution_paths(root) + analytic_prefix = analytic.relative_to(root).as_posix() + "/" + inventory_by_path = {item["path"]: item for item in inventory} + seen_sources: set[str] = set() + expected_outputs: set[str] = set() + for item in manifest["results"]: + if not isinstance(item, dict): + raise ValueError("conversion manifest contains a malformed result") + source_reference = str(item.get("sourceRelativePath") or "").replace("\\", "/") + suffix = ( + source_reference[len(analytic_prefix):] + if source_reference.startswith(analytic_prefix) + else None + ) + expected_output = ( + (Path("XDR Detections") / Path(suffix)).as_posix() + if suffix + else None + ) + output_reference = str(item.get("outputRelativePath") or "").replace("\\", "/") + if ( + source_reference not in inventory_by_path + or source_reference in seen_sources + or expected_output is None + or output_reference != expected_output + or output_reference in expected_outputs + or item.get("status") != "converted" + or item.get("reviewRequired") is not False + or item.get("errors") not in ([], None) + ): + raise ValueError( + "conversion manifest has scoped, stale, incomplete, technical, " + "or unresolved-review results" + ) + seen_sources.add(source_reference) + expected_outputs.add(output_reference) + if seen_sources != set(inventory_by_path): + raise ValueError( + "conversion manifest does not match the current complete source inventory" + ) + return expected_outputs + + +def _source_waiver_unrelated_blockers(record: dict[str, Any]) -> list[str]: + blockers = [] + for result in record.get("results") or []: + if ( + result.get("status") != "migrated" + or result.get("conversionStatus") != "converted" + or result.get("validationStatus") != "passed" + or result.get("reviewRequired") is True + or (result.get("target") or {}).get("structurallyValid") is not True + or any( + isinstance(finding, dict) + and finding.get("unresolved") is True + and finding.get("kind") in {"error", "review"} + for finding in result.get("findings") or [] + ) + ): + blockers.append(str(result.get("ruleId") or "")) + return blockers + + +def _validate_source_waiver_conversion_gates( + solution: Path, + state_path: Path, + state: dict[str, Any], +) -> None: + if any( + state["stages"][stage].get("status") != "passed" + for stage in ("discovery", "conversion") + ): + raise ValueError( + "source-waived validation requires passed discovery and full-solution conversion stages" + ) + conversion_stage = state["stages"]["conversion"] + + if state.get("authoring"): + record, _ = _current_yolo_record(solution, state_path, state) + _validate_current_yolo_targets(solution, record) + blockers = _source_waiver_unrelated_blockers(record) + if blockers: + raise ValueError( + "source-waived validation is blocked by unrelated technical or review gates: " + + ", ".join(blockers) + ) + return + + manifest_path = state_path.parent / "manifest.json" + conversion_artifacts = conversion_stage.get("artifacts") + expected_hash = ( + conversion_artifacts.get("manifestSha256") + if isinstance(conversion_artifacts, dict) + else None + ) + if not manifest_path.is_file() or not isinstance(expected_hash, str): + raise ValueError( + "source-waived validation requires a current full-solution conversion manifest" + ) + manifest_bytes = manifest_path.read_bytes() + if hashlib.sha256(manifest_bytes).hexdigest() != expected_hash: + raise ValueError("source-waived validation conversion manifest is stale") + try: + manifest = json.loads(manifest_bytes) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ValueError("source-waived validation conversion manifest is invalid") from exc + scope = manifest.get("scope") if isinstance(manifest, dict) else None + results = manifest.get("results") if isinstance(manifest, dict) else None + total = manifest.get("total") if isinstance(manifest, dict) else None + if ( + not isinstance(scope, dict) + or scope.get("kind") != "solution" + or type(total) is not int + or total < 1 + or scope.get("sourceTotal") != total + or scope.get("selectedTotal") != total + or not isinstance(results, list) + or len(results) != total + or manifest.get("converted") != total + or manifest.get("excluded") != 0 + or manifest.get("needsReview") != 0 + or manifest.get("reviewRequired") != 0 + or manifest.get("conflicts") != 0 + ): + raise ValueError( + "source-waived validation requires complete, technically clean full-solution conversion" + ) + + root = solution.expanduser().resolve() + expected_outputs: set[str] = set() + for item in results: + if ( + not isinstance(item, dict) + or item.get("status") != "converted" + or item.get("reviewRequired") is not False + or item.get("errors") not in ([], None) + ): + raise ValueError( + "source-waived validation is blocked by unrelated conversion or review findings" + ) + source_reference = str(item.get("sourceRelativePath") or "").replace("\\", "/") + output_reference = str(item.get("outputRelativePath") or "").replace("\\", "/") + parts = Path(source_reference).parts + if ( + len(parts) < 2 + or parts[0].casefold() not in {"analytic rules", "analytics rules"} + or output_reference != (Path("XDR Detections") / Path(*parts[1:])).as_posix() + or output_reference in expected_outputs + ): + raise ValueError( + "source-waived validation conversion manifest has incomplete or conflicting output paths" + ) + expected_outputs.add(output_reference) + target_path = content_path(root, output_reference) + if not target_path.is_file() or target_path.is_symlink(): + raise ValueError( + "source-waived validation conversion target is missing or unsafe" + ) + try: + document = yaml.safe_load(target_path.read_text(encoding="utf-8-sig")) or {} + except (OSError, yaml.YAMLError) as exc: + raise ValueError("source-waived validation conversion target is invalid") from exc + conversion = ( + ((document.get("contentProvenance") or {}).get("conversion") or {}) + if isinstance(document, dict) + else {} + ) + if ( + not isinstance(conversion, dict) + or conversion.get("status") != "converted" + or conversion.get("reviewRequired") is not False + or conversion.get("errors") not in ([], None) + ): + raise ValueError( + "source-waived validation is blocked by current Custom Detection review or conversion findings" + ) + + output_directory = root / "XDR Detections" + current_outputs = { + path.relative_to(root).as_posix() + for path in xdr_detection_files(output_directory) + } + if current_outputs != expected_outputs: + raise ValueError( + "source-waived validation requires conversion targets to match the full manifest inventory" + ) + structural = validate_solution(root) + if ( + structural.get("conversionScope", {}).get("kind") != "solution" + or structural.get("total") != total + or structural.get("invalid") != 0 + or len(structural.get("results") or []) != total + or any(item.get("valid") is not True for item in structural.get("results") or []) + ): + raise ValueError( + "source-waived validation requires current full-solution structural validation to pass" + ) + + +def _validate_offline_authoring_gates( + solution: Path, + state_path: Path, + state: dict[str, Any], + *, + record: dict[str, Any] | None = None, +) -> None: + """Require fresh full-inventory local evidence before offline Authoring completion.""" + if state.get("context", {}).get("workflowProfile") != "authoring": + raise ValueError("offline validation completion is available only for Authoring") + if any( + state["stages"][stage].get("status") != "passed" + for stage in ("discovery", "conversion") + ): + raise ValueError( + "offline Authoring validation requires passed discovery and full-solution conversion" + ) + runtime_reports = [ + report_directory(solution) / f"runtime-validation.{provider}.json" + for provider in ("triage-mcp", "azure-monitor-sdk", "log-analytics-cli", "graph") + ] + present_reports = [path.name for path in runtime_reports if path.exists()] + if present_reports: + raise ValueError( + "offline Authoring cannot replace existing runtime evidence; inspect current " + "reports and preserve their results: " + ", ".join(present_reports) + ) + + root = solution.expanduser().resolve() + inventory = _source_inventory(root) + conversion_artifacts = state["stages"]["conversion"].get("artifacts") or {} + expected_manifest_hash = conversion_artifacts.get("manifestSha256") + if not isinstance(expected_manifest_hash, str) or not re.fullmatch( + r"[a-f0-9]{64}", expected_manifest_hash + ): + raise ValueError( + "offline Authoring requires a hash-bound full-solution conversion manifest; " + "run workflow-recover-conversion-evidence for eligible legacy evidence" + ) + manifest = _read_full_conversion_manifest( + root, + state_path.parent, + inventory, + expected_sha256=expected_manifest_hash, + ) + manifest_outputs = _validate_clean_full_conversion_manifest( + root, + inventory, + manifest, + ) + if state.get("authoring"): + if record is None: + record, _ = _current_yolo_record(root, state_path, state) + _validate_current_yolo_targets(root, record) + blockers = _source_waiver_unrelated_blockers(record) + if blockers: + raise ValueError( + "offline Authoring validation is blocked by current technical or review " + "findings: " + ", ".join(blockers) + ) + expected_outputs = { + (Path("XDR Detections") / Path(*Path(rule["path"]).parts[1:])).as_posix() + for rule in inventory + } + else: + _validate_source_waiver_conversion_gates(root, state_path, state) + analytic, output = solution_paths(root)[1:] + analytic_prefix = analytic.relative_to(root).as_posix() + "/" + inventory_by_path = {rule["path"]: rule for rule in inventory} + manifest_by_source: dict[str, dict[str, Any]] = {} + expected_outputs: set[str] = set() + for item in manifest.get("results") or []: + if not isinstance(item, dict): + raise ValueError("offline Authoring conversion manifest contains a malformed result") + source_reference = str(item.get("sourceRelativePath") or "").replace("\\", "/") + if source_reference in manifest_by_source: + raise ValueError("offline Authoring conversion manifest contains duplicate sources") + source_rule = inventory_by_path.get(source_reference) + source_suffix = ( + source_reference[len(analytic_prefix):] + if source_reference.startswith(analytic_prefix) + else None + ) + expected_output = ( + (Path("XDR Detections") / Path(source_suffix)).as_posix() + if source_suffix + else None + ) + if ( + source_rule is None + or expected_output is None + or item.get("outputRelativePath") != expected_output + or item.get("status") != "converted" + or item.get("reviewRequired") is not False + or item.get("errors") not in ([], None) + ): + raise ValueError( + "offline Authoring conversion manifest is incomplete, stale, or has " + "unresolved technical/review findings" + ) + manifest_by_source[source_reference] = item + expected_outputs.add(expected_output) + + source_path = content_path(root, source_reference) + target_path = content_path(root, expected_output) + if ( + source_path.is_symlink() + or target_path.is_symlink() + or not source_path.is_file() + or not target_path.is_file() + ): + raise ValueError("offline Authoring source or converted target is missing or unsafe") + try: + source_document = yaml.safe_load(source_path.read_text(encoding="utf-8-sig")) or {} + target_document = yaml.safe_load(target_path.read_text(encoding="utf-8-sig")) or {} + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"offline Authoring source or target YAML is invalid: {exc}") from exc + provenance = ( + target_document.get("contentProvenance", {}) + if isinstance(target_document, dict) + else {} + ) + source_provenance = ( + provenance.get("source", {}) if isinstance(provenance, dict) else {} + ) + conversion = ( + provenance.get("conversion", {}) if isinstance(provenance, dict) else {} + ) + review_scope = ( + conversion.get("reviewScope", {}) if isinstance(conversion, dict) else {} + ) + target_properties = ( + target_document.get("properties") + if isinstance(target_document, dict) + else None + ) + query_condition = ( + target_properties.get("queryCondition") + if isinstance(target_properties, dict) + else None + ) + target_query = ( + query_condition.get("queryText") + if isinstance(query_condition, dict) + else None + ) + source_query = source_document.get("query") if isinstance(source_document, dict) else None + if ( + not isinstance(source_document, dict) + or not isinstance(source_provenance, dict) + or not isinstance(conversion, dict) + or not isinstance(review_scope, dict) + ): + raise ValueError( + f"offline Authoring conversion evidence is malformed for {source_reference}" + ) + canonical_source_hash = hashlib.sha256( + yaml.safe_dump( + source_document, + sort_keys=True, + allow_unicode=True, + ).encode("utf-8") + ).hexdigest() + current_source_query_hash = ( + hashlib.sha256(source_query.encode("utf-8")).hexdigest() + if isinstance(source_query, str) + else None + ) + current_target_query_hash = ( + hashlib.sha256(target_query.encode("utf-8")).hexdigest() + if isinstance(target_query, str) + else None + ) + if ( + source_provenance.get("path") != source_reference + or source_provenance.get("id") != source_document.get("id") + or source_provenance.get("querySha256") != current_source_query_hash + or not current_target_query_hash + or conversion.get("status") != "converted" + or conversion.get("reviewRequired") is not False + or conversion.get("errors") not in ([], None) + or review_scope.get("sourceDocumentSha256") != canonical_source_hash + or review_scope.get("targetQuerySha256") != current_target_query_hash + ): + raise ValueError( + f"offline Authoring conversion evidence is stale or unresolved for {source_reference}" + ) + if set(manifest_by_source) != set(inventory_by_path): + raise ValueError( + "offline Authoring conversion manifest does not match the current full source inventory" + ) + current_outputs = { + path.relative_to(root).as_posix() + for path in xdr_detection_files(output) + } + if current_outputs != expected_outputs: + raise ValueError( + "offline Authoring converted targets do not match the complete current inventory" + ) + + if expected_outputs != manifest_outputs: + raise ValueError( + "offline Authoring conversion outputs do not match the bound manifest" + ) + structural = validate_solution(root) + if ( + structural.get("conversionScope", {}).get("kind") != "solution" + or structural.get("total") != len(inventory) + or structural.get("invalid") != 0 + or structural.get("valid") != len(inventory) + or len(structural.get("results") or []) != len(inventory) + or any(item.get("valid") is not True for item in structural.get("results") or []) + ): + raise ValueError( + "offline Authoring validation requires current full-solution structural validation to pass" + ) + + +def recover_conversion_evidence(solution: str | Path) -> dict[str, Any]: + """Rebind a legacy Authoring conversion manifest only after complete offline revalidation.""" + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as path: + loaded_path, state = _load(root) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + if state.get("context", {}).get("workflowProfile") != "authoring": + raise ValueError( + "conversion evidence recovery is restricted to explicitly selected Authoring runs" + ) + if any( + state["stages"][stage].get("status") != "passed" + for stage in ("discovery", "conversion") + ): + raise ValueError( + "conversion evidence recovery requires passed discovery and conversion stages" + ) + + conversion_stage = state["stages"]["conversion"] + artifacts = conversion_stage.get("artifacts") + if not isinstance(artifacts, dict): + raise ValueError("passed conversion stage has no artifact record to recover") + manifest_reference = artifacts.get("manifest") + if not isinstance(manifest_reference, str) or not manifest_reference.strip(): + raise ValueError( + "conversion evidence recovery requires the existing manifest artifact reference" + ) + + manifest_path = path.parent / "manifest.json" + if manifest_path.is_symlink() or not manifest_path.is_file(): + raise ValueError("current run conversion manifest is missing or unsafe") + if manifest_reference != "manifest.json": + try: + referenced_manifest = resolve_artifact_reference(root, manifest_reference) + except (OSError, ValueError) as exc: + raise ValueError("conversion manifest artifact reference is invalid") from exc + if referenced_manifest.resolve() != manifest_path.resolve(): + raise ValueError( + "conversion manifest artifact reference does not identify this run's manifest" + ) + + manifest_bytes = manifest_path.read_bytes() + manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest() + prior_sha256 = artifacts.get("manifestSha256") + if prior_sha256 is not None and prior_sha256 != manifest_sha256: + raise ValueError( + "existing conversion manifest hash does not match current bytes; " + "recovery will not replace a bound digest" + ) + + inventory = _source_inventory(root) + canonical_inventory_sha256 = hashlib.sha256( + json.dumps( + inventory, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + prior_inventory_sha256 = artifacts.get("sourceInventorySha256") + if state.get("authoring"): + record, _ = _current_yolo_record(root, path, state) + inventory_sha256 = record["inventory"]["sha256"] + else: + inventory_sha256 = canonical_inventory_sha256 + if ( + prior_inventory_sha256 is not None + and prior_inventory_sha256 != inventory_sha256 + ): + raise ValueError( + "current source inventory differs from the conversion stage inventory; " + "recovery will not rebind stale evidence" + ) + + manifest = _read_full_conversion_manifest( + root, + path.parent, + inventory, + expected_sha256=manifest_sha256, + ) + total = len(inventory) + _validate_clean_full_conversion_manifest(root, inventory, manifest) + + candidate_state = dict(state) + candidate_stages = dict(state["stages"]) + candidate_conversion_stage = dict(conversion_stage) + candidate_artifacts = dict(artifacts) + candidate_artifacts["manifestSha256"] = manifest_sha256 + candidate_conversion_stage["artifacts"] = candidate_artifacts + candidate_stages["conversion"] = candidate_conversion_stage + candidate_state["stages"] = candidate_stages + _validate_offline_authoring_gates( + root, + path, + candidate_state, + ) + + if hashlib.sha256(manifest_path.read_bytes()).hexdigest() != manifest_sha256: + raise ValueError( + "conversion manifest changed during evidence recovery; no digest was persisted" + ) + current_inventory = _source_inventory(root) + current_inventory_sha256 = hashlib.sha256( + json.dumps( + current_inventory, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + if current_inventory_sha256 != inventory_sha256: + if state.get("authoring"): + current_record, _ = _current_yolo_record(root, path, state) + current_inventory_sha256 = current_record["inventory"]["sha256"] + if current_inventory_sha256 != inventory_sha256: + raise ValueError( + "source inventory changed during evidence recovery; no digest was persisted" + ) + + if prior_sha256 is not None: + return { + "runId": state["runId"], + "status": "already-bound", + "manifestSha256": manifest_sha256, + "sourceInventorySha256": inventory_sha256, + "sourceCount": total, + } + + artifacts.update({ + "manifestSha256": manifest_sha256, + "sourceInventorySha256": inventory_sha256, + "manifestEvidenceRecovery": "offline-authoring-full-revalidation", + "manifestEvidenceRecoveredAt": _utc_now(), + }) + _write_unlocked(path, state) + return { + "runId": state["runId"], + "status": "recovered", + "manifestSha256": manifest_sha256, + "sourceInventorySha256": inventory_sha256, + "sourceCount": total, + } + + +def _offline_authoring_validation_message() -> str: + return ( + "Offline Authoring validation passed after full-inventory conversion, current " + "structural validation, and resolved review checks. Sentinel and Advanced Hunting " + "runtime validation was not run; this result is not runtime/XDR-qualified." + ) + + +def _current_runtime_status( + solution: Path, + profile: str, + record: dict[str, Any], +) -> tuple[str, str | None, dict[str, Any]]: + from .runtime import current_runtime_evidence + + evidence = current_runtime_evidence(solution) + if ( + evidence.get("sourceWhitelist") + and not evidence.get("sourceWaiverOnlyBlocker") + ): + return ( + "blocked", + "The IdentityInfo source failure is visible as source-waived, but XDR runtime " + "or another exact validation gate is incomplete; validation stays blocked.", + evidence, + ) + if evidence.get("sourceWaiverOnlyBlocker"): + unrelated_blockers = _source_waiver_unrelated_blockers(record) + if unrelated_blockers: + return ( + "blocked", + "Source waiver is recorded, but unrelated technical or review gates remain; validation stays blocked.", + evidence, + ) + return ( + "source-waived", + "Validation completed with the built-in IdentityInfo source-table whitelist. " + "The raw Sentinel query result remains failed; this run is not fully runtime-qualified.", + evidence, + ) + if evidence.get("passed"): + return "passed", None, evidence + if any( + result["status"] != "migrated" + for result in record["results"] + ): + return "blocked", "YOLO rule-level technical blockers remain; see the authoring contract.", evidence + if ( + profile == "authoring" + and record["environmentErrors"] + and not evidence.get("currentQueryFailures") + ): + return ( + "environment-blocked", + "Runtime validation is blocked by provider/environment evidence; no KQL/runtime query failure was relabeled.", + evidence, + ) + return ( + "not-run", + "Runtime validation is not complete; exact current-query provider evidence is missing or blocked.", + evidence, + ) + + +def _source_whitelist_stage_artifacts( + runtime_evidence: dict[str, Any], +) -> dict[str, str]: + entries = runtime_evidence.get("sourceWhitelist") + if not isinstance(entries, list) or not entries: + return {} + summary = [ + { + "detection": item.get("detection"), + "table": item.get("table"), + "status": item.get("status"), + "applied": item.get("applied") is True, + "querySha256": item.get("querySha256"), + "reason": item.get("reason"), + "rawStatus": (item.get("rawFailure") or {}).get("status"), + "rawStatusCode": (item.get("rawFailure") or {}).get("statusCode"), + "rawFailureKind": (item.get("rawFailure") or {}).get("failureKind"), + } + for item in entries + if isinstance(item, dict) + ] + return { + "sourceRuntimeWhitelistTable": "IdentityInfo", + "sourceRuntimeWhitelistCount": str(len(summary)), + "sourceRuntimeWhitelistStatus": ( + "source-waived" + if summary and all(item["applied"] for item in summary) + else "candidate-blocked" + ), + "sourceRuntimeWhitelist": json.dumps( + summary, sort_keys=True, separators=(",", ":") + ), + } + + +def _complete_runtime_pending_stage( + solution: Path, + state_path: Path, + state: dict[str, Any], + record: dict[str, Any], +) -> dict[str, Any]: + stage = state["stages"]["validation"] + if not ( + stage["status"] == "blocked" + and str(stage.get("message") or "").startswith("Runtime validation is not complete") + ): + return state + status, message, runtime_evidence = _current_runtime_status( + solution, state["context"]["workflowProfile"], record + ) + if status not in {"passed", "environment-blocked", "source-waived", "not-run"}: + return state + if status == "not-run": + try: + _validate_offline_authoring_gates(solution, state_path, state, record=record) + except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError): + return state + if status == "source-waived": + _validate_source_waiver_conversion_gates(solution, state_path, state) + whitelist = runtime_evidence.get("sourceWhitelist") or [] + if not whitelist or any( + item.get("table") != "IdentityInfo" + or item.get("rawFailure", {}).get("status") != "failed" + or item.get("applied") is not True + for item in whitelist + ): + return state + _start_workflow_stage_unlocked(solution, "validation", state_path) + artifacts = ( + {"runtimeStatus": status} + if status in {"environment-blocked", "source-waived"} + else {} + ) + if status == "not-run": + artifacts = { + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + } + if status == "source-waived": + artifacts.update(_source_whitelist_stage_artifacts(runtime_evidence)) + artifacts["sourceRuntimeWhitelistRawStatus"] = "failed" + _complete_workflow_stage_unlocked( + solution, + "validation", + state_path, + status="passed", + message=( + _offline_authoring_validation_message() + if status == "not-run" + else message + ), + artifacts=artifacts, + evidence=None, + ) + return _load(solution)[1] + + +def run_yolo_authoring(solution: str | Path) -> dict[str, Any]: + """Run existing local discovery/conversion/validation once under the frozen YOLO contract.""" + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as state_path: + loaded_path, state = _load(root) + if loaded_path != state_path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + run_id = state.get("runId") + if not run_id: + raise ValueError("YOLO authoring requires an explicitly initialized run-scoped workflow") + with using_run(root, run_id): + state = _recover_yolo_state(root, state_path, state) + if not state.get("authoring") or state["authoring"]["mode"] != "yolo": + raise ValueError("workflow was not explicitly initialized with --yolo") + record, _ = _current_yolo_record(root, state_path, state) + authoring = state["authoring"] + budget = state.get("budget") + if not isinstance(budget, dict): + raise ValueError( + "YOLO workflow has no durable campaign budget; initialize or explicitly " + "link it with workflow-init --yolo --campaign-id" + ) + if ( + authoring["status"] in {"stopped", "conflicted"} + and not str(authoring.get("stopReason") or "").startswith("Campaign budget:") + ): + return { + "runId": run_id, + "status": authoring["status"], + "reason": authoring.get("stopReason"), + "coverage": record["coverage"], + "authoringContract": authoring["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": True, + "workPerformed": False, + } + if budget.get("status") == "stopped": + return { + "runId": run_id, + "status": "budget-exhausted", + "reason": budget.get("stopReason"), + "coverage": record["coverage"], + "authoringContract": authoring["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": True, + "workPerformed": False, + } + if authoring["status"] in {"stopped", "conflicted"}: + return { + "runId": run_id, + "status": authoring["status"], + "reason": authoring.get("stopReason"), + "coverage": record["coverage"], + "authoringContract": authoring["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": True, + "workPerformed": False, + } + + inventory = _verify_source_inventory(root, record) + inventory_hash = record["inventory"]["sha256"] + if state["context"].get("profileSelectionConfirmed") is not True: + raise ValueError("YOLO requires explicit Authoring or Qualification profile selection") + if state["context"]["workflowProfile"] == "qualification" and not state["context"].get("targetContextLocked"): + raise ValueError("Qualification YOLO requires its existing locked target") + + if any( + state["stages"][stage_name]["status"] != "passed" + for stage_name in ("discovery", "conversion", "validation") + ): + try: + ensure_work_available( + root, + budget["campaignId"], + inventory_sha256=inventory_hash, + ) + except BudgetError as exc: + next_stage = next( + name for name in ("discovery", "conversion", "validation") + if state["stages"][name]["status"] != "passed" + ) + _stop_budget_run(state_path, state, next_stage, str(exc)) + return { + "runId": run_id, + "status": "budget-exhausted", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": state["authoring"]["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": None, + "resumed": True, + "workPerformed": False, + } + + discovery = state["stages"]["discovery"] + if discovery["status"] != "passed": + if discovery["status"] != "running": + _start_workflow_stage_unlocked(root, "discovery", state_path) + discovery_dispatched = False + try: + with measured_operation( + root, + budget["campaignId"], + inventory_sha256=inventory_hash, + run_id=run_id, + dimension="workflowDiscovery", + ): + discovery_dispatched = True + inspection = inspect_solution(root) + except BudgetError as exc: + if discovery_dispatched: + raise + current_state = _load(root)[1] + _stop_budget_run(state_path, current_state, "discovery", str(exc)) + return { + "runId": run_id, + "status": "budget-exhausted", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": current_state["authoring"]["currentContract"], + "workflowStatus": current_state["workflowStatus"], + "next": None, + "resumed": discovery["status"] == "running", + "workPerformed": False, + } + if inspection["analyticRuleCount"] != len(inventory): + _mark_yolo_conflicted( + state_path, state, + "Discovery inventory count changed after YOLO scope freeze.", + ) + raise ValueError("discovery inventory changed after YOLO scope freeze") + _complete_workflow_stage_unlocked( + root, + "discovery", + state_path, + status="passed", + message="Complete recursive source inventory verified against frozen YOLO scope.", + artifacts={"sourceInventorySha256": inventory_hash}, + ) + state = _load(root)[1] + + conversion_stage = state["stages"]["conversion"] + if conversion_stage["status"] == "passed": + expected_manifest_hash = conversion_stage["artifacts"].get("manifestSha256") + conversion = _read_full_conversion_manifest( + root, state_path.parent, inventory, expected_manifest_hash + ) + else: + if conversion_stage["status"] != "running": + _start_workflow_stage_unlocked(root, "conversion", state_path) + conversion_dispatched = False + try: + with measured_operation( + root, + budget["campaignId"], + inventory_sha256=inventory_hash, + run_id=run_id, + dimension="conversionPass", + ): + conversion_dispatched = True + conversion = convert_solution(root) + _verify_source_inventory(root, record) + manifest = _read_full_conversion_manifest( + root, state_path.parent, inventory + ) + except BudgetError as exc: + if conversion_dispatched: + current_path, current_state = _load(root) + stage = current_state["stages"]["conversion"] + if stage["status"] == "running": + _complete_workflow_stage_unlocked( + root, "conversion", state_path, status="failed", + message=f"Conversion ran but its budget receipt failed: {exc}", + ) + return { + "runId": run_id, + "status": "failed", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": current_state["authoring"]["currentContract"], + "workflowStatus": current_state["workflowStatus"], + "next": _next_stage_name(current_state), + "resumed": conversion_stage["status"] == "running", + "workPerformed": True, + } + current_path, current_state = _load(root) + if current_path != state_path: + raise ValueError("workflow state selection changed during budget check") from exc + _stop_budget_run(state_path, current_state, "conversion", str(exc)) + return { + "runId": run_id, + "status": "budget-exhausted", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": current_state["authoring"]["currentContract"], + "workflowStatus": current_state["workflowStatus"], + "next": None, + "resumed": conversion_stage["status"] == "running", + "workPerformed": False, + } + except (OSError, RuntimeError, ValueError, yaml.YAMLError) as exc: + current_path, current_state = _load(root) + if current_path != state_path: + raise ValueError("workflow state selection changed during conversion") from exc + stage = current_state["stages"]["conversion"] + if stage["status"] == "running": + _complete_workflow_stage_unlocked( + root, "conversion", state_path, status="failed", + message=f"Full-solution conversion did not complete: {exc}", + ) + return { + "runId": run_id, + "status": "failed", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": current_state["authoring"]["currentContract"], + "workflowStatus": current_state["workflowStatus"], + "next": _next_stage_name(current_state), + "resumed": conversion_stage["status"] == "running", + "workPerformed": True, + } + conversion = manifest + conversion_sha256 = hashlib.sha256( + (state_path.parent / "manifest.json").read_bytes() + ).hexdigest() + state = _load(root)[1] + _complete_workflow_stage_unlocked( + root, + "conversion", + state_path, + status="passed", + message=( + "Existing deterministic converter completed the full frozen source inventory; " + "per-rule technical outcomes remain in the authoring contract." + ), + artifacts={ + "manifest": "manifest.json", + "manifestSha256": conversion_sha256, + "sourceInventorySha256": inventory_hash, + }, + ) + state = _load(root)[1] + + validation_stage = state["stages"]["validation"] + if validation_stage["status"] == "passed": + record, _ = _current_yolo_record(root, state_path, state) + return { + "runId": run_id, + "status": state["authoring"]["status"], + "coverage": record["coverage"], + "authoringContract": state["authoring"]["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": True, + "workPerformed": False, + } + if ( + validation_stage["status"] == "blocked" + and str(validation_stage.get("message") or "").startswith( + "Runtime validation is not complete" + ) + ): + refreshed = refresh_yolo_authoring(root) + state = _load(root)[1] + record, _ = _current_yolo_record(root, state_path, state) + return { + "runId": run_id, + "status": state["authoring"]["status"], + "coverage": record["coverage"], + "authoringContract": state["authoring"]["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": True, + "workPerformed": bool(refreshed["updated"]), + } + if validation_stage["status"] == "blocked": + record, _ = _current_yolo_record(root, state_path, state) + return { + "runId": run_id, + "status": "blocked", + "reason": validation_stage.get("message"), + "coverage": record["coverage"], + "authoringContract": state["authoring"]["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": True, + "workPerformed": False, + } + + _start_workflow_stage_unlocked(root, "validation", state_path) + state = _load(root)[1] + validation_dispatched = False + try: + with measured_operation( + root, + budget["campaignId"], + inventory_sha256=inventory_hash, + run_id=run_id, + dimension="workflowValidation", + ): + validation_dispatched = True + validation = validate_solution(root) + except BudgetError as exc: + if validation_dispatched: + current_state = _load(root)[1] + _complete_workflow_stage_unlocked( + root, + "validation", + state_path, + status="failed", + message=f"Validation ran but its budget receipt failed: {exc}", + ) + return { + "runId": run_id, + "status": "failed", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": current_state["authoring"]["currentContract"], + "workflowStatus": current_state["workflowStatus"], + "next": _next_stage_name(current_state), + "resumed": False, + "workPerformed": True, + } + current_state = _load(root)[1] + _stop_budget_run(state_path, current_state, "validation", str(exc)) + return { + "runId": run_id, + "status": "budget-exhausted", + "reason": str(exc), + "coverage": record["coverage"], + "authoringContract": current_state["authoring"]["currentContract"], + "workflowStatus": current_state["workflowStatus"], + "next": None, + "resumed": False, + "workPerformed": False, + } + _verify_source_inventory(root, record) + results, environment_errors = _conversion_contract_results( + root, + state_path.parent, + inventory, + conversion, + validation, + record, + ) + current_reference = state["authoring"]["currentContract"] + core = _contract_core( + root, + run_id, + inventory, + revision=current_reference["revision"] + 1, + previous_sha256=current_reference["sha256"], + results=results, + environment_errors=environment_errors, + ) + new_record, _ = _persist_contract_snapshot(state_path.parent, state, core) + coverage = new_record["coverage"] + stopped = bool(coverage["stopThresholdReached"]) + if stopped: + state["authoring"]["status"] = "stopped" + state["authoring"]["stopReason"] = ( + f"Durable rule-error threshold reached: {coverage['E']}/{coverage['N']} " + "(100*E > 50*N)." + ) + state["workflowStatus"] = "blocked" + _write_unlocked(state_path, state) + + if validation.get("conversionScope", {}).get("kind") == "rule": + validation_status, message = "blocked", "Structural validation is scoped; full solution validation is required." + elif validation["invalid"]: + validation_status, message = "blocked", "One or more generated detections failed structural validation." + elif any(result["status"] != "migrated" for result in new_record["results"]): + validation_status, message = ( + "blocked", + "YOLO rule-level technical blockers remain; inspect the per-rule authoring contract.", + ) + elif stopped: + validation_status, message = ( + "blocked", + state["authoring"]["stopReason"], + ) + else: + validation_status, message, runtime_evidence = _current_runtime_status( + root, state["context"]["workflowProfile"], new_record + ) + if validation_status == "not-run": + try: + _validate_offline_authoring_gates( + root, + state_path, + state, + record=new_record, + ) + except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError) as exc: + validation_status = "blocked" + message = str(exc) + else: + message = _offline_authoring_validation_message() + offline_authoring = ( + validation_status == "not-run" + and state["context"]["workflowProfile"] == "authoring" + ) + if offline_authoring: + final_status = "passed" + final_artifacts = { + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + } + elif validation_status == "environment-blocked": + final_status = "passed" + final_artifacts = {"runtimeStatus": "environment-blocked"} + elif validation_status == "source-waived": + final_status = "passed" + whitelist = runtime_evidence.get("sourceWhitelist") or [] + final_artifacts = { + "runtimeStatus": "source-waived", + "sourceRuntimeWhitelistTable": "IdentityInfo", + "sourceRuntimeWhitelistCount": str(len(whitelist)), + "sourceRuntimeWhitelistRawStatus": "failed", + } + elif validation_status == "passed": + final_status = "passed" + final_artifacts = {} + else: + final_status = "blocked" + final_artifacts = {"authoringContractSha256": state["authoring"]["currentContract"]["sha256"]} + _complete_workflow_stage_unlocked( + root, + "validation", + state_path, + status=final_status, + message=message, + artifacts=final_artifacts, + evidence=[state["authoring"]["currentContract"]["path"]], + ) + state = _load(root)[1] + record, _ = _current_yolo_record(root, state_path, state) + return { + "runId": run_id, + "status": state["authoring"]["status"], + "coverage": record["coverage"], + "authoringContract": state["authoring"]["currentContract"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + "resumed": conversion_stage["status"] == "running", + "workPerformed": True, + "validationStatus": final_status, + "runtimeStatus": validation_status, + "packageAuthorized": False, + "qualificationAuthorized": False, + "prCreationAuthorized": False, + } + + +def initialize_workflow( + solution: str | Path, + *, + tenant_id: str | None = None, + subscription_id: str | None = None, + workspace_resource_id: str | None = None, + workspace_customer_id: str | None = None, + version_bump: str | None = None, + target_version: str | None = None, + workflow_profile: str | None = None, + yolo: bool | None = None, + campaign_id: str | None = None, + new_run: bool = False, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + if not root.is_dir(): + raise ValueError(f"solution folder does not exist: {root}") + if version_bump not in (None, "none", "patch", "minor", "major"): + raise ValueError("version bump must be none, patch, minor, or major") + if target_version is not None: + validate_v31_target_version(target_version) + if version_bump not in (None, "none"): + raise ValueError("target version cannot be combined with a version bump") + if workflow_profile is None: + raise ValueError( + "workflow profile selection is required: choose authoring or qualification" + ) + if workflow_profile not in WORKFLOW_PROFILES: + raise ValueError("workflow profile must be authoring or qualification") + explicit_campaign_id = campaign_id + if campaign_id is not None: + from .budget import _safe_campaign_id + campaign_id = _safe_campaign_id(campaign_id) + if yolo is True and campaign_id is None and new_run: + raise ValueError("new YOLO workflows require an explicit --campaign-id") + target = None + if workflow_profile == "qualification": + missing = [ + name + for name, value in ( + ("tenant-id", tenant_id), + ("subscription-id", subscription_id), + ("workspace-resource-id", workspace_resource_id), + ("workspace-customer-id", workspace_customer_id), + ) + if not value + ] + if missing: + raise ValueError( + "qualification requires a locked target context: " + + ", ".join(missing) + ) + target = build_target( + tenant_id=tenant_id or "", + subscription_id=subscription_id or "", + workspace_resource_id=workspace_resource_id or "", + workspace_customer_id=workspace_customer_id or "", + ) + requested_context = { + "tenantId": target["tenantId"] if target else tenant_id, + "subscriptionId": target["subscriptionId"] if target else subscription_id, + "workspaceResourceId": ( + target["workspaceResourceId"] if target else workspace_resource_id + ), + "workspaceCustomerId": ( + target["workspaceCustomerId"] if target else workspace_customer_id + ), + "targetContextLocked": bool(target), + "versionBump": version_bump, + "targetVersion": target_version, + } + if new_run: + if requested_run_id(root): + raise ValueError("--new-run and --run-id cannot be combined") + _preflight_campaign_binding( + root, + campaign_id, + yolo=yolo is True, + ) + run = create_run(root) + with using_run(root, run.name): + return initialize_workflow( + root, tenant_id=tenant_id, subscription_id=subscription_id, + workspace_resource_id=workspace_resource_id, + workspace_customer_id=workspace_customer_id, version_bump=version_bump, + target_version=target_version, + workflow_profile=workflow_profile, yolo=yolo, campaign_id=campaign_id, + ) + existing_path = existing_artifact_path(root, STATE_FILE_NAME) + if existing_path.exists(): + with _run_writer_lock(existing_path.parent): + path, state = _load(root) + if yolo is True and "authoring" not in state: + raise ValueError( + "YOLO cannot be enabled on an existing legacy run; initialize a new run explicitly" + ) + if yolo is True and not state.get("budget") and not campaign_id: + raise ValueError( + "pre-existing YOLO run has unmeasured usage; provide " + "--campaign-id to link it conservatively" + ) + context = state["context"] + if target_version is not None and context.get("versionBump") not in (None, "none"): + raise ValueError( + "target version cannot be combined with the workflow's selected version bump" + ) + for key, value in requested_context.items(): + if key == "targetContextLocked": + continue + if value is not None and context.get(key) not in (None, value): + raise ValueError( + f"workflow already uses {key}={context.get(key)!r}, " + f"not {value!r}" + ) + if "authoring" in state: + state = _recover_yolo_state(root, path, state) + record, _ = _current_yolo_record(root, path, state) + inventory_hash = record["inventory"]["sha256"] + rule_ids = [rule["ruleId"] for rule in record["inventory"]["rules"]] + budget_campaign_id = ( + state.get("budget", {}).get("campaignId") + or campaign_id + ) + else: + inventory_hash, rule_ids = _budget_source_inventory(root, yolo=False) + budget_campaign_id = ( + state.get("budget", {}).get("campaignId") + or campaign_id + ) + existing_budget = state.get("budget") + link_campaign = False + if existing_budget: + if explicit_campaign_id and explicit_campaign_id != existing_budget["campaignId"]: + raise ValueError( + "workflow is already linked to a different campaign; " + "campaign binding is immutable" + ) + if existing_budget["inventorySha256"] != inventory_hash: + raise ValueError( + "campaign frozen source inventory changed; preserve the existing run " + "and explicitly initialize a separately reviewed campaign" + ) + budget_summary( + root, existing_budget["campaignId"], expected_inventory=inventory_hash, + ) + elif budget_campaign_id: + campaign_exists = validate_campaign_binding( + root, + budget_campaign_id, + inventory_sha256=inventory_hash, + rule_ids=rule_ids, + ) + if campaign_exists and state["runId"] not in budget_summary( + root, budget_campaign_id, expected_inventory=inventory_hash, + )["linkedRunIds"]: + raise BudgetReceiptError( + "an unbudgeted existing workflow cannot be joined to an active " + "campaign; initialize an explicitly linked replacement run" + ) + link_campaign = True + for key, value in requested_context.items(): + if key == "targetContextLocked": + if value: + context[key] = True + continue + if value is not None and context.get(key) is None: + context[key] = value + _select_workflow_profile(state, workflow_profile) + if link_campaign: + create_or_link_campaign( + root, + budget_campaign_id, + inventory_sha256=inventory_hash, + run_id=state["runId"], + rule_ids=rule_ids, + legacy_usage_unknown=True, + ) + state["budget"] = { + "schemaVersion": "1.0.0", + "campaignId": budget_campaign_id, + "inventorySha256": inventory_hash, + "status": "active", + "stopReason": None, + } + _write_unlocked(path, state) + if target: + write_target(root, target) + return { + **state, + "statePath": str(path), + "resumed": True, + "next": _next_stage_name(state), + } + + frozen_inventory = _source_inventory(root) if yolo is True else None + if frozen_inventory is not None and not campaign_id: + raise ValueError("new YOLO workflows require an explicit --campaign-id") + preflight_inventory = _preflight_campaign_binding( + root, + campaign_id, + yolo=yolo is True, + inventory=frozen_inventory, + ) + if yolo is True and requested_run_id(root) is None: + run = create_run(root) + with using_run(root, run.name): + return initialize_workflow( + root, + tenant_id=tenant_id, + subscription_id=subscription_id, + workspace_resource_id=workspace_resource_id, + workspace_customer_id=workspace_customer_id, + version_bump=version_bump, + target_version=target_version, + workflow_profile=workflow_profile, + yolo=True, + campaign_id=campaign_id, + ) + + now = _utc_now() + path = artifact_path(root, STATE_FILE_NAME, create_parent=True) + state = { + "schemaVersion": SCHEMA_VERSION, + "solution": str(root), + "workflowStatus": "inProgress", + "createdAt": now, + "updatedAt": now, + "context": requested_context, + "stages": { + name: _stage_result( + name, + required=not ( + workflow_profile == "authoring" + and name in AUTHORING_OPTIONAL_STAGES + ), + ) + for name in STAGES + }, + } + state["context"]["workflowProfile"] = workflow_profile + state["context"]["profileSelectionConfirmed"] = True + if frozen_inventory is not None: + record, _ = _persist_contract_snapshot( + path.parent, + state, + _contract_core( + root, + path.parent.name, + frozen_inventory, + revision=1, + previous_sha256=None, + ), + ) + campaign_inventory_hash = record["inventory"]["sha256"] + campaign_rule_ids = [rule["ruleId"] for rule in record["inventory"]["rules"]] + else: + campaign_inventory_hash, campaign_rule_ids = ( + preflight_inventory + if preflight_inventory is not None + else _budget_source_inventory(root, yolo=False) + ) + if campaign_id: + create_or_link_campaign( + root, + campaign_id, + inventory_sha256=campaign_inventory_hash, + run_id=path.parent.name, + rule_ids=campaign_rule_ids, + ) + state["budget"] = { + "schemaVersion": "1.0.0", + "campaignId": campaign_id, + "inventorySha256": campaign_inventory_hash, + "status": "active", + "stopReason": None, + } + _write(path, state) + if target: + write_target(root, target) + return { + **state, + "statePath": str(path), + "resumed": False, + "next": _next_stage_name(state), + } + + +def _select_workflow_profile(state: dict[str, Any], workflow_profile: str) -> None: + context = state["context"] + current = context["workflowProfile"] + if current != workflow_profile: + protected = [ + name + for name in (*AUTHORING_OPTIONAL_STAGES, "report") + if state["stages"][name]["status"] not in {"pending", "notRequired"} + ] + if protected: + raise ValueError( + "workflow profile cannot change after qualification/report stages " + f"started: {', '.join(sorted(protected))}" + ) + for name in AUTHORING_OPTIONAL_STAGES: + state["stages"][name] = _stage_result( + name, + required=workflow_profile == "qualification", + ) + context["workflowProfile"] = workflow_profile + context["profileSelectionConfirmed"] = True + + +def _validate_packaging_evidence( + state: dict[str, Any], + artifacts: dict[str, str], + solution: str | Path, +) -> None: + if artifacts.get("packager") != "V3.1": + raise ValueError("passed packaging stage requires packager=V3.1; rebuild old packages") + report_value = artifacts.get("packageReport") + if not report_value: + raise ValueError("passed packaging stage requires a V3.1 packageReport artifact") + report_path = resolve_artifact_reference(solution, report_value) + if not report_path.is_file(): + raise ValueError(f"V3.1 package report does not exist: {report_path}") + try: + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"V3.1 package report is invalid: {report_path}: {exc}") from exc + if not isinstance(report, dict) or report.get("packager") != "V3.1": + raise ValueError(f"package report does not prove V3.1 packaging: {report_path}") + selection = verify_applied_package_selection(solution, str(state.get("runId") or "")) + validation_status = state["stages"]["validation"]["status"] + if validation_status != "passed": + if not selection: + raise ValueError( + "partial V3.1 packaging requires a fresh, applied evidence-based selection plan" + ) + plan = selection["plan"] + verification = report.get("selectionVerification") or {} + summary = plan.get("summary") or {} + if ( + plan.get("summary", {}).get("outcome") != "partial" + or not _partial_coverage_allowed(summary) + or report.get("selectionOutcome") != "partial-package-validated" + or report.get("selectionPlanSha256") != plan.get("planSha256") + or artifacts.get("selectionPlanSha256") != plan.get("planSha256") + or report.get("selectionSummary") != summary + or verification.get("planSha256") != plan.get("planSha256") + or verification.get("outcome") != "partial" + or verification.get("analyticRuleIncluded") + != summary.get("analyticRuleIncluded") + or verification.get("xdrDetectionIncluded") + != summary.get("xdrDetectionIncluded") + or verification.get("coverageNumerator") + != summary.get("coverageNumerator") + or verification.get("coverageDenominator") + != summary.get("coverageDenominator") + or verification.get("partialPublicationAllowed") is not True + or verification.get("mainTemplateVerified") is not True + or verification.get("zipVerified") is not True + ): + raise ValueError( + "partial package evidence does not bind the complete current excluded inventory" + ) + elif selection: + plan = selection["plan"] + summary = plan.get("summary") or {} + verification = report.get("selectionVerification") or {} + if ( + report.get("selectionPlanSha256") != plan.get("planSha256") + or artifacts.get("selectionPlanSha256") != plan.get("planSha256") + or report.get("selectionSummary") != summary + or verification.get("planSha256") != plan.get("planSha256") + or verification.get("analyticRuleIncluded") + != summary.get("analyticRuleIncluded") + or verification.get("xdrDetectionIncluded") + != summary.get("xdrDetectionIncluded") + or verification.get("mainTemplateVerified") is not True + or verification.get("zipVerified") is not True + ): + raise ValueError("V3.1 package report does not match the applied selection plan") + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + value = artifacts.get(name) or report.get(name) + if not value or not resolve_artifact_reference(solution, value).is_file(): + raise ValueError(f"V3.1 packaging evidence is missing artifact: {name}") + packaging = state.get("packaging") + requested_bump = state["context"].get("versionBump") + effective_bump = ( + ((packaging.get("currentAttempt") or {}).get("effectiveVersionBump")) + if packaging else requested_bump + ) + if effective_bump is not None and report.get("versionBump") != effective_bump: + raise ValueError( + "V3.1 package report version bump does not match workflow context: " + f"{report.get('versionBump')!r} != {effective_bump!r}" + ) + if packaging: + attempt = packaging.get("currentAttempt") or {} + if ( + attempt.get("status") != "passed" + or report.get("packageAttemptId") != attempt.get("attemptId") + or report.get("workflowRunId") != state.get("runId") + or report.get("requestedVersionBump") != attempt.get("requestedVersionBump") + or report.get("effectiveVersionBump") != attempt.get("effectiveVersionBump") + or report.get("requestedTargetVersion") != attempt.get("requestedTargetVersion") + or report.get("effectiveTargetVersion") != attempt.get("effectiveTargetVersion") + ): + raise ValueError("V3.1 package report is stale or belongs to another workflow attempt") + input_after = (report.get("packageInputs") or {}).get("after") or {} + if input_after.get("sha256") != attempt.get("packageInputSha256"): + raise ValueError("V3.1 package input identity does not match the current workflow attempt") + recorded_hashes = report.get("packageArtifactHashes") + if not isinstance(recorded_hashes, dict): + raise ValueError("V3.1 package report has no package artifact hashes") + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + path = resolve_artifact_reference(solution, artifacts.get(name) or report.get(name)) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if recorded_hashes.get(name) != digest: + raise ValueError(f"V3.1 package artifact is stale: {name}") + report_digest = hashlib.sha256(report_path.read_bytes()).hexdigest() + if report_digest != attempt.get("packageReportSha256"): + raise ValueError("V3.1 package report hash does not match the current workflow attempt") + + +def _yolo_package_readiness( + solution: Path, + state_path: Path, + state: dict[str, Any], + *, + allow_partial_selection: bool = False, +) -> None: + """Require the full YOLO inventory to clear technical and explicit-review gates.""" + validation_status = state["stages"]["validation"]["status"] + if validation_status != "passed" and not ( + allow_partial_selection + and not state.get("authoring") + and validation_status == "blocked" + ): + raise ValueError("YOLO packaging requires the existing validation stage to pass") + if allow_partial_selection and not state.get("authoring"): + return + if not state.get("authoring"): + output = Path(solution).expanduser().resolve() / "XDR Detections" + pending: list[str] = [] + for path in xdr_detection_files(output): + try: + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"cannot verify XDR review state in {path}: {exc}") from exc + if not isinstance(document, dict): + raise ValueError(f"cannot verify XDR review state in {path}: document is not an object") + conversion = ((document.get("contentProvenance") or {}).get("conversion") or {}) + if ( + isinstance(conversion, dict) + and ( + conversion.get("reviewRequired") is True + or conversion.get("status") == "needsReview" + ) + ): + pending.append(path.relative_to(solution).as_posix()) + if pending: + raise ValueError( + "V3.1 packaging is blocked by unresolved detection review flags: " + + ", ".join(sorted(pending)) + ) + return + record, _ = _current_yolo_record(solution, state_path, state) + results = record.get("results") or [] + rules = record.get("inventory", {}).get("rules") or [] + if len(results) != len(rules) or not results: + raise ValueError("YOLO packaging requires complete full-inventory authoring results") + blockers = [] + for result in results: + rule_id = result.get("ruleId", "") + if ( + result.get("status") != "migrated" + or result.get("conversionStatus") != "converted" + or result.get("validationStatus") != "passed" + or result.get("reviewRequired") is True + or (result.get("target") or {}).get("structurallyValid") is not True + or any( + finding.get("kind") == "error" and finding.get("unresolved") is True + for finding in result.get("findings", []) + ) + ): + blockers.append(rule_id) + if blockers: + raise ValueError( + "YOLO packaging is blocked by unresolved technical or explicit-review " + "items in the complete inventory: " + ", ".join(blockers) + ) + + +def _verify_current_package_state(solution: Path, state: dict[str, Any]) -> None: + packaging = state.get("packaging") or {} + successful = packaging.get("lastSuccessful") or {} + if not successful.get("packageSucceeded"): + raise ValueError("workflow has no successful current V3.1 package attempt") + if package_input_identity(solution)["sha256"] != successful.get("packageInputSha256"): + raise ValueError("package inputs changed after V3.1 packaging; rebuild before continuing") + report_reference = successful.get("packageReport") + if not isinstance(report_reference, str) or not report_reference: + raise ValueError("current V3.1 package report reference is missing") + report_path = resolve_artifact_reference(solution, report_reference) + if not report_path.is_file(): + raise ValueError("current V3.1 package report is missing") + report_bytes = report_path.read_bytes() + if hashlib.sha256(report_bytes).hexdigest() != successful.get("packageReportSha256"): + raise ValueError("current V3.1 package report is stale or modified") + report = json.loads(report_bytes) + if ( + report.get("packager") != "V3.1" + or report.get("workflowRunId") != state.get("runId") + or report.get("packageAttemptId") != successful.get("attemptId") + or report.get("packageArtifactsSha256") != successful.get("packageArtifactsSha256") + ): + raise ValueError("current package report does not match the successful V3.1 workflow attempt") + if report.get("selectionPlanSha256") is not None: + selection = verify_applied_package_selection( + solution, str(state.get("runId") or ""), + ) + if ( + not selection + or selection["plan"].get("planSha256") != report.get("selectionPlanSha256") + ): + raise ValueError("current V3.1 package selection evidence is stale or mismatched") + summary = selection["plan"].get("summary") or {} + if ( + summary.get("outcome") == "partial" + and ( + not _partial_coverage_allowed(summary) + or report.get("selectionOutcome") != "partial-package-validated" + ) + ): + raise ValueError("current partial package no longer meets migrated-detection coverage") + elif report.get("selectionOutcome") == "partial-package-validated": + raise ValueError("partial V3.1 package is missing its selection-plan binding") + recorded_hashes = report.get("packageArtifactHashes") or {} + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + artifact = report.get(name) + if not artifact: + raise ValueError(f"current V3.1 package report has no {name} artifact") + path = resolve_artifact_reference(solution, artifact) + if ( + not path.is_file() + or hashlib.sha256(path.read_bytes()).hexdigest() != recorded_hashes.get(name) + ): + raise ValueError(f"current V3.1 package artifact is stale: {name}") + + +def _package_freshness(solution: str | Path, state: dict[str, Any]) -> dict[str, Any] | None: + successful = (state.get("packaging") or {}).get("lastSuccessful") + if not successful: + return None + try: + current_identity = package_input_identity(solution)["sha256"] + except (OSError, ValueError) as exc: + return { + "status": "unverifiable", + "currentPackageInputSha256": None, + "packagedInputSha256": successful.get("packageInputSha256"), + "reason": str(exc), + } + packaged_identity = successful.get("packageInputSha256") + if current_identity != packaged_identity: + return { + "status": "stale", + "currentPackageInputSha256": current_identity, + "packagedInputSha256": packaged_identity, + "reason": "solution package inputs changed after the successful V3.1 build", + } + try: + _verify_current_package_state(Path(solution).expanduser().resolve(), state) + except (OSError, ValueError, json.JSONDecodeError, TypeError, AttributeError) as exc: + return { + "status": "stale", + "currentPackageInputSha256": current_identity, + "packagedInputSha256": packaged_identity, + "reason": str(exc), + } + return { + "status": "current", + "currentPackageInputSha256": current_identity, + "packagedInputSha256": packaged_identity, + "reason": None, + } + + +def _archive_package_evidence( + solution: Path, + successful: dict[str, Any], +) -> dict[str, str]: + report_reference = successful.get("packageReport") + if not isinstance(report_reference, str) or not report_reference: + raise ValueError("prior V3.1 package report reference is missing") + report_path = resolve_artifact_reference(solution, report_reference) + if not report_path.is_file(): + raise ValueError("prior V3.1 package report is missing") + report_bytes = report_path.read_bytes() + if hashlib.sha256(report_bytes).hexdigest() != successful.get("packageReportSha256"): + raise ValueError("prior V3.1 package report is stale or modified") + report = json.loads(report_bytes) + if report.get("packager") != "V3.1": + raise ValueError("prior package evidence is not from V3.1") + attempt_id = str(successful.get("attemptId") or uuid.uuid4()) + archive = report_path.parent / "package-history" / attempt_id + files = {"packageReport": report_path} + files.update({ + name: resolve_artifact_reference(solution, report[name]) + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip") + if isinstance(report.get(name), str) and report[name] + }) + if set(files) != { + "packageReport", "mainTemplate", "createUiDefinition", "testParameters", "zip", + }: + raise ValueError("prior V3.1 package report is missing artifact references") + recorded_hashes = report.get("packageArtifactHashes") or {} + archived: dict[str, str] = {} + for name, source in files.items(): + if not source.is_file(): + raise ValueError(f"prior V3.1 package artifact is missing: {name}") + digest = hashlib.sha256(source.read_bytes()).hexdigest() + expected = ( + successful.get("packageReportSha256") + if name == "packageReport" else recorded_hashes.get(name) + ) + if digest != expected: + raise ValueError(f"prior V3.1 package artifact is stale: {name}") + archive.mkdir(parents=True, exist_ok=False) + for name, source in files.items(): + destination = archive / f"{name}-{source.name}" + shutil.copy2(source, destination) + archived[name] = destination.relative_to(solution).as_posix() + return archived + + +def _validate_historical_package_receipt( + solution: Path, + state: dict[str, Any], + successful: dict[str, Any], + expected_version: str, +) -> dict[str, Any]: + report_reference = successful.get("packageReport") + if not isinstance(report_reference, str) or not report_reference: + raise ValueError("prior V3.1 package report reference is missing") + report_path = resolve_artifact_reference(solution, report_reference) + if not report_path.is_file(): + raise ValueError("prior V3.1 package report is missing") + report_bytes = report_path.read_bytes() + if hashlib.sha256(report_bytes).hexdigest() != successful.get("packageReportSha256"): + raise ValueError("prior V3.1 package report is stale or modified") + try: + report = json.loads(report_bytes) + except json.JSONDecodeError as exc: + raise ValueError("prior V3.1 package report is invalid JSON") from exc + package_inputs = report.get("packageInputs") or {} + if ( + report.get("status") != "passed" + or report.get("packager") != "V3.1" + or report.get("workflowRunId") != state.get("runId") + or report.get("packageAttemptId") != successful.get("attemptId") + or report.get("version") != expected_version + or successful.get("version") != expected_version + or report.get("packageArtifactsSha256") != successful.get("packageArtifactsSha256") + or (package_inputs.get("after") or {}).get("sha256") + != successful.get("packageInputSha256") + ): + raise ValueError( + "prior package receipt is not a successful V3.1 build for this workflow run, " + "version, attempt, and input identity" + ) + return report + + +def _release_notes_refresh_proposal( + solution: Path, + *, + release_date: str, + change_history: str, +) -> tuple[Path, str, str]: + version = solution_data_version(solution) + if not version or not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError("current solution package version must use X.Y.Z format") + try: + parsed_date = datetime.strptime(release_date, "%d-%m-%Y") + except ValueError as exc: + raise ValueError("release date must be a valid DD-MM-YYYY date") from exc + if parsed_date.strftime("%d-%m-%Y") != release_date: + raise ValueError("release date must use DD-MM-YYYY format") + if not change_history.strip() or "|" in change_history or "\n" in change_history or "\r" in change_history: + raise ValueError("release history must be one non-empty Markdown table cell") + path = solution / "ReleaseNotes.md" + if not path.is_file(): + raise ValueError("solution ReleaseNotes.md is missing") + original = path.read_bytes() + try: + text = original.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("ReleaseNotes.md must be UTF-8") from exc + lines = text.splitlines(keepends=True) + + def table_cells(line: str) -> list[str] | None: + stripped = line.strip() + if not stripped.startswith("|") or not stripped.endswith("|"): + return None + return [cell.strip() for cell in stripped[1:-1].split("|")] + + expected_header = [ + "**Version**", + "**Date Modified (DD-MM-YYYY)**", + "**Change History**", + ] + header_index = next( + (index for index, line in enumerate(lines) if table_cells(line) == expected_header), + None, + ) + if header_index is None or header_index + 1 >= len(lines): + raise ValueError("ReleaseNotes.md does not have the required three-column table header") + separator_cells = table_cells(lines[header_index + 1]) + if ( + separator_cells is None + or len(separator_cells) != 3 + or any(not re.fullmatch(r":?-{3,}:?", cell) for cell in separator_cells) + ): + raise ValueError("ReleaseNotes.md table separator is invalid") + + versions: list[tuple[int, int, int]] = [] + seen: set[str] = set() + for line in lines[header_index + 2:]: + stripped = line.strip() + if not stripped: + continue + cells = table_cells(line) + if cells is None or len(cells) != 3: + raise ValueError("ReleaseNotes.md contains a row that is not exactly three columns") + if not cells[0]: + continue + if not re.fullmatch(r"\d+\.\d+\.\d+", cells[0]): + raise ValueError("ReleaseNotes.md contains an invalid semantic version") + if cells[0] in seen: + raise ValueError("ReleaseNotes.md contains a duplicate version") + seen.add(cells[0]) + try: + date_value = datetime.strptime(cells[1], "%d-%m-%Y") + except ValueError as exc: + raise ValueError("ReleaseNotes.md contains an invalid DD-MM-YYYY date") from exc + if date_value.strftime("%d-%m-%Y") != cells[1] or not cells[2]: + raise ValueError("ReleaseNotes.md contains an invalid date or empty change history") + versions.append(tuple(int(part) for part in cells[0].split("."))) + if versions != sorted(versions, reverse=True): + raise ValueError("ReleaseNotes.md versions are not in descending order") + new_version = tuple(int(part) for part in version.split(".")) + if version in seen or (versions and new_version <= versions[0]): + raise ValueError( + "same-release notes refresh requires the current package version to be absent " + "and newer than the existing release notes" + ) + + newline = "\r\n" if "\r\n" in text else "\n" + row = f"| {version} | {release_date} | {change_history.strip()} |{newline}" + updated_lines = lines[:header_index + 2] + [row] + lines[header_index + 2:] + updated = "".join(updated_lines) + if not text.endswith(("\n", "\r")) and updated.endswith(("\n", "\r")): + updated = updated.rstrip("\r\n") + return ( + path, + hashlib.sha256(original).hexdigest(), + hashlib.sha256(updated.encode("utf-8")).hexdigest(), + ) + + +def _identity_from_file_hashes(files: dict[str, str]) -> str: + canonical = json.dumps( + [ + {"path": name, "sha256": digest} + for name, digest in sorted(files.items(), key=lambda item: Path(item[0])) + ], + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def _validate_package_input_file_manifest( + value: Any, + *, + expected_sha256: str, + expected_file_count: int | None = None, + label: str, +) -> dict[str, str]: + if not isinstance(value, dict): + raise ValueError(f"{label} has no complete per-file package-input manifest") + files: dict[str, str] = {} + for name, digest in value.items(): + if ( + not isinstance(name, str) + or not name + or name.replace("\\", "/") != name + or Path(name).is_absolute() + or ".." in Path(name).parts + or Path(name).as_posix() != name + or not isinstance(digest, str) + or not re.fullmatch(r"[a-f0-9]{64}", digest) + ): + raise ValueError(f"{label} contains an invalid package-input manifest entry") + files[name] = digest + if ( + not files + or _identity_from_file_hashes(files) != expected_sha256 + or (expected_file_count is not None and len(files) != expected_file_count) + ): + raise ValueError( + f"{label} per-file manifest does not match its receipt-bound package-input identity" + ) + return dict(sorted(files.items(), key=lambda item: Path(item[0]))) + + +def _legacy_baseline_evidence( + solution: Path, + evidence_reference: str | Path, + *, + expected_current_sha256: str, + current_files: dict[str, str], + expected_prior_sha256: str, + expected_prior_file_count: int | None, + expected_evidence_sha256: str | None = None, +) -> tuple[dict[str, str], dict[str, str]]: + reference = str(evidence_reference).replace("\\", "/") + evidence_path = content_path(solution, reference) + if evidence_path.is_symlink() or not evidence_path.is_file(): + raise ValueError("legacy baseline evidence is missing or unsafe") + evidence_bytes = evidence_path.read_bytes() + evidence_sha256 = hashlib.sha256(evidence_bytes).hexdigest() + if expected_evidence_sha256 and evidence_sha256 != expected_evidence_sha256: + raise ValueError("legacy baseline evidence changed after the rebuild plan") + try: + evidence = json.loads(evidence_bytes) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("legacy baseline evidence is not valid JSON") from exc + current_sha256 = _identity_from_file_hashes(current_files) + changes = evidence.get("changedInputs") if isinstance(evidence, dict) else None + if ( + not isinstance(evidence, dict) + or evidence.get("priorReceiptIdentity") != expected_prior_sha256 + or evidence.get("currentIdentity") != expected_current_sha256 + or current_sha256 != expected_current_sha256 + or evidence.get("currentFileCount") != len(current_files) + or ( + expected_prior_file_count is not None + and evidence.get("priorFileCount") != expected_prior_file_count + ) + or not isinstance(evidence.get("priorFileCount"), int) + or isinstance(evidence.get("priorFileCount"), bool) + or not isinstance(evidence.get("currentFileCount"), int) + or isinstance(evidence.get("currentFileCount"), bool) + or not isinstance(changes, list) + or not changes + ): + raise ValueError( + "legacy baseline evidence is not bound to the successful receipt and current input backup" + ) + + baseline = dict(current_files) + recorded_changes: dict[str, tuple[str | None, str | None]] = {} + for change in changes: + if not isinstance(change, dict): + raise ValueError("legacy baseline evidence has an invalid changed-input record") + name = change.get("path") + before = change.get("beforeSha256") + after = change.get("currentSha256") + if ( + not isinstance(name, str) + or not name + or name.replace("\\", "/") != name + or Path(name).is_absolute() + or ".." in Path(name).parts + or Path(name).as_posix() != name + or name in recorded_changes + or (before is not None and ( + not isinstance(before, str) or not re.fullmatch(r"[a-f0-9]{64}", before) + )) + or (after is not None and ( + not isinstance(after, str) or not re.fullmatch(r"[a-f0-9]{64}", after) + )) + or before == after + or current_files.get(name) != after + or (name not in current_files and after is not None) + or name.casefold().endswith("releasenotes.md") + ): + raise ValueError("legacy baseline evidence has an invalid or out-of-scope changed input") + recorded_changes[name] = (before, after) + if before is None: + baseline.pop(name, None) + else: + baseline[name] = before + + baseline = dict(sorted(baseline.items(), key=lambda item: Path(item[0]))) + actual_changes = { + name: (baseline.get(name), current_files.get(name)) + for name in sorted(set(baseline) | set(current_files), key=Path) + if baseline.get(name) != current_files.get(name) + } + if ( + recorded_changes != actual_changes + or len(baseline) != evidence["priorFileCount"] + or _identity_from_file_hashes(baseline) != expected_prior_sha256 + ): + raise ValueError( + "legacy baseline evidence does not reconstruct the complete successful " + "package-input manifest" + ) + return baseline, { + "path": reference, + "sha256": evidence_sha256, + } + + +def _same_version_rebuild_scope( + baseline_files: dict[str, str], + current_files: dict[str, str], + approved_changes: dict[str, Any], +) -> dict[str, str | None]: + actual_changes = { + name: current_files.get(name) + for name in sorted(set(baseline_files) | set(current_files), key=Path) + if baseline_files.get(name) != current_files.get(name) + } + if not actual_changes: + raise ValueError("same-version rebuild requires actual package-input changes") + if any(name.casefold().endswith("releasenotes.md") for name in actual_changes): + raise ValueError("same-version content rebuild does not permit any ReleaseNotes change") + if set(approved_changes) != set(actual_changes): + missing = sorted(set(actual_changes) - set(approved_changes)) + unchanged_or_extra = sorted(set(approved_changes) - set(actual_changes)) + details = [] + if missing: + details.append(f"unapproved actual changes: {', '.join(missing)}") + if unchanged_or_extra: + details.append( + "approved paths that are unchanged or not package inputs: " + + ", ".join(unchanged_or_extra) + ) + raise ValueError( + "same-version rebuild changed-input scope must exactly match the " + "successful receipt baseline (" + "; ".join(details) + ")" + ) + if approved_changes != actual_changes: + raise ValueError( + "same-version rebuild approved path hashes do not match the exact " + "receipt-to-current input diff" + ) + return actual_changes + + +def _validate_release_notes_refresh_inputs( + solution: Path, + action: dict[str, Any], + current_identity: str, +) -> None: + if action.get("actionType") != "release-notes-refresh": + return + notes_path = action.get("releaseNotesPath") + before = action.get("packageInputFilesBefore") + if not isinstance(notes_path, str) or not isinstance(before, dict): + raise ValueError("ReleaseNotes refresh action is missing its bound input manifest") + if ( + not all( + isinstance(name, str) + and isinstance(digest, str) + and re.fullmatch(r"[a-f0-9]{64}", digest) + for name, digest in before.items() + ) + or notes_path not in before + or before.get(notes_path) != action.get("releaseNotesOriginalSha256") + ): + raise ValueError("ReleaseNotes refresh baseline manifest is invalid") + current = package_input_file_hashes(solution) + if set(current) != set(before): + raise ValueError("package inventory changed during ReleaseNotes refresh") + changed = [name for name in current if current[name] != before[name]] + if changed != [notes_path]: + raise ValueError( + "ReleaseNotes refresh permits only the exact planned ReleaseNotes.md change" + ) + if current[notes_path] != action.get("releaseNotesExpectedSha256"): + raise ValueError("ReleaseNotes.md does not match the exact approved refresh") + expected_files = dict(before) + expected_files[notes_path] = action["releaseNotesExpectedSha256"] + expected_identity = _identity_from_file_hashes(expected_files) + if ( + expected_identity != action.get("packageInputSha256") + or current_identity != expected_identity + ): + raise ValueError("ReleaseNotes refresh package-input identity is stale or mismatched") + + +def _validate_same_version_content_rebuild_inputs( + solution: Path, + action: dict[str, Any], + current_identity: str, + current_version: str | None, + successful: dict[str, Any], + legacy_baseline_evidence: str | Path | None = None, +) -> None: + if action.get("actionType") != "same-version-content-rebuild": + return + target_version = action.get("targetVersion") + files = action.get("packageInputFilesBefore") + approved_changes = action.get("approvedChangedInputs") + backup_root = action.get("inputBackupRoot") + historical_evidence = action.get("historicalEvidence") + if ( + not isinstance(target_version, str) + or target_version != current_version + or target_version != successful.get("version") + or not isinstance(files, dict) + or not isinstance(approved_changes, dict) + or not approved_changes + or not isinstance(backup_root, str) + or not backup_root + or Path(backup_root).is_absolute() + or ".." in Path(backup_root).parts + or not isinstance(historical_evidence, dict) + ): + raise ValueError( + "same-version rebuild is missing its exact current version or input backup binding" + ) + if ( + not all( + isinstance(name, str) + and isinstance(digest, str) + and re.fullmatch(r"[a-f0-9]{64}", digest) + for name, digest in files.items() + ) + or package_input_file_hashes(solution) != files + or _identity_from_file_hashes(files) != action.get("packageInputSha256") + or current_identity != action.get("packageInputSha256") + or action.get("basePackageInputSha256") != successful.get("packageInputSha256") + or current_identity == successful.get("packageInputSha256") + or action.get("priorPackageAttemptId") != successful.get("attemptId") + or action.get("priorPackageReportSha256") != successful.get("packageReportSha256") + ): + raise ValueError( + "same-version rebuild inputs changed or no longer match the explicit approval" + ) + archived_report_reference = historical_evidence.get("packageReport") + if not isinstance(archived_report_reference, str): + raise ValueError("same-version rebuild is missing its archived prior package report") + archived_report_path = content_path(solution, archived_report_reference) + if ( + archived_report_path.is_symlink() + or not archived_report_path.is_file() + or hashlib.sha256(archived_report_path.read_bytes()).hexdigest() + != action.get("priorPackageReportSha256") + ): + raise ValueError("archived prior V3.1 package report is missing or modified") + try: + archived_report = json.loads(archived_report_path.read_bytes()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("archived prior V3.1 package report is invalid") from exc + if ( + archived_report.get("packager") != "V3.1" + or archived_report.get("workflowRunId") != action.get("runId") + or archived_report.get("packageAttemptId") != action.get("priorPackageAttemptId") + or archived_report.get("version") != target_version + or (archived_report.get("packageInputs") or {}).get("after", {}).get("sha256") + != action.get("basePackageInputSha256") + ): + raise ValueError("archived prior V3.1 receipt does not match the rebuild plan") + package_inputs = archived_report.get("packageInputs") or {} + prior_inputs = package_inputs.get("after") or {} + if prior_inputs.get("sha256") != action.get("basePackageInputSha256"): + raise ValueError("archived prior V3.1 receipt has a mismatched input identity") + baseline_manifest = prior_inputs.get("files") + evidence_binding = action.get("legacyBaselineEvidence") + evidence_reference: str | Path | None = None + expected_evidence_sha256: str | None = None + if evidence_binding is not None: + if ( + not isinstance(evidence_binding, dict) + or not isinstance(evidence_binding.get("path"), str) + or not isinstance(evidence_binding.get("sha256"), str) + or not re.fullmatch(r"[a-f0-9]{64}", evidence_binding["sha256"]) + ): + raise ValueError("same-version rebuild legacy baseline evidence binding is invalid") + evidence_reference = evidence_binding["path"] + expected_evidence_sha256 = evidence_binding["sha256"] + if ( + legacy_baseline_evidence is not None + and str(legacy_baseline_evidence).replace("\\", "/") + != evidence_binding["path"] + ): + raise ValueError( + "legacy baseline evidence differs from the evidence selected by the rebuild plan" + ) + elif legacy_baseline_evidence is not None: + evidence_reference = legacy_baseline_evidence + + if baseline_manifest is not None: + if evidence_reference is not None: + raise ValueError( + "legacy baseline evidence is only accepted when the successful receipt " + "does not contain a per-file manifest" + ) + baseline_files = _validate_package_input_file_manifest( + baseline_manifest, + expected_sha256=action["basePackageInputSha256"], + expected_file_count=prior_inputs.get("fileCount"), + label="archived successful V3.1 receipt", + ) + else: + if evidence_reference is None: + raise ValueError( + "legacy successful V3.1 receipt has no per-file package-input baseline; " + "provide exact receipt-bound --legacy-baseline-evidence or preserve a " + "baseline manifest" + ) + baseline_files, recovered_binding = _legacy_baseline_evidence( + solution, + evidence_reference, + expected_current_sha256=action["packageInputSha256"], + current_files=files, + expected_prior_sha256=action["basePackageInputSha256"], + expected_prior_file_count=prior_inputs.get("fileCount"), + expected_evidence_sha256=expected_evidence_sha256, + ) + if evidence_binding and recovered_binding != evidence_binding: + raise ValueError("legacy baseline evidence binding does not match the rebuild plan") + + approved_changes = { + name: digest + for name, digest in approved_changes.items() + if isinstance(name, str) + } + if len(approved_changes) != len(action.get("approvedChangedInputs") or {}): + raise ValueError("same-version rebuild approved input paths are invalid") + if any( + digest is not None + and ( + not isinstance(digest, str) + or not re.fullmatch(r"[a-f0-9]{64}", digest) + ) + for digest in approved_changes.values() + ): + raise ValueError("same-version rebuild approved input hashes are invalid") + _same_version_rebuild_scope(baseline_files, files, approved_changes) + + recorded_hashes = archived_report.get("packageArtifactHashes") or {} + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + reference = historical_evidence.get(name) + if not isinstance(reference, str): + raise ValueError(f"same-version rebuild is missing archived {name} evidence") + archived_artifact = content_path(solution, reference) + if ( + archived_artifact.is_symlink() + or not archived_artifact.is_file() + or hashlib.sha256(archived_artifact.read_bytes()).hexdigest() + != recorded_hashes.get(name) + ): + raise ValueError(f"archived prior V3.1 package artifact is missing or modified: {name}") + for relative, digest in files.items(): + if Path(relative).is_absolute() or ".." in Path(relative).parts: + raise ValueError("same-version rebuild input manifest contains an unsafe path") + backup = content_path(solution, f"{backup_root}/{relative}") + if ( + backup.is_symlink() + or not backup.is_file() + or hashlib.sha256(backup.read_bytes()).hexdigest() != digest + ): + raise ValueError( + f"same-version rebuild input backup is missing or modified: {relative}" + ) + + +def verify_same_version_content_rebuild( + solution: str | Path, + *, + legacy_baseline_evidence: str | Path | None = None, +) -> dict[str, Any]: + """Read-only verification of a pending or completed same-version rebuild.""" + root = Path(solution).expanduser().resolve() + _state_path, state = _load(root) + package_state = state.get("packaging") or {} + pending = package_state.get("pendingReleaseAction") or {} + completed_action = None + successful = package_state.get("lastSuccessful") or {} + if pending.get("actionType") == "same-version-content-rebuild": + action = pending + prior_successful = successful + receipt_verified = False + else: + actions = package_state.get("releaseActions") or [] + action = next(( + item for item in reversed(actions) + if item.get("actionType") == "same-version-content-rebuild" + and item.get("status") == "consumed" + and item.get("packageInputSha256") == successful.get("packageInputSha256") + and item.get("targetVersion") == successful.get("version") + ), None) + if action is None: + raise ValueError( + "selected run has no pending or successfully consumed same-version rebuild" + ) + completed_action = action + last_consumed = package_state.get("lastConsumedAction") or {} + if ( + successful.get("packageSucceeded") is not True + or successful.get("effectiveVersionBump") != "none" + or successful.get("attemptId") != last_consumed.get("attemptId") + or successful.get("packageInputSha256") != last_consumed.get("packageInputSha256") + or last_consumed.get("packageSucceeded") is not True + ): + raise ValueError("completed same-version rebuild is not linked to its consumed V3.1 receipt") + receipt_path = resolve_artifact_reference(root, successful.get("packageReport")) + if ( + receipt_path.is_symlink() + or not receipt_path.is_file() + or hashlib.sha256(receipt_path.read_bytes()).hexdigest() + != successful.get("packageReportSha256") + ): + raise ValueError("completed same-version V3.1 receipt is missing or modified") + try: + receipt = json.loads(receipt_path.read_bytes()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("completed same-version V3.1 receipt is invalid") from exc + current_files = package_input_file_hashes(root) + current_identity = _identity_from_file_hashes(current_files) + receipt_inputs = receipt.get("packageInputs") or {} + before = receipt_inputs.get("before") or {} + after = receipt_inputs.get("after") or {} + if ( + receipt.get("status") != "passed" + or receipt.get("packager") != "V3.1" + or receipt.get("workflowRunId") != state.get("runId") + or receipt.get("packageAttemptId") != successful.get("attemptId") + or receipt.get("version") != action.get("targetVersion") + or receipt.get("effectiveVersionBump") != "none" + or receipt.get("effectiveTargetVersion") is not None + or current_identity != action.get("packageInputSha256") + or before.get("sha256") != current_identity + or after.get("sha256") != current_identity + or after.get("fileCount") != len(current_files) + ): + raise ValueError("completed same-version receipt does not bind the approved current inputs") + candidate_manifest = after.get("files") + if candidate_manifest is not None and _validate_package_input_file_manifest( + candidate_manifest, + expected_sha256=current_identity, + expected_file_count=len(current_files), + label="completed same-version V3.1 receipt", + ) != current_files: + raise ValueError("completed same-version receipt file manifest differs from current inputs") + artifact_hashes = receipt.get("packageArtifactHashes") or {} + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + reference = receipt.get(name) + if not isinstance(reference, str) or not reference: + raise ValueError(f"completed same-version receipt is missing {name}") + artifact_path = resolve_artifact_reference(root, reference) + if ( + artifact_path.is_symlink() + or not artifact_path.is_file() + or hashlib.sha256(artifact_path.read_bytes()).hexdigest() + != artifact_hashes.get(name) + ): + raise ValueError(f"completed same-version package artifact is missing or modified: {name}") + archived_report = action.get("historicalEvidence", {}).get("packageReport") + prior_successful = { + "packageSucceeded": True, + "version": action.get("targetVersion"), + "packageInputSha256": action.get("basePackageInputSha256"), + "attemptId": action.get("priorPackageAttemptId"), + "packageReportSha256": action.get("priorPackageReportSha256"), + "packageReport": archived_report, + } + receipt_verified = True + + if successful.get("packageSucceeded") is not True: + raise ValueError("same-version rebuild verification requires its successful prior V3.1 receipt") + current_identity = package_input_identity(root)["sha256"] + _validate_same_version_content_rebuild_inputs( + root, + action, + current_identity, + solution_data_version(root), + prior_successful, + legacy_baseline_evidence, + ) + return { + "runId": state.get("runId"), + "status": "verified", + "targetVersion": action.get("targetVersion"), + "packageInputSha256": action.get("packageInputSha256"), + "changedInputs": sorted(action.get("approvedChangedInputs") or {}), + "receiptVerified": receipt_verified, + "packageReport": successful.get("packageReport") if completed_action else None, + "stateChanged": False, + "workflowCompletionClaimed": False, + } + + +def plan_release_notes_refresh( + solution: str | Path, + *, + release_date: str, + change_history: str, + expected_package_input_sha256: str, +) -> dict[str, Any]: + """Authorize only an exact ReleaseNotes row refresh without incrementing package version.""" + if not re.fullmatch(r"[a-f0-9]{64}", expected_package_input_sha256 or ""): + raise ValueError("expected package input identity must be a lowercase SHA-256") + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as path: + loaded_path, state = _load(root) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + if state.get("context", {}).get("profileSelectionConfirmed") is not True: + raise ValueError("release-notes refresh requires explicit profile selection") + if state.get("context", {}).get("workflowProfile") != "authoring": + raise ValueError("same-release ReleaseNotes refresh is supported only for Authoring") + if state["stages"]["validation"]["status"] != "passed": + raise ValueError("release-notes refresh requires passed Authoring validation") + validation_artifacts = state["stages"]["validation"].get("artifacts") or {} + if ( + validation_artifacts.get("validationMode") != "offline-authoring" + or validation_artifacts.get("runtimeStatus") != "not-run" + ): + raise ValueError("release-notes refresh requires current offline Authoring validation") + _validate_offline_authoring_gates(root, path, state) + if state.get("authoring") and state["authoring"]["status"] != "active": + raise ValueError("stopped or conflicted authoring runs cannot refresh release notes") + package_state = state.get("packaging") or {} + if package_state.get("pendingReleaseAction"): + raise ValueError("another package release action is already pending") + if (package_state.get("currentAttempt") or {}).get("status") == "running": + raise ValueError("cannot plan a ReleaseNotes refresh while V3.1 packaging is running") + successful = package_state.get("lastSuccessful") or {} + if not successful.get("packageSucceeded"): + raise ValueError("ReleaseNotes refresh requires a prior successful V3.1 package") + _verify_current_package_state(root, state) + files = package_input_file_hashes(root) + base_identity = _identity_from_file_hashes(files) + if ( + base_identity != successful.get("packageInputSha256") + or base_identity != expected_package_input_sha256 + ): + raise ValueError("package inputs must exactly match the prior successful V3.1 build") + current_version = solution_data_version(root) + if current_version != successful.get("version"): + raise ValueError("same-release notes refresh must retain the last packaged version") + notes_path, original_notes_sha, expected_notes_sha = _release_notes_refresh_proposal( + root, release_date=release_date, change_history=change_history, + ) + relative_notes = notes_path.relative_to(root).as_posix() + expected_files = dict(files) + expected_files[relative_notes] = expected_notes_sha + expected_identity = _identity_from_file_hashes(expected_files) + historical_evidence = _archive_package_evidence(root, successful) + selected_at = _utc_now() + action = { + "actionType": "release-notes-refresh", + "versionBump": "none", + "basePackageInputSha256": base_identity, + "packageInputSha256": expected_identity, + "releaseNotesPath": relative_notes, + "releaseNotesOriginalSha256": original_notes_sha, + "releaseNotesExpectedSha256": expected_notes_sha, + "releaseNotesDate": release_date, + "releaseNotesVersion": current_version, + "releaseNotesChangeHistory": change_history.strip(), + "packageInputFilesBefore": files, + "selectedAt": selected_at, + } + package_state = state.setdefault("packaging", {}) + package_state.setdefault("releaseActions", []).append({ + **action, + "status": "selected", + "historicalEvidence": historical_evidence, + }) + package_state["pendingReleaseAction"] = action + for name in ("packaging", "deployment", "mockIngestion", "alertParity", "report"): + stage = state["stages"][name] + if stage["status"] != "notRequired": + stage.update({ + "status": "blocked", + "completedAt": None, + "message": ( + "An explicitly approved same-version ReleaseNotes refresh is pending; " + "only the exact planned ReleaseNotes row may change before V3.1 rebuild." + ), + }) + state["workflowStatus"] = "blocked" + _write_unlocked(path, state) + return { + "runId": state["runId"], + "version": current_version, + "packageInputSha256Before": base_identity, + "packageInputSha256After": expected_identity, + "releaseNotesPath": relative_notes, + "releaseNotesDate": release_date, + "releaseNotesChangeHistory": change_history.strip(), + "next": "add the exact approved row to ReleaseNotes.md, then package-v3-1 --version-bump none", + "workflowStatus": state["workflowStatus"], + } + + +def plan_same_version_content_rebuild( + solution: str | Path, + *, + target_version: str, + changed_inputs: list[str], + expected_package_input_sha256: str, + legacy_baseline_evidence: str | Path | None = None, +) -> dict[str, Any]: + """Authorize one exact-input V3.1 rebuild without changing the solution version.""" + validate_v31_target_version(target_version) + if not re.fullmatch(r"[a-f0-9]{64}", expected_package_input_sha256 or ""): + raise ValueError("expected package input identity must be a lowercase SHA-256") + + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as path: + loaded_path, state = _load(root) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + if state.get("context", {}).get("profileSelectionConfirmed") is not True: + raise ValueError("same-version rebuild requires explicit profile selection") + if state.get("context", {}).get("workflowProfile") != "authoring": + raise ValueError("same-version content rebuild is supported only for Authoring") + if state["stages"]["validation"]["status"] != "passed": + raise ValueError( + "same-version content rebuild requires passed Authoring validation" + ) + validation_artifacts = state["stages"]["validation"].get("artifacts") or {} + if ( + validation_artifacts.get("validationMode") != "offline-authoring" + or validation_artifacts.get("runtimeStatus") != "not-run" + ): + raise ValueError( + "same-version content rebuild requires current offline Authoring validation" + ) + _validate_offline_authoring_gates(root, path, state) + if state.get("authoring") and state["authoring"]["status"] != "active": + raise ValueError("stopped or conflicted authoring runs cannot rebuild a package") + _yolo_package_readiness(root, path, state) + if verify_applied_package_selection(root, str(state.get("runId") or "")): + raise ValueError( + "same-version content rebuild requires the complete solution package; " + "evidence-based package selections are not supported" + ) + + current_version = solution_data_version(root) + if current_version != target_version: + raise ValueError( + "same-version rebuild target must exactly match the current solution version" + ) + package_state = state.get("packaging") or {} + if package_state.get("pendingReleaseAction"): + raise ValueError("another package release action is already pending") + if (package_state.get("currentAttempt") or {}).get("status") == "running": + raise ValueError("cannot plan a same-version rebuild while V3.1 packaging is running") + successful = package_state.get("lastSuccessful") or {} + if successful.get("packageSucceeded") is not True: + raise ValueError( + "same-version rebuild requires a prior successful V3.1 package receipt" + ) + if successful.get("effectiveVersionBump") not in {"patch", "minor", "major", "none"}: + raise ValueError("prior V3.1 package receipt has no valid version action") + historical_report = _validate_historical_package_receipt( + root, state, successful, target_version, + ) + last_consumed = package_state.get("lastConsumedAction") or {} + if ( + last_consumed.get("packageSucceeded") is not True + or last_consumed.get("attemptId") != successful.get("attemptId") + or last_consumed.get("packageInputSha256") != successful.get("packageInputSha256") + or last_consumed.get("version") not in (None, target_version) + ): + raise ValueError( + "prior package receipt is not linked to the current run's successful " + "consumed-action chain" + ) + prior_identity = successful.get("packageInputSha256") + if not isinstance(prior_identity, str) or prior_identity == expected_package_input_sha256: + raise ValueError( + "same-version content rebuild requires changed package inputs since the " + "prior successful V3.1 build" + ) + files = package_input_file_hashes(root) + current_identity = _identity_from_file_hashes(files) + if ( + current_identity != expected_package_input_sha256 + or current_identity == prior_identity + ): + raise ValueError( + "package inputs changed after same-version rebuild approval; refresh status " + "and approve the exact current input identity" + ) + package_inputs = historical_report.get("packageInputs") or {} + prior_inputs = package_inputs.get("after") or {} + if prior_inputs.get("sha256") != prior_identity: + raise ValueError("prior successful receipt has a mismatched package-input identity") + legacy_evidence_binding = None + if prior_inputs.get("files") is None: + if legacy_baseline_evidence is None: + raise ValueError( + "legacy successful V3.1 receipt has no per-file package-input baseline; " + "provide exact receipt-bound --legacy-baseline-evidence" + ) + baseline_files, legacy_evidence_binding = _legacy_baseline_evidence( + root, + legacy_baseline_evidence, + expected_current_sha256=current_identity, + current_files=files, + expected_prior_sha256=prior_identity, + expected_prior_file_count=prior_inputs.get("fileCount"), + ) + else: + if legacy_baseline_evidence is not None: + raise ValueError( + "legacy baseline evidence is only accepted for receipts without " + "a per-file manifest" + ) + baseline_files = _validate_package_input_file_manifest( + prior_inputs.get("files"), + expected_sha256=prior_identity, + expected_file_count=prior_inputs.get("fileCount"), + label="prior successful V3.1 receipt", + ) + normalized_changed_inputs = [ + name.replace("\\", "/") if isinstance(name, str) else name + for name in changed_inputs + ] + if ( + not normalized_changed_inputs + or any(not isinstance(name, str) for name in normalized_changed_inputs) + or len(set(normalized_changed_inputs)) != len(normalized_changed_inputs) + or any( + not name + or name.replace("\\", "/") != name + or Path(name).is_absolute() + or ".." in Path(name).parts + or Path(name).as_posix() != name + for name in normalized_changed_inputs + ) + ): + raise ValueError( + "same-version rebuild requires a unique, nonempty list of safe package-input paths" + ) + approved_changes = { + name: files.get(name) for name in normalized_changed_inputs + } + _same_version_rebuild_scope(baseline_files, files, approved_changes) + if any( + action.get("actionType") == "same-version-content-rebuild" + and action.get("packageInputSha256") == current_identity + for action in package_state.get("releaseActions", []) + ): + raise ValueError( + "a same-version rebuild action was already selected for these package inputs" + ) + + historical_evidence = _archive_package_evidence(root, successful) + backup_id = str(uuid.uuid4()) + backup_root = ( + path.parent / "Reports" / "package-history" / backup_id / "input-backup" + ) + current_files = package_input_file_hashes(root) + if current_files != files: + raise ValueError("package inputs changed while the rebuild backup was prepared") + for relative, digest in files.items(): + source = content_path(root, relative) + if source.is_symlink() or not source.is_file(): + raise ValueError(f"package input cannot be backed up safely: {relative}") + if hashlib.sha256(source.read_bytes()).hexdigest() != digest: + raise ValueError(f"package input changed while backing up: {relative}") + destination = backup_root.joinpath(*Path(relative).parts) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + if hashlib.sha256(destination.read_bytes()).hexdigest() != digest: + raise ValueError(f"package input backup verification failed: {relative}") + if package_input_file_hashes(root) != files: + raise ValueError("package inputs changed while the rebuild backup was prepared") + relative_backup_root = backup_root.relative_to(root).as_posix() + historical_evidence["inputBackupRoot"] = relative_backup_root + + selected_at = _utc_now() + action = { + "actionType": "same-version-content-rebuild", + "versionBump": "none", + "runId": state["runId"], + "targetVersion": target_version, + "basePackageInputSha256": prior_identity, + "packageInputSha256": current_identity, + "packageInputFilesBefore": files, + "approvedChangedInputs": approved_changes, + "priorPackageAttemptId": successful["attemptId"], + "priorPackageReportSha256": successful["packageReportSha256"], + "inputBackupRoot": relative_backup_root, + "backupId": backup_id, + "selectedAt": selected_at, + "historicalEvidence": historical_evidence, + } + if legacy_evidence_binding is not None: + action["legacyBaselineEvidence"] = legacy_evidence_binding + release_action = { + **action, + "status": "selected", + "historicalEvidence": historical_evidence, + } + package_state.setdefault("releaseActions", []).append(release_action) + package_state["pendingReleaseAction"] = action + for name in ("packaging", "deployment", "mockIngestion", "alertParity", "report"): + stage = state["stages"][name] + if stage["status"] != "notRequired": + stage.update({ + "status": "blocked", + "completedAt": None, + "message": ( + "An explicitly approved same-version content rebuild is pending; " + "the exact backed-up package inputs and current offline Authoring " + "gates must remain unchanged." + ), + }) + state["workflowStatus"] = "blocked" + _write_unlocked(path, state) + return { + "runId": state["runId"], + "version": target_version, + "priorPackageInputSha256": prior_identity, + "packageInputSha256": current_identity, + "approvedChangedInputs": action["approvedChangedInputs"], + "inputBackupRoot": relative_backup_root, + "historicalEvidence": historical_evidence, + "legacyBaselineEvidence": legacy_evidence_binding, + "next": "package-v3-1 --version-bump none", + "workflowStatus": state["workflowStatus"], + } + + +def plan_new_package_release( + solution: str | Path, + *, + version_bump: str | None = None, + target_version: str | None = None, + expected_package_input_sha256: str, +) -> dict[str, Any]: + """Explicitly reopen a stale package stage for one versioned V3.1 release action.""" + if (version_bump is None) == (target_version is None): + raise ValueError("select exactly one version bump or target version") + if target_version is not None: + validate_v31_target_version(target_version) + version_bump = "none" + action_type = "target-version" + else: + if version_bump not in {"patch", "minor", "major"}: + raise ValueError("new package release action must be patch, minor, or major") + action_type = "version-bump" + if not re.fullmatch(r"[a-f0-9]{64}", expected_package_input_sha256 or ""): + raise ValueError("expected package input identity must be a lowercase SHA-256") + + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as path: + loaded_path, state = _load(root) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + if state.get("context", {}).get("profileSelectionConfirmed") is not True: + raise ValueError("release planning requires explicit profile selection") + if state.get("authoring") and state["authoring"]["status"] != "active": + raise ValueError("stopped or conflicted YOLO runs cannot plan a package release") + selection_evidence = verify_applied_package_selection( + root, str(state["runId"]), + ) + partial_selection = bool( + selection_evidence + and selection_evidence["plan"].get("summary", {}).get("outcome") == "partial" + ) + validation_status = state["stages"]["validation"]["status"] + partial_release_ready = ( + partial_selection + and validation_status == "blocked" + and not state.get("authoring") + and state["stages"]["conversion"]["status"] == "passed" + ) + complete_selection = bool( + selection_evidence + and selection_evidence["plan"].get("summary", {}).get("outcome") == "complete" + ) + complete_release_ready = ( + complete_selection + and validation_status == "passed" + and state["stages"]["conversion"]["status"] == "passed" + ) + validation_artifacts = state["stages"]["validation"].get("artifacts") or {} + offline_authoring_ready = ( + state["context"].get("workflowProfile") == "authoring" + and validation_status == "passed" + and validation_artifacts.get("runtimeStatus") == "not-run" + and validation_artifacts.get("validationMode") == "offline-authoring" + ) + if offline_authoring_ready: + _validate_offline_authoring_gates(root, path, state) + first_release_ready = ( + partial_release_ready or complete_release_ready or offline_authoring_ready + ) + _yolo_package_readiness( + root, + path, + state, + allow_partial_selection=partial_release_ready, + ) + if any( + state["stages"][dependency]["status"] != "passed" + for dependency in _dependencies(state, "packaging") + if not (dependency == "validation" and partial_release_ready) + ): + raise ValueError("package release planning is blocked by unfinished prerequisites") + if not first_release_ready and validation_status != "passed": + raise ValueError( + "package release planning requires passed validation or a fresh, " + "applied partial package selection with all exclusions accounted for" + ) + + current_identity = package_input_identity(root)["sha256"] + if current_identity != expected_package_input_sha256: + raise ValueError( + "package inputs changed after release action selection; refresh status " + "and explicitly select an action for the current input identity" + ) + if target_version is not None and solution_data_version(root) == target_version: + raise ValueError( + "target version already matches the solution; a new release action must " + "change the version" + ) + + package_state = state.get("packaging") + if not isinstance(package_state, dict): + package_state = { + "schemaVersion": "1.0.0", + "lastSuccessful": None, + "lastConsumedAction": None, + "currentAttempt": None, + "releaseActions": [], + "pendingReleaseAction": None, + } + state["packaging"] = package_state + package_state.setdefault("releaseActions", []) + package_state.setdefault("pendingReleaseAction", None) + if (package_state.get("currentAttempt") or {}).get("status") == "running": + raise ValueError("cannot plan a new release while V3.1 packaging is running") + prior = package_state.get("lastSuccessful") + last_consumed = package_state.get("lastConsumedAction") or {} + if prior is None and last_consumed.get("bumpConsumed") is True: + if last_consumed.get("packageInputSha256") == current_identity: + raise ValueError( + "the initial version action was consumed by a failed build; retry " + "with --version-bump none for the matching package inputs" + ) + raise ValueError( + "package inputs changed after the initial version action was consumed; " + "the failed build's retry rules must be resolved before planning a new release" + ) + if prior is None and not first_release_ready: + raise ValueError( + "first release planning without a prior successful V3.1 package requires " + "fresh passed validation with an applied complete selection or explicit " + "offline Authoring evidence, or an eligible partial selection with blocked validation" + ) + if prior and current_identity == prior.get("packageInputSha256"): + raise ValueError( + "package inputs still match the successful V3.1 package; " + "a new release action is not warranted" + ) + pending = package_state.get("pendingReleaseAction") + if pending: + if pending.get("packageInputSha256") == current_identity: + raise ValueError( + "a release action is already selected for these package inputs; " + "continue package-v3-1 with that exact action" + ) + raise ValueError( + "package inputs changed after the pending release action was selected" + ) + if any( + item.get("packageInputSha256") == current_identity + and ( + ( + action_type == "target-version" + and item.get("actionType") == "target-version" + and item.get("targetVersion") == target_version + ) + or ( + action_type == "version-bump" + and item.get("versionBump") == version_bump + and item.get("actionType") != "target-version" + ) + ) + for item in package_state["releaseActions"] + ): + raise ValueError("this explicit release action was already consumed for these inputs") + + selected_at = _utc_now() + historical_evidence = ( + _archive_package_evidence(root, prior) if prior else {} + ) + selected_action = { + "versionBump": version_bump, + "packageInputSha256": current_identity, + "selectedAt": selected_at, + "status": "selected", + "historicalEvidence": historical_evidence, + } + pending_action = { + "versionBump": version_bump, + "packageInputSha256": current_identity, + "selectedAt": selected_at, + } + if target_version is not None: + selected_action.update({ + "actionType": action_type, + "targetVersion": target_version, + }) + pending_action.update({ + "actionType": action_type, + "targetVersion": target_version, + }) + package_state["releaseActions"].append(selected_action) + package_state["pendingReleaseAction"] = pending_action + for name in ("packaging", "deployment", "mockIngestion", "alertParity", "report"): + stage = state["stages"][name] + if stage["status"] != "notRequired": + stage.update({ + "status": "blocked", + "completedAt": None, + "message": ( + "Package inputs changed after the prior V3.1 release. " + "Prior artifacts and evidence are historical; an explicit " + "new release action was selected." + ), + }) + state["workflowStatus"] = "blocked" + _write_unlocked(path, state) + return { + "runId": state["runId"], + "versionBump": version_bump, + "targetVersion": target_version, + "packageInputSha256": current_identity, + "selectedAt": selected_at, + "next": _next_stage_name(state), + "workflowStatus": state["workflowStatus"], + } + + +def package_workflow_v3_1( + solution: str | Path, + *, + requested_version_bump: str, + requested_target_version: str | None = None, + toolkit_root: str | Path | None = None, + legacy_baseline_evidence: str | Path | None = None, +) -> dict[str, Any]: + """Package one current workflow attempt and persist fresh, hash-bound evidence.""" + if requested_version_bump not in {"none", "patch", "minor", "major"}: + raise ValueError("version bump must be none, patch, minor, or major") + if requested_target_version is not None: + validate_v31_target_version(requested_target_version) + if requested_version_bump != "none": + raise ValueError("target version cannot be combined with a version bump") + root = Path(solution).expanduser().resolve() + with _state_writer_lock(root) as path: + loaded_path, state = _load(root) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + if not state.get("runId"): + raise ValueError("workflow packaging requires an explicitly selected run") + if state["context"].get("profileSelectionConfirmed") is not True: + raise ValueError("workflow packaging requires explicit profile selection") + selection_evidence = verify_applied_package_selection( + root, str(state["runId"]), + ) + validation_status = state["stages"]["validation"]["status"] + partial_selection = bool( + selection_evidence + and selection_evidence["plan"].get("summary", {}).get("outcome") == "partial" + ) + if validation_status != "passed" and not ( + partial_selection + and validation_status == "blocked" + and not state.get("authoring") + ): + raise ValueError( + "workflow packaging requires passed validation or a fresh, applied " + "partial package selection with all exclusions accounted for" + ) + validation_artifacts = state["stages"]["validation"].get("artifacts") or {} + offline_authoring_package = ( + state["context"].get("workflowProfile") == "authoring" + and validation_status == "passed" + and validation_artifacts.get("runtimeStatus") == "not-run" + and validation_artifacts.get("validationMode") == "offline-authoring" + ) + if offline_authoring_package: + _validate_offline_authoring_gates(root, path, state) + _yolo_package_readiness( + root, + path, + state, + allow_partial_selection=partial_selection, + ) + if state.get("authoring") and state["authoring"]["status"] != "active": + raise ValueError("stopped or conflicted YOLO runs cannot be packaged") + campaign_budget = state.get("budget") + if not campaign_budget and state.get("authoring"): + raise ValueError( + "YOLO V3.1 package builds require a persisted campaign budget; resume " + "workflow-init with its explicit --campaign-id to link this run conservatively" + ) + if campaign_budget and campaign_budget.get("status") == "stopped": + raise ValueError( + "budget-stopped workflow runs cannot be resumed by an extension; " + "initialize an explicitly linked replacement run" + ) + if campaign_budget: + current_inventory_hash, _ = _budget_source_inventory( + root, + yolo=bool(state.get("authoring")), + ) + if current_inventory_hash != campaign_budget["inventorySha256"]: + raise ValueError( + "campaign frozen source inventory changed; package inputs are no longer " + "bound to this campaign" + ) + budget_summary( + root, + campaign_budget["campaignId"], + expected_inventory=current_inventory_hash, + ) + pending_dependencies = [ + dependency + for dependency in _dependencies(state, "packaging") + if state["stages"][dependency]["status"] != "passed" + ] + if pending_dependencies and not ( + pending_dependencies == ["validation"] + and partial_selection + and validation_status == "blocked" + and not state.get("authoring") + ): + raise ValueError("workflow packaging is blocked by unfinished prerequisites") + + inputs = package_input_identity(root) + package_state = state.get("packaging") or {} + pending_release = package_state.get("pendingReleaseAction") or {} + notes_refresh = pending_release.get("actionType") == "release-notes-refresh" + same_version_rebuild = ( + pending_release.get("actionType") == "same-version-content-rebuild" + ) + if legacy_baseline_evidence is not None and not same_version_rebuild: + raise ValueError( + "--legacy-baseline-evidence is only valid for a pending same-version rebuild" + ) + if notes_refresh and not offline_authoring_package: + raise ValueError( + "same-release ReleaseNotes refresh requires offline Authoring validation; " + "Qualification must use its runtime-bound release gates" + ) + if same_version_rebuild and not offline_authoring_package: + raise ValueError( + "same-version content rebuild requires offline Authoring validation; " + "Qualification must use its runtime-bound release gates" + ) + if same_version_rebuild and selection_evidence: + raise ValueError( + "same-version content rebuild requires the complete solution package; " + "evidence-based package selections are not supported" + ) + if notes_refresh and requested_version_bump != "none": + raise ValueError( + "approved same-release ReleaseNotes refresh must be rebuilt with " + "--version-bump none" + ) + if same_version_rebuild and ( + requested_version_bump != "none" or requested_target_version is not None + ): + raise ValueError( + "approved same-version content rebuild must use " + "--version-bump none without --target-version" + ) + successful_package = package_state.get("lastSuccessful") or {} + current_successful_no_bump_receipt = bool( + successful_package.get("packageSucceeded") is True + and successful_package.get("effectiveVersionBump") == "none" + and successful_package.get("packageInputSha256") == inputs["sha256"] + and (package_state.get("lastConsumedAction") or {}).get("effectiveVersionBump") == "none" + and any( + action.get("actionType") in { + "release-notes-refresh", "same-version-content-rebuild", + } + and action.get("status") == "consumed" + and action.get("packageInputSha256") == inputs["sha256"] + for action in package_state.get("releaseActions", []) + ) + ) + if current_successful_no_bump_receipt: + _verify_current_package_state(root, state) + if campaign_budget: + prior_package_attempts = package_history( + root, campaign_budget["campaignId"], + ) + last_consumed = next(( + item for item in reversed(prior_package_attempts) + if item["finish"] + and (item["finish"].get("completionMetadata") or {}).get("bumpConsumed") is True + ), None) + if last_consumed: + prior_after = ( + last_consumed["finish"].get("completionMetadata") or {} + ).get("packageInputSha256After") + if ( + not (notes_refresh or same_version_rebuild) + and not current_successful_no_bump_receipt + and prior_after == inputs["sha256"] + and ( + requested_version_bump != "none" + or requested_target_version is not None + ) + ): + raise ValueError( + "a campaign version action was already consumed for unchanged " + "package inputs; rebuild with --version-bump none" + ) + if ( + not (notes_refresh or same_version_rebuild) + and not current_successful_no_bump_receipt + and prior_after != inputs["sha256"] + and requested_version_bump == "none" + and requested_target_version is None + ): + raise ValueError( + "campaign package inputs changed after the consumed version action; " + "select an explicit new version bump" + ) + package_state = state.get("packaging") or { + "schemaVersion": "1.0.0", + "lastSuccessful": None, + "lastConsumedAction": None, + "currentAttempt": None, + "releaseActions": [], + "pendingReleaseAction": None, + } + package_state.setdefault("releaseActions", []) + package_state.setdefault("pendingReleaseAction", None) + pending_release = package_state.get("pendingReleaseAction") + notes_refresh = bool( + pending_release + and pending_release.get("actionType") == "release-notes-refresh" + ) + same_version_rebuild = bool( + pending_release + and pending_release.get("actionType") == "same-version-content-rebuild" + ) + previous = package_state.get("lastSuccessful") + previous_successful = previous + last_consumed = package_state.get("lastConsumedAction") or {} + if notes_refresh: + _validate_release_notes_refresh_inputs( + root, pending_release, inputs["sha256"], + ) + if same_version_rebuild: + _validate_same_version_content_rebuild_inputs( + root, + pending_release, + inputs["sha256"], + solution_data_version(root), + previous or {}, + legacy_baseline_evidence, + ) + failed_consumed_retry = ( + last_consumed.get("bumpConsumed") is True + and last_consumed.get("packageSucceeded") is False + and last_consumed.get("packageInputSha256") == inputs["sha256"] + ) + if previous is not None and inputs["sha256"] != previous.get("packageInputSha256"): + if not pending_release and not failed_consumed_retry: + raise ValueError( + "package inputs changed after V3.1 packaging; select an explicit " + "new release action before rebuilding" + ) + if pending_release and ( + pending_release.get("packageInputSha256") != inputs["sha256"] + or pending_release.get("versionBump") != requested_version_bump + or ( + pending_release.get("actionType") == "target-version" + and pending_release.get("targetVersion") != requested_target_version + ) + or ( + pending_release.get("actionType") != "target-version" + and requested_target_version is not None + ) + ): + raise ValueError( + "package input identity or version action differs from the explicitly " + "selected new release action" + ) + elif pending_release and ( + pending_release.get("packageInputSha256") != inputs["sha256"] + or pending_release.get("versionBump") != requested_version_bump + or ( + pending_release.get("actionType") == "target-version" + and pending_release.get("targetVersion") != requested_target_version + ) + or ( + pending_release.get("actionType") != "target-version" + and requested_target_version is not None + ) + ): + raise ValueError( + "pending release action no longer matches changed package inputs" + ) + if ( + previous is not None + and inputs["sha256"] == previous.get("packageInputSha256") + and ( + requested_version_bump != "none" + or requested_target_version is not None + ) + ): + raise ValueError( + "a V3.1 package already exists for unchanged inputs; use " + "--version-bump none without --target-version for a repeat build" + ) + if failed_consumed_retry and requested_version_bump != "none": + raise ValueError( + "the selected release action was consumed by a failed build; retry " + "with --version-bump none for the matching package inputs" + ) + effective_target_version = None + if pending_release and pending_release.get("actionType") == "target-version": + if requested_target_version != pending_release.get("targetVersion"): + raise ValueError( + "package target version differs from the explicitly selected release action" + ) + effective_target_version = pending_release["targetVersion"] + elif requested_target_version is not None: + selected_target = state["context"].get("targetVersion") + selected_bump = state["context"].get("versionBump") + if selected_bump not in (None, "none"): + raise ValueError( + "target version cannot be combined with the workflow's selected version bump" + ) + if selected_target not in (None, requested_target_version): + raise ValueError( + "requested target version differs from the workflow's selected target" + ) + if previous is None and solution_data_version(root) == requested_target_version: + raise ValueError( + "target version already matches the solution; a new release action must " + "change the version" + ) + effective_target_version = requested_target_version + elif ( + previous is None + and state["context"].get("targetVersion") is not None + and not failed_consumed_retry + ): + raise ValueError( + "the workflow selected a target version; package with the same " + "--target-version and --version-bump none" + ) + + if ( + not notes_refresh + and not pending_release + and requested_version_bump == "none" + and last_consumed.get("bumpConsumed") is True + ): + if inputs["sha256"] != last_consumed.get("packageInputSha256"): + raise ValueError( + "package inputs changed after the consumed version action; " + "select an explicit new version bump" + ) + effective_bump = "none" + elif notes_refresh: + effective_bump = "none" + elif pending_release: + effective_bump = pending_release["versionBump"] + elif previous is None: + selected_bump = state["context"].get("versionBump") + if selected_bump is not None and selected_bump != requested_version_bump: + raise ValueError( + "requested package version bump differs from the workflow's " + f"selected action: {requested_version_bump!r} != {selected_bump!r}" + ) + if selected_bump is None: + state["context"]["versionBump"] = requested_version_bump + if effective_target_version is not None and state["context"].get("targetVersion") is None: + state["context"]["targetVersion"] = effective_target_version + effective_bump = requested_version_bump + elif requested_version_bump == "none": + raise ValueError( + "retry with version bump none is allowed only after the original " + "version action was consumed" + ) + else: + effective_bump = requested_version_bump + + stage = state["stages"]["packaging"] + if stage["status"] == "notRequired": + raise ValueError("packaging is not required for this workflow") + attempt_number = stage["attempts"] + 1 + attempt_id = str(uuid.uuid4()) + started_at = _utc_now() + stage.update({ + "status": "running", + "attempts": attempt_number, + "startedAt": started_at, + "completedAt": None, + "message": None, + "artifacts": {}, + "evidence": [], + }) + package_state["currentAttempt"] = { + "attemptId": attempt_id, + "attempt": attempt_number, + "status": "running", + "requestedVersionBump": requested_version_bump, + "effectiveVersionBump": effective_bump, + "requestedTargetVersion": requested_target_version, + "effectiveTargetVersion": effective_target_version, + "bumpConsumed": False, + "targetConsumed": False, + "packageInputSha256": inputs["sha256"], + "packageReportSha256": None, + "startedAt": started_at, + } + state["packaging"] = package_state + state["workflowStatus"] = "inProgress" + _write_unlocked(path, state) + + version_before = solution_data_version(root) + try: + if campaign_budget: + package_receipt_details: dict[str, Any] = {} + operation = measured_operation( + root, + campaign_budget["campaignId"], + inventory_sha256=campaign_budget["inventorySha256"], + run_id=state["runId"], + dimension="packageBuild", + metadata={ + "packageInputSha256": inputs["sha256"], + "requestedVersionBump": requested_version_bump, + "effectiveVersionBump": effective_bump, + "requestedTargetVersion": requested_target_version, + "effectiveTargetVersion": effective_target_version, + }, + completion_metadata=package_receipt_details, + ) + else: + from contextlib import nullcontext + operation = nullcontext() + package_receipt_details = {} + package_dispatched = False + with operation: + try: + current_selection = verify_applied_package_selection( + root, str(state["runId"]), + ) + if ( + selection_evidence + and ( + not current_selection + or current_selection["plan"].get("planSha256") + != selection_evidence["plan"].get("planSha256") + ) + ): + raise ValueError( + "applied package selection changed after the workflow attempt was authorized" + ) + package_dispatched = True + result = package_solution_v3_1( + root, + version_bump=effective_bump, + target_version=effective_target_version, + toolkit_root=toolkit_root, + requested_version_bump=requested_version_bump, + package_attempt_id=attempt_id, + workflow_run_id=state["runId"], + selection_plan=( + current_selection["plan"] if current_selection else None + ), + ) + except BaseException: + version_after_failure = solution_data_version(root) + consumed_on_failure = _version_action_was_consumed( + version_before, + version_after_failure, + effective_bump, + effective_target_version, + ) + package_receipt_details.update({ + "bumpConsumed": consumed_on_failure, + "targetConsumed": ( + effective_target_version is not None + and consumed_on_failure + ), + "targetVersion": effective_target_version, + "packageInputSha256After": ( + package_input_identity(root)["sha256"] + if consumed_on_failure else inputs["sha256"] + ), + }) + raise + else: + package_receipt_details.update({ + "bumpConsumed": _version_action_was_consumed( + version_before, + solution_data_version(root), + effective_bump, + effective_target_version, + ), + "targetConsumed": ( + effective_target_version is not None + and solution_data_version(root) == effective_target_version + and version_before != effective_target_version + ), + "targetVersion": effective_target_version, + "packageInputSha256After": package_input_identity(root)["sha256"], + }) + report_for_receipt = json.loads( + Path(result["packageReport"]).read_text(encoding="utf-8-sig") + ) + receipt_inputs = report_for_receipt.get("packageInputs") or {} + report_after = (receipt_inputs.get("after") or {}).get("sha256") + if report_after: + package_receipt_details["packageInputSha256After"] = report_after + report_path = resolve_artifact_reference(root, result["packageReport"]) + report_hash = hashlib.sha256(report_path.read_bytes()).hexdigest() + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + report_inputs = report.get("packageInputs") or {} + report_before = (report_inputs.get("before") or {}).get("sha256") + if report_before != inputs["sha256"]: + raise ValueError("package inputs changed after the workflow attempt was authorized") + input_after = (report_inputs.get("after") or {}).get("sha256") + if not input_after: + raise ValueError("V3.1 package report has no post-build package input identity") + if notes_refresh: + current_after = package_input_identity(root)["sha256"] + _validate_release_notes_refresh_inputs( + root, pending_release, current_after, + ) + if ( + input_after != current_after + or report.get("version") != pending_release.get("releaseNotesVersion") + or solution_data_version(root) != pending_release.get("releaseNotesVersion") + or report.get("effectiveVersionBump") != "none" + ): + raise ValueError( + "V3.1 receipt does not bind the exact approved same-version " + "ReleaseNotes refresh" + ) + if same_version_rebuild: + current_after = package_input_identity(root)["sha256"] + _validate_same_version_content_rebuild_inputs( + root, + pending_release, + current_after, + solution_data_version(root), + successful_package, + legacy_baseline_evidence, + ) + if ( + input_after != current_after + or report.get("version") != pending_release.get("targetVersion") + or solution_data_version(root) != pending_release.get("targetVersion") + or report.get("effectiveVersionBump") != "none" + or report.get("effectiveTargetVersion") is not None + ): + raise ValueError( + "V3.1 receipt does not bind the exact approved same-version " + "content rebuild" + ) + if effective_target_version is not None and ( + report.get("version") != effective_target_version + or solution_data_version(root) != effective_target_version + or report.get("effectiveTargetVersion") != effective_target_version + ): + raise ValueError( + "V3.1 receipt does not bind the explicitly selected target version" + ) + current_attempt = package_state["currentAttempt"] + current_attempt.update({ + "status": "passed", + "bumpConsumed": True, + "targetConsumed": effective_target_version is not None, + "packageInputSha256": input_after, + "packageReportSha256": report_hash, + "completedAt": _utc_now(), + }) + package_state["lastConsumedAction"] = { + "requestedVersionBump": requested_version_bump, + "effectiveVersionBump": effective_bump, + "requestedTargetVersion": requested_target_version, + "effectiveTargetVersion": effective_target_version, + "targetConsumed": effective_target_version is not None, + "bumpConsumed": True, + "packageInputSha256": input_after, + "attemptId": attempt_id, + "packageSucceeded": True, + } + if pending_release: + for release_action in reversed(package_state["releaseActions"]): + if ( + release_action["status"] == "selected" + and release_action["packageInputSha256"] == inputs["sha256"] + ): + release_action["status"] = "consumed" + break + package_state["pendingReleaseAction"] = None + artifacts = dict(result["workflowArtifacts"]) + artifacts.update({ + "requestedVersionBump": requested_version_bump, + "effectiveVersionBump": effective_bump, + "packageAttemptId": attempt_id, + "packageInputSha256": input_after, + "packageReportSha256": report_hash, + }) + if requested_target_version is not None or effective_target_version is not None: + artifacts.update({ + "requestedTargetVersion": requested_target_version, + "effectiveTargetVersion": effective_target_version, + }) + if selection_evidence: + artifacts.update({ + "selectionPlan": selection_evidence["planPath"], + "selectionReceipt": selection_evidence["receiptPath"], + "selectionPlanSha256": selection_evidence["plan"]["planSha256"], + "selectionOutcome": report.get("selectionOutcome"), + }) + stage.update({ + "status": "passed", + "completedAt": _utc_now(), + "message": ( + "Fresh V3.1 package evidence verified for the current workflow attempt." + if validation_status == "passed" + else "Fresh partial V3.1 package evidence verified; excluded inventory remains unchanged." + ), + "artifacts": artifacts, + "evidence": [result["packageReport"]], + }) + _validate_packaging_evidence(state, artifacts, root) + package_state["lastSuccessful"] = { + **package_state["lastConsumedAction"], + "packageReport": result["packageReport"], + "packageReportSha256": report_hash, + "packageArtifactsSha256": report.get("packageArtifactsSha256"), + "version": result["version"], + } + state["workflowStatus"] = ( + "inProgress" + ) + _write_unlocked(path, state) + return { + **result, + "runId": state["runId"], + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + } + except BudgetError as exc: + if package_dispatched: + package_state["lastSuccessful"] = previous_successful + current_attempt = package_state["currentAttempt"] + version_after = solution_data_version(root) + consumed = _version_action_was_consumed( + version_before, + version_after, + effective_bump, + effective_target_version, + ) + if consumed: + try: + input_after_failure = package_input_identity(root)["sha256"] + except (OSError, ValueError): + input_after_failure = None + if input_after_failure: + package_state["lastConsumedAction"] = { + "requestedVersionBump": requested_version_bump, + "effectiveVersionBump": effective_bump, + "requestedTargetVersion": requested_target_version, + "effectiveTargetVersion": effective_target_version, + "targetConsumed": ( + effective_target_version is not None and consumed + ), + "bumpConsumed": True, + "packageInputSha256": input_after_failure, + "attemptId": attempt_id, + "packageSucceeded": False, + } + current_attempt["bumpConsumed"] = True + current_attempt["targetConsumed"] = ( + effective_target_version is not None + ) + current_attempt["packageInputSha256"] = input_after_failure + if pending_release: + package_state["pendingReleaseAction"] = None + for release_action in reversed(package_state["releaseActions"]): + if ( + release_action["status"] == "selected" + and release_action["packageInputSha256"] == inputs["sha256"] + ): + release_action["status"] = "consumed" + break + current_attempt.update({ + "status": "failed", + "completedAt": _utc_now(), + "failure": f"V3.1 build was dispatched but receipt persistence failed: {exc}"[:1000], + }) + stage.update({ + "status": "failed", + "completedAt": _utc_now(), + "message": f"V3.1 build was dispatched but receipt persistence failed: {exc}"[:2000], + "artifacts": {}, + "evidence": [], + }) + state["workflowStatus"] = "failed" + _write_unlocked(path, state) + raise + package_state["lastSuccessful"] = previous_successful + current_attempt = package_state["currentAttempt"] + current_attempt.update({ + "status": "blocked", + "completedAt": _utc_now(), + "failure": str(exc)[:1000], + }) + stage.update({ + "status": "blocked", + "completedAt": _utc_now(), + "message": f"V3.1 build was not dispatched: {exc}"[:2000], + "artifacts": {}, + "evidence": [], + }) + _stop_budget_run(path, state, "packaging", str(exc)) + raise + except Exception as exc: + package_state["lastSuccessful"] = previous_successful + current_attempt = package_state["currentAttempt"] + version_after = solution_data_version(root) + consumed = _version_action_was_consumed( + version_before, + version_after, + effective_bump, + effective_target_version, + ) + if consumed: + try: + input_after_failure = package_input_identity(root)["sha256"] + except (OSError, ValueError): + input_after_failure = None + if input_after_failure: + package_state["lastConsumedAction"] = { + "requestedVersionBump": requested_version_bump, + "effectiveVersionBump": effective_bump, + "requestedTargetVersion": requested_target_version, + "effectiveTargetVersion": effective_target_version, + "targetConsumed": ( + effective_target_version is not None and consumed + ), + "bumpConsumed": True, + "packageInputSha256": input_after_failure, + "attemptId": attempt_id, + "packageSucceeded": False, + } + current_attempt["bumpConsumed"] = True + current_attempt["targetConsumed"] = ( + effective_target_version is not None + ) + current_attempt["packageInputSha256"] = input_after_failure + if pending_release: + package_state["pendingReleaseAction"] = None + for release_action in reversed(package_state["releaseActions"]): + if ( + release_action["status"] == "selected" + and release_action["packageInputSha256"] == inputs["sha256"] + ): + release_action["status"] = "consumed" + break + current_attempt.update({ + "status": "failed", + "completedAt": _utc_now(), + "failure": str(exc)[:1000], + }) + stage.update({ + "status": "failed", + "completedAt": _utc_now(), + "message": f"V3.1 package attempt failed: {exc}"[:2000], + "artifacts": {}, + "evidence": [], + }) + state["workflowStatus"] = "failed" + _write_unlocked(path, state) + raise + + +def workflow_status(solution: str | Path) -> dict[str, Any]: + path, state = _load(solution) + from .runtime import current_runtime_evidence + + try: + runtime_evidence = current_runtime_evidence(solution) + except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError) as exc: + runtime_evidence = { + "passed": False, + "sourceStatus": "blocked", + "sourceWhitelist": [], + "issues": [f"current runtime assessment unavailable: {type(exc).__name__}"], + } + authoring_summary = None + if state.get("authoring"): + document, _ = _load_contract_snapshot(path.parent, state["authoring"]["currentContract"]) + authoring_summary = { + "status": state["authoring"]["status"], + "contract": state["authoring"]["currentContract"], + "coverage": document["coverage"], + "inventorySha256": document["inventory"]["sha256"], + } + freshness = _package_freshness(solution, state) + next_stage = _next_stage_name(state) + if freshness and freshness["status"] != "current": + next_stage = "packaging" + partial_package_summary = None + packaging_stage = (state.get("stages") or {}).get("packaging") or {} + packaging_artifacts = packaging_stage.get("artifacts") or {} + validation_stage = (state.get("stages") or {}).get("validation") or {} + validation_artifacts = validation_stage.get("artifacts") or {} + offline_authoring_marker = ( + state.get("context", {}).get("workflowProfile") == "authoring" + and validation_stage.get("status") == "passed" + and validation_artifacts.get("runtimeStatus") == "not-run" + and validation_artifacts.get("validationMode") == "offline-authoring" + ) + runtime_report_directory = report_directory(solution) + offline_runtime_reports = tuple( + runtime_report_directory / f"runtime-validation.{provider}.json" + for provider in ( + "triage-mcp", + "azure-monitor-sdk", + "log-analytics-cli", + "graph", + ) + ) + offline_authoring = offline_authoring_marker and not any( + report_path.is_file() for report_path in offline_runtime_reports + ) + if ( + packaging_stage.get("status") == "passed" + and packaging_artifacts.get("selectionOutcome") == "partial-package-validated" + and freshness + and freshness.get("status") == "current" + ): + successful = ((state.get("packaging") or {}).get("lastSuccessful") or {}) + report_reference = successful.get("packageReport") + if isinstance(report_reference, str): + report_path = resolve_artifact_reference(solution, report_reference) + package_report = json.loads(report_path.read_text(encoding="utf-8-sig")) + partial_package_summary = { + "status": "partial-package-validated", + "profile": (state.get("context") or {}).get("workflowProfile"), + "selectionPlanSha256": package_report.get("selectionPlanSha256"), + "coverage": package_report.get("selectionSummary"), + "validationStageStatus": state["stages"]["validation"]["status"], + "fullyRuntimeQualified": runtime_evidence.get("passed") is True, + "parityStatus": ( + (state.get("stages") or {}).get("alertParity") or {} + ).get("status"), + } + return { + **state, + "packageFreshness": freshness, + "runtimeAssessment": { + "runtimeStatus": ( + "not-run" + if offline_authoring + else "passed" + if runtime_evidence.get("passed") + else "source-waived" + if runtime_evidence.get("sourceStatus") == "source-waived" + else "blocked" + ), + "sourceStatus": runtime_evidence.get("sourceStatus", "not-run"), + "fullyRuntimeQualified": bool(runtime_evidence.get("passed")), + "sourceWaiverOnlyBlocker": bool( + runtime_evidence.get("sourceWaiverOnlyBlocker") + ), + "sourceWhitelist": runtime_evidence.get("sourceWhitelist") or [], + "issues": runtime_evidence.get("issues") or [], + }, + "authoringSummary": authoring_summary, + "partialSolution": partial_package_summary, + "budgetSummary": ( + budget_summary(solution, state["budget"]["campaignId"]) + if state.get("budget") else ( + { + "enforcement": "unknown-unmeasured-history", + "legacyUsageStatus": "unknown", + "message": "This workflow has no persisted campaign budget.", + } + if state.get("authoring", {}).get("mode") == "yolo" else None + ) + ), + "conversionScope": conversion_scope(solution), + "statePath": str(existing_artifact_path(solution, STATE_FILE_NAME)), + "next": next_stage, + } + + +def _stop_budget_run( + state_path: Path, + state: dict[str, Any], + stage_name: str, + reason: str, +) -> None: + stage = state["stages"][stage_name] + if stage["status"] == "running": + stage.update({ + "status": "blocked", + "completedAt": _utc_now(), + "message": f"Campaign budget stopped new work: {reason}"[:2000], + }) + state["workflowStatus"] = "blocked" + budget = state.get("budget") + if isinstance(budget, dict): + budget["status"] = "stopped" + budget["stopReason"] = reason[:1000] + authoring = state.get("authoring") + if isinstance(authoring, dict): + authoring["status"] = "stopped" + authoring["stopReason"] = f"Campaign budget: {reason}"[:1000] + _write_unlocked(state_path, state) + + +def invalidate_scoped_completion(solution: str | Path) -> None: + """Retain historical evidence, but never reuse passed stages after a partial refresh.""" + if not existing_artifact_path(solution, STATE_FILE_NAME).is_file(): + return + with _state_writer_lock(solution) as path: + loaded_path, state = _load(solution) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + for name in STAGES[1:]: + current = state["stages"][name] + if current["status"] == "passed": + current["status"] = "blocked" + current["message"] = ( + "Rule-scoped conversion invalidated full-solution completion. " + "Retained artifacts/evidence are historical; reconvert and revalidate the solution." + ) + state["workflowStatus"] = "blocked" + _write_unlocked(path, state) + + +def invalidate_runtime_completion( + solution: str | Path, + changed_queries: list[str], + *, + reason: str | None = None, +) -> None: + """Block completed validation when current query or post-offline evidence changes.""" + if ( + (not changed_queries and not reason) + or not existing_artifact_path(solution, STATE_FILE_NAME).is_file() + ): + return + with _state_writer_lock(solution) as path: + loaded_path, state = _load(solution) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + validation = state["stages"]["validation"] + offline_validation = ( + validation["status"] == "passed" + and (validation.get("artifacts") or {}).get("runtimeStatus") == "not-run" + and (validation.get("artifacts") or {}).get("validationMode") == "offline-authoring" + ) + if reason and not offline_validation: + return + affected = STAGES[2:] + if not any( + state["stages"][name]["status"] not in {"pending", "notRequired"} + for name in affected + ): + return + if reason: + message = ( + "Runtime validation is not complete: " + + reason + + " Reconcile the current complete provider evidence before completing Validation." + ) + else: + names = ", ".join(sorted(set(changed_queries))) + message = ( + "A converted Advanced Hunting query changed; previous runtime evidence is " + "historical and must not be reused. Rerun the complete Sentinel and Advanced " + f"Hunting query families against the exact current queries. Changed outputs: {names}" + ) + for name in affected: + current = state["stages"][name] + if current["status"] not in {"pending", "notRequired"}: + current["status"] = "blocked" + current["completedAt"] = None + current["message"] = message + state["workflowStatus"] = "blocked" + _write_unlocked(path, state) + + +def _dependencies(state: dict[str, Any], stage: str) -> tuple[str, ...]: + if state["context"]["workflowProfile"] == "authoring": + return AUTHORING_DEPENDENCIES.get(stage, ()) + return QUALIFICATION_DEPENDENCIES[stage] + + +def _next_stage_name(state: dict[str, Any]) -> str | None: + if state.get("authoring", {}).get("status") in {"stopped", "conflicted"}: + return None + running = [ + name for name in STAGES if state["stages"][name]["status"] == "running" + ] + if running: + return running[0] + if (state.get("packaging") or {}).get("pendingReleaseAction"): + return "packaging" + for name in STAGES: + stage = state["stages"][name] + if ( + name == "validation" + and _partial_package_state(state) + ): + continue + if stage["status"] in {"passed", "notRequired"}: + continue + if all( + state["stages"][dependency]["status"] == "passed" + for dependency in _dependencies(state, name) + ): + return name + return None + + +def _partial_package_state(state: dict[str, Any]) -> bool: + packaging = (state.get("stages") or {}).get("packaging") or {} + artifacts = packaging.get("artifacts") or {} + return ( + packaging.get("status") == "passed" + and artifacts.get("selectionOutcome") == "partial-package-validated" + ) + + +def next_workflow_stage(solution: str | Path) -> dict[str, Any]: + path, state = _load(solution) + name = _next_stage_name(state) + freshness = _package_freshness(solution, state) + release_action_required = bool(freshness and freshness["status"] != "current") + if release_action_required: + name = "packaging" + blocked_by = [] + if name is None and state["workflowStatus"] != "completed": + for candidate in STAGES: + if candidate == "validation" and _partial_package_state(state): + continue + if state["stages"][candidate]["status"] in {"passed", "notRequired"}: + continue + blocked_by.extend( + dependency + for dependency in _dependencies(state, candidate) + if state["stages"][dependency]["status"] != "passed" + ) + if blocked_by: + break + return { + "runId": state.get("runId"), + "conversionScope": conversion_scope(solution), + "solution": state["solution"], + "statePath": str(existing_artifact_path(solution, STATE_FILE_NAME)), + "workflowStatus": state["workflowStatus"], + "next": name, + "blockedBy": blocked_by, + "packageFreshness": freshness, + "releaseActionRequired": release_action_required, + } + + +def start_workflow_stage(solution: str | Path, stage: str) -> dict[str, Any]: + with _state_writer_lock(solution) as path: + return _start_workflow_stage_unlocked(solution, stage, path) + + +def _start_workflow_stage_unlocked( + solution: str | Path, + stage: str, + path: Path, +) -> dict[str, Any]: + if stage not in STAGES: + raise ValueError(f"unknown workflow stage: {stage}") + if stage in STAGES[3:]: + require_solution_conversion_scope(solution) + loaded_path, state = _load(solution) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + authoring = state.get("authoring") + if authoring and authoring["status"] in {"stopped", "conflicted"} and stage != "report": + raise ValueError( + "YOLO authoring is stopped; new migration stages cannot be started" + ) + current = state["stages"][stage] + if current["status"] == "notRequired": + raise ValueError( + f"workflow stage is not required for " + f"{state['context']['workflowProfile']} profile: {stage}" + ) + if current["status"] == "passed": + raise ValueError(f"workflow stage already passed: {stage}") + if current["status"] == "running": + return {**current, "statePath": str(path), "resumed": True} + + blocked_by = [ + dependency + for dependency in _dependencies(state, stage) + if state["stages"][dependency]["status"] != "passed" + ] + if blocked_by: + raise ValueError( + f"workflow stage {stage} is blocked by: {', '.join(blocked_by)}" + ) + if state.get("packaging") and stage in {"deployment", "report"}: + _verify_current_package_state(Path(solution).expanduser().resolve(), state) + + current.update( + { + "status": "running", + "attempts": current["attempts"] + 1, + "startedAt": _utc_now(), + "completedAt": None, + "message": None, + "artifacts": {}, + "evidence": [], + } + ) + state["workflowStatus"] = "inProgress" + _write_unlocked(path, state) + return {**current, "statePath": str(path), "resumed": False} + + +def complete_workflow_stage( + solution: str | Path, + stage: str, + *, + status: str, + message: str | None = None, + artifacts: dict[str, str] | None = None, + evidence: list[str] | None = None, +) -> dict[str, Any]: + with _state_writer_lock(solution) as path: + return _complete_workflow_stage_unlocked( + solution, stage, path, status=status, message=message, + artifacts=artifacts, evidence=evidence, + ) + + +def _complete_workflow_stage_unlocked( + solution: str | Path, + stage: str, + path: Path, + *, + status: str, + message: str | None = None, + artifacts: dict[str, str] | None = None, + evidence: list[str] | None = None, +) -> dict[str, Any]: + if stage not in STAGES: + raise ValueError(f"unknown workflow stage: {stage}") + if status not in TERMINAL_STATUSES: + raise ValueError("stage status must be passed, failed, or blocked") + if status in {"failed", "blocked"} and not str(message or "").strip(): + raise ValueError(f"{status} workflow stages require a message") + if status == "passed" and stage != "discovery": + require_solution_conversion_scope(solution) + + loaded_path, state = _load(solution) + if loaded_path != path: + raise ValueError("workflow state selection changed while acquiring its writer lock") + current = state["stages"][stage] + if current["status"] != "running": + raise ValueError(f"workflow stage is not running: {stage}") + final_artifacts = dict(artifacts or {}) + if stage == "validation" and status == "passed": + runtime_status = final_artifacts.get("runtimeStatus") + from .runtime import current_runtime_evidence + + runtime_evidence = current_runtime_evidence(solution) + if runtime_status == "source-waived": + if not runtime_evidence.get("sourceWaiverOnlyBlocker"): + raise ValueError( + "source-waived validation requires an exact IdentityInfo source failure " + "and every other exact runtime gate to pass" + ) + _validate_source_waiver_conversion_gates( + Path(solution).expanduser().resolve(), + path, + state, + ) + whitelist = runtime_evidence.get("sourceWhitelist") or [] + if not whitelist or any( + item.get("table") != "IdentityInfo" + or item.get("rawFailure", {}).get("status") != "failed" + or item.get("applied") is not True + for item in whitelist + ): + raise ValueError( + "source-waived validation requires current failed raw results " + "classified by the IdentityInfo whitelist" + ) + final_artifacts.update( + _source_whitelist_stage_artifacts(runtime_evidence) + ) + final_artifacts["runtimeStatus"] = "source-waived" + final_artifacts["sourceRuntimeWhitelistRawStatus"] = "failed" + final_artifacts.setdefault("sentinelRuntimeProvider", runtime_evidence["sentinelProvider"]) + final_artifacts.setdefault("sentinelRuntimeReport", runtime_evidence["sentinelReport"]) + final_artifacts.setdefault("sentinelRuntimeJson", runtime_evidence["sentinelReport"]) + final_artifacts.setdefault("advancedHuntingRuntimeReport", runtime_evidence["graphReport"]) + elif runtime_status == "environment-blocked": + if ( + runtime_evidence.get("sourceStatus") == "source-waived" + or runtime_evidence.get("sourceWhitelist") + ): + raise ValueError( + "an IdentityInfo source whitelist match cannot be relabeled " + "environment-blocked; XDR and source-waiver gates must be evaluated" + ) + if state["context"]["workflowProfile"] != "authoring": + raise ValueError( + "Qualification validation cannot pass as environment-blocked; " + "runtime query validation is required" + ) + if not str(message or "").strip(): + raise ValueError( + "environment-blocked Authoring validation requires an explanation" + ) + if runtime_evidence.get("currentQueryFailures"): + raise ValueError( + "a current exact-query KQL/runtime failure cannot be relabeled " + "environment-blocked: " + + "; ".join(runtime_evidence["currentQueryFailures"]) + ) + elif runtime_status == "not-run": + if final_artifacts.get("validationMode") != "offline-authoring": + raise ValueError( + "runtimeStatus=not-run requires the explicit offline-authoring validation mode" + ) + _validate_offline_authoring_gates( + Path(solution).expanduser().resolve(), + path, + state, + ) + final_artifacts = { + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + } + message = _offline_authoring_validation_message() + else: + if not runtime_evidence["passed"]: + raise ValueError( + "passed validation requires complete Sentinel and Advanced Hunting " + "runtime evidence bound to every current query: " + + "; ".join(runtime_evidence["issues"]) + ) + final_artifacts.setdefault("sentinelRuntimeProvider", runtime_evidence["sentinelProvider"]) + final_artifacts.setdefault("sentinelRuntimeReport", runtime_evidence["sentinelReport"]) + final_artifacts.setdefault("sentinelRuntimeJson", runtime_evidence["sentinelReport"]) + final_artifacts.setdefault("advancedHuntingRuntimeReport", runtime_evidence["graphReport"]) + elif stage == "validation" and status in {"blocked", "failed"}: + from .runtime import current_runtime_evidence + + try: + runtime_evidence = current_runtime_evidence(solution) + except (OSError, ValueError, KeyError, TypeError, yaml.YAMLError): + runtime_evidence = {} + final_artifacts.update( + _source_whitelist_stage_artifacts(runtime_evidence) + ) + if final_artifacts.get("sourceRuntimeWhitelistCount"): + final_artifacts["sourceRuntimeWhitelistRawStatus"] = "failed" + if stage == "packaging" and status == "passed": + _validate_packaging_evidence(state, final_artifacts, solution) + if ( + status == "passed" + and stage in {"deployment", "report"} + and state.get("packaging") + ): + _verify_current_package_state(Path(solution).expanduser().resolve(), state) + current.update( + { + "status": status, + "completedAt": _utc_now(), + "message": message, + "artifacts": final_artifacts, + "evidence": evidence or [], + } + ) + if status == "failed": + state["workflowStatus"] = "failed" + elif status == "blocked": + state["workflowStatus"] = "blocked" + elif stage == "report": + state["workflowStatus"] = ( + "partialCompleted" + if _partial_package_state(state) + else "completed" + ) + else: + state["workflowStatus"] = "inProgress" + _write_unlocked(path, state) + return { + **current, + "statePath": str(path), + "workflowStatus": state["workflowStatus"], + "next": _next_stage_name(state), + } diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/arm_template_acceptance_solution/Package/acceptance-template-snapshot.json b/Tools/SentinelToXDRMigration/tests/fixtures/arm_template_acceptance_solution/Package/acceptance-template-snapshot.json new file mode 100644 index 00000000000..d4ca8a1825d --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/arm_template_acceptance_solution/Package/acceptance-template-snapshot.json @@ -0,0 +1,8 @@ +{ + "parameters": { + "workspace": { + "type": "string" + } + }, + "resources": [] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/arm_template_acceptance_solution/Package/mainTemplate.json b/Tools/SentinelToXDRMigration/tests/fixtures/arm_template_acceptance_solution/Package/mainTemplate.json new file mode 100644 index 00000000000..d4ca8a1825d --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/arm_template_acceptance_solution/Package/mainTemplate.json @@ -0,0 +1,8 @@ +{ + "parameters": { + "workspace": { + "type": "string" + } + }, + "resources": [] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data Connectors/configured.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data Connectors/configured.json new file mode 100644 index 00000000000..faa1f9e6cb2 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data Connectors/configured.json @@ -0,0 +1,3 @@ +{ + "id": "configured-connector" +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data Connectors/legacy-unlisted.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data Connectors/legacy-unlisted.json new file mode 100644 index 00000000000..01d63a0eae0 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data Connectors/legacy-unlisted.json @@ -0,0 +1,3 @@ +{ + "id": "legacy-connector-not-in-solution-data" +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data/Solution_PlanningFixture.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data/Solution_PlanningFixture.json new file mode 100644 index 00000000000..65f503ca388 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Data/Solution_PlanningFixture.json @@ -0,0 +1,15 @@ +{ + "Version": "1.0.0", + "Playbooks": [ + "Playbooks/first.json", + "Playbooks/second.json", + "Playbooks/third.json", + "Playbooks/api-connector.json" + ], + "Data Connectors": [ + "Data Connectors/configured.json" + ], + "Workbooks": [ + "Workbooks/workbook.json" + ] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/api-connector.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/api-connector.json new file mode 100644 index 00000000000..ba8db0407ff --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/api-connector.json @@ -0,0 +1,8 @@ +{ + "resources": [ + { + "type": "Microsoft.Web/connections", + "name": "[parameters('ConnectionName')]" + } + ] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/companion.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/companion.json new file mode 100644 index 00000000000..c1b005ef1cb --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/companion.json @@ -0,0 +1,5 @@ +{ + "metadata": { + "purpose": "companion definition, not a separately declared playbook" + } +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/first.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/first.json new file mode 100644 index 00000000000..54462d13b8f --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/first.json @@ -0,0 +1,8 @@ +{ + "resources": [ + { + "type": "Microsoft.Logic/workflows", + "name": "shared-playbook" + } + ] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/second.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/second.json new file mode 100644 index 00000000000..983ea7113fa --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/second.json @@ -0,0 +1,8 @@ +{ + "resources": [ + { + "type": "Microsoft.Logic/workflows", + "name": "another-playbook" + } + ] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/third.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/third.json new file mode 100644 index 00000000000..067f88de0fa --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Playbooks/third.json @@ -0,0 +1,8 @@ +{ + "resources": [ + { + "type": "Microsoft.Logic/workflows", + "name": "[parameters('PlaybookName')]" + } + ] +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Workbooks/workbook.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Workbooks/workbook.json new file mode 100644 index 00000000000..58523039fbb --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning-inventory/Workbooks/workbook.json @@ -0,0 +1,3 @@ +{ + "id": "workbook" +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/Analytic Rules/alpha.yaml b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/Analytic Rules/alpha.yaml new file mode 100644 index 00000000000..dccdcc95a7e --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/Analytic Rules/alpha.yaml @@ -0,0 +1,2 @@ +id: 11111111-2222-3333-4444-555555555555 +name: Alpha diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/Analytic Rules/nested/beta.yaml b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/Analytic Rules/nested/beta.yaml new file mode 100644 index 00000000000..6ba0be6310a --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/Analytic Rules/nested/beta.yaml @@ -0,0 +1,2 @@ +id: 11111111-2222-3333-4444-555555555555 +name: Beta diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/workflow-state.json b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/workflow-state.json new file mode 100644 index 00000000000..88adeaa7ce2 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/deployment-planning/workflow-state.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "1.2.0", + "solution": ".", + "workflowStatus": "inProgress", + "createdAt": "2026-01-01T00:00:00+00:00", + "updatedAt": "2026-01-01T00:00:00+00:00", + "context": { + "workspaceResourceId": "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/resourceGroups/rg-test/providers/Microsoft.OperationalInsights/workspaces/ws-test", + "tenantId": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", + "targetContextLocked": true, + "versionBump": "none", + "workflowProfile": "qualification", + "profileSelectionConfirmed": true + }, + "stages": { + "mockIngestion": { + "status": "pending", + "artifacts": {} + } + } +} diff --git a/Tools/SentinelToXDRMigration/tests/fixtures/parser_collisions.json b/Tools/SentinelToXDRMigration/tests/fixtures/parser_collisions.json new file mode 100644 index 00000000000..7278cadc220 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/fixtures/parser_collisions.json @@ -0,0 +1,38 @@ +[ + { + "name": "ring0-dg Digital Guardian - Sensitive data transfer over insecure channel", + "parser": "DigitalGuardianDLPEvent", + "query": "let DigitalGuardianDLPEvent = (\nSyslog\n| where SyslogMessage contains 'managed_device_id' and SyslogMessage contains 'number_of_incidents'\n| mv-apply ExtractedFields = extract_all(@'\\s(?P[a-zA-Z0-9-_]+)=\\\"?(?P[a-zA-Z0-9-_:/@.,#{}>< ]+)\\\"?', dynamic([\"key\",\"value\"]), SyslogMessage) on (\n project packed = pack(tostring(ExtractedFields[0]), tostring(ExtractedFields[1]))\n | summarize bag = make_bag(packed)\n)\n| evaluate bag_unpack(bag) : (*, timestamp:string, action_taken:string, destination:string, destination_ip:string, destination_port:string, incident_id:string, incident_status:string, incidents_url:string, matched_policies_by_severity:string, number_of_incidents:string, protocol:string, source:string, source_ip:string, source_port:string, inspected_document:string)\n| extend EventEndTime=todatetime(timestamp)\n| project-away timestamp\n| project-rename DvcAvtion=action_taken\n , DstUserName=destination\n , DstIpAddr=destination_ip\n , DstPortNumber=destination_port\n , IncidentId=incident_id\n , IncidentStatus=incident_status\n , IncidentsUrl=incidents_url\n , MatchedPolicies=matched_policies_by_severity\n , EventCount=number_of_incidents\n , NetworkApplicationProtocol=protocol\n , SrcUserName=source\n , SrcIpAddr=source_ip\n , SrcPortNumber=source_port\n| where TimeGenerated >= ago(1h)\n);\nDigitalGuardianDLPEvent\n| where isnotempty(MatchedPolicies)\n| where isnotempty(inspected_document)\n| where NetworkApplicationProtocol =~ 'HTTP'\n| extend AccountCustomEntity = SrcUserName, IPCustomEntity = SrcIpAddr\n| extend Ring0SourceIp = tostring(SrcIpAddr)", + "sourceSha256": "9e617452241ebae8f00c23b7d3ccefabcc37ab4a6d571f9c10c5b9fea312712f" + }, + { + "name": "ring0-dg Digital Guardian - Exfiltration using DNS protocol", + "parser": "DigitalGuardianDLPEvent", + "query": "let DigitalGuardianDLPEvent = (\nSyslog\n| where SyslogMessage contains 'managed_device_id' and SyslogMessage contains 'number_of_incidents'\n| mv-apply ExtractedFields = extract_all(@'\\s(?P[a-zA-Z0-9-_]+)=\\\"?(?P[a-zA-Z0-9-_:/@.,#{}>< ]+)\\\"?', dynamic([\"key\",\"value\"]), SyslogMessage) on (\n project packed = pack(tostring(ExtractedFields[0]), tostring(ExtractedFields[1]))\n | summarize bag = make_bag(packed)\n)\n| evaluate bag_unpack(bag) : (*, timestamp:string, action_taken:string, destination:string, destination_ip:string, destination_port:string, incident_id:string, incident_status:string, incidents_url:string, matched_policies_by_severity:string, number_of_incidents:string, protocol:string, source:string, source_ip:string, source_port:string, inspected_document:string)\n| extend EventEndTime=todatetime(timestamp)\n| project-away timestamp\n| project-rename DvcAvtion=action_taken\n , DstUserName=destination\n , DstIpAddr=destination_ip\n , DstPortNumber=destination_port\n , IncidentId=incident_id\n , IncidentStatus=incident_status\n , IncidentsUrl=incidents_url\n , MatchedPolicies=matched_policies_by_severity\n , EventCount=number_of_incidents\n , NetworkApplicationProtocol=protocol\n , SrcUserName=source\n , SrcIpAddr=source_ip\n , SrcPortNumber=source_port\n| where TimeGenerated >= ago(1h)\n);\nDigitalGuardianDLPEvent\n| where DstPortNumber == 53\n| extend AccountCustomEntity = SrcUserName\n| extend Ring0SourceIp = tostring(SrcIpAddr)", + "sourceSha256": "01abb772fa49015aaea310a650abc83d379beff7e8583d14e79b605ea6984c42" + }, + { + "name": "ring0-dg Digital Guardian - Possible SMTP protocol abuse", + "parser": "DigitalGuardianDLPEvent", + "query": "let DigitalGuardianDLPEvent = (\nSyslog\n| where SyslogMessage contains 'managed_device_id' and SyslogMessage contains 'number_of_incidents'\n| mv-apply ExtractedFields = extract_all(@'\\s(?P[a-zA-Z0-9-_]+)=\\\"?(?P[a-zA-Z0-9-_:/@.,#{}>< ]+)\\\"?', dynamic([\"key\",\"value\"]), SyslogMessage) on (\n project packed = pack(tostring(ExtractedFields[0]), tostring(ExtractedFields[1]))\n | summarize bag = make_bag(packed)\n)\n| evaluate bag_unpack(bag) : (*, timestamp:string, action_taken:string, destination:string, destination_ip:string, destination_port:string, incident_id:string, incident_status:string, incidents_url:string, matched_policies_by_severity:string, number_of_incidents:string, protocol:string, source:string, source_ip:string, source_port:string, inspected_document:string)\n| extend EventEndTime=todatetime(timestamp)\n| project-away timestamp\n| project-rename DvcAvtion=action_taken\n , DstUserName=destination\n , DstIpAddr=destination_ip\n , DstPortNumber=destination_port\n , IncidentId=incident_id\n , IncidentStatus=incident_status\n , IncidentsUrl=incidents_url\n , MatchedPolicies=matched_policies_by_severity\n , EventCount=number_of_incidents\n , NetworkApplicationProtocol=protocol\n , SrcUserName=source\n , SrcIpAddr=source_ip\n , SrcPortNumber=source_port\n| where TimeGenerated >= ago(1h)\n);\nDigitalGuardianDLPEvent\n| where NetworkApplicationProtocol =~ 'SMTP'\n| where DstPortNumber != 25\n| extend AccountCustomEntity = SrcUserName\n| extend Ring0SourceIp = tostring(SrcIpAddr)", + "sourceSha256": "4304423eed2f1c5343c60e8e52c8fcbbdc2b13a16734e7960d0322a355944422" + }, + { + "name": "ring0-pf Ping Federate - Forbidden country", + "parser": "PingFederateEvent", + "query": "let PingFederateEvent = (\nCommonSecurityLog \n| where DeviceProduct has 'PingFederate'\n| extend EventVendor = DeviceVendor\n| extend EventProduct = DeviceProduct\n| extend EventProductVersion = DeviceVersion\n| extend EventSeverity = LogSeverity\n| extend SrcIpAddr = SourceIP\n| extend SrcHostname = SourceHostName\n| extend SrcUserName = SourceUserName\n| extend EventMessage = Message\n| extend EventSubType = DeviceEventClassID\n| extend EventType = Activity\n| extend DstUserName = DestinationUserID\n| extend DstGeoCountry = extract(@'country=;?(\\w+),.*', 1, AdditionalExtensions)\n| extend EventResultDetails = extract(@'description=(.*?),.*', 1, AdditionalExtensions)\n| extend DvcHostname = DeviceAddress\n| project TimeGenerated\n , EventVendor\n , EventProduct\n , EventProductVersion\n , EventMessage\n , EventType\n , EventSubType\n , DstUserName\n , DstGeoCountry\n , DvcHostname\n , EventSeverity\n , SrcIpAddr\n , SrcHostname\n , SrcUserName\n , EventResultDetails\n , DeviceCustomString1Label\n , DeviceCustomString1\n , DeviceCustomString2Label\n , DeviceCustomString2\n , DeviceCustomString3Label\n , DeviceCustomString3\n , DeviceCustomString4Label\n , DeviceCustomString4\n , DeviceCustomString5Label\n , DeviceCustomString5\n , DeviceCustomString6Label\n , DeviceCustomString6\n , AdditionalExtensions\n| where TimeGenerated >= ago(1h)\n);\nlet forbidden_geo = dynamic(['CH']);\nPingFederateEvent\n| where isnotempty(DstGeoCountry)\n| where tostring(DstGeoCountry) in~ (forbidden_geo)\n| extend AccountCustomEntity = DstUserName\n| extend IpCustomEntity = SrcIpAddr\n| extend Ring0SourceIp = tostring(SrcIpAddr)", + "sourceSha256": "5344691356853ae112b319a8a30917335528e5290582c302e44a0db97c368c21" + }, + { + "name": "ring0-pf Ping Federate - OAuth old version", + "parser": "PingFederateEvent", + "query": "let PingFederateEvent = (\nCommonSecurityLog \n| where DeviceProduct has 'PingFederate'\n| extend EventVendor = DeviceVendor\n| extend EventProduct = DeviceProduct\n| extend EventProductVersion = DeviceVersion\n| extend EventSeverity = LogSeverity\n| extend SrcIpAddr = SourceIP\n| extend SrcHostname = SourceHostName\n| extend SrcUserName = SourceUserName\n| extend EventMessage = Message\n| extend EventSubType = DeviceEventClassID\n| extend EventType = Activity\n| extend DstUserName = DestinationUserID\n| extend DstGeoCountry = extract(@'country=;?(\\w+),.*', 1, AdditionalExtensions)\n| extend EventResultDetails = extract(@'description=(.*?),.*', 1, AdditionalExtensions)\n| extend DvcHostname = DeviceAddress\n| project TimeGenerated\n , EventVendor\n , EventProduct\n , EventProductVersion\n , EventMessage\n , EventType\n , EventSubType\n , DstUserName\n , DstGeoCountry\n , DvcHostname\n , EventSeverity\n , SrcIpAddr\n , SrcHostname\n , SrcUserName\n , EventResultDetails\n , DeviceCustomString1Label\n , DeviceCustomString1\n , DeviceCustomString2Label\n , DeviceCustomString2\n , DeviceCustomString3Label\n , DeviceCustomString3\n , DeviceCustomString4Label\n , DeviceCustomString4\n , DeviceCustomString5Label\n , DeviceCustomString5\n , DeviceCustomString6Label\n , DeviceCustomString6\n , AdditionalExtensions\n| where TimeGenerated >= ago(1d)\n);\nPingFederateEvent\n| where isnotempty(DeviceCustomString3)\n| extend proto = extract(@'(OAuth)', 1, DeviceCustomString3)\n| extend ver = extract(@'(\\d+)', 1, DeviceCustomString3)\n| where proto =~ 'OAuth'\n| where ver !~ '20'\n| extend AccountCustomEntity = DstUserName\n| extend IpCustomEntity = SrcIpAddr\n| extend Ring0SourceIp = tostring(SrcIpAddr)", + "sourceSha256": "0d5aa936df04c3a909253bb3be7eeacccc529fa5c190f21382f1153086222a29" + }, + { + "name": "ring0-pf Ping Federate - SAML old version", + "parser": "PingFederateEvent", + "query": "let PingFederateEvent = (\nCommonSecurityLog \n| where DeviceProduct has 'PingFederate'\n| extend EventVendor = DeviceVendor\n| extend EventProduct = DeviceProduct\n| extend EventProductVersion = DeviceVersion\n| extend EventSeverity = LogSeverity\n| extend SrcIpAddr = SourceIP\n| extend SrcHostname = SourceHostName\n| extend SrcUserName = SourceUserName\n| extend EventMessage = Message\n| extend EventSubType = DeviceEventClassID\n| extend EventType = Activity\n| extend DstUserName = DestinationUserID\n| extend DstGeoCountry = extract(@'country=;?(\\w+),.*', 1, AdditionalExtensions)\n| extend EventResultDetails = extract(@'description=(.*?),.*', 1, AdditionalExtensions)\n| extend DvcHostname = DeviceAddress\n| project TimeGenerated\n , EventVendor\n , EventProduct\n , EventProductVersion\n , EventMessage\n , EventType\n , EventSubType\n , DstUserName\n , DstGeoCountry\n , DvcHostname\n , EventSeverity\n , SrcIpAddr\n , SrcHostname\n , SrcUserName\n , EventResultDetails\n , DeviceCustomString1Label\n , DeviceCustomString1\n , DeviceCustomString2Label\n , DeviceCustomString2\n , DeviceCustomString3Label\n , DeviceCustomString3\n , DeviceCustomString4Label\n , DeviceCustomString4\n , DeviceCustomString5Label\n , DeviceCustomString5\n , DeviceCustomString6Label\n , DeviceCustomString6\n , AdditionalExtensions\n| where TimeGenerated >= ago(1d)\n);\nPingFederateEvent\n| where isnotempty(DeviceCustomString3)\n| extend proto = extract(@'(SAML)', 1, DeviceCustomString3)\n| extend ver = extract(@'(\\d+)', 1, DeviceCustomString3)\n| where proto =~ 'SAML'\n| where ver !~ '20'\n| extend AccountCustomEntity = DstUserName\n| extend IpCustomEntity = SrcIpAddr\n| extend Ring0SourceIp = tostring(SrcIpAddr)", + "sourceSha256": "c1fda56a740cdcbf516cc756b3e34cfcbad8f894061f74c8bb31fdae49a4814b" + } +] diff --git a/Tools/SentinelToXDRMigration/tests/test_alert_parity.py b/Tools/SentinelToXDRMigration/tests/test_alert_parity.py new file mode 100644 index 00000000000..0d0ed764216 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_alert_parity.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration.alert_parity import ( + _capture_queries, + _qualification_scope, + _set_pair_enabled, + compare_alert_captures, + complete_alert_parity, + start_alert_parity, + start_alert_parity_batch, +) +from sentinel_xdr_migration.artifacts import artifact_path + +TARGET = { + "tenantId": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceResourceId": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/r/providers/Microsoft.OperationalInsights/workspaces/w" + ), + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", +} + + +def alert(match_key: str, *, account: str = "user@example.test") -> dict: + return { + "matchKey": match_key, + "severity": "high", + "tactics": ["Exfiltration"], + "entities": {"accounts": [{"upn": account}]}, + "evidence": {"eventIds": [match_key], "bytes": 1024}, + } + + +class AlertParityTests(unittest.TestCase): + def test_custom_detection_capture_query_uses_valid_unambiguous_columns( + self, + ) -> None: + queries = _capture_queries( + {"displayName": "Rule"}, + "2026-09-10T13:00:00+00:00", + "192.0.2.11", + ) + + custom = queries["customDetection"] + self.assertIn('DetectionSource == "Custom detection"', custom) + self.assertIn("AlertTimestamp=Timestamp", custom) + self.assertIn("EvidenceTimestamp=Timestamp", custom) + self.assertIn("URL=RemoteUrl", custom) + self.assertNotIn(", URL, Timestamp", custom) + self.assertIn('ProviderName == "ASI Scheduled Alerts"', queries["sentinel"]) + self.assertIn("summarize arg_max", queries["sentinel"]) + + @mock.patch( + "sentinel_xdr_migration.alert_parity._graph_request", + return_value=(200, {"status": "enabled"}), + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._arm_request", + return_value=(200, {"properties": {"enabled": True}}), + ) + def test_rule_enablement_omits_sentinel_read_only_properties( + self, arm_request: mock.Mock, _graph_request: mock.Mock + ) -> None: + pair = { + "analyticRuleId": "source-id", + "customDetectionId": "detection-id", + "sentinelResource": { + "kind": "Scheduled", + "properties": { + "displayName": "Rule", + "query": "Cloudflare | take 1", + "enabled": False, + "lastModifiedUtc": "2026-09-10T00:00:00Z", + "createdBy": {"name": "read-only"}, + }, + }, + } + + errors = _set_pair_enabled( + pair, + "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "arm-token", + "graph-token", + True, + ) + + self.assertEqual(errors, []) + body = arm_request.call_args.args[3] + self.assertTrue(body["properties"]["enabled"]) + self.assertNotIn("lastModifiedUtc", body["properties"]) + self.assertNotIn("createdBy", body["properties"]) + + def test_strict_comparison_passes_for_identical_alerts(self) -> None: + results = { + "results": [ + { + "detection": "Rule.yaml", + "analyticRule": {"alerts": [alert("event-1")]}, + "customDetection": {"alerts": [alert("event-1")]}, + } + ] + } + + comparison = compare_alert_captures({"Rule.yaml"}, results) + + self.assertTrue(comparison[0]["passed"]) + self.assertTrue(all(comparison[0]["dimensions"].values())) + + def test_strict_comparison_fails_entity_mismatch(self) -> None: + results = { + "results": [ + { + "detection": "Rule.yaml", + "analyticRule": {"alerts": [alert("event-1")]}, + "customDetection": { + "alerts": [alert("event-1", account="other@example.test")] + }, + } + ] + } + + comparison = compare_alert_captures({"Rule.yaml"}, results) + + self.assertFalse(comparison[0]["passed"]) + self.assertFalse(comparison[0]["dimensions"]["entities"]) + + def test_strict_comparison_fails_alert_count_mismatch(self) -> None: + results = { + "results": [ + { + "detection": "Rule.yaml", + "analyticRule": {"alerts": [alert("event-1")]}, + "customDetection": {"alerts": []}, + } + ] + } + + comparison = compare_alert_captures({"Rule.yaml"}, results) + + self.assertFalse(comparison[0]["passed"]) + self.assertFalse(comparison[0]["dimensions"]["alertCount"]) + + def test_strict_comparison_rejects_shared_benign_false_positive(self) -> None: + results = { + "results": [ + { + "detection": "Rule.yaml", + "analyticRule": { + "alerts": [alert("malicious"), alert("benign-control")] + }, + "customDetection": { + "alerts": [alert("malicious"), alert("benign-control")] + }, + } + ] + } + + comparison = compare_alert_captures( + {"Rule.yaml"}, results, expected_match_keys={"malicious"} + ) + + self.assertFalse(comparison[0]["passed"]) + self.assertFalse(comparison[0]["dimensions"]["expectedMatches"]) + + def test_strict_comparison_uses_per_detection_expected_keys(self) -> None: + results = { + "results": [ + { + "detection": "One.yaml", + "analyticRule": {"alerts": [alert("one-event")]}, + "customDetection": {"alerts": [alert("one-event")]}, + }, + { + "detection": "Two.yaml", + "analyticRule": {"alerts": [alert("two-event")]}, + "customDetection": {"alerts": [alert("two-event")]}, + }, + ] + } + + comparison = compare_alert_captures( + {"One.yaml", "Two.yaml"}, + results, + expected_match_keys_by_detection={ + "One.yaml": {"one-event"}, + "Two.yaml": {"two-event"}, + }, + ) + + self.assertTrue(all(item["passed"] for item in comparison)) + + @mock.patch( + "sentinel_xdr_migration.alert_parity._disable_all", + return_value=[ + {"detection": "Rule.yaml", "disabled": True, "errors": []} + ], + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity.require_locked_target", + return_value=TARGET, + ) + def test_complete_always_disables_rules_on_invalid_capture( + self, _target: mock.Mock, disable: mock.Mock + ) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output = root / "XDR Detections" + output.mkdir() + state = { + "runId": "run-1", + "status": "awaiting-alerts", + "scenarioMarker": "scenario-1", + "workspaceResourceId": TARGET["workspaceResourceId"], + "target": TARGET, + "rules": [{"detection": "Rule.yaml"}], + } + artifact_path(root, "alert-parity-state.json", create_parent=True).write_text( + json.dumps(state), encoding="utf-8" + ) + results = root / "results.json" + results.write_text("{}", encoding="utf-8") + + report = complete_alert_parity(root, results_path=results) + + disable.assert_called_once() + self.assertEqual(report["status"], "failed") + self.assertTrue(report["cleanup"][0]["disabled"]) + + @mock.patch( + "sentinel_xdr_migration.packaging_selection.current_applied_package_selection", + return_value={ + "plan": { + "planSha256": "new-plan", + "selection": {"xdrDetections": ["XDR Detections/Rule.yaml"]}, + "summary": {"outcome": "partial"}, + } + }, + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._disable_all", + return_value=[ + {"detection": "Rule.yaml", "disabled": True, "errors": []} + ], + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity.require_locked_target", + return_value=TARGET, + ) + def test_complete_rejects_changed_selection_and_still_cleans_up( + self, + _target: mock.Mock, + disable: mock.Mock, + _selection: mock.Mock, + ) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detection = root / "XDR Detections" / "Rule.yaml" + detection.parent.mkdir(parents=True) + detection.write_text("kind: CustomDetection\n", encoding="utf-8") + pair = { + "detection": "Rule.yaml", + "analyticRuleId": "source-id", + "customDetectionId": "detection-id", + } + original_selection = { + "plan": { + "planSha256": "old-plan", + "selection": {"xdrDetections": ["XDR Detections/Rule.yaml"]}, + "summary": {"outcome": "partial"}, + } + } + state = { + "runId": "run-1", + "status": "awaiting-alerts", + "scenarioMarker": "scenario-1", + "target": TARGET, + "rules": [pair], + "qualificationScope": _qualification_scope( + root, [pair], original_selection + ), + } + artifact_path(root, "alert-parity-state.json", create_parent=True).write_text( + json.dumps(state), encoding="utf-8" + ) + results = root / "results.json" + results.write_text("{}", encoding="utf-8") + + report = complete_alert_parity(root, results_path=results) + + disable.assert_called_once() + self.assertEqual(report["status"], "failed") + self.assertFalse(report["qualificationScopeVerified"]) + self.assertIn("no longer matches", report["scopeVerificationError"]) + self.assertTrue(report["cleanup"][0]["disabled"]) + + @mock.patch( + "sentinel_xdr_migration.alert_parity._disable_all", + return_value=[ + {"detection": "Rule.yaml", "disabled": True, "errors": []} + ], + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._ingest", + side_effect=RuntimeError("rejected"), + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._set_pair_enabled", + return_value=[], + ) + @mock.patch("sentinel_xdr_migration.alert_parity._read_rule_states") + @mock.patch( + "sentinel_xdr_migration.alert_parity._deployment_token", + return_value="graph-token", + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._arm_token", + return_value="arm-token", + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity.require_locked_target", + return_value=TARGET, + ) + def test_start_disables_rules_when_ingestion_fails( + self, + _target: mock.Mock, + _arm: mock.Mock, + _graph: mock.Mock, + _read: mock.Mock, + _enable: mock.Mock, + _ingest: mock.Mock, + disable: mock.Mock, + ) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output = root / "XDR Detections" + output.mkdir() + payload = root / "payload.json" + payload.write_text( + json.dumps([{"user": "scenario-user@example.test"}]), + encoding="utf-8", + ) + pair = { + "detection": "Rule.yaml", + "displayName": "Rule", + "analyticRuleId": "source-id", + "customDetectionId": "detection-id", + "entityMappings": {}, + "sentinelResource": {"kind": "Scheduled", "properties": {}}, + } + with mock.patch( + "sentinel_xdr_migration.alert_parity._load_pairs", + return_value=(root, [pair]), + ): + with self.assertRaisesRegex(RuntimeError, "rejected"): + start_alert_parity( + root, + workspace_resource_id=TARGET["workspaceResourceId"], + contract=root / "contract.json", + payload=payload, + scenario_marker="scenario-user@example.test", + ) + + state = json.loads( + artifact_path(root, "alert-parity-state.json").read_text( + encoding="utf-8" + ) + ) + + disable.assert_called_once() + self.assertEqual(state["status"], "failed") + self.assertTrue(state["cleanup"][0]["disabled"]) + + @mock.patch( + "sentinel_xdr_migration.alert_parity._ingest", + side_effect=[{"status": "accepted"}, {"status": "accepted"}], + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._set_pair_enabled", + return_value=[], + ) + @mock.patch("sentinel_xdr_migration.alert_parity._read_rule_states") + @mock.patch( + "sentinel_xdr_migration.alert_parity._deployment_token", + return_value="graph-token", + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity._arm_token", + return_value="arm-token", + ) + @mock.patch( + "sentinel_xdr_migration.alert_parity.require_locked_target", + return_value=TARGET, + ) + def test_batch_start_enables_all_and_ingests_each_fixture( + self, + _target: mock.Mock, + _arm: mock.Mock, + _graph: mock.Mock, + _read: mock.Mock, + enable: mock.Mock, + ingest: mock.Mock, + ) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output = root / "XDR Detections" + output.mkdir() + fixtures = [] + pairs = [] + for index, name in enumerate(("One.yaml", "Two.yaml"), start=1): + contract = root / f"contract-{index}.json" + payload = root / f"payload-{index}.json" + contract.write_text("{}", encoding="utf-8") + payload.write_text( + json.dumps([{"marker": f"scenario-{index}"}]), + encoding="utf-8", + ) + fixtures.append( + { + "detection": name, + "contract": contract.name, + "payload": payload.name, + "scenarioMarker": f"scenario-{index}", + "expectedMatchKeys": [f"event-{index}"], + } + ) + pairs.append( + { + "detection": name, + "displayName": name, + "analyticRuleId": f"source-{index}", + "customDetectionId": f"detection-{index}", + "entityMappings": {}, + } + ) + plan = root / "plan.json" + plan.write_text(json.dumps({"fixtures": fixtures}), encoding="utf-8") + + with mock.patch( + "sentinel_xdr_migration.alert_parity._load_pairs", + return_value=(root, pairs), + ): + result = start_alert_parity_batch( + root, + workspace_resource_id=TARGET["workspaceResourceId"], + plan_path=plan, + ) + + state = json.loads( + artifact_path(root, "alert-parity-state.json").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(result["rulesEnabled"], 2) + self.assertEqual(result["fixturesIngested"], 2) + self.assertEqual(enable.call_count, 2) + self.assertEqual(ingest.call_count, 2) + self.assertEqual( + state["expectedMatchKeysByDetection"]["One.yaml"], + ["event-1"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_analytic_deployment.py b/Tools/SentinelToXDRMigration/tests/test_analytic_deployment.py new file mode 100644 index 00000000000..3828f8e43ea --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_analytic_deployment.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import ast +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.analytic_deployment import ( + analytic_rule_payload, + deploy_analytic_rules, +) + + +def document() -> dict: + return { + "id": "a7ce6135-9d55-4f14-b058-adc2e920a4fb", + "name": "Test analytic rule", + "description": "Test", + "severity": "Medium", + "queryFrequency": "1h", + "queryPeriod": "2h", + "triggerOperator": "gt", + "triggerThreshold": 0, + "query": "Cloudflare | take 1", + "tactics": ["InitialAccess"], + "relevantTechniques": ["T1190"], + "entityMappings": [ + { + "entityType": "IP", + "fieldMappings": [ + {"identifier": "Address", "columnName": "SrcIpAddr"} + ], + } + ], + "version": "1.0.1", + "kind": "Scheduled", + } + + +class AnalyticDeploymentTests(unittest.TestCase): + def test_package_import_graph_has_no_analytic_or_parity_cycle(self) -> None: + package = Path(__file__).resolve().parents[1] / "sentinel_xdr_migration" + modules = {path.stem: path for path in package.glob("*.py")} + graph = {name: set() for name in modules} + for name, path in modules.items(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.level != 1: + continue + if node.module and node.module.split(".")[0] in modules: + graph[name].add(node.module.split(".")[0]) + elif node.module is None: + graph[name].update( + alias.name for alias in node.names if alias.name in modules + ) + + for root in ("analytic_deployment", "alert_parity"): + pending = [root] + visited = {root} + while pending: + module = pending.pop() + for dependency in graph[module]: + if dependency == root: + self.fail(f"import cycle returns to {root} through {module}") + if dependency not in visited: + visited.add(dependency) + pending.append(dependency) + + def test_analytic_and_parity_imports_work_in_either_order(self) -> None: + package_root = Path(__file__).resolve().parents[1] + for imports in ( + "import sentinel_xdr_migration.analytic_deployment as analytic_deployment\n" + "import sentinel_xdr_migration.alert_parity as alert_parity\n" + "import sentinel_xdr_migration.analytic_rule as analytic_rule\n" + "import sentinel_xdr_migration.arm_client as arm_client\n" + "import sentinel_xdr_migration.deployment_planning as deployment_planning\n" + "assert analytic_deployment._arm_request is arm_client._arm_request\n" + "assert alert_parity._arm_request is arm_client._arm_request\n" + "assert (\n" + " analytic_deployment.analytic_rule_payload\n" + " is analytic_rule.analytic_rule_payload\n" + ")\n" + "assert (\n" + " deployment_planning.analytic_rule_payload\n" + " is analytic_rule.analytic_rule_payload\n" + ")", + "import sentinel_xdr_migration.deployment_planning as deployment_planning\n" + "import sentinel_xdr_migration.alert_parity as alert_parity\n" + "import sentinel_xdr_migration.analytic_deployment as analytic_deployment\n" + "import sentinel_xdr_migration.analytic_rule as analytic_rule\n" + "import sentinel_xdr_migration.arm_client as arm_client\n" + "assert analytic_deployment._arm_request is arm_client._arm_request\n" + "assert alert_parity._arm_request is arm_client._arm_request\n" + "assert (\n" + " analytic_deployment.analytic_rule_payload\n" + " is analytic_rule.analytic_rule_payload\n" + ")\n" + "assert (\n" + " deployment_planning.analytic_rule_payload\n" + " is analytic_rule.analytic_rule_payload\n" + ")", + ): + with self.subTest(imports=imports): + subprocess.run( + [sys.executable, "-c", imports], + cwd=package_root, + check=True, + capture_output=True, + text=True, + ) + + def test_payload_is_disabled_and_preserves_source_metadata(self) -> None: + payload = analytic_rule_payload(document()) + + self.assertFalse(payload["properties"]["enabled"]) + self.assertEqual(payload["properties"]["queryFrequency"], "PT1H") + self.assertEqual(payload["properties"]["queryPeriod"], "PT2H") + self.assertEqual(payload["properties"]["triggerOperator"], "GreaterThan") + self.assertEqual( + payload["properties"]["alertRuleTemplateName"], document()["id"] + ) + + @mock.patch( + "sentinel_xdr_migration.analytic_deployment._arm_token", + return_value="token", + ) + @mock.patch( + "sentinel_xdr_migration.analytic_deployment.require_locked_target", + return_value={ + "tenantId": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceResourceId": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg/providers/Microsoft.OperationalInsights/" + "workspaces/ws" + ), + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", + }, + ) + @mock.patch("sentinel_xdr_migration.analytic_deployment._arm_request") + def test_deploy_upserts_rule_disabled( + self, request: mock.Mock, _target: mock.Mock, _token: mock.Mock + ) -> None: + request.return_value = ( + 201, + {"properties": {"enabled": False}}, + ) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / "Analytic Rules" + source.mkdir() + (source / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + + result = deploy_analytic_rules( + root, + workspace_resource_id="/subscriptions/sub/resourceGroups/rg" + "/providers/Microsoft.OperationalInsights/workspaces/ws", + ) + + self.assertEqual(result["succeeded"], 1) + self.assertEqual(result["failed"], 0) + self.assertEqual(request.call_args.args[0], "PUT") + self.assertFalse(request.call_args.args[3]["properties"]["enabled"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_arm_acceptance.py b/Tools/SentinelToXDRMigration/tests/test_arm_acceptance.py new file mode 100644 index 00000000000..22cd66738fa --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_arm_acceptance.py @@ -0,0 +1,1311 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import unittest +from contextlib import nullcontext, redirect_stdout +from io import StringIO +from pathlib import Path, PurePosixPath, PureWindowsPath +from unittest import mock + +from sentinel_xdr_migration import arm_acceptance +from sentinel_xdr_migration.arm_acceptance import ( + _BATCH_ARM_ACCEPTANCE_CONFIRMATION, + _acceptance_package_plan, + _arm_acceptance_batch_plan, + _source_paths_in_order, + execute_arm_template_acceptance_batch, +) +FIXTURE_ROOT = Path(__file__).parent / "fixtures" / "arm_template_acceptance_solution" +TARGET = { + "tenantId": "11111111-2222-3333-4444-555555555555", + "subscriptionId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "resourceGroup": "arm-acceptance-fixture-rg", + "workspaceResourceId": ( + "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/" + "resourceGroups/arm-acceptance-fixture-rg/providers/" + "Microsoft.OperationalInsights/workspaces/arm-acceptance-fixture-ws" + ), + "workspaceCustomerId": "99999999-8888-7777-6666-555555555555", +} +RUN_ID = "synthetic-arm-acceptance-run" +FLAGS = { + "DeployAnalyticsRule": True, + "DeployCustomDetection": True, + "DeployDataConnector": True, + "DeployPlaybook": True, + "DeployWorkbook": True, +} +VALIDATION_RECEIPTS = ["current-build-and-validate-report.md"] +MEMBERS = [ + { + "solution": "FixtureOne", + "solutionPath": str(FIXTURE_ROOT), + "runId": RUN_ID, + } +] +NORMALIZED_MEMBERS = [ + { + "solution": "FixtureOne", + "solutionPath": str(FIXTURE_ROOT), + "runId": RUN_ID, + } +] + + +class ArmTemplateAcceptanceTests(unittest.TestCase): + def test_cli_help_has_only_acceptance_commands_without_full_package_module(self): + script = """ +import builtins +original_import = builtins.__import__ +def reject_full_package(name, *args, **kwargs): + if name.endswith(".full_package"): + raise AssertionError("acceptance CLI imported the unfinished full-package module") + return original_import(name, *args, **kwargs) +builtins.__import__ = reject_full_package +from sentinel_xdr_migration.cli import main +try: + main(["--help"]) +except SystemExit as exc: + if exc.code != 0: + raise +""" + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("plan-arm-template-acceptance-batch", result.stdout) + self.assertIn("deploy-arm-template-acceptance-batch", result.stdout) + self.assertNotIn("plan-full-package-install", result.stdout) + self.assertNotIn("install-full-package", result.stdout) + self.assertNotIn("plan-full-package-activation", result.stdout) + self.assertNotIn("activate-full-package", result.stdout) + + def _package_plan(self, *, flags=FLAGS, template_hash="a" * 64): + parameters = { + "workspace": "arm-acceptance-fixture-ws", + **flags, + } + return { + "status": "ready", + "stage": "post-validation-arm-template-deployment-acceptance", + "solution": str(FIXTURE_ROOT), + "runId": RUN_ID, + "target": TARGET, + "packagePlanSha256": "c" * 64, + "mainTemplatePath": str(FIXTURE_ROOT / "Package" / "mainTemplate.json"), + "mainTemplateSha256": template_hash, + "packageIdentity": { + "version": "3.1.1", + "packageInputSha256": "d" * 64, + "packageArtifactHashes": {"mainTemplate": template_hash}, + "packageContentSha256": "9" * 64, + "packageReportSha256": "8" * 64, + }, + "mainTemplateSnapshotPath": str( + FIXTURE_ROOT / "Package" / "acceptance-template-snapshot.json" + ), + "mainTemplateSnapshotSha256": template_hash, + "validationReceipt": { + "path": VALIDATION_RECEIPTS[0], + "sha256": "7" * 64, + "status": "PASSED", + "validatorSummary": { + "total": 3, + "passed": 2, + "failed": 0, + "skipped": 1, + }, + }, + "declaredContentFlags": flags, + "parameters": parameters, + "parametersSha256": arm_acceptance._canonical_sha256(parameters), + } + + def _batch_plan(self): + snapshot = FIXTURE_ROOT / "Package" / "acceptance-template-snapshot.json" + snapshot_hash = arm_acceptance.hashlib.sha256(snapshot.read_bytes()).hexdigest() + member = self._package_plan(template_hash=snapshot_hash) + member.update( + { + "solution": "FixtureOne", + "solutionPath": str(FIXTURE_ROOT), + "deploymentName": "sentinel-arm-fixture-deployment", + } + ) + return { + "status": "ready", + "stage": "post-validation-arm-template-deployment-acceptance", + "batchPlanSha256": "b" * 64, + "target": TARGET, + "sharedDecisionReceipt": {"sha256": "e" * 64}, + "members": [member], + "confirmation": _BATCH_ARM_ACCEPTANCE_CONFIRMATION, + } + + def _state(self): + return { + "runId": RUN_ID, + "context": { + "workflowProfile": "qualification", + "profileSelectionConfirmed": True, + "targetContextLocked": True, + }, + "stages": { + "packaging": { + "status": "passed", + "artifacts": {"packager": "V3.1"}, + }, + }, + "packaging": { + "lastSuccessful": { + "packageReport": "Logs/sentinel-xdr-migration/package-report.json", + "packageReportSha256": "8" * 64, + }, + }, + "packageFreshness": {"status": "current"}, + } + + def _current_package(self, *, template_hash): + return { + "status": "current", + "version": "3.1.1", + "packageInputSha256": "9" * 64, + "packageArtifactHashes": { + "mainTemplate": template_hash, + "createUiDefinition": "a" * 64, + "testParameters": "b" * 64, + "zip": "c" * 64, + }, + "packageContentSha256": "d" * 64, + "bicepExtensibilityImports": [{"name": "Microsoft.Security"}], + } + + def _write_build_validation_receipt( + self, + *, + rows=None, + summary="Total: 2 validators | 1 passed | 0 failed | 1 skipped", + result="PASSED", + source_path_semantics="posix", + producer_platform=None, + ): + toolkit_root = FIXTURE_ROOT / "validation-toolkit" + if toolkit_root.exists(): + shutil.rmtree(toolkit_root) + package_report_directory = ( + FIXTURE_ROOT / "Logs" / "sentinel-xdr-migration" / "acceptance-validation-fixture" + ) + if package_report_directory.exists(): + shutil.rmtree(package_report_directory) + package_report_directory.mkdir(parents=True) + package_directory = FIXTURE_ROOT / "Package" + created_package_files = [] + for name, content in ( + ("createUiDefinition.json", "{\"createUiDefinition\":{}}\n"), + ("testParameters.json", "{}\n"), + ("3.1.1.zip", "fixture package archive\n"), + ): + path = package_directory / name + if not path.exists(): + path.write_text(content, encoding="utf-8") + created_package_files.append(path) + + def file_fingerprints(root, *, excluded=(), path_semantics=None): + excluded = {name.casefold() for name in excluded} + rows = [] + paths = list(root.rglob("*")) + if path_semantics == "windows": + paths.sort(key=lambda item: PureWindowsPath(item.relative_to(root).as_posix())) + elif path_semantics == "posix": + paths.sort(key=lambda item: PurePosixPath(item.relative_to(root).as_posix())) + else: + paths.sort(key=lambda item: item.as_posix()) + for path in paths: + relative = path.relative_to(root) + if any(part.casefold() in excluded for part in relative.parts): + continue + if any( + part.casefold() == ".env" or part.casefold().startswith(".env.") + for part in relative.parts + ): + continue + if path.is_symlink(): + raise AssertionError(f"fixture unexpectedly contains a symlink: {path}") + if path.is_file(): + rows.append( + { + "path": relative.as_posix(), + "sha256": arm_acceptance.hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + return rows + + package_artifact_paths = { + "mainTemplate": "Package/mainTemplate.json", + "createUiDefinition": "Package/createUiDefinition.json", + "testParameters": "Package/testParameters.json", + "zip": "Package/3.1.1.zip", + } + package_artifact_hashes = { + name: arm_acceptance.hashlib.sha256( + (FIXTURE_ROOT / relative).read_bytes() + ).hexdigest() + for name, relative in package_artifact_paths.items() + } + package_files = file_fingerprints(package_directory) + package_report_path = package_report_directory / "packaging.v3_1.json" + package_input_files = file_fingerprints( + FIXTURE_ROOT, + excluded={"package", "logs", "evidence", ".git", ".venv", "__pycache__"}, + path_semantics=source_path_semantics, + ) + package_input_sha256 = arm_acceptance._canonical_sha256(package_input_files) + package_artifacts_sha256 = "f" * 64 + package_attempt_id = "attempt-validation-fixture" + package_workflow_run_id = "run-validation-fixture" + package_report = { + "packager": "V3.1", + "version": "3.1.1", + "packageAttemptId": package_attempt_id, + "workflowRunId": package_workflow_run_id, + "packageInputs": {"after": {"sha256": package_input_sha256}}, + "packageArtifactsSha256": package_artifacts_sha256, + "packageArtifactHashes": package_artifact_hashes, + **package_artifact_paths, + } + package_report_path.write_text( + json.dumps(package_report, indent=2) + "\n", + encoding="utf-8", + ) + package_identity = { + "version": "3.1.1", + "packageInputSha256": package_input_sha256, + "packageContentSha256": arm_acceptance._canonical_sha256(package_files), + "packageReportSha256": arm_acceptance.hashlib.sha256( + package_report_path.read_bytes() + ).hexdigest(), + "packageAttemptId": package_attempt_id, + "workflowRunId": package_workflow_run_id, + "packageArtifactsSha256": package_artifacts_sha256, + "recordedPackageArtifactHashes": package_artifact_hashes, + } + + evidence_path = ( + toolkit_root + / ".script" + / "local-validation" + / "Logs" + / "20261012T010000000Z" + ) + evidence_path.mkdir(parents=True, exist_ok=True) + script_path = ( + toolkit_root + / ".script" + / "local-validation" + / "build-and-validate.ps1" + ) + script_path.write_text("# test fixture\n", encoding="utf-8") + binding_script_path = script_path.with_name("validation-binding.ps1") + binding_script_path.write_text("# binding fixture\n", encoding="utf-8") + report_path = evidence_path / "build-and-validate-report.md" + content_root = FIXTURE_ROOT.parent.resolve() + table = rows if rows is not None else [ + "| TS | TypeScript validators | PASS | 1 passed |", + "| CI-PS | ARM-TTK | SKIP | no import validation |", + ] + binding = { + "schemaVersion": 1, + "producer": "build-and-validate.ps1", + "validationResult": result, + "validationRan": True, + "packagingSkipped": True, + "toolkitRoot": str(toolkit_root.resolve()), + "contentRoot": str(content_root), + "solutionPath": str(FIXTURE_ROOT.resolve()), + "sourcePathSemantics": source_path_semantics, + "producerPlatform": producer_platform + or ("windows" if sys.platform == "win32" else "posix"), + "evidenceRunPath": str(evidence_path.resolve()), + "validationStartedAtUtc": "2026-10-12T01:00:00+00:00", + "validationCompletedAtUtc": "2026-10-12T01:01:00+00:00", + "validatorSummary": { + "total": len(table), + "passed": sum("| PASS |" in row for row in table), + "failed": sum("| FAIL |" in row for row in table), + "skipped": sum("| SKIP |" in row for row in table), + }, + "package": { + "path": package_report_path.relative_to(FIXTURE_ROOT).as_posix(), + "sha256": package_identity["packageReportSha256"], + "version": "3.1.1", + "packageAttemptId": package_attempt_id, + "workflowRunId": package_workflow_run_id, + "packageInputSha256": package_input_sha256, + "packageArtifactsSha256": package_artifacts_sha256, + "packageArtifactHashes": package_artifact_hashes, + "packageFiles": package_files, + }, + "inputFilesBefore": package_input_files, + "inputFilesAfter": package_input_files, + "packageFilesBefore": package_files, + "packageFilesAfter": package_files, + "producerFilesBefore": { + "build-and-validate.ps1": arm_acceptance.hashlib.sha256( + script_path.read_bytes() + ).hexdigest(), + "validation-binding.ps1": arm_acceptance.hashlib.sha256( + binding_script_path.read_bytes() + ).hexdigest(), + }, + } + binding["producerFilesAfter"] = binding["producerFilesBefore"] + binding_path = evidence_path / "build-and-validate-binding.json" + binding_path.write_text(json.dumps(binding, indent=2) + "\n", encoding="utf-8") + report_path.write_text( + "\n".join( + [ + "[REPORT_START]", + "# BUILD & VALIDATION REPORT", + "", + f"Solution: {FIXTURE_ROOT.name}", + f"Toolkit root: {toolkit_root}", + f"Content root: {content_root}", + f"Evidence: {evidence_path}", + f"Validation binding: {binding_path}", + "Validation binding SHA256: " + + arm_acceptance.hashlib.sha256(binding_path.read_bytes()).hexdigest(), + f"Validation binding result: {result}", + "Result: " + result, + "", + "## Validation Results", + "| Step | Validator | Status | Details |", + "|------|-----------|--------|---------|", + *table, + "", + "## Summary", + summary, + "", + "**BUILD SUCCESSFUL**" if result == "PASSED" else "**BUILD FAILED**", + "[REPORT_END]", + "", + ] + ), + encoding="utf-8", + ) + return toolkit_root, report_path, package_report_path, package_identity, created_package_files + + def _cleanup_build_validation_fixture( + self, toolkit_root, package_report_path, created_package_files + ): + shutil.rmtree(toolkit_root, ignore_errors=True) + shutil.rmtree(package_report_path.parent, ignore_errors=True) + for path in created_package_files: + path.unlink(missing_ok=True) + + def test_build_validation_receipt_is_exact_complete_fresh_and_preserves_skips(self): + toolkit_root, report_path, package_report_path, package_identity, created = ( + self._write_build_validation_receipt() + ) + try: + result = arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + self.assertEqual("PASSED", result["status"]) + self.assertEqual(1, result["validatorSummary"]["passed"]) + self.assertEqual(1, result["validatorSummary"]["skipped"]) + self.assertEqual("SKIP", result["validatorResults"][1]["status"]) + self.assertEqual( + arm_acceptance.hashlib.sha256(report_path.read_bytes()).hexdigest(), + result["sha256"], + ) + + report_path.write_text( + report_path.read_text(encoding="utf-8").replace( + f"Content root: {FIXTURE_ROOT.parent.resolve()}", + f"Content root: {FIXTURE_ROOT.resolve()}", + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "content root"): + arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + finally: + self._cleanup_build_validation_fixture(toolkit_root, package_report_path, created) + + def test_windows_path_order_is_consumed_cross_host_from_recorded_semantics(self): + source_paths = [ + "Solutions/New-CloudShell/Analytic Rules/rule.yaml", + "Solutions/azure NRT/Data Connectors/connector.json", + "Solutions/azure NRT/Data/Solution.json", + ] + windows_paths = sorted(source_paths, key=PureWindowsPath) + posix_paths = sorted(source_paths, key=PurePosixPath) + self.assertNotEqual(posix_paths, windows_paths) + self.assertTrue(_source_paths_in_order(windows_paths, "windows")) + self.assertFalse(_source_paths_in_order(posix_paths, "windows")) + self.assertTrue(_source_paths_in_order(posix_paths, "posix")) + with self.assertRaisesRegex(ValueError, "platform-neutral"): + _source_paths_in_order(windows_paths, "platform-neutral") + + toolkit_root, report_path, package_report_path, package_identity, created = ( + self._write_build_validation_receipt( + source_path_semantics="windows", + producer_platform="posix", + ) + ) + try: + receipt = arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + self.assertEqual("windows", receipt["sourcePathSemantics"]) + self.assertEqual("posix", receipt["producerPlatform"]) + finally: + self._cleanup_build_validation_fixture(toolkit_root, package_report_path, created) + + def test_build_validation_receipt_rejects_skipped_failed_and_copied_bindings(self): + toolkit_root, report_path, package_report_path, package_identity, created = ( + self._write_build_validation_receipt( + rows=[], + summary="Total: 0 validators | 0 passed | 0 failed | 0 skipped", + ) + ) + try: + with self.assertRaisesRegex(ValueError, "no completed validator results"): + arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + + failed_rows = [ + "| TS | TypeScript validators | FAIL | 1 failed |", + ] + report_path.write_text( + "\n".join( + [ + "[REPORT_START]", + "# BUILD & VALIDATION REPORT", + f"Solution: {FIXTURE_ROOT.name}", + f"Toolkit root: {toolkit_root}", + f"Content root: {FIXTURE_ROOT.parent.resolve()}", + f"Evidence: {report_path.parent}", + "Result: FAILED", + *failed_rows, + "Total: 1 validators | 0 passed | 1 failed | 0 skipped", + "**BUILD FAILED**", + "[REPORT_END]", + ] + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "did not report success"): + arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + + self._cleanup_build_validation_fixture(toolkit_root, package_report_path, created) + toolkit_root, report_path, package_report_path, package_identity, created = ( + self._write_build_validation_receipt() + ) + copied_binding = report_path.parent.parent / "copied" / "build-and-validate-binding.json" + copied_binding.parent.mkdir(parents=True, exist_ok=True) + copied_binding.write_bytes( + (report_path.parent / "build-and-validate-binding.json").read_bytes() + ) + report_path.write_text( + report_path.read_text(encoding="utf-8").replace( + str(report_path.parent / "build-and-validate-binding.json"), + str(copied_binding), + ).replace( + arm_acceptance.hashlib.sha256( + (report_path.parent / "build-and-validate-binding.json").read_bytes() + ).hexdigest(), + arm_acceptance.hashlib.sha256(copied_binding.read_bytes()).hexdigest(), + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "copied or is outside"): + arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=package_identity, + ) + finally: + self._cleanup_build_validation_fixture(toolkit_root, package_report_path, created) + + def test_build_validation_binding_rejects_a_changed_current_package_identity(self): + toolkit_root, report_path, package_report_path, package_identity, created = ( + self._write_build_validation_receipt() + ) + changed_identity = dict(package_identity) + changed_identity["packageArtifactsSha256"] = "0" * 64 + try: + with self.assertRaisesRegex(ValueError, "package report identity"): + arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=changed_identity, + ) + changed_version = dict(package_identity) + changed_version["version"] = "3.2.0" + with self.assertRaisesRegex(ValueError, "package report identity"): + arm_acceptance._parse_build_validation_receipt( + FIXTURE_ROOT, + report_path, + package_report_path=package_report_path, + package_identity=changed_version, + ) + finally: + self._cleanup_build_validation_fixture(toolkit_root, package_report_path, created) + + def test_package_plan_requires_current_qualification_v31_and_sets_each_flag_true(self): + template = { + "parameters": { + "workspace": {"type": "string"}, + **{ + name: {"type": "bool", "defaultValue": False} + for name in FLAGS + }, + "environment": {"type": "string", "defaultValue": "public"}, + }, + "resources": [], + } + template_text = json.dumps(template) + template_path = FIXTURE_ROOT / "Package" / "mainTemplate.json" + original_read_bytes = Path.read_bytes + def read_bytes(path): + if path == template_path: + return template_text.encode() + return original_read_bytes(path) + + with ( + mock.patch.object(Path, "read_bytes", read_bytes), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.workflow_status", + return_value=self._state(), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._current_package", + return_value=self._current_package( + template_hash=arm_acceptance.hashlib.sha256( + template_text.encode() + ).hexdigest() + ), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.resolve_artifact_reference", + return_value=FIXTURE_ROOT / "Package" / "mainTemplate.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._parse_build_validation_receipt", + return_value={"status": "PASSED", "sha256": "7" * 64}, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.artifact_path", + return_value=FIXTURE_ROOT / "Package" / "acceptance-template-snapshot.json", + ), + mock.patch("sentinel_xdr_migration.arm_acceptance._write_template_snapshot"), + ): + plan = _acceptance_package_plan(FIXTURE_ROOT, VALIDATION_RECEIPTS[0]) + + self.assertEqual("ready", plan["status"]) + self.assertEqual(FLAGS, plan["declaredContentFlags"]) + self.assertEqual("arm-acceptance-fixture-ws", plan["parameters"]["workspace"]) + for name in FLAGS: + self.assertIs(plan["parameters"][name], True) + self.assertNotIn("environment", plan["parameters"]) + self.assertFalse(plan["resourceReadbackRequired"]) + self.assertFalse(plan["installationVerified"]) + self.assertEqual("PASSED", plan["validationReceipt"]["status"]) + self.assertEqual(template_path.as_posix(), plan["mainTemplatePath"]) + self.assertEqual( + arm_acceptance.hashlib.sha256(template_text.encode()).hexdigest(), + plan["mainTemplateSnapshotSha256"], + ) + self.assertEqual(4, len(plan["packageIdentity"]["packageArtifactHashes"])) + + def test_package_plan_rejects_stale_template_and_required_unbound_parameters(self): + template_path = FIXTURE_ROOT / "Package" / "mainTemplate.json" + actual = template_path.read_bytes() + template_text = json.dumps( + { + "parameters": { + "workspace": {"type": "string"}, + "requiredName": {"type": "string"}, + **{name: {"type": "bool"} for name in FLAGS}, + }, + "resources": [], + } + ) + + def read_bytes(path): + return template_text.encode() if path == template_path else actual + + shared = ( + mock.patch.object(Path, "read_bytes", read_bytes), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.workflow_status", + return_value=self._state(), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.resolve_artifact_reference", + return_value=FIXTURE_ROOT / "Package" / "mainTemplate.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._parse_build_validation_receipt", + return_value={"status": "PASSED", "sha256": "7" * 64}, + ), + ) + with shared[0], shared[1], shared[2], shared[3], shared[4]: + with mock.patch( + "sentinel_xdr_migration.arm_acceptance._current_package", + return_value=self._current_package(template_hash="a" * 64), + ): + with self.assertRaisesRegex(ValueError, "no longer matches"): + _acceptance_package_plan(FIXTURE_ROOT, VALIDATION_RECEIPTS[0]) + + # A fresh hash still cannot hide an undeclared, required package parameter. + fresh_hash = arm_acceptance.hashlib.sha256(template_text.encode()).hexdigest() + with shared[0], shared[1], shared[2], shared[3], shared[4]: + with mock.patch( + "sentinel_xdr_migration.arm_acceptance._current_package", + return_value=self._current_package(template_hash=fresh_hash), + ): + with self.assertRaisesRegex(ValueError, "no default"): + _acceptance_package_plan(FIXTURE_ROOT, VALIDATION_RECEIPTS[0]) + + def test_batch_plan_requires_exact_same_workstream_flags_but_allows_zero_flag_members(self): + package_plan = self._package_plan() + package_plan["declaredContentFlags"] = FLAGS + package_plan["parameters"] = { + "workspace": "arm-acceptance-fixture-ws", + **FLAGS, + } + package_plan["parametersSha256"] = arm_acceptance._canonical_sha256( + package_plan["parameters"] + ) + receipt = {"decisions": {"knownFlagSelections": FLAGS}} + binding = {"sha256": "e" * 64, "solutions": ["FixtureOne"]} + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=NORMALIZED_MEMBERS, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._acceptance_package_plan", + return_value=package_plan, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_shared_decisions", + return_value=(receipt, binding), + ), + ): + batch = _arm_acceptance_batch_plan( + MEMBERS, + shared_decisions_path=Path("shared-decisions.json"), + validation_receipts=VALIDATION_RECEIPTS, + ) + self.assertEqual("ready", batch["status"]) + self.assertEqual(FLAGS, batch["members"][0]["declaredContentFlags"]) + self.assertFalse(batch["environmentWritesAuthorized"]) + self.assertFalse(batch["resourceReadbackRequired"]) + + package_plan["declaredContentFlags"] = {} + package_plan["parameters"] = {"workspace": "arm-acceptance-fixture-ws"} + package_plan["parametersSha256"] = arm_acceptance._canonical_sha256( + package_plan["parameters"] + ) + receipt = {"decisions": {"knownFlagSelections": {}}} + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=NORMALIZED_MEMBERS, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._acceptance_package_plan", + return_value=package_plan, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_shared_decisions", + return_value=(receipt, binding), + ), + ): + empty_batch = _arm_acceptance_batch_plan( + MEMBERS, + shared_decisions_path=Path("shared-decisions.json"), + validation_receipts=VALIDATION_RECEIPTS, + ) + self.assertEqual({}, empty_batch["members"][0]["declaredContentFlags"]) + + receipt = { + "decisions": { + "knownFlagSelections": { + **FLAGS, + "DeployAnalyticsRule": False, + "InventedFlag": True, + } + } + } + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=NORMALIZED_MEMBERS, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._acceptance_package_plan", + return_value=package_plan, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_shared_decisions", + return_value=(receipt, binding), + ), + ): + with self.assertRaisesRegex(ValueError, "exactly enumerate"): + _arm_acceptance_batch_plan( + MEMBERS, + shared_decisions_path=Path("shared-decisions.json"), + validation_receipts=VALIDATION_RECEIPTS, + ) + + def test_batch_plan_refuses_members_from_different_locked_labs(self): + second_member = { + "solution": "FixtureTwo", + "solutionPath": str(FIXTURE_ROOT), + "runId": RUN_ID + "-2", + } + second_plan = self._package_plan() + second_plan["runId"] = second_member["runId"] + second_plan["target"] = {**TARGET, "resourceGroup": "other-rg"} + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=[*NORMALIZED_MEMBERS, second_member], + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._acceptance_package_plan", + side_effect=[self._package_plan(), second_plan], + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_shared_decisions" + ) as shared, + ): + with self.assertRaisesRegex(ValueError, "cannot cross locked lab targets"): + _arm_acceptance_batch_plan( + [*MEMBERS, second_member], + shared_decisions_path=Path("shared-decisions.json"), + validation_receipts=[*VALIDATION_RECEIPTS, VALIDATION_RECEIPTS[0]], + ) + shared.assert_not_called() + + def _execute( + self, + response, + *, + code=0, + current_plan=None, + member_count=1, + account_check=None, + runner_side_effect=None, + ): + batch = self._batch_plan() + if member_count == 2: + second = dict(batch["members"][0]) + second.update( + { + "solution": "FixtureTwo", + "runId": RUN_ID + "-2", + "deploymentName": "sentinel-arm-fixture-deployment-two", + } + ) + batch["members"].append(second) + current = current_plan or batch + normalized = [ + { + "solution": item["solution"], + "solutionPath": str(FIXTURE_ROOT), + "runId": item["runId"], + } + for item in batch["members"] + ] + receipt_path = FIXTURE_ROOT / "no-write-arm-acceptance-receipt.json" + persisted: list[dict] = [] + + def write(_root, _path, value): + persisted.append(json.loads(json.dumps(value))) + + if runner_side_effect is not None: + runner = mock.Mock(side_effect=runner_side_effect) + elif isinstance(response, list): + runner = mock.Mock(side_effect=response) + else: + runner = mock.Mock( + return_value=(code, response, "fixture CLI error" if code else "") + ) + patches = ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._arm_acceptance_batch_plan", + side_effect=[batch, batch, *([current] if current_plan else []), *([batch] * 8)], + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=normalized, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.existing_artifact_path", + return_value=receipt_path, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.artifact_path", + return_value=receipt_path, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._member_attempt_path", + return_value=FIXTURE_ROOT / "no-write-member-attempt.json", + ), + mock.patch("sentinel_xdr_migration.arm_acceptance._reserve_attempt"), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.write_json_artifact", + side_effect=write, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.using_run", + side_effect=lambda *_args, **_kwargs: nullcontext(), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._verify_existing_cli_target", + return_value=account_check + or { + "statusCode": 0, + "tenantMatches": True, + "subscriptionMatches": True, + }, + ), + mock.patch( + "sentinel_xdr_migration.graph_client_config.configured_azure_cli_profile", + return_value=Path("."), + ), + mock.patch( + "sentinel_xdr_migration.graph_client_config.azure_cli_profile", + side_effect=lambda _profile: nullcontext(), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._run_cli", + side_effect=runner, + ), + ) + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patches[4], + patches[5] as reserve, + patches[6], + patches[7], + patches[8], + patches[9], + patches[10], + patches[11], + ): + result = execute_arm_template_acceptance_batch( + MEMBERS, + expected_batch_plan_sha256=batch["batchPlanSha256"], + shared_decisions_path=Path("shared-decisions.json"), + validation_receipts=VALIDATION_RECEIPTS, + actor="approved maintainer", + confirmation=_BATCH_ARM_ACCEPTANCE_CONFIRMATION, + ) + return result, runner, persisted, reserve + + def test_success_requires_only_terminal_succeeded_and_intent_precedes_cli(self): + snapshot_path = FIXTURE_ROOT / "Package" / "acceptance-template-snapshot.json" + original_template = FIXTURE_ROOT / "Package" / "mainTemplate.json" + original_bytes = original_template.read_bytes() + + def mutate_original_during_submission(command, **_kwargs): + original_template.write_bytes(b'{"changed":true}') + try: + submitted_path = Path(command[command.index("--template-file") + 1]) + submitted_bytes = submitted_path.read_bytes() + return ( + 1, + { + "id": "/subscriptions/fixture/resourceGroups/fixture-rg/providers/Microsoft.Resources/deployments/sentinel-arm", + "properties": {"provisioningState": "Succeeded"}, + }, + "", + ) if submitted_bytes == snapshot_path.read_bytes() else (1, None, "snapshot mismatch") + finally: + original_template.write_bytes(original_bytes) + + try: + result, runner, persisted, reserve = self._execute( + { + "id": "/subscriptions/fixture/resourceGroups/fixture-rg/providers/Microsoft.Resources/deployments/sentinel-arm", + "properties": {"provisioningState": "Succeeded"}, + }, + code=1, + runner_side_effect=mutate_original_during_submission, + ) + finally: + original_template.write_bytes(original_bytes) + self.assertEqual("accepted", result["status"]) + self.assertEqual("Succeeded", result["members"][0]["provisioningState"]) + self.assertEqual("succeeded", result["members"][0]["normalizedProvisioningState"]) + self.assertEqual(1, result["members"][0]["commandExitCode"]) + self.assertEqual(1, runner.call_count) + batch_reports = [item for item in persisted if isinstance(item.get("members"), list)] + self.assertTrue( + any(item["members"][0]["status"] == "write-intent-persisted" for item in batch_reports) + ) + self.assertEqual(2, reserve.call_count) + self.assertFalse(result["resourceReadbackPerformed"]) + self.assertFalse(result["installationVerified"]) + self.assertFalse(result["activationPerformed"]) + command = runner.call_args.args[0] + self.assertEqual(["deployment", "group", "create"], command[:3]) + self.assertEqual(TARGET["subscriptionId"], command[command.index("--subscription") + 1]) + self.assertEqual(TARGET["resourceGroup"], command[command.index("--resource-group") + 1]) + self.assertEqual( + str(snapshot_path), + command[command.index("--template-file") + 1], + ) + self.assertEqual("Incremental", command[command.index("--mode") + 1]) + self.assertIn("workspace=arm-acceptance-fixture-ws", command) + for name in FLAGS: + self.assertIn(f"{name}=true", command) + + def test_terminal_failure_nonterminal_and_timeout_are_persisted_without_retry(self): + failed, runner, persisted, *_ = self._execute( + {"id": "deployment-fixture", "properties": {"provisioningState": "Failed"}}, + ) + self.assertEqual("failed", failed["status"]) + self.assertEqual("Failed", failed["members"][0]["provisioningState"]) + self.assertEqual(1, runner.call_count) + batch_reports = [item for item in persisted if isinstance(item.get("members"), list)] + self.assertTrue(batch_reports[-1]["retryAuthorized"] is False) + + running, runner, persisted, *_ = self._execute( + {"id": "deployment-fixture", "properties": {"provisioningState": "Running"}}, + ) + self.assertEqual("nonterminal", running["status"]) + self.assertEqual("Running", running["members"][0]["provisioningState"]) + self.assertEqual(1, runner.call_count) + + def test_partial_batch_result_is_durable_and_stops_after_first_non_succeeded_member(self): + succeeded = { + "id": "deployment-fixture-one", + "properties": {"provisioningState": "Succeeded"}, + } + failed = { + "id": "deployment-fixture-two", + "properties": {"provisioningState": "Failed"}, + } + result, runner, persisted, *_ = self._execute( + [ + (0, succeeded, ""), + (1, failed, "second package failed"), + ], + member_count=2, + ) + self.assertEqual("partial-failed", result["status"]) + self.assertEqual( + ["accepted", "failed"], + [member["status"] for member in result["members"]], + ) + self.assertEqual( + ["deployment-fixture-one", "deployment-fixture-two"], + [member["deploymentId"] for member in result["members"]], + ) + self.assertEqual(2, runner.call_count) + batch_reports = [item for item in persisted if isinstance(item.get("members"), list)] + self.assertEqual("partial-failed", batch_reports[-1]["status"]) + + unknown, runner, persisted, *_ = self._execute( + None, + code=124, + ) + self.assertEqual("unknown", unknown["status"]) + self.assertIsNone(unknown["members"][0]["deploymentId"]) + self.assertEqual(1, runner.call_count) + + def test_wrong_existing_cli_tenant_or_subscription_blocks_without_write_intent(self): + result, runner, persisted, reserve = self._execute( + None, + account_check={ + "statusCode": 0, + "tenantMatches": True, + "subscriptionMatches": False, + "error": None, + }, + ) + self.assertEqual("blocked", result["status"]) + self.assertFalse(result["writeAttempted"]) + self.assertEqual(0, runner.call_count) + self.assertFalse(persisted) + reserve.assert_not_called() + + def test_changed_batch_hash_before_member_write_is_recorded_without_submit(self): + changed = self._batch_plan() + changed["batchPlanSha256"] = "f" * 64 + result, runner, persisted, *_ = self._execute( + None, + current_plan=changed, + ) + self.assertEqual("blocked", result["status"]) + self.assertEqual("blocked-before-write", result["members"][0]["status"]) + self.assertEqual(0, runner.call_count) + self.assertTrue(persisted) + + def test_mismatched_approval_or_existing_attempt_refuses_before_account_check(self): + batch = self._batch_plan() + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._arm_acceptance_batch_plan", + return_value=batch, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=NORMALIZED_MEMBERS, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.using_run", + side_effect=lambda *args, **kwargs: nullcontext(), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.artifact_path", + return_value=FIXTURE_ROOT / "missing-acceptance-report.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.existing_artifact_path", + return_value=FIXTURE_ROOT / "missing-acceptance-report.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._member_attempt_path", + return_value=FIXTURE_ROOT / "missing-member-attempt.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._verify_existing_cli_target" + ) as account_check, + ): + with self.assertRaisesRegex(ValueError, "exact enumerated"): + execute_arm_template_acceptance_batch( + MEMBERS, + expected_batch_plan_sha256="f" * 64, + shared_decisions_path="shared-decisions.json", + validation_receipts=VALIDATION_RECEIPTS, + actor="approved maintainer", + confirmation=_BATCH_ARM_ACCEPTANCE_CONFIRMATION, + ) + account_check.assert_not_called() + + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._arm_acceptance_batch_plan", + return_value=batch, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=NORMALIZED_MEMBERS, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.using_run", + side_effect=lambda *args, **kwargs: nullcontext(), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.artifact_path", + return_value=FIXTURE_ROOT / "missing-acceptance-report.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.existing_artifact_path", + return_value=FIXTURE_ROOT / "missing-acceptance-report.json", + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._member_attempt_path", + return_value=Path(__file__), + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._verify_existing_cli_target" + ) as account_check, + ): + with self.assertRaisesRegex(ValueError, "attempt already exists"): + execute_arm_template_acceptance_batch( + MEMBERS, + expected_batch_plan_sha256=batch["batchPlanSha256"], + shared_decisions_path="shared-decisions.json", + validation_receipts=VALIDATION_RECEIPTS, + actor="approved maintainer", + confirmation=_BATCH_ARM_ACCEPTANCE_CONFIRMATION, + ) + account_check.assert_not_called() + + def test_reordered_batch_cannot_retry_a_member_with_an_existing_attempt(self): + batch = self._batch_plan() + first = dict(batch["members"][0]) + first.update({"solution": "FixtureA", "runId": RUN_ID + "-a"}) + second = dict(batch["members"][0]) + second.update( + { + "solution": "FixtureB", + "runId": RUN_ID + "-b", + "deploymentName": "sentinel-arm-fixture-deployment-b", + } + ) + batch["members"] = [second, first] + batch["batchPlanSha256"] = "c" * 64 + normalized = [ + {"solution": item["solution"], "solutionPath": str(FIXTURE_ROOT), "runId": item["runId"]} + for item in batch["members"] + ] + prior_attempt = FIXTURE_ROOT / "prior-member-b-attempt.json" + central_report = FIXTURE_ROOT / "no-write-arm-acceptance-receipt.json" + + with ( + mock.patch( + "sentinel_xdr_migration.arm_acceptance._arm_acceptance_batch_plan", + return_value=batch, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._validated_members", + return_value=normalized, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.existing_artifact_path", + return_value=central_report, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance.artifact_path", + return_value=central_report, + ), + mock.patch( + "sentinel_xdr_migration.arm_acceptance._member_attempt_path", + side_effect=lambda item: ( + prior_attempt + if item["solution"] == "FixtureB" + else FIXTURE_ROOT / "unattempted-member-a.json" + ), + ), + mock.patch.object(Path, "exists", autospec=True, side_effect=lambda path: path == prior_attempt), + mock.patch("sentinel_xdr_migration.arm_acceptance._verify_existing_cli_target") as account, + mock.patch("sentinel_xdr_migration.arm_acceptance._reserve_attempt") as reserve, + mock.patch("sentinel_xdr_migration.arm_acceptance._run_cli") as runner, + ): + with self.assertRaisesRegex(ValueError, "deployment identity"): + execute_arm_template_acceptance_batch( + [ + {"solution": "FixtureB", "solutionPath": str(FIXTURE_ROOT), "runId": RUN_ID + "-b"}, + {"solution": "FixtureA", "solutionPath": str(FIXTURE_ROOT), "runId": RUN_ID + "-a"}, + ], + expected_batch_plan_sha256=batch["batchPlanSha256"], + shared_decisions_path="shared-decisions.json", + validation_receipts=[VALIDATION_RECEIPTS[0], VALIDATION_RECEIPTS[0]], + actor="approved maintainer", + confirmation=_BATCH_ARM_ACCEPTANCE_CONFIRMATION, + ) + account.assert_not_called() + reserve.assert_not_called() + runner.assert_not_called() + + def test_cli_surface_registers_separate_acceptance_commands(self): + from sentinel_xdr_migration import cli + + with mock.patch( + "sentinel_xdr_migration.cli.plan_arm_template_acceptance_batch", + return_value={"status": "ready"}, + ) as plan: + result = cli.main( + [ + "plan-arm-template-acceptance-batch", + "--member", + f"{FIXTURE_ROOT}::{RUN_ID}", + "--validation-receipt", + VALIDATION_RECEIPTS[0], + "--shared-decisions", + "shared-decisions.json", + ] + ) + self.assertEqual(0, result) + self.assertEqual(str(FIXTURE_ROOT), plan.call_args.args[0][0]["solution"]) + with mock.patch( + "sentinel_xdr_migration.cli.execute_arm_template_acceptance_batch", + return_value={"status": "accepted"}, + ) as execute: + result = cli.main( + [ + "deploy-arm-template-acceptance-batch", + "--member", + f"{FIXTURE_ROOT}::{RUN_ID}", + "--validation-receipt", + VALIDATION_RECEIPTS[0], + "--shared-decisions", + "shared-decisions.json", + "--expected-batch-plan-sha256", + "b" * 64, + "--actor", + "approved maintainer", + "--confirm-exact-scope-write", + _BATCH_ARM_ACCEPTANCE_CONFIRMATION, + ] + ) + self.assertEqual(0, result) + self.assertEqual(str(FIXTURE_ROOT), execute.call_args.args[0][0]["solution"]) + + with ( + mock.patch( + "sentinel_xdr_migration.cli.execute_arm_template_acceptance_batch", + return_value={"status": "unknown"}, + ), + redirect_stdout(StringIO()), + ): + failed_result = cli.main( + [ + "deploy-arm-template-acceptance-batch", + "--member", + f"{FIXTURE_ROOT}::{RUN_ID}", + "--validation-receipt", + VALIDATION_RECEIPTS[0], + "--shared-decisions", + "shared-decisions.json", + "--expected-batch-plan-sha256", + "b" * 64, + "--actor", + "approved maintainer", + "--confirm-exact-scope-write", + _BATCH_ARM_ACCEPTANCE_CONFIRMATION, + ] + ) + self.assertEqual(1, failed_result) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_artifacts.py b/Tools/SentinelToXDRMigration/tests/test_artifacts.py new file mode 100644 index 00000000000..3b4bf5b8108 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_artifacts.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration.artifacts import ( + artifact_path, existing_artifact_path, existing_artifacts, migrate_reports, + portable_artifact, report_directory, resolve_artifact_reference, +) +from sentinel_xdr_migration.content_paths import yaml_files +from sentinel_xdr_migration.workflow import ( + initialize_workflow, workflow_status, start_workflow_stage, + complete_workflow_stage, _validate_packaging_evidence, +) + + +class ArtifactTests(unittest.TestCase): + def setUp(self): + self.storage = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.storage.cleanup) + self.repository = Path(self.storage.name) + self.solution = self.repository / "Solutions" / "Space Solution" + self.solution.mkdir(parents=True) + self.old = self.repository / "Reports" / self.solution.name / "sentinel-xdr-migration" + + @property + def new(self): + return report_directory(self.solution) + + def legacy(self, name, value): + path = self.old / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + return path + + def test_default_and_override_only_write_solution_logs(self): + with mock.patch.dict("os.environ", {"AZURE_SENTINEL_REPORTS_ROOT": str(self.repository / "override")}): + directory = report_directory(self.solution, create=True) + self.assertEqual(self.solution / "Logs" / "sentinel-xdr-migration", directory.parent) + self.assertEqual(self.new, directory) + self.assertFalse(self.old.exists()) + + def test_read_only_fallback_then_explicit_copy_preserves_blocked_state(self): + initialize_workflow(self.solution, workflow_profile="authoring") + start_workflow_stage(self.solution, "discovery") + complete_workflow_stage(self.solution, "discovery", status="blocked", message="Environment unavailable") + state = artifact_path(self.solution, "workflow-state.json") + self.old.mkdir(parents=True) + legacy = self.old / state.name + state.replace(legacy) + original = legacy.read_bytes() + with self.assertWarns(UserWarning): + status = workflow_status(self.solution) + self.assertEqual("blocked", status["workflowStatus"]) + self.assertFalse(state.exists()) + self.assertEqual(["workflow-state.json"], migrate_reports(self.solution)["files"]) + self.assertFalse(state.exists()) + with self.assertRaisesRegex(ValueError, "migrate-reports"): + start_workflow_stage(self.solution, "discovery") + migrate_reports(self.solution, apply=True) + self.assertEqual(original, legacy.read_bytes()) + self.assertEqual("blocked", workflow_status(self.solution)["workflowStatus"]) + start_workflow_stage(self.solution, "discovery") + self.assertEqual(2, workflow_status(self.solution)["stages"]["discovery"]["attempts"]) + self.assertEqual([], migrate_reports(self.solution, apply=True)["files"]) + + def test_conflicting_copies_fail_without_partial_copy(self): + self.legacy("a.json", {"a": 1}) + self.legacy("state.json", {"old": True}) + self.new.mkdir(parents=True) + (self.new / "state.json").write_text('{"new": true}') + with self.assertRaisesRegex(ValueError, "conflicting"): + existing_artifact_path(self.solution, "state.json") + with self.assertRaisesRegex(ValueError, "conflicting"): + migrate_reports(self.solution, apply=True) + self.assertFalse((self.new / "a.json").exists()) + + def test_changed_legacy_and_missing_copied_artifacts_fail(self): + path = self.legacy("manifest.json", {"total": 2}) + migrate_reports(self.solution, apply=True) + path.write_text('{"total": 3}') + with self.assertRaisesRegex(ValueError, "changed after migration"): + existing_artifact_path(self.solution, "manifest.json") + path.write_text(json.dumps({"total": 2})) + (self.new / "manifest.json").unlink() + with self.assertRaisesRegex(ValueError, "missing"): + report_directory(self.solution, create=True) + + def test_runtime_discovery_includes_old_reports_and_detects_conflicts(self): + old = self.legacy("runtime-validation.graph.json", {"provider": "graph"}) + with self.assertWarns(UserWarning): + self.assertEqual([old], existing_artifacts(self.solution, "runtime-validation.*.json")) + self.new.mkdir(parents=True) + (self.new / old.name).write_text('{"provider": "other"}') + with self.assertRaisesRegex(ValueError, "conflicting"): + existing_artifacts(self.solution, "runtime-validation.*.json") + + def test_portable_packaging_evidence_resolves_against_solution_not_cwd(self): + initialize_workflow(self.solution, workflow_profile="authoring", version_bump="none") + package = self.solution / "Package" + package.mkdir() + document = {"packager": "V3.1", "versionBump": "none"} + for field, name in ( + ("mainTemplate", "mainTemplate.json"), ("createUiDefinition", "createUiDefinition.json"), + ("testParameters", "testParameters.json"), ("zip", "3.1.0.zip"), + ): + (package / name).write_text("{}") + document[field] = f"Package\\{name}" + report = artifact_path(self.solution, "packaging.v3_1.json") + report.write_text(json.dumps(document)) + state = workflow_status(self.solution) + state["stages"]["validation"]["status"] = "passed" + _validate_packaging_evidence( + state, + {"packager": "V3.1", "packageReport": "Logs\\sentinel-xdr-migration\\packaging.v3_1.json"}, + self.solution, + ) + self.assertEqual(package / "mainTemplate.json", resolve_artifact_reference( + self.solution, "Package\\mainTemplate.json")) + + def test_serialization_preserves_identity_and_error_semantics(self): + message = f"Provider cannot read {self.solution}/private/file" + query = ( + 'DeviceProcessEvents\n' + '| where NewProcessName endswith "\\\\\\\\certutil.exe"\n' + '| extend SourcePath = "C:\\\\\\\\Program Files\\\\\\\\source\\\\\\\\agent.exe"\n' + '| join kind=inner (DeviceNetworkEvents | project DeviceId, RemoteUrl) on DeviceId\n' + '| where RemoteUrl contains "https://example.test/source?mode=join"\n' + '| project DeviceId, SourcePath' + ) + value = portable_artifact(self.solution, { + "solution": str(self.solution), + "source": str(self.solution / "Analytic Rules" / "Nested Rule.yaml"), + "sourceReference": {"source": "Analytic Rules\\Nested Rule.yaml"}, + "context": {"workspaceResourceId": "/subscriptions/id/resourceGroups/rg", "tenantId": "tenant"}, + "error": message, + "query": 'print Path="/home/example"', + "queries": {"source": query, "converted": query}, + "errorDetails": {"message": message, "source": "C:\\provider\\error"}, + "artifacts": { + "manifest": str(self.solution / "Logs" / "manifest.json"), + }, + "access_token": "secret", "details": "Bearer eySecret.token.value https://example/?sig=secret&se=secret", + }) + self.assertEqual(".", value["solution"]) + self.assertEqual("Analytic Rules/Nested Rule.yaml", value["source"]) + self.assertEqual( + "Analytic Rules/Nested Rule.yaml", + value["sourceReference"]["source"], + ) + self.assertEqual(message, value["error"]) + self.assertEqual('print Path="/home/example"', value["query"]) + self.assertEqual(query, value["queries"]["source"]) + self.assertEqual(query, value["queries"]["converted"]) + self.assertEqual(message, value["errorDetails"]["message"]) + self.assertEqual("C:\\provider\\error", value["errorDetails"]["source"]) + self.assertEqual("Logs/manifest.json", value["artifacts"]["manifest"]) + self.assertEqual("tenant", value["context"]["tenantId"]) + self.assertNotIn("secret", json.dumps(value)) + self.assertNotIn("eySecret", value["details"]) + + def test_yaml_inventory_excludes_logs_and_reports(self): + for name in ["Actual.yaml", "Nested/Rule.yml", "Logs/config.yaml", "Reports/config.yml"]: + path = self.solution / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("name: fixture") + self.assertEqual(["Actual.yaml", "Nested/Rule.yml"], [ + path.relative_to(self.solution).as_posix() for path in yaml_files(self.solution) + ]) + + def test_all_solution_logs_stay_gitignored(self): + paths = [ + "Solutions/Reporting Fixture/Logs/sentinel-xdr-migration/workflow-state.json", + "Solutions/Reporting Fixture/Logs/sentinel-xdr-migration/run.log", + "Solutions/Other Fixture/Logs/other-run/raw.json", + ] + result = subprocess.run( + ["git", "check-ignore", "--no-index", "--stdin"], + input="\n".join(paths) + "\n", capture_output=True, text=True, check=False, + ) + self.assertEqual(paths, result.stdout.splitlines()) + + def test_xdr_legacy_copy_keeps_detection_yaml_and_config_unchanged(self): + output = self.solution / "XDR Detections" + output.mkdir() + (output / "Rule.yaml").write_text("kind: CustomDetection\n") + config = output / "migration-config.yaml" + config.write_text("tableMappings:\n Old: New\n") + migrate_reports(self.solution, apply=True) + self.assertEqual(config.read_bytes(), (self.new / config.name).read_bytes()) + self.assertFalse((self.new / "Rule.yaml").exists()) + self.assertTrue(config.exists()) + + def test_deployment_blocks_before_auth_or_provider_writes_with_legacy_evidence(self): + from sentinel_xdr_migration.deployment import deploy_solution + from sentinel_xdr_migration.analytic_deployment import deploy_analytic_rules + + self.legacy("workflow-state.json", {"status": "blocked"}) + with mock.patch("sentinel_xdr_migration.deployment._deployment_token") as graph_token: + with self.assertRaisesRegex(ValueError, "migrate-reports"): + deploy_solution(self.solution) + graph_token.assert_not_called() + (self.solution / "Analytic Rules").mkdir() + with mock.patch("sentinel_xdr_migration.analytic_deployment._arm_token") as arm_token: + with self.assertRaisesRegex(ValueError, "migrate-reports"): + deploy_analytic_rules(self.solution) + arm_token.assert_not_called() + + def test_binary_legacy_evidence_is_preserved_byte_for_byte(self): + self.old.mkdir(parents=True) + evidence = self.old / "capture.png" + evidence.write_bytes(b"\x89PNG\r\n\xff\x00") + migrate_reports(self.solution, apply=True) + self.assertEqual(evidence.read_bytes(), (self.new / evidence.name).read_bytes()) diff --git a/Tools/SentinelToXDRMigration/tests/test_authoring_contract.py b/Tools/SentinelToXDRMigration/tests/test_authoring_contract.py new file mode 100644 index 00000000000..0582efcd30e --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_authoring_contract.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import copy +import unittest + +from sentinel_xdr_migration.authoring_contract import ( + AuthoringContractError, + AUTHORING_SCHEMA_VERSION, + calculate_coverage, + calculate_inventory_sha256, + calculate_operation_eligibility, + seal_authoring_contract, + validate_authoring_contract, +) + + +HASH = "a" * 64 +TARGET_HASH = "b" * 64 +EVIDENCE_HASH = "c" * 64 +PARITY_POLICY = { + "assertion": "value-preserved-in-actual-corresponding-alert", + "comparison": "value-only", + "entityTypeIdentifierAndCustomFieldMayDiffer": True, + "requiredForQualification": True, +} + + +def make_contract(statuses: list[str]) -> dict: + inventory = [] + results = [] + for index, status in enumerate(statuses): + rule_id = f"rule-{index}" + source_hash = f"{index:064x}" + inventory.append({ + "ruleId": rule_id, + "sha256": source_hash, + "evidenceReferences": [], + }) + disposition = { + "migrated": "normal", + "blocked": "deploymentBlocked", + "suppressed": "suppressed", + "unprocessed": "normal", + }[status] + findings = [] + if status in {"blocked", "suppressed"}: + findings.append({ + "findingId": "error-1", + "kind": "error", + "message": "Rule-specific conversion error", + "unresolved": True, + "evidenceReferences": [], + }) + results.append({ + "ruleId": rule_id, + "sourceSha256": source_hash, + "status": status, + "deploymentDisposition": disposition, + "target": { + "path": f"XDR Detections/{rule_id}.yaml", + "sha256": TARGET_HASH, + "structurallyValid": True, + } if status != "unprocessed" else None, + "reviewRequired": False, + "mappingAssumptions": [], + "findings": findings, + "qualificationStatus": "untested", + "qualificationEvidenceReferences": [], + "evidenceReferences": [], + }) + return seal_authoring_contract({ + "schemaVersion": "1.0.0", + "runId": "run-1", + "source": {"identity": "Sample solution", "sha256": HASH}, + "inventory": {"complete": True, "rules": inventory}, + "results": results, + "environmentErrors": [], + "entityValueParityPolicy": copy.deepcopy(PARITY_POLICY), + }) + + +class AuthoringContractTests(unittest.TestCase): + def coverage(self, record: dict): + return calculate_coverage(validate_authoring_contract(record)) + + def test_eight_of_ten_passes_seven_of_ten_fails_without_rounding(self): + eight = self.coverage(make_contract(["migrated"] * 8 + ["unprocessed"] * 2)) + seven = self.coverage(make_contract(["migrated"] * 7 + ["unprocessed"] * 3)) + self.assertTrue(eight.migration_threshold_met) + self.assertEqual((10, 8, 0), (eight.N, eight.M, eight.E)) + self.assertFalse(seven.migration_threshold_met) + self.assertEqual((10, 7), (seven.N, seven.M)) + + def test_error_stop_boundary_is_strict_and_uses_integer_comparison(self): + five = self.coverage(make_contract(["blocked"] * 5 + ["unprocessed"] * 5)) + six = self.coverage(make_contract(["blocked"] * 6 + ["unprocessed"] * 4)) + self.assertEqual(5, five.E) + self.assertFalse(five.stop_threshold_reached) + self.assertEqual(6, six.E) + self.assertTrue(six.stop_threshold_reached) + + def test_two_of_three_is_below_threshold(self): + coverage = self.coverage(make_contract(["migrated", "migrated", "unprocessed"])) + self.assertEqual((3, 2), (coverage.N, coverage.M)) + self.assertFalse(coverage.migration_threshold_met) + + def test_warnings_and_assumptions_are_retained_without_verification_claims(self): + record = make_contract(["migrated"]) + result = record["results"][0] + result["reviewRequired"] = True + result["mappingAssumptions"] = ["Table availability is assumed, not queried."] + result["findings"] = [ + { + "findingId": "warning-1", + "kind": "warning", + "message": "Advisory warning", + "unresolved": False, + "evidenceReferences": [], + }, + { + "findingId": "assumption-1", + "kind": "assumption", + "message": "Mapping premise remains subject to review.", + "unresolved": False, + "evidenceReferences": [], + }, + ] + parsed = validate_authoring_contract(record) + coverage = calculate_coverage(parsed) + self.assertEqual(1, coverage.M) + self.assertTrue(parsed.results[0].review_required) + self.assertEqual(("Table availability is assumed, not queried.",), + parsed.results[0].mapping_assumptions) + self.assertEqual("untested", parsed.results[0].qualification_status) + self.assertEqual(0, coverage.qualified) + self.assertEqual(1, coverage.untested) + + def test_unprocessed_and_suppressed_remain_in_denominator_and_suppressed_is_error(self): + coverage = self.coverage(make_contract(["migrated", "suppressed", "unprocessed"])) + self.assertEqual((3, 1, 1), (coverage.N, coverage.M, coverage.E)) + + def test_multiple_findings_for_one_rule_count_once(self): + record = make_contract(["blocked", "unprocessed"]) + record["results"][0]["findings"].extend( + { + "findingId": f"error-{index}", + "kind": "error", + "message": "Another unresolved error", + "unresolved": True, + "evidenceReferences": [], + } + for index in range(2, 20) + ) + coverage = self.coverage(record) + self.assertEqual(1, coverage.E) + self.assertEqual(19, len(record["results"][0]["findings"])) + + def test_shared_provider_outage_stays_separate_from_rule_errors(self): + record = make_contract(["unprocessed", "unprocessed"]) + record["environmentErrors"].append({ + "code": "providerUnavailable", + "message": "Shared provider outage", + "evidenceReferences": [], + }) + parsed = validate_authoring_contract(record) + coverage = calculate_coverage(parsed) + self.assertEqual(1, len(parsed.environment_errors)) + self.assertEqual(0, coverage.E) + self.assertEqual(2, coverage.N) + + def test_empty_and_duplicate_inventory_are_rejected(self): + with self.assertRaisesRegex(AuthoringContractError, "non-empty"): + validate_authoring_contract(make_contract([])) + duplicate = make_contract(["unprocessed", "unprocessed"]) + duplicate["inventory"]["rules"][1]["ruleId"] = "rule-0" + with self.assertRaisesRegex(AuthoringContractError, "duplicate rule identities"): + validate_authoring_contract(duplicate) + + def test_unsupported_version_malformed_hash_and_escaping_paths_are_rejected(self): + record = make_contract(["migrated"]) + record["schemaVersion"] = "2.0.0" + with self.assertRaisesRegex(AuthoringContractError, "unsupported authoring contract version"): + validate_authoring_contract(record) + + record = make_contract(["migrated"]) + record["source"]["sha256"] = "not-a-hash" + with self.assertRaises(AuthoringContractError): + validate_authoring_contract(record) + + record = make_contract(["migrated"]) + record["results"][0]["evidenceReferences"] = [{ + "path": "../outside.json", + "sha256": EVIDENCE_HASH, + "kind": "other", + }] + with self.assertRaisesRegex(AuthoringContractError, "safe repository-relative path"): + validate_authoring_contract(record) + + def test_duplicate_results_cross_reference_and_status_inconsistencies_fail(self): + duplicate = make_contract(["unprocessed", "unprocessed"]) + duplicate["results"][1]["ruleId"] = "rule-0" + with self.assertRaisesRegex(AuthoringContractError, "duplicate rule identities"): + validate_authoring_contract(duplicate) + + mismatch = make_contract(["unprocessed"]) + mismatch["results"][0]["sourceSha256"] = TARGET_HASH + with self.assertRaisesRegex(AuthoringContractError, "source hash does not match"): + validate_authoring_contract(mismatch) + + invalid_status = make_contract(["migrated"]) + invalid_status["results"][0]["target"]["structurallyValid"] = False + with self.assertRaisesRegex(AuthoringContractError, "migrated rule"): + validate_authoring_contract(invalid_status) + + error_mismatch = make_contract(["migrated"]) + error_mismatch["results"][0]["findings"] = [{ + "findingId": "bad", + "kind": "error", + "message": "Unresolved", + "unresolved": True, + "evidenceReferences": [], + }] + with self.assertRaisesRegex(AuthoringContractError, "migrated rule"): + validate_authoring_contract(error_mismatch) + + def test_qualified_requires_runtime_and_actual_alert_parity_evidence(self): + record = make_contract(["migrated"]) + record["results"][0]["qualificationStatus"] = "qualified" + with self.assertRaisesRegex(AuthoringContractError, "needs runtimeVerification and alertParity"): + validate_authoring_contract(record) + + record["results"][0]["qualificationEvidenceReferences"] = [ + {"path": "evidence/runtime.json", "sha256": EVIDENCE_HASH, "kind": "runtimeVerification"}, + {"path": "evidence/alert.json", "sha256": TARGET_HASH, "kind": "alertParity"}, + ] + record.pop("coverage") + record.pop("operationEligibility") + parsed = validate_authoring_contract(seal_authoring_contract(record)) + self.assertEqual(1, calculate_coverage(parsed).qualified) + + def test_inconsistent_embedded_coverage_counts_are_rejected(self): + record = make_contract(["migrated", "unprocessed"]) + record["coverage"]["M"] = 2 + with self.assertRaisesRegex(AuthoringContractError, "coverage counts or thresholds"): + validate_authoring_contract(record) + + def test_operation_eligibility_is_advisory_and_never_authorizes_actions(self): + coverage = self.coverage(make_contract(["migrated"] * 8 + ["unprocessed"] * 2)) + eligibility = calculate_operation_eligibility(coverage).as_dict() + self.assertTrue(eligibility["migrationThresholdMet"]) + self.assertTrue(eligibility["successAndPrThresholdMet"]) + self.assertTrue(eligibility["requiresExactScopeAuthorization"]) + self.assertFalse(eligibility["coverageAuthorizesOperations"]) + self.assertFalse(eligibility["deploymentAuthorized"]) + self.assertFalse(eligibility["packagingAuthorized"]) + self.assertFalse(eligibility["ingestionAuthorized"]) + self.assertFalse(eligibility["prCreationAuthorized"]) + record = make_contract(["migrated"]) + record["operationEligibility"]["deploymentAuthorized"] = True + with self.assertRaises(AuthoringContractError): + validate_authoring_contract(record) + + def test_version_1_1_binds_complete_paths_queries_and_immutable_snapshot_revision(self): + record = make_contract(["migrated", "unprocessed"]) + record["schemaVersion"] = AUTHORING_SCHEMA_VERSION + record["snapshot"] = {"revision": 1, "previousSha256": None} + for index, rule in enumerate(record["inventory"]["rules"]): + rule["path"] = f"Analytic Rules/Nested/Rule{index}.yaml" + rule["querySha256"] = f"{index + 3:064x}" + result = record["results"][index] + result["conversionStatus"] = "converted" if index == 0 else "unprocessed" + result["validationStatus"] = "passed" if index == 0 else "notRun" + if result["target"]: + result["target"]["querySha256"] = f"{index + 5:064x}" + record["inventory"]["sha256"] = calculate_inventory_sha256( + record["inventory"]["rules"] + ) + record["source"]["sha256"] = record["inventory"]["sha256"] + record.pop("coverage") + record.pop("operationEligibility") + record = seal_authoring_contract(record) + + parsed = validate_authoring_contract(record) + self.assertEqual(AUTHORING_SCHEMA_VERSION, parsed.schema_version) + self.assertEqual(1, parsed.revision) + self.assertEqual( + "Analytic Rules/Nested/Rule0.yaml", + parsed.inventory[0].path, + ) + self.assertEqual(f"{5:064x}", parsed.results[0].target.query_sha256) + self.assertEqual((2, 1, 0), ( + calculate_coverage(parsed).N, + calculate_coverage(parsed).M, + calculate_coverage(parsed).E, + )) + + stale = copy.deepcopy(record) + stale["inventory"]["rules"][0]["querySha256"] = "f" * 64 + stale.pop("coverage") + stale.pop("operationEligibility") + with self.assertRaisesRegex(AuthoringContractError, "inventory SHA-256"): + seal_authoring_contract(stale) + + def test_version_1_1_requires_safe_paths_query_hashes_and_snapshot_chain_shape(self): + record = make_contract(["unprocessed"]) + record["schemaVersion"] = AUTHORING_SCHEMA_VERSION + record["snapshot"] = {"revision": 1, "previousSha256": None} + record["inventory"]["rules"][0]["path"] = "../outside.yaml" + record["inventory"]["rules"][0]["querySha256"] = HASH + record["inventory"]["sha256"] = calculate_inventory_sha256( + record["inventory"]["rules"] + ) + record["results"][0]["conversionStatus"] = "unprocessed" + record["results"][0]["validationStatus"] = "notRun" + record.pop("coverage") + record.pop("operationEligibility") + with self.assertRaisesRegex(AuthoringContractError, "safe repository-relative path"): + seal_authoring_contract(record) + + record = make_contract(["unprocessed"]) + record["schemaVersion"] = AUTHORING_SCHEMA_VERSION + record["snapshot"] = {"revision": 2, "previousSha256": None} + record["inventory"]["rules"][0]["path"] = "Analytic Rules/Rule.yaml" + record["inventory"]["rules"][0]["querySha256"] = HASH + record["inventory"]["sha256"] = calculate_inventory_sha256( + record["inventory"]["rules"] + ) + record["results"][0]["conversionStatus"] = "unprocessed" + record["results"][0]["validationStatus"] = "notRun" + record.pop("coverage") + record.pop("operationEligibility") + with self.assertRaisesRegex(AuthoringContractError, "revision 1"): + seal_authoring_contract(record) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_budget.py b/Tools/SentinelToXDRMigration/tests/test_budget.py new file mode 100644 index 00000000000..cc055ef195f --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_budget.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import json +import tempfile +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sentinel_xdr_migration.budget import ( + BudgetExhausted, + BudgetReceiptError, + BudgetUnknown, + begin_operation, + budget_summary, + create_or_link_campaign, + extend_budget, + finish_operation, + measured_operation, + reconcile_legacy_usage, + reconcile_pending_operation, + set_observation_deadline, +) +from sentinel_xdr_migration.artifacts import create_run + + +INVENTORY = "a" * 64 +RULES = ["11111111-1111-1111-1111-111111111111"] + + +class CampaignBudgetTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.solution = Path(self.temp.name) / "Sample" + self.solution.mkdir() + create_run(self.solution, run_id="run-1") + self.campaign_id = "campaign-a" + create_or_link_campaign( + self.solution, + self.campaign_id, + inventory_sha256=INVENTORY, + run_id="run-1", + rule_ids=RULES, + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def _begin(self, dimension: str, **kwargs): + if dimension == "ruleRevision": + kwargs.setdefault("metadata", { + "inputSha256": "b" * 64, + "evidenceSha256": "c" * 64, + "proposedChangeSha256": "d" * 64, + }) + return begin_operation( + self.solution, + self.campaign_id, + inventory_sha256=INVENTORY, + run_id="run-1", + dimension=dimension, + **kwargs, + ) + + def _finish(self, receipt: dict, *, status: str = "passed", elapsed: float = 0): + return finish_operation( + self.solution, + self.campaign_id, + receipt["operationId"], + status=status, + elapsed_seconds=elapsed, + ) + + def test_conversion_limit_boundary_and_audited_extension(self) -> None: + for _ in range(3): + receipt = self._begin("conversionPass") + self._finish(receipt, status="failed") + with self.assertRaises(BudgetExhausted): + self._begin("conversionPass") + summary = budget_summary(self.solution, self.campaign_id) + self.assertEqual(summary["usage"]["conversionPasses"], 3) + self.assertEqual(summary["remaining"]["conversionPasses"], 0) + # Read-only status/evidence remains available after a budget stop. + self.assertEqual( + 3, + budget_summary(self.solution, self.campaign_id)["usage"]["conversionPasses"], + ) + extended = extend_budget( + self.solution, + self.campaign_id, + dimension="conversionPasses", + new_limit=4, + actor="owner", + reason="approved retry", + evidence_reference="approval-42", + ) + self.assertEqual(extended["remaining"]["conversionPasses"], 1) + receipt = self._begin("conversionPass") + self._finish(receipt) + self.assertEqual( + budget_summary(self.solution, self.campaign_id)["usage"]["conversionPasses"], + 4, + ) + + def test_rule_limit_per_rule_and_active_time_is_measured_not_waited(self) -> None: + rule_id = RULES[0] + for elapsed in (1.25, 2.5, 0.75): + receipt = self._begin("ruleRevision", item_id=rule_id) + self._finish(receipt, elapsed=elapsed) + summary = budget_summary(self.solution, self.campaign_id) + self.assertEqual(summary["usage"]["ruleRevisions"][rule_id], 3) + self.assertEqual(summary["usage"]["ruleActiveSeconds"][rule_id], 4.5) + self.assertEqual(summary["usage"]["activeExecutionSeconds"], 4.5) + with self.assertRaises(BudgetExhausted): + self._begin("ruleRevision", item_id=rule_id) + + def test_failed_cancelled_and_provider_retry_attempts_are_consumed(self) -> None: + failed = self._begin("providerHttpAttempt") + self._finish(failed, status="failed", elapsed=0.4) + with self.assertRaisesRegex(RuntimeError, "cancel"): + with measured_operation( + self.solution, + self.campaign_id, + inventory_sha256=INVENTORY, + run_id="run-1", + dimension="providerHttpAttempt", + ): + raise RuntimeError("cancel") + summary = budget_summary(self.solution, self.campaign_id) + self.assertEqual(summary["usage"]["providerHttpAttempts"], 2) + self.assertGreater(summary["usage"]["activeExecutionSeconds"], 0.4) + self.assertEqual(summary["usage"]["pendingOperations"], []) + + def test_ingestion_unknown_reconciliation_prevents_replay(self) -> None: + arguments = { + "item_id": "pair-1/fixture-1", + "approval_reference": "write-approval-1", + "attempt_marker": "marker-1", + } + pending = self._begin("ingestionSubmission", **arguments) + with self.assertRaises(BudgetUnknown): + self._begin("ingestionSubmission", **arguments) + reconcile_pending_operation( + self.solution, + self.campaign_id, + pending["operationId"], + status="unknown", + elapsed_seconds=2.0, + actor="owner", + reason="submission outcome reconciled as unknown", + evidence_reference="provider-receipt-1", + ) + with self.assertRaises(BudgetExhausted): + self._begin("ingestionSubmission", **arguments) + # A genuinely new attempt has a new marker and a new exact-scope approval. + fresh = self._begin( + "ingestionSubmission", + item_id="pair-1/fixture-1", + approval_reference="write-approval-2", + attempt_marker="marker-2", + ) + self._finish(fresh, status="passed", elapsed=1) + self.assertEqual( + budget_summary(self.solution, self.campaign_id)["usage"]["ingestionSubmissions"], + 2, + ) + + def test_unknown_legacy_usage_blocks_until_explicit_evidence_reconciliation(self) -> None: + other = self.solution + create_run(other, run_id="old-run") + create_or_link_campaign( + other, + "legacy", + inventory_sha256=INVENTORY, + run_id="old-run", + rule_ids=RULES, + legacy_usage_unknown=True, + ) + with self.assertRaises(BudgetUnknown): + begin_operation( + other, "legacy", inventory_sha256=INVENTORY, + run_id="old-run", dimension="conversionPass", + ) + baseline = { + "activeExecutionSeconds": 12.0, + "conversionPasses": 1, + "packageBuilds": 0, + "providerHttpAttempts": 4, + "ingestionSubmissions": 0, + "ruleRevisions": {RULES[0]: 1}, + "ruleActiveSeconds": {RULES[0]: 8.0}, + } + reconciled = reconcile_legacy_usage( + other, + "legacy", + consumed=baseline, + actor="owner", + reason="reviewed preserved artifacts and provider logs", + evidence_reference="legacy-usage-review-17", + ) + self.assertEqual(reconciled["legacyUsageStatus"], "known") + self.assertEqual(reconciled["usage"]["conversionPasses"], 1) + for _ in range(2): + next_receipt = begin_operation( + other, "legacy", inventory_sha256=INVENTORY, + run_id="old-run", dimension="ruleRevision", item_id=RULES[0], + metadata={ + "inputSha256": "b" * 64, + "evidenceSha256": "c" * 64, + "proposedChangeSha256": "d" * 64, + }, + ) + finish_operation( + other, "legacy", next_receipt["operationId"], + status="passed", elapsed_seconds=0.01, + ) + with self.assertRaises(BudgetExhausted): + begin_operation( + other, "legacy", inventory_sha256=INVENTORY, + run_id="old-run", dimension="ruleRevision", item_id=RULES[0], + metadata={ + "inputSha256": "b" * 64, + "evidenceSha256": "c" * 64, + "proposedChangeSha256": "d" * 64, + }, + ) + + def test_linked_replacement_run_shares_campaign_and_inventory_binding(self) -> None: + receipt = self._begin("packageBuild") + self._finish(receipt) + replacement = create_or_link_campaign( + self.solution, + self.campaign_id, + inventory_sha256=INVENTORY, + run_id="replacement-run", + rule_ids=RULES, + ) + self.assertEqual(replacement["usage"]["packageBuilds"], 1) + self.assertEqual(replacement["linkedRunIds"], ["run-1", "replacement-run"]) + with self.assertRaises(BudgetReceiptError): + create_or_link_campaign( + self.solution, + self.campaign_id, + inventory_sha256="b" * 64, + run_id="replacement-2", + rule_ids=RULES, + ) + with self.assertRaises(ValueError): + create_or_link_campaign( + self.solution, + "../escape", + inventory_sha256=INVENTORY, + run_id="replacement-2", + rule_ids=RULES, + ) + + def test_concurrent_work_never_spends_more_than_campaign_limit(self) -> None: + def attempt(_: int) -> bool: + for _ in range(20): + try: + with measured_operation( + self.solution, + self.campaign_id, + inventory_sha256=INVENTORY, + run_id="run-1", + dimension="conversionPass", + ): + time.sleep(0.003) + return True + except BudgetUnknown: + time.sleep(0.001) + except BudgetExhausted: + return False + return False + + with ThreadPoolExecutor(max_workers=8) as pool: + outcomes = list(pool.map(attempt, range(8))) + summary = budget_summary(self.solution, self.campaign_id) + self.assertEqual(sum(outcomes), 3) + self.assertEqual(summary["usage"]["conversionPasses"], 3) + self.assertEqual(summary["usage"]["pendingOperations"], []) + + def test_observation_deadline_is_schedule_aware_and_wait_is_not_active(self) -> None: + past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() + set_observation_deadline( + self.solution, + self.campaign_id, + test_id="test-past", + deadline_at=past, + approval_reference="approval-test-1", + actor="owner", + reason="approved schedule window", + ) + with self.assertRaises(BudgetExhausted): + self._begin("alertObservationPoll", test_id="test-past") + self.assertEqual( + budget_summary(self.solution, self.campaign_id)["usage"]["activeExecutionSeconds"], + 0, + ) + future = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat() + set_observation_deadline( + self.solution, + self.campaign_id, + test_id="test-future", + deadline_at=future, + approval_reference="approval-test-2", + actor="owner", + reason="approved schedule window", + ) + time.sleep(0.02) + receipt = self._begin("alertObservationPoll", test_id="test-future") + self._finish(receipt, elapsed=0.25) + self.assertEqual( + 0.25, + budget_summary(self.solution, self.campaign_id)["usage"]["activeExecutionSeconds"], + ) + + def test_tampered_receipt_hash_is_rejected(self) -> None: + receipt = self._begin("conversionPass") + self._finish(receipt) + path = ( + self.solution / "Logs" / "sentinel-xdr-migration-campaigns" + / self.campaign_id / "budget.json" + ) + self.assertFalse( + (self.solution / "Logs" / "sentinel-xdr-migration" / "run-1" / "campaigns").exists() + ) + document = json.loads(path.read_text()) + valid = json.dumps(document) + document["receipts"][0]["runId"] = "tampered" + path.write_text(json.dumps(document)) + with self.assertRaises(BudgetReceiptError): + budget_summary(self.solution, self.campaign_id) + document = json.loads(valid) + document["schemaVersion"] = "2.0.0" + path.write_text(json.dumps(document)) + with self.assertRaises(BudgetReceiptError): + budget_summary(self.solution, self.campaign_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_connector_metadata.py b/Tools/SentinelToXDRMigration/tests/test_connector_metadata.py new file mode 100644 index 00000000000..c6867d60e4b --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_connector_metadata.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import copy +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml +from jsonschema import Draft202012Validator + +from sentinel_xdr_migration.converter import ( + XDR_SCHEMA_PATH, + build_xdr_document, + convert_solution, + validate_document, + validate_solution, +) +from sentinel_xdr_migration.deployment import graph_detection_payload + +import test_detection_identity as identity +from test_converter import RULE + + +CONNECTORS = [ + { + "connectorId": "MicrosoftThreatProtection", + "dataTypes": ["IdentityInfo", "DeviceEvents", "IdentityInfo"], + "extension": {"labels": ["CaseSensitive"], "enabled": False}, + }, + {"connectorId": "OtherConnector", "dataTypes": []}, +] +ABSENT = object() + + +class ConnectorMetadataTests(unittest.TestCase): + def setUp(self): + self.fixture = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.fixture.cleanup) + self.solution = Path(self.fixture.name) + self.source = self.solution / "Analytic Rules/Rule.yaml" + self.source.parent.mkdir() + self.output = self.solution / "XDR Detections/Rule.yaml" + + def write_source(self, connectors=ABSENT): + source = yaml.safe_load(RULE) + if connectors is not ABSENT: + source["requiredDataConnectors"] = copy.deepcopy(connectors) + self.source.write_text(yaml.safe_dump(source, sort_keys=False)) + return source + + def test_present_empty_and_absent_round_trip_and_reconversion(self): + for connectors in (CONNECTORS, [], ABSENT): + with self.subTest(connectors=connectors): + self.write_source(connectors) + source_bytes = self.source.read_bytes() + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(1, result["converted"]) + document = yaml.safe_load(self.output.read_text()) + self.assertEqual(connectors is not ABSENT, "requiredDataConnectors" in document) + if connectors is not ABSENT: + self.assertEqual(connectors, document["requiredDataConnectors"]) + self.assertEqual([], validate_document(document)) + self.assertEqual(source_bytes, self.source.read_bytes()) + self.assertEqual(f"xdr-{identity.SOURCE_ID}", document["properties"]["id"]) + self.assertEqual("disabled", document["properties"]["status"]) + document["version"] = "3.1.2" + self.output.write_text(yaml.safe_dump(document, sort_keys=False)) + self.assertEqual(0, convert_solution(self.solution, overwrite=True)["conflicts"]) + self.assertEqual("3.1.2", yaml.safe_load(self.output.read_text())["version"]) + after = self.output.read_bytes() + self.assertEqual(0, convert_solution(self.solution)["conflicts"]) + self.assertEqual(after, self.output.read_bytes()) + + def test_refreshing_older_output_requires_explicit_overwrite(self): + self.write_source() + convert_solution(self.solution) + before = self.output.read_bytes() + self.write_source(CONNECTORS) + self.assertEqual(1, convert_solution(self.solution)["conflicts"]) + self.assertEqual(before, self.output.read_bytes()) + self.assertEqual(0, convert_solution(self.solution, overwrite=True)["conflicts"]) + self.assertEqual(CONNECTORS, yaml.safe_load(self.output.read_text())["requiredDataConnectors"]) + + def test_metadata_is_deep_copied_without_mutating_source_aliases(self): + source = self.write_source(CONNECTORS) + source["requiredDataConnectors"].append(source["requiredDataConnectors"][0]) + expected = copy.deepcopy(source) + with mock.patch("sentinel_xdr_migration.converter.yaml.safe_load", return_value=source): + document = build_xdr_document(self.source, self.solution, {}) + self.assertEqual(expected, source) + self.assertEqual(source["requiredDataConnectors"], document["requiredDataConnectors"]) + document["requiredDataConnectors"][0]["extension"]["labels"].append("changed") + document["requiredDataConnectors"][1]["dataTypes"].append("changed") + self.assertEqual(expected, source) + + def test_malformed_source_is_preserved_but_never_reported_converted(self): + malformed = ( + None, False, "connector", {}, [None], ["connector"], [{}], + [{"connectorId": "x"}], [{"dataTypes": []}], + [{"connectorId": 1, "dataTypes": []}], + [{"connectorId": "x", "dataTypes": None}], + [{"connectorId": "x", "dataTypes": "DeviceEvents"}], + [{"connectorId": "x", "dataTypes": [1]}], + ) + for value in malformed: + with self.subTest(value=value): + self.write_source(value) + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(0, result["converted"]) + self.assertEqual(1, result["needsReview"]) + self.assertTrue(any("requiredDataConnectors" in error + for error in result["results"][0]["errors"])) + document = yaml.safe_load(self.output.read_text()) + self.assertEqual(value, document["requiredDataConnectors"]) + self.assertTrue(any(error.startswith("schema:") for error in validate_document(document))) + self.assertEqual(1, validate_solution(self.solution)["invalid"]) + with self.assertRaises(ValueError): + graph_detection_payload(document) + + def test_schema_rejects_malformed_authored_metadata_without_conversion_errors(self): + self.write_source() + document = build_xdr_document(self.source, self.solution, {}) + schema = json.loads(XDR_SCHEMA_PATH.read_text()) + Draft202012Validator.check_schema(schema) + document["requiredDataConnectors"] = [{"connectorId": [], "dataTypes": {}}] + self.assertTrue(list(Draft202012Validator(schema).iter_errors(document))) + self.assertTrue(any(error.startswith("schema:") for error in validate_document(document))) + + def test_graph_payload_excludes_authoring_metadata_and_has_no_aliases(self): + self.write_source(CONNECTORS) + document = build_xdr_document(self.source, self.solution, {}) + before = copy.deepcopy(document) + payload = graph_detection_payload(document) + self.assertEqual(before, document) + self.assertNotIn("requiredDataConnectors", payload) + self.assertNotIn("contentProvenance", payload) + self.assertEqual(f"xdr-{identity.SOURCE_ID}", payload["id"]) + self.assertEqual("disabled", payload["status"]) + payload["queryCondition"]["queryText"] = "changed" + self.assertEqual(before, document) + + +@unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") +class PackagedConnectorMetadataTests(unittest.TestCase): + setUp = identity.PackagedIdentityTests.setUp + run_ps = identity.PackagedIdentityTests.run_ps + prepare_integration = identity.PackagedIdentityTests.prepare_integration + package = identity.PackagedIdentityTests.package + write_metadata = identity.PackagedIdentityTests.write_metadata + fixture = identity.PackagedIdentityTests.fixture + + def test_authoring_metadata_does_not_leak_into_install_or_registration(self): + entry, path, document = self.fixture() + document["requiredDataConnectors"] = CONNECTORS + path.write_text("---\n" + json.dumps(document)) + before = path.read_bytes() + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + install = next(r for r in template["resources"] if r["type"] == "Microsoft.Resources/deployments" + and r.get("condition") == "[parameters('DeployCustomDetection')]") + registration = next(r for r in template["resources"] + if r.get("properties", {}).get("contentKind") == "CustomDetection") + for wrapper in (install, registration): + self.assertNotIn("requiredDataConnectors", json.dumps(wrapper)) + self.assertNotIn("OtherConnector", json.dumps(wrapper)) + self.assertEqual(before, path.read_bytes()) + self.assertEqual(CONNECTORS, yaml.safe_load(path.read_text())["requiredDataConnectors"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_content_deployment_parameters.py b/Tools/SentinelToXDRMigration/tests/test_content_deployment_parameters.py new file mode 100644 index 00000000000..e5def24ba15 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_content_deployment_parameters.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import copy +import itertools +import json +import shutil +import unittest +import zipfile + +import test_customer_usage_attribution as attribution +from sentinel_xdr_migration.packaging import _deployment_parameters + + +CONTRACT = json.loads( + (attribution.PACKAGER / "common/contentDeploymentParameters.json").read_text() +) +FIVE = ( + "DeployAnalyticsRule", "DeployPlaybook", "DeployWorkbook", + "DeployDataConnector", "DeployCustomDetection", +) +PREFIX = "Microsoft.OperationalInsights/workspaces/providers/" + + +class ContentDeploymentDescriptionTests(unittest.TestCase): + def test_sentinel_content_descriptions_use_microsoft_branding(self): + expected = { + "DeployAnalyticsRule": ( + "Deploy the Microsoft Sentinel analytic rules carried by this solution " + "(ContentKind AnalyticsRule and its AnalyticsRuleTemplate registration face)." + ), + "DeployWorkbook": ( + "Deploy the Microsoft Sentinel workbook content and its Content Hub registration " + "carried by this solution (ContentKind Workbook and WorkbookTemplate)." + ), + "DeployHuntingQuery": "Deploy the Microsoft Sentinel hunting queries carried by this solution.", + "DeployInvestigationQuery": ( + "Deploy the Microsoft Sentinel investigation queries carried by this solution." + ), + "DeployAutomationRule": "Deploy the Microsoft Sentinel automation rules carried by this solution.", + } + self.assertEqual( + expected, + {name: CONTRACT[name]["description"] for name in expected}, + ) + + +def registration(kind, name): + return { + "type": PREFIX + "contentTemplates", "apiVersion": "2023-04-01-preview", + "name": name, "dependsOn": ["package"], + "properties": {"contentKind": kind, "mainTemplate": { + "parameters": {"innerOnly": {"type": "bool", "defaultValue": True}}, + "variables": {"query": "print Test = 1"}, + "resources": [{"name": "inner", "type": "Synthetic/content", + "condition": "[parameters('innerOnly')]", "properties": {"id": name}}], + }}, + } + + +@unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") +class ContentDeploymentTests(unittest.TestCase): + setUp = attribution.AttributionTests.setUp + run_ps = attribution.AttributionTests.run_ps + prepare_integration = attribution.AttributionTests.prepare_integration + package = attribution.AttributionTests.package + write_metadata = attribution.AttributionTests.write_metadata + + def apply_contract(self, template): + source = self.repository / "selection-input.json" + source.write_text(json.dumps(template)) + result = self.run_ps( + f". {attribution.ps_literal(attribution.PACKAGER / 'common/contentDeploymentParameters.ps1')}; " + f"$template = Get-Content -Raw {attribution.ps_literal(source)} | ConvertFrom-Json; " + "$ui = [pscustomobject]@{parameters=[pscustomobject]@{steps=@();outputs=[pscustomobject]@{}}}; " + "Add-SolutionContentDeploymentParameters -Template $template -UiDefinition $ui; " + "@{template=$template;ui=$ui} | ConvertTo-Json -Depth 100" + ) + return result + + def test_five_parameter_matrix_preserves_every_resource_and_scope(self): + families = {} + for parameter in FIVE: + for kind in CONTRACT[parameter]["kinds"]: + families[kind] = parameter + resources = [registration(kind, kind) for kind in families] + for suffix in ("dataConnectors", "dataConnectorDefinitions"): + resources.append({"type": PREFIX + suffix, "name": suffix, "properties": {}}) + families[suffix] = "DeployDataConnector" + resources.append({ + "type": PREFIX + "metadata", "name": "connector-metadata", + "dependsOn": ["dataConnectors"], + "properties": {"kind": "DataConnector", "parentId": "dataConnectors"}, + }) + families["connector-metadata"] = "DeployDataConnector" + install = { + "name": "cd-install", "type": "Microsoft.Resources/deployments", + "dependsOn": ["package"], "properties": { + "mode": "Incremental", "expressionEvaluationOptions": {"scope": "inner"}, + "template": {"languageVersion": "2.0", + "imports": {"MicrosoftSecurity": {"provider": "MicrosoftSecurity", "version": "1.0.0"}}, + "resources": {"detectionRule": { + "import": "MicrosoftSecurity", "type": "Microsoft.Security/detectionRules@2025-06-01", + "properties": {"id": "11111111-2222-3333-4444-555555555555", + "status": "disabled", "queryCondition": {"queryText": "print Test=1"}}, + }}}, + }, + } + resources.append(install) + families["cd-install"] = "DeployCustomDetection" + package = {"name": "package", "type": PREFIX + "contentPackages", + "properties": {"contentKind": "Solution", "dependencies": { + "operator": "AND", "criteria": [{"kind": "AnalyticsRule", "contentId": "id"}], + }}} + resources.append(package) + original = {"parameters": {"workspace": {"type": "string"}}, + "variables": {"sentinel": {"id": "untouched"}, "array": [1, 2]}, + "resources": resources} + result = self.apply_contract(original) + self.assertEqual(0, result.returncode, result.stderr) + output = json.loads(result.stdout) + generated = output["template"] + self.assertEqual(original["variables"], generated["variables"]) + self.assertEqual(set(FIVE), set(generated["parameters"]) - {"workspace"}) + self.assertEqual( + {name: CONTRACT[name]["defaultValue"] for name in FIVE}, + _deployment_parameters(generated, output["ui"], generated["parameters"], has_xdr=True), + ) + for before, after in zip(resources, generated["resources"], strict=True): + body = copy.deepcopy(after) + if before["name"] in families: + self.assertEqual(f"[parameters('{families[before['name']]}')]", body.pop("condition")) + self.assertEqual(before, body) + + # Exhaustive ARM boolean selection truth table, comparing resource identities, + # not counts. Nested stored content is never evaluated in the outer scope. + for flags in itertools.product((False, True), repeat=5): + values = dict(zip(FIVE, flags, strict=True)) + expected = {"package"} | {name for name, family in families.items() if values[family]} + actual = { + resource["name"] for resource in generated["resources"] + if "condition" not in resource or values[resource["condition"][13:-3]] + } + self.assertEqual(expected, actual, values) + self.assertEqual(values["DeployAnalyticsRule"], "AnalyticsRule" in actual) + self.assertEqual(values["DeployCustomDetection"], "cd-install" in actual) + self.assertEqual("CustomDetection" in actual, "cd-install" in actual) + + def test_existing_conditions_and_child_scope_are_composed_not_replaced(self): + resources = [] + for index, condition in enumerate(( + True, False, "[equals(parameters('existing'), 'a[b]')]", + )): + item = registration("Playbook", str(index)) + item["condition"] = condition + item["resources"] = [{"name": "child", "type": "child", + "condition": "[parameters('existing')]"}] + resources.append(item) + result = self.apply_contract({ + "parameters": {"existing": {"type": "bool", "defaultValue": True}}, + "variables": {}, "resources": resources, + }) + self.assertEqual(0, result.returncode, result.stderr) + generated = json.loads(result.stdout)["template"]["resources"] + for before, after, expression in zip( + resources, generated, ("true()", "false()", "equals(parameters('existing'), 'a[b]')"), strict=True, + ): + self.assertEqual(f"[and(parameters('DeployPlaybook'), {expression})]", after["condition"]) + self.assertEqual("[and(parameters('DeployPlaybook'), parameters('existing'))]", + after["resources"][0]["condition"]) + self.assertEqual(before["properties"], after["properties"]) + + def test_vocabulary_subsets_and_unclassified_resources_fail_specifically(self): + resources = [registration(kind, kind) + for entry in CONTRACT.values() for kind in entry["kinds"]] + result = self.apply_contract({"parameters": {}, "variables": {}, "resources": resources}) + self.assertEqual(0, result.returncode, result.stderr) + template = json.loads(result.stdout)["template"] + self.assertEqual(set(CONTRACT), set(template["parameters"])) + self.assertNotIn("DeployDefenderWorkbook", template["parameters"]) + for resource in ( + registration("DefenderWorkbook", "unsupported-workbook"), + registration("FutureKind", "future-kind"), + {"type": "Microsoft.Resources/deployments", "name": "unowned-deployment", "properties": {}}, + ): + result = self.apply_contract({"parameters": {}, "resources": [resource]}) + self.assertNotEqual(0, result.returncode) + self.assertIn(resource["name"], result.stderr) + self.assertIn("Unclassified V3.1", result.stderr) + + def test_real_entry_points_defaults_registration_and_legacy_isolation(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + original = json.loads(self.data.read_text()) + for configured in (attribution.ABSENT, False, True): + data = dict(original) + if configured is not attribution.ABSENT: + data["Include XDR Content Registration"] = configured + self.data.write_text(json.dumps(data)) + for pipeline in (False, True): + with self.subTest(configured=configured, pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("live provider registration support remains unverified", result.stdout) + if configured is not attribution.ABSENT: + self.assertIn("deprecated and ignored", result.stdout) + package = self.solution / "Package" + template, ui, parameters = [ + json.loads((package / name).read_text(encoding="utf-8-sig")) + for name in ("mainTemplate.json", "createUiDefinition.json", "testParameters.json") + ] + self.assertEqual( + {"DeployAnalyticsRule": True, "DeployCustomDetection": False}, + _deployment_parameters(template, ui, parameters, has_xdr=True), + ) + self.assertNotIn("E5Flavor", json.dumps(template)) + self.assertNotIn("RegisterE5Content", json.dumps(template)) + installed = next(r for r in template["resources"] + if r["type"] == "Microsoft.Resources/deployments" + and r["name"] != attribution.TRACKING_ID) + registered = next(r for r in template["resources"] + if r.get("properties", {}).get("contentKind") == "CustomDetection") + self.assertEqual(installed["condition"], registered["condition"]) + self.assertIn(f"[resourceId('Microsoft.Resources/deployments', '{installed['name']}')]", + registered["dependsOn"]) + self.assertFalse(any(registered["name"] in dep for dep in installed["dependsOn"])) + for body in (installed["properties"]["template"], registered["properties"]["mainTemplate"]): + self.assertEqual("2.0", body["languageVersion"]) + self.assertEqual("1.0.0", body["imports"]["MicrosoftSecurity"]["version"]) + self.assertEqual("disabled", body["resources"]["detectionRule"]["properties"]["status"]) + self.assertNotIn("Deploy", json.dumps(body)) + self.assertIn(attribution.AttributionTests.marker(attribution.TRACKING_ID), template["resources"]) + with zipfile.ZipFile(package / "3.1.0.zip") as archive: + self.assertEqual(template, json.loads(archive.read("mainTemplate.json"))) + self.assertEqual(ui, json.loads(archive.read("createUiDefinition.json"))) + original["XDR Detections"] = [] + self.data.write_text(json.dumps(original)) + for pipeline in (False, True): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + self.assertIn("DeployAnalyticsRule", template["parameters"]) + self.assertNotIn("DeployCustomDetection", template["parameters"]) + legacy = entry.with_name("legacy.ps1") + shutil.copyfile(attribution.PACKAGER / "V3/createSolutionV3.ps1", legacy) + result = self.package(legacy, False, version_bump="patch") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + self.assertFalse(set(CONTRACT) & template["parameters"].keys()) + self.assertTrue(all("condition" not in r for r in template["resources"])) + + def test_custom_detection_dependencies_use_registration_identity_and_version(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + data = json.loads(self.data.read_text()) + source_rule = json.loads( + (self.solution / "Analytic Rules/Test.yaml").read_text().split("\n", 1)[1] + ) + source_detection = json.loads( + (self.solution / "XDR Detections/Test.yaml").read_text().split("\n", 1)[1] + ) + identities = [ + ("11111111-2222-3333-4444-555555555555", "3.1.0", "Test"), + ("22222222-2222-3333-4444-555555555555", "3.1.1", "Second"), + ("33333333-2222-3333-4444-555555555555", "3.1.2", "Third"), + ] + analytic_paths = [] + detection_paths = [] + for content_id, version, stem in identities: + rule = copy.deepcopy(source_rule) + rule.update(id=content_id, name=f"{stem} rule") + rule_path = self.solution / f"Analytic Rules/{stem}.yaml" + rule_path.write_text("---\n" + json.dumps(rule)) + analytic_paths.append(str(rule_path.relative_to(self.solution))) + + detection = copy.deepcopy(source_detection) + detection["version"] = version + detection["contentProvenance"]["source"]["id"] = content_id + detection["properties"]["id"] = content_id + detection["properties"]["displayName"] = f"{stem} detection" + detection_path = self.solution / f"XDR Detections/{stem}.yaml" + detection_path.write_text("---\n" + json.dumps(detection)) + detection_paths.append(str(detection_path.relative_to(self.solution))) + + data["Analytic Rules"] = analytic_paths + data["XDR Detections"] = [] + self.data.write_text(json.dumps(data)) + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + sentinel_only = json.loads( + (self.solution / "Package/mainTemplate.json").read_text(encoding="utf-8-sig") + ) + package = next( + resource for resource in sentinel_only["resources"] + if resource["type"].endswith("/contentPackages") + ) + sentinel_dependencies = package["properties"]["dependencies"] + self.assertEqual("AND", sentinel_dependencies["operator"]) + self.assertFalse(any( + criterion["kind"] == "CustomDetection" + for criterion in sentinel_dependencies["criteria"] + )) + + data["XDR Detections"] = detection_paths + self.data.write_text(json.dumps(data)) + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + hybrid = json.loads( + (self.solution / "Package/mainTemplate.json").read_text(encoding="utf-8-sig") + ) + package = next( + resource for resource in hybrid["resources"] + if resource["type"].endswith("/contentPackages") + ) + dependencies = package["properties"]["dependencies"] + registrations = [ + resource for resource in hybrid["resources"] + if resource.get("properties", {}).get("contentKind") == "CustomDetection" + ] + custom_detection_criteria = [ + criterion for criterion in dependencies["criteria"] + if criterion["kind"] == "CustomDetection" + ] + self.assertEqual("AND", dependencies["operator"]) + self.assertEqual( + sentinel_dependencies["criteria"], + dependencies["criteria"][:-len(custom_detection_criteria)], + ) + self.assertEqual(3, len(custom_detection_criteria)) + self.assertEqual( + [ + { + "kind": "CustomDetection", + "contentId": registration["properties"]["contentId"], + "version": registration["properties"]["version"], + } + for registration in registrations + ], + custom_detection_criteria, + ) + self.assertEqual( + [(f"xdr-{content_id}", version) for content_id, version, _ in identities], + [(item["contentId"], item["version"]) for item in custom_detection_criteria], + ) + + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + regenerated = json.loads( + (self.solution / "Package/mainTemplate.json").read_text(encoding="utf-8-sig") + ) + regenerated_package = next( + resource for resource in regenerated["resources"] + if resource["type"].endswith("/contentPackages") + ) + self.assertEqual( + dependencies["criteria"], + regenerated_package["properties"]["dependencies"]["criteria"], + ) + + def test_python_rejects_legacy_stale_or_unsupported_selections(self): + template = {"parameters": {"DeployAnalyticsRule": {"type": "bool", "defaultValue": True}}} + ui = {"parameters": {"outputs": {"DeployAnalyticsRule": "[steps('contentSelection').DeployAnalyticsRule]"}, + "steps": [{"name": "contentSelection", "elements": [{ + "name": "DeployAnalyticsRule", "type": "Microsoft.Common.CheckBox", "defaultValue": True, + }]}]}} + self.assertEqual({"DeployAnalyticsRule": True}, + _deployment_parameters(template, ui, template["parameters"], has_xdr=False)) + with self.assertRaisesRegex(RuntimeError, "testParameters"): + _deployment_parameters(template, ui, {}, has_xdr=False) + with self.assertRaisesRegex(RuntimeError, "UI selections"): + _deployment_parameters(template, {}, template["parameters"], has_xdr=False) + template["parameters"]["E5Flavor"] = {"type": "bool", "defaultValue": False} + with self.assertRaisesRegex(RuntimeError, "obsolete"): + _deployment_parameters(template, ui, template["parameters"], has_xdr=False) + + def test_real_mixed_emitters_share_only_the_applicable_five_controls(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + data = json.loads(self.data.read_text()) + fixtures = { + "Workbooks/Test.json": {"version": "Notebook/1.0", "items": [], "styleSettings": {}}, + "Workbooks/WorkbooksMetadata.json": [{ + "templateRelativePath": "Test.json", "workbookKey": "Test", + "title": "Test", "description": "Test workbook", "version": "1.0.0", + "dataTypesDependencies": [], "dataConnectorsDependencies": [], + }], + "Data Connectors/Test.json": { + "id": "TestConnector", "title": "Test connector", "publisher": "Test", + "descriptionMarkdown": "Test connector", "graphQueries": [], "dataTypes": [], + "connectivityCriterias": [], "sampleQueries": [], "availability": {"isPreview": False}, + "permissions": {"resourceProvider": []}, "instructionSteps": [], + }, + } + for name, resource_type in ( + ("Workflow", "Microsoft.Logic/workflows"), + ("Connector", "Microsoft.Web/customApis"), + ("Function", "Microsoft.Web/sites"), + ): + fixtures[f"Playbooks/{name}/azuredeploy.json"] = { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": {"title": name, "description": "Test"}, + "parameters": { + "FunctionAppName": {"type": "string", "defaultValue": "TestFunction"}, + "ConnectorName": {"type": "string", "defaultValue": "TestConnector"}, + }, + "variables": {}, + "resources": [{ + "type": resource_type, + "name": "[parameters('ConnectorName')]" if name == "Connector" else name, + "apiVersion": "2019-05-01", + "location": "[resourceGroup().location]", "tags": {"hidden-SentinelTemplateVersion": "1.0.0"}, + "properties": {"definition": {"triggers": {}, "actions": {}}}, + }], + } + for relative, document in fixtures.items(): + path = self.solution / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document)) + # The existing workbook emitter reads metadata relative to repositoryBasePath. + # Supply that fixture too; this parameter-only change does not alter path resolution. + workbook_metadata = self.repository / "Workbooks/WorkbooksMetadata.json" + workbook_metadata.parent.mkdir() + workbook_metadata.write_text(json.dumps(fixtures["Workbooks/WorkbooksMetadata.json"])) + data.update({ + "Workbooks": ["Workbooks/Test.json"], "Data Connectors": ["Data Connectors/Test.json"], + "Playbooks": [name for name in fixtures if name.startswith("Playbooks/")], + }) + self.data.write_text(json.dumps(data)) + for pipeline in (False, True): + with self.subTest(pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + package = self.solution / "Package" + template, ui, parameters = [ + json.loads((package / name).read_text(encoding="utf-8-sig")) + for name in ("mainTemplate.json", "createUiDefinition.json", "testParameters.json") + ] + self.assertEqual( + {name: CONTRACT[name]["defaultValue"] for name in FIVE}, + _deployment_parameters(template, ui, parameters, has_xdr=True), + ) + for resource in template["resources"]: + kind = resource.get("properties", {}).get("contentKind") + if kind in ("Playbook", "LogicAppsCustomConnector", "AzureFunction"): + self.assertEqual("[parameters('DeployPlaybook')]", resource["condition"]) + if resource["type"] in (PREFIX + "dataConnectors", PREFIX + "metadata"): + self.assertEqual("[parameters('DeployDataConnector')]", resource["condition"]) + self.assertEqual( + {"Playbook", "LogicAppsCustomConnector", "AzureFunction"}, + {r.get("properties", {}).get("contentKind") for r in template["resources"] + if r.get("condition") == "[parameters('DeployPlaybook')]"}, + ) + + def test_first_authored_tactic_only_in_both_arm_copies_without_source_mutation(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + path = self.solution / "XDR Detections/Test.yaml" + document = json.loads(path.read_text().removeprefix("---\n")) + document["properties"]["detectionAction"]["alertTemplate"]["customDetails"] = { + "AdditionalEvidence": "Test", + } + tactics = [ + {"tactic": "Execution", "techniques": [ + {"technique": "T1059", "subTechniques": ["T1059.003", "T1059.001"]}, + {"technique": "T1047"}, + ]}, + {"tactic": "Persistence", "techniques": [{"technique": "T1547", "subTechniques": ["T1547.001"]}]}, + {"tactic": "Discovery", "techniques": [{"technique": "T1082"}]}, + ] + for authored in (tactics[:1], tactics[:2], tactics, list(reversed(tactics))): + document["properties"]["detectionAction"]["alertTemplate"]["tactics"] = authored + document["contentProvenance"]["originalTactics"] = copy.deepcopy(authored) + path.write_text("---\n" + json.dumps(document)) + original = {p: p.read_bytes() for p in ( + path, self.solution / "Analytic Rules/Test.yaml", self.data, self.metadata, + )} + for pipeline in (False, True): + with self.subTest(authored=authored, pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + if len(authored) > 1: + self.assertIn(f"first authored tactic '{authored[0]['tactic']}'", result.stdout) + self.assertIn("omitted tactics: " + ", ".join(t["tactic"] for t in authored[1:]), + result.stdout) + self.assertIn("not a recommended classification", result.stdout) + else: + self.assertNotIn("V3.1 TACTIC SELECTION:", result.stdout) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + bodies = [] + for resource in template["resources"]: + if resource["type"] == "Microsoft.Resources/deployments" and resource.get("condition"): + bodies.append(resource["properties"]["template"]) + elif resource.get("properties", {}).get("contentKind") == "CustomDetection": + bodies.append(resource["properties"]["mainTemplate"]) + self.assertEqual(2, len(bodies)) + expected = copy.deepcopy(document["properties"]) + expected["id"] = ( + "xdr-" + document["contentProvenance"]["source"]["id"] + ) + expected["id"] = f"[if(true(), '{expected['id']}', '{expected['id']}')]" + expected["detectionAction"]["alertTemplate"]["tactics"] = [authored[0]] + for body in bodies: + self.assertEqual(expected, body["resources"]["detectionRule"]["properties"]) + self.assertEqual(original, {p: p.read_bytes() for p in original}) + + def test_invalid_tactic_lists_are_rejected_before_any_payload_selection(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + path = self.solution / "XDR Detections/Test.yaml" + document = json.loads(path.read_text().removeprefix("---\n")) + for tactics in (None, [], "Execution", ["Execution"], [{"tactic": ""}], + [{"tactic": "Execution"}, {"tactic": "Persistence", "techniques": ["T1547"]}]): + document["properties"]["detectionAction"]["alertTemplate"]["tactics"] = tactics + path.write_text("---\n" + json.dumps(document)) + with self.subTest(tactics=tactics): + result = self.package(entry, False) + self.assertNotEqual(0, result.returncode) + self.assertIn("Custom Detection", result.stderr) + self.assertFalse((self.solution / "Package/mainTemplate.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py b/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py new file mode 100644 index 00000000000..4729f41521c --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import io +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +from sentinel_xdr_migration.converter import ( + ConversionResult, + ENTITY_MAP, + assess_schedule, + build_xdr_document, + convert_entities, + convert_query, + constrain_event_window, + validate_document, +) + + +def entity(kind: str, **fields: str) -> dict: + return { + "entityType": kind, + "fieldMappings": [{"identifier": key, "columnName": value} for key, value in fields.items()], + } + + +HOST_QUERY = """DeviceEvents +| project DeviceName +| extend HostName = iff(DeviceName has '.', substring(DeviceName, 0, indexof(DeviceName, '.')), DeviceName) +| extend DnsDomain = iff(DeviceName has '.', substring(DeviceName, indexof(DeviceName, '.') + 1), "") +""" + +LOG_DELETION_QUERY = """// Look for use of wevtutil to clear multiple logs +DeviceProcessEvents +| where ProcessCommandLine has "WEVTUTIL" and ProcessCommandLine has "CL" +| summarize LogClearCount = dcount(tostring(ProcessCommandLine)), ClearedLogList = make_set(ProcessCommandLine, 100000) by DeviceId, DeviceName, bin(TimeGenerated, 5m) +| where LogClearCount > 10 +| extend HostName = iff(DeviceName has '.', substring(DeviceName, 0, indexof(DeviceName, '.')), DeviceName) +| extend DnsDomain = iff(DeviceName has '.', substring(DeviceName, indexof(DeviceName, '.') + 1), "") +""" + + +class EntitySemanticsTests(unittest.TestCase): + def convert(self, mappings: list[dict], query: str, renames: dict | None = None): + information: list[str] = [] + output, warnings = convert_entities( + {"entityMappings": mappings}, query, renames, informational=information, + ) + return output, warnings, information + + def test_added_direct_contracts(self): + cases = [ + ("DNS", "DomainName", "dns", "domainNameColumn"), + ("File", "Name", "files", "nameColumn"), + ("MailCluster", "Query", "mailClusters", "queryColumn"), + ("MailCluster", "Source", "mailClusters", "sourceColumn"), + ("RegistryValue", "Name", "registryValues", "valueNameColumn"), + ("SecurityGroup", "DistinguishedName", "securityGroups", "distinguishedNameColumn"), + ("SecurityGroup", "SID", "securityGroups", "sidColumn"), + ("SecurityGroup", "ObjectGuid", "securityGroups", "objectIdColumn"), + ] + for kind, identifier, collection, field in cases: + with self.subTest(kind=kind, identifier=identifier): + output, warnings, information = self.convert( + [entity(kind, **{identifier: "Evidence"})], + "DeviceEvents | project Evidence", + ) + self.assertEqual(output[collection][0][field], "Evidence") + self.assertEqual(warnings, []) + self.assertEqual(information, []) + + def test_all_direct_targets_follow_verified_graph_contracts(self): + contracts = { + "hosts": {"deviceIdColumn", "dnsDomainColumn", "nameColumn", "netBiosNameColumn", "ntDomainColumn"}, + "accounts": {"aadUserIdColumn", "dnsDomainColumn", "nameColumn", "ntDomainColumn", "sidColumn", "upnColumn", "upnSuffixColumn"}, + "ips": {"addressColumn"}, "urls": {"addressColumn"}, + "azureResources": {"resourceIdColumn"}, + "cloudApplications": {"appIdColumn", "nameColumn"}, + "mailboxes": {"primaryAddressColumn"}, + "mailMessages": {"networkMessageIdColumn", "recipientColumn", "senderColumn", "subjectColumn"}, + "dns": {"domainNameColumn", "hostIpAddressColumn", "serverIpColumn"}, + "files": {"nameColumn", "sha1Column", "sha256Column"}, + "processes": {"sha1Column", "sha256Column"}, + "mailClusters": {"queryColumn", "sourceColumn"}, + "registryValues": {"keyColumn", "valueNameColumn"}, + "securityGroups": {"distinguishedNameColumn", "objectIdColumn", "sidColumn"}, + } + for collection, fields in ENTITY_MAP.values(): + self.assertLessEqual(set(fields.values()), contracts[collection]) + + def test_internal_references_and_unrepresentable_fields_warn(self): + for kind, identifier in [ + ("DNS", "DnsServerIp"), ("DNS", "HostIpAddress"), + ("File", "Directory"), ("File", "FileHashes"), + ("RegistryValue", "Key"), ("RegistryValue", "Value"), + ("Process", "ProcessId"), ("Process", "ImageFile"), ("Process", "CommandLine"), + ("Host", "AzureID"), ("SecurityGroup", "Name"), + ]: + with self.subTest(kind=kind, identifier=identifier): + output, warnings, info = self.convert( + [entity(kind, **{identifier: "Evidence"})], "DeviceEvents | project Evidence", + ) + self.assertFalse(output) + self.assertTrue(any(f"converter cannot map {kind}.{identifier}" in value for value in warnings)) + self.assertEqual(info, []) + + def test_missing_final_columns_and_configured_column_mapping(self): + for query in ("DeviceEvents | project Other", "DeviceEvents | extend Evidence=Other | project Other"): + output, warnings, _ = self.convert([entity("File", Name="Evidence")], query) + self.assertFalse(output) + self.assertIn("not produced by the query", warnings[0]) + output, warnings, _ = self.convert( + [entity("File", Name="Original")], "DeviceEvents | project Renamed", {"Original": "Renamed"}, + ) + self.assertEqual("Renamed", output["files"][0]["nameColumn"]) + self.assertFalse(warnings) + + def test_hash_algorithm_is_proven_from_query_not_column_spelling(self): + for algorithm, target in [("SHA1", "sha1Column"), ("SHA256", "sha256Column")]: + output, warnings, info = self.convert( + [entity("FileHash", Algorithm="Algorithm", Value="Hash")], + f'DeviceEvents | extend Algorithm="{algorithm}" | project Algorithm, Hash', + ) + self.assertEqual("Hash", output["files"][0][target]) + self.assertFalse(warnings) + self.assertEqual(1, len(info)) + for query in [ + 'DeviceEvents | extend Algorithm="MD5" | project Algorithm, Hash', + "DeviceEvents | project Algorithm, Hash", + 'DeviceEvents | extend Algorithm=iff(Flag, "SHA1", "SHA256") | project Algorithm, Hash', + 'DeviceEvents | extend Algorithm="SHA1" | extend Algorithm=Other | project Algorithm, Hash', + 'DeviceEvents | extend Algorithm="SHA1" | project Algorithm', + 'DeviceEvents | project Algorithm, Hash // Algorithm="SHA1"', + 'DeviceEvents | extend Algorithm="SHA1" | union OtherTable', + 'DeviceEvents | extend Algorithm="SHA1"', + ]: + with self.subTest(query=query): + output, warnings, info = self.convert( + [entity("FileHash", Algorithm="Algorithm", Value="Hash")], query, + ) + self.assertFalse(output) + self.assertTrue(any("converter cannot map FileHash.Algorithm/Value" in warning for warning in warnings)) + self.assertFalse(info) + + def test_deimos_fullname_decomposition_is_informational(self): + output, warnings, info = self.convert( + [entity("Host", FullName="DeviceName", HostName="HostName", DnsDomain="DnsDomain")], + HOST_QUERY, + ) + self.assertEqual("HostName", output["hosts"][0]["nameColumn"]) + self.assertNotIn("fullNameColumn", output["hosts"][0]) + self.assertFalse(warnings) + self.assertIn("safely represented", info[0]) + + def test_fullname_loss_or_conflicts_remain_warnings(self): + for query in [ + "DeviceEvents | project DeviceName, HostName, DnsDomain", + HOST_QUERY.replace("indexof(DeviceName, '.') + 1", "indexof(DeviceName, '.') + 2"), + HOST_QUERY + "| extend HostName=Other", + HOST_QUERY + "| extend DeviceName=Other", + HOST_QUERY + "| project DeviceName, DnsDomain", + HOST_QUERY + "| union OtherTable", + "DeviceEvents | project DeviceName, HostName, DnsDomain // " + HOST_QUERY.replace("\n", " "), + ]: + with self.subTest(query=query): + _, warnings, info = self.convert( + [entity("Host", FullName="DeviceName", HostName="HostName", DnsDomain="DnsDomain")], query, + ) + self.assertTrue(any( + "Best-effort entity mapping requires review:" in value + or "converter cannot map Host.FullName" in value + for value in warnings + )) + self.assertFalse(info) + + def test_fullname_decomposition_after_preserving_aggregation(self): + for query in ( + LOG_DELETION_QUERY, + LOG_DELETION_QUERY.replace("TimeGenerated", "Timestamp"), + HOST_QUERY + "| summarize Count=count() by DeviceName, HostName, DnsDomain", + ): + with self.subTest(query=query): + output, warnings, info = self.convert( + [entity("Host", FullName="DeviceName", HostName="HostName", DnsDomain="DnsDomain")], query, + ) + self.assertEqual("HostName", output["hosts"][0]["nameColumn"]) + self.assertEqual("DnsDomain", output["hosts"][0]["dnsDomainColumn"]) + self.assertFalse(warnings) + self.assertIn("safely represented", info[0]) + + def test_destructive_aggregation_overwrites_and_joins_do_not_prove_fullname(self): + for query in ( + LOG_DELETION_QUERY.replace("by DeviceId, DeviceName,", "by DeviceId,"), + LOG_DELETION_QUERY.replace("by DeviceId, DeviceName,", "by DeviceId, DeviceName=tostring(Other),"), + LOG_DELETION_QUERY.replace("by DeviceId, DeviceName,", "by DeviceId, tolower(DeviceName),"), + LOG_DELETION_QUERY + "| extend DeviceName=Other", + LOG_DELETION_QUERY + "| extend HostName=Other", + LOG_DELETION_QUERY + "| join kind=inner (DeviceInfo) on DeviceName", + HOST_QUERY + "| summarize Count=count() by DeviceName", + HOST_QUERY + "| summarize HostName=take_any(HostName) by DeviceName, DnsDomain", + HOST_QUERY + "| summarize Count=count() by HostName, DnsDomain | project DeviceName, HostName, DnsDomain", + HOST_QUERY.replace("| project DeviceName", "| project Other | project DeviceName"), + ): + with self.subTest(query=query): + _, warnings, info = self.convert( + [entity("Host", FullName="DeviceName", HostName="HostName", DnsDomain="DnsDomain")], query, + ) + self.assertTrue(any( + "Best-effort entity mapping requires review:" in value + or "converter cannot map Host.FullName" in value + for value in warnings + )) + self.assertFalse(info) + + def test_multiple_source_identifiers_do_not_silently_overwrite(self): + output, warnings, _ = self.convert( + [entity("MailMessage", Sender="Envelope", P2Sender="Header")], + "EmailEvents | project Envelope, Header", + ) + self.assertEqual("Envelope", output["mailMessages"][0]["senderColumn"]) + self.assertTrue(any("conflicting senderColumn" in value for value in warnings)) + + def test_legacy_two_value_unpacking_is_preserved(self): + output, warnings = convert_entities( + {"entityMappings": [entity("IP", Address="IPAddress")]}, "DeviceEvents", + ) + self.assertEqual("IPAddress", output["ips"][0]["addressColumn"]) + self.assertFalse(warnings) + + +class ScheduleSemanticsTests(unittest.TestCase): + def assess(self, period="4h", frequency="1h", target="PT1H", query=None, source=None): + query = query or f"DeviceEvents | where Timestamp > ago({period}) | project DeviceName" + return assess_schedule( + {"queryFrequency": frequency, "queryPeriod": period, "kind": "Scheduled"}, + source or query, query, target, + ) + + def test_documented_native_lookbacks_with_explicit_matching_event_bounds(self): + for frequency, period, target in [("1h", "4h", "PT1H"), ("3h", "12h", "PT3H"), + ("12h", "48h", "PT12H"), ("24h", "30d", "P1D")]: + with self.subTest(frequency=frequency): + warnings, info = self.assess(period, frequency, target) + self.assertFalse(warnings) + self.assertIn("frequency and explicit event-time window are preserved", info[0]) + self.assertIn("source frequency=", info[0]) + self.assertIn("https://learn.microsoft.com", info[0]) + + def test_literal_duration_or_comment_is_not_equivalence(self): + for query in [ + "DeviceEvents // 4h", + 'DeviceEvents | where ActionType == "4h"', + "DeviceEvents | where Timestamp > ago(1h) // queryPeriod 4h", + ]: + # Equal configured lookbacks may safely share the same explicit narrower bound. + warnings, info = self.assess(query=query) + if "Timestamp >" in query: + self.assertFalse(warnings) + self.assertIn("source=3600s, target=3600s", info[0]) + else: + self.assertTrue(warnings) + self.assertFalse(info) + warnings, info = self.assess("1h", query="DeviceEvents") + self.assertIn("source and native service windows differ", warnings[0]) + self.assertFalse(info) + + def test_frequency_unknown_surface_and_time_basis_remain_warnings(self): + cases = [ + {"frequency": "3h"}, {"frequency": "nonsense"}, {"period": "unknown"}, + {"query": "SecurityEvent | where TimeGenerated > ago(4h)"}, + {"query": "UnknownTable | where Timestamp > ago(4h)"}, + {"query": "union DeviceEvents, SecurityEvent | where Timestamp > ago(4h)"}, + {"query": "DeviceEvents | join kind=inner (SecurityEvent) on DeviceName"}, + {"query": "DeviceEvents | where ingestion_time() > ago(4h)"}, + {"query": "DeviceEvents | where Timestamp > ago(4h) or Flag"}, + {"query": "DeviceEvents | where Timestamp > ago(Window)"}, + {"query": "DeviceEvents | extend Timestamp=Other | where Timestamp > ago(4h)"}, + {"query": "DeviceEvents | where Timestamp > ago(2h)", + "source": "DeviceEvents | where Timestamp > ago(4h)"}, + {"frequency": "2h", "target": "PT2H"}, + {"source": "DeviceProcessEvents | where Timestamp > ago(4h)"}, + ] + for case in cases: + with self.subTest(case=case): + warnings, info = self.assess(**case) + self.assertTrue(warnings) + self.assertFalse(info) + + def test_nrt_is_not_silently_equated_with_hourly(self): + warnings, info = assess_schedule( + {"kind": "NRT", "queryPeriod": "4h"}, "DeviceEvents", "DeviceEvents", "PT1H", + ) + self.assertIn("NRT timing is not preserved", warnings[0]) + self.assertFalse(info) + + def test_native_surface_after_aggregation_reports_concrete_daily_mismatch(self): + warnings, info = self.assess( + period="1d", frequency="1d", target="P1D", + source=LOG_DELETION_QUERY, query=LOG_DELETION_QUERY.replace("TimeGenerated", "Timestamp"), + ) + self.assertFalse(info) + self.assertIn("source frequency=1d, lookback=1d", warnings[0]) + self.assertIn("target frequency=P1D, native lookback=2592000s", warnings[0]) + self.assertIn("source and native service windows differ", warnings[0]) + self.assertNotIn("unknown query surface", warnings[0]) + + def test_native_surface_is_not_a_proof_of_aggregate_time_equivalence(self): + warnings, info = self.assess( + period="30d", frequency="1d", target="P1D", + query=LOG_DELETION_QUERY.replace("TimeGenerated", "Timestamp"), + ) + self.assertFalse(info) + self.assertIn("native lookback=2592000s", warnings[0]) + self.assertIn("no explicit event-time bound proves equivalence", warnings[0]) + for suffix in ( + "| join kind=inner (SecurityEvent) on DeviceName", + "| union SecurityEvent", + "| invoke UnknownFunction()", + ): + warnings, info = self.assess(query=LOG_DELETION_QUERY + suffix) + self.assertFalse(info) + self.assertIn("unknown query surface", warnings[0]) + + def test_time_column_rename_can_preserve_window_without_rewriting(self): + warnings, info = self.assess( + source="DeviceEvents | where TimeGenerated > ago(4h)", + query="DeviceEvents | where Timestamp > ago(4h)", + ) + self.assertFalse(warnings) + self.assertTrue(info) + + def test_narrower_source_window_is_injected_before_aggregation(self): + for period, expected in (("30m", 1800), ("2h", 7200), ("3h", 10800)): + query = "DeviceEvents | summarize Count=count() by DeviceId, Timestamp" + converted, evidence, review = constrain_event_window( + {"queryPeriod": period, "queryFrequency": "1h"}, + query, query, "PT1H", + ) + with self.subTest(period=period): + self.assertIsNone(review) + self.assertIn(f"where Timestamp >= ago({period})", converted) + self.assertLess(converted.index("where Timestamp"), converted.index("summarize")) + self.assertEqual(evidence["effectiveEventWindowSeconds"], expected) + self.assertEqual(evidence["xdrServiceWindowSeconds"], 14400) + self.assertTrue(evidence["filterInjected"]) + self.assertIn("Late-ingestion risk", evidence["lateIngestionRisk"]) + + def test_timegenerated_binding_is_lexical_and_targets_timestamp(self): + query = ( + 'DeviceEvents | summarize Values=make_set("TimeGenerated") ' + "by bin(TimeGenerated, 5m) // TimeGenerated is only a source comment\n" + ) + converted, evidence, review = constrain_event_window( + {"queryPeriod": "2h", "queryFrequency": "1h"}, + query, query, "PT1H", + ) + self.assertIsNone(review) + self.assertIn("where Timestamp >= ago(2h)", converted) + self.assertIn("bin(Timestamp, 5m)", converted) + self.assertIn('"TimeGenerated"', converted) + self.assertIn("// TimeGenerated is only a source comment", converted) + self.assertEqual("TimeGenerated", evidence["sourceTimeColumn"]) + self.assertEqual("Timestamp", evidence["targetTimeColumn"]) + + def test_unsafe_native_query_shapes_require_review_without_rewrite(self): + for query in ( + "DeviceEvents | join kind=inner (DeviceInfo) on DeviceId", + "union DeviceEvents, DeviceInfo | where Timestamp > ago(2h)", + "let baseline = DeviceEvents | summarize count(); DeviceEvents | where Timestamp > ago(2h)", + ): + with self.subTest(query=query): + converted, evidence, review = constrain_event_window( + {"queryPeriod": "2h", "queryFrequency": "1h"}, + query, query, "PT1H", + ) + self.assertEqual(query, converted) + self.assertIsNone(evidence) + self.assertIn("cannot be proven equivalent", review) + + +class InformationContractTests(unittest.TestCase): + def document(self, **updates): + rule = { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Example", "query": HOST_QUERY, "queryFrequency": "1h", "queryPeriod": "4h", + "entityMappings": [entity("Host", FullName="DeviceName", HostName="HostName", DnsDomain="DnsDomain")], + } + rule.update(updates) + original_open = Path.open + source = Path("Example/Analytic Rules/Test.yaml") + + def open_input(path, *args, **kwargs): + return io.StringIO(yaml.safe_dump(rule)) if path == source else original_open(path, *args, **kwargs) + + with patch.object(Path, "open", autospec=True, side_effect=open_input): + with patch("sentinel_xdr_migration.converter.normalize_parser_bindings", side_effect=lambda query, _: (query, [])): + return build_xdr_document(source, Path("Example"), {}) + + def test_information_nonblocking_and_schema_optional(self): + document = self.document() + conversion = document["contentProvenance"]["conversion"] + self.assertTrue(conversion["informational"]) + self.assertFalse(conversion["errors"]) + self.assertFalse(conversion["reviewRequired"]) + self.assertEqual("converted", conversion["status"]) + self.assertEqual([], validate_document(document)) + conversion["informational"] = "not a list" + self.assertTrue(any("schema:" in error for error in validate_document(document))) + conversion["informational"] = [12] + self.assertTrue(any("schema:" in error for error in validate_document(document))) + conversion.pop("informational") + self.assertFalse(validate_document(document)) + + def test_information_does_not_override_tactic_or_required_asset_gates(self): + document = self.document(tactics=["Execution", "Collection"]) + self.assertEqual("converted", document["contentProvenance"]["conversion"]["status"]) + document = self.document(tactics=["Execution", "InvalidTactic"]) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual("needsReview", conversion["status"]) + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue(conversion["informational"]) + document = self.document( + query='DeviceEvents | extend Algorithm="SHA1" | project Algorithm, Hash', + entityMappings=[entity("FileHash", Algorithm="Algorithm", Value="Hash")], + ) + conversion = document["contentProvenance"]["conversion"] + self.assertTrue(conversion["informational"]) + self.assertIn("a Host, Account, Mailbox, or IP mapping is required", conversion["errors"]) + + def test_narrowed_event_window_is_recorded_without_parity_claim(self): + query = "DeviceEvents | summarize Count=count() by DeviceId, Timestamp" + document = self.document( + query=query, + queryFrequency="1h", + queryPeriod="2h", + ) + conversion = document["contentProvenance"]["conversion"] + target_query = document["properties"]["queryCondition"]["queryText"] + self.assertIn("DeviceEvents | where Timestamp >= ago(2h) | summarize", target_query) + self.assertEqual(conversion["status"], "converted") + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue(any( + "Best-effort entity mapping requires review:" in reason + for reason in conversion["reviewReasons"] + )) + window = conversion["eventTimeWindow"] + self.assertEqual(window["originalSentinelLookbackSeconds"], 7200) + self.assertEqual(window["effectiveEventWindowSeconds"], 7200) + self.assertEqual(window["xdrServiceWindowSeconds"], 14400) + self.assertTrue(window["filterInjected"]) + self.assertTrue(any( + "event-time window constrained to original Sentinel lookback" in message + for message in conversion["informational"] + )) + self.assertTrue(any("late-ingestion risk" in message for message in conversion["warnings"])) + self.assertTrue(any( + "parity is not established" in message.lower() + for message in conversion["informational"] + )) + + def test_unsafe_event_window_shape_remains_needs_review(self): + query = "DeviceEvents | join kind=inner (DeviceInfo) on DeviceId" + document = self.document(query=query, queryFrequency="1h", queryPeriod="2h") + conversion = document["contentProvenance"]["conversion"] + self.assertEqual("needsReview", conversion["status"]) + self.assertTrue(conversion["reviewRequired"]) + self.assertIn(query, document["properties"]["queryCondition"]["queryText"]) + self.assertTrue(any("source and target table inputs cannot be proven equivalent" in reason + for reason in conversion["reviewReasons"])) + + def test_configured_column_and_table_mappings_do_not_cross_join_scopes(self): + query = ( + "DeviceEvents as d | join kind=inner (SecurityEvent as s) on d.DeviceId == s.DeviceId " + "| where d.TimeGenerated > ago(2h) | project d.TimeGenerated, s.AccountUPN" + ) + converted, warnings, errors = convert_query(query, { + "tableMappings": {"DeviceEvents": "AlertEvidence"}, + "columnMappings": { + "DeviceEvents": {"TimeGenerated": "Timestamp"}, + "SecurityEvent": {"AccountUPN": "AccountUpn"}, + }, + }) + self.assertEqual(query, converted) + self.assertFalse(errors) + self.assertTrue(any("table/alias scope is ambiguous" in warning for warning in warnings)) + + def test_table_mapping_rewrites_only_the_table_token(self): + query = ( + 'CiscoASA_CL | extend Label = "CiscoASA_CL" ' + "// CiscoASA_CL is source documentation\n" + "| where TimeGenerated > ago(1h)" + ) + converted, warnings, errors = convert_query( + query, {"tableMappings": {"CiscoASA_CL": "DeviceNetworkEvents"}} + ) + self.assertTrue(converted.startswith("DeviceNetworkEvents")) + self.assertIn('"CiscoASA_CL"', converted) + self.assertIn("// CiscoASA_CL is source documentation", converted) + self.assertIn("Timestamp > ago(1h)", converted) + self.assertFalse(warnings) + self.assertFalse(errors) + + def test_result_serializes_information_without_breaking_positional_callers(self): + result = ConversionResult(Path("source"), Path("target"), "Example", "converted", False, (), (), ()) + self.assertEqual([], result.as_dict()["informational"]) + result = ConversionResult(Path("source"), Path("target"), "Example", "converted", False, (), (), (), ("Preserved",)) + self.assertEqual(["Preserved"], result.as_dict()["informational"]) + self.assertFalse(result.as_dict()["reviewRequired"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_converter.py b/Tools/SentinelToXDRMigration/tests/test_converter.py new file mode 100644 index 00000000000..13e8ec4dc3b --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_converter.py @@ -0,0 +1,1100 @@ +from __future__ import annotations + +import json +import hashlib +import tempfile +import tomllib +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +import yaml +from sentinel_xdr_migration.artifacts import artifact_path + +from sentinel_xdr_migration.converter import ( + convert_query, + convert_solution, + inspect_solution, + runtime_validation_plan, + validate_document, + validate_solution, +) +from sentinel_xdr_migration import __version__ +from sentinel_xdr_migration.onboarding import configure_workspace, doctor, setup +from sentinel_xdr_migration.runtime import ( + record_runtime_validation, + validate_advanced_hunting, +) + + +RULE = """\ +id: 11111111-2222-3333-4444-555555555555 +name: Suspicious test activity +description: Detects a representative activity. +severity: High +queryFrequency: 1h +queryPeriod: 1h +tactics: + - Discovery +relevantTechniques: + - T1087 +query: | + DeviceEvents + | where TimeGenerated > ago(1h) + | project TimeGenerated, AccountUpn, IPAddress +entityMappings: + - entityType: Account + fieldMappings: + - identifier: Upn + columnName: AccountUpn + - entityType: IP + fieldMappings: + - identifier: Address + columnName: IPAddress +version: 1.0.0 +kind: Scheduled +""" + + +class ConverterTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.solution = Path(self.temp.name) / "Sample" + rules = self.solution / "Analytic Rules" + rules.mkdir(parents=True) + (rules / "SampleRule.yaml").write_text(RULE, encoding="utf-8") + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_inspect_finds_analytic_rules(self) -> None: + result = inspect_solution(self.solution) + self.assertEqual(result["analyticRuleCount"], 1) + self.assertEqual(result["existingXdrDetectionCount"], 0) + + def test_inspect_supports_legacy_analytics_rules_directory(self) -> None: + legacy_solution = Path(self.temp.name) / "Legacy" + rules = legacy_solution / "Analytics Rules" + rules.mkdir(parents=True) + (rules / "SampleRule.yaml").write_text(RULE, encoding="utf-8") + + result = inspect_solution(legacy_solution) + + self.assertEqual(result["analyticRuleCount"], 1) + self.assertEqual(result["analyticRulesDirectory"], str(rules.resolve())) + + def test_convert_writes_versioned_provenance_and_disabled_detection(self) -> None: + result = convert_solution(self.solution) + self.assertEqual(result["converted"], 1) + output = self.solution / "XDR Detections" / "SampleRule.yaml" + document = yaml.safe_load(output.read_text(encoding="utf-8")) + self.assertEqual(document["schemaVersion"], "1.0.0") + self.assertEqual(document["version"], "3.1.0") + self.assertEqual(document["contentProvenance"]["source"]["version"], "1.0.0") + self.assertEqual(document["contentProvenance"]["conversion"]["version"], __version__) + self.assertEqual(document["apiVersion"], "2026-06-01-preview") + self.assertEqual((self.solution / "Analytic Rules/SampleRule.yaml").read_text(), RULE) + self.assertEqual(document["resourceType"], "Microsoft.Security/detectionRules") + self.assertEqual(document["properties"]["status"], "disabled") + self.assertIn("Timestamp", document["properties"]["queryCondition"]["queryText"]) + self.assertEqual( + document["contentProvenance"]["source"]["id"], + "11111111-2222-3333-4444-555555555555", + ) + self.assertEqual( + f"xdr-{document['contentProvenance']['source']['id']}", + document["properties"]["id"], + ) + self.assertEqual( + document["contentProvenance"]["conversion"]["requiredWorkloads"], ["sentinel"] + ) + self.assertFalse(document["contentProvenance"]["conversion"]["reviewRequired"]) + + def test_reconversion_preserves_independent_release_version(self) -> None: + convert_solution(self.solution) + output = self.solution / "XDR Detections/SampleRule.yaml" + original = yaml.safe_load(output.read_text()) + for version in ("3.1.1", "3.9.0"): + with self.subTest(version=version): + output.write_text(output.read_text().replace(f"version: {original['version']}\n", f"version: {version}\n", 1)) + before = output.read_bytes() + result = convert_solution(self.solution) + self.assertEqual(0, result["conflicts"]) + self.assertEqual(before, output.read_bytes()) + source = self.solution / "Analytic Rules/SampleRule.yaml" + source.write_text(RULE.replace("version: 1.0.0", "version: 1.0.3")) + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(0, result["conflicts"]) + updated = yaml.safe_load(output.read_text()) + self.assertEqual(version, updated["version"]) + self.assertEqual("1.0.3", updated["contentProvenance"]["source"]["version"]) + self.assertEqual(original["properties"]["id"], updated["properties"]["id"]) + self.assertEqual(RULE.replace("version: 1.0.0", "version: 1.0.3"), source.read_text()) + original = updated + + def test_conversion_information_round_trips_yaml_and_manifest(self) -> None: + source = self.solution / "Analytic Rules/SampleRule.yaml" + source.write_text(RULE.replace("queryPeriod: 1h", "queryPeriod: 4h"), encoding="utf-8") + summary = convert_solution(self.solution) + document = yaml.safe_load((self.solution / "XDR Detections/SampleRule.yaml").read_text()) + conversion = document["contentProvenance"]["conversion"] + manifest = json.loads(Path(summary["manifest"]).read_text()) + self.assertEqual("converted", conversion["status"]) + self.assertFalse(conversion["reviewRequired"]) + self.assertFalse(conversion["errors"]) + self.assertTrue(conversion["informational"]) + self.assertEqual(conversion["informational"], summary["results"][0]["informational"]) + self.assertEqual(conversion["informational"], manifest["results"][0]["informational"]) + + def test_xdr_release_boundaries_in_schema_and_structural_validation(self) -> None: + convert_solution(self.solution) + output = self.solution / "XDR Detections/SampleRule.yaml" + document = yaml.safe_load(output.read_text()) + for version, accepted in (("3.0.0", False), ("3.1.0", True), ("3.9.0", True), + ("4.0.0", False), ("1.0.0", False), ("3.1", False), + ("3.1.0.1", False), ("3.1.0-preview", False), + ("", False), (None, False), (3.1, False)): + with self.subTest(version=version): + document["version"] = version + errors = validate_document(document) + self.assertEqual(accepted, not errors, errors) + if not accepted: + self.assertTrue(any(error.startswith("schema:") for error in errors)) + self.assertTrue(any("XDR top-level version" in error for error in errors)) + document.pop("version") + errors = validate_document(document) + self.assertTrue(any("'version' is a required property" in error for error in errors)) + self.assertTrue(any("reconvert with --overwrite" in error for error in errors)) + + def test_reconversion_does_not_reset_invalid_or_unrelated_existing_release(self) -> None: + convert_solution(self.solution) + output = self.solution / "XDR Detections/SampleRule.yaml" + original = yaml.safe_load(output.read_text()) + for version in ("3.0.0", "4.0.0", "", None): + for overwrite in (False, True): + with self.subTest(version=version, overwrite=overwrite): + document = dict(original, version=version) + output.write_text(yaml.safe_dump(document, sort_keys=False)) + before = output.read_bytes() + result = convert_solution(self.solution, overwrite=overwrite) + self.assertEqual(1, result["conflicts"]) + self.assertEqual(before, output.read_bytes()) + self.assertIn("XDR top-level version", result["results"][0]["errors"][0]) + original["contentProvenance"]["source"]["id"] = "different-source" + output.write_text(yaml.safe_dump(original, sort_keys=False)) + before = output.read_bytes() + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(1, result["conflicts"]) + self.assertEqual(before, output.read_bytes()) + self.assertIn("different source rule", result["results"][0]["errors"][0]) + + def test_legacy_detection_requires_explicit_reconversion(self) -> None: + convert_solution(self.solution) + output = self.solution / "XDR Detections/SampleRule.yaml" + document = yaml.safe_load(output.read_text()) + document.pop("version") + output.write_text(yaml.safe_dump(document, sort_keys=False)) + before = output.read_bytes() + self.assertEqual(1, validate_solution(self.solution)["invalid"]) + self.assertEqual(1, convert_solution(self.solution)["conflicts"]) + self.assertEqual(before, output.read_bytes()) + self.assertEqual(0, convert_solution(self.solution, overwrite=True)["conflicts"]) + updated = yaml.safe_load(output.read_text()) + self.assertEqual("3.1.0", updated["version"]) + self.assertEqual(document["contentProvenance"], updated["contentProvenance"]) + self.assertEqual(1, validate_solution(self.solution)["valid"]) + + def test_validate_accepts_generated_detection(self) -> None: + convert_solution(self.solution) + result = validate_solution(self.solution) + self.assertEqual(result["valid"], 1) + self.assertEqual(result["invalid"], 0) + + def test_conversion_does_not_require_event_time_output(self) -> None: + queries = ( + "DeviceEvents", + "DeviceEvents | project DeviceName", + "DeviceEvents | summarize Events=count() by DeviceName", + "DeviceEvents | project EventTime=Timestamp, DeviceName", + "DeviceEvents | where Timestamp > ago(1h) | project DeviceName", + "DeviceEvents | project-away Timestamp", + "SecurityEvent | project Computer", + ) + for query in queries: + with self.subTest(query=query): + converted, warnings, errors = convert_query(query, {}) + self.assertEqual(query, converted) + self.assertEqual([], errors) + self.assertEqual([], warnings) + + def test_detection_without_event_time_passes_conversion_and_structure(self) -> None: + source = self.solution / "Analytic Rules/SampleRule.yaml" + rule = yaml.safe_load(RULE) + rule["query"] = "DeviceEvents | project AccountUpn, IPAddress" + source.write_text(yaml.safe_dump(rule), encoding="utf-8") + + result = convert_solution(self.solution) + self.assertEqual(0, result["converted"]) + self.assertEqual(1, result["needsReview"]) + output = self.solution / "XDR Detections/SampleRule.yaml" + document = yaml.safe_load(output.read_text(encoding="utf-8")) + self.assertEqual(rule["query"], document["properties"]["queryCondition"]["queryText"]) + self.assertEqual([], validate_document(document)) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual("needsReview", conversion["status"]) + self.assertTrue(any( + "source query does not establish an event-time column" in reason + for reason in conversion["reviewReasons"] + )) + + def test_other_query_blockers_remain_without_event_time(self) -> None: + _, _, errors = convert_query( + 'workspace("other").DeviceEvents | project DeviceName', {} + ) + self.assertEqual(["cross-workspace queries require manual redesign"], errors) + + def test_convert_generates_self_contained_html_report(self) -> None: + result = convert_solution(self.solution) + report = Path(result["transformationReport"]) + self.assertTrue(report.is_file()) + content = report.read_text(encoding="utf-8") + self.assertIn("", content) + self.assertIn("Sentinel to XDR transformation report", content) + self.assertIn("Suspicious test activity", content) + self.assertIn("SampleRule.yaml", content) + self.assertIn("converted", content) + self.assertNotRegex(content, r"(?is)<(?:script|img|iframe|source|video|audio|embed)\b[^>]*\bsrc\s*=") + self.assertNotRegex(content, r"(?is)]*\brel\s*=\s*[\"'](?:stylesheet|preload|modulepreload)") + self.assertNotRegex(content, r"(?i)@import\b|url\s*\(") + + def test_convert_keeps_xdr_detections_yaml_only(self) -> None: + result = convert_solution(self.solution) + output = self.solution / "XDR Detections" + + self.assertEqual( + ["SampleRule.yaml"], + sorted(path.name for path in output.iterdir()), + ) + self.assertTrue(Path(result["manifest"]).is_file()) + self.assertNotEqual(output, Path(result["manifest"]).parent) + + def test_converter_refuses_to_overwrite_different_content(self) -> None: + convert_solution(self.solution) + output = self.solution / "XDR Detections" / "SampleRule.yaml" + output.write_text("changed: true\n", encoding="utf-8") + result = convert_solution(self.solution) + self.assertEqual(result["conflicts"], 1) + + def test_runtime_plan_pairs_source_and_converted_queries(self) -> None: + convert_solution(self.solution) + result = runtime_validation_plan(self.solution) + self.assertEqual(len(result["rules"]), 1) + self.assertIn("TimeGenerated", result["rules"][0]["sentinelQuery"]) + self.assertIn("Timestamp", result["rules"][0]["advancedHuntingQuery"]) + self.assertEqual( + hashlib.sha256(result["rules"][0]["sentinelQuery"].encode()).hexdigest(), + result["rules"][0]["sentinelQuerySha256"], + ) + self.assertEqual( + hashlib.sha256(result["rules"][0]["advancedHuntingQuery"].encode()).hexdigest(), + result["rules"][0]["advancedHuntingQuerySha256"], + ) + + def test_explicit_mappings_are_applied(self) -> None: + output = self.solution / "XDR Detections" + output.mkdir() + artifact_path(self.solution, "migration-config.yaml", create_parent=True).write_text( + """\ +schemaVersion: 1.0.0 +tableMappings: + DeviceEvents: AlertEvidence +functionMappings: {} +columnMappings: + DeviceEvents: + AccountUpn: InitiatingProcessAccountUpn +""", + encoding="utf-8", + ) + convert_solution(self.solution) + document = yaml.safe_load((output / "SampleRule.yaml").read_text(encoding="utf-8")) + query = document["properties"]["queryCondition"]["queryText"] + self.assertIn("AlertEvidence", query) + self.assertIn("InitiatingProcessAccountUpn", query) + account = document["properties"]["detectionAction"]["alertTemplate"][ + "entityMappings" + ]["accounts"][0] + self.assertEqual( + account["upnColumn"], + "InitiatingProcessAccountUpn", + ) + validation = validate_solution(self.solution) + self.assertEqual(validation["total"], 1) + + def test_missing_asset_mapping_requires_review(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +DeviceEvents +| where TimeGenerated > ago(1h) +| project TimeGenerated, Url +""" + document["entityMappings"] = [ + { + "entityType": "URL", + "fieldMappings": [{"identifier": "Url", "columnName": "Url"}], + } + ] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + result = convert_solution(self.solution) + self.assertEqual(result["needsReview"], 1) + validation = validate_solution(self.solution) + self.assertEqual(validation["invalid"], 1) + + def test_iso_frequency_is_preserved(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["queryFrequency"] = "PT30M" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + self.assertEqual(output["properties"]["schedule"]["frequency"], "PT30M") + + def test_rewrites_skip_strings_and_comments(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +DeviceEvents +| extend Label = "TimeGenerated" // TimeGenerated stays in this comment +| where TimeGenerated > ago(1h) +| project TimeGenerated, AccountUpn, IPAddress, Label +""" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + query = output["properties"]["queryCondition"]["queryText"] + self.assertIn('"TimeGenerated"', query) + self.assertIn("// TimeGenerated stays in this comment", query) + self.assertIn("| where Timestamp", query) + + def test_passthrough_table_preserves_timegenerated(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +AzureActivity +| where TimeGenerated > ago(1h) +| project TimeGenerated, AccountUpn, IPAddress +""" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + query = output["properties"]["queryCondition"]["queryText"] + self.assertIn("TimeGenerated", query) + self.assertNotIn("Timestamp", query) + self.assertEqual(validate_solution(self.solution)["valid"], 1) + + def test_mixed_native_and_passthrough_query_preserves_branch_time_columns(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +let Native = DeviceProcessEvents +| where Timestamp > ago(1h) +| project EventTime = Timestamp, AccountUpn, IPAddress; +let Sentinel = SecurityEvent +| where TimeGenerated > ago(1h) +| project EventTime = TimeGenerated, AccountUpn, IPAddress; +union Native, Sentinel +""" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + result = convert_solution(self.solution) + self.assertEqual(result["needsReview"], 1) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + query = output["properties"]["queryCondition"]["queryText"] + self.assertIn("EventTime = Timestamp", query) + self.assertIn("EventTime = TimeGenerated", query) + + def test_project_away_does_not_hide_passthrough_table_columns(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +AzureActivity +| extend Temporary = parse_json(Properties) +| project-away Temporary +""" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + mappings = output["properties"]["detectionAction"]["alertTemplate"]["entityMappings"] + self.assertEqual(mappings["accounts"][0]["upnColumn"], "AccountUpn") + self.assertEqual(mappings["ips"][0]["addressColumn"], "IPAddress") + + def test_externaldata_requires_runtime_review_but_is_structurally_valid(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] += ( + "\n| join kind=leftouter (externaldata(AccountUpn:string)" + "['https://example.test/list.csv']) on AccountUpn\n" + ) + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + result = convert_solution(self.solution) + self.assertEqual(result["needsReview"], 1) + self.assertEqual(validate_solution(self.solution)["valid"], 1) + + def test_multiple_techniques_are_grouped(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["tactics"] = ["Persistence"] + document["relevantTechniques"] = ["T1078", "T1078.004", "T1098"] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + techniques = output["properties"]["detectionAction"]["alertTemplate"]["tactics"][0][ + "techniques" + ] + self.assertEqual( + techniques, + [ + {"technique": "T1078", "subTechniques": ["T1078.004"]}, + {"technique": "T1098"}, + ], + ) + + def test_multiple_tactics_preserved_without_rule_override(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["tactics"] = ["Persistence", "CommandAndControl"] + document["relevantTechniques"] = ["T1505", "T1071"] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + + result = convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(result["needsReview"], 0) + self.assertEqual(result["deploymentReady"], 0) + self.assertEqual(output["properties"]["detectionAction"]["alertTemplate"]["tactics"], [ + {"tactic": "Persistence", "techniques": [{"technique": "T1505"}]}, + {"tactic": "CommandAndControl", "techniques": [{"technique": "T1071"}]}, + ]) + + def test_rule_override_changes_named_tactic_techniques_without_narrowing(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["tactics"] = ["Persistence", "CommandAndControl"] + document["relevantTechniques"] = ["T1505", "T1071"] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + config = self.solution / "override.yaml" + config.write_text( + yaml.safe_dump( + { + "ruleOverrides": { + document["id"]: { + "tactic": "Persistence", + "techniques": ["T1505"], + } + } + } + ), + encoding="utf-8", + ) + + result = convert_solution(self.solution, config_path=config) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(result["converted"], 1) + tactic = output["properties"]["detectionAction"]["alertTemplate"]["tactics"][0] + self.assertEqual( + tactic, + {"tactic": "Persistence", "techniques": [{"technique": "T1505"}]}, + ) + self.assertEqual( + output["properties"]["detectionAction"]["alertTemplate"]["tactics"][1], + {"tactic": "CommandAndControl", "techniques": [{"technique": "T1071"}]}, + ) + conversion = output["contentProvenance"]["conversion"] + self.assertEqual( + conversion["originalTactics"], ["Persistence", "CommandAndControl"] + ) + self.assertEqual(conversion["originalTechniques"], ["T1505", "T1071"]) + + def test_rule_override_acceptance_is_explicit_and_hash_scoped(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["queryPeriod"] = "4h" + document["query"] += "\n| union isfuzzy=true (DeviceEvents)\n" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + reason = "isfuzzy unions can still fail semantic binding in Advanced Hunting" + convert_solution(self.solution) + output_path = self.solution / "XDR Detections" / "SampleRule.yaml" + current = yaml.safe_load(output_path.read_text(encoding="utf-8")) + scope = current["contentProvenance"]["conversion"]["reviewScope"] + config = self.solution / "override.yaml" + config.write_text( + yaml.safe_dump( + { + "ruleOverrides": { + document["id"]: { + "reviewDecisions": [{ + "reason": reason, + "decision": "accepted", + **scope, + }], + } + } + } + ), + encoding="utf-8", + ) + + result = convert_solution(self.solution, overwrite=True, config_path=config) + output = yaml.safe_load(output_path.read_text(encoding="utf-8")) + + self.assertEqual(result["converted"], 1) + conversion = output["contentProvenance"]["conversion"] + self.assertFalse(conversion["reviewRequired"]) + self.assertEqual(conversion["reviewReasons"], []) + self.assertEqual(conversion["acceptedReviewReasons"], [reason]) + self.assertEqual(conversion["reviewDecisions"][0]["decision"], "accepted") + self.assertIn(reason, conversion["warnings"]) + + def test_review_approval_expires_when_converted_query_hash_changes(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["queryPeriod"] = "4h" + document["query"] = 'DeviceEvents | search "needle"' + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output_path = self.solution / "XDR Detections" / "SampleRule.yaml" + current = yaml.safe_load(output_path.read_text(encoding="utf-8")) + conversion = current["contentProvenance"]["conversion"] + reason = "search queries should be replaced with explicit tables" + self.assertIn(reason, conversion["reviewReasons"]) + scope = conversion["reviewScope"] + config = self.solution / "changed-target.yaml" + config.write_text(yaml.safe_dump({ + "schemaVersion": "1.0.0", + "tableMappings": {"DeviceEvents": "AlertEvidence"}, + "ruleOverrides": { + document["id"]: { + "reviewDecisions": [{ + "reason": reason, + "decision": "accepted", + **scope, + }], + }, + }, + }), encoding="utf-8") + + result = convert_solution(self.solution, overwrite=True, config_path=config) + updated = yaml.safe_load(output_path.read_text(encoding="utf-8")) + conversion = updated["contentProvenance"]["conversion"] + self.assertEqual(1, result["needsReview"]) + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue(any("reviewDecision" in error and "stale" in error for error in conversion["errors"])) + self.assertNotEqual( + scope["targetQuerySha256"], + conversion["reviewScope"]["targetQuerySha256"], + ) + + def test_review_decisions_reject_unscoped_and_stale_acceptance(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] += "\n| union isfuzzy=true (DeviceEvents)\n" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + reason = "isfuzzy unions can still fail semantic binding in Advanced Hunting" + unscoped = self.solution / "unscoped.yaml" + unscoped.write_text(yaml.safe_dump({ + "ruleOverrides": {document["id"]: {"acceptedReviewReasons": [reason]}} + }), encoding="utf-8") + convert_solution(self.solution, config_path=unscoped) + output_path = self.solution / "XDR Detections" / "SampleRule.yaml" + result = yaml.safe_load(output_path.read_text(encoding="utf-8")) + conversion = result["contentProvenance"]["conversion"] + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue(any("is unscoped" in error for error in conversion["errors"])) + + first = convert_solution(self.solution, overwrite=True) + output = yaml.safe_load(output_path.read_text(encoding="utf-8")) + scope = output["contentProvenance"]["conversion"]["reviewScope"] + scoped = self.solution / "scoped.yaml" + scoped.write_text(yaml.safe_dump({ + "ruleOverrides": {document["id"]: {"reviewDecisions": [{ + "reason": reason, "decision": "accepted", **scope, + }]}} + }), encoding="utf-8") + document["description"] = "changed source content invalidates the decision" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution, overwrite=True, config_path=scoped) + output = yaml.safe_load(output_path.read_text(encoding="utf-8")) + conversion = output["contentProvenance"]["conversion"] + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue(any("is stale" in error for error in conversion["errors"])) + + def test_rule_override_can_supply_reviewed_entity_mappings(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["entityMappings"] = [] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + config = self.solution / "override.yaml" + config.write_text( + yaml.safe_dump( + { + "ruleOverrides": { + document["id"]: { + "entityMappings": { + "ips": [ + { + "id": "ip1", + "addressColumn": "IPAddress", + } + ] + } + } + } + } + ), + encoding="utf-8", + ) + + result = convert_solution(self.solution, config_path=config) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(result["converted"], 1) + self.assertEqual( + output["properties"]["detectionAction"]["alertTemplate"][ + "entityMappings" + ], + {"ips": [{"id": "ip1", "addressColumn": "IPAddress"}]}, + ) + + def test_config_can_exclude_a_sentinel_only_rule(self) -> None: + convert_solution(self.solution) + output = self.solution / "XDR Detections" + generated = output / "SampleRule.yaml" + config = artifact_path(self.solution, "migration-config.yaml", create_parent=True) + config.write_text( + yaml.safe_dump( + { + "excludedRuleIds": { + "11111111-2222-3333-4444-555555555555": ( + "operational health rule has no affected asset" + ) + } + } + ), + encoding="utf-8", + ) + + result = convert_solution(self.solution, overwrite=True) + + self.assertEqual(result["converted"], 0) + self.assertEqual(result["excluded"], 1) + self.assertEqual(result["needsReview"], 0) + self.assertFalse(generated.exists()) + self.assertEqual(result["results"][0]["status"], "excluded") + + def test_account_name_upn_alias_is_repaired(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +AzureActivity +| project TimeGenerated, Caller, CallerIpAddress +""" + document["entityMappings"] = [ + { + "entityType": "Account", + "fieldMappings": [{"identifier": "Name", "columnName": "Caller"}], + }, + { + "entityType": "IP", + "fieldMappings": [ + {"identifier": "Address", "columnName": "CallerIpAddress"} + ], + }, + ] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + account = output["properties"]["detectionAction"]["alertTemplate"]["entityMappings"][ + "accounts" + ][0] + self.assertEqual(account["upnColumn"], "Caller") + + def test_mv_expand_assignment_is_a_projected_column(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = """\ +AzureActivity +| summarize make_set(CallerIpAddress) by Caller +| mv-expand CallerIpAddress = set_CallerIpAddress +| project Caller, CallerIpAddress +""" + document["entityMappings"] = [ + { + "entityType": "Account", + "fieldMappings": [{"identifier": "Name", "columnName": "Caller"}], + }, + { + "entityType": "IP", + "fieldMappings": [ + {"identifier": "Address", "columnName": "CallerIpAddress"} + ], + }, + ] + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + mappings = output["properties"]["detectionAction"]["alertTemplate"]["entityMappings"] + self.assertEqual(mappings["ips"][0]["addressColumn"], "CallerIpAddress") + + def test_nrt_uses_supported_default_schedule_without_review(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["kind"] = "NRT" + document.pop("queryFrequency") + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + result = convert_solution(self.solution) + self.assertEqual(result["converted"], 1) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + self.assertEqual(output["properties"]["schedule"]["frequency"], "PT1H") + + @mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token", + return_value="token", + ) + @mock.patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_runtime_validation_reports_query_failure_without_table_probes( + self, run_query: mock.Mock, _token: mock.Mock + ) -> None: + convert_solution(self.solution) + run_query.return_value = { + "ok": False, + "statusCode": 400, + "rowCount": 0, + "schema": [], + "error": "table unavailable", + "errorDetails": None, + } + result = validate_advanced_hunting(self.solution) + self.assertEqual(result["blocked"], 0) + self.assertEqual(result["invalid"], 1) + self.assertEqual(result["provider"], "graph") + self.assertEqual(result["results"][0]["status"], "failed") + expected_query = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text( + encoding="utf-8" + ) + )["properties"]["queryCondition"]["queryText"] + run_query.assert_called_once_with(expected_query, _token="token") + self.assertEqual({}, result["tableAvailability"]) + self.assertTrue(Path(result["jsonReport"]).is_file()) + self.assertTrue(Path(result["htmlReport"]).is_file()) + + @mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token", + return_value="token", + ) + @mock.patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_runtime_validation_ignores_migration_config( + self, run_query: mock.Mock, _token: mock.Mock + ) -> None: + output = self.solution / "XDR Detections" + output.mkdir() + artifact_path(self.solution, "migration-config.yaml", create_parent=True).write_text( + "schemaVersion: 1.0.0\n", + encoding="utf-8", + ) + convert_solution(self.solution) + run_query.return_value = { + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [], + "error": None, + "errorDetails": None, + } + + result = validate_advanced_hunting(self.solution) + + self.assertEqual(result["total"], 1) + self.assertEqual(result["valid"], 1) + self.assertEqual(result["results"][0]["detection"], "SampleRule.yaml") + self.assertEqual( + hashlib.sha256( + yaml.safe_load( + (self.solution / "XDR Detections/SampleRule.yaml").read_text() + )["properties"]["queryCondition"]["queryText"].encode() + ).hexdigest(), + result["results"][0]["querySha256"], + ) + + @mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token", + return_value="token", + ) + @mock.patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_runtime_validation_queries_parser_function_without_probe( + self, run_query: mock.Mock, _token: mock.Mock + ) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = "ExampleParser | project Caller, CallerIpAddress" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + parser_root = self.solution / "Parsers" + parser_root.mkdir() + (parser_root / "ExampleParser.yaml").write_text( + yaml.safe_dump( + { + "FunctionName": "ExampleParser", + "FunctionQuery": "Example_CL", + }, + sort_keys=False, + ), + encoding="utf-8", + ) + convert_solution(self.solution) + run_query.return_value = { + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [], + "error": None, + "errorDetails": None, + } + + result = validate_advanced_hunting(self.solution) + + expected_query = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text( + encoding="utf-8" + ) + )["properties"]["queryCondition"]["queryText"] + self.assertEqual(result["valid"], 1) + run_query.assert_called_once_with(expected_query, _token="token") + self.assertNotEqual(expected_query, "ExampleParser | take 0") + + def test_records_normalized_triage_mcp_results(self) -> None: + convert_solution(self.solution) + source_query_hash = hashlib.sha256( + yaml.safe_load((self.solution / "Analytic Rules/SampleRule.yaml").read_text())["query"].encode() + ).hexdigest() + results_path = Path(self.temp.name) / "triage-results.json" + results_path.write_text( + """\ +{ + "results": [ + { + "detection": "SampleRule.yaml", + "status": "passed", + "statusCode": 200, + "rowCount": 1, + "querySha256": "%s", + "schemaColumnCount": 3, + "error": null + } + ] +} +""" % source_query_hash, + encoding="utf-8", + ) + result = record_runtime_validation( + self.solution, provider="triage-mcp", results_path=results_path + ) + self.assertEqual(result["provider"], "triage-mcp") + self.assertEqual(result["valid"], 1) + self.assertEqual(result["invalid"], 0) + self.assertEqual(result["blocked"], 0) + self.assertEqual(result["notRun"], 0) + self.assertEqual(result["platform"], "Microsoft Sentinel Triage MCP") + self.assertEqual(result["querySurface"], "microsoft-sentinel-triage") + self.assertTrue(Path(result["jsonReport"]).is_file()) + html = Path(result["htmlReport"]).read_text(encoding="utf-8") + self.assertIn("Provider: triage-mcp", html) + self.assertIn("SampleRule.yaml", html) + + def test_records_log_analytics_results_and_not_run_status(self) -> None: + convert_solution(self.solution) + results_path = Path(self.temp.name) / "log-analytics-results.json" + results_path.write_text( + """\ +{ + "results": [ + { + "detection": "SampleRule.yaml", + "status": "not-run", + "statusCode": 0, + "rowCount": 0, + "schemaColumnCount": 0, + "error": null + } + ] +} +""", + encoding="utf-8", + ) + result = record_runtime_validation( + self.solution, + provider="log-analytics-cli", + results_path=results_path, + ) + self.assertEqual(result["platform"], "Microsoft Sentinel Log Analytics") + self.assertEqual(result["querySurface"], "log-analytics-workspace") + self.assertEqual(result["notRun"], 1) + self.assertEqual(result["invalid"], 0) + self.assertEqual(result["blocked"], 0) + html = Path(result["htmlReport"]).read_text(encoding="utf-8") + self.assertIn("Microsoft Sentinel Log Analytics runtime validation", html) + self.assertIn("not-run", html) + + def test_package_and_provenance_versions_match(self) -> None: + pyproject = tomllib.loads( + (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8") + ) + self.assertEqual(pyproject["project"]["version"], __version__) + + @mock.patch( + "sentinel_xdr_migration.onboarding._advanced_hunting_status", + return_value={ + "name": "advancedHunting", + "status": "actionRequired", + "detail": "sign-in required", + }, + ) + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_doctor_reports_first_run_and_offline_fallback( + self, _which: mock.Mock, _hunting: mock.Mock + ) -> None: + def runner(command, **_kwargs): + if "get-access-token" in command: + return CompletedProcess(command, 1, "", "login required") + return CompletedProcess(command, 0, "", "") + + state_dir = Path(self.temp.name) / "state" + result = doctor(state_dir=state_dir, runner=runner, environment={}) + self.assertTrue(result["firstRun"]) + self.assertEqual(result["mode"], "offline") + self.assertTrue(result["conversionAvailable"]) + self.assertFalse(result["runtimeValidationAvailable"]) + + @mock.patch( + "sentinel_xdr_migration.onboarding._advanced_hunting_status", + return_value={ + "name": "advancedHunting", + "status": "actionRequired", + "detail": "sign-in required", + }, + ) + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_noninteractive_setup_persists_nonsecret_config( + self, _which: mock.Mock, _hunting: mock.Mock + ) -> None: + def runner(command, **_kwargs): + if "get-access-token" in command: + return CompletedProcess(command, 1, "", "login required") + return CompletedProcess(command, 0, "", "") + + state_dir = Path(self.temp.name) / "state" + result = setup( + interactive=False, + tenant_id="tenant-id", + workspace_id="workspace-id", + state_dir=state_dir, + runner=runner, + ) + self.assertTrue(result["setupCompleted"]) + self.assertEqual(result["mode"], "offline") + self.assertFalse(result["doctor"]["firstRun"]) + config = (state_dir / "config.json").read_text(encoding="utf-8") + self.assertIn("tenant-id", config) + self.assertIn("workspace-id", config) + self.assertNotIn("token", config.lower()) + + def test_configured_workspace_is_reusable_across_solutions(self) -> None: + state_dir = Path(self.temp.name) / "state" + resource_id = ( + "/subscriptions/subscription-id/resourceGroups/resource-group/providers/" + "Microsoft.OperationalInsights/workspaces/lab-workspace" + ) + + configured = configure_workspace( + resource_id, + workspace_customer_id="customer-id", + state_dir=state_dir, + ) + + self.assertEqual("configured", configured["status"]) + config = json.loads( + (state_dir / "config.json").read_text(encoding="utf-8") + ) + self.assertEqual(resource_id, config["workspaceResourceId"]) + self.assertEqual("customer-id", config["workspaceId"]) + + @mock.patch( + "sentinel_xdr_migration.onboarding._advanced_hunting_status", + return_value={ + "name": "advancedHunting", + "status": "ready", + "detail": "ready", + }, + ) + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_doctor_returns_configured_workspace_for_confirmation( + self, + _which: mock.Mock, + _hunting: mock.Mock, + ) -> None: + def runner(command, **_kwargs): + return CompletedProcess(command, 0, "", "") + + state_dir = Path(self.temp.name) / "state" + resource_id = ( + "/subscriptions/subscription-id/resourceGroups/resource-group/providers/" + "Microsoft.OperationalInsights/workspaces/lab-workspace" + ) + configure_workspace( + resource_id, + workspace_customer_id="customer-id", + tenant_id="tenant-id", + subscription_id="subscription-id", + state_dir=state_dir, + ) + + result = doctor(state_dir=state_dir, runner=runner, environment={}) + + self.assertEqual(resource_id, result["configuredWorkspaceResourceId"]) + self.assertEqual("customer-id", result["configuredWorkspaceCustomerId"]) + self.assertEqual("tenant-id", result["configuredTenantId"]) + self.assertEqual("subscription-id", result["configuredSubscriptionId"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_coordinator.py b/Tools/SentinelToXDRMigration/tests/test_coordinator.py new file mode 100644 index 00000000000..93898cd8d35 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_coordinator.py @@ -0,0 +1,995 @@ +from __future__ import annotations + +import json +import io +import tempfile +import time +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration import coordinator +from sentinel_xdr_migration.cli import main as cli_main +from sentinel_xdr_migration.workflow import initialize_workflow, workflow_status + + +class CoordinatorTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.root = Path(self.temp.name) + self.solution = self.root / "Solutions" / "Cisco" + self.solution.mkdir(parents=True) + self.registry = self.root / "coordinator" / "registry.json" + self.run = initialize_workflow( + self.solution, + workflow_profile="authoring", + version_bump="none", + )["runId"] + self.real_status = workflow_status(self.solution) + self.stages = json.loads( + Path(self.real_status["statePath"]).read_text(encoding="utf-8") + )["stages"] + self.next_stage = "discovery" + self.workflow_state = self.real_status + self._patches: list[mock._patch] = [] + self._install_fake_workflow() + + def tearDown(self) -> None: + for patcher in reversed(self._patches): + patcher.stop() + self.temp.cleanup() + + def _install_fake_workflow(self) -> None: + def get_status(solution: str | Path) -> dict: + return { + **self.workflow_state, + "runId": self.run, + "statePath": self.real_status["statePath"], + "stages": self.stages, + "context": { + **self.workflow_state.get("context", {}), + "workflowProfile": self.workflow_state.get("context", {}).get( + "workflowProfile", "authoring" + ), + }, + } + + def get_next(solution: str | Path) -> dict: + return { + "runId": self.run, + "next": self.next_stage, + "blockedBy": [], + } + + self._patches.extend([ + mock.patch.object(coordinator, "workflow_status", side_effect=get_status), + mock.patch.object(coordinator, "next_workflow_stage", side_effect=get_next), + mock.patch.object( + coordinator, + "runtime_validation_plan", + return_value={ + "rules": [{ + "sentinelQuerySha256": "a" * 64, + "advancedHuntingQuerySha256": "b" * 64, + }] + }, + ), + mock.patch.object( + coordinator, + "current_runtime_evidence", + return_value={ + "sourceStatus": "blocked", + "graphReport": None, + "issues": ["runtime evidence is not complete"], + }, + ), + ]) + for patcher in self._patches[-4:]: + patcher.start() + + def _enroll(self, *, owner: str = "solution-owner", dependencies: list[str] | None = None) -> dict: + return coordinator.enroll( + self.solution, + owner=owner, + required_shared_dependencies=dependencies, + registry_path=self.registry, + ) + + def _qualification_run(self) -> None: + self.workflow_state = initialize_workflow( + self.solution, + workflow_profile="qualification", + tenant_id="39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + subscription_id="42382e39-f157-46d1-a931-b8cfd779ece5", + workspace_resource_id=( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws" + ), + workspace_customer_id="756386d8-e2d4-4f09-905a-74b24313721f", + version_bump="none", + ) + self.run = self.workflow_state["runId"] + self.real_status = workflow_status(self.solution) + self.stages = json.loads( + Path(self.real_status["statePath"]).read_text(encoding="utf-8") + )["stages"] + + def test_shadow_enrollment_produces_cisco_preflight_or_approval_decision(self) -> None: + self._qualification_run() + self.next_stage = "deployment" + result = self._enroll() + deployment = next(item for item in result["actions"] if item["actionId"] == "deployment") + self.assertEqual("approval-needed", deployment["status"]) + self.assertEqual("customDetection", deployment["authPurpose"]) + self.assertIn("preflight", deployment["reason"]) + self.assertEqual(["preflight-deployment"], deployment["recommendedCommands"]) + self.assertFalse(deployment["serviceSettingsVerifiedByCoordinator"]) + self.assertFalse(deployment["authorizationGrantedByCoordinator"]) + self.assertEqual("shadow", result["dispatchMode"]) + self.assertFalse(result["automaticCommandExecution"]) + self.assertTrue(deployment["targetSha256"]) + with self.assertRaisesRegex(ValueError, "approval-needed"): + coordinator.claim( + self.solution, + action_id="deployment", + executor="ephemeral-executor", + claim_key="claim-1", + registry_path=self.registry, + ) + + def test_cli_registry_reconcile_and_status_entrypoints(self) -> None: + output = io.StringIO() + with redirect_stdout(output): + result = cli_main([ + "coordinator-enroll", + "--solution", str(self.solution), + "--run-id", self.run, + "--owner", "cli-owner", + "--registry", str(self.registry), + ]) + self.assertEqual(0, result) + enrolled = json.loads(output.getvalue()) + self.assertEqual(self.run, enrolled["runId"]) + output.seek(0) + output.truncate() + with redirect_stdout(output): + result = cli_main([ + "coordinator-status", + "--solution", str(self.solution), + "--run-id", self.run, + "--registry", str(self.registry), + ]) + self.assertEqual(0, result) + current = json.loads(output.getvalue()) + self.assertEqual("cli-owner", current["owner"]) + self.assertEqual("shadow", current["dispatchMode"]) + + def test_owner_handoff_is_explicit_and_audited(self) -> None: + self._enroll(owner="current-owner") + with self.assertRaisesRegex(ValueError, "only the current accountable owner"): + coordinator.record_event( + self.solution, + event_id="unauthorized-handoff", + kind="owner-handoff", + actor="other-actor", + new_owner="new-owner", + reference="change-ticket", + registry_path=self.registry, + ) + handoff = coordinator.record_event( + self.solution, + event_id="authorized-handoff", + kind="owner-handoff", + actor="current-owner", + new_owner="new-owner", + reference="reviewed ownership transfer", + registry_path=self.registry, + ) + self.assertTrue(handoff["created"]) + self.assertEqual( + "new-owner", + coordinator.status(self.solution, registry_path=self.registry)["owner"], + ) + + def test_stale_package_inputs_still_require_existing_release_gate(self) -> None: + self.next_stage = "packaging" + self.stages["validation"]["status"] = "passed" + next_info = mock.patch.object( + coordinator, + "next_workflow_stage", + return_value={ + "runId": self.run, + "next": "packaging", + "blockedBy": [], + "releaseActionRequired": True, + "packageFreshness": { + "status": "stale", + "currentPackageInputSha256": "e" * 64, + }, + }, + ) + next_info.start() + self._patches.append(next_info) + result = self._enroll() + packaging = next( + item for item in result["actions"] if item["actionId"] == "packaging" + ) + self.assertEqual("approval-needed", packaging["status"]) + self.assertEqual("e" * 64, packaging["packageInputSha256"]) + self.assertEqual(["workflow-plan-package-release"], packaging["recommendedCommands"]) + + def test_claim_and_event_are_idempotent_and_do_not_start_workflow(self) -> None: + self._enroll() + first = coordinator.claim( + self.solution, + action_id="discovery", + executor="worker-1", + claim_key="stable-claim", + registry_path=self.registry, + ) + replay = coordinator.claim( + self.solution, + action_id="discovery", + executor="worker-1", + claim_key="stable-claim", + registry_path=self.registry, + ) + self.assertEqual(first["claimId"], replay["claimId"]) + self.assertTrue(replay["idempotentReplay"]) + self.assertEqual("pending", self.stages["discovery"]["status"]) + evidence = self.solution / "completion.json" + evidence.write_text('{"result":"passed"}', encoding="utf-8") + self.stages["discovery"]["status"] = "passed" + self.stages["discovery"]["artifacts"] = {"result": "completion.json"} + self.next_stage = "conversion" + first_event = coordinator.record_event( + self.solution, + event_id="completion-1", + kind="executor-completed", + action_id="discovery", + target_sha256=None, + evidence=evidence, + registry_path=self.registry, + ) + replay_event = coordinator.record_event( + self.solution, + event_id="completion-1", + kind="executor-completed", + action_id="discovery", + target_sha256=None, + evidence=evidence, + registry_path=self.registry, + ) + self.assertTrue(first_event["created"]) + self.assertFalse(replay_event["created"]) + self.assertEqual(first_event["evidenceSha256"], replay_event["evidenceSha256"]) + registry = json.loads(self.registry.read_text(encoding="utf-8")) + self.assertEqual(1, len([e for e in registry["events"] if e["eventId"] == "completion-1"])) + self.assertEqual("pending", self.stages["conversion"]["status"]) + + def test_expired_executor_retains_owner_and_unknown_outcome_blocks_retry(self) -> None: + self._enroll(owner="durable-solution-owner") + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="ephemeral-worker", + claim_key="vanishing-worker", + lease_seconds=0.1, + registry_path=self.registry, + ) + time.sleep(0.15) + reconciled = coordinator.reconcile(self.solution, registry_path=self.registry) + self.assertEqual("durable-solution-owner", reconciled["owner"]) + self.assertEqual("blocked", reconciled["actions"][0]["status"]) + self.assertEqual("expired-unknown", coordinator.status( + self.solution, registry_path=self.registry + )["claim"]["status"]) + self.assertIn("expired-claim-unknown", { + warning["type"] for warning in coordinator.status( + self.solution, registry_path=self.registry + )["watchdog"] + }) + with self.assertRaisesRegex(ValueError, "unknown outcome"): + coordinator.claim( + self.solution, + action_id="discovery", + executor="replacement-worker", + claim_key="must-not-retry", + registry_path=self.registry, + ) + self.assertEqual(claim["claimId"], reconciled["actions"][0]["claimId"]) + owner_evidence = self.solution / "claim-reconciliation.json" + owner_evidence.write_text('{"requestWasNotIssued":true}', encoding="utf-8") + resolved = coordinator.record_event( + self.solution, + event_id="claim-not-started", + kind="claim-resolved", + claim_id=claim["claimId"], + outcome="not-started", + actor="durable-solution-owner", + reference="reviewed local executor record", + evidence=owner_evidence, + registry_path=self.registry, + ) + self.assertTrue(resolved["created"]) + replacement = coordinator.claim( + self.solution, + action_id="discovery", + executor="replacement-after-evidence", + claim_key="verified-not-started", + registry_path=self.registry, + ) + self.assertEqual("active", replacement["status"]) + + def test_status_persists_expired_claim_and_blocks_the_action(self) -> None: + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="short-lived-worker", + claim_key="status-expiry", + lease_seconds=0.1, + registry_path=self.registry, + ) + time.sleep(0.15) + current = coordinator.status(self.solution, registry_path=self.registry) + self.assertEqual("expired-unknown", current["claim"]["status"]) + self.assertEqual("blocked", current["actions"][0]["status"]) + persisted = json.loads(self.registry.read_text(encoding="utf-8")) + entry = next(iter(persisted["solutions"].values())) + self.assertEqual("expired-unknown", entry["claim"]["status"]) + reconciled = coordinator.reconcile(self.solution, registry_path=self.registry) + self.assertEqual("blocked", reconciled["actions"][0]["status"]) + self.assertEqual(claim["claimId"], entry["claim"]["claimId"]) + self.assertEqual( + "expired-unknown", + coordinator.status(self.solution, registry_path=self.registry)["claim"]["status"], + ) + + def test_authoritative_stage_evidence_resolves_expired_claim_exactly(self) -> None: + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="vanished-worker", + claim_key="authoritative-completion", + lease_seconds=0.1, + registry_path=self.registry, + ) + time.sleep(0.15) + expired = coordinator.status(self.solution, registry_path=self.registry) + self.assertEqual("expired-unknown", expired["claim"]["status"]) + + evidence = self.solution / "completion.json" + evidence.write_text('{"official":"passed"}', encoding="utf-8") + self.stages["discovery"]["status"] = "passed" + self.stages["discovery"]["artifacts"] = {"result": "completion.json"} + self.next_stage = "conversion" + unrelated = self.solution / "unrelated.json" + unrelated.write_text('{"not":"official evidence"}', encoding="utf-8") + coordinator.status(self.solution, registry_path=self.registry) + with self.assertRaisesRegex(ValueError, "exact target, action, and official workflow evidence"): + coordinator.record_event( + self.solution, + event_id="wrong-completion-evidence", + kind="executor-completed", + action_id="discovery", + target_sha256=None, + evidence=unrelated, + registry_path=self.registry, + ) + + completion = coordinator.record_event( + self.solution, + event_id="official-completion", + kind="executor-completed", + action_id="discovery", + target_sha256=None, + evidence=evidence, + registry_path=self.registry, + ) + self.assertTrue(completion["consumed"]) + resolved = coordinator.status(self.solution, registry_path=self.registry) + self.assertEqual(claim["claimId"], resolved["claim"]["claimId"]) + self.assertEqual("completed", resolved["claim"]["status"]) + self.assertEqual("passed", self.stages["discovery"]["status"]) + + def test_claim_resolved_completed_requires_official_bound_evidence(self) -> None: + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="vanished-worker", + claim_key="claim-resolution-completion", + lease_seconds=0.1, + registry_path=self.registry, + ) + time.sleep(0.15) + coordinator.status(self.solution, registry_path=self.registry) + evidence = self.solution / "completion.json" + evidence.write_text('{"official":"passed"}', encoding="utf-8") + self.stages["discovery"]["status"] = "passed" + self.stages["discovery"]["artifacts"] = {"result": "completion.json"} + self.next_stage = "conversion" + unrelated = self.solution / "unrelated.json" + unrelated.write_text('{"not":"official evidence"}', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "exact current target and evidence"): + coordinator.record_event( + self.solution, + event_id="claim-completed-without-target", + kind="claim-resolved", + claim_id=claim["claimId"], + outcome="completed", + actor="solution-owner", + reference="reviewed official result", + evidence=unrelated, + registry_path=self.registry, + ) + completed = coordinator.record_event( + self.solution, + event_id="claim-completed-authoritatively", + kind="claim-resolved", + claim_id=claim["claimId"], + outcome="completed", + actor="solution-owner", + reference="reviewed official result", + target_sha256=None, + evidence=evidence, + registry_path=self.registry, + ) + self.assertTrue(completed["created"]) + current = coordinator.status(self.solution, registry_path=self.registry) + self.assertEqual("completed", current["claim"]["status"]) + + def test_active_claim_completion_survives_status_interleave(self) -> None: + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="completion-worker", + claim_key="status-interleave", + registry_path=self.registry, + ) + evidence = self.solution / "discovery-result.json" + evidence.write_text('{"result":"passed"}', encoding="utf-8") + self.stages["discovery"]["status"] = "passed" + self.stages["discovery"]["artifacts"] = {"result": "discovery-result.json"} + self.next_stage = "conversion" + + current = coordinator.status(self.solution, registry_path=self.registry) + self.assertEqual("active", current["claim"]["status"]) + persisted = json.loads(self.registry.read_text(encoding="utf-8")) + entry = next(iter(persisted["solutions"].values())) + self.assertEqual(claim["actionSha256"], entry["claim"]["action"]["actionSha256"]) + self.assertNotIn("discovery", {item["actionId"] for item in entry["actions"]}) + + wrong_evidence = self.solution / "wrong-result.json" + wrong_evidence.write_text('{"result":"not the official artifact"}', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "exact target, action, and official workflow evidence"): + coordinator.record_event( + self.solution, + event_id="status-interleave-wrong-evidence", + kind="executor-completed", + action_id="discovery", + target_sha256=claim["targetSha256"], + evidence=wrong_evidence, + registry_path=self.registry, + ) + completion = coordinator.record_event( + self.solution, + event_id="status-interleave-completion", + kind="executor-completed", + action_id="discovery", + target_sha256=claim["targetSha256"], + evidence=evidence, + registry_path=self.registry, + ) + self.assertTrue(completion["consumed"]) + self.assertEqual( + "completed", + coordinator.status(self.solution, registry_path=self.registry)["claim"]["status"], + ) + + def test_active_claim_completion_survives_reconcile_interleave(self) -> None: + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="completion-worker", + claim_key="reconcile-interleave", + registry_path=self.registry, + ) + evidence = self.solution / "discovery-result.json" + evidence.write_text('{"result":"passed"}', encoding="utf-8") + self.stages["discovery"]["status"] = "passed" + self.stages["discovery"]["artifacts"] = {"result": "discovery-result.json"} + self.next_stage = "conversion" + + coordinator.reconcile(self.solution, registry_path=self.registry) + persisted = json.loads(self.registry.read_text(encoding="utf-8")) + entry = next(iter(persisted["solutions"].values())) + self.assertEqual("active", entry["claim"]["status"]) + self.assertEqual(claim["actionSha256"], entry["claim"]["action"]["actionSha256"]) + completion = coordinator.record_event( + self.solution, + event_id="reconcile-interleave-completion", + kind="executor-completed", + action_id="discovery", + target_sha256=claim["targetSha256"], + evidence=evidence, + registry_path=self.registry, + ) + self.assertTrue(completion["consumed"]) + self.assertEqual( + "completed", + coordinator.status(self.solution, registry_path=self.registry)["claim"]["status"], + ) + + def _assert_terminal_retry_handoff_uses_original_claim( + self, terminal_status: str + ) -> None: + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor=f"{terminal_status}-worker", + claim_key=f"{terminal_status}-retry-handoff", + registry_path=self.registry, + ) + evidence = self.solution / f"{terminal_status}-result.json" + evidence.write_text( + json.dumps({"officialStatus": terminal_status}), + encoding="utf-8", + ) + self.stages["discovery"]["status"] = terminal_status + self.stages["discovery"]["artifacts"] = {"result": evidence.name} + self.next_stage = "discovery" + + refreshed = coordinator.status(self.solution, registry_path=self.registry) + retry_action = next( + item for item in refreshed["actions"] + if item["actionId"] == "discovery" + ) + self.assertNotEqual(claim["actionSha256"], retry_action["actionSha256"]) + self.assertEqual("blocked", retry_action["status"]) + persisted = json.loads(self.registry.read_text(encoding="utf-8")) + entry = next(iter(persisted["solutions"].values())) + self.assertEqual("active", entry["claim"]["status"]) + self.assertEqual( + claim["actionSha256"], + entry["claim"]["action"]["actionSha256"], + ) + wrong_evidence = self.solution / "wrong-retry-evidence.json" + wrong_evidence.write_text('{"not":"official"}', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "claim's exact target, action"): + coordinator.record_event( + self.solution, + event_id=f"{terminal_status}-wrong-evidence", + kind="executor-completed", + action_id="discovery", + target_sha256=claim["targetSha256"], + evidence=wrong_evidence, + registry_path=self.registry, + ) + completion = coordinator.record_event( + self.solution, + event_id=f"{terminal_status}-authoritative-handoff", + kind="executor-completed", + action_id="discovery", + target_sha256=claim["targetSha256"], + evidence=evidence, + registry_path=self.registry, + ) + self.assertTrue(completion["consumed"]) + self.assertEqual( + "completed", + coordinator.status(self.solution, registry_path=self.registry)["claim"]["status"], + ) + + def test_failed_stage_handoff_uses_original_claim_action_on_retry(self) -> None: + self._assert_terminal_retry_handoff_uses_original_claim("failed") + + def test_blocked_stage_handoff_uses_original_claim_action_on_retry(self) -> None: + self._assert_terminal_retry_handoff_uses_original_claim("blocked") + + def test_claim_completion_rejects_stale_qualification_target(self) -> None: + self._qualification_run() + self._enroll() + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="qualification-worker", + claim_key="stale-target-completion", + registry_path=self.registry, + ) + evidence = self.solution / "discovery-result.json" + evidence.write_text('{"result":"passed"}', encoding="utf-8") + self.stages["discovery"]["status"] = "passed" + self.stages["discovery"]["artifacts"] = {"result": "discovery-result.json"} + self.next_stage = "conversion" + + state_path = Path(self.real_status["statePath"]) + state_document = json.loads(state_path.read_text(encoding="utf-8")) + changed_customer_id = "956386d8-e2d4-4f09-905a-74b24313721f" + state_document["context"]["workspaceCustomerId"] = changed_customer_id + state_path.write_text(json.dumps(state_document), encoding="utf-8") + target_path = state_path.parent / "qualification-target.json" + target_document = json.loads(target_path.read_text(encoding="utf-8")) + target_document["workspaceCustomerId"] = changed_customer_id + target_path.write_text(json.dumps(target_document), encoding="utf-8") + self.workflow_state["context"]["workspaceCustomerId"] = changed_customer_id + + coordinator.reconcile(self.solution, registry_path=self.registry) + persisted = json.loads(self.registry.read_text(encoding="utf-8")) + entry = next(iter(persisted["solutions"].values())) + self.assertEqual("stale", entry["claim"]["status"]) + with self.assertRaisesRegex(ValueError, "event target does not match the current locked target"): + coordinator.record_event( + self.solution, + event_id="stale-target-completion", + kind="executor-completed", + action_id="discovery", + target_sha256=claim["targetSha256"], + evidence=evidence, + registry_path=self.registry, + ) + + def test_recurrent_ready_decision_gets_a_fresh_outbox_delivery(self) -> None: + self._enroll() + initial = coordinator.status(self.solution, registry_path=self.registry) + original = next( + item for item in initial["outbox"] + if item.get("kind") == "action-decision" + and item["action"]["actionId"] == "discovery" + ) + coordinator.acknowledge_outbox( + self.solution, + delivery_id=original["deliveryId"], + actor="delivery-adapter", + registry_path=self.registry, + ) + claim = coordinator.claim( + self.solution, + action_id="discovery", + executor="temporary-worker", + claim_key="decision-recurrence", + lease_seconds=0.02, + registry_path=self.registry, + ) + time.sleep(0.04) + coordinator.reconcile(self.solution, registry_path=self.registry) + evidence = self.solution / "not-started.json" + evidence.write_text('{"requestIssued":false}', encoding="utf-8") + coordinator.record_event( + self.solution, + event_id="resolve-recurrence", + kind="claim-resolved", + claim_id=claim["claimId"], + outcome="not-started", + actor="solution-owner", + reference="checked executor ledger", + evidence=evidence, + registry_path=self.registry, + ) + ready = coordinator.status(self.solution, registry_path=self.registry) + recurrent = [ + item for item in ready["outbox"] + if item.get("kind") == "action-decision" + and item.get("action", {}).get("actionId") == "discovery" + and item.get("status") == "pending" + ] + self.assertTrue(recurrent) + self.assertNotEqual(original["deliveryId"], recurrent[-1]["deliveryId"]) + self.assertGreater( + recurrent[-1]["decisionGeneration"], + original["decisionGeneration"], + ) + + def test_same_event_id_is_idempotent_only_within_its_run(self) -> None: + self._enroll(owner="accountable-owner") + first = coordinator.record_event( + self.solution, + event_id="cross-run-handoff", + kind="owner-handoff", + actor="accountable-owner", + new_owner="next-owner", + reference="handoff-ticket", + registry_path=self.registry, + ) + self.assertTrue(first["created"]) + + first_solution = self.solution + second_solution = self.root / "Solutions" / "Cisco-Second-Run" + second_solution.mkdir(parents=True) + self.solution = second_solution + self.workflow_state = initialize_workflow( + self.solution, + workflow_profile="authoring", + version_bump="none", + ) + self.run = self.workflow_state["runId"] + self.real_status = workflow_status(self.solution) + self.stages = json.loads( + Path(self.real_status["statePath"]).read_text(encoding="utf-8") + )["stages"] + self.next_stage = "discovery" + self._enroll(owner="accountable-owner") + second = coordinator.record_event( + self.solution, + event_id="cross-run-handoff", + kind="owner-handoff", + actor="accountable-owner", + new_owner="next-owner", + reference="handoff-ticket", + registry_path=self.registry, + ) + self.assertTrue(second["created"]) + self.assertEqual(first["eventId"], second["eventId"]) + persisted = json.loads(self.registry.read_text(encoding="utf-8")) + matching = [ + item for item in persisted["events"] + if item["eventId"] == "cross-run-handoff" + ] + self.assertEqual(2, len(matching)) + self.assertNotEqual( + matching[0]["payload"]["entryId"], + matching[1]["payload"]["entryId"], + ) + self.assertNotEqual(first_solution, self.solution) + + def test_watchdog_reports_missing_owner_idle_work_and_unconsumed_handoff(self) -> None: + self._enroll() + registry_document = json.loads(self.registry.read_text(encoding="utf-8")) + next(iter(registry_document["solutions"].values()))["owner"] = None + self.registry.write_text(json.dumps(registry_document), encoding="utf-8") + stale = coordinator.status( + self.solution, + registry_path=self.registry, + idle_seconds=0, + ) + self.assertIn("missing-owner", {item["type"] for item in stale["watchdog"]}) + + self._enroll(owner="reassigned-owner") + ready = coordinator.status( + self.solution, + registry_path=self.registry, + idle_seconds=0, + ) + self.assertIn("ready-idle", {item["type"] for item in ready["watchdog"]}) + handoff = self.solution / "unmatched-handoff.json" + handoff.write_text('{"stage":"not-yet-finished"}', encoding="utf-8") + event = coordinator.record_event( + self.solution, + event_id="unfinished-handoff", + kind="executor-completed", + action_id="discovery", + target_sha256=None, + evidence=handoff, + registry_path=self.registry, + ) + self.assertFalse(event["consumed"]) + current = coordinator.status( + self.solution, + registry_path=self.registry, + idle_seconds=0, + ) + warnings = {item["type"] for item in current["watchdog"]} + self.assertIn("unconsumed-handoff", warnings) + self.assertIn("ready-idle", warnings) + + def test_restart_keeps_pending_outbox_and_ack_does_not_grant_approval(self) -> None: + self._qualification_run() + self.next_stage = "deployment" + enrolled = self._enroll() + approval = coordinator.record_event( + self.solution, + event_id="deployment-approval-request", + kind="approval-requested", + action_id="deployment", + purpose="customDetection", + target_sha256=enrolled["target"]["sha256"], + actor="solution-owner", + registry_path=self.registry, + ) + self.assertFalse(approval["approvalGranted"]) + pending = next( + item for item in approval["reconciliation"]["outbox"] + if item["kind"] == "approval-request" + ) + restarted = coordinator.status(self.solution, registry_path=self.registry) + self.assertIn( + pending["deliveryId"], + {item["deliveryId"] for item in restarted["outbox"]}, + ) + self.assertIn( + "undelivered-approval", + { + item["type"] + for item in coordinator.status( + self.solution, + registry_path=self.registry, + idle_seconds=0, + )["watchdog"] + }, + ) + ack = coordinator.acknowledge_outbox( + self.solution, + delivery_id=pending["deliveryId"], + actor="adapter", + registry_path=self.registry, + ) + self.assertFalse(ack["approvalGranted"]) + current = coordinator.status(self.solution, registry_path=self.registry) + self.assertEqual("approval-needed", current["actions"][0]["status"]) + acked = next(item for item in current["outbox"] if item["deliveryId"] == pending["deliveryId"]) + self.assertEqual("acknowledged", acked["status"]) + + def test_validation_source_completion_is_independent_of_xdr_blocker(self) -> None: + self.stages["conversion"]["status"] = "passed" + self.stages["validation"]["status"] = "blocked" + self.next_stage = "validation" + graph_report = self.solution / "Logs" / "sentinel-xdr-migration" / self.run / "runtime-validation.graph.json" + graph_report.parent.mkdir(parents=True, exist_ok=True) + graph_report.write_text('{"status":"failed"}', encoding="utf-8") + patcher = mock.patch.object( + coordinator, + "current_runtime_evidence", + return_value={ + "sourceStatus": "passed", + "graphReport": None, + "issues": ["Advanced Hunting is blocked"], + }, + ) + patcher.start() + self._patches.append(patcher) + result = self._enroll() + actions = {item["actionId"]: item for item in result["actions"]} + self.assertEqual("complete", actions["validation:sentinel-runtime"]["status"]) + self.assertEqual("blocked", actions["validation:advanced-hunting"]["status"]) + self.assertEqual("validation", result["nextStage"]) + + def test_source_query_remains_independently_actionable_under_xdr_blocker(self) -> None: + self.stages["conversion"]["status"] = "passed" + self.stages["validation"]["status"] = "blocked" + self.next_stage = "validation" + graph_report = self.solution / "Logs" / "sentinel-xdr-migration" / self.run / "runtime-validation.graph.json" + graph_report.parent.mkdir(parents=True, exist_ok=True) + graph_report.write_text('{"status":"failed"}', encoding="utf-8") + patcher = mock.patch.object( + coordinator, + "current_runtime_evidence", + return_value={ + "sourceStatus": "blocked", + "graphReport": None, + "issues": ["Advanced Hunting query failed"], + }, + ) + patcher.start() + self._patches.append(patcher) + result = self._enroll() + actions = {item["actionId"]: item for item in result["actions"]} + self.assertEqual("approval-needed", actions["validation:sentinel-runtime"]["status"]) + self.assertEqual("blocked", actions["validation:advanced-hunting"]["status"]) + + def test_advanced_hunting_and_custom_detection_dependencies_are_purpose_scoped(self) -> None: + self._qualification_run() + self.stages["conversion"]["status"] = "passed" + self.next_stage = "validation" + declarations = [ + "validation:advanced-hunting=graph-auth:advancedHunting", + "validation:advanced-hunting=graph-auth:customDetection", + ] + enrolled = self._enroll(dependencies=declarations) + target_hash = enrolled["target"]["sha256"] + evidence = self.solution / "graph-auth.json" + evidence.write_text('{"granted":true}', encoding="utf-8") + event = coordinator.record_event( + self.solution, + event_id="ah-auth-ready", + kind="shared-dependency-completed", + action_id="graph-auth", + purpose="advancedHunting", + target_sha256=target_hash, + evidence=evidence, + actor="identity-owner", + registry_path=self.registry, + ) + self.assertTrue(event["created"]) + reconciled = coordinator.reconcile(self.solution, registry_path=self.registry) + hunting = next( + item for item in reconciled["actions"] + if item["actionId"] == "validation:advanced-hunting" + ) + purposes = {item["purpose"]: item["status"] for item in hunting["sharedPrerequisites"]} + self.assertEqual("reported", purposes["advancedHunting"]) + self.assertEqual("missing", purposes["customDetection"]) + self.assertEqual("blocked", hunting["status"]) + + def test_stale_target_or_query_approval_is_not_claimable(self) -> None: + self._qualification_run() + self.stages["conversion"]["status"] = "passed" + self.next_stage = "validation" + enrolled = self._enroll() + hunting = next( + item for item in enrolled["actions"] + if item["actionId"] == "validation:advanced-hunting" + ) + event = coordinator.record_event( + self.solution, + event_id="approval-1", + kind="approval-requested", + action_id=hunting["actionId"], + purpose="advancedHunting", + target_sha256=enrolled["target"]["sha256"], + actor="operator", + registry_path=self.registry, + ) + self.assertFalse(event["approvalGranted"]) + approval_delivery_id = next( + item["deliveryId"] + for item in event["reconciliation"]["outbox"] + if item.get("eventId") == "approval-1" + ) + changed_plan = mock.patch.object( + coordinator, + "runtime_validation_plan", + return_value={ + "rules": [{ + "sentinelQuerySha256": "c" * 64, + "advancedHuntingQuerySha256": "d" * 64, + }] + }, + ) + changed_plan.start() + self._patches.append(changed_plan) + refreshed = coordinator.reconcile(self.solution, registry_path=self.registry) + fresh = next( + item for item in refreshed["actions"] + if item["actionId"] == "validation:advanced-hunting" + ) + self.assertNotEqual(hunting["actionSha256"], fresh["actionSha256"]) + after_query_change = coordinator.status( + self.solution, + registry_path=self.registry, + ) + stale_approval = next( + item for item in after_query_change["outbox"] + if item["deliveryId"] == approval_delivery_id + ) + self.assertEqual("superseded", stale_approval["status"]) + state_path = Path(self.real_status["statePath"]) + state_document = json.loads(state_path.read_text(encoding="utf-8")) + state_context = state_document["context"] + changed_customer_id = "956386d8-e2d4-4f09-905a-74b24313721f" + state_context["workspaceCustomerId"] = changed_customer_id + state_path.write_text(json.dumps(state_document), encoding="utf-8") + target_path = Path(self.real_status["statePath"]).parent / "qualification-target.json" + target_document = json.loads(target_path.read_text(encoding="utf-8")) + target_document["workspaceCustomerId"] = changed_customer_id + target_path.write_text(json.dumps(target_document), encoding="utf-8") + self.workflow_state["context"]["workspaceCustomerId"] = changed_customer_id + coordinator.reconcile(self.solution, registry_path=self.registry) + with self.assertRaisesRegex(ValueError, "not claimable"): + coordinator.claim( + self.solution, + action_id="validation:advanced-hunting", + executor="dispatcher", + claim_key="approval-cannot-dispatch", + registry_path=self.registry, + ) + with self.assertRaisesRegex(ValueError, "target does not match"): + coordinator.record_event( + self.solution, + event_id="stale-approval", + kind="approval-requested", + action_id=fresh["actionId"], + purpose="advancedHunting", + target_sha256="0" * 64, + actor="operator", + registry_path=self.registry, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_customer_usage_attribution.py b/Tools/SentinelToXDRMigration/tests/test_customer_usage_attribution.py new file mode 100644 index 00000000000..b9771a1e749 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_customer_usage_attribution.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path + +from sentinel_xdr_migration.packaging import V31_TOOLKIT_PATHS + + +ROOT = Path(__file__).resolve().parents[3] +PACKAGER = ROOT / "Tools" / "Create-Azure-Sentinel-Solution" +TRACKING_ID = "pid-399fb80b-c914-4257-b61e-91ab73733888-partnercenter" +ABSENT = object() + + +def ps_literal(value: Path | str) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +@unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") +class AttributionTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix=".v3-1-test-", dir=ROOT) + self.addCleanup(self.temp.cleanup) + self.repository = Path(self.temp.name) + self.solution = self.repository / "Solutions" / "Sample" + self.data = self.solution / "Data" / "Solution_Sample.json" + self.metadata = self.solution / "SolutionMetadata.json" + self.data.parent.mkdir(parents=True) + self.metadata.write_text("{}", encoding="utf-8") + + def run_ps(self, command: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference = 'Stop'; $env:SENTINEL_SKIP_ATTRIBUTION_LOOKUP = '1'; " + + getattr(self, "ps_prelude", "") + command], + cwd=self.repository, capture_output=True, text=True, + ) + + def write_metadata(self, tracking_id=ABSENT) -> None: + value = { + "publisherId": "testpublisher", + "offerId": "test-sentinel", + "version": "3.1.0", + "firstPublishDate": "2021-01-01", + "providers": ["Microsoft"], + "categories": {"domains": ["Security"]}, + "support": {"name": "Test", "tier": "Microsoft", "link": "https://example.com"}, + } + if tracking_id is not ABSENT: + value["trackingId"] = tracking_id + self.metadata.write_text(json.dumps(value), encoding="utf-8") + + def helper(self, tracking_id=ABSENT, warn: bool = True) -> subprocess.CompletedProcess: + self.write_metadata(tracking_id) + return self.run_ps( + f". {ps_literal(PACKAGER / 'common/customerUsageAttribution.ps1')}; " + "$template = [pscustomobject]@{ resources = @() }; " + f"Add-CustomerUsageAttribution -SolutionMetadataPath {ps_literal(self.metadata)} " + f"-Template $template -WarnIfMissing ${str(warn).lower()}; " + "$template | ConvertTo-Json -Depth 30" + ) + + def test_absent_null_and_blank_continue_with_actionable_warning(self) -> None: + for value in (ABSENT, None, "", " \t"): + with self.subTest(value=value): + result = self.helper(value) + self.assertEqual(0, result.returncode, result.stderr) + for text in ("No attribution marker", "Partner Center", "Technical configuration", + "SolutionMetadata.json", "rebuild", "https://learn.microsoft.com/"): + self.assertIn(text, result.stdout) + result = self.helper(warn=False) + self.assertEqual({"resources": []}, json.loads(result.stdout)) + + def test_complete_ids_are_preserved_and_marker_shape_is_exact(self) -> None: + for tracking_id in (TRACKING_ID, "pid-contoso-myoffer-partnercenter"): + with self.subTest(tracking_id=tracking_id): + result = self.helper(tracking_id) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual({"resources": [self.marker(tracking_id)]}, json.loads(result.stdout)) + + @staticmethod + def marker(tracking_id: str) -> dict: + return { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": tracking_id, + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [], + }, + }, + } + + def test_invalid_supplied_ids_fail_instead_of_synthesizing(self) -> None: + for value in ("399fb80b-c914-4257-b61e-91ab73733888", 123, {}, [], + "pid-", "pid-bad/name", "[variables('trackingId')]", + TRACKING_ID + " ", "pid-" + "a" * 61, "pid-ok\n"): + with self.subTest(value=value): + result = self.helper(value) + self.assertNotEqual(0, result.returncode) + self.assertIn("Invalid trackingId", result.stderr) + + def test_duplicate_marker_is_rejected(self) -> None: + self.write_metadata(TRACKING_ID) + add_marker = ( + f"Add-CustomerUsageAttribution -SolutionMetadataPath {ps_literal(self.metadata)} " + "-Template $template; " + ) + command = ( + f". {ps_literal(PACKAGER / 'common/customerUsageAttribution.ps1')}; " + "$template = [pscustomobject]@{ resources = @() }; " + ) + result = self.run_ps(command + add_marker + add_marker) + self.assertNotEqual(0, result.returncode) + self.assertIn("Duplicate customer usage", result.stderr) + + def prepare_integration(self) -> Path: + for relative in V31_TOOLKIT_PATHS: + source = ROOT / relative + destination = self.repository / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + tools = self.repository / "Tools" / "Create-Azure-Sentinel-Solution" + entry = tools / "V3" / "createSolutionV3_1.ps1" + # Run real packaging, but isolate output and exclude the unrelated full validators. + (tools / "common/commonFunctions.ps1").write_text( + f". {ps_literal(PACKAGER / 'common/commonFunctions.ps1')}\n" + f"function global:git {{ $global:LASTEXITCODE = 0; {ps_literal(self.repository)} }}\n" + "function RunArmTtkOnPackage { }\n" + "function CheckJsonIsValid { }\n", + encoding="utf-8", + ) + source_id = "11111111-2222-3333-4444-555555555555" + rule = { + "id": source_id, "name": "Test rule", "description": "Packaging test.", + "severity": "Medium", "kind": "Scheduled", "version": "1.0.0", + "query": "print Test = 1", "queryFrequency": "1h", "queryPeriod": "1h", + "triggerOperator": "gt", "triggerThreshold": 0, "tactics": ["Execution"], + "requiredDataConnectors": [], "entityMappings": [], + } + detection = { + "schemaVersion": "1.0.0", "version": "3.1.0", + "kind": "CustomDetection", "resourceType": "Microsoft.Security/detectionRules", + "apiVersion": "2025-06-01", + "contentProvenance": {"source": { + "id": source_id, "version": "1.0.0", + "platform": "Microsoft Sentinel", "kind": "AnalyticsRule", + }}, + "properties": { + "id": source_id, + "displayName": "Test detection", "status": "disabled", + "queryCondition": {"queryText": "print Test = 1"}, + "detectionAction": {"alertTemplate": {"tactics": [{"tactic": "Execution"}], "entityMappings": {}}}, + }, + } + for folder, document in (("Analytic Rules", rule), ("XDR Detections", detection)): + path = self.solution / folder / "Test.yaml" + path.parent.mkdir() + path.write_text("---\n" + json.dumps(document), encoding="utf-8") + self.data.write_text(json.dumps({ + "Name": "Sample", "Author": "Test", "Description": "Test package", + "Version": "3.1.0", "TemplateSpec": True, + "Analytic Rules": ["Analytic Rules/Test.yaml"], + "XDR Detections": ["XDR Detections/Test.yaml"], + "Metadata": "SolutionMetadata.json", + "trackingId": "pid-stale-data-partnercenter", + }), encoding="utf-8") + return entry + + def package( + self, entry: Path, pipeline: bool, *, + version_bump: str = "none", version_mode: str = "local", + calculated_version: str = "3.1.0", + skip_lookup: bool = False, + ) -> subprocess.CompletedProcess: + extra = " -SkipAttributionLookup" if skip_lookup else "" + if not pipeline: + return self.run_ps( + f"& {ps_literal(entry)} -SolutionDataFolderPath {ps_literal(self.data.parent)} " + f"-VersionMode {version_mode} -VersionBump {version_bump}{extra}" + ) + return self.run_ps( + f"$data = Get-Content -Raw {ps_literal(self.data)} | ConvertFrom-Json; " + f"$metadata = Get-Content -Raw {ps_literal(self.metadata)} | ConvertFrom-Json; " + "foreach ($property in $metadata.PSObject.Properties) { " + "if ($property.Name -notin @('version', 'trackingId')) { " + "$data | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value } }; " + f"& {ps_literal(entry)} {ps_literal(str(self.repository) + '/')} 'Sample' $data " + "'Solution_Sample.json' 'Data Connectors' 'Data' 'test' '1' '1' " + f"{ps_literal(calculated_version)} '3.0.0' $false{extra}" + ) + + def mock_lookup(self, *, catalog=None, document=None, failure="", mutation="") -> None: + if catalog is None: + catalog = {"items": [{ + "publisherId": "testpublisher", "offerId": "test-sentinel", + "ocpSolutionId": "must-not-be-used", + "plans": [{ + "id": "0001", "planId": "different-plan-id", + "artifacts": [{ + "type": "Template", "name": "DefaultTemplate", + "uri": "https://catalogartifact.azureedge.net/publicartifacts/test/mainTemplate.json", + }], + }], + }]} + if document is None: + document = { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", "resources": [self.marker(TRACKING_ID)], + } + document["resources"][0]["apiVersion"] = "2020-10-01" + for name, value in (("catalog.json", catalog), ("published.json", document)): + (self.repository / name).write_text(json.dumps(value), encoding="utf-8") + self.ps_prelude = ( + "$env:SENTINEL_SKIP_ATTRIBUTION_LOOKUP = '0'; " + "$global:lookupCalls = 0; " + "function global:Invoke-WebRequest { param($Uri, $Method, $TimeoutSec, $OperationTimeoutSeconds, $MaximumRedirection) " + "if ($TimeoutSec -ne 20 -or $MaximumRedirection -ne 0 -or $Method -ne 'Get') { throw 'Unbounded request' }; " + "if ((Get-Command Microsoft.PowerShell.Utility\\Invoke-WebRequest).Parameters.ContainsKey('OperationTimeoutSeconds') " + "-and $OperationTimeoutSeconds -ne 20) { throw 'Unbounded read' }; " + "$global:lookupCalls++; " + + failure + + "if ($Uri -like 'https://catalogapi.azure.com/*') { " + "if ([uri]::UnescapeDataString($Uri) -notlike \"*publisherId eq 'testpublisher' and offerId eq 'test-sentinel'*\") " + "{ throw 'Wrong catalog identity filter' }; " + f"$content = Get-Content -Raw {ps_literal(self.repository / 'catalog.json')} " + "} else { " + + mutation + + f"$content = Get-Content -Raw {ps_literal(self.repository / 'published.json')} " + "}; [pscustomobject]@{ Content = $content } }; " + ) + + def lookup_helper(self, extra="") -> subprocess.CompletedProcess: + return self.run_ps( + f". {ps_literal(PACKAGER / 'common/customerUsageAttribution.ps1')}; " + "$template = [pscustomobject]@{ resources = @() }; " + f"Add-CustomerUsageAttribution -SolutionMetadataPath {ps_literal(self.metadata)} " + f"-Template $template {extra}; " + f"$template | ConvertTo-Json -Depth 30 | Set-Content {ps_literal(self.repository / 'result.json')}; " + "Write-Host \"LOOKUP_CALLS=$global:lookupCalls\"" + ) + + def test_discovery_persists_exact_id_preserving_bytes_and_replay_is_offline(self) -> None: + for value in (ABSENT, None, "", " \t"): + with self.subTest(value=value): + self.write_metadata(value) + metadata = json.loads(self.metadata.read_text()) + metadata["nested"] = {"trackingId": "never replace é", "text": "line1\nline2"} + original = json.dumps(metadata, indent="\t", ensure_ascii=False).replace("\n", "\r\n") + "\r\n" + self.metadata.write_bytes(b"\xef\xbb\xbf" + original.encode()) + self.mock_lookup() + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + expected = dict(metadata, trackingId=TRACKING_ID) + self.assertEqual(expected, json.loads(self.metadata.read_text(encoding="utf-8-sig"))) + updated = self.metadata.read_bytes() + if value is ABSENT: + expected_text = original.replace('{\r\n\t', '{\r\n\t"trackingId": "' + TRACKING_ID + '",\r\n\t', 1) + else: + expected_text = original.replace('"trackingId": ' + json.dumps(value), + '"trackingId": "' + TRACKING_ID + '"', 1) + self.assertEqual(b"\xef\xbb\xbf" + expected_text.encode(), updated) + self.assertEqual({"resources": [self.marker(TRACKING_ID)]}, + json.loads((self.repository / "result.json").read_text())) + for text in ("publisherId=testpublisher", "offerId=test-sentinel", + "planId=different-plan-id", "templateURL=https://", + "updated metadata=", "LOOKUP_CALLS=2"): + self.assertIn(text, result.stdout) + self.mock_lookup(failure="throw 'NETWORK MUST NOT RUN'; ") + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("LOOKUP_CALLS=0", result.stdout) + self.assertEqual(updated, self.metadata.read_bytes()) + + def test_ambiguous_or_nonconformant_remote_documents_warn_without_writes(self) -> None: + self.mock_lookup() + catalog = json.loads((self.repository / "catalog.json").read_text()) + document = json.loads((self.repository / "published.json").read_text()) + import copy + cases = [] + for label in ("wrong publisher", "wrong offer", "multiple offers", "multiple plans", + "multiple artifacts", "missing uri", "missing plan id", "catalog array", + "pagination"): + value = copy.deepcopy(catalog) + offer = value["items"][0] + if label == "wrong publisher": + offer["publisherId"] = "other" + elif label == "wrong offer": + offer["offerId"] = "other" + elif label == "multiple offers": + value["items"].append(copy.deepcopy(offer)) + elif label == "multiple plans": + offer["plans"].append(copy.deepcopy(offer["plans"][0])) + elif label == "multiple artifacts": + offer["plans"][0]["artifacts"] *= 2 + elif label == "missing uri": + del offer["plans"][0]["artifacts"][0]["uri"] + elif label == "missing plan id": + del offer["plans"][0]["planId"] + elif label == "catalog array": + value = [] + else: + value["nextLink"] = "https://catalogapi.azure.com/next" + cases.append((label, value, document)) + for label in ("missing marker", "multiple markers", "functional wrapper", "linked template", + "conditional marker", "expression", "bad id", "root array", "unversioned object", + "bad schema", "bad version", "scalar resource", "bad marker api"): + value = copy.deepcopy(document) + marker = value["resources"][0] + if label == "missing marker": + value["resources"] = [] + elif label == "multiple markers": + value["resources"].append(copy.deepcopy(marker)) + elif label == "functional wrapper": + marker["properties"]["template"]["resources"] = [{"type": "Microsoft.Security/detectionRules"}] + elif label == "linked template": + marker["properties"]["templateLink"] = {"uri": "https://example.invalid"} + elif label == "conditional marker": + marker["condition"] = False + elif label == "expression": + marker["name"] = "[concat('pid-', 'not-literal')]" + elif label == "bad id": + marker["name"] = "pid-invalid/name" + elif label == "root array": + value = [value] + elif label == "bad schema": + value["$schema"] = "https://schemaXmanagementXazure.com/schemas/2019-04-01/deploymentTemplate.json#" + elif label == "bad version": + value["contentVersion"] = "" + elif label == "scalar resource": + value["resources"].append("invalid resource") + elif label == "bad marker api": + marker["apiVersion"] = "invalid" + else: + value["resources"] = {"marker": marker} + cases.append((label, catalog, value)) + for label, catalog_value, document_value in cases: + with self.subTest(label=label): + self.write_metadata() + before = self.metadata.read_bytes() + self.mock_lookup(catalog=catalog_value, document=document_value) + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("CUSTOMER USAGE ATTRIBUTION: Published lookup failed [", result.stdout) + self.assertNotIn("ATTRIBUTION SOURCE:", result.stdout) + self.assertEqual(before, self.metadata.read_bytes()) + self.assertEqual({"resources": []}, json.loads((self.repository / "result.json").read_text())) + + def test_symbolic_root_uses_literal_name_not_symbolic_key(self) -> None: + self.write_metadata() + self.mock_lookup() + path = self.repository / "published.json" + document = json.loads(path.read_text()) + document["languageVersion"] = "2.0" + document["resources"] = {"symbolicMarker": document["resources"][0]} + path.write_text(json.dumps(document)) + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(TRACKING_ID, json.loads(self.metadata.read_text())["trackingId"]) + + def test_network_and_json_failures_are_classified_and_redacted(self) -> None: + failures = ( + ("throw [System.TimeoutException]::new('secret?sig=hidden'); ", "timeout"), + ("throw [System.Net.Http.HttpRequestException]::new('secret?sig=hidden'); ", "network/read failure"), + ("$e = [Exception]::new('secret?sig=hidden'); " + "$e | Add-Member Response ([pscustomobject]@{StatusCode=503}); throw $e; ", "HTTP 503"), + ("return [pscustomobject]@{ Content = 'invalid json secret?sig=hidden' }; ", "invalid JSON"), + ) + for stage in ("catalog", "template"): + for failure, classification in failures: + with self.subTest(stage=stage, classification=classification): + self.write_metadata() + before = self.metadata.read_bytes() + condition = "$global:lookupCalls -eq " + ("1" if stage == "catalog" else "2") + self.mock_lookup(failure=f"if ({condition}) {{ {failure} }}; ") + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn(f"{stage}: {classification}", result.stdout) + self.assertNotIn("hidden", result.stdout + result.stderr) + self.assertEqual(before, self.metadata.read_bytes()) + + def test_duplicate_json_properties_are_not_silently_overwritten(self) -> None: + self.write_metadata() + before = self.metadata.read_bytes() + self.mock_lookup() + path = self.repository / "published.json" + path.write_text(path.read_text().replace( + '"resources": [', '"resources": [], "resources": [', 1)) + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("template: invalid JSON", result.stdout) + self.assertEqual(before, self.metadata.read_bytes()) + + def test_protected_template_url_query_is_not_logged(self) -> None: + self.write_metadata() + self.mock_lookup() + path = self.repository / "catalog.json" + catalog = json.loads(path.read_text()) + catalog["items"][0]["plans"][0]["artifacts"][0]["uri"] += "?sig=hidden-secret&se=expiry" + path.write_text(json.dumps(catalog)) + result = self.lookup_helper() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("ATTRIBUTION SOURCE:", result.stdout) + self.assertNotIn("hidden-secret", result.stdout + result.stderr) + self.assertNotIn("?sig=", result.stdout + result.stderr) + + def test_explicit_values_and_skip_controls_never_lookup(self) -> None: + for value, extra in ((TRACKING_ID, ""), ("bad/id", ""), (ABSENT, "-SkipAttributionLookup"), + (ABSENT, "")): + with self.subTest(value=value, extra=extra): + self.write_metadata(value) + before = self.metadata.read_bytes() + self.mock_lookup(failure="throw 'NETWORK MUST NOT RUN'; ") + if value is ABSENT and not extra: + self.ps_prelude += "$env:SENTINEL_SKIP_ATTRIBUTION_LOOKUP = '1'; " + result = self.lookup_helper(extra) + if value == "bad/id": + self.assertNotEqual(0, result.returncode) + self.assertIn("Invalid trackingId", result.stderr) + else: + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("LOOKUP_CALLS=0", result.stdout) + self.assertNotIn("NETWORK MUST NOT RUN", result.stdout + result.stderr) + self.assertEqual(before, self.metadata.read_bytes()) + + def test_write_failure_and_concurrent_edit_are_hard_errors(self) -> None: + for failure in ("write", "concurrent"): + with self.subTest(failure=failure): + self.write_metadata() + before = self.metadata.read_bytes() + if failure == "write": + mutation = f"$global:lockedMetadata = [IO.File]::Open({ps_literal(self.metadata)}, 'Open', 'Read', 'None'); " + else: + mutation = f"[IO.File]::AppendAllText({ps_literal(self.metadata)}, ' '); " + self.mock_lookup(mutation=mutation) + result = self.lookup_helper() + self.assertNotEqual(0, result.returncode) + self.assertIn("metadata write failed", result.stderr) + self.assertNotIn("ATTRIBUTION SOURCE:", result.stdout) + self.assertEqual(before + (b" " if failure == "concurrent" else b""), self.metadata.read_bytes()) + self.assertEqual([], list(self.solution.glob("*.attribution-*"))) + + def test_lookup_local_and_pipeline_use_authoritative_metadata_and_keep_cached_id_on_bump(self) -> None: + entry = self.prepare_integration() + for pipeline in (False, True): + for skip in (False, True): + with self.subTest(pipeline=pipeline, skip=skip): + self.write_metadata() + self.mock_lookup() + result = self.package(entry, pipeline, skip_lookup=skip) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + markers = [r for r in template["resources"] if r.get("name") == TRACKING_ID] + self.assertEqual(0 if skip else 1, len(markers)) + self.assertEqual(None if skip else TRACKING_ID, + json.loads(self.metadata.read_text()).get("trackingId")) + self.write_metadata() + self.mock_lookup() + result = self.package(entry, False, version_bump="patch") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + metadata = json.loads(self.metadata.read_text()) + self.assertEqual("3.1.1", metadata["version"]) + self.assertEqual(TRACKING_ID, metadata["trackingId"]) + + def test_failed_packaging_prerequisite_does_not_persist_discovery(self) -> None: + entry = self.prepare_integration() + self.write_metadata() + before = self.metadata.read_bytes() + data = json.loads(self.data.read_text()) + data["Version"] = "4.0.0" + self.data.write_text(json.dumps(data)) + self.mock_lookup() + result = self.package(entry, False) + self.assertNotEqual(0, result.returncode) + self.assertIn("V3.1 version policy", result.stdout + result.stderr) + self.assertNotIn("ATTRIBUTION SOURCE:", result.stdout) + self.assertEqual(before, self.metadata.read_bytes()) + + def test_local_and_positional_pipeline_preserve_nested_xdr_and_authoritative_id(self) -> None: + entry = self.prepare_integration() + self.write_metadata(TRACKING_ID) + before = self.data.read_bytes(), self.metadata.read_bytes() + for pipeline in (False, True): + with self.subTest(pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + package = self.solution / "Package" + template = json.loads((package / "mainTemplate.json").read_text(encoding="utf-8-sig")) + self.assertNotIn("languageVersion", template) + self.assertIsInstance(template["resources"], list) + deployments = [r for r in template["resources"] if r["type"] == "Microsoft.Resources/deployments"] + self.assertEqual(2, len(deployments)) + self.assertIn(self.marker(TRACKING_ID), deployments) + xdr = next(r for r in deployments if r["name"] != TRACKING_ID) + self.assertEqual("[parameters('DeployCustomDetection')]", xdr["condition"]) + self.assertEqual("2.0", xdr["properties"]["template"]["languageVersion"]) + self.assertIsInstance(xdr["properties"]["template"]["resources"], dict) + self.assertEqual( + "disabled", + xdr["properties"]["template"]["resources"]["detectionRule"]["properties"]["status"], + ) + analytic = next(r for r in template["resources"] + if r.get("properties", {}).get("contentKind") == "AnalyticsRule") + self.assertEqual("[parameters('DeployAnalyticsRule')]", analytic["condition"]) + self.assertEqual("3.0.0", analytic["properties"]["contentSchemaVersion"]) + self.assertEqual("3.1.0", template["variables"]["_solutionVersion"]) + self.assertNotIn("trackingId", json.dumps(template)) + self.assertNotIn("pid-stale-data", json.dumps(template)) + with zipfile.ZipFile(package / "3.1.0.zip") as archive: + self.assertEqual(template, json.loads(archive.read("mainTemplate.json").decode("utf-8-sig"))) + self.assertEqual(before, (self.data.read_bytes(), self.metadata.read_bytes())) + + def test_entry_points_warn_for_missing_id_and_fail_for_invalid_id(self) -> None: + entry = self.prepare_integration() + for pipeline in (False, True): + with self.subTest(pipeline=pipeline): + self.write_metadata() + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("CUSTOMER USAGE ATTRIBUTION:", result.stdout) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text(encoding="utf-8-sig")) + self.assertEqual(1, len([r for r in template["resources"] if r["type"] == "Microsoft.Resources/deployments"])) + self.write_metadata("bad/id") + result = self.package(entry, pipeline) + self.assertNotEqual(0, result.returncode) + self.assertIn("Invalid trackingId", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_dcr_readiness.py b/Tools/SentinelToXDRMigration/tests/test_dcr_readiness.py new file mode 100644 index 00000000000..ef8a217b891 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_dcr_readiness.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +INGESTION_ROOT = REPOSITORY_ROOT / "Tools" / "AzureMonitorLogsIngestion" +sys.path.insert(0, str(INGESTION_ROOT)) + +from azure_monitor_logs_ingestion.contract import load_contract # noqa: E402 +from azure_monitor_logs_ingestion.registry import resolve_contract # noqa: E402 +from sentinel_xdr_migration import dcr_readiness # noqa: E402 + + +TARGET = { + "tenantId": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceResourceId": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws" + ), + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", +} + + +def contract_plan() -> dict: + resolution = resolve_contract("CommonSecurityLog") + return { + "status": "planned", + "targetSha256": "target-fingerprint", + "inventory": {"sha256": "inventory-fingerprint"}, + "groups": [ + { + "table": "CommonSecurityLog", + "schemaFingerprint": resolution["schemaFingerprint"], + "transformFingerprint": resolution["transformFingerprint"], + "ruleIds": ["11111111-2222-3333-4444-555555555555"], + } + ], + "rules": [ + { + "ruleId": "11111111-2222-3333-4444-555555555555", + "status": "manual-review-required", + "blockers": ["fixture review is pending"], + } + ], + } + + +def candidate_resources(): + resolution = resolve_contract("CommonSecurityLog") + contract_path = INGESTION_ROOT / resolution["contractPath"] + contract = load_contract(contract_path) + dce_id, dcr_id, table_id = dcr_readiness._arm_resource_ids(TARGET, contract) + dce = { + "id": dce_id, + "tags": { + "managed-by": "azure-monitor-logs-ingestion", + "ingestion-contract": contract.name, + }, + "properties": {"logsIngestion": {"endpoint": "https://example.invalid"}}, + } + dcr = { + "id": dcr_id, + "kind": "Direct", + "tags": { + "managed-by": "azure-monitor-logs-ingestion", + "ingestion-contract": contract.name, + }, + "properties": { + "dataCollectionEndpointId": dce_id, + "streamDeclarations": { + contract.input_stream: { + "columns": [column.as_dict() for column in contract.input_columns] + } + }, + "dataFlows": [ + { + "streams": [contract.input_stream], + "destinations": ["workspace"], + "outputStream": contract.destination.output_stream, + "transformKql": contract.transform_kql, + } + ], + "destinations": { + "logAnalytics": [ + { + "workspaceResourceId": TARGET["workspaceResourceId"], + "name": "workspace", + } + ] + }, + }, + } + table = { + "id": table_id, + "properties": { + "schema": { + "standardColumns": [ + column.as_dict() for column in contract.destination.columns + ] + } + }, + } + return contract, dce, dcr, table + + +def response_body(value: dict): + response = mock.MagicMock() + response.status = 200 + response.read.return_value = json.dumps(value).encode("utf-8") + context = mock.MagicMock() + context.__enter__.return_value = response + return context + + +class DcrReadinessTests(unittest.TestCase): + def _run_preflight(self, root: Path, *, responses, plan=None): + target_path = root / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + credential = mock.Mock() + credential.get_token.return_value.token = "header.private-token.signature" + with ( + mock.patch.object( + dcr_readiness, "_build_local_plan", return_value=plan or contract_plan() + ), + mock.patch.object( + dcr_readiness, "resolve_workspace_customer_id", return_value=TARGET["workspaceCustomerId"] + ), + mock.patch.object( + dcr_readiness, "azure_cli_credential", return_value=credential + ), + mock.patch.object( + dcr_readiness, + "_check_query_permission", + return_value={ + "status": "ready", + "detail": "read-only query succeeded", + "meansDataVisible": False, + }, + ), + mock.patch("urllib.request.urlopen", side_effect=responses) as urlopen, + ): + report = dcr_readiness.preflight_dcr_permissions( + root, target, auth_method="azure-cli" + ) + return report, urlopen + + def test_wildcards_custom_roles_and_exclusions_are_evaluated(self): + self.assertEqual( + "ready", + dcr_readiness._action_permission( + [{"actions": ["Microsoft.Insights/*"], "notActions": []}], + dcr_readiness.INGESTION_ACTION, + )["status"], + ) + self.assertEqual( + "denied", + dcr_readiness._action_permission( + [ + { + "actions": ["*"], + "notActions": ["Microsoft.Insights/Telemetry/*"], + } + ], + dcr_readiness.INGESTION_ACTION, + )["status"], + ) + self.assertEqual( + "ready", + dcr_readiness._action_permission( + [ + { + "actions": ["*"], + "notActions": ["Microsoft.Insights/Telemetry/*"], + }, + {"dataActions": ["Microsoft.Insights/Telemetry/Write"]}, + ], + dcr_readiness.INGESTION_ACTION, + )["status"], + ) + self.assertEqual( + "denied", + dcr_readiness._action_permission( + [ + { + "dataActions": ["Microsoft.Insights/*"], + "notDataActions": ["Microsoft.Insights/Telemetry/Write"], + } + ], + dcr_readiness.INGESTION_ACTION, + )["status"], + ) + + def test_malformed_partial_and_conditioned_permission_responses_are_unknown(self): + malformed = dcr_readiness._action_permission( + [{"actions": "Microsoft.Insights/*"}], dcr_readiness.INGESTION_ACTION + ) + conditioned = dcr_readiness._action_permission( + [{"actions": ["*"], "condition": "@Resource[foo]"}], + dcr_readiness.INGESTION_ACTION, + ) + self.assertEqual("unknown", malformed["status"]) + self.assertEqual("unknown", conditioned["status"]) + with mock.patch.object( + dcr_readiness, "_arm_get", return_value=(200, {"value": "invalid"}) + ): + malformed_response = dcr_readiness._permissions_for_scope( + "token", "/scope" + ) + self.assertEqual("unknown", malformed_response["inspectionStatus"]) + with mock.patch.object( + dcr_readiness, "_arm_get", return_value=(200, {"value": [], "nextLink": "next"}) + ): + partial = dcr_readiness._permissions_for_scope("token", "/scope") + self.assertEqual("unknown", partial["inspectionStatus"]) + + def test_permission_inspection_http_failures_remain_distinct(self): + for code, expected in ((401, "unavailable"), (403, "denied"), (404, "unknown")): + with self.subTest(code=code), mock.patch.object( + dcr_readiness, "_arm_get", return_value=(code, {"error": {"code": "failure"}}) + ): + report = dcr_readiness._permissions_for_scope("token", "/scope") + self.assertEqual(expected, report["inspectionStatus"]) + self.assertEqual(code, report["httpStatus"]) + with mock.patch.object( + dcr_readiness, + "_arm_get", + return_value=(403, {"error": {"code": "AuthorizationFailed", "message": "forbidden"}}), + ): + ingestion = dcr_readiness._check_action( + "token", "/exact/dcr", dcr_readiness.INGESTION_ACTION + ) + self.assertEqual("unknown", ingestion["status"]) + self.assertEqual("denied", ingestion["inspectionStatus"]) + self.assertEqual("AuthorizationFailed", ingestion["error"]["code"]) + + def test_error_evidence_redacts_identity_and_token_material(self): + value = ( + "client 'person@example.com' with object id " + "'39768270-33ce-4b90-a1a6-e0caeb3ba0ab' sent Bearer " + "secret.jwt.value" + ) + sanitized = dcr_readiness._safe_message(value) + self.assertNotIn("person@example.com", sanitized) + self.assertNotIn("39768270-33ce-4b90-a1a6-e0caeb3ba0ab", sanitized) + self.assertNotIn("secret.jwt.value", sanitized) + + def test_unselected_credentials_do_not_start_authentication_or_permission_calls(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + target_path = root / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + with ( + mock.patch.object( + dcr_readiness, "_build_local_plan", return_value=contract_plan() + ), + mock.patch.object( + dcr_readiness, "azure_cli_credential" + ) as credential_factory, + mock.patch.object( + dcr_readiness, "resolve_workspace_customer_id" + ) as verify_target, + mock.patch.object(dcr_readiness, "artifact_path", return_value=root / "report.json"), + mock.patch.object(dcr_readiness, "write_json_artifact"), + mock.patch("urllib.request.urlopen") as urlopen, + ): + report = dcr_readiness.preflight_dcr_permissions( + root, target, auth_method=None + ) + self.assertEqual("action-required", report["status"]) + self.assertEqual("action-required", report["authentication"]["status"]) + credential_factory.assert_not_called() + verify_target.assert_not_called() + urlopen.assert_not_called() + + def test_workspace_query_permission_is_separate_and_read_only(self): + client = mock.Mock() + with ( + mock.patch.object( + dcr_readiness, "logs_query_client", return_value=client + ), + mock.patch.object( + dcr_readiness, + "execute_workspace_query", + return_value={"status": "passed", "rowCount": 0}, + ) as query, + ): + result = dcr_readiness._check_query_permission(TARGET, mock.Mock()) + self.assertEqual("ready", result["status"]) + self.assertFalse(result["meansDataVisible"]) + self.assertEqual(TARGET["workspaceCustomerId"], query.call_args.args[1]) + self.assertEqual("print SentinelXdrPreflight=1", query.call_args.args[2]) + client.close.assert_called_once() + + def test_existing_compatible_dcr_checks_telemetry_permission_without_writes(self): + _, dce, dcr, table = candidate_resources() + responses = [ + response_body(dce), + response_body(dcr), + response_body(table), + response_body( + { + "value": [ + { + "actions": ["Microsoft.Insights/Telemetry/Write"], + "notActions": [], + "dataActions": [], + "notDataActions": [], + } + ] + } + ), + ] + with tempfile.TemporaryDirectory() as temp: + report, urlopen = self._run_preflight(Path(temp), responses=responses) + saved = Path(report["reportPath"]) + self.assertTrue(saved.is_file()) + persisted = json.loads(saved.read_text(encoding="utf-8")) + + candidate = report["contracts"][0] + self.assertEqual("ready", report["workspaceQueryPermission"]["status"]) + self.assertEqual("ready", candidate["resourceReadAccess"]["status"]) + self.assertEqual("compatible", candidate["tableSchemaCompatibility"]["status"]) + self.assertEqual("ready", candidate["ingestionAuthorization"]["status"]) + self.assertEqual("not-needed", candidate["tableCreationAuthorization"]["status"]) + self.assertEqual(0, report["payloadsSent"]) + self.assertEqual("ready", report["status"]) + self.assertEqual(64, len(candidate["resourceFingerprint"])) + self.assertEqual(4, urlopen.call_count) + self.assertTrue(all(call.args[0].method == "GET" for call in urlopen.call_args_list)) + self.assertNotIn("private-token", json.dumps(persisted)) + self.assertEqual("registered-contract-resolver", candidate["candidateProvenance"]) + + def test_missing_pair_checks_exact_resource_group_creation_scopes(self): + _, _, _, table = candidate_resources() + with tempfile.TemporaryDirectory() as temp: + target_path = Path(temp) / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + credential = mock.Mock() + credential.get_token.return_value.token = "header.token.signature" + group_scope = ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg" + ) + with ( + mock.patch.object( + dcr_readiness, "_build_local_plan", return_value=contract_plan() + ), + mock.patch.object( + dcr_readiness, + "resolve_workspace_customer_id", + return_value=TARGET["workspaceCustomerId"], + ), + mock.patch.object( + dcr_readiness, "azure_cli_credential", return_value=credential + ), + mock.patch.object( + dcr_readiness, + "_check_query_permission", + return_value={"status": "ready"}, + ), + mock.patch.object( + dcr_readiness, + "_check_resource", + side_effect=[ + ("missing", None, {"httpStatus": 404}), + ("missing", None, {"httpStatus": 404}), + ("present", table, {"httpStatus": 200}), + ], + ), + mock.patch.object( + dcr_readiness, + "_check_action", + side_effect=lambda token, scope, action: { + "status": "ready", + "scope": scope, + "requiredAction": action, + }, + ) as check_action, + mock.patch.object(dcr_readiness, "artifact_path", return_value=Path(temp) / "report.json"), + mock.patch.object(dcr_readiness, "write_json_artifact"), + ): + report = dcr_readiness.preflight_dcr_permissions( + Path(temp), target, auth_method="azure-cli" + ) + + candidate = report["contracts"][0] + self.assertEqual("not-yet-verifiable", candidate["ingestionAuthorization"]["status"]) + self.assertEqual("ready", candidate["resourceProvisioningAuthorization"]["status"]) + self.assertNotIn("roleAssignment", candidate["resourceProvisioningAuthorization"]) + self.assertEqual(2, check_action.call_count) + self.assertEqual( + [(group_scope, dcr_readiness.DCR_WRITE_ACTION), (group_scope, dcr_readiness.DCE_WRITE_ACTION)], + [(call.args[1], call.args[2]) for call in check_action.call_args_list], + ) + + def test_missing_native_table_does_not_trigger_table_creation_permission_check(self): + contract, _, _, _ = candidate_resources() + _, _, table_id = dcr_readiness._arm_resource_ids(TARGET, contract) + with tempfile.TemporaryDirectory() as temp: + target_path = Path(temp) / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + credential = mock.Mock() + credential.get_token.return_value.token = "header.token.signature" + with ( + mock.patch.object( + dcr_readiness, "_build_local_plan", return_value=contract_plan() + ), + mock.patch.object( + dcr_readiness, + "resolve_workspace_customer_id", + return_value=TARGET["workspaceCustomerId"], + ), + mock.patch.object( + dcr_readiness, "azure_cli_credential", return_value=credential + ), + mock.patch.object( + dcr_readiness, + "_check_query_permission", + return_value={"status": "ready"}, + ), + mock.patch.object( + dcr_readiness, + "_check_resource", + side_effect=[ + ("missing", None, {"httpStatus": 404}), + ("missing", None, {"httpStatus": 404}), + ("missing", None, {"httpStatus": 404}), + ], + ), + mock.patch.object( + dcr_readiness, "_check_action", return_value={"status": "ready"} + ) as check_action, + mock.patch.object(dcr_readiness, "artifact_path", return_value=Path(temp) / "report.json"), + mock.patch.object(dcr_readiness, "write_json_artifact"), + ): + report = dcr_readiness.preflight_dcr_permissions( + Path(temp), target, auth_method="azure-cli" + ) + + candidate = report["contracts"][0] + self.assertEqual("blocked", candidate["tableSchemaCompatibility"]["status"]) + self.assertEqual("not-needed", candidate["tableCreationAuthorization"]["status"]) + self.assertEqual("not-needed", candidate["resourceProvisioningAuthorization"]["status"]) + check_action.assert_not_called() + + def test_locked_workspace_customer_mismatch_is_rejected_before_arm_access(self): + with tempfile.TemporaryDirectory() as temp: + target_path = Path(temp) / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + credential_factory = mock.Mock() + with ( + mock.patch.object( + dcr_readiness, "_build_local_plan", return_value=contract_plan() + ), + mock.patch.object( + dcr_readiness, "resolve_workspace_customer_id", return_value="bad-id" + ), + mock.patch.object( + dcr_readiness, "azure_cli_credential", credential_factory + ), + mock.patch.object(dcr_readiness, "artifact_path", return_value=Path(temp) / "report.json"), + mock.patch.object(dcr_readiness, "write_json_artifact"), + ): + report = dcr_readiness.preflight_dcr_permissions( + Path(temp), target, auth_method="azure-cli" + ) + credential_factory.assert_not_called() + self.assertEqual("blocked", report["status"]) + self.assertEqual("blocked", report["targetVerification"]["status"]) + + def test_authenticated_tenant_and_subscription_mismatches_are_blocked(self): + for message in ( + "authenticated Azure tenant does not match the locked target", + "active Azure subscription does not match the locked target", + ): + with self.subTest(message=message), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + target_path = root / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + credential_factory = mock.Mock() + with ( + mock.patch.object( + dcr_readiness, "_build_local_plan", return_value=contract_plan() + ), + mock.patch.object( + dcr_readiness, + "resolve_workspace_customer_id", + side_effect=RuntimeError(message), + ), + mock.patch.object( + dcr_readiness, "azure_cli_credential", credential_factory + ), + mock.patch.object( + dcr_readiness, + "artifact_path", + return_value=root / "report.json", + ), + mock.patch.object(dcr_readiness, "write_json_artifact"), + ): + report = dcr_readiness.preflight_dcr_permissions( + root, target, auth_method="azure-cli" + ) + self.assertEqual("blocked", report["status"]) + self.assertEqual("blocked", report["targetVerification"]["status"]) + self.assertIn(message, report["targetVerification"]["reason"]) + credential_factory.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_delegated_app_registration.py b/Tools/SentinelToXDRMigration/tests/test_delegated_app_registration.py new file mode 100644 index 00000000000..45f2c173350 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_delegated_app_registration.py @@ -0,0 +1,2351 @@ +from __future__ import annotations + +import base64 +from contextlib import contextmanager, redirect_stdout +from io import StringIO +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration.delegated_app_registration import ( + APP_DISPLAY_NAME, + APP_REDIRECT_URI, + BOOTSTRAP_SCOPE, + BOOTSTRAP_SCOPES, + CUSTOM_DETECTION_SCOPE, + CUSTOM_DETECTION_SCOPE_ID, + GRAPH_RESOURCE_APP_ID, + READBACK_SCOPES, + THREAT_HUNTING_SCOPE, + THREAT_HUNTING_SCOPE_ID, + _list_owned_apps, + _list_owned_application_ids, + _registration_payload, + diagnose_app_readback_scopes, + plan_delegated_app_registration, + plan_delegated_app_permission_update, + register_delegated_app, + setup_app_readback_authentication, + verify_delegated_app_readback, + verify_delegated_app_permission_update, +) +from sentinel_xdr_migration.graph_client_config import ( + DEFAULT_GRAPH_CLIENT_ID, + PRIVATE_AUTH_CONFIG_NAME, + azure_cli_credential, + azure_cli_profile, + configure_azure_cli_profile, + configure_graph_client, + graph_auth_record_path, + graph_token_cache_name, + read_delegated_app_registration, + read_private_auth_config, + resolve_graph_client_id, + verify_graph_auth_binding, + write_graph_auth_metadata, + write_delegated_app_registration, +) +from sentinel_xdr_migration.artifacts import create_run +from sentinel_xdr_migration.cli import main as cli_main + +TENANT_ID = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" +CUSTOM_CLIENT_ID = "11111111-2222-3333-4444-555555555555" +OBJECT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +APP_ID = "99999999-8888-7777-6666-555555555555" +READBACK_TENANT_ID = TENANT_ID +READBACK_APPLICATION_ID = APP_ID +READBACK_OBJECT_ID = OBJECT_ID +READBACK_DISPLAY_NAME = "Example-Sentinel-XDR-Application" +READBACK_AUTH_CLIENT_ID = DEFAULT_GRAPH_CLIENT_ID +READBACK_SCOPE_DIAGNOSTIC_RUN_ID = "test-readback-run" + + +class DelegatedAppRegistrationTests(unittest.TestCase): + def test_advanced_hunting_auth_accepts_scope_superset_and_rejects_invalid_claims( + self, + ) -> None: + from sentinel_xdr_migration.onboarding import GRAPH_SCOPE, _login_advanced_hunting + + class Record: + tenant_id = TENANT_ID + + def serialize(self): + return "synthetic-auth-record" + + def jwt(claims: dict) -> str: + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode() + ).decode().rstrip("=") + return f"header.{encoded}.signature" + + base_claims = { + "tid": TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "ThreatHunting.Read.All User.Read email openid profile", + } + cases = ( + (base_claims, "completed"), + ({**base_claims, "scp": "User.Read"}, "failed"), + ({**base_claims, "tid": "wrong-tenant"}, "failed"), + ({**base_claims, "aud": "https://other.example"}, "failed"), + ({**base_claims, "scp": ["ThreatHunting.Read.All"]}, "failed"), + ( + { + "tid": TENANT_ID, + "aud": "https://graph.microsoft.com", + "roles": ["ThreatHunting.Read.All"], + }, + "failed", + ), + ) + for claims, expected_status in cases: + with self.subTest(claims=claims), tempfile.TemporaryDirectory() as temp: + with ( + mock.patch("azure.identity.DeviceCodeCredential") as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions"), + ): + credential_type.return_value.authenticate.return_value = Record() + credential_type.return_value.get_token.return_value.token = jwt(claims) + result = _login_advanced_hunting(Path(temp), TENANT_ID) + self.assertEqual(expected_status, result["status"]) + credential_type.return_value.authenticate.assert_called_once_with( + scopes=[GRAPH_SCOPE] + ) + credential_type.return_value.get_token.assert_called_once_with( + GRAPH_SCOPE + ) + if expected_status == "completed": + self.assertEqual( + ["email", "openid", "profile", "User.Read"], + result["scopeDiagnostics"]["extraScopeNames"], + ) + self.assertTrue(list(Path(temp).rglob("*.json"))) + else: + self.assertEqual([], list(Path(temp).rglob("*.json"))) + + @staticmethod + def _readback_settings(**updates): + value = { + "id": READBACK_OBJECT_ID, + "appId": READBACK_APPLICATION_ID, + "displayName": READBACK_DISPLAY_NAME, + "signInAudience": "AzureADMyOrg", + "publicClient": {"redirectUris": ["http://localhost"]}, + "requiredResourceAccess": [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [{ + "id": CUSTOM_DETECTION_SCOPE_ID, + "type": "Scope", + }, { + "id": THREAT_HUNTING_SCOPE_ID, + "type": "Scope", + }], + }], + "passwordCredentials": [], + "keyCredentials": [], + } + value.update(updates) + return value + + @contextmanager + def _scope_diagnostic_fixtures( + self, + temp: str, + claims: dict | None, + *, + target_tenant: str = READBACK_TENANT_ID, + auth_record: object | None = "approved-record", + auth_error: Exception | None = None, + ): + root = Path(temp) / "Solutions" / "Cisco" + root.mkdir(parents=True) + workflow_state = Path(temp) / "workflow-state.json" + workflow_state.write_text(json.dumps({ + "runId": READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + "context": { + "workflowProfile": "qualification", + "tenantId": READBACK_TENANT_ID, + "targetContextLocked": True, + }, + }), encoding="utf-8") + private = Path(temp) / "private" + write_delegated_app_registration(private, { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "reconciliation-required", + }) + configure_graph_client(private, "appReadback", DEFAULT_GRAPH_CLIENT_ID) + configure_graph_client(private, "appRegistration", DEFAULT_GRAPH_CLIENT_ID) + readback_record_path = graph_auth_record_path( + private, + "appReadback", + DEFAULT_GRAPH_CLIENT_ID, + "app-readback-auth-record.json", + ) + readback_record_path.write_text("mock readback record", encoding="utf-8") + write_graph_auth_metadata( + readback_record_path, + purpose="appReadback", + client_id=DEFAULT_GRAPH_CLIENT_ID, + tenant_id=READBACK_TENANT_ID, + ) + bootstrap_record_path = graph_auth_record_path( + private, + "appRegistration", + DEFAULT_GRAPH_CLIENT_ID, + "app-registration-auth-record.json", + ) + bootstrap_record_path.write_text("mock bootstrap record", encoding="utf-8") + write_graph_auth_metadata( + bootstrap_record_path, + purpose="appRegistration", + client_id=DEFAULT_GRAPH_CLIENT_ID, + tenant_id=READBACK_TENANT_ID, + ) + record = mock.Mock( + tenant_id=READBACK_TENANT_ID, + client_id=DEFAULT_GRAPH_CLIENT_ID, + ) + if auth_error is not None: + auth_patch = mock.patch( + "sentinel_xdr_migration.delegated_app_registration._load_auth_record", + side_effect=auth_error, + ) + else: + auth_patch = mock.patch( + "sentinel_xdr_migration.delegated_app_registration._load_auth_record", + return_value=record if auth_record is not None else None, + ) + with ( + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.existing_artifact_path", + return_value=workflow_state, + ), + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": target_tenant}, + ), + auth_patch as load_auth_record, + mock.patch( + "azure.identity.InteractiveBrowserCredential" + ) as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions") as cache_options, + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value=claims, + ), + mock.patch( + "sentinel_xdr_migration.delegated_app_registration._graph_request" + ) as graph_request, + mock.patch( + "sentinel_xdr_migration.delegated_app_registration._list_owned_application_ids" + ) as owned_applications, + ): + credential_type.return_value.authenticate.side_effect = AssertionError( + "interactive authentication is not expected in scope-diagnostic fixtures" + ) + credential_type.return_value.get_token.return_value.token = ( + "opaque-synthetic-token-not-for-output" + ) + yield { + "root": root, + "private": private, + "readbackRecordPath": readback_record_path, + "bootstrapRecordPath": bootstrap_record_path, + "credential": credential_type, + "cache": cache_options, + "loadAuthRecord": load_auth_record, + "graphRequest": graph_request, + "ownedApplications": owned_applications, + } + + def test_registration_payload_is_single_tenant_public_and_one_delegated_scope(self) -> None: + payload = _registration_payload() + self.assertEqual(APP_DISPLAY_NAME, "Sentinel-XDR-CustomDetection") + self.assertEqual(payload["displayName"], APP_DISPLAY_NAME) + self.assertEqual(payload["signInAudience"], "AzureADMyOrg") + self.assertEqual(payload["publicClient"], {"redirectUris": [APP_REDIRECT_URI]}) + self.assertEqual( + payload["requiredResourceAccess"], + [ + { + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + {"id": CUSTOM_DETECTION_SCOPE_ID, "type": "Scope"} + ], + } + ], + ) + self.assertEqual(CUSTOM_DETECTION_SCOPE, "CustomDetection.ReadWrite.All") + + def test_private_client_configuration_is_namespaced_and_keeps_default_compatibility(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) / "private" + self.assertEqual( + resolve_graph_client_id(root, "customDetection"), + DEFAULT_GRAPH_CLIENT_ID, + ) + self.assertEqual( + graph_auth_record_path( + root, "customDetection", DEFAULT_GRAPH_CLIENT_ID, "auth.json" + ).resolve(), + (root / "auth.json").resolve(), + ) + self.assertEqual( + graph_token_cache_name( + "customDetection", DEFAULT_GRAPH_CLIENT_ID, "cache" + ), + "cache", + ) + configure_graph_client(root, "customDetection", CUSTOM_CLIENT_ID) + self.assertEqual( + resolve_graph_client_id(root, "customDetection"), CUSTOM_CLIENT_ID + ) + custom_record = graph_auth_record_path( + root, "customDetection", CUSTOM_CLIENT_ID, "auth.json" + ) + self.assertNotEqual(custom_record, root / "auth.json") + self.assertNotEqual( + graph_token_cache_name("customDetection", CUSTOM_CLIENT_ID, "cache"), + "cache", + ) + self.assertEqual( + json.loads((root / PRIVATE_AUTH_CONFIG_NAME).read_text())[ + "graphClientIds" + ]["customDetection"], + CUSTOM_CLIENT_ID, + ) + self.assertEqual((root.stat().st_mode & 0o777), 0o700) + self.assertEqual(((root / PRIVATE_AUTH_CONFIG_NAME).stat().st_mode & 0o777), 0o600) + + def test_auth_binding_rejects_client_and_tenant_mismatches(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + record_path = graph_auth_record_path( + root, "customDetection", CUSTOM_CLIENT_ID, "auth.json" + ) + record = mock.Mock(tenant_id=TENANT_ID, client_id=CUSTOM_CLIENT_ID) + write_graph_auth_metadata( + record_path, + purpose="customDetection", + client_id=CUSTOM_CLIENT_ID, + tenant_id=TENANT_ID, + ) + verify_graph_auth_binding( + record_path, + record, + purpose="customDetection", + client_id=CUSTOM_CLIENT_ID, + tenant_id=TENANT_ID, + ) + with self.assertRaisesRegex(ValueError, "client"): + verify_graph_auth_binding( + record_path, + record, + purpose="customDetection", + client_id=DEFAULT_GRAPH_CLIENT_ID, + tenant_id=TENANT_ID, + ) + with self.assertRaisesRegex(ValueError, "tenant"): + verify_graph_auth_binding( + record_path, + record, + purpose="customDetection", + client_id=CUSTOM_CLIENT_ID, + tenant_id="00000000-0000-0000-0000-000000000001", + ) + + def test_bootstrap_authentication_accepts_scope_superset_without_changing_request(self) -> None: + class Record: + tenant_id = TENANT_ID + username = "operator@example.invalid" + + def serialize(self): + return "serialized-test-record" + + with tempfile.TemporaryDirectory() as temp: + with ( + mock.patch( + "azure.identity.DeviceCodeCredential" + ) as credential_type, + mock.patch( + "azure.identity.TokenCachePersistenceOptions" + ) as cache_options, + ): + credential_type.return_value.authenticate.return_value = Record() + claims = { + "tid": TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": ( + "AppRegistration.Create User.Read App.Read.All " + "email openid profile" + ), + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode() + ).decode().rstrip("=") + credential_type.return_value.get_token.return_value.token = ( + f"header.{encoded}.signature" + ) + from sentinel_xdr_migration.delegated_app_registration import ( + setup_app_registration_authentication, + ) + + result = setup_app_registration_authentication( + tenant_id=TENANT_ID, + auth_method="device-code", + state_dir=temp, + ) + self.assertEqual(result["scopes"], list(BOOTSTRAP_SCOPES)) + self.assertFalse(result["appRegistrationPerformed"]) + self.assertFalse(result["adminConsentRequested"]) + credential_type.return_value.authenticate.assert_called_once_with( + scopes=list(BOOTSTRAP_SCOPES) + ) + credential_type.return_value.get_token.assert_called_once_with( + *BOOTSTRAP_SCOPES + ) + record_path = Path(temp) / "app-registration-auth-record.json" + self.assertEqual(record_path.read_text(), "serialized-test-record") + self.assertEqual((record_path.stat().st_mode & 0o777), 0o600) + metadata = json.loads( + Path(f"{record_path}.metadata.json").read_text() + ) + self.assertEqual(metadata["purpose"], "appRegistration") + self.assertEqual(metadata["clientId"], DEFAULT_GRAPH_CLIENT_ID) + + def test_readback_auth_uses_explicit_private_client_configuration(self) -> None: + class Record: + tenant_id = READBACK_TENANT_ID + username = "operator@example.invalid" + + def serialize(self): + return "serialized-test-readback-record" + + with tempfile.TemporaryDirectory() as temp: + with ( + mock.patch("azure.identity.InteractiveBrowserCredential") as credential_type, + mock.patch( + "azure.identity.TokenCachePersistenceOptions" + ) as cache_options, + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value={ + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All", + }, + ), + ): + credential = credential_type.return_value + credential.authenticate.return_value = Record() + credential.get_token.return_value.token = "token" + setup_app_readback_authentication( + tenant_id=READBACK_TENANT_ID, + readback_client_id=CUSTOM_CLIENT_ID, + state_dir=temp, + ) + credential_type.assert_called_once_with( + client_id=CUSTOM_CLIENT_ID, + tenant_id=READBACK_TENANT_ID, + cache_persistence_options=cache_options.return_value, + ) + credential.authenticate.assert_called_once_with( + scopes=list(READBACK_SCOPES) + ) + credential.get_token.assert_called_once_with(*READBACK_SCOPES) + self.assertIsInstance(credential, mock.Mock) + self.assertEqual( + CUSTOM_CLIENT_ID, + resolve_graph_client_id(temp, "appReadback"), + ) + self.assertTrue( + graph_auth_record_path( + temp, + "appReadback", + CUSTOM_CLIENT_ID, + "app-readback-auth-record.json", + ).is_file() + ) + + def test_configured_public_client_has_separate_readback_record_and_cache(self) -> None: + class Record: + tenant_id = READBACK_TENANT_ID + username = "operator@example.invalid" + + def serialize(self): + return "serialized-approved-readback-record" + + from sentinel_xdr_migration.delegated_app_registration import _app_readback_token + + self.assertEqual(DEFAULT_GRAPH_CLIENT_ID, READBACK_AUTH_CLIENT_ID) + with tempfile.TemporaryDirectory() as temp: + with ( + mock.patch("azure.identity.InteractiveBrowserCredential") as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions") as cache_options, + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value={ + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": ( + "User.Read Application.Read.All email openid profile" + ), + }, + ), + ): + credential_type.return_value.authenticate.return_value = Record() + credential_type.return_value.get_token.return_value.token = "token" + setup_app_readback_authentication( + tenant_id=READBACK_TENANT_ID, + readback_client_id=DEFAULT_GRAPH_CLIENT_ID, + state_dir=temp, + ) + readback_record = graph_auth_record_path( + temp, + "appReadback", + DEFAULT_GRAPH_CLIENT_ID, + "app-readback-auth-record.json", + ) + bootstrap_record = graph_auth_record_path( + temp, + "appRegistration", + DEFAULT_GRAPH_CLIENT_ID, + "app-registration-auth-record.json", + ) + self.assertEqual("app-readback-auth-record.json", readback_record.name) + self.assertNotEqual(readback_record, bootstrap_record) + self.assertEqual("serialized-approved-readback-record", readback_record.read_text()) + bootstrap_record = graph_auth_record_path( + temp, + "appRegistration", + DEFAULT_GRAPH_CLIENT_ID, + "app-registration-auth-record.json", + ) + bootstrap_record.write_text("bootstrap-only-record") + write_graph_auth_metadata( + bootstrap_record, + purpose="appRegistration", + client_id=DEFAULT_GRAPH_CLIENT_ID, + tenant_id=READBACK_TENANT_ID, + ) + self.assertEqual( + "sentinel_xdr_migration_app_readback", + cache_options.call_args.kwargs["name"], + ) + + record = mock.Mock( + tenant_id=READBACK_TENANT_ID, + client_id=DEFAULT_GRAPH_CLIENT_ID, + ) + with ( + mock.patch( + "sentinel_xdr_migration.delegated_app_registration._load_auth_record", + return_value=record, + ) as load_record, + mock.patch("azure.identity.InteractiveBrowserCredential") as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions") as cache_options, + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value={ + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All", + }, + ), + ): + credential_type.return_value.get_token.return_value.token = "token" + self.assertEqual( + "token", + _app_readback_token(Path(temp), READBACK_TENANT_ID), + ) + load_record.assert_called_once_with(readback_record) + self.assertEqual( + "sentinel_xdr_migration_app_readback", + cache_options.call_args.kwargs["name"], + ) + + def test_readback_default_client_never_falls_back_or_reuses_bootstrap_cache(self) -> None: + from sentinel_xdr_migration.delegated_app_registration import _app_readback_token + + with tempfile.TemporaryDirectory() as temp: + with self.assertRaisesRegex(RuntimeError, "not configured"): + _app_readback_token(Path(temp), READBACK_TENANT_ID) + configure_graph_client(temp, "appRegistration", DEFAULT_GRAPH_CLIENT_ID) + bootstrap_record = graph_auth_record_path( + temp, + "appRegistration", + DEFAULT_GRAPH_CLIENT_ID, + "app-registration-auth-record.json", + ) + bootstrap_record.write_text("bootstrap-only-record") + configure_graph_client(temp, "appReadback", DEFAULT_GRAPH_CLIENT_ID) + self.assertNotEqual( + bootstrap_record, + graph_auth_record_path( + temp, + "appReadback", + DEFAULT_GRAPH_CLIENT_ID, + "app-readback-auth-record.json", + ), + ) + with self.assertRaisesRegex(RuntimeError, "readback browser authentication is missing"): + _app_readback_token(Path(temp), READBACK_TENANT_ID) + + def test_owned_application_projection_allows_omitted_annotation_and_bounds_pagination(self) -> None: + calls = [] + + def paged(method, url, token, body=None): + calls.append((method, url)) + if len(calls) == 1: + return 200, { + "value": [{"id": READBACK_OBJECT_ID}], + "@odata.nextLink": ( + "https://graph.microsoft.com/v1.0/me/ownedObjects/" + "microsoft.graph.application?$skiptoken=page2" + ), + } + return 200, { + "value": [{ + "id": "01234567-89ab-cdef-0123-456789abcdef", + "@odata.type": "#microsoft.graph.application", + }] + } + + result = _list_owned_application_ids("token", graph_request=paged) + self.assertEqual( + [READBACK_OBJECT_ID, "01234567-89ab-cdef-0123-456789abcdef"], + result, + ) + self.assertTrue(all(method == "GET" for method, _ in calls)) + self.assertIn("%24select=id", calls[0][1]) + self.assertEqual(2, len(calls)) + + for unsafe_link in ( + "https://evil.example/v1.0/me/ownedObjects/microsoft.graph.application", + "https://graph.microsoft.com.evil.example/v1.0/me/ownedObjects/microsoft.graph.application", + "https://graph.microsoft.com/v1.0/applications", + ): + with self.subTest(unsafe_link=unsafe_link): + count = [] + + def unsafe(method, url, token, body=None): + count.append(method) + return 200, { + "value": [{"id": READBACK_OBJECT_ID}], + "@odata.nextLink": unsafe_link, + } + + with self.assertRaisesRegex(RuntimeError, "unsafe continuation"): + _list_owned_application_ids("token", graph_request=unsafe) + self.assertEqual(["GET"], count) + + def wrong_type(method, url, token, body=None): + return 200, { + "value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.servicePrincipal", + }] + } + + with self.assertRaisesRegex(RuntimeError, "conflicting explicit object type"): + _list_owned_application_ids("token", graph_request=wrong_type) + + page_count = [] + + def too_many_pages(method, url, token, body=None): + page_count.append(method) + return 200, { + "value": [], + "@odata.nextLink": ( + "https://graph.microsoft.com/v1.0/me/ownedObjects/" + "microsoft.graph.application?$skiptoken=next" + ), + } + + with self.assertRaisesRegex(RuntimeError, "10-page safety bound"): + _list_owned_application_ids("token", graph_request=too_many_pages) + self.assertEqual(10, len(page_count)) + + def test_readback_token_requires_tenant_audience_and_required_scopes(self) -> None: + from sentinel_xdr_migration.delegated_app_registration import _app_readback_token + + with tempfile.TemporaryDirectory() as temp: + configure_graph_client(temp, "appReadback", DEFAULT_GRAPH_CLIENT_ID) + record_path = graph_auth_record_path( + temp, + "appReadback", + DEFAULT_GRAPH_CLIENT_ID, + "app-readback-auth-record.json", + ) + record_path.write_text("test-record") + write_graph_auth_metadata( + record_path, + purpose="appReadback", + client_id=DEFAULT_GRAPH_CLIENT_ID, + tenant_id=READBACK_TENANT_ID, + ) + record = mock.Mock( + tenant_id=READBACK_TENANT_ID, + client_id=DEFAULT_GRAPH_CLIENT_ID, + ) + with ( + mock.patch( + "sentinel_xdr_migration.delegated_app_registration._load_auth_record", + return_value=record, + ), + mock.patch("azure.identity.InteractiveBrowserCredential") as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions"), + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value={ + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All", + }, + ), + ): + credential_type.return_value.get_token.return_value.token = "token" + self.assertEqual("token", _app_readback_token(Path(temp), READBACK_TENANT_ID)) + credential_type.return_value.get_token.assert_called_once_with( + *READBACK_SCOPES + ) + + for claims, tenant, should_pass, message in ( + ( + {"tid": READBACK_TENANT_ID, "aud": "https://graph.microsoft.com", + "scp": ( + "User.Read Application.Read.All Directory.Read.All " + "email openid profile" + )}, + READBACK_TENANT_ID, + True, + "", + ), + ( + {"tid": "00000000-0000-0000-0000-000000000001", + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All"}, + READBACK_TENANT_ID, + False, + "tenant", + ), + ( + {"tid": READBACK_TENANT_ID, "aud": "https://other.example", + "scp": "User.Read Application.Read.All"}, + READBACK_TENANT_ID, + False, + "Microsoft Graph", + ), + ( + {"tid": READBACK_TENANT_ID, "aud": "https://graph.microsoft.com", + "scp": "User.Read"}, + READBACK_TENANT_ID, + False, + "must include", + ), + ( + {"tid": READBACK_TENANT_ID, "aud": "https://graph.microsoft.com", + "scp": ["User.Read", "Application.Read.All"]}, + READBACK_TENANT_ID, + False, + "must include", + ), + ( + {"tid": READBACK_TENANT_ID, "aud": "https://graph.microsoft.com", + "roles": ["User.Read", "Application.Read.All"]}, + READBACK_TENANT_ID, + False, + "must include", + ), + ): + with ( + mock.patch( + "sentinel_xdr_migration.delegated_app_registration._load_auth_record", + return_value=record, + ), + mock.patch("azure.identity.InteractiveBrowserCredential") as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions"), + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value=claims, + ), + ): + credential_type.return_value.get_token.return_value.token = "token" + if should_pass: + self.assertEqual("token", _app_readback_token(Path(temp), tenant)) + else: + with self.assertRaisesRegex(RuntimeError, message): + _app_readback_token(Path(temp), tenant) + + def test_scope_diagnostic_accepts_required_scope_superset_and_never_calls_graph(self) -> None: + with tempfile.TemporaryDirectory() as temp: + claims = { + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": ( + "User.Read Application.Read.All AppRegistration.Create " + "CustomDetection.ReadWrite.All email openid profile " + "ThreatHunting.Read.All" + ), + } + with self._scope_diagnostic_fixtures(temp, claims) as fixtures: + before_registration = read_delegated_app_registration( + fixtures["private"] + ) + before_config = read_private_auth_config(fixtures["private"]) + result = diagnose_app_readback_scopes( + fixtures["root"], + run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + state_dir=fixtures["private"], + ) + self.assertEqual("required-scopes-present", result["status"]) + self.assertEqual("valid", result["scopeClaimStatus"]) + self.assertEqual( + {"User.Read", "Application.Read.All"}, + set(result["expectedScopeNames"]), + ) + self.assertEqual( + { + "User.Read", + "Application.Read.All", + "AppRegistration.Create", + "CustomDetection.ReadWrite.All", + "email", + "openid", + "profile", + "ThreatHunting.Read.All", + }, + set(result["actualScopeNames"]), + ) + self.assertEqual([], result["missingScopeNames"]) + self.assertEqual( + [ + "AppRegistration.Create", + "CustomDetection.ReadWrite.All", + "email", + "openid", + "profile", + "ThreatHunting.Read.All", + ], + result["extraScopeNames"], + ) + self.assertEqual(0, result["graphResourceRequests"]) + self.assertFalse(result["interactivePromptUsed"]) + self.assertFalse(result["privateConfigurationChanged"]) + self.assertEqual( + before_registration, + read_delegated_app_registration(fixtures["private"]), + ) + self.assertEqual( + before_config, + read_private_auth_config(fixtures["private"]), + ) + fixtures["credential"].assert_called_once() + credential_options = fixtures["credential"].call_args.kwargs + self.assertEqual(DEFAULT_GRAPH_CLIENT_ID, credential_options["client_id"]) + self.assertEqual(READBACK_TENANT_ID, credential_options["tenant_id"]) + self.assertTrue(credential_options["disable_automatic_authentication"]) + fixtures["credential"].return_value.authenticate.assert_not_called() + fixtures["credential"].return_value.get_token.assert_called_once_with( + *READBACK_SCOPES + ) + fixtures["cache"].assert_called_once() + self.assertEqual( + "sentinel_xdr_migration_app_readback", + fixtures["cache"].call_args.kwargs["name"], + ) + fixtures["loadAuthRecord"].assert_called_once_with( + fixtures["readbackRecordPath"] + ) + self.assertNotEqual( + fixtures["readbackRecordPath"], + fixtures["bootstrapRecordPath"], + ) + fixtures["graphRequest"].assert_not_called() + fixtures["ownedApplications"].assert_not_called() + self.assertNotIn( + "opaque-synthetic-token-not-for-output", + json.dumps(result), + ) + self.assertNotIn("claims", result) + self.assertNotIn("token", result) + + def test_scope_diagnostic_reports_missing_and_extra_names_and_gates_on_missing_only(self) -> None: + cases = ( + ("User.Read", {"Application.Read.All"}, set(), False), + ( + "User.Read Application.Read.All AppRegistration.Create", + set(), + {"AppRegistration.Create"}, + True, + ), + ( + "User.Read Directory.Read.All", + {"Application.Read.All"}, + {"Directory.Read.All"}, + False, + ), + ( + "User.Read Application.Read.All email openid profile", + set(), + {"email", "openid", "profile"}, + True, + ), + ) + for scope_claim, expected_missing, expected_extra, should_pass in cases: + with self.subTest(scope_claim=scope_claim), tempfile.TemporaryDirectory() as temp: + claims = { + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": scope_claim, + } + with self._scope_diagnostic_fixtures(temp, claims) as fixtures: + result = diagnose_app_readback_scopes( + fixtures["root"], + run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + state_dir=fixtures["private"], + ) + self.assertEqual( + "required-scopes-present" if should_pass else "scope-mismatch", + result["status"], + ) + self.assertEqual(expected_missing, set(result["missingScopeNames"])) + self.assertEqual(expected_extra, set(result["extraScopeNames"])) + self.assertEqual(0, result["graphResourceRequests"]) + fixtures["graphRequest"].assert_not_called() + fixtures["ownedApplications"].assert_not_called() + + from sentinel_xdr_migration.delegated_app_registration import ( + _app_readback_token, + ) + + if should_pass: + self.assertEqual( + "opaque-synthetic-token-not-for-output", + _app_readback_token( + fixtures["private"], READBACK_TENANT_ID + ), + ) + else: + with self.assertRaisesRegex( + RuntimeError, "must include" + ): + _app_readback_token( + fixtures["private"], READBACK_TENANT_ID + ) + + def test_scope_diagnostic_blocks_missing_malformed_and_untrusted_claims(self) -> None: + cases = ( + ( + {"tid": READBACK_TENANT_ID, "aud": "https://graph.microsoft.com"}, + "missing", + ), + ( + { + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": ["User.Read", "Application.Read.All"], + }, + "malformed", + ), + ( + { + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "User.Read https://graph.microsoft.com/Application.Read.All", + }, + "malformed", + ), + ( + { + "tid": "00000000-0000-0000-0000-000000000001", + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All", + }, + "not-available", + ), + ( + { + "tid": READBACK_TENANT_ID, + "aud": "https://other.example", + "scp": "User.Read Application.Read.All", + }, + "not-available", + ), + ) + for claims, claim_status in cases: + with self.subTest(claims=claims), tempfile.TemporaryDirectory() as temp: + with self._scope_diagnostic_fixtures(temp, claims) as fixtures: + result = diagnose_app_readback_scopes( + fixtures["root"], + run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + state_dir=fixtures["private"], + ) + self.assertEqual("diagnostic-blocked", result["status"]) + self.assertEqual(claim_status, result["scopeClaimStatus"]) + self.assertEqual([], result["actualScopeNames"]) + self.assertEqual( + {"User.Read", "Application.Read.All"}, + set(result["missingScopeNames"]), + ) + self.assertEqual([], result["extraScopeNames"]) + self.assertEqual(0, result["graphResourceRequests"]) + self.assertNotIn("https://graph.microsoft.com", json.dumps(result)) + fixtures["graphRequest"].assert_not_called() + fixtures["ownedApplications"].assert_not_called() + + def test_scope_diagnostic_cache_failures_suppress_sensitive_details_and_never_prompt(self) -> None: + sensitive_error = ( + "Bearer synthetic-access-token /private/state/auth-record.json " + "client_secret=synthetic-secret claims={\"tid\":\"synthetic-tenant\"}" + ) + for auth_record, auth_error in ( + (None, None), + (None, RuntimeError(sensitive_error)), + ): + with self.subTest(auth_error=auth_error), tempfile.TemporaryDirectory() as temp: + claims = { + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All", + } + with self._scope_diagnostic_fixtures( + temp, + claims, + auth_record=auth_record, + auth_error=auth_error, + ) as fixtures: + result = diagnose_app_readback_scopes( + fixtures["root"], + run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + state_dir=fixtures["private"], + ) + self.assertEqual("diagnostic-blocked", result["status"]) + self.assertEqual("not-available", result["scopeClaimStatus"]) + self.assertEqual([], result["actualScopeNames"]) + self.assertEqual( + {"User.Read", "Application.Read.All"}, + set(result["missingScopeNames"]), + ) + for sensitive_value in ( + "synthetic-access-token", + "/private/state/auth-record.json", + "synthetic-secret", + "synthetic-tenant", + ): + self.assertNotIn(sensitive_value, result["reason"]) + fixtures["credential"].assert_not_called() + fixtures["graphRequest"].assert_not_called() + fixtures["ownedApplications"].assert_not_called() + + def test_scope_diagnostic_is_bound_to_exact_run_and_locked_tenant(self) -> None: + with tempfile.TemporaryDirectory() as temp: + claims = { + "tid": READBACK_TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "User.Read Application.Read.All", + } + with self._scope_diagnostic_fixtures(temp, claims) as fixtures: + with self.assertRaisesRegex( + ValueError, + "scope diagnostic requires the exact pinned Qualification run and tenant", + ): + diagnose_app_readback_scopes( + fixtures["root"], + run_id="different-run", + state_dir=fixtures["private"], + ) + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": "00000000-0000-0000-0000-000000000001"}, + ): + with self.assertRaisesRegex( + RuntimeError, + "private app-registration record is not bound to the locked tenant", + ): + diagnose_app_readback_scopes( + fixtures["root"], + run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + state_dir=fixtures["private"], + ) + fixtures["credential"].assert_not_called() + fixtures["graphRequest"].assert_not_called() + fixtures["ownedApplications"].assert_not_called() + + def test_scope_diagnostic_cli_requires_run_and_preserves_blocked_exit_status(self) -> None: + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "Cisco" + create_run(solution, run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID) + output = StringIO() + with redirect_stdout(output), self.assertRaises(SystemExit) as missing_run: + cli_main([ + "diagnose-app-readback-scopes", + "--solution", + str(solution), + ]) + self.assertEqual(2, missing_run.exception.code) + + with ( + redirect_stdout(output), + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.diagnose_app_readback_scopes", + return_value={"status": "required-scopes-present"}, + ) as diagnostic, + ): + status = cli_main([ + "diagnose-app-readback-scopes", + "--solution", + str(solution), + "--run-id", + READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + ]) + self.assertEqual(0, status) + diagnostic.assert_called_once_with( + str(solution), + run_id=READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + ) + with ( + redirect_stdout(output), + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.diagnose_app_readback_scopes", + return_value={"status": "scope-mismatch"}, + ), + ): + status = cli_main([ + "diagnose-app-readback-scopes", + "--solution", + str(solution), + "--run-id", + READBACK_SCOPE_DIAGNOSTIC_RUN_ID, + ]) + self.assertEqual(1, status) + + def test_readback_requires_owned_exact_object_and_gets_only_approved_settings(self) -> None: + with tempfile.TemporaryDirectory() as temp: + registration = { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "created-needs-ownership-verification", + "creationSnapshot": {"status": 201}, + } + write_delegated_app_registration(temp, registration) + methods = [] + urls = [] + + def request(method, url, token, body=None): + methods.append(method) + urls.append(url) + if len(methods) == 1: + return 200, { + "value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }] + } + self.assertIsNone(body) + return 200, self._readback_settings() + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + result = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda _state, _tenant: "read-only-token", + graph_request=request, + ) + self.assertEqual("readback-verified-private-config-completed", result["status"]) + self.assertEqual(["GET", "GET"], methods) + self.assertIn("/me/ownedObjects/microsoft.graph.application?", urls[0]) + self.assertIn("%24select=id", urls[0]) + self.assertIn(f"/applications/{READBACK_OBJECT_ID}?", urls[1]) + self.assertIn("passwordCredentials", urls[1]) + self.assertIn("keyCredentials", urls[1]) + self.assertFalse(result["graphWritesPerformed"]) + saved = read_delegated_app_registration(temp) + self.assertEqual("configured", saved["status"]) + self.assertEqual( + "created-needs-ownership-verification", + saved["creationStatus"], + ) + self.assertEqual({"status": 201}, saved["creationSnapshot"]) + self.assertEqual("passed", saved["readbackVerification"]["status"]) + self.assertFalse(saved["readbackVerification"]["adminConsentVerified"]) + self.assertEqual( + READBACK_APPLICATION_ID, + read_private_auth_config(temp)["graphClientIds"]["customDetection"], + ) + self.assertEqual( + READBACK_APPLICATION_ID, + read_private_auth_config(temp)["graphClientIds"]["advancedHunting"], + ) + + def test_readback_unknown_ids_can_complete_only_after_exact_owned_get_matches(self) -> None: + with tempfile.TemporaryDirectory() as temp: + calls = [] + write_delegated_app_registration( + temp, + { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "create-outcome-unknown", + }, + ) + + def request(method, url, token, body=None): + self.assertEqual("GET", method) + self.assertIsNone(body) + calls.append(url) + if "/ownedObjects/" in url: + return 200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }]} + self.assertIn(f"/applications/{READBACK_OBJECT_ID}?", url) + return 200, self._readback_settings() + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + result = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda _state, _tenant: "read-only-token", + graph_request=request, + ) + self.assertEqual("readback-verified-private-config-completed", result["status"]) + self.assertEqual("create-outcome-unknown", result["preservedCreationStatus"]) + self.assertEqual(2, len(calls)) + self.assertTrue(result["privateConfigurationCompleted"]) + self.assertFalse(result["graphWritesPerformed"]) + self.assertFalse(result["adminConsentVerified"]) + + def test_readback_failure_matrix_does_not_complete_private_configuration(self) -> None: + cases = ( + ("not-owned", lambda: (200, {"value": []})), + ("owned-object-type-conflict", lambda: (200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.servicePrincipal", + }]})), + ("duplicate-owned", lambda: (200, {"value": [ + {"id": READBACK_OBJECT_ID, "@odata.type": "#microsoft.graph.application"}, + {"id": READBACK_OBJECT_ID, "@odata.type": "#microsoft.graph.application"}, + ]})), + ) + for name, owned_response in cases: + with self.subTest(name=name), tempfile.TemporaryDirectory() as temp: + original = { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "reconciliation-required", + } + write_delegated_app_registration(temp, original) + calls = [] + + def request(method, url, token, body=None): + calls.append(method) + return owned_response() + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + result = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda _state, _tenant: "read-only-token", + graph_request=request, + ) + self.assertEqual("verification-blocked", result["status"]) + self.assertEqual(["GET"], calls) + self.assertFalse(result["privateConfigurationCompleted"]) + self.assertEqual( + "reconciliation-required", + read_delegated_app_registration(temp)["status"], + ) + + mismatch_cases = ( + ("wrong-client-id", {"appId": "00000000-0000-0000-0000-000000000001"}), + ("wrong-name", {"displayName": "Other"}), + ("wrong-audience", {"signInAudience": "AzureADMultipleOrgs"}), + ("redirect-drift", {"publicClient": {"redirectUris": ["https://elsewhere"]}}), + ("additional-delegated-permission", {"requiredResourceAccess": [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + {"id": CUSTOM_DETECTION_SCOPE_ID, "type": "Scope"}, + {"id": "00000000-0000-0000-0000-000000000001", "type": "Scope"}, + ], + }]}), + ("application-permission", {"requiredResourceAccess": [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [{"id": "00000000-0000-0000-0000-000000000001", + "type": "Role"}], + }]}), + ("password-credential", {"passwordCredentials": [{"keyId": "not-exposed"}]}), + ("key-credential", {"keyCredentials": [{"keyId": "not-exposed"}]}), + ("redacted-permission-field", {"requiredResourceAccess": None}), + ) + for name, updates in mismatch_cases: + with self.subTest(name=name), tempfile.TemporaryDirectory() as temp: + write_delegated_app_registration( + temp, + { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "reconciliation-required", + }, + ) + calls = [] + + def request(method, url, token, body=None): + calls.append(method) + if len(calls) == 1: + return 200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }]} + return 200, self._readback_settings(**updates) + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + result = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda _state, _tenant: "read-only-token", + graph_request=request, + ) + self.assertEqual("verification-blocked", result["status"]) + self.assertEqual(["GET", "GET"], calls) + self.assertFalse(result["privateConfigurationCompleted"]) + self.assertEqual( + "reconciliation-required", + read_delegated_app_registration(temp)["status"], + ) + + def test_readback_refuses_wrong_target_foreign_private_ids_cache_and_graph_denial(self) -> None: + with tempfile.TemporaryDirectory() as temp: + pending = { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "reconciliation-required", + } + write_delegated_app_registration(temp, pending) + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": "00000000-0000-0000-0000-000000000001"}, + ): + with self.assertRaisesRegex( + RuntimeError, + "private app-registration record is not bound to the locked tenant", + ): + verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda *_args: self.fail("must not authenticate"), + ) + + for field, value in ( + ("tenantId", "00000000-0000-0000-0000-000000000001"), + ("displayName", "Other"), + ("applicationId", "00000000-0000-0000-0000-000000000001"), + ("objectId", "00000000-0000-0000-0000-000000000001"), + ): + foreign = dict(pending, **{field: value}) + write_delegated_app_registration(temp, foreign) + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + if field == "tenantId": + with self.assertRaisesRegex( + RuntimeError, + "private app-registration record is not bound to the locked tenant", + ): + verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda *_args: self.fail("must not authenticate"), + ) + continue + + def request(method, url, token, body=None): + self.assertEqual("GET", method) + self.assertIsNone(body) + if "/ownedObjects/" in url: + return 200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }]} + return 200, self._readback_settings() + + result = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda *_args: "synthetic-read-only-token", + graph_request=request, + ) + self.assertEqual("verification-blocked", result["status"]) + self.assertEqual( + "not-owned-or-not-visible" if field == "objectId" else "verified", + result["ownership"], + ) + self.assertEqual( + "not-read" if field == "objectId" else "mismatch-or-incomplete", + result["settings"], + ) + self.assertFalse(result["privateConfigurationCompleted"]) + self.assertFalse(result["graphWritesPerformed"]) + self.assertEqual(foreign, read_delegated_app_registration(temp)) + + write_delegated_app_registration(temp, pending) + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + missing_cache = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda *_args: (_ for _ in ()).throw( + RuntimeError( + "app readback browser authentication is missing; run " + "`setup-app-readback-auth` explicitly first; Bearer " + "secret.access.token" + ) + ), + ) + self.assertEqual("verification-blocked", missing_cache["status"]) + self.assertEqual("not-run", missing_cache["ownership"]) + self.assertIn("setup-app-readback-auth", missing_cache["reason"]) + self.assertNotIn("secret.access.token", missing_cache["reason"]) + + calls = [] + + def denied(method, url, token, body=None): + calls.append(method) + return 403, {"error": {"message": "denied"}} + + blocked = verify_delegated_app_readback( + Path(temp) / "Example", + state_dir=temp, + token_provider=lambda *_args: "read-only-token", + graph_request=denied, + ) + self.assertEqual("verification-blocked", blocked["status"]) + self.assertEqual("unverified", blocked["ownership"]) + self.assertEqual(["GET"], calls) + self.assertEqual( + "reconciliation-required", + read_delegated_app_registration(temp)["status"], + ) + + def test_readback_cli_is_separate_and_reports_blocked_status_as_failure(self) -> None: + output = StringIO() + with redirect_stdout(output), self.assertRaises(SystemExit) as help_exit: + cli_main(["verify-delegated-app-readback", "--help"]) + self.assertEqual(0, help_exit.exception.code) + self.assertIn("--solution", output.getvalue()) + self.assertNotIn("--approve", output.getvalue()) + self.assertNotIn("register-delegated-app", output.getvalue()) + + with tempfile.TemporaryDirectory() as temp, redirect_stdout(output), mock.patch( + "sentinel_xdr_migration.delegated_app_registration.verify_delegated_app_readback", + return_value={ + "status": "verification-blocked", + "graphWritesPerformed": False, + }, + ): + status = cli_main([ + "verify-delegated-app-readback", + "--solution", + str(Path(temp) / "Example"), + ]) + self.assertEqual(1, status) + + def test_manual_permission_update_plan_is_exact_and_verifier_is_get_only(self) -> None: + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "Example" + legacy_permissions = [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [{ + "id": CUSTOM_DETECTION_SCOPE_ID, + "type": "Scope", + }], + }] + desired_permissions = [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + {"id": CUSTOM_DETECTION_SCOPE_ID, "type": "Scope"}, + {"id": THREAT_HUNTING_SCOPE_ID, "type": "Scope"}, + ], + }] + registration = { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "reconciliation-required", + } + write_delegated_app_registration(temp, registration) + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + plan = plan_delegated_app_permission_update( + solution, + state_dir=temp, + ) + self.assertEqual(READBACK_TENANT_ID, plan["tenantId"]) + self.assertEqual(READBACK_APPLICATION_ID, plan["applicationId"]) + self.assertEqual(READBACK_OBJECT_ID, plan["objectId"]) + self.assertEqual(READBACK_DISPLAY_NAME, plan["displayName"]) + self.assertEqual(legacy_permissions, plan["previouslyApprovedRequiredResourceAccess"]) + self.assertEqual(desired_permissions, plan["desiredRequiredResourceAccess"]) + self.assertEqual( + [CUSTOM_DETECTION_SCOPE, THREAT_HUNTING_SCOPE], + plan["desiredDelegatedScopeNames"], + ) + self.assertFalse(plan["automatedReplacementSupported"]) + self.assertEqual([], plan["writeOperations"]) + + calls = [] + + def request(method, url, token, body=None): + calls.append((method, url, body)) + self.assertEqual("GET", method) + if "/ownedObjects/" in url: + return 200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }]} + return 200, self._readback_settings( + requiredResourceAccess=legacy_permissions + ) + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + legacy_result = verify_delegated_app_permission_update( + solution, + expected_plan_sha256=plan["planSha256"], + state_dir=temp, + token_provider=lambda *_: "synthetic-readonly-token", + graph_request=request, + ) + self.assertEqual("manual-update-required", legacy_result["status"]) + self.assertTrue(legacy_result["manualPermissionAdditionRequired"]) + self.assertFalse(legacy_result["automatedUpdatePerformed"]) + self.assertEqual(["GET", "GET"], [method for method, _, _ in calls]) + self.assertTrue(all(body is None for _, _, body in calls)) + self.assertEqual( + "reconciliation-required", + read_delegated_app_registration(temp)["status"], + ) + self.assertEqual({}, read_private_auth_config(temp)["graphClientIds"]) + + calls.clear() + + def desired_request(method, url, token, body=None): + calls.append((method, url, body)) + self.assertEqual("GET", method) + if "/ownedObjects/" in url: + return 200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }]} + return 200, self._readback_settings( + requiredResourceAccess=desired_permissions + ) + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + verified = verify_delegated_app_permission_update( + solution, + expected_plan_sha256=plan["planSha256"], + state_dir=temp, + token_provider=lambda *_: "synthetic-readonly-token", + graph_request=desired_request, + ) + self.assertEqual("manual-permission-update-verified", verified["status"]) + self.assertFalse(verified["automatedUpdatePerformed"]) + self.assertFalse(verified["graphWritesPerformed"]) + self.assertEqual(["GET", "GET"], [method for method, _, _ in calls]) + self.assertTrue(all(body is None for _, _, body in calls)) + clients = read_private_auth_config(temp)["graphClientIds"] + self.assertEqual(READBACK_APPLICATION_ID, clients["customDetection"]) + self.assertEqual(READBACK_APPLICATION_ID, clients["advancedHunting"]) + + def test_manual_permission_update_blocks_wrong_plan_and_unreviewed_settings(self) -> None: + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "Example" + write_delegated_app_registration( + temp, + { + "schemaVersion": 1, + "applicationId": READBACK_APPLICATION_ID, + "objectId": READBACK_OBJECT_ID, + "tenantId": READBACK_TENANT_ID, + "displayName": READBACK_DISPLAY_NAME, + "status": "reconciliation-required", + }, + ) + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + plan = plan_delegated_app_permission_update( + solution, + state_dir=temp, + ) + with self.assertRaisesRegex(ValueError, "plan has changed"): + verify_delegated_app_permission_update( + solution, + expected_plan_sha256="0" * 64, + state_dir=temp, + token_provider=lambda *_: self.fail("must not authenticate"), + ) + + extra_permission = [{ + "resourceAppId": GRAPH_RESOURCE_APP_ID, + "resourceAccess": [ + {"id": CUSTOM_DETECTION_SCOPE_ID, "type": "Scope"}, + {"id": THREAT_HUNTING_SCOPE_ID, "type": "Scope"}, + {"id": "00000000-0000-0000-0000-000000000001", "type": "Scope"}, + ], + }] + + def request(method, url, token, body=None): + self.assertEqual("GET", method) + if "/ownedObjects/" in url: + return 200, {"value": [{ + "id": READBACK_OBJECT_ID, + "@odata.type": "#microsoft.graph.application", + }]} + return 200, self._readback_settings( + requiredResourceAccess=extra_permission + ) + + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": READBACK_TENANT_ID}, + ): + result = verify_delegated_app_permission_update( + solution, + expected_plan_sha256=plan["planSha256"], + state_dir=temp, + token_provider=lambda *_: "synthetic-readonly-token", + graph_request=request, + ) + self.assertEqual("verification-blocked", result["status"]) + self.assertFalse(result["graphWritesPerformed"]) + self.assertEqual( + "reconciliation-required", + read_delegated_app_registration(temp)["status"], + ) + self.assertEqual({}, read_private_auth_config(temp)["graphClientIds"]) + + def test_manual_permission_update_cli_routes_without_selecting_workflow_run(self) -> None: + output = StringIO() + with ( + mock.patch("sentinel_xdr_migration.cli.select_run") as select_run, + mock.patch("sentinel_xdr_migration.cli.configure_logging") as configure_logging, + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.plan_delegated_app_permission_update", + return_value={"status": "manual-readback-required"}, + ) as plan, + redirect_stdout(output), + ): + self.assertEqual( + 0, + cli_main([ + "plan-delegated-app-permission-update", + "--solution", + "solution", + ]), + ) + plan.assert_called_once_with("solution") + select_run.assert_not_called() + configure_logging.assert_not_called() + + with ( + mock.patch("sentinel_xdr_migration.cli.select_run") as select_run, + mock.patch("sentinel_xdr_migration.cli.configure_logging") as configure_logging, + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.verify_delegated_app_permission_update", + return_value={"status": "manual-update-required"}, + ) as verify, + redirect_stdout(output), + ): + self.assertEqual( + 1, + cli_main([ + "verify-delegated-app-permission-update", + "--solution", + "solution", + "--expected-plan-sha256", + "a" * 64, + ]), + ) + verify.assert_called_once_with( + "solution", + expected_plan_sha256="a" * 64, + ) + select_run.assert_not_called() + configure_logging.assert_not_called() + + def test_bootstrap_client_override_is_private_and_cache_namespaced(self) -> None: + class Record: + tenant_id = TENANT_ID + username = "operator@example.invalid" + + def serialize(self): + return "serialized-test-record" + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + with ( + mock.patch( + "azure.identity.DeviceCodeCredential" + ) as credential_type, + mock.patch("azure.identity.TokenCachePersistenceOptions") as cache, + ): + credential_type.return_value.authenticate.return_value = Record() + claims = { + "tid": TENANT_ID, + "aud": GRAPH_RESOURCE_APP_ID, + "scp": ( + "AppRegistration.Create User.Read Directory.Read.All " + "email openid profile" + ), + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode() + ).decode().rstrip("=") + credential_type.return_value.get_token.return_value.token = ( + f"header.{encoded}.signature" + ) + from sentinel_xdr_migration.delegated_app_registration import ( + setup_app_registration_authentication, + ) + + setup_app_registration_authentication( + tenant_id=TENANT_ID, + bootstrap_client_id=CUSTOM_CLIENT_ID, + state_dir=root, + ) + self.assertEqual( + credential_type.call_args.kwargs["client_id"], CUSTOM_CLIENT_ID + ) + self.assertEqual( + cache.call_args.kwargs["name"], + graph_token_cache_name( + "appRegistration", + CUSTOM_CLIENT_ID, + "sentinel_xdr_migration_app_registration", + ), + ) + self.assertEqual( + resolve_graph_client_id(root, "appRegistration"), + CUSTOM_CLIENT_ID, + ) + self.assertFalse((root / "app-registration-auth-record.json").exists()) + records = [ + path + for path in root.glob("app-registration-auth-record.*.json") + if not path.name.endswith(".metadata.json") + ] + self.assertEqual(len(records), 1) + + def test_private_graph_state_cannot_be_placed_under_logs(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) / "Logs" + with self.assertRaisesRegex(ValueError, "Logs"): + configure_graph_client(root, "customDetection", CUSTOM_CLIENT_ID) + + def test_azure_cli_profile_is_explicit_isolated_and_restored(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + profile = root / "lab-cli" + result = configure_azure_cli_profile(root / "state", profile) + self.assertEqual(result["azureConfigDir"], str(profile.resolve())) + original = os.environ.get("AZURE_CONFIG_DIR") + with azure_cli_profile(result["azureConfigDir"]): + self.assertEqual(os.environ["AZURE_CONFIG_DIR"], result["azureConfigDir"]) + self.assertEqual(os.environ.get("AZURE_CONFIG_DIR"), original) + with self.assertRaisesRegex(ValueError, "separate profile"): + configure_azure_cli_profile(root / "state", Path.home() / ".azure") + + def test_profiled_cli_credential_scopes_environment_to_token_request(self) -> None: + with tempfile.TemporaryDirectory() as temp: + profile = str((Path(temp) / "cli").resolve()) + with mock.patch( + "azure.identity.AzureCliCredential" + ) as credential_type: + with mock.patch.dict(os.environ, {"AZURE_CONFIG_DIR": profile}): + credential_type.return_value.get_token.side_effect = ( + lambda *_scopes, **_kwargs: self.assertEqual( + os.environ["AZURE_CONFIG_DIR"], profile + ) + ) + credential = azure_cli_credential( + state_root=Path(temp) / "state" + ) + credential.get_token("scope") + credential_type.return_value.get_token.assert_called_once_with("scope") + + def test_plan_is_read_only_and_declares_no_consent_or_grants(self) -> None: + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "solution" + solution.mkdir() + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration(solution, state_dir=Path(temp) / "state") + self.assertEqual(plan["status"], "approval-required") + self.assertTrue(plan["authorization"]["exactScopeApprovalRequired"]) + self.assertEqual(plan["maximumApplicationCreates"], 1) + self.assertIn("not-verified", plan["tenantWideDisplayNameUniqueness"]) + self.assertFalse(plan["bootstrap"]["adminConsentRequested"]) + self.assertTrue(plan["bootstrap"]["declarationIsNotConsent"]) + self.assertEqual( + plan["bootstrap"]["delegatedScopes"], list(BOOTSTRAP_SCOPES) + ) + self.assertFalse((Path(temp) / "state").exists()) + + def test_requested_app_display_name_changes_plan_hash_and_rejects_old_hash(self) -> None: + proposed_name = "Example-Offline-Registration-App" + graph = mock.Mock() + with tempfile.TemporaryDirectory() as temp: + state = Path(temp) / "state" + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + default_plan = plan_delegated_app_registration(temp, state_dir=state) + named_plan = plan_delegated_app_registration( + temp, state_dir=state, app_display_name=proposed_name + ) + self.assertEqual(default_plan["app"]["displayName"], APP_DISPLAY_NAME) + self.assertEqual(named_plan["app"]["displayName"], proposed_name) + self.assertNotEqual(default_plan["planSha256"], named_plan["planSha256"]) + with self.assertRaisesRegex(ValueError, "plan has changed"): + register_delegated_app( + temp, + expected_plan_sha256=default_plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + app_display_name=proposed_name, + token_provider=lambda *_: "controlled-token", + graph_request=graph, + ) + graph.assert_not_called() + + def test_cli_forwards_exact_display_name_for_plan_and_registration(self) -> None: + proposed_name = "Example-Offline-Registration-App" + run_id = "20261011T200000Z-" + "a" * 32 + with ( + mock.patch("sentinel_xdr_migration.cli.select_run", return_value=None) as select_run, + mock.patch("sentinel_xdr_migration.cli.configure_logging"), + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.plan_delegated_app_registration", + return_value={"status": "approval-required"}, + ) as plan, + redirect_stdout(StringIO()), + ): + self.assertEqual( + cli_main([ + "plan-delegated-app-registration", + "--solution", "solution", + "--run-id", run_id, + "--app-display-name", proposed_name, + ]), + 0, + ) + plan.assert_called_once_with("solution", app_display_name=proposed_name) + select_run.assert_called_once_with("solution", run_id) + + with ( + mock.patch("sentinel_xdr_migration.cli.select_run", return_value=None) as select_run, + mock.patch("sentinel_xdr_migration.cli.configure_logging"), + mock.patch( + "sentinel_xdr_migration.delegated_app_registration.register_delegated_app", + return_value={"status": "configured", "operation": "created"}, + ) as register, + redirect_stdout(StringIO()), + ): + self.assertEqual( + cli_main([ + "register-delegated-app", + "--solution", "solution", + "--run-id", run_id, + "--app-display-name", proposed_name, + "--expected-plan-sha256", "a" * 64, + "--approve-exact-app-registration", + ]), + 0, + ) + register.assert_called_once_with( + "solution", + expected_plan_sha256="a" * 64, + approve_exact_app_registration=True, + app_display_name=proposed_name, + ) + select_run.assert_called_once_with("solution", run_id) + + def test_changed_name_requires_reconciliation_without_renaming_or_creating(self) -> None: + proposed_name = "Example-Offline-Registration-App" + calls = [] + with tempfile.TemporaryDirectory() as temp: + state = Path(temp) / "state" + write_delegated_app_registration( + state, + { + "schemaVersion": 1, + "applicationId": APP_ID, + "objectId": OBJECT_ID, + "tenantId": TENANT_ID, + "displayName": APP_DISPLAY_NAME, + "status": "configured", + }, + ) + existing_old_name = { + **_registration_payload(), + "id": OBJECT_ID, + "appId": APP_ID, + } + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration( + temp, state_dir=state, app_display_name=proposed_name + ) + self.assertEqual(plan["status"], "reconciliation-required") + self.assertEqual( + plan["recordedRegistration"]["displayName"], APP_DISPLAY_NAME + ) + with self.assertRaisesRegex(RuntimeError, "refusing to create a duplicate"): + register_delegated_app( + temp, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + app_display_name=proposed_name, + token_provider=lambda *_: "controlled-token", + graph_request=lambda method, *_args, **_kwargs: ( + calls.append(method) or 200, + {"value": [existing_old_name]}, + ), + ) + self.assertEqual(calls, ["GET"]) + + def test_registration_requires_explicit_exact_plan_approval(self) -> None: + with tempfile.TemporaryDirectory() as temp: + with self.assertRaisesRegex(PermissionError, "explicit"): + register_delegated_app( + temp, + expected_plan_sha256="0" * 64, + state_dir=Path(temp) / "state", + ) + + def test_registration_hash_and_target_are_rechecked_before_any_graph_call(self) -> None: + graph = mock.Mock() + with tempfile.TemporaryDirectory() as temp: + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration(temp, state_dir=Path(temp) / "state") + with self.assertRaisesRegex(ValueError, "plan has changed"): + register_delegated_app( + temp, + expected_plan_sha256="0" * 64, + approve_exact_app_registration=True, + state_dir=Path(temp) / "state", + token_provider=lambda *_: "controlled-token", + graph_request=graph, + ) + graph.assert_not_called() + + def test_bootstrap_token_requires_tenant_audience_and_minimum_scopes(self) -> None: + from sentinel_xdr_migration.delegated_app_registration import ( + BOOTSTRAP_AUTH_RECORD_NAME, + _bootstrap_token, + ) + + def jwt(claims: dict) -> str: + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode() + ).decode().rstrip("=") + return f"header.{encoded}.signature" + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + record_path = root / BOOTSTRAP_AUTH_RECORD_NAME + record_path.write_text("serialized") + write_graph_auth_metadata( + record_path, + purpose="appRegistration", + client_id=DEFAULT_GRAPH_CLIENT_ID, + tenant_id=TENANT_ID, + ) + base_claims = { + "tid": TENANT_ID, + "aud": GRAPH_RESOURCE_APP_ID, + "scp": ( + "AppRegistration.Create User.Read App.Read.All " + "email openid profile" + ), + } + record = type("Record", (), {"tenant_id": TENANT_ID})() + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration._load_auth_record", + return_value=record, + ), mock.patch( + "azure.identity.DeviceCodeCredential" + ) as credential_type: + credential_type.return_value.get_token.return_value.token = jwt( + base_claims + ) + self.assertEqual(_bootstrap_token(root, TENANT_ID), jwt(base_claims)) + credential_type.return_value.get_token.return_value.token = jwt( + {**base_claims, "tid": "00000000-0000-0000-0000-000000000001"} + ) + with self.assertRaisesRegex(RuntimeError, "tenant"): + _bootstrap_token(root, TENANT_ID) + credential_type.return_value.get_token.return_value.token = jwt( + {**base_claims, "aud": "https://other.example"} + ) + with self.assertRaisesRegex(RuntimeError, "Microsoft Graph"): + _bootstrap_token(root, TENANT_ID) + credential_type.return_value.get_token.return_value.token = jwt( + {**base_claims, "scp": "AppRegistration.Create"} + ) + with self.assertRaisesRegex(RuntimeError, "required delegated"): + _bootstrap_token(root, TENANT_ID) + credential_type.return_value.get_token.return_value.token = jwt( + { + **base_claims, + "scp": ["AppRegistration.Create", "User.Read"], + } + ) + with self.assertRaisesRegex(RuntimeError, "required delegated"): + _bootstrap_token(root, TENANT_ID) + credential_type.return_value.get_token.return_value.token = jwt( + { + "tid": TENANT_ID, + "aud": GRAPH_RESOURCE_APP_ID, + "roles": ["AppRegistration.Create", "User.Read"], + } + ) + with self.assertRaisesRegex(RuntimeError, "required delegated"): + _bootstrap_token(root, TENANT_ID) + + def test_registration_lists_owned_apps_then_creates_exactly_one(self) -> None: + proposed_name = "Example-Offline-Registration-App" + payload = _registration_payload(proposed_name) + created = { + **payload, + "id": OBJECT_ID, + "appId": APP_ID, + } + calls = [] + get_count = 0 + + def graph_request(method, url, token, body=None): + nonlocal get_count + calls.append((method, url, body)) + if method == "GET": + get_count += 1 + return 200, {"value": [] if get_count == 1 else [created]} + return 201, created + + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "solution" + solution.mkdir() + state = Path(temp) / "private" + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration( + solution, state_dir=state, app_display_name=proposed_name + ) + result = register_delegated_app( + solution, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + app_display_name=proposed_name, + token_provider=lambda _state, tenant: ( + self.assertEqual(tenant, TENANT_ID) or "controlled-token" + ), + graph_request=graph_request, + ) + self.assertEqual(result["status"], "configured") + self.assertEqual(result["operation"], "created") + self.assertEqual(result["applicationId"], APP_ID) + self.assertEqual(result["displayName"], proposed_name) + self.assertEqual(result["adminConsent"], "not-requested") + self.assertEqual([call[0] for call in calls], ["GET", "POST", "GET"]) + self.assertEqual(calls[1][2], payload) + private_config = json.loads( + (state / PRIVATE_AUTH_CONFIG_NAME).read_text(encoding="utf-8") + ) + self.assertEqual( + private_config["graphClientIds"]["customDetection"], APP_ID + ) + self.assertEqual( + private_config["delegatedAppRegistration"]["displayName"], + proposed_name, + ) + reports = list(solution.rglob("delegated-app-registration.report.json")) + self.assertEqual(len(reports), 1) + report = json.loads(reports[0].read_text()) + self.assertEqual(report["adminConsent"], "not-requested") + self.assertEqual(report["displayName"], proposed_name) + self.assertEqual(report["applicationPermissionGrant"], "not-requested") + self.assertNotIn("secret", json.dumps(private_config).casefold()) + + def test_registration_reuses_exact_owned_app_but_never_modifies_existing_app(self) -> None: + proposed_name = "Example-Offline-Registration-App" + payload = _registration_payload(proposed_name) + matching = {**payload, "id": OBJECT_ID, "appId": APP_ID} + calls = [] + + def graph_request(method, url, token, body=None): + calls.append(method) + return 200, {"value": [matching]} + + with tempfile.TemporaryDirectory() as temp: + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration( + temp, + state_dir=Path(temp) / "state", + app_display_name=proposed_name, + ) + result = register_delegated_app( + temp, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=Path(temp) / "state", + app_display_name=proposed_name, + token_provider=lambda *_: "controlled-token", + graph_request=graph_request, + ) + self.assertEqual(result["operation"], "reused-exact-owned-app") + self.assertEqual(result["displayName"], proposed_name) + self.assertEqual(calls, ["GET"]) + + calls.clear() + changed = {**matching, "publicClient": {"redirectUris": ["https://evil.invalid"]}} + with tempfile.TemporaryDirectory() as temp: + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration( + temp, + state_dir=Path(temp) / "state", + app_display_name=proposed_name, + ) + with self.assertRaisesRegex(RuntimeError, "different settings"): + register_delegated_app( + temp, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=Path(temp) / "state", + app_display_name=proposed_name, + token_provider=lambda *_: "controlled-token", + graph_request=lambda method, *_args: ( + calls.append(method) or 200, + {"value": [changed]}, + ), + ) + self.assertEqual(calls, ["GET"]) + + def test_owned_app_listing_refuses_non_graph_continuation(self) -> None: + with self.assertRaisesRegex(RuntimeError, "unsafe continuation"): + _list_owned_apps( + "controlled-token", + graph_request=lambda *_: ( + 200, + { + "value": [], + "@odata.nextLink": "https://example.invalid/steal-token", + }, + ), + ) + + def test_owned_app_listing_fails_closed_when_permission_is_insufficient(self) -> None: + with self.assertRaisesRegex(RuntimeError, "cannot safely verify ownership"): + _list_owned_apps( + "controlled-token", + graph_request=lambda *_: (403, {"error": {"message": "forbidden"}}), + ) + + def test_registration_fails_closed_when_owned_app_properties_are_redacted(self) -> None: + calls = [] + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "solution" + solution.mkdir() + state = Path(temp) / "private" + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration(solution, state_dir=state) + with self.assertRaisesRegex(RuntimeError, "cannot read enough"): + register_delegated_app( + solution, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + token_provider=lambda *_: "controlled-token", + graph_request=lambda method, *_args, **_kwargs: ( + calls.append(method) or 200, + {"value": [{"id": OBJECT_ID, "@odata.type": "#microsoft.graph.application"}]}, + ), + ) + self.assertEqual(calls, ["GET"]) + + def test_created_app_not_visible_as_owned_blocks_configuration_and_future_duplicate(self) -> None: + payload = _registration_payload() + created = {**payload, "id": OBJECT_ID, "appId": APP_ID} + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "solution" + solution.mkdir() + state = Path(temp) / "private" + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration(solution, state_dir=state) + responses = iter( + [ + (200, {"value": []}), + (201, created), + (200, {"value": []}), + ] + ) + result = register_delegated_app( + solution, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + token_provider=lambda *_: "controlled-token", + graph_request=lambda *_args, **_kwargs: next(responses), + ) + self.assertEqual(result["status"], "reconciliation-required") + persisted = json.loads( + (state / PRIVATE_AUTH_CONFIG_NAME).read_text(encoding="utf-8") + )["delegatedAppRegistration"] + self.assertEqual( + persisted["status"], "created-needs-ownership-verification" + ) + next_plan = plan_delegated_app_registration(solution, state_dir=state) + self.assertEqual(next_plan["status"], "reconciliation-required") + with self.assertRaisesRegex(RuntimeError, "refusing to create a duplicate"): + register_delegated_app( + solution, + expected_plan_sha256=next_plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + token_provider=lambda *_: "controlled-token", + graph_request=lambda *_args, **_kwargs: (200, {"value": []}), + ) + + def test_post_create_owned_app_readback_failure_is_reported_without_success_claim(self) -> None: + proposed_name = "Example-Offline-Registration-App" + payload = _registration_payload(proposed_name) + created = {**payload, "id": OBJECT_ID, "appId": APP_ID} + get_count = 0 + + def graph_request(method, *_args, **_kwargs): + nonlocal get_count + if method == "GET": + get_count += 1 + if get_count == 1: + return 200, {"value": []} + return 403, {"error": {"message": "readback denied"}} + return 201, created + + with tempfile.TemporaryDirectory() as temp: + solution = Path(temp) / "solution" + solution.mkdir() + state = Path(temp) / "private" + with mock.patch( + "sentinel_xdr_migration.delegated_app_registration.require_locked_target", + return_value={"tenantId": TENANT_ID}, + ): + plan = plan_delegated_app_registration( + solution, state_dir=state, app_display_name=proposed_name + ) + result = register_delegated_app( + solution, + expected_plan_sha256=plan["planSha256"], + approve_exact_app_registration=True, + state_dir=state, + app_display_name=proposed_name, + token_provider=lambda *_: "controlled-token", + graph_request=graph_request, + ) + + self.assertEqual(result["status"], "reconciliation-required") + self.assertEqual(result["operation"], "created") + self.assertEqual(result["displayName"], proposed_name) + self.assertFalse(result["ownershipVerified"]) + self.assertIn("HTTP 403", result["error"]) + self.assertEqual(result["reason"], result["error"]) + reports = list(solution.rglob("delegated-app-registration.report.json")) + self.assertEqual(len(reports), 1) + report = json.loads(reports[0].read_text()) + self.assertEqual(report["status"], "reconciliation-required") + self.assertEqual(report["displayName"], proposed_name) + self.assertEqual(report["reason"], result["reason"]) + self.assertEqual(report["error"], result["error"]) + private = json.loads( + (state / PRIVATE_AUTH_CONFIG_NAME).read_text(encoding="utf-8") + ) + self.assertEqual( + private["delegatedAppRegistration"]["displayName"], + proposed_name, + ) + self.assertNotIn("customDetection", private.get("graphClientIds", {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_deployment.py b/Tools/SentinelToXDRMigration/tests/test_deployment.py new file mode 100644 index 00000000000..2114e305e8f --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_deployment.py @@ -0,0 +1,995 @@ +from __future__ import annotations + +import base64 +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.deployment import ( + _deployment_token, + _graph_request, + _validate_deployment_token, + deploy_solution, + graph_detection_payload, +) +from sentinel_xdr_migration import dcr_readiness +from sentinel_xdr_migration.graph_readiness import ( + _graph_get, + _token_diagnostics, + preflight_deployment, +) + +TARGET = { + "tenantId": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceResourceId": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws" + ), + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", +} + + +RULE_ID = "11111111-2222-3333-4444-555555555555" + + +def document(rule_id: str = RULE_ID) -> dict: + return { + "schemaVersion": "1.0.0", + "version": "3.1.0", + "kind": "CustomDetection", + "resourceType": "Microsoft.Security/detectionRules", + "apiVersion": "2026-06-01-preview", + "contentProvenance": { + "source": { + "platform": "Microsoft Sentinel", + "kind": "AnalyticsRule", + "id": rule_id, + "path": "Analytic Rules/rule.yaml", + "querySha256": "hash", + "schedule": { + "queryFrequency": "1h", + "queryPeriod": "1h", + }, + }, + "conversion": { + "tool": "sentinel-to-xdr-migration", + "version": "0.1.0", + "status": "converted", + "reviewRequired": False, + "reviewReasons": [], + "requiredWorkloads": ["defender"], + "warnings": [], + "errors": [], + }, + }, + "properties": { + "id": rule_id, + "displayName": "Test rule", + "status": "disabled", + "queryCondition": {"queryText": "DeviceInfo | take 1"}, + "schedule": {"frequency": "PT1H"}, + "detectionAction": { + "alertTemplate": { + "title": "Test rule", + "description": "Test", + "severity": "low", + "entityMappings": { + "hosts": [{"id": "host1", "deviceIdColumn": "DeviceId"}] + }, + } + }, + }, + } + + +class DeploymentTests(unittest.TestCase): + def test_custom_detection_token_accepts_scope_supersets_but_not_invalid_claims( + self, + ) -> None: + base = { + "tid": TARGET["tenantId"], + "aud": "https://graph.microsoft.com", + "scp": ( + "CustomDetection.ReadWrite.All CustomDetection.Read.All " + "User.Read email openid profile" + ), + } + with mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value=base, + ): + diagnostics = _validate_deployment_token( + "synthetic-token", + tenant_id=TARGET["tenantId"], + ) + self.assertEqual( + { + "CustomDetection.Read.All", + "User.Read", + "email", + "openid", + "profile", + }, + set(diagnostics["delegatedScopes"]["extraScopeNames"]), + ) + for claims, message in ( + ({**base, "tid": "wrong-tenant"}, "tenant"), + ({**base, "aud": "https://other.example"}, "Microsoft Graph"), + ({**base, "scp": "CustomDetection.Read.All"}, "required delegated"), + ({**base, "scp": ["CustomDetection.ReadWrite.All"]}, "required delegated"), + ( + { + "tid": TARGET["tenantId"], + "aud": "https://graph.microsoft.com", + "roles": ["CustomDetection.ReadWrite.All"], + }, + "required delegated", + ), + ): + with self.subTest(claims=claims), mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value=claims, + ): + with self.assertRaisesRegex(RuntimeError, message): + _validate_deployment_token( + "synthetic-token", + tenant_id=TARGET["tenantId"], + ) + + @mock.patch("urllib.request.urlopen") + def test_graph_write_request_builds_authorized_json_request( + self, urlopen: mock.Mock + ) -> None: + response = mock.MagicMock() + response.status = 201 + response.read.return_value = b'{"id":"xdr-example"}' + urlopen.return_value.__enter__.return_value = response + + status, body = _graph_request( + "POST", + "https://graph.microsoft.com/beta/security/rules/detectionRules", + "sample-token", + {"id": "xdr-example", "status": "disabled"}, + ) + + request = urlopen.call_args.args[0] + self.assertEqual(status, 201) + self.assertEqual(body, {"id": "xdr-example"}) + self.assertEqual(request.method, "POST") + self.assertEqual( + request.get_header("Authorization"), + "Bearer sample-token", + ) + self.assertEqual( + json.loads(request.data), + {"id": "xdr-example", "status": "disabled"}, + ) + + @mock.patch("urllib.request.urlopen") + def test_graph_preflight_read_request_is_exact_bearer_get( + self, urlopen: mock.Mock + ) -> None: + response = mock.MagicMock() + response.status = 200 + response.read.return_value = b'{"id":"xdr-example"}' + urlopen.return_value.__enter__.return_value = response + + status, body = _graph_get( + "https://graph.microsoft.com/beta/security/rules/" + "detectionRules/xdr-example", + "sample-token", + timeout_seconds=7, + ) + + request = urlopen.call_args.args[0] + self.assertEqual(status, 200) + self.assertEqual(body, {"id": "xdr-example"}) + self.assertEqual(request.method, "GET") + self.assertIsNone(request.data) + self.assertEqual( + request.get_header("Authorization"), + "Bearer sample-token", + ) + self.assertEqual(urlopen.call_args.kwargs["timeout"], 7) + + def test_payload_is_graph_ready_and_disabled(self) -> None: + payload = graph_detection_payload(document()) + + self.assertEqual( + payload["@odata.type"], "#microsoft.graph.security.detectionRule" + ) + self.assertEqual(payload["status"], "disabled") + self.assertEqual(f"xdr-{RULE_ID}", payload["id"]) + + def test_payload_rejects_review_required_detection(self) -> None: + value = document() + value["contentProvenance"]["conversion"]["status"] = "needsReview" + value["contentProvenance"]["conversion"]["reviewRequired"] = True + + with self.assertRaisesRegex(ValueError, "no review required"): + graph_detection_payload(value) + + def test_payload_projects_first_tactic_and_keeps_compatible_techniques(self) -> None: + value = document() + value["properties"]["detectionAction"]["alertTemplate"]["tactics"] = [ + { + "tactic": "Execution", + "techniques": [ + { + "technique": "T1059", + "subTechniques": ["T1059.001", "T1059.003"], + } + ], + }, + { + "tactic": "Persistence", + "techniques": [{"technique": "T1547"}], + }, + ] + authored = json.loads(json.dumps(value)) + + payload = graph_detection_payload(value) + projected = payload["detectionAction"]["alertTemplate"]["tactics"] + + self.assertEqual( + projected, + [ + { + "tactic": "Execution", + "techniques": [ + { + "technique": "T1059", + "subTechniques": ["T1059.001", "T1059.003"], + } + ], + } + ], + ) + self.assertEqual(value, authored) + + def test_preflight_reports_projection_without_modifying_authored_yaml(self) -> None: + value = document() + value["properties"]["detectionAction"]["alertTemplate"]["tactics"] = [ + {"tactic": "Execution", "techniques": [{"technique": "T1059"}]}, + {"tactic": "Persistence", "techniques": [{"technique": "T1547"}]}, + ] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + path = detections / "rule.yaml" + original = yaml.safe_dump(value) + path.write_text(original, encoding="utf-8") + with mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ): + report = preflight_deployment(root) + + self.assertEqual(path.read_text(encoding="utf-8"), original) + self.assertEqual(report["status"], "action-required") + readiness = report["payloadReadiness"][0] + self.assertEqual(readiness["status"], "ready") + projection = readiness["graphTacticProjection"] + self.assertEqual(projection["projectedTactics"], ["Execution"]) + self.assertEqual(projection["omittedTactics"], ["Persistence"]) + self.assertEqual(projection["classificationParity"], "not-established") + self.assertEqual(report["permissions"]["writeAuthorization"], "not-tested") + + def test_preflight_read_only_rule_check_does_not_claim_write_readiness(self) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + "scp": ( + "CustomDetection.ReadWrite.All CustomDetection.Read.All " + "User.Read email openid profile" + ), + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + credential = mock.Mock() + credential.get_token.return_value.token = f"header.{encoded}.signature" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=credential, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=(200, {"id": f"xdr-{RULE_ID}"}), + ) as graph_get, + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual(report["status"], "readiness-unconfirmed") + self.assertEqual(report["readChecks"][0]["status"], "confirmed") + self.assertTrue(report["readChecks"][0]["exists"]) + self.assertEqual(report["permissions"]["readAccess"], "confirmed") + self.assertEqual(report["permissions"]["writeAuthorization"], "not-tested") + self.assertEqual(report["permissions"]["effectiveRole"]["status"], "unknown") + self.assertEqual(graph_get.call_count, 1) + credential.get_token.assert_called_once() + self.assertNotIn("header.", json.dumps(report)) + self.assertEqual( + report["authentication"]["requestedScope"], + "https://graph.microsoft.com/.default", + ) + self.assertEqual( + report["authentication"]["scopeRequestKind"], + "graph-resource-default", + ) + self.assertEqual( + graph_get.call_args.args[0], + "https://graph.microsoft.com/beta/security/rules/detectionRules/" + f"xdr-{RULE_ID}", + ) + self.assertEqual( + report["requiredManagementPermission"], + "CustomDetection.ReadWrite.All", + ) + self.assertEqual( + report["managementPermissionGrant"]["status"], + "ready", + ) + self.assertEqual( + report["managementPermissionGrant"]["grantType"], + "delegated", + ) + self.assertEqual( + { + "CustomDetection.Read.All", + "User.Read", + "email", + "openid", + "profile", + }, + set( + report["authentication"]["delegatedScopeDiagnostics"][ + "extraScopeNames" + ] + ), + ) + self.assertEqual(report["deploymentReadiness"], "readiness-unconfirmed") + + def test_read_permission_and_successful_get_do_not_establish_deployment_readiness( + self, + ) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + "scp": "CustomDetection.Read.All", + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + credential = mock.Mock() + credential.get_token.side_effect = [ + RuntimeError("write scope unavailable"), + mock.Mock(token=f"header.{encoded}.signature"), + ] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=credential, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=(200, {"id": f"xdr-{RULE_ID}"}), + ) as graph_get, + ): + report = preflight_deployment(root, auth_method="device-code") + + self.assertEqual(report["readChecks"][0]["status"], "confirmed") + self.assertEqual(report["permissions"]["readAccess"], "confirmed") + self.assertEqual( + report["managementPermissionGrant"]["status"], + "missing", + ) + self.assertEqual( + report["managementPermissionGrant"]["grantType"], + "delegated", + ) + self.assertEqual( + report["managementPermissionGrant"]["credentialRequestScope"], + "https://graph.microsoft.com/CustomDetection.Read.All", + ) + self.assertEqual(report["deploymentReadiness"], "action-required") + self.assertEqual(report["status"], "action-required") + self.assertEqual(report["permissions"]["writeAuthorization"], "not-tested") + graph_get.assert_called_once() + + def test_app_only_write_role_is_distinguished_from_delegated_scope(self) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + "roles": ["CustomDetection.ReadWrite.All"], + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + credential = mock.Mock() + credential.get_token.return_value.token = f"header.{encoded}.signature" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=credential, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=(200, {"id": f"xdr-{RULE_ID}"}), + ), + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual( + report["managementPermissionGrant"]["status"], + "missing", + ) + self.assertEqual( + report["managementPermissionGrant"]["grantType"], + "application", + ) + self.assertEqual( + report["managementPermissionGrant"]["claimType"], + "roles", + ) + self.assertEqual(report["permissions"]["effectiveRole"]["status"], "unknown") + self.assertEqual(report["permissions"]["writeAuthorization"], "not-tested") + self.assertEqual(report["authentication"]["status"], "blocked") + self.assertEqual(report["deploymentReadiness"], "blocked") + + def test_missing_or_malformed_permission_claims_are_unknown(self) -> None: + base = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + } + cases = [ + (base, "missing"), + ({**base, "scp": []}, "malformed"), + ({**base, "roles": "CustomDetection.ReadWrite.All"}, "malformed"), + ({**base, "scp": " "}, "malformed"), + ] + for claims, claim_type in cases: + with self.subTest(claims=claims): + diagnostics = _token_diagnostics(claims, TARGET["tenantId"]) + self.assertEqual( + diagnostics["managementPermissionGrant"]["status"], + "unknown", + ) + self.assertEqual( + diagnostics["managementPermissionGrant"]["claimType"], + claim_type, + ) + + def test_preflight_404_does_not_claim_exact_rule_read_access(self) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + "scp": "CustomDetection.ReadWrite.All", + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + credential = mock.Mock() + credential.get_token.return_value.token = f"header.{encoded}.signature" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=credential, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=(404, {"error": {"code": "NotFound"}}), + ), + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual(report["status"], "unknown") + self.assertEqual(report["permissions"]["readAccess"], "unknown") + self.assertEqual(report["readChecks"][0]["status"], "not-found") + self.assertFalse(report["readChecks"][0]["exists"]) + + def test_preflight_missing_cached_auth_does_not_start_login(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=None, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get" + ) as graph_get, + ): + report = preflight_deployment(root, auth_method="device-code") + + self.assertEqual(report["status"], "action-required") + self.assertEqual(report["authentication"]["status"], "action-required") + graph_get.assert_not_called() + + def test_preflight_blocks_review_required_payload_before_auth(self) -> None: + value = document() + value["contentProvenance"]["conversion"]["status"] = "needsReview" + value["contentProvenance"]["conversion"]["reviewRequired"] = True + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(value), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential" + ) as credential, + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual(report["status"], "blocked") + self.assertEqual(report["payloadReadiness"][0]["status"], "blocked") + self.assertIn( + "no review required", + report["payloadReadiness"][0]["message"], + ) + credential.assert_not_called() + + def test_preflight_runs_dcr_diagnostics_before_review_required_payload_block(self) -> None: + value = document() + value["contentProvenance"]["conversion"]["status"] = "needsReview" + value["contentProvenance"]["conversion"]["reviewRequired"] = True + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(value), encoding="utf-8" + ) + target = { + **TARGET, + "targetPath": str(root / "qualification-target.json"), + } + Path(target["targetPath"]).write_text( + json.dumps(TARGET), encoding="utf-8" + ) + dcr_credential = mock.Mock() + dcr_credential.get_token.return_value.token = "header.mock-token.signature" + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=target, + ), + mock.patch.object( + dcr_readiness, + "resolve_workspace_customer_id", + return_value=TARGET["workspaceCustomerId"], + ), + mock.patch.object( + dcr_readiness, + "azure_cli_credential", + return_value=dcr_credential, + ) as selected_credential, + mock.patch.object( + dcr_readiness, + "_check_query_permission", + return_value={ + "status": "ready", + "meansDataVisible": False, + }, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential" + ) as credential, + mock.patch("urllib.request.urlopen") as urlopen, + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual(report["status"], "blocked") + self.assertEqual( + report["dcrPermissionPreflight"]["status"], + "unknown", + ) + self.assertEqual( + "confirmed", + report["dcrPermissionPreflight"]["targetVerification"]["status"], + ) + self.assertEqual( + "ready", + report["dcrPermissionPreflight"]["workspaceQueryPermission"]["status"], + ) + selected_credential.assert_called_once() + urlopen.assert_not_called() + credential.assert_not_called() + + def test_cli_preflight_uses_real_dcr_helper_signature_without_cloud_calls(self) -> None: + from sentinel_xdr_migration import cli, graph_readiness + + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + target_path = root / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + target = {**TARGET, "targetPath": str(target_path)} + azure_credential = mock.Mock() + azure_credential.get_token.return_value.token = "header.mock-token.signature" + real_helper = dcr_readiness.preflight_dcr_permissions + + def call_real_helper(solution, locked_target, *, auth_method): + return real_helper( + solution, + locked_target, + auth_method=auth_method, + ) + + output = io.StringIO() + with ( + mock.patch.object( + graph_readiness, + "require_locked_target", + return_value=target, + ), + mock.patch.object( + graph_readiness, + "preflight_dcr_permissions", + autospec=True, + side_effect=call_real_helper, + ) as dcr_preflight, + mock.patch.object( + dcr_readiness, + "resolve_workspace_customer_id", + return_value=TARGET["workspaceCustomerId"], + ), + mock.patch.object( + dcr_readiness, + "azure_cli_credential", + return_value=azure_credential, + ) as selected_credential, + mock.patch.object( + dcr_readiness, + "_check_query_permission", + return_value={ + "status": "ready", + "meansDataVisible": False, + }, + ), + mock.patch("urllib.request.urlopen") as urlopen, + contextlib.redirect_stdout(output), + ): + result = cli.main( + [ + "preflight-deployment", + "--solution", + str(root), + "--auth-method", + "azure-cli", + ] + ) + + report = json.loads(output.getvalue()) + self.assertEqual(0, result) + self.assertEqual("blocked", report["status"]) + self.assertEqual( + "unknown", + report["dcrPermissionPreflight"]["status"], + ) + self.assertEqual( + "confirmed", + report["dcrPermissionPreflight"]["targetVerification"]["status"], + ) + self.assertEqual( + "ready", + report["dcrPermissionPreflight"]["workspaceQueryPermission"]["status"], + ) + selected_credential.assert_called_once() + dcr_preflight.assert_called_once_with( + root.resolve(), + target, + auth_method="azure-cli", + ) + + def test_preflight_preserves_forbidden_read_and_unknown_role(self) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + "scp": "CustomDetection.ReadWrite.All", + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + credential = mock.Mock() + credential.get_token.return_value.token = f"header.{encoded}.signature" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=credential, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=( + 403, + {"error": {"code": "Authorization_RequestDenied"}}, + ), + ), + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual(report["status"], "blocked") + self.assertEqual(report["permissions"]["readAccess"], "denied") + self.assertEqual(report["permissions"]["effectiveRole"]["status"], "unknown") + self.assertEqual(report["readChecks"][0]["status"], "denied") + self.assertEqual(report["readChecks"][0]["httpStatus"], 403) + self.assertEqual( + report["readChecks"][0]["graphErrorCode"], + "Authorization_RequestDenied", + ) + + def test_preflight_preserves_unknown_graph_error_instead_of_reporting_pass(self) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "00000003-0000-0000-c000-000000000000", + "scp": "CustomDetection.ReadWrite.All", + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + credential = mock.Mock() + credential.get_token.return_value.token = f"header.{encoded}.signature" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + detections = root / "XDR Detections" + detections.mkdir() + (detections / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=TARGET, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential", + return_value=credential, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=( + 503, + {"error": {"code": "ServiceUnavailable"}}, + ), + ), + ): + report = preflight_deployment(root, auth_method="azure-cli") + + self.assertEqual(report["status"], "unknown") + self.assertEqual(report["readChecks"][0]["status"], "unknown") + self.assertEqual(report["readChecks"][0]["httpStatus"], 503) + self.assertEqual( + report["readChecks"][0]["graphErrorCode"], + "ServiceUnavailable", + ) + + def test_preflight_does_not_continue_after_locked_target_mismatch(self) -> None: + with ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + side_effect=ValueError("tenantId does not match the locked qualification target"), + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._get_cached_credential" + ) as credential, + ): + with self.assertRaisesRegex(ValueError, "locked qualification target"): + preflight_deployment("unused", auth_method="azure-cli") + + credential.assert_not_called() + + @mock.patch("azure.identity.AzureCliCredential") + def test_deployment_token_falls_back_to_azure_cli( + self, credential: mock.Mock + ) -> None: + claims = { + "tid": TARGET["tenantId"], + "aud": "https://graph.microsoft.com", + "scp": "CustomDetection.ReadWrite.All User.Read email openid profile", + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + token_value = f"header.{encoded}.signature" + credential.return_value.get_token.return_value.token = token_value + with tempfile.TemporaryDirectory() as temp: + token = _deployment_token(temp, tenant_id=TARGET["tenantId"]) + + self.assertEqual(token, token_value) + + @mock.patch( + "sentinel_xdr_migration.deployment._deployment_token", + return_value="token", + ) + @mock.patch( + "sentinel_xdr_migration.deployment.require_locked_target", + return_value=TARGET, + ) + @mock.patch("sentinel_xdr_migration.deployment._graph_request") + def test_deploy_creates_missing_rule( + self, request: mock.Mock, _target: mock.Mock, _token: mock.Mock + ) -> None: + request.side_effect = [ + (404, {"error": {"code": "NotFound"}}), + (201, {"id": RULE_ID}), + (200, {"id": RULE_ID, "status": "disabled"}), + ] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output = root / "XDR Detections" + output.mkdir() + (output / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + + result = deploy_solution(root) + + self.assertEqual(result["succeeded"], 1) + self.assertEqual(result["failed"], 0) + self.assertEqual(result["results"][0]["deployedStatus"], "disabled") + self.assertEqual(request.call_args_list[1].args[0], "POST") + self.assertEqual(f"xdr-{RULE_ID}", request.call_args_list[1].args[3]["id"]) + self.assertTrue(result["results"][0]["tacticProjectionVerified"]) + + @mock.patch( + "sentinel_xdr_migration.deployment._deployment_token", + return_value="token", + ) + @mock.patch( + "sentinel_xdr_migration.deployment.require_locked_target", + return_value=TARGET, + ) + @mock.patch("sentinel_xdr_migration.deployment._graph_request") + def test_deploy_fails_if_graph_readback_does_not_match_projection( + self, request: mock.Mock, _target: mock.Mock, _token: mock.Mock + ) -> None: + value = document() + value["properties"]["detectionAction"]["alertTemplate"]["tactics"] = [ + {"tactic": "Execution", "techniques": [{"technique": "T1059"}]}, + {"tactic": "Persistence", "techniques": [{"technique": "T1547"}]}, + ] + request.side_effect = [ + (404, {"error": {"code": "NotFound"}}), + (201, {"id": RULE_ID}), + ( + 200, + { + "id": RULE_ID, + "status": "disabled", + "detectionAction": {"alertTemplate": {"tactics": []}}, + }, + ), + ] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output = root / "XDR Detections" + output.mkdir() + (output / "rule.yaml").write_text( + yaml.safe_dump(value), encoding="utf-8" + ) + + result = deploy_solution(root) + + self.assertEqual(result["succeeded"], 0) + self.assertFalse(result["results"][0]["tacticProjectionVerified"]) + self.assertEqual( + result["results"][0]["tacticProjection"]["omittedTactics"], + ["Persistence"], + ) + + @mock.patch( + "sentinel_xdr_migration.deployment._deployment_token", + return_value="token", + ) + @mock.patch( + "sentinel_xdr_migration.deployment.require_locked_target", + return_value=TARGET, + ) + @mock.patch("sentinel_xdr_migration.deployment._graph_request") + def test_deploy_updates_existing_rule( + self, request: mock.Mock, _target: mock.Mock, _token: mock.Mock + ) -> None: + request.side_effect = [ + (200, {"id": RULE_ID}), + (200, {"id": RULE_ID}), + (200, {"id": RULE_ID, "status": "disabled"}), + ] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output = root / "XDR Detections" + output.mkdir() + (output / "rule.yaml").write_text( + yaml.safe_dump(document()), encoding="utf-8" + ) + + result = deploy_solution(root) + + self.assertEqual(result["succeeded"], 1) + self.assertEqual(result["results"][0]["deployedStatus"], "disabled") + self.assertEqual(request.call_args_list[1].args[0], "PATCH") + self.assertTrue(request.call_args_list[1].args[1].endswith("/xdr-" + RULE_ID)) + update = request.call_args_list[1].args[3] + self.assertNotIn("id", update) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_deployment_planning.py b/Tools/SentinelToXDRMigration/tests/test_deployment_planning.py new file mode 100644 index 00000000000..dd01f0d72b4 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_deployment_planning.py @@ -0,0 +1,932 @@ +from __future__ import annotations + +import json +import hashlib +import io +import subprocess +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration import artifacts +from sentinel_xdr_migration.cli import main +from sentinel_xdr_migration import deployment_planning as planning + +FIXTURE = Path(__file__).parent / "fixtures" / "deployment-planning" +INVENTORY_FIXTURE = Path(__file__).parent / "fixtures" / "deployment-planning-inventory" +TARGET = { + "tenantId": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceResourceId": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg-test/providers/Microsoft.OperationalInsights/" + "workspaces/ws-test" + ), + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", +} + + +class DeploymentPlanningTests(unittest.TestCase): + RULE_IDS = [ + "11111111-2222-3333-4444-555555555551", + "11111111-2222-3333-4444-555555555552", + "11111111-2222-3333-4444-555555555553", + ] + + @staticmethod + def _rule_record(rule_id: str, index: int) -> dict: + return { + "id": rule_id, + "path": f"Analytic Rules/rule-{index}.yaml", + "document": { + "id": rule_id, + "kind": "Scheduled", + "name": f"Rule {index}", + "query": "DeviceEvents", + "severity": "Medium", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + }, + } + + @staticmethod + def _candidate(rule: dict, *, multi_tactic: bool = False, review: bool = False, valid: bool = True) -> dict: + source_document = rule["document"] + source_yaml = yaml.safe_dump(source_document, sort_keys=True, allow_unicode=True).encode("utf-8") + tactics = [ + {"tactic": "Exfiltration", "techniques": [{"technique": "T1041"}]}, + ] + if multi_tactic: + tactics.append( + {"tactic": "Impact", "techniques": [{"technique": "T1498"}]} + ) + query_text = "DeviceEvents" + source_id = source_document["id"] + candidate = { + "schemaVersion": "1.0.0", + "version": "3.1.1", + "kind": "CustomDetection", + "resourceType": "Microsoft.Security/detectionRules", + "apiVersion": "2026-06-01-preview", + "contentProvenance": { + "source": { + "platform": "Microsoft Sentinel", + "kind": "AnalyticsRule", + "id": source_id, + "path": rule["path"], + "querySha256": hashlib.sha256( + source_document["query"].encode("utf-8") + ).hexdigest(), + "schedule": {"frequency": "PT5M", "period": "PT5M"}, + }, + "conversion": { + "tool": "sentinel-to-xdr-migration", + "version": "1.0.0", + "status": "converted", + "reviewRequired": review, + "reviewReasons": [], + "requiredWorkloads": [], + "warnings": [], + }, + }, + "properties": { + "id": f"xdr-{source_id}", + "displayName": source_document["name"], + "status": "disabled", + "queryCondition": {"queryText": query_text if valid else ""}, + "schedule": {"frequency": "PT5M"}, + "detectionAction": { + "alertTemplate": { + "entityMappings": {"hosts": {}}, + "tactics": tactics, + } + }, + }, + } + candidate["contentProvenance"]["conversion"]["reviewScope"] = { + "sourceDocumentSha256": hashlib.sha256(source_yaml).hexdigest(), + "targetQuerySha256": hashlib.sha256(query_text.encode("utf-8")).hexdigest(), + } + return candidate + + def _artifact(self, root: Path, name: str) -> Path: + if name == "workflow-state.json": + return FIXTURE / name + return root / "Logs" / "sentinel-xdr-migration" / name + + def _build( + self, + target=TARGET, + consent_record=None, + *, + source_sha256=None, + package_content_sha256="f" * 64, + source_inventory=None, + ): + original = planning.existing_artifact_path + original_source_inventory = planning._source_inventory + + def artifact(root, name): + if name == planning.CONSENT_FILE_NAME and consent_record is not None: + return Path("consent-record.json") + return self._artifact(Path(root), name) + + def read_path(path, *args, **kwargs): + if Path(path) == Path("consent-record.json"): + return json.dumps(consent_record) + with Path(path).open(*args, **kwargs) as stream: + return stream.read() + + package = { + "status": "provisional", + "version": None, + "packageInputSha256": None, + "packageArtifactHashes": {}, + "packageContentSha256": package_content_sha256, + "recordedPackageArtifactHashes": {}, + "deploymentParameters": {}, + "resourceInventory": [], + "bicepExtensibilityImports": [], + "whatIfSupport": "not-evaluated", + } + with ( + mock.patch.object(planning, "existing_artifact_path", side_effect=artifact), + mock.patch.object( + planning, + "_read_existing_dcr_preflight", + return_value={"status": "not-recorded", "contracts": []}, + ), + mock.patch.object(planning, "package_input_identity", side_effect=ValueError("package not built")), + mock.patch.object(planning, "_current_package", return_value=package), + mock.patch.object( + planning, + "_source_inventory", + side_effect=lambda root: ( + { + **( + source_inventory + if source_inventory is not None + else original_source_inventory(root) + ), + **( + {"sourceInventorySha256": source_sha256} + if source_sha256 is not None + else {} + ), + } + ), + ), + mock.patch.object( + planning.Path, + "read_text", + autospec=True, + side_effect=read_path, + ) if consent_record is not None else mock.patch.object(planning, "_consent_state", return_value={"status": "not-recorded", "approvedComponents": []}), + ): + return planning.build_deployment_plan(FIXTURE, target=target) + + def test_recursive_inventory_counts_duplicate_rule_files_once_by_identity(self) -> None: + plan = self._build() + + self.assertEqual(plan["sourceInventory"]["analyticRules"], 1) + self.assertEqual(plan["sourceInventory"]["analyticRuleFiles"], 2) + self.assertEqual(plan["detectionScope"]["sourceAnalyticRuleCount"], 1) + self.assertEqual(plan["detectionScope"]["duplicateSourceRuleIds"], [ + "11111111-2222-3333-4444-555555555555" + ]) + self.assertEqual(plan["detectionScope"]["eligibleMaximumAdditionalCustomDetections"], 0) + + def test_graph_payload_projection_controls_eligibility_not_stale_converter_flag(self) -> None: + rules = { + rule_id.casefold(): self._rule_record(rule_id, index) + for index, rule_id in enumerate(self.RULE_IDS) + } + + def build_candidate(source, _root, _config): + index = int(Path(source).stem.rsplit("-", 1)[1]) + return self._candidate(rules[self.RULE_IDS[index].casefold()], multi_tactic=index < 2) + + with mock.patch.object(planning, "build_xdr_document", side_effect=build_candidate): + eligible, ineligible, projections = planning._eligible_rule_ids( + FIXTURE, rules, set(), set() + ) + + self.assertEqual(eligible, self.RULE_IDS) + self.assertEqual(ineligible, []) + self.assertEqual(projections[self.RULE_IDS[0]]["projectedTactics"], ["Exfiltration"]) + self.assertEqual(projections[self.RULE_IDS[0]]["omittedTactics"], ["Impact"]) + self.assertEqual( + projections[self.RULE_IDS[0]]["omittedTechniques"], + [{"tactic": "Impact", "techniques": ["T1498"]}], + ) + self.assertEqual(projections[self.RULE_IDS[0]]["classificationParity"], "not-established") + + candidates = { + self.RULE_IDS[0].casefold(): self._candidate( + rules[self.RULE_IDS[0].casefold()], multi_tactic=True, review=True + ), + self.RULE_IDS[1].casefold(): self._candidate( + rules[self.RULE_IDS[1].casefold()], valid=False + ), + self.RULE_IDS[2].casefold(): self._candidate(rules[self.RULE_IDS[2].casefold()]), + } + with mock.patch.object( + planning, + "build_xdr_document", + side_effect=lambda source, _root, _config: candidates[ + self.RULE_IDS[int(Path(source).stem.rsplit("-", 1)[1])].casefold() + ], + ): + eligible, ineligible, _ = planning._eligible_rule_ids( + FIXTURE, rules, set(), {self.RULE_IDS[2].casefold()} + ) + self.assertEqual(eligible, []) + self.assertEqual(set(ineligible), set(self.RULE_IDS)) + + def test_source_analytic_rule_eligibility_is_independent_from_graph_projection(self) -> None: + rules = { + rule_id.casefold(): self._rule_record(rule_id, index) + for index, rule_id in enumerate(self.RULE_IDS) + } + eligible, ineligible = planning._eligible_analytic_rule_ids(rules, set()) + + self.assertEqual(eligible, self.RULE_IDS) + self.assertEqual(ineligible, []) + + def test_plan_distinguishes_total_reviewed_custom_detections_from_additional_cap(self) -> None: + rules = { + rule_id.casefold(): self._rule_record(rule_id, index) + for index, rule_id in enumerate(self.RULE_IDS) + } + projections = { + rule_id: { + "status": "projected", + "projectedTactics": ["Exfiltration"], + "omittedTactics": ["Impact"], + "projectedTechniques": ["T1041"], + "omittedTechniques": ["T1498"], + "classificationParity": "not-established", + } + for rule_id in self.RULE_IDS[:2] + } + with ( + mock.patch.object(planning, "_source_records", return_value=(rules, set())), + mock.patch.object( + planning, + "_reviewed_current_drafts", + return_value=(self.RULE_IDS, [], projections), + ), + mock.patch.object( + planning, + "_eligible_rule_ids", + return_value=(self.RULE_IDS[2:], self.RULE_IDS[:2], projections), + ), + mock.patch.object( + planning, + "_eligible_analytic_rule_ids", + return_value=(self.RULE_IDS, []), + ), + ): + plan = self._build( + source_inventory={ + "counts": { + "analyticRules": 3, + "playbooks": 3, + "configuredPlaybookTemplateCount": 4, + "uniquePlaybookWorkflowCount": 3, + "workbooks": 1, + "dataConnectors": 1, + }, + "fileCounts": {"playbooks": 6, "dataConnectors": 2}, + "manifestContentInventory": { + "status": "ready", + "manifestReferenceCounts": {"playbooks": 4, "dataConnectors": 1}, + "configuredPlaybookTemplateCount": 4, + "uniquePlaybookWorkflowCount": 3, + }, + "analyticRuleCount": 3, + "sourceInventorySha256": "a" * 64, + } + ) + + scope = plan["detectionScope"] + self.assertEqual(scope["currentReviewedDraftCount"], 3) + self.assertEqual(scope["eligibleMaximumAdditionalCustomDetections"], 0) + self.assertEqual(scope["eligibleMaximumTotalCustomDetections"], 3) + self.assertEqual(scope["eligibleMaximumTotalDisabledRulePairs"], 3) + self.assertEqual(plan["components"]["xdrCustomDetections"]["maximumCount"], 3) + self.assertEqual(plan["components"]["xdrCustomDetections"]["maximumAdditionalCount"], 0) + self.assertEqual(plan["components"]["sentinelAlertRules"]["maximumCount"], 3) + self.assertEqual(plan["sourceInventory"]["playbooks"], 3) + self.assertEqual(plan["sourceInventory"]["configuredPlaybookTemplateCount"], 4) + self.assertEqual(plan["sourceInventory"]["uniquePlaybookWorkflowCount"], 3) + self.assertEqual(plan["sourceFileCounts"]["playbooks"], 6) + self.assertEqual( + plan["manifestContentInventory"]["manifestReferenceCounts"]["playbooks"], + 4, + ) + self.assertEqual(scope["ineligibleRuleIds"], []) + self.assertEqual(scope["ineligibleNewConversionCandidateRuleIds"], self.RULE_IDS[:2]) + self.assertEqual( + scope["currentReviewedDraftsNotEligibleAsNewConversionCandidates"], + self.RULE_IDS[:2], + ) + self.assertEqual(scope["eligiblePairRuleIds"], self.RULE_IDS) + parity = plan["components"]["controlledAlertParity"] + self.assertEqual(parity["status"], "provisional") + self.assertEqual(parity["eligiblePairRuleIds"], self.RULE_IDS) + self.assertEqual(parity["maximumEnableWriteRequests"], 6) + self.assertEqual(parity["maximumRestoreWriteRequests"], 6) + self.assertEqual(parity["maximumWriteRequestsForOneBoundedRun"], 12) + self.assertEqual(scope["sourceAnalyticRuleCount"], 3) + + def test_plan_keeps_sentinel_rule_scope_when_graph_payload_is_ineligible(self) -> None: + rules = { + rule_id.casefold(): self._rule_record(rule_id, index) + for index, rule_id in enumerate(self.RULE_IDS) + } + with ( + mock.patch.object(planning, "_source_records", return_value=(rules, set())), + mock.patch.object(planning, "_reviewed_current_drafts", return_value=([], [], {})), + mock.patch.object( + planning, + "_eligible_rule_ids", + return_value=([], self.RULE_IDS, {}), + ), + mock.patch.object( + planning, + "_eligible_analytic_rule_ids", + return_value=(self.RULE_IDS, []), + ), + ): + plan = self._build( + source_inventory={ + "counts": {"analyticRules": 3}, + "manifestContentInventory": {"status": "ready"}, + "analyticRuleCount": 3, + "sourceInventorySha256": "b" * 64, + } + ) + + self.assertEqual(plan["components"]["sentinelAlertRules"]["maximumCount"], 3) + self.assertEqual(plan["components"]["xdrCustomDetections"]["maximumCount"], 0) + self.assertEqual(plan["detectionScope"]["eligibleMaximumTotalDisabledRulePairs"], 0) + + def test_parity_and_cleanup_scope_gaps_are_explicit_and_not_consentable(self) -> None: + plan = self._build() + + parity = plan["components"]["controlledAlertParity"] + self.assertFalse(parity["advanceConsentSupported"]) + self.assertEqual(parity["status"], "unavailable") + self.assertEqual(parity["timeWindow"]["status"], "not-recorded") + self.assertEqual(parity["maximumRulePairCount"], 0) + cleanup = plan["components"]["ownedResourceCleanup"] + self.assertEqual(cleanup["status"], "unsupported") + self.assertFalse(cleanup["advanceConsentSupported"]) + self.assertEqual(cleanup["maximumDeleteCount"], 0) + self.assertEqual(cleanup["resources"], []) + self.assertNotIn("controlledAlertParity", planning.COMPONENTS) + self.assertNotIn("ownedResourceCleanup", planning.COMPONENTS) + for unsupported_component in ("controlledAlertParity", "ownedResourceCleanup"): + with ( + mock.patch.object(planning, "build_deployment_plan", return_value=plan), + self.assertRaisesRegex(ValueError, "unsupported planning consent component"), + ): + planning.record_planning_consent( + FIXTURE, + expected_plan_sha256=plan["planSha256"], + actor="reviewer", + approved_components=[unsupported_component], + ) + + def test_dotenv_is_excluded_before_reading_content(self) -> None: + with ( + mock.patch.object(Path, "rglob", return_value=[FIXTURE / ".env"]), + mock.patch.object(Path, "read_bytes", side_effect=AssertionError(".env must not be read")), + ): + self.assertEqual(planning._file_inventory(FIXTURE), []) + + def test_authoring_profile_has_no_deployment_plan_or_target_lookup(self) -> None: + state = json.loads((FIXTURE / "workflow-state.json").read_text()) + state["context"]["workflowProfile"] = "authoring" + with ( + mock.patch.object( + planning, + "existing_artifact_path", + return_value=Path("workflow-state.json"), + ), + mock.patch.object( + Path, + "read_text", + autospec=True, + side_effect=lambda path, *args, **kwargs: json.dumps(state), + ), + mock.patch.object(Path, "is_file", return_value=True), + mock.patch.object(planning, "require_locked_target") as locked_target, + ): + plan = planning.build_deployment_plan(FIXTURE) + + self.assertEqual(plan["status"], "not-applicable") + self.assertEqual(plan["selectedProfile"], "authoring") + locked_target.assert_not_called() + + def test_resource_scan_handles_nested_templates_language_v2_and_extension_scope(self) -> None: + inventory, imports = planning._scan_package_resources( + { + "languageVersion": "2.0", + "imports": [{"provider": "Contoso.Extensions", "version": "1.0.0"}], + "resources": { + "extension": { + "type": "Contoso.Extensions/widgets@2026-01-01", + "scope": "[resourceGroup().id]", + }, + "nestedDeployment": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "properties": { + "template": { + "resources": [ + { + "type": "Microsoft.Insights/dataCollectionRules", + "apiVersion": "2023-03-11", + } + ] + } + }, + }, + }, + } + ) + + by_type = {item["type"]: item for item in inventory} + self.assertEqual(by_type["Contoso.Extensions/widgets"]["typeAtVersion"], "Contoso.Extensions/widgets@2026-01-01") + self.assertTrue(by_type["Contoso.Extensions/widgets"]["extensionResource"]) + self.assertEqual( + by_type["Contoso.Extensions/widgets"]["scopeModel"], + "generic-arm-extension-resource-scope", + ) + self.assertEqual(by_type["Microsoft.Resources/deployments"]["count"], 1) + self.assertIn("Microsoft.Insights/dataCollectionRules", by_type) + self.assertEqual(imports, [{"provider": "Contoso.Extensions", "version": "1.0.0"}]) + + def test_package_identity_uses_selected_current_attempt_not_historical_report(self) -> None: + input_sha = "9" * 64 + artifact_contents = { + "mainTemplate": json.dumps( + { + "imports": [{"provider": "Contoso.Extensions", "version": "1.0.0"}], + "resources": [ + { + "type": "Contoso.Extensions/widgets@2026-01-01", + "scope": "[resourceGroup().id]", + }, + { + "type": "Microsoft.Insights/dataCollectionRules", + "apiVersion": "2023-03-11", + }, + ], + } + ).encode(), + "createUiDefinition": b"{}", + "testParameters": b"{}", + "zip": b"package", + } + artifact_hashes = { + name: hashlib.sha256(content).hexdigest() + for name, content in artifact_contents.items() + } + report = { + "packager": "V3.1", + "packageAttemptId": "current-attempt", + "packageArtifactsSha256": "a" * 64, + "packageInputs": {"after": {"sha256": input_sha}}, + "packageArtifactHashes": artifact_hashes, + "version": "3.1.2", + "deploymentParameters": {"DeployAnalyticsRule": True, "DeployCustomDetection": False}, + **{name: name for name in artifact_contents}, + } + report_bytes = json.dumps(report).encode() + report_hash = hashlib.sha256(report_bytes).hexdigest() + + class MemoryArtifact: + def __init__(self, name: str, content: bytes): + self.name = name + self.content = content + + def read_bytes(self): + return self.content + + def read_text(self, encoding=None): + return self.content.decode(encoding or "utf-8") + + def is_file(self): + return True + + def resolve(self): + return self + + report_artifact = MemoryArtifact("current-report", report_bytes) + resolved_artifacts = { + "current-report": report_artifact, + "historical-report": MemoryArtifact("historical-report", b"{}"), + **{ + name: MemoryArtifact(name, content) + for name, content in artifact_contents.items() + }, + } + selected_state = { + "packaging": { + "lastSuccessful": { + "packageSucceeded": True, + "attemptId": "current-attempt", + "packageInputSha256": input_sha, + "packageReport": "current-report", + "packageReportSha256": report_hash, + "packageArtifactsSha256": "a" * 64, + "version": "3.1.2", + } + }, + "stages": { + "packaging": { + "status": "passed", + "artifacts": { + "packageAttemptId": "current-attempt", + "packageReport": "current-report", + "packageReportSha256": report_hash, + }, + } + }, + } + with ( + mock.patch.object(planning, "package_input_identity", return_value={"sha256": input_sha}), + mock.patch.object( + planning, + "resolve_artifact_reference", + side_effect=lambda _root, value: resolved_artifacts[value], + ), + ): + current = planning._current_package(FIXTURE, selected_state) + + self.assertEqual(current["status"], "current") + self.assertEqual(current["version"], "3.1.2") + self.assertEqual(current["recordedPackageArtifactHashes"], artifact_hashes) + resources_by_type = {item["type"]: item for item in current["resourceInventory"]} + self.assertIn("Microsoft.Insights/dataCollectionRules", resources_by_type) + self.assertEqual( + resources_by_type["Contoso.Extensions/widgets"]["scopeModel"], + "generic-arm-extension-resource-scope", + ) + self.assertEqual(current["whatIfSupport"], "unsupported-bicep-extensibility-imports") + + historical_state = json.loads(json.dumps(selected_state)) + historical_state["stages"]["packaging"]["artifacts"]["packageReport"] = "historical-report" + historical_state["stages"]["packaging"]["artifacts"]["packageAttemptId"] = "old-attempt" + historical = planning._current_package(FIXTURE, historical_state) + self.assertEqual(historical["status"], "stale") + self.assertEqual(historical["resourceInventory"], []) + + def test_missing_or_changed_plan_consent_never_becomes_approved(self) -> None: + plan = self._build() + changed_target = {**TARGET, "workspaceCustomerId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} + changed = self._build(target=changed_target) + changed_package = self._build(package_content_sha256="0" * 64) + stale_consent = self._build( + source_sha256="0" * 64, + consent_record={ + "schemaVersion": planning.PLAN_SCHEMA_VERSION, + "status": "approved", + "planSha256": plan["planSha256"], + }, + ) + + self.assertNotEqual(plan["planSha256"], changed["planSha256"]) + self.assertNotEqual(plan["planSha256"], changed_package["planSha256"]) + self.assertEqual(changed["planningConsent"]["status"], "not-recorded") + self.assertEqual(changed["exactScopeWriteApproval"], "not-recorded") + self.assertNotEqual(plan["planSha256"], stale_consent["planSha256"]) + self.assertEqual(stale_consent["planningConsent"]["status"], "stale") + self.assertEqual(stale_consent["packageIdentity"]["status"], "provisional") + self.assertEqual(stale_consent["packageAcceptancePlan"]["status"], "provisional") + + def test_preflight_consent_is_scoped_local_artifact_not_write_authority(self) -> None: + plan = { + "planSha256": "a" * 64, + "components": { + "fallbackDcr": { + "status": "conditional", + "compatibleExistingDcrProven": False, + "resourceTypes": ["Microsoft.Insights/dataCollectionRules"], + } + }, + "target": TARGET, + "packageResourceInventory": [], + } + with ( + mock.patch.object(planning, "build_deployment_plan", return_value=plan), + mock.patch.object(planning, "artifact_path", return_value=Path("consent.json")), + mock.patch.object(planning, "write_json_artifact") as write_json, + ): + result = planning.record_planning_consent( + FIXTURE, + expected_plan_sha256="a" * 64, + actor="reviewer", + approved_components=["fallbackDcr"], + ) + + record = write_json.call_args.args[2] + self.assertEqual(result["status"], "approved") + self.assertEqual(record["approvedComponents"], ["fallbackDcr"]) + self.assertFalse(record["environmentWritesAuthorized"]) + self.assertEqual(record["scope"]["target"], TARGET) + self.assertEqual(record["scope"]["resourceTypes"], ["Microsoft.Insights/dataCollectionRules"]) + + def test_persisting_plan_does_not_change_workflow_state_or_stage_status(self) -> None: + original_state = (FIXTURE / "workflow-state.json").read_bytes() + plan = {"planSha256": "f" * 64, "status": "provisional"} + with ( + mock.patch.object(planning, "build_deployment_plan", return_value=plan), + mock.patch.object(planning, "artifact_path", return_value=Path("scope-plan.json")), + mock.patch.object(planning, "write_json_artifact") as write_json, + ): + persisted = planning.persist_deployment_plan(FIXTURE) + + self.assertEqual(persisted["planSha256"], "f" * 64) + self.assertEqual(write_json.call_args.args[1], Path("scope-plan.json")) + self.assertEqual((FIXTURE / "workflow-state.json").read_bytes(), original_state) + + def test_plan_cli_uses_explicit_run_without_authentication(self) -> None: + output = io.StringIO() + + def persist(_solution): + return {"selectedRunId": artifacts.requested_run_id(FIXTURE)} + + with ( + mock.patch("sentinel_xdr_migration.cli.persist_deployment_plan", side_effect=persist), + redirect_stdout(output), + ): + exit_code = main( + [ + "plan-deployment", + "--solution", + str(FIXTURE), + "--run-id", + "run-2026-10-11", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(json.loads(output.getvalue())["selectedRunId"], "run-2026-10-11") + + def test_cli_help_and_workflow_module_imports_succeed_in_clean_processes(self) -> None: + package_dir = Path(__file__).parents[1] + commands = [ + ["--help"], + ["doctor", "--help"], + ["plan-deployment", "--help"], + ["solution-report", "--help"], + ] + for arguments in commands: + with self.subTest(arguments=arguments): + result = subprocess.run( + [sys.executable, "-m", "sentinel_xdr_migration.cli", *arguments], + cwd=package_dir, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + for module in ( + "sentinel_xdr_migration.cli", + "sentinel_xdr_migration.deployment", + "sentinel_xdr_migration.graph_readiness", + "sentinel_xdr_migration.deployment_planning", + "sentinel_xdr_migration.solution_report", + ): + with self.subTest(module=module): + result = subprocess.run( + [ + sys.executable, + "-c", + f"import importlib; importlib.import_module({module!r})", + ], + cwd=package_dir, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_stale_plan_hash_and_partial_rule_pair_consent_are_rejected(self) -> None: + pair_plan = { + "planSha256": "b" * 64, + "components": { + "sentinelAlertRules": {"maximumCount": 2}, + "xdrCustomDetections": {"maximumCount": 2}, + }, + "detectionScope": {"eligibleMaximumAdditionalCustomDetections": 2}, + "target": TARGET, + "packageResourceInventory": [], + } + with mock.patch.object(planning, "build_deployment_plan", return_value=pair_plan): + with self.assertRaisesRegex(ValueError, "current plan hash"): + planning.record_planning_consent( + FIXTURE, + expected_plan_sha256="c" * 64, + actor="reviewer", + approved_components=["sentinelAlertRules", "xdrCustomDetections"], + max_additional_custom_detections=2, + ) + with self.assertRaisesRegex(ValueError, "both sentinelAlertRules"): + planning.record_planning_consent( + FIXTURE, + expected_plan_sha256="b" * 64, + actor="reviewer", + approved_components=["xdrCustomDetections"], + max_additional_custom_detections=1, + ) + + def test_changed_consent_cap_is_not_reused_without_a_new_review(self) -> None: + pair_components = { + "sentinelAlertRules": { + "resourceTypes": ["Microsoft.SecurityInsights/alertRules"], + "maximumCount": 3, + }, + "xdrCustomDetections": { + "resourceTypes": ["Microsoft.Security/detectionRules"], + "maximumCount": 3, + "maximumAdditionalCount": 2, + }, + } + plan = { + "planSha256": "e" * 64, + "components": pair_components, + "detectionScope": {"eligibleMaximumAdditionalCustomDetections": 2}, + "target": TARGET, + "packageResourceInventory": [], + } + with ( + mock.patch.object(planning, "build_deployment_plan", return_value=plan), + mock.patch.object(planning, "artifact_path", return_value=Path("consent.json")), + mock.patch.object(planning, "write_json_artifact") as write_json, + ): + planning.record_planning_consent( + FIXTURE, + expected_plan_sha256="e" * 64, + actor="reviewer", + approved_components=["sentinelAlertRules", "xdrCustomDetections"], + max_additional_custom_detections=1, + ) + record = write_json.call_args.args[2] + with ( + mock.patch.object(planning, "existing_artifact_path", return_value=Path("consent.json")), + mock.patch.object(Path, "read_text", autospec=True, return_value=json.dumps(record)), + ): + accepted = planning._consent_state( + FIXTURE, + "e" * 64, + components=pair_components, + package={"status": "provisional", "resourceInventory": []}, + target=TARGET, + ) + self.assertEqual(accepted["status"], "approved") + + record["maxAdditionalCustomDetections"] = 2 + with ( + mock.patch.object(planning, "existing_artifact_path", return_value=Path("consent.json")), + mock.patch.object(Path, "read_text", autospec=True, return_value=json.dumps(record)), + ): + tampered = planning._consent_state( + FIXTURE, + "e" * 64, + components=pair_components, + package={"status": "provisional", "resourceInventory": []}, + target=TARGET, + ) + self.assertEqual(tampered["status"], "unknown") + self.assertEqual(tampered["approvedComponents"], []) + + def test_unknown_ingestion_plan_stays_provisional_and_dce_requires_explicit_selection(self) -> None: + self.assertEqual( + planning._mock_ingestion_plan(FIXTURE, {"stages": {"mockIngestion": {"artifacts": {}}}})["status"], + "provisional", + ) + plan = { + "planSha256": "d" * 64, + "components": { + "fallbackDcr": {"status": "conditional", "compatibleExistingDcrProven": False}, + "fallbackDce": {"status": "conditional"}, + }, + "target": TARGET, + "packageResourceInventory": [], + } + with mock.patch.object(planning, "build_deployment_plan", return_value=plan): + with self.assertRaisesRegex(ValueError, "explicit --dce-selected"): + planning.record_planning_consent( + FIXTURE, + expected_plan_sha256="d" * 64, + actor="reviewer", + approved_components=["fallbackDcr", "fallbackDce"], + ) + + def test_custom_table_creation_requires_proven_absence_and_native_tables_never_get_lookalikes(self) -> None: + contract_text = """ +destination: + kind: custom + columns: + - name: TimeGenerated + type: datetime + - name: EventType + type: string +""" + contract = { + "table": "Example_CL", + "contractPath": "contracts/example.yaml", + "schemaFingerprint": "a" * 64, + "transformFingerprint": "b" * 64, + "contractFingerprint": "c" * 64, + "resources": { + "destinationTable": { + "id": "/subscriptions/example/workspaces/ws/tables/Example_CL", + "state": "missing", + } + }, + "tableSchemaCompatibility": {"status": "unknown"}, + } + with ( + mock.patch.object(planning, "repository_root", return_value=FIXTURE), + mock.patch.object(Path, "read_text", autospec=True, side_effect=lambda path, *args, **kwargs: ( + contract_text if path.name == "example.yaml" else Path.open(path, *args, **kwargs).read() + )), + ): + components = planning._components( + FIXTURE, + source_ids=[], + sentinel_rule_ids=[], + pair_rule_ids=[], + additional_rule_ids=[], + graph_projections={}, + package={"status": "provisional", "target": {"subscriptionId": "sub", "resourceGroup": "rg"}}, + dcr={"contracts": [contract]}, + ingestion={"status": "provisional"}, + ) + table_scope = components["customTableCreate"] + self.assertEqual(table_scope["status"], "conditional") + self.assertEqual(table_scope["tables"][0]["columnCount"], 2) + self.assertEqual(table_scope["tables"][0]["schemaFingerprint"], "a" * 64) + self.assertFalse(table_scope["nativeCommonSecurityLogLookalikeAllowed"]) + + standard_text = "destination:\n kind: standard\n columns: []\n" + standard_contract = { + **contract, + "table": "CommonSecurityLog", + "resources": { + "destinationTable": { + "id": "/subscriptions/example/workspaces/ws/tables/CommonSecurityLog", + "state": "present", + } + }, + "tableSchemaCompatibility": {"status": "compatible"}, + } + with ( + mock.patch.object(planning, "repository_root", return_value=FIXTURE), + mock.patch.object(Path, "read_text", autospec=True, side_effect=lambda path, *args, **kwargs: ( + standard_text if path.name == "example.yaml" else Path.open(path, *args, **kwargs).read() + )), + ): + native_components = planning._components( + FIXTURE, + source_ids=[], + sentinel_rule_ids=[], + pair_rule_ids=[], + additional_rule_ids=[], + graph_projections={}, + package={"status": "provisional", "target": {"subscriptionId": "sub", "resourceGroup": "rg"}}, + dcr={"contracts": [standard_contract]}, + ingestion={"status": "provisional"}, + ) + self.assertEqual(native_components["customTableCreate"]["status"], "not-needed") + self.assertEqual(native_components["customTableCreate"]["tables"], []) + + def test_manifest_inventory_counts_unique_workflows_and_separates_unlisted_files(self) -> None: + inventory = planning._manifest_content_inventory(INVENTORY_FIXTURE) + + self.assertEqual(inventory["status"], "ready") + self.assertEqual(inventory["manifestReferenceCounts"]["playbooks"], 4) + self.assertEqual(inventory["configuredPlaybookTemplateCount"], 4) + self.assertEqual(inventory["uniquePlaybookWorkflowCount"], 3) + self.assertEqual(inventory["contentCounts"]["playbooks"], 3) + self.assertEqual(inventory["folderFileCounts"]["playbooks"], 5) + self.assertEqual(len(inventory["unlistedFiles"]["playbooks"]), 1) + self.assertEqual(inventory["manifestReferenceCounts"]["dataConnectors"], 1) + self.assertEqual(inventory["contentCounts"]["dataConnectors"], 1) + self.assertEqual(inventory["folderFileCounts"]["dataConnectors"], 2) + self.assertEqual(len(inventory["unlistedFiles"]["dataConnectors"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_detection_identity.py b/Tools/SentinelToXDRMigration/tests/test_detection_identity.py new file mode 100644 index 00000000000..e8334157999 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_detection_identity.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +import copy +import json +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml +from jsonschema import Draft202012Validator + +from sentinel_xdr_migration.converter import convert_solution, validate_document, validate_solution +from sentinel_xdr_migration.deployment import graph_detection_payload + +import test_customer_usage_attribution as attribution +from test_converter import RULE + + +SOURCE_ID = "11111111-2222-3333-4444-555555555555" +OTHER_ID = "11111111-2222-3333-4444-666666666666" +LEGACY_ID = "xdr-suspicious-test-activity-11111111" +CANONICAL_ID = f"xdr-{SOURCE_ID}" + + +class DetectionIdentityTests(unittest.TestCase): + def setUp(self): + self.fixture = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.fixture.cleanup) + self.solution = Path(self.fixture.name) + self.source = self.solution / "Analytic Rules/Rule.yaml" + self.source.parent.mkdir() + self.source.write_text(RULE) + self.output = self.solution / "XDR Detections/Rule.yaml" + + def read_output(self): + return yaml.safe_load(self.output.read_text()) + + def write_output(self, document): + self.output.write_text(yaml.safe_dump(document, sort_keys=False)) + + def legacy_output(self, *, version="3.1.1"): + convert_solution(self.solution) + document = self.read_output() + document["properties"]["id"] = LEGACY_ID + document["version"] = version + self.write_output(document) + return document + + def test_full_source_guid_is_stable_across_name_changes_and_shared_prefixes(self): + second = self.source.with_name("Second.yaml") + second.write_text(RULE.replace(SOURCE_ID, OTHER_ID)) + source_bytes = self.source.read_bytes(), second.read_bytes() + result = convert_solution(self.solution) + self.assertEqual(2, result["converted"]) + self.assertEqual(CANONICAL_ID, self.read_output()["properties"]["id"]) + other = yaml.safe_load(self.output.with_name("Second.yaml").read_text()) + self.assertEqual(f"xdr-{OTHER_ID}", other["properties"]["id"]) + self.assertEqual(source_bytes, (self.source.read_bytes(), second.read_bytes())) + self.source.write_text(RULE.replace("Suspicious test activity", "Renamed test activity")) + self.assertEqual(0, convert_solution(self.solution, overwrite=True)["conflicts"]) + document = self.read_output() + self.assertEqual(CANONICAL_ID, document["properties"]["id"]) + self.assertEqual(SOURCE_ID, document["contentProvenance"]["source"]["id"]) + self.assertEqual(CANONICAL_ID, graph_detection_payload(document)["id"]) + self.assertEqual("3.1.0", document["version"]) + self.assertEqual("disabled", document["properties"]["status"]) + + def test_source_guid_format_and_case_are_preserved_not_normalized(self): + for source_id, valid in ( + ("ABCDEF12-2222-3333-4444-555555555555", True), + ("source-id", False), + ("11111111222233334444555555555555", False), + ("{11111111-2222-3333-4444-555555555555}", False), + (SOURCE_ID + " ", False), + ): + with self.subTest(source_id=source_id): + if self.output.exists(): + self.output.unlink() + self.source.write_text(RULE.replace(SOURCE_ID, json.dumps(source_id))) + convert_solution(self.solution) + document = self.read_output() + self.assertEqual(f"xdr-{source_id}", document["properties"]["id"]) + self.assertEqual(valid, not validate_document(document)) + + def test_legacy_reconversion_is_explicit_local_only_and_idempotent(self): + self.legacy_output() + before = self.output.read_bytes() + with mock.patch("sentinel_xdr_migration.deployment._graph_request") as graph: + self.assertEqual(1, convert_solution(self.solution)["conflicts"]) + self.assertEqual(before, self.output.read_bytes()) + self.source.write_text(RULE.replace("Suspicious test activity", "Renamed")) + result = convert_solution(self.solution, overwrite=True) + graph.assert_not_called() + self.assertEqual(0, result["conflicts"]) + document = self.read_output() + self.assertEqual("3.1.1", document["version"]) + self.assertEqual(CANONICAL_ID, document["properties"]["id"]) + self.assertEqual("disabled", document["properties"]["status"]) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual({ + "previousId": LEGACY_ID, "currentId": CANONICAL_ID, "scope": "local-artifact-only", + }, conversion["identityChange"]) + warning = next(value for value in conversion["warnings"] if "Local detection ID changed" in value) + self.assertIn("No cloud rules were updated, deleted, or migrated", warning) + manifest = json.loads(Path(result["manifest"]).read_text()) + self.assertIn(warning, manifest["results"][0]["warnings"]) + self.assertIn("Local detection ID changed", Path(result["transformationReport"]).read_text()) + self.assertEqual([], validate_document(document)) + after = self.output.read_bytes() + self.assertEqual(0, convert_solution(self.solution)["conflicts"]) + self.assertEqual(after, self.output.read_bytes()) + + def test_unversioned_legacy_output_migrates_but_custom_ids_do_not(self): + document = self.legacy_output() + document.pop("version") + self.write_output(document) + self.assertEqual(0, convert_solution(self.solution, overwrite=True)["conflicts"]) + self.assertEqual("3.1.0", self.read_output()["version"]) + self.assertEqual(CANONICAL_ID, self.read_output()["properties"]["id"]) + for custom_id in ("hand-authored-id", "xdr-custom-authored-11111111", OTHER_ID): + with self.subTest(custom_id=custom_id): + document = self.read_output() + document["properties"]["id"] = custom_id + self.write_output(document) + before = self.output.read_bytes() + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(1, result["conflicts"]) + self.assertIn("custom identity", result["results"][0]["errors"][0]) + self.assertEqual(before, self.output.read_bytes()) + + def test_conflicting_or_missing_provenance_is_never_overwritten_or_excluded(self): + original = self.legacy_output() + for field, value in (("id", OTHER_ID), ("path", "Analytic Rules/Other.yaml"), + ("platform", "Unrelated"), ("kind", "Other")): + for versioned in (True, False): + with self.subTest(field=field, versioned=versioned): + document = copy.deepcopy(original) + document["contentProvenance"]["source"][field] = value + # A converter marker must not override conflicting source provenance. + if not versioned: + document.pop("version") + self.write_output(document) + before = self.output.read_bytes() + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(1, result["conflicts"]) + self.assertEqual(before, self.output.read_bytes()) + document = copy.deepcopy(original) + document.pop("contentProvenance") + self.write_output(document) + config = self.solution / "exclude.json" + config.write_text(json.dumps({"excludedRuleIds": {SOURCE_ID: "test"}})) + before = self.output.read_bytes() + self.assertEqual(1, convert_solution(self.solution, overwrite=True, config_path=config)["conflicts"]) + self.assertEqual(before, self.output.read_bytes()) + + def test_legacy_shape_without_converter_marker_is_not_auto_migrated(self): + document = self.legacy_output() + document["contentProvenance"]["conversion"]["tool"] = "other" + self.write_output(document) + before = self.output.read_bytes() + self.assertEqual(1, convert_solution(self.solution, overwrite=True)["conflicts"]) + self.assertEqual(before, self.output.read_bytes()) + + def test_duplicate_source_guids_block_all_colliding_outputs(self): + for source_id in (SOURCE_ID, "ABCDEF12-2222-3333-4444-555555555555"): + with self.subTest(source_id=source_id): + self.source.write_text(RULE.replace(SOURCE_ID, source_id)) + self.source.with_name("Second.yaml").write_text(RULE.replace(SOURCE_ID, source_id.lower())) + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(2, result["conflicts"]) + self.assertFalse(self.output.exists()) + + def test_id_owned_by_another_output_is_not_reused(self): + convert_solution(self.solution) + document = self.read_output() + document["contentProvenance"]["source"]["id"] = OTHER_ID + document["contentProvenance"]["source"]["path"] = "Analytic Rules/Other.yaml" + other_output = self.output.with_name("Unrelated.yaml") + other_output.write_text(yaml.safe_dump(document)) + before = self.output.read_bytes(), other_output.read_bytes() + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(1, result["conflicts"]) + self.assertEqual(before, (self.output.read_bytes(), other_output.read_bytes())) + validation = validate_solution(self.solution) + self.assertEqual(2, validation["invalid"]) + self.assertTrue(all(any("duplicate Custom Detection" in error for error in row["errors"]) + for row in validation["results"])) + + def test_validation_contract_is_source_derived_only(self): + convert_solution(self.solution) + document = self.read_output() + document["properties"]["id"] = OTHER_ID + self.assertTrue(any("canonical xdr- prefix" in error for error in validate_document(document))) + document["properties"]["id"] = LEGACY_ID + errors = validate_document(document) + self.assertTrue(any("canonical xdr- prefix" in error for error in errors)) + with self.assertRaisesRegex(ValueError, "canonical xdr- prefix"): + graph_detection_payload(document) + document["contentProvenance"]["source"]["platform"] = "Other" + document["contentProvenance"]["source"]["kind"] = "Other" + document["contentProvenance"]["source"]["id"] = "other-source" + document["contentProvenance"]["conversion"]["tool"] = "other-tool" + self.assertEqual([], validate_document(document)) + schema = json.loads((attribution.ROOT / "Tools/SentinelToXDRMigration/schema/xdr-detection.schema.json").read_text()) + Draft202012Validator.check_schema(schema) + + +@unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") +class PackagedIdentityTests(unittest.TestCase): + setUp = attribution.AttributionTests.setUp + run_ps = attribution.AttributionTests.run_ps + prepare_integration = attribution.AttributionTests.prepare_integration + package = attribution.AttributionTests.package + write_metadata = attribution.AttributionTests.write_metadata + + def fixture(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + path = self.solution / "XDR Detections/Test.yaml" + document = json.loads(path.read_text().removeprefix("---\n")) + document["contentProvenance"]["source"].update( + platform="Microsoft Sentinel", kind="AnalyticsRule", + ) + document["properties"]["id"] = SOURCE_ID + path.write_text("---\n" + json.dumps(document)) + data = json.loads(self.data.read_text()) + data["Include XDR Content Registration"] = True + self.data.write_text(json.dumps(data)) + return entry, path, document + + def test_packaging_preserves_full_guid_in_install_and_registration_wrappers(self): + entry, path, document = self.fixture() + before = path.read_bytes() + for pipeline in (False, True): + with self.subTest(pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + install = next(r for r in template["resources"] if r["type"] == "Microsoft.Resources/deployments" + and r.get("condition") == "[parameters('DeployCustomDetection')]") + registration = next(r for r in template["resources"] + if r.get("properties", {}).get("contentKind") == "CustomDetection") + packaged_id = f"xdr-{SOURCE_ID}" + expected_id = f"[if(true(), '{packaged_id}', '{packaged_id}')]" + install_properties = install["properties"]["template"]["resources"]["detectionRule"]["properties"] + registered_properties = registration["properties"]["mainTemplate"]["resources"]["detectionRule"]["properties"] + self.assertEqual(expected_id, install_properties["id"]) + self.assertEqual(expected_id, registered_properties["id"]) + self.assertEqual(packaged_id, registration["properties"]["contentId"]) + self.assertEqual( + f"Sample-CD-{packaged_id}", install["name"], + ) + self.assertEqual( + f"[resourceId('Microsoft.Resources/deployments', 'Sample-CD-{packaged_id}')]", + registration["dependsOn"][0], + ) + self.assertIn( + f"uniquestring('{packaged_id}')", + registration["name"], + ) + self.assertIn( + f"'{packaged_id}'", + registration["properties"]["contentProductId"], + ) + content_packages = next( + resource for resource in template["resources"] + if resource["type"].endswith("/contentPackages") + ) + packaged_criteria = [ + criterion for criterion in content_packages["properties"]["dependencies"]["criteria"] + if criterion.get("kind") == "CustomDetection" + ] + self.assertEqual([{ + "kind": "CustomDetection", "contentId": packaged_id, "version": "3.1.0", + }], packaged_criteria) + self.assertEqual( + SOURCE_ID, + json.loads(path.read_text().removeprefix("---\n"))["contentProvenance"]["source"]["id"], + ) + self.assertEqual("3.1.0", registration["properties"]["version"]) + self.assertEqual("disabled", install_properties["status"]) + self.assertEqual(before, path.read_bytes()) + + def test_already_prefixed_identity_is_idempotent_across_repeated_packaging(self): + entry, path, document = self.fixture() + document["properties"]["id"] = f"xdr-{SOURCE_ID}" + path.write_text("---\n" + json.dumps(document)) + before = path.read_bytes() + packaged_ids = [] + for pipeline in (False, True): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + registration = next( + resource for resource in template["resources"] + if resource.get("properties", {}).get("contentKind") == "CustomDetection" + ) + packaged_ids.append(registration["properties"]["contentId"]) + self.assertEqual([f"xdr-{SOURCE_ID}", f"xdr-{SOURCE_ID}"], packaged_ids) + self.assertEqual(before, path.read_bytes()) + + def test_letter_starting_source_guid_uses_same_canonical_prefix(self): + entry, path, document = self.fixture() + source_id = "ABCDEF12-2222-3333-4444-555555555555" + document["contentProvenance"]["source"]["id"] = source_id + document["properties"]["id"] = source_id + path.write_text("---\n" + json.dumps(document)) + analytic = self.solution / "Analytic Rules/Test.yaml" + rule = yaml.safe_load(analytic.read_text()) + rule["id"] = source_id + analytic.write_text(yaml.safe_dump(rule, sort_keys=False)) + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + registration = next( + resource for resource in template["resources"] + if resource.get("properties", {}).get("contentKind") == "CustomDetection" + ) + self.assertEqual(f"xdr-{source_id}", registration["properties"]["contentId"]) + self.assertEqual(source_id, json.loads(path.read_text().removeprefix("---\n"))[ + "contentProvenance"]["source"]["id"] + ) + + def test_bare_and_prefixed_copy_collision_is_rejected_after_normalization(self): + entry, path, document = self.fixture() + duplicate = self.solution / "XDR Detections/Copy.yaml" + duplicate_document = copy.deepcopy(document) + duplicate_document["properties"]["id"] = f"xdr-{SOURCE_ID}" + duplicate.write_text("---\n" + json.dumps(duplicate_document)) + data = json.loads(self.data.read_text()) + data["XDR Detections"].append("XDR Detections/Copy.yaml") + self.data.write_text(json.dumps(data)) + result = self.package(entry, False) + self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("Duplicate Custom Detection id", result.stdout + result.stderr) + self.assertIn("after canonical ID normalization", result.stdout + result.stderr) + + def test_packaging_rejects_mismatched_legacy_and_malformed_source_ids(self): + entry, path, original = self.fixture() + for source_id, detection_id in ((SOURCE_ID, LEGACY_ID), (SOURCE_ID, OTHER_ID), + ("source-id", "source-id")): + for pipeline in (False, True): + with self.subTest(source_id=source_id, detection_id=detection_id, pipeline=pipeline): + document = copy.deepcopy(original) + document["contentProvenance"]["source"]["id"] = source_id + document["properties"]["id"] = detection_id + path.write_text("---\n" + json.dumps(document)) + result = self.package(entry, pipeline) + self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) + output = result.stdout + result.stderr + self.assertTrue( + "originating Sentinel GUID" in output + or "conflicts with originating" in output + or "canonical xdr-" in output + or "originating Sentinel template GUID" in output, + output, + ) + self.assertFalse((self.solution / "Package/mainTemplate.json").exists()) + + def test_packaging_keeps_unrelated_custom_detection_id_contract(self): + entry, path, document = self.fixture() + document["contentProvenance"]["source"].update(platform="Other", kind="Other") + document["properties"]["id"] = "custom-authored-id" + path.write_text("---\n" + json.dumps(document)) + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_evidence.py b/Tools/SentinelToXDRMigration/tests/test_evidence.py new file mode 100644 index 00000000000..e313b630d9f --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_evidence.py @@ -0,0 +1,679 @@ +from __future__ import annotations + +import hashlib +import io +import json +import shutil +import subprocess +import unittest +import uuid +from copy import deepcopy +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.authoring_contract import seal_authoring_contract +from sentinel_xdr_migration.artifacts import report_directory, using_run +from sentinel_xdr_migration.cli import main +from sentinel_xdr_migration.content_paths import yaml_files +from sentinel_xdr_migration.evidence import _markdown, _public_solution_name, export_evidence +from sentinel_xdr_migration.workflow import initialize_workflow, workflow_status + + +class EvidenceTests(unittest.TestCase): + def setUp(self): + self.storage = Path.cwd() / (".evidence-test-" + uuid.uuid4().hex) + self.solution = self.storage / "Solutions" / "Sample" + self.solution.mkdir(parents=True) + self.addCleanup(shutil.rmtree, self.storage) + self.state = initialize_workflow(self.solution, workflow_profile="authoring", version_bump="none") + self.run = report_directory(self.solution) + self.run_id = self.run.name + self.source = self.solution / "Analytic Rules" / "Rule.yaml" + self.output = self.solution / "XDR Detections" / "Rule.yaml" + self.source.parent.mkdir() + self.output.parent.mkdir() + self.source.write_text(yaml.safe_dump({"id": "source-rule", "version": "1.2.3", "query": "DeviceEvents"})) + self.output.write_text(yaml.safe_dump({ + "version": "3.1.0", "contentProvenance": { + "source": {"id": "source-rule", "path": "Analytic Rules/Rule.yaml", + "querySha256": hashlib.sha256(b"DeviceEvents").hexdigest()}, + "conversion": {"version": "0.1.0"}, + }, + })) + + def write(self, name, value): + (self.run / name).write_text(json.dumps(value)) + + def export(self): + result = export_evidence(self.solution) + return json.loads((self.solution / result["evidence"]).read_text()) + + def authoring_contract(self, statuses, *, rule_ids=None, qualification_statuses=None): + inventory = [] + results = [] + rule_ids = rule_ids or [f"source-rule-{index}" for index in range(len(statuses))] + qualification_statuses = qualification_statuses or ["untested"] * len(statuses) + for index, (status, rule_id, qualification) in enumerate( + zip(statuses, rule_ids, qualification_statuses) + ): + source_hash = f"{index + 1:064x}" + inventory.append({ + "ruleId": rule_id, + "sha256": source_hash, + "evidenceReferences": [{ + "path": "evidence/private-tenant-secret.json", + "sha256": "c" * 64, + "kind": "source", + }], + }) + findings = [] + if index == 0: + findings.extend([ + { + "findingId": "warning-1", + "kind": "warning", + "message": "PRIVATE_WARNING_PROSE", + "unresolved": False, + "evidenceReferences": [], + }, + { + "findingId": "assumption-1", + "kind": "assumption", + "message": "PRIVATE_ASSUMPTION_PROSE", + "unresolved": False, + "evidenceReferences": [], + }, + ]) + if status in {"blocked", "suppressed"}: + findings.append({ + "findingId": "error-1", + "kind": "error", + "message": "PRIVATE_ERROR_PROSE", + "unresolved": True, + "evidenceReferences": [], + }) + qualification_references = [] + if qualification == "qualified": + qualification_references = [ + { + "path": f"evidence/runtime-{index}.json", + "sha256": "d" * 64, + "kind": "runtimeVerification", + }, + { + "path": f"evidence/parity-{index}.json", + "sha256": "e" * 64, + "kind": "alertParity", + }, + ] + results.append({ + "ruleId": rule_id, + "sourceSha256": source_hash, + "status": status, + "deploymentDisposition": { + "migrated": "normal", + "blocked": "deploymentBlocked", + "suppressed": "suppressed", + "unprocessed": "normal", + }[status], + "target": { + "path": f"XDR Detections/private-tenant-{index}.yaml", + "sha256": "b" * 64, + "structurallyValid": True, + } if status != "unprocessed" else None, + "reviewRequired": index == 0, + "mappingAssumptions": ["PRIVATE_MAPPING_ASSUMPTION_PROSE"] if index == 0 else [], + "findings": findings, + "qualificationStatus": qualification, + "qualificationEvidenceReferences": qualification_references, + "evidenceReferences": [], + }) + return seal_authoring_contract({ + "schemaVersion": "1.0.0", + "runId": self.run_id, + "source": { + "identity": "PRIVATE_SOURCE_IDENTITY workspace tenant /Users/private", + "sha256": "a" * 64, + }, + "inventory": {"complete": True, "rules": inventory}, + "results": results, + "environmentErrors": [{ + "code": "providerUnavailable", + "message": "PRIVATE_ENVIRONMENT_ERROR tenant workspace", + "evidenceReferences": [], + }], + "entityValueParityPolicy": { + "assertion": "value-preserved-in-actual-corresponding-alert", + "comparison": "value-only", + "entityTypeIdentifierAndCustomFieldMayDiffer": True, + "requiredForQualification": True, + }, + }) + + def manifest(self): + self.write("manifest.json", {"total": 3, "converted": 1, "needsReview": 1, "excluded": 1, "results": [ + {"sourceRelativePath": "Analytic Rules/Rule.yaml", "outputRelativePath": "XDR Detections/Rule.yaml", + "status": status, "reviewRequired": status == "needsReview"} for status in ("converted", "needsReview", "excluded") + ]}) + + def test_all_statuses_counts_hashes_and_original_reports_preserved(self): + self.manifest() + state = json.loads((self.run / "workflow-state.json").read_text()) + state["workflowStatus"] = "blocked" + state["stages"]["discovery"]["status"] = "passed" + state["stages"]["conversion"]["status"] = "blocked" + self.write("workflow-state.json", state) + self.write("runtime-validation.graph.json", { + "total": 4, "valid": 1, "invalid": 1, "blocked": 1, "notRun": 1, + "results": [{"detection": "Rule.yaml", "status": status, "error": "raw-private-response"} + for status in ("passed", "failed", "blocked", "not-run")], + }) + before = {p.name: p.read_bytes() for p in self.run.iterdir()} + evidence = self.export() + self.assertEqual("blocked", evidence["recordedWorkflowStatus"]) + self.assertEqual("notRequired", evidence["stages"]["alertParity"]["recordedStatus"]) + self.assertEqual("pending", evidence["stages"]["packaging"]["recordedStatus"]) + report = evidence["reports"]["runtimeGraph"] + self.assertEqual(["passed", "failed", "blocked", "not-run"], [x["recordedStatus"] for x in report["items"]]) + self.assertTrue(all( + item["queryHashBinding"]["status"] == "stale-or-missing" + for item in report["items"] + )) + self.assertEqual(1, report["counts"]["invalid"]) + for item in report["items"]: + reference = item["content"] + self.assertFalse(Path(reference["path"]).is_absolute()) + self.assertEqual(hashlib.sha256(self.output.read_bytes()).hexdigest(), reference["sha256"]) + self.assertEqual("matches", evidence["reports"]["conversion"]["items"][0]["provenance"]["sourceQuery"]) + self.assertEqual("unsupported", evidence["reports"]["conversion"]["items"][0]["provenance"]["historicalValidationBinding"]) + self.assertEqual(before, {p.name: p.read_bytes() for p in self.run.iterdir()}) + self.assertNotIn("raw-private-response", json.dumps(evidence)) + + def test_solution_env_is_never_read_or_copied_into_optional_evidence(self): + private_marker = "PRIVATE_ENV_SENTINEL_DO_NOT_EXPORT" + (self.solution / ".env").write_text( + f"PRIVATE_SETTING={private_marker}\n", encoding="utf-8" + ) + evidence = self.export() + exported = json.dumps(evidence) + "\n" + ( + self.solution / "Evidence" / self.run_id / "summary.md" + ).read_text(encoding="utf-8") + self.assertNotIn(private_marker, exported) + self.assertTrue((self.solution / ".env").is_file()) + + def test_runtime_result_records_exact_current_query_binding(self): + source_query = "DeviceEvents | where TimeGenerated > ago(4h)" + target_query = "DeviceEvents | where Timestamp >= ago(2h)" + self.source.write_text(yaml.safe_dump({ + "id": "source-rule", "query": source_query, + })) + self.output.write_text(yaml.safe_dump({ + "version": "3.1.0", + "contentProvenance": { + "source": {"id": "source-rule", "path": "Analytic Rules/Rule.yaml"}, + }, + "properties": { + "queryCondition": {"queryText": target_query}, + }, + })) + self.write("runtime-validation.graph.json", { + "provider": "graph", + "results": [{ + "detection": "Rule.yaml", + "status": "passed", + "querySha256": hashlib.sha256(target_query.encode()).hexdigest(), + }], + }) + self.write("runtime-validation.triage-mcp.json", { + "provider": "triage-mcp", + "results": [{ + "detection": "Rule.yaml", + "status": "passed", + "querySha256": hashlib.sha256(source_query.encode()).hexdigest(), + }], + }) + self.write("runtime-validation.azure-monitor-sdk.json", { + "provider": "azure-monitor-sdk", + "results": [{ + "detection": "Rule.yaml", + "status": "passed", + "querySha256": hashlib.sha256(source_query.encode()).hexdigest(), + "tables": [{"columns": [{"name": "DeviceEvents"}], "rows": [["private row"]]}], + }], + }) + + evidence = self.export() + self.assertEqual( + "matches", + evidence["reports"]["runtimeGraph"]["items"][0]["queryHashBinding"]["status"], + ) + self.assertEqual( + "matches", + evidence["reports"]["runtimeTriage"]["items"][0]["queryHashBinding"]["status"], + ) + sdk_item = evidence["reports"]["runtimeAzureMonitorSdk"]["items"][0] + self.assertEqual("azure-monitor-sdk", sdk_item["provider"]) + self.assertEqual("matches", sdk_item["queryHashBinding"]["status"]) + self.assertNotIn("private row", json.dumps(evidence)) + + def test_completed_workflow_does_not_turn_missing_evidence_into_pass(self): + state = json.loads((self.run / "workflow-state.json").read_text()) + state["workflowStatus"] = "completed" + self.write("workflow-state.json", state) + evidence = self.export() + self.assertEqual("completed", evidence["recordedWorkflowStatus"]) + self.assertEqual("missing", evidence["reports"]["packaging"]["availability"]) + self.assertEqual("unknown", evidence["reports"]["packaging"]["recordedStatus"]) + self.assertEqual("unsupported", evidence["baselineDecision"]["availability"]) + self.assertEqual("recorded-only-not-attested", evidence["verification"]) + + def test_free_text_provider_payloads_credentials_ids_and_urls_never_exported(self): + secrets = [ + "Bearer eyJhbGciOi-secret", "password=pwd123", "tenant-private-value", + "11111111-2222-3333-4444-555555555555", + "/Users/alice/credentials", r"C:\Users\alice\credentials", + "https://host.invalid/private?sig=SIGNED_SECRET", + ] + state = json.loads((self.run / "workflow-state.json").read_text()) + state["context"]["tenantId"] = secrets[3] + state["context"]["versionBump"] = secrets[0] + state["context"]["workflowProfile"] = secrets[1] + for item in state["stages"].values(): + item.update(message=secrets, evidence=secrets, artifacts={"providerResponse": secrets}) + self.write("workflow-state.json", state) + self.write("manifest.json", {"unknown": secrets, "results": [ + {"status": secret, "displayName": secret, "source": secret, "output": secret, + "warnings": secrets, "reviewReasons": secrets, "errors": secrets, + "providerResponse": {"records": secrets}, "toolVersion": secret, "command": secret} + for secret in secrets + ]}) + self.write("packaging.v3_1.json", {"status": "failed", "warnings": secrets, "attributionSources": secrets, "version": secrets[0], "mainTemplate": secrets[4]}) + self.write("alert-parity-report.json", {"status": "failed", "comparisons": [ + {"detection": "Rule.yaml", "passed": False, "alerts": secrets, "error": secrets} + ]}) + self.write("raw-provider-response.json", {"records": secrets}) + evidence = self.export() + published = "\n".join(path.read_text() for path in (self.solution / "Evidence" / self.run_id).iterdir()) + for secret in secrets: + self.assertNotIn(secret, published) + self.assertNotIn("raw-provider-response", published) + self.assertEqual("failed", evidence["reports"]["alertParity"]["items"][0]["recordedStatus"]) + self.assertEqual(7, evidence["reports"]["conversion"]["items"][0]["errorCount"]) + + def test_unknown_invalid_missing_and_mismatched_reports_are_explicit(self): + (self.run / "manifest.json").write_text("{invalid private") + self.write("runtime-validation.graph.json", {"runId": "different-run", "status": "passed"}) + self.write("structural-validation.json", {"results": [{"valid": False, "file": "XDR Detections/Rule.yaml", "errors": ["private"]}, {"status": "novel-status"}]}) + evidence = self.export() + self.assertEqual("invalid", evidence["reports"]["conversion"]["availability"]) + self.assertEqual("run-mismatch", evidence["reports"]["runtimeGraph"]["availability"]) + self.assertEqual("unknown", evidence["reports"]["runtimeGraph"]["recordedStatus"]) + self.assertEqual(["failed", "unknown"], [x["recordedStatus"] for x in evidence["reports"]["structural"]["items"]]) + + def test_stale_source_provenance_not_treated_as_verified(self): + self.manifest() + self.source.write_text(yaml.safe_dump({"id": "source-rule", "query": "changed"})) + item = self.export()["reports"]["conversion"]["items"][0] + self.assertEqual("mismatch", item["provenance"]["sourceQuery"]) + + def test_never_overwrites_snapshot_or_loads_it_as_workflow_state(self): + self.export() + destination = self.solution / "Evidence" / self.run_id + original = (destination / "evidence.json").read_bytes() + with self.assertRaisesRegex(ValueError, "never overwrite"): + export_evidence(self.solution, authoring_contract=self.authoring_contract(["migrated"])) + self.assertEqual(original, (destination / "evidence.json").read_bytes()) + (destination / "workflow-state.json").write_text('{"workflowStatus":"completed"}') + self.assertEqual("inProgress", workflow_status(self.solution)["workflowStatus"]) + (self.run / "workflow-state.json").unlink() + with self.assertRaisesRegex(ValueError, "does not exist"): + workflow_status(self.solution) + + @mock.patch("sentinel_xdr_migration.evidence._utc_now", return_value="2026-10-07T15:00:00+00:00") + def test_identical_inputs_produce_identical_snapshot_bytes(self, clock): + self.manifest() + self.export() + destination = self.solution / "Evidence" / self.run_id + original = {p.name: p.read_bytes() for p in destination.iterdir()} + shutil.rmtree(destination) + self.export() + self.assertEqual(original, {p.name: p.read_bytes() for p in destination.iterdir()}) + + def test_authoring_contract_projects_every_rule_counts_and_validated_digests_only(self): + contract = self.authoring_contract(["migrated", "blocked", "suppressed", "unprocessed"]) + result = export_evidence(self.solution, authoring_contract=contract) + evidence_path = self.solution / result["evidence"] + evidence = json.loads(evidence_path.read_text()) + projection = evidence["authoringContract"] + self.assertEqual(4, len(projection["rules"])) + self.assertEqual( + ["source-rule-0", "source-rule-1", "source-rule-2", "source-rule-3"], + [item["sourceRuleId"] for item in projection["rules"]], + ) + self.assertEqual( + ["migrated", "blocked", "suppressed", "unprocessed"], + [item["outcome"] for item in projection["rules"]], + ) + self.assertEqual(["untested"] * 4, [item["qualificationStatus"] for item in projection["rules"]]) + self.assertTrue(all(item["qualificationStatus"] != "qualified" for item in projection["rules"])) + first = projection["rules"][0] + self.assertTrue(first["targetStructurallyValid"]) + self.assertTrue(first["reviewRequired"]) + self.assertEqual(0, first["unresolvedErrorCount"]) + self.assertEqual(1, first["mappingAssumptionCount"]) + self.assertEqual(1, first["assumptionFindingCount"]) + self.assertEqual({"error": 0, "warning": 1, "assumption": 1}, first["findingCounts"]) + self.assertEqual(1, projection["rules"][1]["unresolvedErrorCount"]) + self.assertEqual(1, projection["rules"][2]["unresolvedErrorCount"]) + self.assertEqual({"N": 4, "M": 1, "E": 2}, { + key: projection["coverage"][key] for key in ("N", "M", "E") + }) + self.assertFalse(projection["coverage"]["migrationThreshold"]["met"]) + self.assertFalse(projection["coverage"]["errorStopThreshold"]["reached"]) + self.assertEqual(80, projection["coverage"]["migrationThreshold"]["successPercent"]) + self.assertEqual("strictlyGreaterThan", projection["coverage"]["errorStopThreshold"]["comparison"]) + self.assertEqual("advisory-only", projection["coverage"]["scope"]) + for key in ("coverageAuthorizesOperations", "packagingAuthorized", "deploymentAuthorized", + "ingestionAuthorized", "prCreationAuthorized"): + self.assertFalse(projection["coverage"][key]) + self.assertEqual("validated-record-only-not-rehashed-or-authenticated", + projection["evidenceDigestBinding"]) + for item in projection["rules"]: + self.assertTrue(all(len(digest) == 64 for digest in item["evidenceDigests"])) + self.assertNotIn("path", item) + self.assertIn("c" * 64, first["evidenceDigests"]) + published = evidence_path.read_text() + (self.solution / result["summary"]).read_text() + for private_text in ( + "PRIVATE_SOURCE_IDENTITY", "PRIVATE_WARNING_PROSE", "PRIVATE_ASSUMPTION_PROSE", + "PRIVATE_MAPPING_ASSUMPTION_PROSE", "PRIVATE_ERROR_PROSE", + "PRIVATE_ENVIRONMENT_ERROR", "private-tenant-secret.json", + "XDR Detections/private-tenant-0.yaml", + ): + self.assertNotIn(private_text, published) + summary = (self.solution / result["summary"]).read_text() + self.assertIn("Coverage is advisory only and never authorizes packaging", summary) + self.assertIn("c" * 64, summary) + + def test_sensitive_source_rule_id_is_withheld_without_dropping_inventory_row(self): + contract = self.authoring_contract( + ["unprocessed"], rule_ids=["private-tenant-token"], + ) + result = export_evidence(self.solution, authoring_contract=contract) + projection = json.loads((self.solution / result["evidence"]).read_text())["authoringContract"] + self.assertEqual(1, len(projection["rules"])) + self.assertIsNone(projection["rules"][0]["sourceRuleId"]) + self.assertEqual("withheld", projection["rules"][0]["sourceRuleIdAvailability"]) + self.assertNotIn("private-tenant-token", json.dumps(projection)) + + def test_authoring_contract_thresholds_and_qualification_counts_are_exact(self): + success = self.authoring_contract( + ["migrated"] * 8 + ["unprocessed"] * 2, + qualification_statuses=["qualified"] + ["untested"] * 9, + ) + success_result = export_evidence(self.solution, authoring_contract=success) + success_projection = json.loads((self.solution / success_result["evidence"]).read_text())["authoringContract"] + self.assertEqual((10, 8, 0), tuple(success_projection["coverage"][key] for key in ("N", "M", "E"))) + self.assertTrue(success_projection["coverage"]["migrationThreshold"]["met"]) + self.assertEqual(1, success_projection["coverage"]["qualified"]) + self.assertEqual(9, success_projection["coverage"]["untested"]) + shutil.rmtree(self.solution / "Evidence") + + stop = self.authoring_contract(["blocked"] * 6 + ["unprocessed"] * 4) + stop_result = export_evidence(self.solution, authoring_contract=stop) + stop_projection = json.loads((self.solution / stop_result["evidence"]).read_text())["authoringContract"] + self.assertEqual((10, 0, 6), tuple(stop_projection["coverage"][key] for key in ("N", "M", "E"))) + self.assertTrue(stop_projection["coverage"]["errorStopThreshold"]["reached"]) + self.assertFalse(stop_projection["coverage"]["deploymentAuthorized"]) + self.assertFalse(stop_projection["coverage"]["prCreationAuthorized"]) + + def test_authoring_contract_validation_and_run_binding_fail_before_writing(self): + contract = self.authoring_contract(["migrated"]) + invalid = deepcopy(contract) + invalid["inventory"]["rules"][0]["sha256"] = "not-a-sha256" + with self.assertRaises(ValueError): + export_evidence(self.solution, authoring_contract=invalid) + self.assertFalse((self.solution / "Evidence").exists()) + + mismatched = deepcopy(contract) + mismatched["runId"] = "another-run" + mismatched.pop("coverage") + mismatched.pop("operationEligibility") + mismatched = seal_authoring_contract(mismatched) + with self.assertRaisesRegex(ValueError, "does not match the selected evidence run"): + export_evidence(self.solution, authoring_contract=mismatched) + self.assertFalse((self.solution / "Evidence").exists()) + + @mock.patch("sentinel_xdr_migration.evidence._utc_now", return_value="2026-10-07T15:00:00+00:00") + def test_authoring_contract_projection_has_stable_snapshot_bytes(self, clock): + contract = self.authoring_contract(["migrated", "blocked", "unprocessed"]) + result = export_evidence(self.solution, authoring_contract=contract) + destination = self.solution / "Evidence" / self.run_id + original = {path.name: path.read_bytes() for path in destination.iterdir()} + shutil.rmtree(destination) + export_evidence(self.solution, authoring_contract=contract) + self.assertEqual(original, {path.name: path.read_bytes() for path in destination.iterdir()}) + + @mock.patch("sentinel_xdr_migration.evidence._utc_now", return_value="2026-10-07T15:00:00+00:00") + def test_header_public_name_current_version_and_requested_action(self, clock): + (self.solution / "SolutionMetadata.json").write_text(json.dumps({"version": "3.0.18", "Author": "private-user"})) + data = self.solution / "Data" + data.mkdir() + (data / "Solution_Sample.json").write_text(json.dumps({"Name": "https://private.invalid", "Version": "3.0.18"})) + state = json.loads((self.run / "workflow-state.json").read_text()) + state["context"]["versionBump"] = "minor" + state["workflowStatus"] = "blocked" + self.write("workflow-state.json", state) + evidence = self.export() + self.assertEqual("Sample", evidence["solution"]["name"]) + self.assertEqual("3.0.18", evidence["solution"]["currentVersion"]) + self.assertEqual("available", evidence["solution"]["versionAvailability"]) + self.assertIsNone(evidence["solution"]["requestedTargetVersion"]) + self.assertEqual("2026-10-07T15:00:00+00:00", evidence["exportedAt"]) + summary = (self.solution / "Evidence" / self.run_id / "summary.md").read_text() + for text in ("# Migration evidence — Sample", "Profile: **authoring**", + "Current solution version at export: **3.0.18**", "Requested version action: **minor**", + "Requested target solution version: **unknown (not recorded)**", + "export tool, not historical run version", "2026-10-07T15:00:00+00:00"): + self.assertIn(text, summary) + self.assertNotIn("private-user", json.dumps(evidence)) + self.assertNotIn("https://", json.dumps(evidence)) + self.assertNotIn(str(self.storage), summary) + for source in evidence["solution"]["versionSources"]: + path = self.solution / ("SolutionMetadata.json" if source["kind"] == "solution-metadata" else "Data/Solution_Sample.json") + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), source["sha256"]) + + def test_header_missing_optional_metadata_is_unknown_not_invented(self): + evidence = self.export() + self.assertEqual("missing", evidence["solution"]["versionAvailability"]) + self.assertIsNone(evidence["solution"]["currentVersion"]) + self.assertIsNone(evidence["solution"]["requestedTargetVersion"]) + old = dict(evidence) + old.pop("solution") + old.pop("exportedAt") + summary = _markdown(old) + self.assertIn("Current solution version at export: **unknown**", summary) + self.assertIn("Evidence exported at (UTC): **unknown (not recorded)**", summary) + self.assertNotIn(evidence["exportedAt"], summary) + self.assertNotIn("3.1.0", summary) + + def test_header_only_enrichment_preserves_old_results_and_export_identity(self): + evidence = self.export() + evidence.pop("exportedAt") + original_results = json.dumps(evidence["reports"], sort_keys=True) + original_exporter = dict(evidence["exporter"]) + evidence["solution"].update( + currentVersion="3.0.18", versionObservation="header-update", + requestedTargetVersion=None, + ) + evidence["headerUpdatedAt"] = "2026-10-07T16:00:00+00:00" + evidence["headerRenderer"] = {"version": "0.1.0"} + summary = _markdown(evidence) + self.assertIn("Current solution version at header update: **3.0.18**", summary) + self.assertIn("Evidence exported at (UTC): **unknown (not recorded)**", summary) + self.assertIn("Header updated at (UTC): **2026-10-07T16:00:00+00:00**", summary) + self.assertIn("original results, hashes and export identity are preserved", summary) + self.assertEqual(original_results, json.dumps(evidence["reports"], sort_keys=True)) + self.assertEqual(original_exporter, evidence["exporter"]) + evidence["headerUpdatedAt"] = "https://private.invalid" + evidence["headerRenderer"]["version"] = "/Users/private" + summary = _markdown(evidence) + self.assertNotIn("private", summary) + + def test_header_injected_names_and_versions_are_withheld(self): + for value in ( + "https://host.invalid?sig=private", "www.private.invalid", "../outside", + "/Users/private", r"C:\Users\private", "alice@example.com", "Name\nPersonal", + "11111111-2222-3333-4444-555555555555", "token private", + "Name | [private](https://host.invalid)", "", + ): + with self.subTest(value=value): + self.assertIsNone(_public_solution_name(value)) + (self.solution / "SolutionMetadata.json").write_text(json.dumps({ + "version": "3.0.18\n/Users/private", "Name": "Personal secret", "Author": "private-user", + })) + evidence = self.export() + self.assertIsNone(evidence["solution"]["currentVersion"]) + self.assertEqual("invalid", evidence["solution"]["versionAvailability"]) + self.assertNotIn("private", json.dumps(evidence)) + + def test_header_relocated_copy_uses_solution_name_not_parent_identity(self): + copied = self.storage / "private-user-home" / "Solutions" / "Microsoft Defender XDR" + shutil.copytree(self.solution, copied) + result = export_evidence(copied) + evidence = json.loads((copied / result["evidence"]).read_text()) + self.assertEqual("Microsoft Defender XDR", evidence["solution"]["name"]) + self.assertNotIn("private-user-home", json.dumps(evidence)) + self.assertNotIn(str(self.storage), (copied / result["summary"]).read_text()) + + def test_header_multiple_data_copies_and_conflicting_versions_are_not_selected(self): + data = self.solution / "Data" + data.mkdir() + first = data / "Solution_Sample.json" + first.write_text('{"Version":"3.0.18"}') + second = data / "Solution_Copy.json" + second.write_text('{"Version":"3.1.0"}') + evidence = self.export() + self.assertEqual("ambiguous", evidence["solution"]["versionAvailability"]) + self.assertIsNone(evidence["solution"]["currentVersion"]) + shutil.rmtree(self.solution / "Evidence") + second.unlink() + (self.solution / "SolutionMetadata.json").write_text('{"version":"3.1.0"}') + evidence = self.export() + self.assertEqual("conflict", evidence["solution"]["versionAvailability"]) + self.assertIsNone(evidence["solution"]["currentVersion"]) + + def test_header_data_version_available_when_metadata_does_not_declare_version(self): + data = self.solution / "Data" + data.mkdir() + (data / "Solution_Sample.json").write_text('{"Version":"3.0.18"}') + (self.solution / "SolutionMetadata.json").write_text('{"Name":"ignored-private-value"}') + evidence = self.export() + self.assertEqual("3.0.18", evidence["solution"]["currentVersion"]) + self.assertEqual("available", evidence["solution"]["versionAvailability"]) + self.assertEqual("not-recorded", evidence["solution"]["versionSources"][0]["availability"]) + self.assertNotIn("ignored-private-value", json.dumps(evidence)) + + def test_multi_run_requires_selection_and_isolates_results(self): + self.manifest() + second = initialize_workflow(self.solution, workflow_profile="authoring", new_run=True) + with self.assertRaisesRegex(ValueError, "multiple migration runs"): + self.export() + with using_run(self.solution, second["runId"]): + self.assertEqual("missing", self.export()["reports"]["conversion"]["availability"]) + with using_run(self.solution, self.run_id): + self.assertEqual(3, self.export()["reports"]["conversion"]["counts"]["total"]) + + def test_traversal_symlink_and_unsafe_content_paths_are_rejected(self): + for run_id in ("../outside", "/outside"): + with self.assertRaises(ValueError): + with using_run(self.solution, run_id): + self.export() + (self.solution / "Evidence").symlink_to(self.storage, target_is_directory=True) + with self.assertRaises(ValueError): + self.export() + (self.solution / "Evidence").unlink() + self.write("manifest.json", {"results": [{"source": "Analytic Rules/../secret.yaml", "output": "XDR Detections/token.yaml"}]}) + item = self.export()["reports"]["conversion"]["items"][0] + self.assertEqual("withheld", item["source"]["availability"]) + self.assertEqual("withheld", item["output"]["availability"]) + + def test_cli_does_not_configure_logging_or_change_state(self): + output = io.StringIO() + before = {p.name: p.read_bytes() for p in self.run.iterdir()} + with mock.patch("sentinel_xdr_migration.cli.configure_logging") as logging, redirect_stdout(output): + status = main(["export-evidence", "--solution", str(self.solution), "--run-id", self.run_id]) + self.assertEqual(0, status) + logging.assert_not_called() + self.assertEqual("exported", json.loads(output.getvalue())["status"]) + self.assertEqual(before, {p.name: p.read_bytes() for p in self.run.iterdir()}) + + def test_missing_run_does_not_create_state_or_evidence(self): + shutil.rmtree(self.solution / "Logs") + with self.assertRaisesRegex(ValueError, "existing run"): + self.export() + self.assertFalse((self.solution / "Evidence").exists()) + + def test_changed_input_aborts_before_writing(self): + with mock.patch("sentinel_xdr_migration.evidence._Snapshot.unchanged", return_value=False): + with self.assertRaisesRegex(ValueError, "inputs changed"): + self.export() + self.assertFalse((self.solution / "Evidence").exists()) + + def test_state_identity_mismatch_cannot_be_exported(self): + state = json.loads((self.run / "workflow-state.json").read_text()) + state["runId"] = "unrelated-run" + self.write("workflow-state.json", state) + with self.assertRaisesRegex(ValueError, "does not match"): + self.export() + self.assertFalse((self.solution / "Evidence").exists()) + + def test_consolidated_structural_and_runtime_results_are_preserved(self): + self.write("migration-report.json", {"rules": [{ + "sourceFile": "Analytic Rules/Rule.yaml", + "analyticRule": {"alertStatus": "not-run", "runtime": [{"status": "blocked", "provider": "triage-mcp", "records": ["private"]}]}, + "customDetection": {"structuralStatus": "passed", "conversionStatus": "needsReview", "runtime": []}, + }]}) + evidence = self.export() + item = evidence["reports"]["solutionReport"]["items"][0] + self.assertEqual("blocked", item["analyticRule"]["runtime"][0]["recordedStatus"]) + self.assertEqual("not-run", item["analyticRule"]["alertStatus"]) + self.assertEqual("not-recorded", item["customDetection"]["runtimeAvailability"]) + summary = (self.solution / "Evidence" / self.run_id / "summary.md").read_text() + self.assertIn("structural: passed=1", summary) + self.assertNotIn("private", json.dumps(evidence)) + + def test_content_discovery_excludes_evidence_logs_and_reports(self): + for name in ("Evidence", "Logs", "Reports"): + path = self.output.parent / name / "private.yaml" + path.parent.mkdir() + path.write_text("private: true") + self.assertEqual([self.output], yaml_files(self.output.parent)) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") + def test_powershell_input_guard_and_fixed_zip_inputs(self): + root = Path(__file__).resolve().parents[3] / "Tools/Create-Azure-Sentinel-Solution" + common = root / "common/commonFunctions.ps1" + script = ( + "$ErrorActionPreference='Stop'; . '" + str(common).replace("'", "''") + "'; " + "foreach ($folder in @('Evidence','Logs','Reports','eViDeNcE')) { " + "$rejected=$false; try { Assert-NoOperationalContent -Content " + "([pscustomobject]@{'Playbooks'=@(\"$folder/private.json\")}) } " + "catch {$rejected=$true}; if (-not $rejected) {throw 'unsafe content accepted'} }; " + "foreach ($value in @('Evidence/private.json', '[\"Logs/private.json\"]')) { " + "$rejected=$false; try { Assert-NoOperationalContent -Content " + "([pscustomobject]@{'Playbooks'=$value}) } " + "catch {$rejected=$true}; if (-not $rejected) {throw 'unsafe string content accepted'} }; " + "Assert-NoOperationalContent -Content ([pscustomobject]@{'Analytic Rules'=@('Analytic Rules/Rule.yaml')})" + ) + result = subprocess.run(["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn('Path = "$solutionFolder/createUiDefinition.json", "$solutionFolder/mainTemplate.json"', common.read_text()) + for name in ("common/createSolutionLocal.ps1", "V3/createSolutionV3.ps1", "V3/createSolutionV3_1.ps1"): + self.assertIn("Assert-NoOperationalContent -Content $contentToImport", (root / name).read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_graph_cli_scope.py b/Tools/SentinelToXDRMigration/tests/test_graph_cli_scope.py new file mode 100644 index 00000000000..e31649fe18c --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_graph_cli_scope.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import base64 +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml +from azure.core.exceptions import ClientAuthenticationError + +from azure.identity._credentials import azure_cli + +from sentinel_xdr_migration.graph_readiness import ( + AZURE_CLI_GRAPH_SCOPE, + GRAPH_RESOURCE, + preflight_deployment, +) +from sentinel_xdr_migration.deployment import ( + DEPLOYMENT_SCOPE, + _deployment_token, +) +from sentinel_xdr_migration.graph_auth import ( + delegated_scope_diagnostics, + validate_delegated_graph_claims, + credential_token_scope, +) +from sentinel_xdr_migration.graph_client_config import ( + DEFAULT_GRAPH_CLIENT_ID, + configure_azure_cli_profile, + configure_graph_client, +) + +TENANT_ID = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" +SOURCE_ID = "11111111-2222-3333-4444-555555555555" + + +def locked_target() -> dict[str, str]: + return { + "tenantId": TENANT_ID, + "subscriptionId": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspaceResourceId": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/ws" + ), + "workspaceCustomerId": "756386d8-e2d4-4f09-905a-74b24313721f", + } + + +def detection_document() -> dict: + return { + "schemaVersion": "1.0.0", + "version": "3.1.0", + "kind": "CustomDetection", + "resourceType": "Microsoft.Security/detectionRules", + "apiVersion": "2026-06-01-preview", + "contentProvenance": { + "source": { + "platform": "Microsoft Sentinel", + "kind": "AnalyticsRule", + "id": SOURCE_ID, + "path": "Analytic Rules/rule.yaml", + "querySha256": "hash", + "schedule": { + "queryFrequency": "1h", + "queryPeriod": "1h", + }, + }, + "conversion": { + "tool": "sentinel-to-xdr-migration", + "version": "0.1.0", + "status": "converted", + "reviewRequired": False, + "reviewReasons": [], + "requiredWorkloads": ["defender"], + "warnings": [], + "errors": [], + }, + }, + "properties": { + "id": SOURCE_ID, + "displayName": "Test rule", + "status": "disabled", + "queryCondition": {"queryText": "DeviceInfo | take 1"}, + "schedule": {"frequency": "PT1H"}, + "detectionAction": { + "alertTemplate": { + "title": "Test rule", + "description": "Test", + "severity": "low", + "entityMappings": { + "hosts": [ + {"id": "host1", "deviceIdColumn": "DeviceId"} + ] + }, + } + }, + }, + } + + +def access_token() -> str: + claims = { + "tid": TENANT_ID, + "aud": "00000003-0000-0000-c000-000000000000", + "scp": ( + "CustomDetection.Read.All CustomDetection.ReadWrite.All " + "User.Read email openid profile" + ), + } + encoded = base64.urlsafe_b64encode( + json.dumps(claims).encode("utf-8") + ).decode("ascii").rstrip("=") + return f"header.{encoded}.signature" + + +class AzureCliGraphScopeTests(unittest.TestCase): + def test_required_scope_policy_accepts_valid_extras_and_blocks_invalid_claims( + self, + ) -> None: + diagnostics = delegated_scope_diagnostics( + "CustomDetection.ReadWrite.All User.Read email openid profile", + ("https://graph.microsoft.com/CustomDetection.ReadWrite.All",), + ) + self.assertEqual("satisfied", diagnostics["status"]) + self.assertEqual( + ["email", "openid", "profile", "User.Read"], + diagnostics["extraScopeNames"], + ) + base_claims = { + "tid": TENANT_ID, + "aud": "https://graph.microsoft.com", + "scp": "CustomDetection.ReadWrite.All User.Read", + } + self.assertEqual( + "satisfied", + validate_delegated_graph_claims( + base_claims, + tenant_id=TENANT_ID, + required_scopes=( + "https://graph.microsoft.com/CustomDetection.ReadWrite.All", + ), + purpose="test", + )["delegatedScopes"]["status"], + ) + invalid_claims = ( + ({**base_claims, "tid": "wrong-tenant"}, "tenant"), + ({**base_claims, "aud": "https://other.example"}, "Microsoft Graph"), + ({**base_claims, "scp": "User.Read"}, "required delegated"), + ({**base_claims, "scp": ["CustomDetection.ReadWrite.All"]}, "required delegated"), + ( + { + "tid": TENANT_ID, + "aud": "https://graph.microsoft.com", + "roles": ["CustomDetection.ReadWrite.All"], + }, + "required delegated", + ), + ) + for claims, message in invalid_claims: + with self.subTest(claims=claims), self.assertRaisesRegex( + RuntimeError, message + ): + validate_delegated_graph_claims( + claims, + tenant_id=TENANT_ID, + required_scopes=( + "https://graph.microsoft.com/CustomDetection.ReadWrite.All", + ), + purpose="test", + ) + + def _preflight_patches(self, cli_backend: mock.Mock): + root = Path("preflight-fixture").resolve() + return ( + mock.patch( + "sentinel_xdr_migration.graph_readiness.require_locked_target", + return_value=locked_target(), + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness.preflight_dcr_permissions", + return_value={"status": "not-requested"}, + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness.xdr_detection_files", + side_effect=lambda _directory: [ + root / "XDR Detections" / "rule.yaml" + ], + ), + mock.patch.object( + Path, + "read_text", + return_value=yaml.safe_dump(detection_document()), + ), + mock.patch.object(azure_cli, "_run_command", cli_backend), + mock.patch( + "sentinel_xdr_migration.graph_readiness._graph_get", + return_value=(200, {"id": f"xdr-{SOURCE_ID}"}), + ), + ) + + def test_non_cli_credentials_keep_exact_permission_scopes(self) -> None: + self.assertEqual( + credential_token_scope("device-code", DEPLOYMENT_SCOPE), + DEPLOYMENT_SCOPE, + ) + self.assertEqual( + credential_token_scope( + "browser", + "https://graph.microsoft.com/CustomDetection.Read.All", + ), + "https://graph.microsoft.com/CustomDetection.Read.All", + ) + + @mock.patch("azure.identity._credentials.azure_cli._run_command") + def test_deployment_token_uses_graph_resource_with_cached_azure_cli( + self, cli_command: mock.Mock + ) -> None: + cli_command.return_value = json.dumps( + { + "accessToken": access_token(), + "expires_on": 2_000_000_000, + } + ) + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + state_root = Path(temp) / "synthetic-auth-state" + configure_graph_client( + state_root, "customDetection", DEFAULT_GRAPH_CLIENT_ID + ) + configure_azure_cli_profile( + state_root, Path(temp) / "synthetic-azure-cli-profile" + ) + with mock.patch( + "sentinel_xdr_migration.deployment._state_dir", + return_value=state_root, + ): + token = _deployment_token(tenant_id=TENANT_ID) + + self.assertEqual(token, access_token()) + cli_command.assert_called_once() + command = cli_command.call_args.args[0] + self.assertEqual( + command, + [ + "account", + "get-access-token", + "--output", + "json", + "--resource", + GRAPH_RESOURCE, + "--tenant", + TENANT_ID, + ], + ) + self.assertNotIn("CustomDetection.ReadWrite.All", command) + + def test_azure_cli_preflight_uses_graph_default_resource_and_exact_locked_tenant(self) -> None: + observed: list[tuple[list[str], int]] = [] + + def cli_backend(args: list[str], timeout: int) -> str: + observed.append((args, timeout)) + return json.dumps( + { + "accessToken": access_token(), + "expires_on": 2_000_000_000, + } + ) + + patches = self._preflight_patches(mock.Mock(side_effect=cli_backend)) + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5] as graph_get: + report = preflight_deployment( + "preflight-fixture", + auth_method="azure-cli", + ) + + self.assertEqual(len(observed), 1) + command, timeout = observed[0] + self.assertEqual( + command, + [ + "account", + "get-access-token", + "--output", + "json", + "--resource", + GRAPH_RESOURCE, + "--tenant", + TENANT_ID, + ], + ) + self.assertEqual(timeout, 10) + self.assertNotIn("CustomDetection.ReadWrite.All", command) + self.assertNotIn("CustomDetection.Read.All", command) + self.assertEqual( + report["authentication"]["requestedScope"], + AZURE_CLI_GRAPH_SCOPE, + ) + self.assertEqual( + report["authentication"]["scopeRequestKind"], + "graph-resource-default", + ) + self.assertEqual(report["status"], "readiness-unconfirmed") + self.assertEqual(report["permissions"]["writeAuthorization"], "not-tested") + self.assertEqual(graph_get.call_count, 1) + self.assertNotIn(access_token(), json.dumps(report)) + + def test_azure_cli_resource_failure_is_action_required_without_second_scope_or_get(self) -> None: + backend = mock.Mock( + side_effect=ClientAuthenticationError("controlled token backend failure") + ) + patches = self._preflight_patches(backend) + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5] as graph_get: + report = preflight_deployment( + "preflight-fixture", + auth_method="azure-cli", + ) + + backend.assert_called_once() + self.assertEqual( + report["authentication"]["requestedScope"], + AZURE_CLI_GRAPH_SCOPE, + ) + self.assertEqual(report["status"], "action-required") + self.assertEqual( + report["authentication"]["readScopeAcquisition"]["status"], + "not-attempted", + ) + graph_get.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_logs_query.py b/Tools/SentinelToXDRMigration/tests/test_logs_query.py new file mode 100644 index 00000000000..e81cd18641d --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_logs_query.py @@ -0,0 +1,784 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import os +import tempfile +import unittest +import uuid +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import yaml +from azure.monitor.query import ( + LogsQueryError, + LogsQueryPartialResult, + LogsQueryResult, + LogsTable, +) +from azure.core.pipeline import PipelineContext, PipelineRequest +from azure.core.rest import HttpRequest + +from sentinel_xdr_migration import cli +from sentinel_xdr_migration.artifacts import create_run, report_directory +from sentinel_xdr_migration.graph_client_config import configure_azure_cli_profile +from sentinel_xdr_migration.logs_query import ( + LogsQueryFailure, + azure_cli_credential, + execute_workspace_query, + logs_query_client, + preflight_read_executor, + query_family, + verify_azure_cli_target, +) +from sentinel_xdr_migration.runtime import current_runtime_evidence, validate_sentinel_sdk + + +TENANT = "11111111-2222-3333-4444-555555555555" +SUBSCRIPTION = "22222222-3333-4444-5555-666666666666" +WORKSPACE = "33333333-4444-5555-6666-777777777777" +TARGET = { + "tenantId": TENANT, + "subscriptionId": SUBSCRIPTION, + "workspaceResourceId": ( + "/subscriptions/22222222-3333-4444-5555-666666666666/resourceGroups/lab/" + "providers/Microsoft.OperationalInsights/workspaces/sentinel" + ), + "workspaceCustomerId": WORKSPACE, +} + + +def logs_table(name, columns, rows, column_types=None): + return LogsTable( + name=name, + columns=columns, + columns_types=column_types or ["string"] * len(columns), + rows=rows, + ) + + +class FakeClient: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + self.closed = False + + def query_workspace(self, workspace_id, query, **kwargs): + self.calls.append((workspace_id, query, kwargs)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + def close(self): + self.closed = True + + +class FakeClock: + def __init__(self): + self.value = 100.0 + self.sleeps = [] + + def monotonic(self): + return self.value + + def sleep(self, value): + self.sleeps.append(value) + self.value += value + + +def success(*tables): + return LogsQueryResult(tables=list(tables)) + + +def partial(*tables, error="partial result"): + return LogsQueryPartialResult( + partial_data=list(tables), + partial_error=LogsQueryError(code="PartialError", message=error), + ) + + +class LogsQueryExecutionTests(unittest.TestCase): + def test_exact_multiline_query_and_unbounded_timespan_are_sent(self): + query = 'let cutoff = ago(2h);\nDeviceEvents\n| where TimeGenerated > cutoff\n| where InitiatingProcessCommandLine contains "a|b;''c"\n| summarize count()' + response = success(logs_table("PrimaryResult", ["Count"], [])) + client = FakeClient([response]) + result = execute_workspace_query(client, WORKSPACE, query) + self.assertEqual(query, client.calls[0][1]) + self.assertIsNone(client.calls[0][2]["timespan"]) + self.assertEqual(0, result["rowCount"]) + self.assertEqual("passed", result["status"]) + self.assertEqual([{"name": "Count", "type": "string"}], result["schema"]) + + def test_actual_sdk_deserialization_preserves_zero_row_schema(self): + response = LogsQueryResult._from_generated({ + "tables": [{ + "name": "PrimaryResult", + "columns": [ + {"name": "Computer", "type": "string"}, + {"name": "TimeGenerated", "type": "datetime"}, + ], + "rows": [], + }], + }) + result = execute_workspace_query( + FakeClient([response]), + WORKSPACE, + "DeviceEvents | project Computer, TimeGenerated", + ) + self.assertEqual("passed", result["status"]) + self.assertEqual( + [ + {"name": "Computer", "type": "string"}, + {"name": "TimeGenerated", "type": "datetime"}, + ], + result["schema"], + ) + self.assertEqual([], result["tables"][0]["rows"]) + + def test_actual_sdk_partial_deserialization_preserves_schema_and_error(self): + response = LogsQueryPartialResult._from_generated({ + "tables": [{ + "name": "PrimaryResult", + "columns": [{"name": "Computer", "type": "string"}], + "rows": [["host-a"]], + }], + "error": {"code": "PartialError", "message": "incomplete"}, + }, LogsQueryError) + result = execute_workspace_query( + FakeClient([response]), + WORKSPACE, + "DeviceEvents | project Computer", + ) + self.assertEqual("failed", result["status"]) + self.assertEqual([{"name": "Computer", "type": "string"}], result["schema"]) + self.assertEqual([["host-a"]], result["tables"][0]["rows"]) + self.assertEqual("incomplete", result["error"]) + + def test_malformed_sdk_column_metadata_is_an_explicit_provider_failure(self): + malformed_models = ( + ( + LogsTable( + name="PrimaryResult", + columns=["Computer"], + columns_types=[], + rows=[], + ), + "missing or mismatched column types", + ), + ( + LogsTable( + name="PrimaryResult", + columns=[""], + columns_types=["string"], + rows=[], + ), + "invalid column name", + ), + ( + LogsTable( + name="PrimaryResult", + columns=["Computer"], + columns_types=[""], + rows=[], + ), + "invalid column type", + ), + ) + for malformed, message in malformed_models: + with self.subTest(message=message): + with self.assertRaisesRegex(LogsQueryFailure, message) as caught: + execute_workspace_query( + FakeClient([success(malformed)]), + WORKSPACE, + "DeviceEvents | project Computer", + ) + self.assertEqual("provider", caught.exception.failure_kind) + + def test_successful_sdk_result_without_tables_is_not_accepted(self): + with self.assertRaisesRegex(LogsQueryFailure, "no result tables"): + execute_workspace_query( + FakeClient([LogsQueryResult(tables=[])]), + WORKSPACE, + "print 1", + ) + + def test_typed_results_keep_full_schema_and_rows(self): + query = "DeviceEvents | project TimeGenerated, Amount, Id" + values = [ + datetime(2026, 10, 11, tzinfo=timezone.utc), + Decimal("1.25"), + uuid.UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + ] + client = FakeClient([success(logs_table( + "events", + ["TimeGenerated", "Amount", "Id"], + [values], + ))]) + result = execute_workspace_query(client, WORKSPACE, query) + self.assertEqual( + [ + {"$datetime": "2026-10-11T00:00:00+00:00"}, + {"$decimal": "1.25"}, + {"$uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, + ], + result["tables"][0]["rows"][0], + ) + self.assertEqual(["TimeGenerated", "Amount", "Id"], [c["name"] for c in result["schema"]]) + + def test_partial_results_are_failed_and_retain_available_data_and_error(self): + client = FakeClient([partial(logs_table("events", ["A", "B"], [[1, 2]]), error="incomplete")]) + result = execute_workspace_query(client, WORKSPACE, "T | project A, B") + self.assertEqual("failed", result["status"]) + self.assertTrue(result["partial"]) + self.assertEqual("incomplete", result["error"]) + self.assertEqual(2, len(result["schema"])) + self.assertEqual([[1, 2]], result["tables"][0]["rows"]) + + def test_auth_authorization_and_kql_errors_are_not_retried(self): + for status, expected in ((401, "authentication"), (403, "authorization"), (400, "query")): + with self.subTest(status=status): + error = RuntimeError("service rejected request") + error.status_code = status + client = FakeClient([error]) + with self.assertRaises(LogsQueryFailure) as caught: + execute_workspace_query(client, WORKSPACE, "T | take 1") + self.assertEqual(expected, caught.exception.failure_kind) + self.assertEqual(1, len(client.calls)) + + def test_transient_retries_are_bounded_and_kql_errors_do_not_stop_other_rules(self): + transient = RuntimeError("temporarily unavailable") + transient.status_code = 503 + client = FakeClient([ + transient, + success(logs_table("events", ["A"], [[1]])), + RuntimeError("bad KQL"), + ]) + client.responses[-1].status_code = 400 + clock = FakeClock() + query_hash = hashlib.sha256(b"T | take 1").hexdigest() + results = query_family( + client, + WORKSPACE, + [ + {"detection": "one.yaml", "query": "T | take 1", "querySha256": query_hash}, + {"detection": "two.yaml", "query": "bad kql", "querySha256": "0" * 64}, + ], + retry_count=1, + monotonic=clock.monotonic, + sleep=clock.sleep, + ) + self.assertEqual(3, len(client.calls)) + self.assertEqual([2], clock.sleeps) + self.assertEqual(["passed", "failed"], [item["status"] for item in results]) + self.assertEqual("query", results[1]["failureKind"]) + + def test_provider_failure_stops_family_and_records_remaining_queries_not_run(self): + error = RuntimeError("no access") + error.status_code = 403 + client = FakeClient([error]) + items = [ + {"detection": "one.yaml", "query": "T | take 1", "querySha256": "1" * 64}, + {"detection": "two.yaml", "query": "U | take 1", "querySha256": "2" * 64}, + ] + results = query_family(client, WORKSPACE, items) + self.assertEqual(["blocked", "not-run"], [item["status"] for item in results]) + self.assertEqual(1, len(client.calls)) + + def test_family_timeout_marks_unstarted_queries_not_run(self): + clock = FakeClock() + + class SlowClient(FakeClient): + def query_workspace(self, workspace_id, query, **kwargs): + call = super().query_workspace(workspace_id, query, **kwargs) + clock.value += 2 + return call + + client = SlowClient([ + success(logs_table("events", ["A"], [[1]])), + success(logs_table("events", ["B"], [[2]])), + ]) + results = query_family( + client, + WORKSPACE, + [ + {"detection": "one.yaml", "query": "T | take 1", "querySha256": "1" * 64}, + {"detection": "two.yaml", "query": "U | take 1", "querySha256": "2" * 64}, + ], + family_timeout_seconds=1, + monotonic=clock.monotonic, + ) + self.assertEqual(["passed", "not-run"], [item["status"] for item in results]) + self.assertEqual(1, len(client.calls)) + + def test_preflight_adapter_reuses_sdk_executor_and_locked_workspace(self): + client = FakeClient([success(logs_table("events", ["A"], []))]) + request = { + "provider": "logAnalytics", + "query": "T\n| take 0", + "target": TARGET, + } + with ( + mock.patch("sentinel_xdr_migration.logs_query.verify_azure_cli_target") as verify, + mock.patch("sentinel_xdr_migration.logs_query.azure_cli_credential", return_value=object()), + mock.patch("sentinel_xdr_migration.logs_query.logs_query_client", return_value=client), + ): + result = preflight_read_executor(request) + verify.assert_called_once_with(TARGET) + self.assertEqual(WORKSPACE, client.calls[0][0]) + self.assertEqual(request["query"], client.calls[0][1]) + self.assertIsNone(client.calls[0][2]["timespan"]) + self.assertTrue(result["ok"]) + self.assertEqual(0, result["rowCount"]) + + def test_credential_construction_is_pinned_and_does_not_launch_login(self): + with tempfile.TemporaryDirectory() as temp: + state = Path(temp) / "state" + profile = str((Path(temp) / "isolated-cli").resolve()) + configure_azure_cli_profile(state, profile) + observations = [] + + def construct(*, tenant_id, subscription=None, process_timeout=10): + def get_token(*scopes, **kwargs): + observations.append((scopes, kwargs, os.environ.get("AZURE_CONFIG_DIR"))) + return SimpleNamespace(token="mock-token", expires_on=4070908800) + + return SimpleNamespace( + tenant_id=tenant_id, + subscription=subscription, + _process_timeout=process_timeout, + get_token=mock.Mock(side_effect=get_token), + ) + + with ( + mock.patch("azure.identity.AzureCliCredential", side_effect=construct) as constructor, + mock.patch("sentinel_xdr_migration.logs_query._state_dir", return_value=state), + ): + previous_profile = os.environ.get("AZURE_CONFIG_DIR") + credential = azure_cli_credential(TARGET) + constructor.assert_called_once_with(tenant_id=TENANT, subscription=None) + self.assertEqual(TENANT, credential.tenant_id) + self.assertIsNone(credential.subscription) + self.assertEqual(10, credential._process_timeout) + credential.get_token("https://api.loganalytics.io/.default") + self.assertEqual( + [(("https://api.loganalytics.io/.default",), {}, profile)], + observations, + ) + self.assertEqual(previous_profile, os.environ.get("AZURE_CONFIG_DIR")) + + def test_logs_query_bearer_policy_uses_tenant_pinned_cli_without_subscription_flag(self): + calls = [] + + class OfflineCliCredential: + def __init__(self, *, tenant_id, subscription=None, process_timeout=10): + self.tenant_id = tenant_id + self.subscription = subscription + self._process_timeout = process_timeout + + def get_token_info(self, *scopes, options=None): + # Record the CLI-shaped contract without invoking SDK auth or a process. + command = ["account", "get-access-token", "--output", "json", "--resource", + scopes[0].removesuffix("/.default")] + if self.tenant_id: + command.extend(["--tenant", self.tenant_id]) + if self.subscription: + command.extend(["--subscription", self.subscription]) + calls.append((scopes, options, command, self._process_timeout)) + return SimpleNamespace( + token="mock-token", expires_on=4070908800, refresh_on=None, + ) + + with tempfile.TemporaryDirectory() as temp: + with ( + mock.patch("azure.identity.AzureCliCredential", side_effect=OfflineCliCredential) as constructor, + mock.patch("sentinel_xdr_migration.logs_query._state_dir", return_value=Path(temp)), + ): + credential = azure_cli_credential(TARGET) + constructor.assert_called_once_with(tenant_id=TENANT, subscription=None) + client = logs_query_client(credential, retry_total=0) + policy = client._query_op._config.authentication_policy + request = PipelineRequest( + HttpRequest("POST", "https://api.loganalytics.io/v1/workspaces/query"), + PipelineContext(None), + ) + try: + with mock.patch.object( + credential, "get_token_info", wraps=credential.get_token_info, + ) as get_token_info: + policy.on_request(request) + get_token_info.assert_called_once_with( + "https://api.loganalytics.io/.default", options={}, + ) + finally: + client.close() + + self.assertEqual("Bearer mock-token", request.http_request.headers["Authorization"]) + self.assertEqual(1, len(calls)) + scopes, options, command, process_timeout = calls[0] + self.assertEqual(("https://api.loganalytics.io/.default",), scopes) + self.assertEqual({}, options) + self.assertEqual(10, process_timeout) + self.assertIn("--tenant", command) + self.assertIn(TENANT, command) + self.assertNotIn("--subscription", command) + + def test_azure_cli_target_requires_exact_tenant_and_subscription(self): + completed = SimpleNamespace(returncode=0) + account = {"tenantId": TENANT, "id": SUBSCRIPTION} + with ( + mock.patch("sentinel_xdr_migration.target_context._run_az", return_value=completed) as run, + mock.patch("sentinel_xdr_migration.target_context._json_output", return_value=account), + ): + verify_azure_cli_target(TARGET) + run.assert_called_once() + self.assertEqual(("account", "show"), run.call_args.args[0]) + with ( + mock.patch("sentinel_xdr_migration.target_context._run_az", return_value=completed), + mock.patch( + "sentinel_xdr_migration.target_context._json_output", + return_value={"tenantId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "id": SUBSCRIPTION}, + ), + ): + with self.assertRaisesRegex(LogsQueryFailure, "does not match"): + verify_azure_cli_target(TARGET) + + +class SentinelSdkProviderTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.solution = Path(self.temp.name) / "Example" + self.analytic = self.solution / "Analytic Rules" + self.detections = self.solution / "XDR Detections" + self.analytic.mkdir(parents=True) + self.detections.mkdir(parents=True) + self.sources = { + "Rule.yaml": 'let allowed = dynamic(["x", "y"]);\nDeviceEvents\n| where ActionType in (allowed)\n| where InitiatingProcessCommandLine contains "a|b;''c"', + "Nested/Other.yaml": "SecurityEvent\n| where EventID == 4624\n| project TimeGenerated, Computer", + } + for relative, query in self.sources.items(): + source = self.analytic / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text( + yaml.safe_dump({"id": self._guid(relative), "query": query}), + encoding="utf-8", + ) + target = self.detections / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + yaml.safe_dump({ + "kind": "CustomDetection", + "contentProvenance": { + "source": { + "path": f"Analytic Rules/{relative}", + "querySha256": hashlib.sha256(query.encode()).hexdigest(), + } + }, + "properties": {"queryCondition": {"queryText": query}}, + }), + encoding="utf-8", + ) + self.target = dict(TARGET) + + @staticmethod + def _guid(value): + return ( + "11111111-2222-3333-4444-" + + ("555555555555" if value == "Rule.yaml" else "666666666666") + ) + + def _factory(self, client): + return lambda credential, **kwargs: client + + def _write_provider_gap_evidence(self): + evidence = self.solution / "provider-gap.json" + evidence.write_text(json.dumps({ + "mcpProvider": "triage-mcp", + "advertisedTool": "unavailable", + "queryFamily": "sentinel", + "fallbackProvider": "azure-monitor-sdk", + "scopeReference": TARGET["workspaceResourceId"], + "reasonCode": "tool-unavailable", + "reason": "No Triage workspace query tool was available.", + "batchComplete": True, + "scopeVerified": True, + "permissionsVerified": True, + "kqlError": False, + })) + return evidence + + def test_complete_inventory_is_run_through_cli_and_full_rows_are_reported(self): + responses = [ + success(logs_table("PrimaryResult", ["A", "B"], [[1, "row"]])), + success(logs_table("PrimaryResult", ["TimeGenerated", "Computer"], [])), + ] + client = FakeClient(responses) + output = io.StringIO() + create_run(self.solution, run_id="20261011T000000Z-" + "a" * 32, migrating=True) + with ( + mock.patch("sentinel_xdr_migration.logs_query.verify_azure_cli_target") as verify, + mock.patch("sentinel_xdr_migration.logs_query.azure_cli_credential", return_value=object()), + mock.patch("sentinel_xdr_migration.logs_query.logs_query_client", side_effect=self._factory(client)), + mock.patch("sentinel_xdr_migration.cli.require_locked_target", return_value=self.target), + contextlib.redirect_stdout(output), + ): + code = cli.main([ + "validate-sentinel-sdk", + "--solution", str(self.solution), + "--run-id", "20261011T000000Z-" + "a" * 32, + ]) + self.assertEqual(0, code) + verify.assert_called_once_with(self.target) + self.assertEqual(2, len(client.calls)) + self.assertIn(self.sources["Rule.yaml"], [call[1] for call in client.calls]) + self.assertTrue(all(call[2]["timespan"] is None for call in client.calls)) + report_path = report_directory(self.solution) / "runtime-validation.azure-monitor-sdk.json" + report = json.loads(report_path.read_text(encoding="utf-8")) + self.assertEqual("azure-monitor-sdk", report["provider"]) + self.assertTrue(report["familyComplete"]) + self.assertEqual(2, report["valid"]) + self.assertEqual(["passed", "passed"], [item["status"] for item in report["results"]]) + self.assertEqual(0, report["results"][1]["rowCount"]) + self.assertEqual([[1, "row"]], report["results"][0]["tables"][0]["rows"]) + self.assertEqual( + [{"name": "A", "type": "string"}, {"name": "B", "type": "string"}], + report["results"][0]["tables"][0]["columns"], + ) + self.assertTrue(client.closed) + + def test_sdk_auth_failure_is_provider_block_not_per_rule_kql_failure(self): + error = RuntimeError("access denied") + error.status_code = 403 + client = FakeClient([error]) + report = validate_sentinel_sdk( + self.solution, + target=self.target, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + self.assertEqual(0, report["invalid"]) + self.assertEqual(1, report["blocked"]) + self.assertEqual(1, report["notRun"]) + self.assertEqual("authorization", report["providerFailure"]["failureKind"]) + self.assertFalse(report["familyComplete"]) + + def test_full_sdk_family_can_record_only_explicit_triage_provider_gap(self): + evidence = self._write_provider_gap_evidence() + client = FakeClient([ + success(logs_table("events", ["A"], [])), + success(logs_table("events", ["B"], [])), + ]) + report = validate_sentinel_sdk( + self.solution, + target=self.target, + provider_gap_evidence=evidence, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + self.assertEqual(2, report["valid"]) + self.assertTrue(report["familyComplete"]) + self.assertEqual("azure-monitor-sdk", report["providerFallback"]["fallbackProvider"]) + + def test_sdk_kql_failure_with_provider_gap_is_persisted_then_rejected(self): + evidence = self._write_provider_gap_evidence() + previous_client = FakeClient([ + success(logs_table("events", ["A"], [])), + success(logs_table("events", ["B"], [])), + ]) + previous_report = validate_sentinel_sdk( + self.solution, + target=self.target, + provider_gap_evidence=evidence, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(previous_client), + ) + self.assertEqual(2, previous_report["valid"]) + + query_error = RuntimeError("Failed to resolve table 'MissingTable'") + query_error.status_code = 400 + client = FakeClient([ + query_error, + success(logs_table("events", ["B"], [])), + ]) + with self.assertRaisesRegex(ValueError, "Failed to resolve table 'MissingTable'"): + validate_sentinel_sdk( + self.solution, + target=self.target, + provider_gap_evidence=evidence, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + + report_path = report_directory(self.solution) / "runtime-validation.azure-monitor-sdk.json" + report = json.loads(report_path.read_text(encoding="utf-8")) + failed = next(item for item in report["results"] if item["status"] == "failed") + self.assertEqual("query", failed["failureKind"]) + self.assertEqual(400, failed["statusCode"]) + self.assertIn("Failed to resolve table 'MissingTable'", failed["errorDetails"]) + self.assertEqual( + hashlib.sha256(self.sources["Nested/Other.yaml"].encode()).hexdigest(), + failed["querySha256"], + ) + self.assertEqual("azure-monitor-sdk", report["providerFallback"]["fallbackProvider"]) + self.assertTrue(report["familyComplete"]) + self.assertEqual(["failed", "passed"], [item["status"] for item in report["results"]]) + self.assertEqual(2, len(client.calls)) + current = current_runtime_evidence(self.solution) + self.assertIsNone(current["sentinelProvider"]) + self.assertTrue(any("Failed to resolve table" in issue for issue in current["currentQueryFailures"])) + + def test_sdk_blocked_and_unexecuted_fallback_results_are_persisted(self): + evidence = self._write_provider_gap_evidence() + error = RuntimeError("access denied") + error.status_code = 403 + client = FakeClient([error]) + with self.assertRaisesRegex(ValueError, "is blocked: access denied"): + validate_sentinel_sdk( + self.solution, + target=self.target, + provider_gap_evidence=evidence, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + + report_path = report_directory(self.solution) / "runtime-validation.azure-monitor-sdk.json" + report = json.loads(report_path.read_text(encoding="utf-8")) + self.assertEqual(["blocked", "not-run"], [item["status"] for item in report["results"]]) + self.assertEqual("authorization", report["providerFailure"]["failureKind"]) + self.assertFalse(report["familyComplete"]) + self.assertEqual("azure-monitor-sdk", report["providerFallback"]["fallbackProvider"]) + + def test_sdk_partial_fallback_result_is_persisted_with_diagnostics(self): + evidence = self._write_provider_gap_evidence() + response = partial(logs_table("events", ["A"], [[1]]), error="partial response details") + client = FakeClient([ + response, + success(logs_table("events", ["B"], [])), + ]) + with self.assertRaisesRegex(ValueError, "partial response details"): + validate_sentinel_sdk( + self.solution, + target=self.target, + provider_gap_evidence=evidence, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + + report_path = report_directory(self.solution) / "runtime-validation.azure-monitor-sdk.json" + report = json.loads(report_path.read_text(encoding="utf-8")) + partial_result = next(item for item in report["results"] if item.get("partial")) + self.assertEqual("failed", partial_result["status"]) + self.assertEqual("partial response details", partial_result["error"]) + self.assertEqual("partial-response", partial_result["failureKind"]) + self.assertEqual([[1]], partial_result["tables"][0]["rows"]) + self.assertTrue(report["familyComplete"]) + + def test_empty_and_stale_source_inventory_fail_before_sdk_calls(self): + client = FakeClient([]) + target_file = self.detections / "Rule.yaml" + stale_target = yaml.safe_load(target_file.read_text(encoding="utf-8")) + stale_target["contentProvenance"]["source"]["querySha256"] = "0" * 64 + target_file.write_text(yaml.safe_dump(stale_target), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "stale source-query hash"): + validate_sentinel_sdk( + self.solution, + target=self.target, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + self.assertEqual([], client.calls) + original_query = self.sources["Rule.yaml"] + stale_target["contentProvenance"]["source"]["querySha256"] = hashlib.sha256( + original_query.encode() + ).hexdigest() + target_file.write_text(yaml.safe_dump(stale_target), encoding="utf-8") + (self.detections / "Rule.yaml").unlink() + with self.assertRaisesRegex(ValueError, "complete source inventory"): + validate_sentinel_sdk( + self.solution, + target=self.target, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + self.assertEqual([], client.calls) + for path in self.analytic.rglob("*.yaml"): + path.unlink() + with self.assertRaisesRegex(ValueError, "inventory is empty"): + validate_sentinel_sdk( + self.solution, + target=self.target, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + + def test_target_mismatch_blocks_cli_before_any_sdk_query(self): + client = FakeClient([]) + create_run(self.solution, run_id="20261011T000000Z-" + "b" * 32, migrating=True) + run_path = report_directory(self.solution) + (run_path / "workflow-state.json").write_text(json.dumps({ + "context": { + "workflowProfile": "qualification", + "targetContextLocked": True, + "tenantId": TENANT, + "subscriptionId": SUBSCRIPTION, + "workspaceResourceId": TARGET["workspaceResourceId"], + "workspaceCustomerId": WORKSPACE, + } + })) + (run_path / "qualification-target.json").write_text(json.dumps({ + "schemaVersion": "1.0.0", + "locked": True, + **{key: value for key, value in self.target.items() if key != "workspaceCustomerId"}, + "workspaceCustomerId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + })) + out = io.StringIO() + with ( + mock.patch("sentinel_xdr_migration.logs_query.verify_azure_cli_target"), + mock.patch("sentinel_xdr_migration.logs_query.azure_cli_credential", return_value=object()), + mock.patch("sentinel_xdr_migration.logs_query.logs_query_client", side_effect=self._factory(client)), + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(io.StringIO()), + ): + code = cli.main([ + "validate-sentinel-sdk", + "--solution", str(self.solution), + "--run-id", "20261011T000000Z-" + "b" * 32, + ]) + self.assertEqual(2, code) + self.assertEqual([], client.calls) + + def test_partial_schema_and_rows_are_retained_in_sdk_report_as_failed(self): + response = partial(logs_table("events", ["A"], [[1]]), error="partial") + client = FakeClient([response, success(logs_table("events", ["B"], []))]) + report = validate_sentinel_sdk( + self.solution, + target=self.target, + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=self._factory(client), + ) + self.assertEqual(1, report["invalid"]) + partial_result = next(item for item in report["results"] if item["detection"] == "Nested/Other.yaml") + self.assertEqual("failed", partial_result["status"]) + self.assertTrue(partial_result["partial"]) + self.assertEqual("partial", partial_result["error"]) + self.assertEqual([[1]], partial_result["tables"][0]["rows"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_mapping_evidence.py b/Tools/SentinelToXDRMigration/tests/test_mapping_evidence.py new file mode 100644 index 00000000000..b4c0fbcdbd8 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_mapping_evidence.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import hashlib +import json +import unittest + +from sentinel_xdr_migration.converter import convert_query, convert_query_with_mapping_evidence +from sentinel_xdr_migration.mapping_evidence import ( + MappingEvidenceError, + build_mapping_evidence, + validate_mapping_evidence_freshness, +) + + +def evidence(kind: str, path: str, content: str) -> dict[str, str]: + return { + "kind": kind, + "path": path, + "content": content, + "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), + } + + +class MappingEvidenceTests(unittest.TestCase): + def test_directional_rename_cast_derived_drop_and_filter_evidence(self) -> None: + source_query = ( + 'CiscoASA_CL | where raw_port_d > 1024 and zone_s == "inside" ' + "| project SourceIP_s, raw_port_d, zone_s, retired_s" + ) + config = { + "tableMappings": {"CiscoASA_CL": "DeviceNetworkEvents"}, + "columnMappings": { + "CiscoASA_CL": { + "SourceIP_s": "RemoteIP", + "raw_port_d": "LocalPort", + "zone_s": "NetworkProtocol", + } + }, + } + source_metadata = { + "CiscoASA_CL": { + "columns": { + "SourceIP_s": "string", + "raw_port_d": "real", + "zone_s": "string", + "retired_s": "string", + }, + "targetTable": "DeviceNetworkEvents", + "columnMappings": { + "SourceIP_s": {"target": "RemoteIP"}, + "raw_port_d": {"target": "LocalPort", "transform": "tolong(raw_port_d)"}, + "zone_s": {"target": "NetworkProtocol", "expression": 'strcat("asa:", zone_s)'}, + }, + "droppedColumns": ["retired_s"], + "evidence": evidence( + "table-schema", + "schemas/ciscoasa-source.json", + json.dumps({ + "table": "CiscoASA_CL", + "columns": { + "SourceIP_s": "string", + "raw_port_d": "real", + "zone_s": "string", + "retired_s": "string", + }, + }, sort_keys=True), + ), + } + } + target_metadata = { + "DeviceNetworkEvents": { + "columns": { + "RemoteIP": "string", + "LocalPort": "long", + "NetworkProtocol": "string", + }, + "evidence": evidence( + "table-schema", + "schemas/device-network-events.json", + json.dumps({ + "table": "DeviceNetworkEvents", + "columns": { + "RemoteIP": "string", + "LocalPort": "long", + "NetworkProtocol": "string", + }, + }, sort_keys=True), + ), + } + } + + target_query, warnings, errors, record = convert_query_with_mapping_evidence( + source_query, + config, + source_metadata=source_metadata, + target_metadata=target_metadata, + ) + self.assertFalse(errors) + self.assertFalse(warnings) + self.assertIn("DeviceNetworkEvents", target_query) + self.assertIn("RemoteIP, LocalPort, NetworkProtocol, retired_s", target_query) + self.assertIn('> 1024 and NetworkProtocol == "inside"', target_query) + self.assertEqual(source_query, record["sourceQuery"]) + self.assertEqual(target_query, record["targetQuery"]) + self.assertEqual("sentinel-to-xdr", record["direction"]) + self.assertEqual("schema-verified", record["overallStatus"]) + + by_source = {item["sourceColumn"]: item for item in record["mappings"]} + self.assertEqual(("rename", "RemoteIP"), (by_source["SourceIP_s"]["kind"], by_source["SourceIP_s"]["targetColumn"])) + self.assertEqual(("cast", "long"), (by_source["raw_port_d"]["kind"], by_source["raw_port_d"]["targetType"])) + self.assertEqual("derived", by_source["zone_s"]["kind"]) + self.assertFalse(by_source["raw_port_d"]["appliedToTargetQuery"]) + self.assertEqual(("dropped", None), (by_source["retired_s"]["kind"], by_source["retired_s"]["targetColumn"])) + self.assertTrue(all(item["status"] == "schema-verified" for item in record["mappings"])) + self.assertEqual("rewritten-by-converter", record["filters"][0]["rewriteStatus"]) + self.assertIn('"inside"', record["filters"][0]["targetPredicate"]) + + self.assertEqual([], validate_mapping_evidence_freshness( + record, source_query, target_query, + source_metadata=source_metadata, target_metadata=target_metadata, config=config, + )) + self.assertIn( + "sourceQuerySha256 is stale", + validate_mapping_evidence_freshness( + record, source_query + " | take 1", target_query, + source_metadata=source_metadata, target_metadata=target_metadata, config=config, + ), + ) + + def test_forward_dcr_transform_is_never_inverted(self) -> None: + query = "CiscoASA_CL | where Action_s == 'deny' | project SourceIP_s" + descriptor = { + "columns": {"SourceIP_s": "string", "Action_s": "string"}, + "targetTable": "CiscoASA_CL", + "transformKql": "source | project SourceIP_s = tostring(src_ip), Action_s = action", + "evidence": evidence("dcr", "connector/dcr.json", '{"transform":"forward-only"}'), + } + record = build_mapping_evidence(query, source_metadata={"CiscoASA_CL": descriptor}) + self.assertEqual(query, record["targetQuery"]) + self.assertTrue(any( + item["code"] == "forward-dcr-transform-not-inverted" for item in record["issues"] + )) + self.assertTrue(all(item["sourceColumn"] != "src_ip" for item in record["mappings"])) + self.assertIn("Action_s == 'deny'", record["filters"][0]["predicate"]) + + def test_join_aliases_are_scoped_and_unqualified_fields_are_not_guessed(self) -> None: + query = ( + "DeviceEvents as d | join kind=inner (SecurityEvent as s) on d.DeviceId == s.DeviceId " + "| project d.TimeGenerated, s.AccountUPN, LooseColumn" + ) + config = {"columnMappings": {"DeviceEvents": {"AccountUPN": "MappedUpn"}}} + source_metadata = { + "DeviceEvents": { + "columns": {"TimeGenerated": "datetime", "AccountUPN": "string"}, + "columnMappings": {"TimeGenerated": "Timestamp"}, + "targetTable": "DeviceEvents", + }, + "SecurityEvent": { + "columns": {"AccountUPN": "string"}, + "columnMappings": {"AccountUPN": "AccountUpn"}, + "targetTable": "SecurityEvent", + }, + } + target_query, warnings, _, record = convert_query_with_mapping_evidence( + query, config, source_metadata=source_metadata + ) + self.assertEqual(query, target_query) + self.assertTrue(any("ambiguous" in warning for warning in warnings)) + mappings = {(item["sourceTable"], item["sourceAlias"], item["sourceColumn"]) for item in record["mappings"]} + self.assertEqual( + {("DeviceEvents", "d", "TimeGenerated"), ("SecurityEvent", "s", "AccountUPN")}, + mappings, + ) + self.assertNotIn("LooseColumn", {item["sourceColumn"] for item in record["mappings"]}) + self.assertTrue(all(item["appliedToTargetQuery"] is False for item in record["mappings"])) + + def test_duplicate_alias_and_union_bindings_remain_unresolved(self) -> None: + metadata = { + "DeviceEvents": { + "columns": {"TimeGenerated": "datetime"}, + "columnMappings": {"TimeGenerated": "Timestamp"}, + "targetTable": "DeviceEvents", + }, + "SecurityEvent": { + "columns": {"TimeGenerated": "datetime"}, + "columnMappings": {"TimeGenerated": "Timestamp"}, + "targetTable": "SecurityEvent", + }, + } + duplicate_alias = ( + "DeviceEvents as d | join kind=inner (SecurityEvent as d) on d.DeviceId " + "| project d.TimeGenerated" + ) + duplicate_record = build_mapping_evidence(duplicate_alias, source_metadata=metadata) + self.assertFalse(duplicate_record["mappings"]) + self.assertTrue(any( + issue["code"] == "alias-binding-ambiguous" for issue in duplicate_record["issues"] + )) + + union_query = "union DeviceEvents, SecurityEvent | where TimeGenerated > ago(2h)" + target_query, warnings, errors = convert_query( + union_query, + {"columnMappings": {"DeviceEvents": {"TimeGenerated": "Timestamp"}}}, + ) + self.assertEqual(union_query, target_query) + self.assertTrue(warnings) + self.assertFalse(errors) + union_record = build_mapping_evidence(union_query, source_metadata=metadata) + self.assertFalse(union_record["mappings"]) + self.assertTrue(any( + issue["code"] == "unqualified-field-unresolved" for issue in union_record["issues"] + )) + + def test_schema_evidence_hash_mismatch_is_rejected(self) -> None: + with self.assertRaisesRegex(MappingEvidenceError, "stale"): + build_mapping_evidence( + "CiscoASA_CL | project SourceIP_s", + source_metadata={ + "CiscoASA_CL": { + "columns": {"SourceIP_s": "string"}, + "evidence": { + "kind": "table-schema", + "path": "schema.json", + "content": '{"columns":["SourceIP_s"]}', + "sha256": "0" * 64, + }, + } + }, + ) + content = json.dumps({ + "table": "CiscoASA_CL", + "columns": {"SourceIP_s": "string", "Extra_s": "string"}, + }, sort_keys=True) + with self.assertRaisesRegex(MappingEvidenceError, "does not match"): + build_mapping_evidence( + "CiscoASA_CL | project SourceIP_s", + source_metadata={ + "CiscoASA_CL": { + "columns": {"SourceIP_s": "string"}, + "evidence": evidence("table-schema", "schema.json", content), + } + }, + ) + + def test_runtime_validation_requires_current_source_and_target_reports(self) -> None: + query = "DeviceEvents | where TimeGenerated > ago(1h) | project DeviceId" + target = "DeviceEvents | where Timestamp > ago(1h) | project DeviceId" + source_hash = hashlib.sha256(query.encode()).hexdigest() + target_hash = hashlib.sha256(target.encode()).hexdigest() + + def report(provider: str, surface: str, query_hash: str) -> dict[str, str]: + content = json.dumps({ + "provider": provider, + "querySurface": surface, + "results": [{"querySha256": query_hash, "status": "passed"}], + }, sort_keys=True) + return {"path": f"evidence/{provider}.json", "content": content} + + runtime = { + "sourceReport": report("log-analytics-cli", "log-analytics-workspace", source_hash), + "targetReport": report("graph", "defender-advanced-hunting", target_hash), + } + record = build_mapping_evidence( + query, target, source_metadata={ + "DeviceEvents": { + "columns": {"TimeGenerated": "datetime"}, + "targetTable": "DeviceEvents", + } + }, runtime_evidence=runtime, + ) + self.assertEqual("runtime-validated", record["mappings"][0]["status"]) + self.assertEqual(2, sum( + reference["kind"] == "runtimeVerification" + for reference in record["mappings"][0]["evidenceReferences"] + )) + + runtime["targetReport"] = report("graph", "defender-advanced-hunting", "0" * 64) + stale = build_mapping_evidence( + query, target, source_metadata={ + "DeviceEvents": { + "columns": {"TimeGenerated": "datetime"}, + "targetTable": "DeviceEvents", + } + }, runtime_evidence=runtime, + ) + self.assertNotEqual("runtime-validated", stale["mappings"][0]["status"]) + self.assertTrue(any( + issue["code"] == "runtime-evidence-not-current" for issue in stale["issues"] + )) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_nested_content.py b/Tools/SentinelToXDRMigration/tests/test_nested_content.py new file mode 100644 index 00000000000..2782d221ef3 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_nested_content.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +import unittest +import uuid +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.alert_parity import _load_pairs, start_alert_parity_batch +from sentinel_xdr_migration.analytic_deployment import deploy_analytic_rules +from sentinel_xdr_migration.content_paths import content_path, detection_reference +from sentinel_xdr_migration.converter import ( + analytic_rule_files, + convert_solution, + inspect_solution, + runtime_validation_plan, + validate_solution, + xdr_detection_files, +) +from sentinel_xdr_migration.deployment import deploy_solution +from sentinel_xdr_migration.packaging import _xdr_references +from sentinel_xdr_migration.runtime import record_runtime_validation, validate_advanced_hunting +from sentinel_xdr_migration.solution_report import build_solution_report + + +class NestedContentTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.solution = Path(self.temp.name) / "Solutions" / "Nested" + self.analytic = self.solution / "Analytic Rules" + self.output = self.solution / "XDR Detections" + self.names = sorted([ + "SampleRule.yaml", + "Execution/SampleRule.yaml", + "Execution/Deep/Rule.yml", + "Persistence/SampleRule.yaml", + "Root.YAML", + ]) + for name in self.names: + path = self.analytic / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump({ + "id": str(uuid.uuid5(uuid.NAMESPACE_URL, name)), + "name": f"Test {name}", + "description": "Detect test activity", + "kind": "Scheduled", + "version": "1.0.0", + "severity": "High", + "queryFrequency": "1h", + "queryPeriod": "1h", + "query": "DeviceEvents | project TimeGenerated, AccountUpn", + "entityMappings": [{ + "entityType": "Account", + "fieldMappings": [{"identifier": "Upn", "columnName": "AccountUpn"}], + }], + }), encoding="utf-8") + + def test_recursive_conversion_preserves_paths_versions_and_source_bytes(self) -> None: + sources = {path: path.read_bytes() for path in analytic_rule_files(self.solution)} + self.assertEqual(self.names, inspect_solution(self.solution)["analyticRules"]) + result = convert_solution(self.solution) + self.assertEqual(len(self.names), result["converted"]) + self.assertEqual(self.names, [ + path.relative_to(self.output).as_posix() + for path in xdr_detection_files(self.output) + ]) + references = [] + for item in result["results"]: + relative = item["sourceRelativePath"].removeprefix("Analytic Rules/") + self.assertEqual(f"XDR Detections/{relative}", item["outputRelativePath"]) + document = yaml.safe_load((self.output / relative).read_text()) + self.assertEqual(item["sourceRelativePath"], document["contentProvenance"]["source"]["path"]) + self.assertEqual("3.1.0", document["version"]) + self.assertEqual("1.0.0", document["contentProvenance"]["source"]["version"]) + references.append(item["outputRelativePath"]) + self.assertEqual( + xdr_detection_files(self.output), + _xdr_references(self.solution, {"XDR Detections": references}), + ) + self.assertEqual( + xdr_detection_files(self.output), + _xdr_references(self.solution, {"XDR Detections": [p.replace("/", "\\") for p in references]}), + ) + first = {path: path.read_bytes() for path in xdr_detection_files(self.output)} + modification_times = {path: path.stat().st_mtime_ns for path in first} + repeated = convert_solution(self.solution) + self.assertEqual(0, repeated["conflicts"]) + self.assertEqual(first, {path: path.read_bytes() for path in first}) + self.assertEqual(modification_times, {path: path.stat().st_mtime_ns for path in first}) + self.assertEqual(sources, {path: path.read_bytes() for path in sources}) + self.assertEqual(len(self.names), validate_solution(self.solution)["valid"]) + self.assertEqual(len(self.names), inspect_solution(self.solution)["existingXdrDetectionCount"]) + self.assertEqual(self.names, [item["detection"] for item in runtime_validation_plan(self.solution)["rules"]]) + html = Path(result["transformationReport"]).read_text() + self.assertIn("XDR Detections/Execution/Deep/Rule.yml", html) + self.assertEqual(len(self.names), len(list(self.output.rglob("*.*")))) + + def test_nested_reconversion_requires_overwrite_and_preserves_release(self) -> None: + convert_solution(self.solution) + target = self.output / "Execution/Deep/Rule.yml" + document = yaml.safe_load(target.read_text()) + document["version"] = "3.7.4" + target.write_text(yaml.safe_dump(document, sort_keys=False)) + source = self.analytic / "Execution/Deep/Rule.yml" + source.write_text(source.read_text().replace("version: 1.0.0", "version: 1.0.3")) + before = target.read_bytes() + self.assertEqual(1, convert_solution(self.solution)["conflicts"]) + self.assertEqual(before, target.read_bytes()) + self.assertEqual(0, convert_solution(self.solution, overwrite=True)["conflicts"]) + updated = yaml.safe_load(target.read_text()) + self.assertEqual("3.7.4", updated["version"]) + self.assertEqual("1.0.3", updated["contentProvenance"]["source"]["version"]) + self.assertEqual(document["properties"]["id"], updated["properties"]["id"]) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") + def test_powershell_packager_resolves_nested_manifest_references(self) -> None: + result = convert_solution(self.solution) + helper = Path(__file__).resolve().parents[2] / "Create-Azure-Sentinel-Solution/common/customDetections.ps1" + references = [item["outputRelativePath"] for item in result["results"]] + repository = str(self.solution.parent.parent).replace("'", "''") + helper_reference = str(helper).replace("'", "''") + statements = [ + "Resolve-CustomDetectionPath " + f"-RepositoryRoot '{repository}' " + "-SolutionName 'Nested' " + f"-ConfiguredPath '{reference}'" + for reference in references + [value.replace("/", "\\") for value in references] + ] + command = ( + f". '{helper_reference}'; @(" + + "; ".join(statements) + ") | ConvertTo-Json -Compress" + ) + completed = subprocess.run( + ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], + check=True, capture_output=True, text=True, + ) + resolved = [Path(value).resolve() for value in json.loads(completed.stdout)] + expected = xdr_detection_files(self.output) + self.assertEqual(expected + expected, resolved) + + def test_legacy_flat_output_requires_manual_reconciliation_even_with_overwrite(self) -> None: + convert_solution(self.solution) + nested = self.output / "Execution/Deep/Rule.yml" + legacy = self.output / "Rule.yml" + nested.rename(legacy) + before = legacy.read_bytes() + for overwrite in (False, True): + result = convert_solution(self.solution, overwrite=overwrite) + self.assertEqual(1, result["conflicts"]) + self.assertIn("explicitly relocate", next( + item for item in result["results"] if item["status"] == "conflict" + )["errors"][0]) + self.assertFalse(nested.exists()) + self.assertEqual(before, legacy.read_bytes()) + + def test_unidentified_flat_output_is_not_duplicated(self) -> None: + self.output.mkdir() + flat = self.output / "Rule.yml" + flat.write_text("manual: content\n") + self.assertEqual(1, convert_solution(self.solution, overwrite=True)["conflicts"]) + self.assertEqual("manual: content\n", flat.read_text()) + self.assertFalse((self.output / "Execution/Deep/Rule.yml").exists()) + + def test_legacy_source_alias_and_config_exclusion(self) -> None: + self.analytic.rename(self.solution / "Analytics Rules") + self.assertEqual(len(self.names), convert_solution(self.solution)["converted"]) + nested = self.output / "Execution/Deep" + (nested / "migration-config.yaml").write_text("rules: {}\n") + (nested / "migration-config.yml").write_text("rules: {}\n") + (nested / "manifest.json").write_text("{}") + self.assertEqual(len(self.names), validate_solution(self.solution)["valid"]) + for path in xdr_detection_files(self.output): + self.assertTrue(yaml.safe_load(path.read_text())["contentProvenance"]["source"]["path"].startswith("Analytics Rules/")) + + def test_recursive_runtime_and_report_do_not_conflate_same_basenames(self) -> None: + convert_solution(self.solution) + query_hashes = { + item["detection"]: item["sentinelQuerySha256"] + for item in runtime_validation_plan(self.solution)["rules"] + } + entries = [ + { + "detection": name, "status": "passed", "rowCount": index, + "querySha256": query_hashes[name], + } + for index, name in enumerate(self.names) + ] + entries[0].update(status="blocked", error="specific nested failure") + results = Path(self.temp.name) / "runtime.json" + results.write_text(json.dumps({"results": entries})) + recorded = record_runtime_validation(self.solution, provider="triage-mcp", results_path=results) + self.assertEqual(len(self.names), recorded["total"]) + report = build_solution_report(self.solution) + self.assertEqual(len(self.names), report["summary"]["rules"]) + for index, item in enumerate(report["rules"]): + self.assertEqual("converted", item["customDetection"]["conversionStatus"]) + runtime = item["analyticRule"]["runtime"] + self.assertEqual(index, runtime[0]["rowCount"]) + self.assertEqual("blocked" if index == 0 else "passed", runtime[0]["status"]) + entries[0]["detection"] = "Rule.yml" + results.write_text(json.dumps(entries)) + with self.assertRaisesRegex(ValueError, "missing detections"): + record_runtime_validation(self.solution, provider="triage-mcp", results_path=results) + + def test_legacy_absolute_manifest_still_maps_distinct_nested_sources(self) -> None: + result = convert_solution(self.solution) + manifest = Path(result["manifest"]) + for item in result["results"]: + item.pop("sourceRelativePath") + item.pop("outputRelativePath") + manifest.write_text(json.dumps(result)) + self.assertEqual( + ["converted"] * len(self.names), + [item["customDetection"]["conversionStatus"] for item in build_solution_report(self.solution)["rules"]], + ) + + @mock.patch("sentinel_xdr_migration.runtime._advanced_hunting_token", return_value="test") + @mock.patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_graph_runtime_covers_nested_files(self, query, token) -> None: + convert_solution(self.solution) + query.return_value = { + "ok": True, "statusCode": 200, "rowCount": 0, + "schema": [], "error": None, "errorDetails": None, + } + result = validate_advanced_hunting(self.solution) + self.assertEqual(self.names, [item["detection"] for item in result["results"]]) + + def test_parity_uses_relative_paths_and_rejects_ambiguous_stems(self) -> None: + convert_solution(self.solution) + _, pairs = _load_pairs(self.solution) + self.assertEqual(self.names, [item["detection"] for item in pairs]) + _, selected = _load_pairs(self.solution, ["Execution\\SampleRule.yaml"]) + self.assertEqual("Execution/SampleRule.yaml", selected[0]["detection"]) + with self.assertRaisesRegex(ValueError, "ambiguous detection"): + _load_pairs(self.solution, ["SampleRule"]) + self.assertEqual("Execution/Deep/Rule.yml", detection_reference("Rule", self.names)) + plan = Path(self.temp.name) / "plan.json" + plan.write_text(json.dumps({"fixtures": [{"detection": "SampleRule"}]})) + with mock.patch("sentinel_xdr_migration.alert_parity.require_locked_target", return_value={"workspaceResourceId": "test"}): + with self.assertRaisesRegex(ValueError, "ambiguous detection"): + start_alert_parity_batch(self.solution, plan_path=plan) + + def test_mocked_deployment_covers_all_nested_ars_and_cds(self) -> None: + convert_solution(self.solution) + with ( + mock.patch("sentinel_xdr_migration.analytic_deployment.require_locked_target", return_value={"workspaceResourceId": "test"}), + mock.patch("sentinel_xdr_migration.analytic_deployment._arm_token", return_value="test"), + mock.patch("sentinel_xdr_migration.analytic_deployment._arm_request", return_value=(201, {"properties": {"enabled": False}})) as arm, + ): + result = deploy_analytic_rules(self.solution) + self.assertEqual(len(self.names), result["succeeded"]) + self.assertEqual(len(self.names), arm.call_count) + with ( + mock.patch("sentinel_xdr_migration.deployment.require_locked_target", return_value={"tenantId": "test"}), + mock.patch("sentinel_xdr_migration.deployment._deployment_token", return_value="test"), + mock.patch("sentinel_xdr_migration.deployment._graph_request", return_value=(200, {"status": "disabled"})), + ): + result = deploy_solution(self.solution) + self.assertEqual(len(self.names), result["succeeded"]) + self.assertEqual(len(self.names), len({item["file"] for item in result["results"]})) + + def test_escaping_paths_and_symlinks_are_rejected(self) -> None: + for name in ("../outside.yaml", "/outside.yaml", r"C:\outside.yaml", r"..\outside.yaml"): + with self.subTest(name=name), self.assertRaises(ValueError): + content_path(self.output, name) + external = Path(self.temp.name) / "outside" + external.mkdir() + linked = self.analytic / "linked" + linked.symlink_to(external, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "escapes|symlink"): + inspect_solution(self.solution) + linked.unlink() + self.output.symlink_to(external, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + convert_solution(self.solution) + self.assertEqual([], list(external.iterdir())) + + def test_runtime_provenance_cannot_escape_solution(self) -> None: + convert_solution(self.solution) + target = self.output / self.names[0] + document = yaml.safe_load(target.read_text()) + document["contentProvenance"]["source"]["path"] = "../outside.yaml" + target.write_text(yaml.safe_dump(document)) + with self.assertRaisesRegex(ValueError, "relative and contained"): + runtime_validation_plan(self.solution) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_packaging.py b/Tools/SentinelToXDRMigration/tests/test_packaging.py new file mode 100644 index 00000000000..99588416a04 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_packaging.py @@ -0,0 +1,1375 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import shutil +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +from sentinel_xdr_migration import cli as cli_module +from sentinel_xdr_migration.packaging import ( + V31_TOOLKIT_PATHS, + _verify_package_selection, + _xdr_references, + _validate_solution_package_inputs, + package_input_file_hashes, + package_input_identity, + package_solution_v3_1, + validate_v31_target_version, +) +from sentinel_xdr_migration.workflow import ( + complete_workflow_stage, + initialize_workflow, + start_workflow_stage, +) + + +class PackagingTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.repository = Path(self.temp.name) + self.solution = self.repository / "Solutions" / "Sample" + data = self.solution / "Data" + detections = self.solution / "XDR Detections" + package = self.solution / "Package" + data.mkdir(parents=True) + detections.mkdir() + package.mkdir() + source_root = Path(__file__).resolve().parents[3] + for relative in V31_TOOLKIT_PATHS: + source = source_root / relative + destination = self.repository / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + (detections / "Detection.yaml").write_text("kind: CustomDetection\n", encoding="utf-8") + (data / "Solution_Sample.json").write_text( + json.dumps({ + "Name": "Sample", + "Version": "1.2.3", + "XDR Detections": ["XDR Detections/Detection.yaml"], + }), + encoding="utf-8", + ) + for name in ( + "mainTemplate.json", + "createUiDefinition.json", + "testParameters.json", + ): + (package / name).write_text("{}", encoding="utf-8") + parameters = { + "DeployCustomDetection": {"type": "bool", "defaultValue": False}, + } + (package / "mainTemplate.json").write_text(json.dumps({"parameters": parameters})) + (package / "testParameters.json").write_text(json.dumps(parameters)) + (package / "createUiDefinition.json").write_text(json.dumps({ + "parameters": { + "outputs": {"DeployCustomDetection": "[steps('contentSelection').DeployCustomDetection]"}, + "steps": [{"name": "contentSelection", "elements": [{ + "name": "DeployCustomDetection", "type": "Microsoft.Common.CheckBox", + "defaultValue": False, + }]}], + }, + })) + with zipfile.ZipFile(package / "1.2.3.zip", "w") as archive: + archive.writestr("mainTemplate.json", "{}") + archive.writestr("createUiDefinition.json", "{}") + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_xdr_reference_array_is_explicit_and_empty_never_scans_folder(self) -> None: + self.assertEqual([], _xdr_references( + self.solution, {"XDR Detections": []}, + )) + with self.assertRaisesRegex(ValueError, "missing an explicit XDR Detections array"): + _xdr_references(self.solution, {"Name": "Sample"}) + with self.assertRaisesRegex(ValueError, "duplicate XDR Detection"): + _xdr_references( + self.solution, + {"XDR Detections": [ + "XDR Detections/Detection.yaml", + "XDR Detections/Detection.yaml", + ]}, + ) + with self.assertRaisesRegex(ValueError, "must be under XDR Detections"): + _xdr_references( + self.solution, + {"XDR Detections": ["XDR Detections/../Analytic Rules/Rule.yaml"]}, + ) + + def test_v3_1_refuses_to_omit_an_applied_selection_plan(self) -> None: + with mock.patch( + "sentinel_xdr_migration.packaging_selection.verify_applied_package_selection", + return_value={ + "plan": {"planSha256": "a" * 64}, + "receipt": {}, + }, + ), mock.patch("sentinel_xdr_migration.packaging.subprocess.run") as run: + with self.assertRaisesRegex(ValueError, "must be passed explicitly"): + package_solution_v3_1( + self.solution, + version_bump="none", + workflow_run_id="selected-run", + ) + run.assert_not_called() + + def test_v31_target_version_boundaries_and_conflicting_bump(self) -> None: + for version in ("3.1.0", "3.1.2", "3.999.999"): + with self.subTest(version=version): + self.assertEqual(version, validate_v31_target_version(version)) + for version in ( + "3.0.999", + "4.0.0", + "3.1", + "3.1.0.1", + "3.1.0-preview", + "03.1.0", + "3.01.0", + "3.1.00", + "", + ): + with self.subTest(version=version): + with self.assertRaisesRegex(ValueError, "target version"): + validate_v31_target_version(version) + with self.assertRaisesRegex(ValueError, "cannot be combined"): + package_solution_v3_1( + self.solution, + version_bump="patch", + target_version="3.1.2", + ) + + def test_v31_target_version_is_forwarded_to_official_packager(self) -> None: + with mock.patch( + "sentinel_xdr_migration.packaging.subprocess.run", + return_value=CompletedProcess( + args=[], returncode=1, stdout="synthetic stop", stderr="", + ), + ) as run: + with self.assertRaisesRegex(RuntimeError, "failed with exit code"): + package_solution_v3_1( + self.solution, + version_bump="none", + target_version="3.1.2", + ) + command = run.call_args.args[0] + self.assertIn("-TargetVersion", command) + self.assertEqual("3.1.2", command[command.index("-TargetVersion") + 1]) + + def test_template_and_zip_match_partial_selection_exclusions(self) -> None: + data_path = self.solution / "Data" / "Solution_Sample.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + data["XDR Detections"] = [ + "XDR Detections/Detection.yaml", + "XDR Detections/Detection2.yaml", + ] + included_source = "11111111-1111-1111-1111-111111111111" + second_included_source = "22222222-2222-2222-2222-222222222222" + excluded_source = "33333333-3333-3333-3333-333333333333" + data_path.write_text(json.dumps(data), encoding="utf-8") + plan = { + "planSha256": "a" * 64, + "summary": { + "outcome": "partial", + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": 3, + "originalSourceRuleIds": [included_source, second_included_source, excluded_source], + "validatedMigratedSourceRuleCount": 2, + "validatedMigratedSourceRuleIds": [included_source, second_included_source], + "coverageNumerator": 2, + "coverageDenominator": 3, + "partialPublicationAllowed": True, + }, + "selection": { + "analyticRules": [], + "xdrDetections": [ + "XDR Detections/Detection.yaml", + "XDR Detections/Detection2.yaml", + ], + }, + "analyticRules": [], + "xdrDetections": [ + { + "id": "xdr-included", + "sourceId": included_source, + "path": "XDR Detections/Detection.yaml", + "included": True, + }, + { + "id": f"xdr-{second_included_source}", + "sourceId": second_included_source, + "path": "XDR Detections/Detection2.yaml", + "included": True, + }, + { + "id": "xdr-excluded", + "sourceId": excluded_source, + "path": "XDR Detections/Failed.yaml", + "included": False, + }, + ], + } + packaged_id = f"xdr-{included_source}" + second_packaged_id = f"xdr-{second_included_source}" + (self.solution / "XDR Detections" / "Detection2.yaml").write_text( + "kind: CustomDetection\n", encoding="utf-8", + ) + template = { + "parameters": {"DeployCustomDetection": {"defaultValue": False}}, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "name": "xdr-install-included", + "properties": { + "template": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": { + "id": f"[if(true(), '{packaged_id}', '{packaged_id}')]", + }, + }, + }, + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "dependsOn": ["[resourceId('Microsoft.Resources/deployments', 'xdr-install-included')]"], + "properties": { + "contentKind": "CustomDetection", + "contentId": packaged_id, + "mainTemplate": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": f"[if(true(), '{packaged_id}', '{packaged_id}')]"}, + }, + }, + }, + }, + }, + { + "type": "Microsoft.Resources/deployments", + "name": "xdr-install-included-2", + "properties": { + "template": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": second_packaged_id}, + }, + }, + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "dependsOn": ["[resourceId('Microsoft.Resources/deployments', 'xdr-install-included-2')]"], + "properties": { + "contentKind": "CustomDetection", + "contentId": second_packaged_id, + "mainTemplate": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": second_packaged_id}, + }, + }, + }, + }, + }, + ], + } + template_path = self.solution / "Package" / "mainTemplate.json" + template_path.write_text(json.dumps(template), encoding="utf-8") + zip_path = self.solution / "Package" / "selection.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("mainTemplate.json", json.dumps(template)) + + result = _verify_package_selection( + self.solution, plan, template, zip_path, + ) + + self.assertEqual(2, result["xdrDetectionIncluded"]) + self.assertTrue(result["mainTemplateVerified"]) + self.assertTrue(result["zipVerified"]) + with zipfile.ZipFile(zip_path, "a") as archive: + archive.writestr( + "Solutions/Sample/XDR Detections/Failed.yaml", + f"properties:\n id: {excluded_source}\n", + ) + with self.assertRaisesRegex(RuntimeError, "includes explicitly excluded content"): + _verify_package_selection(self.solution, plan, template, zip_path) + reference_only_zip = self.solution / "Package" / "selection-reference.zip" + with zipfile.ZipFile(reference_only_zip, "w") as archive: + archive.writestr("mainTemplate.json", json.dumps(template)) + archive.writestr("metadata/stale.yaml", f"excluded: xdr-{excluded_source}\n") + with self.assertRaisesRegex(RuntimeError, "contains a reference to explicitly excluded content"): + _verify_package_selection(self.solution, plan, template, reference_only_zip) + + def test_selection_verification_counts_exact_arm_resources_not_id_substrings(self) -> None: + included_source = "11111111-1111-1111-1111-111111111111" + second_included_source = "22222222-2222-2222-2222-222222222222" + excluded_source = "33333333-3333-3333-3333-333333333333" + data_path = self.solution / "Data" / "Solution_Sample.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + data["Analytic Rules"] = [ + "Analytic Rules/Included.yaml", + "Analytic Rules/Included2.yaml", + ] + data["XDR Detections"] = [ + "XDR Detections/Included.yaml", + "XDR Detections/Included2.yaml", + ] + data_path.write_text(json.dumps(data), encoding="utf-8") + (self.solution / "Analytic Rules").mkdir() + (self.solution / "Analytic Rules" / "Included.yaml").write_text( + f"id: {included_source}\n", encoding="utf-8", + ) + (self.solution / "Analytic Rules" / "Included2.yaml").write_text( + f"id: {second_included_source}\n", encoding="utf-8", + ) + (self.solution / "XDR Detections" / "Included.yaml").write_text( + "kind: CustomDetection\n", encoding="utf-8", + ) + (self.solution / "XDR Detections" / "Included2.yaml").write_text( + "kind: CustomDetection\n", encoding="utf-8", + ) + plan = { + "planSha256": "c" * 64, + "summary": { + "outcome": "partial", + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": 3, + "originalSourceRuleIds": [included_source, second_included_source, excluded_source], + "validatedMigratedSourceRuleCount": 2, + "validatedMigratedSourceRuleIds": [included_source, second_included_source], + "coverageNumerator": 2, + "coverageDenominator": 3, + "partialPublicationAllowed": True, + }, + "selection": { + "analyticRules": ["Analytic Rules/Included.yaml", "Analytic Rules/Included2.yaml"], + "xdrDetections": ["XDR Detections/Included.yaml", "XDR Detections/Included2.yaml"], + }, + "analyticRules": [ + {"id": included_source, "path": "Analytic Rules/Included.yaml", "included": True}, + {"id": second_included_source, "path": "Analytic Rules/Included2.yaml", "included": True}, + {"id": excluded_source, "path": "Analytic Rules/Excluded.yaml", "included": False}, + ], + "xdrDetections": [ + { + "id": f"xdr-{included_source}", + "sourceId": included_source, + "path": "XDR Detections/Included.yaml", + "included": True, + }, + { + "id": f"xdr-{second_included_source}", + "sourceId": second_included_source, + "path": "XDR Detections/Included2.yaml", + "included": True, + }, + { + "id": f"xdr-{excluded_source}", + "sourceId": excluded_source, + "path": "XDR Detections/Excluded.yaml", + "included": False, + }, + ], + } + packaged_id = f"xdr-{included_source}" + second_packaged_id = f"xdr-{second_included_source}" + template = { + "variables": { + "analyticRuleObject1": { + "_analyticRulecontentId1": included_source, + }, + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "properties": { + "contentKind": "AnalyticsRule", + "contentId": "[variables('analyticRuleObject1')._analyticRulecontentId1]", + "mainTemplate": { + "variables": {}, + "resources": [ + { + "type": "Microsoft.SecurityInsights/AlertRuleTemplates", + "name": "[variables('analyticRuleObject1')._analyticRulecontentId1]", + }, + ], + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "properties": { + "contentKind": "AnalyticsRule", + "contentId": second_included_source, + "mainTemplate": { + "resources": [ + { + "type": "Microsoft.SecurityInsights/AlertRuleTemplates", + "name": second_included_source, + }, + ], + }, + }, + }, + { + "type": "Microsoft.Resources/deployments", + "name": "xdr-install-included", + "properties": { + "template": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": f"['{packaged_id}']"}, + }, + }, + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "dependsOn": ["[resourceId('Microsoft.Resources/deployments', 'xdr-install-included')]"], + "properties": { + "contentKind": "CustomDetection", + "contentId": packaged_id, + "mainTemplate": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": f"[if(true(), '{packaged_id}', '{packaged_id}')]"}, + }, + }, + }, + }, + }, + { + "type": "Microsoft.Resources/deployments", + "name": "xdr-install-included-2", + "properties": { + "template": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": second_packaged_id}, + }, + }, + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "dependsOn": ["[resourceId('Microsoft.Resources/deployments', 'xdr-install-included-2')]"], + "properties": { + "contentKind": "CustomDetection", + "contentId": second_packaged_id, + "mainTemplate": { + "resources": { + "detectionRule": { + "type": "Microsoft.Security/detectionRules", + "properties": {"id": second_packaged_id}, + }, + }, + }, + }, + }, + ], + } + zip_path = self.solution / "Package" / "exact-resources.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("mainTemplate.json", json.dumps(template)) + + result = _verify_package_selection(self.solution, plan, template, zip_path) + self.assertEqual(2, result["analyticRuleIncluded"]) + self.assertEqual(2, result["xdrDetectionIncluded"]) + + phantom = json.loads(json.dumps(template)) + phantom["resources"] = [ + {"type": "Microsoft.Resources/deployments", "properties": {"description": included_source}} + ] + with self.assertRaisesRegex(RuntimeError, "Analytic Rule resources"): + _verify_package_selection(self.solution, plan, phantom, zip_path) + + orphan_wrapper = { + "resources": [{ + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "properties": { + "contentKind": "AnalyticsRule", + "contentId": included_source, + "mainTemplate": {"resources": []}, + }, + }], + } + with self.assertRaisesRegex(RuntimeError, "content templates"): + _verify_package_selection(self.solution, plan, orphan_wrapper, zip_path) + + duplicate = json.loads(json.dumps(template)) + duplicate["resources"].append(duplicate["resources"][0]) + with self.assertRaisesRegex(RuntimeError, "exactly 1 matching Analytic Rule"): + _verify_package_selection(self.solution, plan, duplicate, zip_path) + + def test_empty_xdr_partial_selection_is_blocked_by_coverage_policy(self) -> None: + data_path = self.solution / "Data" / "Solution_Sample.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + data["XDR Detections"] = [] + data_path.write_text(json.dumps(data), encoding="utf-8") + source_id = "11111111-1111-1111-1111-111111111111" + plan = { + "planSha256": "b" * 64, + "summary": {"outcome": "partial"}, + "selection": {"analyticRules": [], "xdrDetections": []}, + "analyticRules": [], + "xdrDetections": [{ + "id": "xdr-excluded", + "sourceId": source_id, + "path": "XDR Detections/Detection.yaml", + "included": False, + }], + } + template = {"resources": []} + zip_path = self.solution / "Package" / "empty-selection.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("mainTemplate.json", json.dumps(template)) + + with self.assertRaisesRegex(ValueError, "strict over-50%"): + _verify_package_selection(self.solution, plan, template, zip_path) + + @mock.patch( + "sentinel_xdr_migration.packaging.subprocess.run", + return_value=CompletedProcess( + args=[], + returncode=0, + stdout="=======Starting Package Creation using V3.1 tool=========", + stderr="", + ), + ) + @mock.patch( + "sentinel_xdr_migration.packaging.shutil.which", + return_value=r"C:\Program Files\PowerShell\7\pwsh.exe", + ) + def test_runs_v3_1_and_returns_workflow_evidence( + self, + which: mock.Mock, + run: mock.Mock, + ) -> None: + result = package_solution_v3_1(self.solution, version_bump="none") + + self.assertEqual("V3.1", result["packager"]) + self.assertEqual(1, result["xdrDetectionCount"]) + self.assertEqual("V3.1", result["workflowArtifacts"]["packager"]) + self.assertTrue(Path(result["packageReport"]).is_file()) + self.assertEqual("packaging.v3_1.json", Path(result["packageReport"]).name) + report = json.loads(Path(result["packageReport"]).read_text(encoding="utf-8")) + self.assertEqual("none", report["requestedVersionBump"]) + self.assertEqual("none", report["effectiveVersionBump"]) + self.assertEqual( + report["packageInputs"]["before"]["sha256"], + report["packageInputs"]["after"]["sha256"], + ) + self.assertEqual( + report["packageInputs"]["before"]["fileCount"], + report["packageInputs"]["after"]["fileCount"], + ) + self.assertEqual( + package_input_identity(self.solution)["sha256"], + report["packageInputs"]["after"]["sha256"], + ) + self.assertEqual( + package_input_file_hashes(self.solution), + report["packageInputs"]["after"]["files"], + ) + self.assertEqual( + {"mainTemplate", "createUiDefinition", "testParameters", "zip"}, + set(report["packageArtifactHashes"]), + ) + command = run.call_args.args[0] + self.assertIn("createSolutionV3_1.ps1", command[3]) + self.assertNotIn("-ToolkitRootPath", command) + self.assertEqual("none", command[-1]) + self.assertFalse(any("E5" in value or "Registration" in value for value in command)) + self.assertEqual({"DeployCustomDetection": False}, result["deploymentParameters"]) + self.assertEqual("unverified", result["customDetectionRegistrationSupport"]) + self.assertEqual(str(self.repository), result["toolkitRoot"]) + self.assertEqual(str(self.repository / V31_TOOLKIT_PATHS[0]), result["packagerScript"]) + self.assertEqual(self.repository, run.call_args.kwargs["cwd"]) + which.assert_called_once_with("pwsh") + + @mock.patch( + "sentinel_xdr_migration.packaging.subprocess.run", + return_value=CompletedProcess( + args=[], + returncode=0, + stdout="=======Starting Package Creation using V3.1 tool=========", + stderr="", + ), + ) + @mock.patch( + "sentinel_xdr_migration.packaging.shutil.which", + return_value=r"C:\Program Files\PowerShell\7\pwsh.exe", + ) + def test_partial_selection_outcome_is_persisted_in_workflow_artifacts( + self, + _which: mock.Mock, + _run: mock.Mock, + ) -> None: + selection_plan = { + "planSha256": "d" * 64, + "summary": {"outcome": "partial"}, + } + applied_selection = { + "plan": selection_plan, + "planPath": "Reports/Selection/package-selection-plan.json", + "receiptPath": "Reports/Selection/package-selection-applied.json", + } + with ( + mock.patch( + "sentinel_xdr_migration.packaging_selection.verify_applied_package_selection", + return_value=applied_selection, + ), + mock.patch( + "sentinel_xdr_migration.packaging._verify_package_selection", + return_value={"status": "verified"}, + ), + ): + result = package_solution_v3_1( + self.solution, + version_bump="none", + workflow_run_id="synthetic-package-run", + selection_plan=selection_plan, + ) + + expected = "partial-package-validated" + self.assertEqual(expected, result["selectionOutcome"]) + self.assertEqual( + expected, + result["workflowArtifacts"]["selectionOutcome"], + ) + report = json.loads(Path(result["packageReport"]).read_text(encoding="utf-8")) + self.assertEqual(expected, report["workflowArtifacts"]["selectionOutcome"]) + + @mock.patch( + "sentinel_xdr_migration.packaging.subprocess.run", + return_value=CompletedProcess( + args=[], + returncode=0, + stdout="=======Starting Package Creation using V3.1 tool=========", + stderr="", + ), + ) + @mock.patch( + "sentinel_xdr_migration.packaging.shutil.which", + return_value=r"C:\Program Files\PowerShell\7\pwsh.exe", + ) + def test_cross_root_uses_toolkit_and_keeps_outputs_in_solution( + self, + which: mock.Mock, + run: mock.Mock, + ) -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as target_directory: + target_root = Path(target_directory) + target_solution = target_root / "Solutions" / "Sample" + target_solution.parent.mkdir() + shutil.copytree(self.solution, target_solution) + self.assertFalse((target_root / "Tools").exists()) + + result = package_solution_v3_1( + target_solution, + version_bump="none", + toolkit_root=self.repository, + ) + + command = run.call_args.args[0] + self.assertIn(str(self.repository / V31_TOOLKIT_PATHS[0]), command) + self.assertEqual(str(self.repository), command[command.index("-ToolkitRootPath") + 1]) + self.assertEqual(self.repository, Path(result["toolkitRoot"])) + self.assertEqual(target_solution, Path(result["solution"])) + self.assertTrue(Path(result["packageReport"]).is_relative_to(target_solution)) + self.assertTrue(Path(result["packageReport"]).is_file()) + for key in ("mainTemplate", "createUiDefinition", "testParameters", "zip"): + self.assertTrue(Path(result[key]).is_relative_to(target_solution)) + self.assertFalse((target_root / "Tools").exists()) + self.assertEqual(target_root, run.call_args.kwargs["cwd"]) + report = json.loads(Path(result["packageReport"]).read_text(encoding="utf-8")) + self.assertEqual(str(self.repository), report["toolkitRoot"]) + which.assert_called_once_with("pwsh") + + @unittest.skipUnless( + shutil.which("pwsh") and shutil.which("git"), + "PowerShell 7 and git are required", + ) + def test_cli_runs_real_packager_for_same_and_cross_worktree_roots(self) -> None: + source_root = Path(__file__).resolve().parents[3] + with tempfile.TemporaryDirectory(dir=Path.cwd()) as scratch: + scratch_root = Path(scratch) + module_root = scratch_root / "modules" + module_dir = module_root / "ArmTtk" + module_dir.mkdir(parents=True) + (module_dir / "ArmTtk.psm1").write_text( + "function Test-AzTemplate { param([string]$TemplatePath) @() }; " + "Export-ModuleMember -Function Test-AzTemplate\n", + encoding="utf-8", + ) + module_path = str(module_root) + os.pathsep + os.environ.get("PSModulePath", "") + with mock.patch.dict(os.environ, {"PSModulePath": module_path}): + for mode in ("same-root", "cross-root"): + target_root = scratch_root / mode / "target" + target_root.mkdir(parents=True) + solution = target_root / "Solutions" / "Sample" + data = solution / "Data" + data.mkdir(parents=True) + (solution / "Package").mkdir() + (data / "Solution_Sample.json").write_text(json.dumps({ + "Name": "Sample", + "Author": "Packaging Smoke Test - test@example.invalid", + "Version": "3.1.0", + "Metadata": "SolutionMetadata.json", + "Description": "Synthetic V3.1 packaging smoke test.", + "XDR Detections": [], + }), encoding="utf-8") + (solution / "SolutionMetadata.json").write_text(json.dumps({ + "name": "Sample", + "version": "3.1.0", + "trackingId": "pid-cross-worktree-smoke-test", + "support": { + "name": "Synthetic Test", + "email": "test@example.invalid", + "tier": "Community", + }, + "categories": {"domains": [], "verticals": []}, + }), encoding="utf-8") + subprocess.run( + ["git", "init", "--quiet", str(target_root)], + check=True, + ) + + if mode == "same-root": + toolkit = target_root + for relative in V31_TOOLKIT_PATHS: + destination = toolkit / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_root / relative, destination) + toolkit_arg = None + else: + toolkit = source_root + toolkit_arg = toolkit + self.assertFalse((target_root / "Tools").exists()) + + initialized = initialize_workflow( + solution, + workflow_profile="authoring", + version_bump="none", + ) + for stage in ("discovery", "conversion", "validation"): + start_workflow_stage(solution, stage) + if stage == "validation": + complete_workflow_stage( + solution, + stage, + status="passed", + message="Synthetic package smoke; runtime validation not run.", + artifacts={"runtimeStatus": "environment-blocked"}, + ) + else: + complete_workflow_stage(solution, stage, status="passed") + + args = [ + "package-v3-1", + "--solution", str(solution), + "--run-id", initialized["runId"], + "--version-bump", "none", + ] + if toolkit_arg is not None: + args.extend(["--toolkit-root", str(toolkit_arg)]) + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + self.assertEqual(0, cli_module.main(args)) + + reports = list(solution.rglob("packaging.v3_1.json")) + self.assertEqual(1, len(reports)) + report = json.loads(reports[0].read_text(encoding="utf-8")) + self.assertEqual(str(toolkit.resolve()), report["toolkitRoot"]) + self.assertEqual( + str((toolkit / V31_TOOLKIT_PATHS[0]).resolve()), + report["packagerScript"], + ) + for name in ( + "mainTemplate", "createUiDefinition", "testParameters", "zip", + ): + artifact = Path(report[name]) + if not artifact.is_absolute(): + artifact = solution / artifact + self.assertTrue(artifact.is_relative_to(solution), str(artifact)) + self.assertTrue(artifact.is_file(), str(artifact)) + if mode == "cross-root": + self.assertFalse((target_root / "Tools").exists()) + + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + def test_rejects_incomplete_toolkit_before_invoking_packager( + self, + which: mock.Mock, + run: mock.Mock, + ) -> None: + data_file = self.solution / "Data" / "Solution_Sample.json" + before = json.loads(data_file.read_text(encoding="utf-8"))["Version"] + + with self.assertRaisesRegex(ValueError, "toolkit root is incomplete"): + package_solution_v3_1( + self.solution, + version_bump="minor", + toolkit_root=self.solution, + ) + + self.assertEqual(before, json.loads(data_file.read_text(encoding="utf-8"))["Version"]) + run.assert_not_called() + which.assert_not_called() + + def test_input_identity_excludes_generated_outputs_and_tracks_content(self) -> None: + before = package_input_identity(self.solution)["sha256"] + (self.solution / "Logs").mkdir() + (self.solution / "Logs" / "runtime.json").write_text("{}", encoding="utf-8") + (self.solution / "Package" / "stale.zip").write_text("old", encoding="utf-8") + self.assertEqual(before, package_input_identity(self.solution)["sha256"]) + + detection = self.solution / "XDR Detections" / "Detection.yaml" + detection.write_text("kind: CustomDetection\nname: changed\n", encoding="utf-8") + self.assertNotEqual(before, package_input_identity(self.solution)["sha256"]) + + def test_rejects_missing_xdr_reference_before_packaging(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + document = json.loads(data.read_text(encoding="utf-8")) + document["XDR Detections"] = ["XDR Detections/Missing.yaml"] + data.write_text(json.dumps(document), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "does not exist"): + package_solution_v3_1(self.solution, version_bump="none") + + def test_rejects_logs_as_detection_content(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + document = json.loads(data.read_text()) + document["XDR Detections"] = ["Logs/sentinel-xdr-migration/config.yaml"] + data.write_text(json.dumps(document)) + with self.assertRaisesRegex(ValueError, "never include Logs"): + package_solution_v3_1(self.solution, version_bump="none") + + def test_rejects_evidence_in_any_content_array(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + document = json.loads(data.read_text()) + document["Playbooks"] = ["Evidence/run/evidence.json"] + data.write_text(json.dumps(document)) + with self.assertRaisesRegex(ValueError, "Evidence"): + package_solution_v3_1(self.solution, version_bump="none") + + def test_rejects_local_env_in_any_content_array(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + document = json.loads(data.read_text()) + document["Playbooks"] = [".env"] + data.write_text(json.dumps(document)) + with self.assertRaisesRegex(ValueError, "local .env configuration"): + package_solution_v3_1(self.solution, version_bump="none") + + def test_rejects_unsafe_general_content_paths_before_powershell(self) -> None: + data_file = self.solution / "Data" / "Solution_Sample.json" + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"PATH_TRAVERSAL_FIXTURE_ONLY"}', encoding="utf-8") + references = ( + "../harmless-synthetic.json", + r"..\harmless-synthetic.json", + r"Workbooks\..\..\harmless-synthetic.json", + "/var/harmless-synthetic.json", + r"C:\synthetic\harmless-synthetic.json", + r"\\synthetic-host\share\harmless-synthetic.json", + "https://example.invalid/synthetic.json", + "file:///synthetic.json", + ) + with ( + mock.patch("sentinel_xdr_migration.packaging.subprocess.run") as run, + mock.patch("sentinel_xdr_migration.packaging.shutil.which") as which, + ): + for reference in references: + with self.subTest(reference=reference): + document = json.loads(data_file.read_text(encoding="utf-8")) + document["Workbooks"] = [reference] + data_file.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaises(ValueError): + package_solution_v3_1(self.solution, version_bump="none") + run.assert_not_called() + which.assert_not_called() + + with zipfile.ZipFile(self.solution / "Package" / "1.2.3.zip") as archive: + self.assertNotIn( + b"PATH_TRAVERSAL_FIXTURE_ONLY", + b"".join(archive.read(name) for name in archive.namelist()), + ) + + def test_rejects_path_like_solution_name_and_metadata_before_powershell(self) -> None: + data_file = self.solution / "Data" / "Solution_Sample.json" + original = json.loads(data_file.read_text(encoding="utf-8")) + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"METADATA_FIXTURE_ONLY"}', encoding="utf-8") + with ( + mock.patch("sentinel_xdr_migration.packaging.subprocess.run") as run, + mock.patch("sentinel_xdr_migration.packaging.shutil.which") as which, + ): + for field, values in ( + ("Name", ("../Sample", r"..\Sample", "/Sample", r"C:\Sample", "https://x.invalid/Sample")), + ("Metadata", ("../harmless-synthetic.json", r"..\harmless-synthetic.json", + "/var/harmless-synthetic.json", r"C:\synthetic\metadata.json", + r"\\synthetic-host\share\metadata.json", + "https://example.invalid/metadata.json")), + ): + for value in values: + with self.subTest(field=field, value=value): + document = dict(original) + document[field] = value + data_file.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaises(ValueError): + package_solution_v3_1(self.solution, version_bump="none") + run.assert_not_called() + which.assert_not_called() + + with zipfile.ZipFile(self.solution / "Package" / "1.2.3.zip") as archive: + self.assertNotIn( + b"METADATA_FIXTURE_ONLY", + b"".join(archive.read(name) for name in archive.namelist()), + ) + + def test_accepts_contained_family_and_repository_relative_paths(self) -> None: + workbooks = self.solution / "Workbooks" + workbooks.mkdir() + (workbooks / "Workbook With Spaces.json").write_text( + '{"fixture":"contained"}', encoding="utf-8", + ) + metadata = self.solution / "SolutionMetadata.json" + metadata.write_text('{"name":"Sample"}', encoding="utf-8") + references = ( + "Workbooks/Workbook With Spaces.json", + "Solutions/Sample/Workbooks/Workbook With Spaces.json", + r"Solutions\Sample\Workbooks\Workbook With Spaces.json", + ) + for reference in references: + with self.subTest(reference=reference): + _validate_solution_package_inputs(self.solution, { + "Name": "Sample", + "Metadata": "Solutions/Sample/SolutionMetadata.json", + "Workbooks": [reference], + "DependentDomainSolutionIds": ["synthetic-id"], + }) + + def test_rejects_symlink_content_escape(self) -> None: + workbooks = self.solution / "Workbooks" + workbooks.mkdir() + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"SYMLINK_FIXTURE_ONLY"}', encoding="utf-8") + link = workbooks / "outside.json" + try: + link.symlink_to(outside) + except OSError as exc: + self.skipTest(f"symlink creation is unavailable: {exc}") + with self.assertRaisesRegex(ValueError, "symlink|escapes its root"): + _validate_solution_package_inputs(self.solution, { + "Name": "Sample", + "Workbooks": ["Workbooks/outside.json"], + }) + + def test_rejects_symlink_solution_data_file_before_reading(self) -> None: + data_file = self.solution / "Data" / "Solution_Sample.json" + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"DATA_SYMLINK_FIXTURE_ONLY"}', encoding="utf-8") + data_file.unlink() + try: + data_file.symlink_to(outside) + except OSError as exc: + self.skipTest(f"symlink creation is unavailable: {exc}") + with ( + mock.patch("sentinel_xdr_migration.packaging.subprocess.run") as run, + mock.patch("sentinel_xdr_migration.packaging.shutil.which") as which, + ): + with self.assertRaisesRegex(ValueError, "escapes its root|symlink"): + package_solution_v3_1(self.solution, version_bump="none") + run.assert_not_called() + which.assert_not_called() + + def test_validates_json_encoded_and_malformed_content_arrays(self) -> None: + connectors = self.solution / "Data Connectors" + connectors.mkdir() + (connectors / "definition[1].json").write_text( + '{"fixture":"CONTAINED_CONNECTOR_ONLY"}', encoding="utf-8", + ) + allowed = { + "Name": "Sample", + "Data Connectors": json.dumps([ + "Data Connectors/definition[1].json", + ]), + } + _validate_solution_package_inputs(self.solution, allowed) + self.assertEqual( + ["Data Connectors/definition[1].json"], + allowed["Data Connectors"], + ) + _validate_solution_package_inputs(self.solution, { + "Name": "Sample", + "Data Connectors": json.dumps([ + "Solutions/Sample/Data Connectors/definition[1].json", + ]), + }) + + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"ENCODED_ESCAPE_FIXTURE_ONLY"}', encoding="utf-8") + data_file = self.solution / "Data" / "Solution_Sample.json" + original = json.loads(data_file.read_text(encoding="utf-8")) + invalid_values = ( + "../../harmless-synthetic.json", + r"..\harmless-synthetic.json", + "/var/harmless-synthetic.json", + r"C:\synthetic\harmless-synthetic.json", + r"\\synthetic-host\share\harmless-synthetic.json", + "https://example.invalid/synthetic.json", + ) + with ( + mock.patch("sentinel_xdr_migration.packaging.subprocess.run") as run, + mock.patch("sentinel_xdr_migration.packaging.shutil.which") as which, + ): + for value in invalid_values: + with self.subTest(value=value): + document = dict(original) + document["Data Connectors"] = json.dumps([value]) + data_file.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaises(ValueError): + package_solution_v3_1(self.solution, version_bump="none") + for malformed in ( + "{not-json", + json.dumps("not-an-array"), + json.dumps(["Data Connectors/definition[1].json", 7]), + ): + with self.subTest(malformed=malformed): + document = dict(original) + document["Data Connectors"] = malformed + data_file.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "JSON-encoded content array"): + package_solution_v3_1(self.solution, version_bump="none") + document = dict(original) + document["Data Connectors"] = ["Data Connectors/definition[1].json", 7] + data_file.write_text(json.dumps(document), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "invalid local path reference"): + package_solution_v3_1(self.solution, version_bump="none") + run.assert_not_called() + which.assert_not_called() + + with zipfile.ZipFile(self.solution / "Package" / "1.2.3.zip") as archive: + self.assertNotIn( + b"ENCODED_ESCAPE_FIXTURE_ONLY", + b"".join(archive.read(name) for name in archive.namelist()), + ) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") + def test_powershell_ccp_reader_validates_encoded_arrays_and_literal_paths(self) -> None: + common = Path(__file__).resolve().parents[3] / ( + "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1" + ) + ccp_script = common.with_name("get-ccp-details.ps1") + connectors = self.solution / "Data Connectors" + connectors.mkdir() + exact_file = connectors / "definition[1].json" + exact_file.write_text( + '{"fixture":"CONTAINED_CONNECTOR_ONLY"}', encoding="utf-8", + ) + (connectors / "definition1.json").write_text( + '{"fixture":"WILDCARD_WRONG_TARGET"}', encoding="utf-8", + ) + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"ENCODED_ESCAPE_FIXTURE_ONLY"}', encoding="utf-8") + common_literal = str(common).replace("'", "''") + ccp_literal = str(ccp_script).replace("'", "''") + exact_literal = str(exact_file).replace("'", "''") + solutions_literal = str(self.solution.parent).replace("'", "''") + + direct_reader = subprocess.run( + [ + "pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + f". '{common_literal}'; " + f"$result = ReadFileContent -filePath '{exact_literal}'; " + "if ($result.fixture -cne 'CONTAINED_CONNECTOR_ONLY') { throw 'wildcard read mismatch' }; " + "Write-Output 'DIRECT_READ_OK'", + ], + check=False, capture_output=True, text=True, + ) + self.assertEqual(0, direct_reader.returncode, direct_reader.stderr) + self.assertIn("DIRECT_READ_OK", direct_reader.stdout) + + def invoke_ccp(encoded: str) -> subprocess.CompletedProcess[str]: + escaped = encoded.replace("'", "''") + command = ( + f". '{common_literal}'; . '{ccp_literal}'; " + f"$data = [pscustomobject]@{{ 'Data Connectors' = '{escaped}' }}; " + f"$null = Get-CCP-Dict -dataFileMetadata $data " + f"-baseFolderPath '{solutions_literal}' -solutionName 'Sample' " + "-DCFolderName 'Data Connectors'; Write-Output 'CCP_READ_OK'" + ) + return subprocess.run( + ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], + check=False, capture_output=True, text=True, + ) + + for reference in ( + "Data Connectors/definition[1].json", + "Solutions/Sample/Data Connectors/definition[1].json", + ): + with self.subTest(reference=reference): + valid = invoke_ccp(json.dumps([reference])) + self.assertEqual(0, valid.returncode, valid.stderr) + self.assertIn("CCP_READ_OK", valid.stdout) + + for invalid in ( + json.dumps(["../../harmless-synthetic.json"]), + json.dumps([r"..\harmless-synthetic.json"]), + "{malformed-json", + json.dumps("not-an-array"), + json.dumps(["Data Connectors/definition[1].json", 7]), + ): + with self.subTest(invalid=invalid): + denied = invoke_ccp(invalid) + self.assertNotEqual(0, denied.returncode) + self.assertNotIn( + "ENCODED_ESCAPE_FIXTURE_ONLY", + denied.stdout + denied.stderr, + ) + native_mixed = ( + f". '{common_literal}'; . '{ccp_literal}'; " + "$data = [pscustomobject]@{ 'Data Connectors' = @('Data Connectors/definition[1].json', 7) }; " + f"Get-CCP-Dict -dataFileMetadata $data -baseFolderPath '{solutions_literal}' " + "-solutionName 'Sample' -DCFolderName 'Data Connectors'" + ) + denied_mixed = subprocess.run( + ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", native_mixed], + check=False, capture_output=True, text=True, + ) + self.assertNotEqual(0, denied_mixed.returncode) + self.assertNotIn( + "ENCODED_ESCAPE_FIXTURE_ONLY", + denied_mixed.stdout + denied_mixed.stderr, + ) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") + def test_powershell_contained_reader_rejects_escape_and_accepts_approved_forms(self) -> None: + common = Path(__file__).resolve().parents[3] / ( + "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1" + ) + root = self.solution + (root / "Workbooks").mkdir() + allowed = root / "Workbooks" / "Workbook With Spaces.json" + allowed.write_text('{"fixture":"contained"}', encoding="utf-8") + outside = self.repository / "harmless-synthetic.json" + outside.write_text('{"fixture":"POWERSHELL_OUTSIDE_FIXTURE_ONLY"}', encoding="utf-8") + common_literal = str(common).replace("'", "''") + root_literal = str(root).replace("'", "''") + outside_literal = str(outside).replace("'", "''") + deny = subprocess.run( + [ + "pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + f". '{common_literal}'; " + f"$result = Resolve-SolutionContentPath -SolutionRoot '{root_literal}' " + f"-SolutionName 'Sample' -ContentPath '../{outside.name}' -RequireFile; " + "Get-Content -LiteralPath $result.FullPath -Raw", + ], + check=False, capture_output=True, text=True, + ) + self.assertNotEqual(0, deny.returncode) + self.assertNotIn("POWERSHELL_OUTSIDE_FIXTURE_ONLY", deny.stdout + deny.stderr) + invalid_name = subprocess.run( + [ + "pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + f". '{common_literal}'; " + "Assert-SolutionName -SolutionName '../Sample' -SelectedSolutionName 'Sample'; " + f"Get-Content -LiteralPath '{outside_literal}' -Raw", + ], + check=False, capture_output=True, text=True, + ) + self.assertNotEqual(0, invalid_name.returncode) + self.assertNotIn("POWERSHELL_OUTSIDE_FIXTURE_ONLY", invalid_name.stdout + invalid_name.stderr) + symlink = root / "Workbooks" / "outside.json" + try: + symlink.symlink_to(outside) + except OSError: + symlink = None + if symlink is not None: + symlink_result = subprocess.run( + [ + "pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + f". '{common_literal}'; " + f"$result = Resolve-SolutionContentPath -SolutionRoot '{root_literal}' " + "-SolutionName 'Sample' -ContentPath 'Workbooks/outside.json' -RequireFile; " + "Get-Content -LiteralPath $result.FullPath -Raw", + ], + check=False, capture_output=True, text=True, + ) + self.assertNotEqual(0, symlink_result.returncode) + self.assertNotIn( + "POWERSHELL_OUTSIDE_FIXTURE_ONLY", + symlink_result.stdout + symlink_result.stderr, + ) + scan_result = subprocess.run( + [ + "pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + f". '{common_literal}'; " + f"Assert-NoSolutionContentSymlinks -SolutionRoot '{root_literal}'", + ], + check=False, capture_output=True, text=True, + ) + self.assertNotEqual(0, scan_result.returncode) + self.assertIn("symlink", scan_result.stderr.lower()) + + references = ( + "Workbooks/Workbook With Spaces.json", + "Solutions/Sample/Workbooks/Workbook With Spaces.json", + r"Solutions\Sample\Workbooks\Workbook With Spaces.json", + ) + for reference in references: + escaped_reference = reference.replace("'", "''") + with self.subTest(reference=reference): + allowed_result = subprocess.run( + [ + "pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + f". '{common_literal}'; " + f"$result = Resolve-SolutionContentPath -SolutionRoot '{root_literal}' " + f"-SolutionName 'Sample' -ContentPath '{escaped_reference}' -RequireFile; " + "Get-Content -LiteralPath $result.FullPath -Raw", + ], + check=False, capture_output=True, text=True, + ) + self.assertEqual(0, allowed_result.returncode, allowed_result.stderr) + self.assertIn('"fixture":"contained"', allowed_result.stdout) + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_rejects_evidence_in_marketplace_zip(self, run, which) -> None: + run.return_value = CompletedProcess([], 0, "Starting Package Creation using V3.1 tool", "") + with zipfile.ZipFile(self.solution / "Package" / "1.2.3.zip", "a") as archive: + archive.writestr("eViDeNcE/run/evidence.json", "{}") + with self.assertRaisesRegex(RuntimeError, "Evidence"): + package_solution_v3_1(self.solution, version_bump="none") + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_rejects_runtime_logs_in_marketplace_zip(self, run, which) -> None: + run.return_value = CompletedProcess([], 0, "Starting Package Creation using V3.1 tool", "") + with zipfile.ZipFile(self.solution / "Package" / "1.2.3.zip", "a") as archive: + archive.writestr("Logs/sentinel-xdr-migration/workflow-state.json", "{}") + with self.assertRaisesRegex(RuntimeError, "must not contain Logs"): + package_solution_v3_1(self.solution, version_bump="none") + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_rejects_local_env_in_marketplace_zip(self, run, which) -> None: + run.return_value = CompletedProcess([], 0, "Starting Package Creation using V3.1 tool", "") + with zipfile.ZipFile(self.solution / "Package" / "1.2.3.zip", "a") as archive: + archive.writestr("configuration/.env", "PRIVATE_ENV_SENTINEL") + with self.assertRaisesRegex(RuntimeError, "must not contain local .env"): + package_solution_v3_1(self.solution, version_bump="none") + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_old_entry_point_output_is_not_relabelled(self, run, which) -> None: + run.return_value = CompletedProcess( + [], 0, "Starting Package Creation using V4 tool", "" + ) + with self.assertRaisesRegex(RuntimeError, "did not confirm the V3.1"): + package_solution_v3_1(self.solution, version_bump="none") + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_attribution_warning_is_visible_and_persisted(self, run, which) -> None: + warning = "WARNING: CUSTOMER USAGE ATTRIBUTION: No attribution marker." + run.return_value = CompletedProcess( + [], 0, f"Starting Package Creation using V3.1 tool\n{warning}", "" + ) + with mock.patch("sys.stderr") as stderr: + result = package_solution_v3_1(self.solution, version_bump="none") + self.assertEqual([warning], result["warnings"]) + self.assertTrue(stderr.write.called) + self.assertEqual( + [warning], json.loads(Path(result["packageReport"]).read_text())["warnings"] + ) + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_failure_does_not_accept_old_artifacts(self, run, which) -> None: + run.return_value = CompletedProcess([], 1, "", "Invalid trackingId") + with self.assertRaisesRegex(RuntimeError, "Invalid trackingId"): + package_solution_v3_1(self.solution, version_bump="none") + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_attribution_source_is_reported_separately_from_warnings(self, run, which) -> None: + source = ( + "CUSTOMER USAGE ATTRIBUTION SOURCE: publisherId=test; offerId=sentinel; " + "planId=plan; templateURL=https://catalogartifact.azureedge.net/publicartifacts/test.json; " + "trackingId=pid-test-partnercenter; updated metadata=SolutionMetadata.json" + ) + run.return_value = CompletedProcess( + [], 0, f"Starting Package Creation using V3.1 tool\n{source}", "" + ) + with mock.patch("sys.stderr") as stderr: + result = package_solution_v3_1(self.solution, version_bump="none") + self.assertEqual([], result["warnings"]) + self.assertEqual([source], result["attributionSources"]) + self.assertTrue(stderr.write.called) + self.assertEqual( + [source], json.loads(Path(result["packageReport"]).read_text())["attributionSources"] + ) + + @mock.patch("sentinel_xdr_migration.packaging.shutil.which", return_value="pwsh") + @mock.patch("sentinel_xdr_migration.packaging.subprocess.run") + def test_tactic_projection_is_information_and_registration_remains_warning(self, run, which) -> None: + warnings = [ + "WARNING: V3.1 CONTENT SELECTION: live provider registration support remains unverified.", + ] + projection = { + "detectionId": "11111111-2222-3333-4444-555555555555", + "authoredTactics": ["Execution", "Persistence"], + "packagedTactics": ["Execution"], + "omittedTactics": ["Persistence"], + } + information = ( + "INFORMATION: V3.1 TACTIC SELECTION: Custom Detection " + "'11111111-2222-3333-4444-555555555555' packages 'Execution'; omitted tactics: Persistence." + ) + run.return_value = CompletedProcess( + [], + 0, + "Starting Package Creation using V3.1 tool\n" + + "\n".join(warnings) + + "\n" + + information + + "\nV3.1 TACTIC PROJECTION: " + + json.dumps(projection), + "", + ) + with mock.patch("sys.stderr") as stderr: + result = package_solution_v3_1(self.solution, version_bump="none") + self.assertTrue(stderr.write.called) + self.assertEqual(warnings, result["warnings"]) + self.assertEqual([information], result["informational"]) + self.assertEqual([projection], result["tacticProjection"]) + saved = json.loads(Path(result["packageReport"]).read_text()) + self.assertEqual(warnings, saved["warnings"]) + self.assertEqual([information], saved["informational"]) + self.assertEqual([projection], saved["tacticProjection"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_packaging_selection.py b/Tools/SentinelToXDRMigration/tests/test_packaging_selection.py new file mode 100644 index 00000000000..c1ed847d67f --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_packaging_selection.py @@ -0,0 +1,1628 @@ +from __future__ import annotations + +import copy +import hashlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration.artifacts import report_directory, using_run, write_json_artifact +from sentinel_xdr_migration.packaging_selection import ( + PLAN_NAME, + _build_package_selection_plan, + _analytic_rule_structure_errors, + _content_reference, + _current_target, + _data_context_sha256, + _manifest_reference, + _run_context, + _runtime_target_fingerprint, + _runtime_binding, + _runtime_family, + _review_evidence_sha256, + _source_provider_route_errors, + _selection_arrays, + _selection_plan_hash, + _atomic_exchange_if_unchanged, + _file_snapshot, + _exchange_paths, + _write_selection_receipt, + apply_package_selection, + verify_applied_package_selection, +) +from sentinel_xdr_migration import runtime_consent +from sentinel_xdr_migration.workflow import initialize_workflow +from sentinel_xdr_migration import cli as cli_module + + +class PackagingSelectionTests(unittest.TestCase): + def test_source_runtime_selection_requires_canonical_verified_fallback(self) -> None: + from sentinel_xdr_migration.artifacts import report_directory + + with using_run(self.solution, self.run_id): + reports = report_directory(self.solution, create=True) + (reports / "runtime-validation.triage-mcp.json").write_text( + json.dumps({ + "provider": "triage-mcp", + "providerFailure": {"failureKind": "provider-unavailable"}, + }), + encoding="utf-8", + ) + sdk_path = reports / "runtime-validation.azure-monitor-sdk.json" + sdk_report = { + "provider": "azure-monitor-sdk", + "providerFallback": { + "mcpProvider": "triage-mcp", + "fallbackProvider": "azure-monitor-sdk", + "batchComplete": True, + "permissionsVerified": True, + "kqlError": False, + }, + } + sdk_path.write_text(json.dumps(sdk_report), encoding="utf-8") + + self.assertEqual( + [], + _source_provider_route_errors(reports, "azure-monitor-sdk"), + ) + sdk_report["providerFallback"]["batchComplete"] = False + sdk_path.write_text(json.dumps(sdk_report), encoding="utf-8") + self.assertTrue( + _source_provider_route_errors( + reports, "azure-monitor-sdk" + ) + ) + sdk_report["providerFallback"] = True + sdk_path.write_text(json.dumps(sdk_report), encoding="utf-8") + self.assertTrue( + _source_provider_route_errors( + reports, "azure-monitor-sdk" + ) + ) + + def test_selection_plan_blocks_unverified_global_provider_fallback(self) -> None: + invalid = self._build_one_rule_plan( + source_status="passed", + review_required=False, + source_fallback_verified=False, + ) + self.assertEqual("blocked", invalid["summary"]["outcome"]) + self.assertEqual("failed", invalid["providers"]["sentinel"]["routeStatus"]) + self.assertTrue(any( + "canonical verified Triage fallback proof" in error + for error in invalid["executionErrors"] + )) + + valid = self._build_one_rule_plan( + source_status="passed", + review_required=False, + source_fallback_verified=True, + ) + self.assertEqual("complete", valid["summary"]["outcome"]) + self.assertEqual("passed", valid["providers"]["sentinel"]["routeStatus"]) + + excluded_item = self._build_one_rule_plan( + source_status="passed", + review_required=True, + source_fallback_verified=True, + ) + self.assertFalse(excluded_item["xdrDetections"][0]["included"]) + self.assertEqual([], excluded_item["executionErrors"]) + self.assertEqual( + "passed", excluded_item["providers"]["sentinel"]["routeStatus"] + ) + + def test_runtime_families_require_exact_result_inventory(self) -> None: + from sentinel_xdr_migration.content_paths import detection_reference + + names = ["A.yaml", "B.yaml", "C.yaml"] + detection_paths = {} + for name in names: + path = self.solution / "XDR Detections" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("kind: CustomDetection\nproperties: {}\n", encoding="utf-8") + detection_paths[name] = path + self.assertEqual( + "C.yaml", + detection_reference("C.yaml", names), + ) + query_hash = "a" * 64 + + def entries(provider: str) -> list[dict]: + result = [] + for name in names: + item = { + "detection": name, + "status": "passed", + "querySha256": query_hash, + } + if provider == "graph": + item["schema"] = [{"name": "Column", "type": "string"}] + result.append(item) + return result + + with using_run(self.solution, self.run_id): + run_directory = report_directory(self.solution, create=True) + state = { + "context": {"workflowProfile": "authoring"}, + "stages": {"validation": {"artifacts": {}}}, + } + for provider, surface in ( + ("azure-monitor-sdk", "log-analytics-workspace"), + ("graph", "defender-advanced-hunting"), + ): + report_path = run_directory / f"runtime-validation.{provider}.json" + valid_entries = entries(provider) + + def assess(results, *, family_complete=True): + report_path.write_text( + json.dumps({ + "provider": provider, + "querySurface": surface, + "familyComplete": family_complete, + "results": results, + }), + encoding="utf-8", + ) + with ( + mock.patch( + "sentinel_xdr_migration.packaging_selection._runtime_binding", + return_value=(True, "provider-target-binding-current"), + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection.current_runtime_query_hash", + return_value=query_hash, + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection.custom_detail_schema_errors", + return_value=[], + ), + ): + return _runtime_family( + self.solution, + run_directory, + state, + provider=provider, + detection_paths=detection_paths, + target_fingerprint="f" * 64, + ) + + missing = valid_entries[:-1] + missing_evidence, missing_provider, missing_errors = assess(missing) + self.assertTrue(any("missing expected detection results" in e for e in missing_errors)) + self.assertEqual("error", missing_provider["reportStatus"]) + + failed = [dict(item) for item in valid_entries] + failed[-1]["status"] = "failed" + failed_evidence, failed_provider, failed_errors = assess(failed) + self.assertEqual([], failed_errors) + self.assertEqual("available", failed_provider["reportStatus"]) + self.assertEqual("failed", failed_evidence["C.yaml"]["status"]) + + duplicate = valid_entries + [{ + **valid_entries[-1], + "detection": "XDR Detections/C.yaml", + }] + _, _, duplicate_errors = assess(duplicate) + self.assertTrue(any("duplicate results" in e for e in duplicate_errors)) + + unknown = valid_entries + [{ + "detection": "Unknown.yaml", + "status": "passed", + "querySha256": query_hash, + }] + _, _, unknown_errors = assess(unknown) + self.assertTrue(any("unknown or ambiguous" in e for e in unknown_errors)) + + _, _, incomplete_errors = assess(valid_entries, family_complete=False) + self.assertTrue(any("family incomplete" in e for e in incomplete_errors)) + + _, _, malformed_errors = assess([*valid_entries, None]) + self.assertTrue(any("malformed result" in e for e in malformed_errors)) + + _, _, empty_errors = assess(None) + self.assertTrue(any("no results inventory" in e for e in empty_errors)) + self.assertTrue(any("missing expected detection results" in e for e in empty_errors)) + + def test_lab_consent_change_preserves_persisted_runtime_plan_binding(self) -> None: + target_fingerprint = "f" * 64 + plan = { + "schemaVersion": runtime_consent.PLAN_SCHEMA_VERSION, + "status": "ready", + "solution": ".", + "runId": self.run_id, + "workflowProfile": "qualification", + "target": {"fingerprintSha256": target_fingerprint}, + "labReadConsent": { + "scope": { + "targetFingerprintSha256": target_fingerprint, + "confirmedPrimarySetting": { + "name": "primary-workspace", + "value": "synthetic-primary-workspace", + }, + }, + "status": "not-approved", + "approvedFamilies": [], + "requiresExplicitApproval": True, + "recordedAt": None, + "latestDecision": {"status": "unanswered"}, + }, + "families": { + "defenderXdrQueryFamily": { + "provider": {"provider": "graph"}, + "familySha256": "a" * 64, + }, + }, + } + plan["planSha256"] = runtime_consent._runtime_query_plan_sha256(plan) + with using_run(self.solution, self.run_id): + run_directory = report_directory(self.solution, create=True) + plan_path = run_directory / "runtime-query-plan.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + + approved_plan = copy.deepcopy(plan) + approved_plan["labReadConsent"].update({ + "status": "approved", + "approvedFamilies": ["defenderXdrQueryFamily"], + "requiresExplicitApproval": False, + "recordedAt": "synthetic-approval-time", + "latestDecision": {"status": "approved"}, + }) + self.assertEqual( + plan["planSha256"], + runtime_consent._runtime_query_plan_sha256(approved_plan), + ) + changed_scope = copy.deepcopy(approved_plan) + changed_scope["labReadConsent"]["scope"]["confirmedPrimarySetting"][ + "value" + ] = "changed-primary-workspace" + self.assertNotEqual( + plan["planSha256"], + runtime_consent._runtime_query_plan_sha256(changed_scope), + ) + changed_inventory = copy.deepcopy(approved_plan) + changed_inventory["families"]["defenderXdrQueryFamily"][ + "familySha256" + ] = "b" * 64 + self.assertNotEqual( + plan["planSha256"], + runtime_consent._runtime_query_plan_sha256(changed_inventory), + ) + + binding = { + "runId": self.run_id, + "workflowProfile": "qualification", + "solution": ".", + "targetFingerprintSha256": target_fingerprint, + } + snapshot = { + "status": "derived-from-lab-consent", + "provider": "graph", + "family": "defenderXdrQueryFamily", + "planSha256": runtime_consent._runtime_query_plan_sha256(approved_plan), + "binding": binding, + } + report = { + "provider": "graph", + "runtimeQueryConsent": snapshot, + "runtimeQueryDispatchSnapshot": snapshot, + } + state = {"context": {"workflowProfile": "qualification"}} + bound, reason = _runtime_binding( + self.solution, + run_directory, + state, + provider="graph", + report=report, + target_fingerprint=target_fingerprint, + ) + + self.assertTrue(bound, reason) + persisted = json.loads(plan_path.read_text(encoding="utf-8")) + self.assertEqual("not-approved", persisted["labReadConsent"]["status"]) + self.assertEqual(plan["planSha256"], persisted["planSha256"]) + + def test_coverage_threshold_cannot_waive_global_family_completeness(self) -> None: + from sentinel_xdr_migration.packaging_selection import _coverage_summary + + inventory = [ + {"id": f"00000000-0000-0000-0000-{index:012d}"} + for index in (1, 2, 3) + ] + threshold = _coverage_summary( + inventory, + {item["id"] for item in inventory[:2]}, + ) + self.assertTrue(threshold["partialPublicationAllowed"]) + + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + global_runtime_inventory_error=True, + ) + self.assertEqual("blocked", plan["summary"]["outcome"]) + self.assertTrue(plan["summary"]["partialPublicationAllowed"]) + self.assertTrue(plan["executionErrors"]) + + def test_review_evidence_projection_ignores_report_presentation_only_changes(self) -> None: + review = { + "workflow": {"runId": self.run_id}, + "rules": [{ + "sourceFile": "Analytic Rules/A.yaml", + "sourceSha256": "source-hash", + "analyticRule": {"id": "source-id"}, + "customDetection": { + "conversionStatus": "converted", + "reviewRequired": False, + "reviewScope": {"queryHash": "query-hash"}, + "reviewDecisions": [{"decision": "approved"}], + }, + }], + "updatedAt": "before", + "partialSolution": {"status": "partial-package-validated"}, + } + before = _review_evidence_sha256(review) + presentation_update = copy.deepcopy(review) + presentation_update["updatedAt"] = "after" + presentation_update["partialSolution"]["status"] = "report-refreshed" + presentation_update["rules"][0]["sourceFile"] = ( + "/workspace/Solution/Analytic Rules/A.yaml" + ) + self.assertEqual(before, _review_evidence_sha256(presentation_update)) + + changed_review = copy.deepcopy(review) + changed_review["rules"][0]["customDetection"]["reviewDecisions"] = [] + self.assertNotEqual(before, _review_evidence_sha256(changed_review)) + changed_source = copy.deepcopy(review) + changed_source["rules"][0]["sourceSha256"] = "new-source-hash" + self.assertNotEqual(before, _review_evidence_sha256(changed_source)) + + def test_applied_selection_stays_current_after_final_report_presentation_update(self) -> None: + from sentinel_xdr_migration.solution_report import build_solution_report + + initial = self._build_one_rule_plan( + source_status="passed", review_required=False + ) + + with using_run(self.solution, self.run_id): + plan_path = report_directory(self.solution, create=True) / PLAN_NAME + write_json_artifact(self.solution, plan_path, initial) + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + return_value=initial, + ): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=initial["planSha256"], + ) + with using_run(self.solution, self.run_id): + build_solution_report(self.solution) + finalized = self._build_one_rule_plan( + source_status="passed", + review_required=False, + reuse_existing_review=True, + ) + self.assertEqual(initial["planSha256"], finalized["planSha256"]) + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + return_value=finalized, + ): + current = verify_applied_package_selection(self.solution, self.run_id) + self.assertEqual(initial["planSha256"], current["plan"]["planSha256"]) + + changed_review = self._build_one_rule_plan( + source_status="passed", + review_required=False, + review_decision_change=True, + ) + self.assertNotEqual(initial["planSha256"], changed_review["planSha256"]) + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + return_value=changed_review, + ), self.assertRaisesRegex(ValueError, "evidence or artifact hashes changed"): + verify_applied_package_selection(self.solution, self.run_id) + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.solution = Path(self.temp.name) / "Selection" + self.solution.mkdir() + for folder, name in ( + ("Analytic Rules", "Keep.yaml"), + ("Analytic Rules", "Drop.yaml"), + ("XDR Detections", "Keep.yaml"), + ("XDR Detections", "Drop.yaml"), + ): + path = self.solution / folder / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"name: {name}\n", encoding="utf-8") + self.data_path = self.solution / "Data" / "Solution_Selection.json" + self.data_path.parent.mkdir() + self.original_data = { + "Name": "Selection", + "Version": "3.1.1", + "Analytic Rules": [ + "Analytic Rules/Keep.yaml", + "Analytic Rules/Drop.yaml", + ], + "XDR Detections": [ + "XDR Detections/Keep.yaml", + "XDR Detections/Drop.yaml", + ], + "Parsers": ["Parsers/Hand-authored.yaml"], + } + self.data_path.write_text(json.dumps(self.original_data), encoding="utf-8") + self.run_id = initialize_workflow( + self.solution, workflow_profile="authoring", + )["runId"] + + def test_content_reference_normalizes_selected_solution_repository_prefix(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + repository = Path(temp) + solution = repository / "Solutions" / "Selected" + for relative in ( + Path("Analytic Rules/Rule.yaml"), + Path("XDR Detections/Detection.yaml"), + ): + path = solution / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("name: synthetic\n", encoding="utf-8") + + prefix = solution.relative_to(repository).as_posix() + for family, relative in ( + ("Analytic Rules", "Analytic Rules/Rule.yaml"), + ("XDR Detections", "XDR Detections/Detection.yaml"), + ): + with self.subTest(family=family): + normalized, resolved = _content_reference( + solution, + f"{prefix}/{relative}", + family, + ) + self.assertEqual(relative, normalized) + self.assertEqual((solution / relative).resolve(), resolved) + + windows_reference = ( + f"{prefix}/XDR Detections/Detection.yaml".replace("/", "\\") + ) + normalized, _ = _content_reference( + solution, + windows_reference, + "XDR Detections", + ) + self.assertEqual("XDR Detections/Detection.yaml", normalized) + + def test_content_reference_rejects_wrong_prefix_family_and_traversal(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + repository = Path(temp) + solution = repository / "Solutions" / "Selected" + detection = solution / "XDR Detections" / "Detection.yaml" + detection.parent.mkdir(parents=True) + detection.write_text("name: synthetic\n", encoding="utf-8") + + for reference in ( + "Solutions/Other/XDR Detections/Detection.yaml", + "Solutions/Selected/Analytic Rules/Detection.yaml", + "Solutions/Selected/XDR Detections/../Analytic Rules/Detection.yaml", + "../Solutions/Selected/XDR Detections/Detection.yaml", + "/Solutions/Selected/XDR Detections/Detection.yaml", + "C:/Solutions/Selected/XDR Detections/Detection.yaml", + ): + with self.subTest(reference=reference): + with self.assertRaises(ValueError): + _content_reference(solution, reference, "XDR Detections") + + def test_content_reference_rejects_symlink_targets(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + repository = Path(temp) + solution = repository / "Solutions" / "Selected" + output = solution / "XDR Detections" + output.mkdir(parents=True) + outside = repository / "Outside.yaml" + outside.write_text("name: synthetic\n", encoding="utf-8") + link = output / "Linked.yaml" + try: + link.symlink_to(outside) + except (OSError, NotImplementedError): + self.skipTest("symlinks are unavailable in this environment") + + with self.assertRaisesRegex( + ValueError, + "content path escapes its root: XDR Detections/Linked.yaml", + ): + _content_reference( + solution, + "Solutions/Selected/XDR Detections/Linked.yaml", + "XDR Detections", + ) + + def _plan_builder(self, solution, run_id, *, candidate_lists_override=None, **_kwargs): + data = json.loads(self.data_path.read_text(encoding="utf-8")) + candidates = candidate_lists_override or { + "analyticRules": data["Analytic Rules"], + "xdrDetections": data["XDR Detections"], + } + file_hashes = { + path.relative_to(self.solution).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for folder in ("Analytic Rules", "XDR Detections") + for path in sorted((self.solution / folder).glob("*.yaml")) + } + plan = { + "solution": ".", + "runId": run_id, + "summary": { + "outcome": "partial", + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": 1, + "originalSourceRuleIds": ["11111111-1111-1111-1111-111111111111"], + "validatedMigratedSourceRuleCount": 1, + "validatedMigratedSourceRuleIds": ["11111111-1111-1111-1111-111111111111"], + "coverageNumerator": 1, + "coverageDenominator": 1, + "partialPublicationAllowed": True, + }, + "input": { + "candidateReferences": candidates, + "solutionDataContextSha256": _data_context_sha256(data), + "originalSourceInventorySha256": "b" * 64, + "files": file_hashes, + }, + "selection": { + "analyticRules": ["Analytic Rules/Keep.yaml"], + "xdrDetections": ["XDR Detections/Keep.yaml"], + }, + } + plan["planSha256"] = _selection_plan_hash(plan) + return plan + + def test_partial_coverage_uses_strict_integer_boundary_and_full_inventory(self) -> None: + from sentinel_xdr_migration.packaging_selection import ( + _coverage_summary, + _partial_coverage_allowed, + ) + + for denominator, numerator, expected in ( + (2, 1, False), + (3, 2, True), + (7, 6, True), + (14, 13, True), + (7, 3, False), + ): + source_inventory = [ + { + "id": f"00000000-0000-0000-0000-{index:012d}", + "path": f"Analytic Rules/{index}.yaml", + } + for index in range(1, denominator + 1) + ] + migrated = {item["id"] for item in source_inventory[:numerator]} + summary = _coverage_summary(source_inventory, migrated) + + with self.subTest(denominator=denominator, numerator=numerator): + self.assertEqual(expected, summary["partialPublicationAllowed"]) + self.assertEqual(expected, _partial_coverage_allowed(summary)) + self.assertEqual(denominator, summary["originalSourceRuleCount"]) + self.assertEqual(numerator, summary["validatedMigratedSourceRuleCount"]) + + self.assertFalse(_partial_coverage_allowed(_coverage_summary([], set()))) + + inventory = [ + {"id": "11111111-1111-1111-1111-111111111111"}, + {"id": "22222222-2222-2222-2222-222222222222"}, + ] + duplicate_detection_rows = [ + "11111111-1111-1111-1111-111111111111", + "11111111-1111-1111-1111-111111111111", + ] + duplicate_summary = _coverage_summary(inventory, duplicate_detection_rows) + self.assertEqual(1, duplicate_summary["validatedMigratedSourceRuleCount"]) + self.assertFalse(duplicate_summary["partialPublicationAllowed"]) + + def _save_plan(self) -> dict: + plan = self._plan_builder(self.solution, self.run_id) + with using_run(self.solution, self.run_id): + path = report_directory(self.solution, create=True) / PLAN_NAME + write_json_artifact(self.solution, path, plan) + return plan + + def _build_one_rule_plan( + self, + *, + source_status: str, + review_required: bool, + review_scope_mismatch: bool = False, + metadata_change_after_review: bool = False, + malformed_source: bool = False, + source_kind: str = "Scheduled", + nrt_with_scheduling_fields: bool = False, + missing_xdr_array: bool = False, + explicit_empty_xdr_array: bool = False, + omit_manifest_source: bool = False, + source_fallback_verified: bool | None = None, + review_decision_change: bool = False, + reuse_existing_review: bool = False, + global_runtime_inventory_error: bool = False, + ) -> dict: + source_id = "11111111-1111-1111-1111-111111111111" + source = self.solution / "Analytic Rules" / "Keep.yaml" + output = self.solution / "XDR Detections" / "Keep.yaml" + (self.solution / "Analytic Rules" / "Drop.yaml").unlink(missing_ok=True) + (self.solution / "XDR Detections" / "Drop.yaml").unlink(missing_ok=True) + source_fields = [ + f"id: {source_id}", + "name: Keep", + f"kind: {source_kind}", + "description: Identifies a test event.", + "severity: Medium", + "status: Available", + "version: 1.0.0", + "tactics: [Execution]", + "relevantTechniques: [T1059]", + "requiredDataConnectors:", + " - connectorId: AzureActiveDirectory", + " dataTypes: [SigninLogs]", + "query: DeviceEvents | where Timestamp > ago(5m) | count", + ] + if source_kind == "Scheduled": + source_fields.extend(( + "queryFrequency: 5m", + "queryPeriod: 5m", + "triggerOperator: gt", + "triggerThreshold: 0", + )) + elif nrt_with_scheduling_fields: + source_fields.append("queryFrequency: 5m") + source.write_text("\n".join((*source_fields, "")), encoding="utf-8") + if malformed_source: + source.write_text(f"id: {source_id}\nname: Keep\nquery: DeviceEvents | count\n", encoding="utf-8") + output.write_text( + "\n".join(( + "kind: CustomDetection", + "contentProvenance:", + " source:", + f" id: {source_id}", + " path: Analytic Rules/Keep.yaml", + " conversion:", + " status: converted", + f" reviewRequired: {'true' if review_required else 'false'}", + " reviewScope:", + " mappingHash: current", + "properties:", + " id: xdr-11111111-1111-1111-1111-111111111111", + "", + )), + encoding="utf-8", + ) + self.original_data["Analytic Rules"] = ["Analytic Rules/Keep.yaml"] + if missing_xdr_array: + self.original_data.pop("XDR Detections", None) + else: + self.original_data["XDR Detections"] = ( + [] if explicit_empty_xdr_array else ["XDR Detections/Keep.yaml"] + ) + self.data_path.write_text(json.dumps(self.original_data), encoding="utf-8") + + with using_run(self.solution, self.run_id): + run_directory = report_directory(self.solution, create=True) + manifest_results = [] if omit_manifest_source else [{ + "sourceRelativePath": "Analytic Rules/Keep.yaml", + "outputRelativePath": "XDR Detections/Keep.yaml", + "status": "needsReview" if review_required else "converted", + "reviewRequired": review_required, + "errors": [], + }] + (run_directory / "manifest.json").write_text(json.dumps({ + "scope": {"kind": "solution"}, + "total": len(manifest_results), + "results": manifest_results, + }), encoding="utf-8") + if source_fallback_verified is not None: + (run_directory / "runtime-validation.triage-mcp.json").write_text( + json.dumps({ + "provider": "triage-mcp", + "providerFailure": {"failureKind": "provider-unavailable"}, + }), + encoding="utf-8", + ) + proof = { + "mcpProvider": "triage-mcp", + "fallbackProvider": "azure-monitor-sdk", + "batchComplete": source_fallback_verified, + "permissionsVerified": True, + "kqlError": False, + } + (run_directory / "runtime-validation.azure-monitor-sdk.json").write_text( + json.dumps({ + "provider": "azure-monitor-sdk", + "providerFallback": proof, + }), + encoding="utf-8", + ) + review = { + "workflow": {"runId": self.run_id}, + "rules": [{ + "sourceSha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "sourceFile": "Analytic Rules/Keep.yaml", + "analyticRule": {"id": source_id}, + "customDetection": { + "reviewRequired": review_required, + "reviewScope": { + "mappingHash": "stale" if review_scope_mismatch else "current", + }, + "reviewDecisions": [], + "conversionStatus": "converted", + }, + }], + } + if review_decision_change: + review["rules"][0]["customDetection"]["reviewDecisions"] = [ + {"decision": "changed"} + ] + review_path = run_directory / "migration-report.json" + if reuse_existing_review: + review = json.loads(review_path.read_text(encoding="utf-8")) + else: + review_path.write_text(json.dumps(review), encoding="utf-8") + if metadata_change_after_review: + output.write_text( + output.read_text(encoding="utf-8") + "description: metadata-only change\n", + encoding="utf-8", + ) + state = { + "context": {"workflowProfile": "authoring"}, + "stages": {"validation": { + "artifacts": { + "sentinelRuntimeJson": "runtime-validation.azure-monitor-sdk.json", + }, + }}, + } + query_hash = "a" * 64 + + def runtime_family(_root, _run_directory, _state, *, provider, **_kwargs): + entry = ( + {"status": source_status, "expectedQuerySha256": query_hash} + if provider != "graph" + else {"status": "failed" if review_required else "passed", "reason": "graph-failed"} + ) + errors = ( + [f"{provider} runtime report is missing expected detection results: C.yaml"] + if global_runtime_inventory_error and provider == "graph" + else [] + ) + return {"Keep.yaml": entry}, {"provider": provider}, errors + + source_structural_status = ( + "failed" + if malformed_source or (source_kind == "NRT" and nrt_with_scheduling_fields) + else "passed" + ) + source_structural_errors = ( + ["analytic rule is missing: kind, severity, queryFrequency, queryPeriod"] + if malformed_source else + ["NRT rules must not include queryFrequency"] + if source_kind == "NRT" and nrt_with_scheduling_fields else [] + ) + structural = { + "results": [ + { + "file": "Analytic Rules/Keep.yaml", + "fileSha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "status": source_structural_status, + "kind": "analyticRule", + "errors": source_structural_errors, + }, + { + "file": "XDR Detections/Keep.yaml", + "fileSha256": hashlib.sha256(output.read_bytes()).hexdigest(), + "status": "passed", + "valid": True, + "kind": "customDetection", + "errors": [], + }, + ], + } + full_hashes = { + "Analytic Rules/Keep.yaml": hashlib.sha256(source.read_bytes()).hexdigest(), + "XDR Detections/Keep.yaml": hashlib.sha256(output.read_bytes()).hexdigest(), + } + patches = ( + mock.patch( + "sentinel_xdr_migration.packaging_selection._run_context", + return_value=(run_directory, state), + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection._validation_evidence", + return_value=(structural, full_hashes), + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection._current_target", + return_value=(None, None), + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection._current_review_report", + side_effect=lambda _directory, _run_id: ( + json.loads(review_path.read_text(encoding="utf-8")), + review_path, + ), + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection._runtime_family", + side_effect=runtime_family, + ), + mock.patch( + "sentinel_xdr_migration.packaging_selection.current_runtime_query_hash", + return_value=query_hash, + ), + ) + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: + return _build_package_selection_plan(self.solution, self.run_id) + + def test_plan_hash_does_not_mutate_nested_plan_data(self) -> None: + plan = { + "input": { + "solutionDataSha256AtPlan": "data-hash", + "versionAtPlan": "3.1.1", + "candidateReferences": {"analyticRules": [], "xdrDetections": []}, + }, + "createdAt": "ignored", + } + original = copy.deepcopy(plan) + + _selection_plan_hash(plan) + + self.assertEqual(original, plan) + + def test_conversion_manifest_paths_reject_escape_and_wrong_family(self) -> None: + for path, family in ( + ("Analytic Rules/../secret.yaml", "Analytic Rules"), + ("XDR Detections\\..\\secret.yaml", "XDR Detections"), + ("Other/Rule.yaml", "Analytic Rules"), + ): + with self.subTest(path=path), self.assertRaisesRegex(ValueError, "unsafe"): + _manifest_reference(path, family) + + def test_missing_xdr_array_is_not_interpreted_as_an_empty_selection(self) -> None: + data = dict(self.original_data) + del data["XDR Detections"] + + with self.assertRaisesRegex(ValueError, "missing an explicit XDR Detections array"): + _selection_arrays(self.solution, data) + + def test_legacy_dot_solution_binding_is_rooted_in_its_run_directory(self) -> None: + with using_run(self.solution, self.run_id): + run_directory = report_directory(self.solution) + state_path = run_directory / "workflow-state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["solution"] = "." + state_path.write_text(json.dumps(state), encoding="utf-8") + + bound_directory, bound_state = _run_context(self.solution, self.run_id) + + self.assertEqual(run_directory, bound_directory) + self.assertEqual(".", bound_state["solution"]) + + def test_apply_updates_only_selection_arrays_and_verify_binds_full_file_hashes(self) -> None: + plan = self._save_plan() + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ): + result = apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + applied = verify_applied_package_selection(self.solution, self.run_id) + + updated = json.loads(self.data_path.read_text(encoding="utf-8")) + self.assertEqual(plan["selection"]["analyticRules"], updated["Analytic Rules"]) + self.assertEqual(plan["selection"]["xdrDetections"], updated["XDR Detections"]) + self.assertEqual(self.original_data["Name"], updated["Name"]) + self.assertEqual(self.original_data["Version"], updated["Version"]) + self.assertEqual(self.original_data["Parsers"], updated["Parsers"]) + self.assertEqual(plan["planSha256"], result["selectionPlanSha256"]) + self.assertEqual(plan["planSha256"], applied["plan"]["planSha256"]) + + (self.solution / "XDR Detections" / "Keep.yaml").write_text( + "name: edited after selection\n", encoding="utf-8", + ) + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), self.assertRaisesRegex(ValueError, "evidence or artifact hashes changed"): + verify_applied_package_selection(self.solution, self.run_id) + + def test_apply_rejects_stale_plan_without_changing_data_arrays(self) -> None: + plan = self._save_plan() + (self.solution / "XDR Detections" / "Keep.yaml").write_text( + "name: changed before apply\n", encoding="utf-8", + ) + before = self.data_path.read_bytes() + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), self.assertRaisesRegex(ValueError, "selection plan is stale"): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(before, self.data_path.read_bytes()) + + def test_failed_detection_review_does_not_exclude_healthy_source_rule(self) -> None: + plan = self._build_one_rule_plan(source_status="passed", review_required=True) + + self.assertTrue(plan["analyticRules"][0]["included"]) + self.assertEqual(["Analytic Rules/Keep.yaml"], plan["selection"]["analyticRules"]) + self.assertFalse(plan["xdrDetections"][0]["included"]) + self.assertIn("unresolved-review-required", plan["xdrDetections"][0]["reasons"]) + + def test_failed_source_runtime_excludes_paired_detection_by_closure(self) -> None: + plan = self._build_one_rule_plan(source_status="failed", review_required=False) + + self.assertFalse(plan["analyticRules"][0]["included"]) + self.assertFalse(plan["xdrDetections"][0]["included"]) + self.assertIn( + "source-analytic-rule-is-not-included", + plan["xdrDetections"][0]["reasons"], + ) + + def test_stale_source_query_evidence_excludes_rule_without_rerunning(self) -> None: + plan = self._build_one_rule_plan( + source_status="stale", + review_required=False, + ) + + self.assertFalse(plan["analyticRules"][0]["included"]) + self.assertEqual("a" * 64, plan["analyticRules"][0]["querySha256"]) + self.assertIn( + "source-runtime-not-passed-for-current-query", + plan["analyticRules"][0]["reasons"], + ) + + def test_changed_review_scope_does_not_reuse_old_review_evidence(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + review_scope_mismatch=True, + ) + + self.assertTrue(plan["analyticRules"][0]["included"]) + self.assertFalse(plan["xdrDetections"][0]["included"]) + self.assertIn( + "source-or-review-evidence-stale-or-missing", + plan["xdrDetections"][0]["reasons"], + ) + + def test_metadata_only_yaml_change_keeps_query_hash_and_refreshes_full_hash(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + metadata_change_after_review=True, + ) + + output = self.solution / "XDR Detections" / "Keep.yaml" + self.assertTrue(plan["analyticRules"][0]["included"]) + self.assertTrue(plan["xdrDetections"][0]["included"], plan["xdrDetections"][0]) + self.assertEqual("a" * 64, plan["analyticRules"][0]["querySha256"]) + self.assertEqual("a" * 64, plan["xdrDetections"][0]["sourceQuerySha256"]) + self.assertEqual( + hashlib.sha256(output.read_bytes()).hexdigest(), + plan["xdrDetections"][0]["fileSha256"], + ) + + def test_structural_analytic_rule_validation_excludes_invalid_source_and_paired_detection(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + malformed_source=True, + ) + + self.assertFalse(plan["analyticRules"][0]["included"]) + self.assertEqual("failed", plan["analyticRules"][0]["checks"]["sourceYamlStructure"]["status"]) + self.assertFalse(plan["xdrDetections"][0]["included"]) + self.assertIn( + "source-analytic-rule-is-not-included", + plan["xdrDetections"][0]["reasons"], + ) + + def test_valid_nrt_source_rule_passes_kind_specific_structural_validation(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + source_kind="NRT", + ) + + self.assertTrue(plan["analyticRules"][0]["included"], plan["analyticRules"][0]) + self.assertIn( + "kind: NRT", + (self.solution / "Analytic Rules" / "Keep.yaml").read_text(encoding="utf-8"), + ) + self.assertTrue(plan["xdrDetections"][0]["included"], plan["xdrDetections"][0]) + self.assertEqual( + [], + _analytic_rule_structure_errors({ + "id": "11111111-1111-1111-1111-111111111111", + "name": "NRT rule", + "kind": "NRT", + "description": "Identifies a test event.", + "severity": "Medium", + "status": "Available", + "version": "1.0.0", + "tactics": ["Execution"], + "relevantTechniques": ["T1059"], + "requiredDataConnectors": [{ + "connectorId": "AzureActiveDirectory", + "dataTypes": ["SigninLogs"], + }], + "query": "DeviceEvents | where Timestamp > ago(5m) | count", + }), + ) + + def test_nrt_with_scheduled_fields_is_structurally_excluded(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + source_kind="NRT", + nrt_with_scheduling_fields=True, + ) + + self.assertFalse(plan["analyticRules"][0]["included"]) + self.assertIn( + "NRT rules must not include queryFrequency", + plan["analyticRules"][0]["checks"]["sourceYamlStructure"]["reason"], + ) + self.assertIn( + "NRT rules must not include queryFrequency", + _analytic_rule_structure_errors({ + "id": "11111111-1111-1111-1111-111111111111", + "name": "NRT rule", + "kind": "NRT", + "description": "Identifies a test event.", + "severity": "Medium", + "status": "Available", + "version": "1.0.0", + "tactics": ["Execution"], + "relevantTechniques": ["T1059"], + "requiredDataConnectors": [{ + "connectorId": "AzureActiveDirectory", + "dataTypes": ["SigninLogs"], + }], + "query": "DeviceEvents | where Timestamp > ago(5m) | count", + "queryFrequency": "5m", + }), + ) + self.assertFalse(plan["xdrDetections"][0]["included"]) + + def test_missing_xdr_array_bootstraps_reviewed_manifest_outputs_only_for_planning(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + missing_xdr_array=True, + ) + + self.assertTrue(plan["input"]["xdrArrayWasMissing"]) + self.assertTrue(plan["input"]["xdrReferencesBootstrappedFromManifest"]) + self.assertEqual( + ["XDR Detections/Keep.yaml"], + plan["input"]["bootstrapManifestOutputReferences"], + ) + self.assertTrue(plan["xdrDetections"][0]["included"], plan["xdrDetections"][0]) + with using_run(self.solution, self.run_id): + plan_path = report_directory(self.solution, create=True) / PLAN_NAME + write_json_artifact(self.solution, plan_path, plan) + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + return_value=plan, + ): + applied = apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + verified = verify_applied_package_selection(self.solution, self.run_id) + + updated = json.loads(self.data_path.read_text(encoding="utf-8")) + self.assertEqual( + ["XDR Detections/Keep.yaml"], + updated["XDR Detections"], + ) + self.assertEqual("applied", applied["status"]) + self.assertEqual(plan["planSha256"], verified["plan"]["planSha256"]) + + def test_explicit_empty_xdr_array_is_not_bootstrapped_from_manifest(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + explicit_empty_xdr_array=True, + ) + + self.assertFalse(plan["input"]["xdrArrayWasMissing"]) + self.assertFalse(plan["input"]["xdrReferencesBootstrappedFromManifest"]) + self.assertEqual([], plan["input"]["candidateReferences"]["xdrDetections"]) + self.assertEqual(1, len(plan["xdrDetections"])) + self.assertFalse(plan["xdrDetections"][0]["included"]) + self.assertIn( + "not-in-current-xdr-detection-selection", + plan["xdrDetections"][0]["reasons"], + ) + + def test_all_excluded_inventory_is_blocked_not_ready(self) -> None: + plan = self._build_one_rule_plan( + source_status="failed", + review_required=False, + ) + + self.assertEqual("blocked", plan["summary"]["outcome"]) + self.assertEqual("no-eligible-analytic-rules", plan["summary"]["eligibilityBlockReason"]) + self.assertEqual([], plan["selection"]["analyticRules"]) + self.assertEqual([], plan["selection"]["xdrDetections"]) + + def test_manifest_omission_cannot_shrink_original_inventory(self) -> None: + plan = self._build_one_rule_plan( + source_status="passed", + review_required=False, + omit_manifest_source=True, + ) + + self.assertEqual(1, plan["summary"]["originalSourceRuleCount"]) + self.assertEqual(0, plan["summary"]["validatedMigratedSourceRuleCount"]) + self.assertEqual("blocked", plan["summary"]["outcome"]) + self.assertTrue(any( + "omits current source inventory item" in error + for error in plan["executionErrors"] + )) + + def test_blocked_all_excluded_plan_cannot_be_applied(self) -> None: + plan = self._save_plan() + plan["summary"]["outcome"] = "blocked" + plan["summary"]["eligibilityBlockReason"] = "no-eligible-analytic-rules" + plan["selection"] = {"analyticRules": [], "xdrDetections": []} + plan["planSha256"] = _selection_plan_hash(plan) + with using_run(self.solution, self.run_id): + plan_path = report_directory(self.solution, create=True) / PLAN_NAME + write_json_artifact(self.solution, plan_path, plan) + original = self.data_path.read_bytes() + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + return_value=plan, + ), self.assertRaisesRegex(ValueError, "no eligible Analytic Rules"): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(original, self.data_path.read_bytes()) + + def test_runtime_plan_and_report_bindings_round_trip_for_source_and_graph(self) -> None: + target = { + "tenantId": "tenant", + "subscriptionId": "subscription", + "resourceGroup": "group", + "workspaceResourceId": "/subscriptions/subscription/resourceGroups/group/providers/Microsoft.OperationalInsights/workspaces/lab", + "workspaceName": "lab", + "workspaceCustomerId": "customer", + } + target_fingerprint = _runtime_target_fingerprint(target) + self.assertEqual( + target_fingerprint, + hashlib.sha256( + json.dumps( + target, sort_keys=True, separators=(",", ":"), ensure_ascii=False, + ).encode("utf-8") + ).hexdigest(), + ) + changed_name = dict(target, workspaceName="different") + self.assertNotEqual( + target_fingerprint, + _runtime_target_fingerprint(changed_name), + ) + initialize_workflow(self.solution, workflow_profile="authoring") + + def load_locked_target(root, *, materialize_missing=True): + self.assertEqual(self.run_id, report_directory(root).name) + return target + + with mock.patch( + "sentinel_xdr_migration.packaging_selection.require_locked_target", + side_effect=load_locked_target, + ): + selected_target, selected_fingerprint = _current_target( + self.solution, + { + "runId": self.run_id, + "context": {"workflowProfile": "qualification"}, + }, + ) + self.assertEqual(target, selected_target) + self.assertEqual(target_fingerprint, selected_fingerprint) + run_directory = report_directory(self.solution, create=True) + state = { + "context": {"workflowProfile": "qualification"}, + "stages": {"validation": { + "artifacts": { + "sentinelRuntimeJson": "runtime-validation.azure-monitor-sdk.json", + }, + }}, + } + binding = { + "runId": self.run_id, + "workflowProfile": "qualification", + "solution": ".", + "targetFingerprintSha256": target_fingerprint, + } + source_family = "sentinelSourceQueryFamily" + source_plan = { + "runId": self.run_id, + "workflowProfile": "qualification", + "solution": ".", + "target": {"fingerprintSha256": target_fingerprint}, + "planSha256": "plan-sha", + "families": { + source_family: {"provider": {"provider": "azure-monitor-sdk"}}, + "defenderXdrQueryFamily": {"provider": {"provider": "graph"}}, + }, + } + source_consent = { + "status": "approved", + "approvedFamilies": [source_family], + "planSha256": "plan-sha", + "binding": binding, + } + (run_directory / "runtime-query-plan.json").write_text( + json.dumps(source_plan), encoding="utf-8", + ) + (run_directory / "runtime-query-consent.json").write_text( + json.dumps(source_consent), encoding="utf-8", + ) + source_report = json.loads(json.dumps({ + "provider": "azure-monitor-sdk", + "querySurface": "log-analytics-workspace", + "runtimeQueryConsent": { + "status": "approved", + "provider": "azure-monitor-sdk", + "family": source_family, + "planSha256": "plan-sha", + "binding": binding, + }, + "runtimeQueryDispatchSnapshot": { + "status": "approved", + "provider": "azure-monitor-sdk", + "family": source_family, + "planSha256": "plan-sha", + "binding": binding, + }, + })) + bound, reason = _runtime_binding( + self.solution, + run_directory, + state, + provider="azure-monitor-sdk", + report=source_report, + target_fingerprint=target_fingerprint, + ) + self.assertTrue(bound, reason) + unbound_report = {"provider": "azure-monitor-sdk"} + bound, reason = _runtime_binding( + self.solution, + run_directory, + state, + provider="azure-monitor-sdk", + report=unbound_report, + target_fingerprint=target_fingerprint, + ) + self.assertFalse(bound) + self.assertEqual("provider-runtime-execution-binding-missing", reason) + wrong_binding = dict(binding, targetFingerprintSha256="f" * 64) + wrong_report = json.loads(json.dumps(source_report)) + wrong_report["runtimeQueryConsent"]["binding"] = wrong_binding + wrong_report["runtimeQueryDispatchSnapshot"]["binding"] = wrong_binding + bound, reason = _runtime_binding( + self.solution, + run_directory, + state, + provider="azure-monitor-sdk", + report=wrong_report, + target_fingerprint=target_fingerprint, + ) + self.assertFalse(bound) + self.assertEqual("provider-runtime-execution-binding-stale-or-mismatched", reason) + stale_binding = dict(binding, runId="other-run") + wrong_report["runtimeQueryConsent"]["binding"] = stale_binding + wrong_report["runtimeQueryDispatchSnapshot"]["binding"] = stale_binding + bound, reason = _runtime_binding( + self.solution, + run_directory, + state, + provider="azure-monitor-sdk", + report=wrong_report, + target_fingerprint=target_fingerprint, + ) + self.assertFalse(bound) + + graph_family = "defenderXdrQueryFamily" + graph_report = json.loads(json.dumps({ + "provider": "graph", + "runtimeQueryConsent": { + "status": "approved", + "provider": "graph", + "family": graph_family, + "planSha256": "plan-sha", + "binding": binding, + }, + "runtimeQueryDispatchSnapshot": { + "status": "approved", + "provider": "graph", + "family": graph_family, + "planSha256": "plan-sha", + "binding": binding, + }, + })) + bound, reason = _runtime_binding( + self.solution, + run_directory, + state, + provider="graph", + report=graph_report, + target_fingerprint=target_fingerprint, + ) + self.assertTrue(bound, reason) + + def test_receipt_failure_rolls_back_data_and_allows_clean_retry(self) -> None: + plan = self._save_plan() + original_bytes = self.data_path.read_bytes() + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), mock.patch( + "sentinel_xdr_migration.packaging_selection._write_selection_receipt", + side_effect=OSError("simulated receipt write failure"), + ), self.assertRaisesRegex( + RuntimeError, "rolled back under the package-selection writer lock" + ): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(original_bytes, self.data_path.read_bytes()) + receipt_path = report_directory(self.solution) / "package-selection-applied.json" + self.assertFalse(receipt_path.exists()) + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ): + applied = apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual("applied", applied["status"]) + + def test_concurrent_receipt_is_not_overwritten_and_data_rolls_back(self) -> None: + plan = self._save_plan() + original_bytes = self.data_path.read_bytes() + receipt_path = report_directory(self.solution) / "package-selection-applied.json" + concurrent_bytes = b'{"concurrent":"receipt"}\n' + def race_receipt(solution, path, receipt): + path.write_bytes(concurrent_bytes) + return _write_selection_receipt(solution, path, receipt) + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), mock.patch( + "sentinel_xdr_migration.packaging_selection._write_selection_receipt", + side_effect=race_receipt, + ), self.assertRaisesRegex( + RuntimeError, "rolled back under the package-selection writer lock" + ): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(original_bytes, self.data_path.read_bytes()) + self.assertEqual(concurrent_bytes, receipt_path.read_bytes()) + + def test_apply_detects_data_race_after_plan_freshness_check(self) -> None: + plan = self._save_plan() + original_bytes = self.data_path.read_bytes() + calls = 0 + + def plan_with_concurrent_edit(solution, run_id, **kwargs): + nonlocal calls + result = self._plan_builder(solution, run_id, **kwargs) + calls += 1 + if calls == 2: + changed = json.loads(self.data_path.read_text(encoding="utf-8")) + changed["Name"] = "concurrent edit" + self.data_path.write_text(json.dumps(changed), encoding="utf-8") + return result + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=plan_with_concurrent_edit, + ), self.assertRaisesRegex(RuntimeError, "solution data changed during package-selection update"): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertNotEqual(original_bytes, self.data_path.read_bytes()) + self.assertEqual( + "concurrent edit", + json.loads(self.data_path.read_text(encoding="utf-8"))["Name"], + ) + + def test_apply_preserves_edit_arriving_while_commit_temp_is_prepared(self) -> None: + plan = self._save_plan() + original_fsync = os.fsync + concurrent_bytes = b'{"Name":"user edit during commit"}\n' + + def edit_during_temp_flush(descriptor: int) -> None: + original_fsync(descriptor) + self.data_path.write_bytes(concurrent_bytes) + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), mock.patch( + "sentinel_xdr_migration.packaging_selection.os.fsync", + side_effect=edit_during_temp_flush, + ), self.assertRaisesRegex(RuntimeError, "solution data changed during package-selection update"): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(concurrent_bytes, self.data_path.read_bytes()) + receipt_path = report_directory(self.solution) / "package-selection-applied.json" + self.assertFalse(receipt_path.exists()) + + def test_atomic_exchange_restores_edit_arriving_at_commit_boundary(self) -> None: + before = _file_snapshot(self.data_path) + concurrent_bytes = b'{"Name":"user edit at exchange boundary"}\n' + real_exchange = _exchange_paths + changed = False + + def edit_then_exchange(left: Path, right: Path) -> None: + nonlocal changed + if not changed: + changed = True + self.data_path.write_bytes(concurrent_bytes) + real_exchange(left, right) + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._exchange_paths", + side_effect=edit_then_exchange, + ), self.assertRaisesRegex(RuntimeError, "concurrent edit was restored"): + _atomic_exchange_if_unchanged( + self.data_path, + b'{"Name":"selected"}\n', + before, + ) + + self.assertEqual(concurrent_bytes, self.data_path.read_bytes()) + + def test_receipt_failure_rollback_preserves_edit_arriving_during_rollback(self) -> None: + plan = self._save_plan() + original_fsync = os.fsync + flush_count = 0 + concurrent_bytes = b'{"Name":"user edit during rollback"}\n' + + def edit_during_rollback_flush(descriptor: int) -> None: + nonlocal flush_count + flush_count += 1 + original_fsync(descriptor) + if flush_count == 2: + self.data_path.write_bytes(concurrent_bytes) + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), mock.patch( + "sentinel_xdr_migration.packaging_selection._write_selection_receipt", + side_effect=OSError("simulated receipt write failure"), + ), mock.patch( + "sentinel_xdr_migration.packaging_selection.os.fsync", + side_effect=edit_during_rollback_flush, + ), self.assertRaisesRegex(RuntimeError, "concurrent solution-data edit was preserved"): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(2, flush_count) + self.assertEqual(concurrent_bytes, self.data_path.read_bytes()) + receipt_path = report_directory(self.solution) / "package-selection-applied.json" + self.assertFalse(receipt_path.exists()) + + def test_post_receipt_freshness_check_preserves_concurrent_solution_data_edit(self) -> None: + plan = self._save_plan() + receipt_path = report_directory(self.solution) / "package-selection-applied.json" + concurrent_bytes = b'{"Name":"user edit after receipt"}\n' + + def publish_receipt_then_edit(solution, path, receipt): + _write_selection_receipt(solution, path, receipt) + self.data_path.write_bytes(concurrent_bytes) + + with mock.patch( + "sentinel_xdr_migration.packaging_selection._build_package_selection_plan", + side_effect=self._plan_builder, + ), mock.patch( + "sentinel_xdr_migration.packaging_selection._write_selection_receipt", + side_effect=publish_receipt_then_edit, + ), self.assertRaisesRegex(RuntimeError, "concurrent solution-data edit was preserved"): + apply_package_selection( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + + self.assertEqual(concurrent_bytes, self.data_path.read_bytes()) + self.assertTrue(receipt_path.is_file()) + self.assertEqual( + self.run_id, + json.loads(receipt_path.read_text(encoding="utf-8"))["runId"], + ) + + def test_cli_requires_an_exact_run_id_for_plan_and_apply(self) -> None: + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(sys, "stdout", stdout), mock.patch.object(sys, "stderr", stderr): + with self.assertRaises(SystemExit) as raised: + cli_module.main([ + "package-selection-plan", + "--solution", str(self.solution), + ]) + self.assertEqual(2, raised.exception.code) + self.assertIn("--run-id", stderr.getvalue()) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(sys, "stdout", stdout), mock.patch.object(sys, "stderr", stderr), mock.patch.object( + cli_module, + "create_package_selection_plan", + return_value={"status": "ready", "runId": self.run_id}, + ) as plan: + code = cli_module.main([ + "package-selection-plan", + "--solution", str(self.solution), + "--run-id", self.run_id, + ]) + self.assertEqual(0, code, stderr.getvalue()) + plan.assert_called_once_with(str(self.solution), self.run_id) + self.assertEqual(self.run_id, json.loads(stdout.getvalue())["runId"]) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch.object(sys, "stdout", stdout), mock.patch.object(sys, "stderr", stderr), mock.patch.object( + cli_module, + "create_package_selection_plan", + return_value={"status": "blocked", "executionErrors": ["invalid report"]}, + ): + blocked = cli_module.main([ + "package-selection-plan", + "--solution", str(self.solution), + "--run-id", self.run_id, + ]) + self.assertEqual(1, blocked) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_parser_bindings.py b/Tools/SentinelToXDRMigration/tests/test_parser_bindings.py new file mode 100644 index 00000000000..277d5301ab2 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_parser_bindings.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.converter import build_xdr_document, convert_query +from sentinel_xdr_migration.parser_bindings import normalize_parser_bindings, solution_parser_names + + +ROOT = Path(__file__).resolve().parents[1] +HELPERS = ROOT.parent / "Create-Azure-Sentinel-Solution" / "common" / "customDetections.ps1" +FIXTURES = json.loads((ROOT / "tests" / "fixtures" / "parser_collisions.json").read_text()) + + +def rule(query: str) -> dict: + return { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Parser naming regression", + "version": "1.0.0", + "queryFrequency": "1h", + "queryPeriod": "1h", + "query": query, + "entityMappings": [{ + "entityType": "IP", + "fieldMappings": [{"identifier": "Address", "columnName": "Ring0SourceIp"}], + }], + } + + +class ParserBindingTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.repository = Path(self.temp.name) + self.solution = self.repository / "Solutions" / "Sample" + self.parsers = self.solution / "Parsers" + self.parsers.mkdir(parents=True) + self.source = self.solution / "Analytic Rules" / "Example.yaml" + self.source.parent.mkdir() + + def tearDown(self) -> None: + self.temp.cleanup() + + def parser(self, name: str, alias: str | None = None) -> Path: + path = self.parsers / f"{name}.yaml" + path.write_text(yaml.safe_dump({ + "FunctionName": name, "FunctionAlias": alias or name, + "FunctionQuery": "CommonSecurityLog", + }), encoding="utf-8") + return path + + def test_discovers_names_aliases_and_nested_yml(self) -> None: + self.parser("Parser", "Alias") + nested = self.parsers / "nested" + nested.mkdir() + (nested / "Other.yml").write_text("FunctionName: Other\n", encoding="utf-8") + self.assertEqual(["Alias", "Other", "Parser"], solution_parser_names(self.solution)) + + def test_malformed_parser_inventory_is_not_silently_skipped(self) -> None: + for value in ("- not-an-object\n", "FunctionAlias: 42\n", "FunctionName: ''\n", "{}\n"): + with self.subTest(value=value): + (self.parsers / "Bad.yaml").write_text(value) + with self.assertRaises(ValueError): + solution_parser_names(self.solution) + + @mock.patch("sentinel_xdr_migration.parser_bindings.subprocess.run") + def test_solution_without_parsers_does_not_require_node(self, run: mock.Mock) -> None: + self.assertEqual(("let Other=1; print Other", []), + normalize_parser_bindings("let Other=1; print Other", self.solution)) + run.assert_not_called() + + @mock.patch("sentinel_xdr_migration.parser_bindings.shutil.which", return_value=None) + def test_missing_node_is_an_explicit_setup_error(self, which: mock.Mock) -> None: + self.parser("Parser") + with self.assertRaisesRegex(RuntimeError, "Node.js"): + normalize_parser_bindings("let Parser=1; print Value=Parser", self.solution) + + def test_converter_normalizes_all_six_queries_and_preserves_sources(self) -> None: + for fixture in FIXTURES: + with self.subTest(query=fixture["name"]): + parser = self.parser(fixture["parser"]) + self.source.write_text(yaml.safe_dump(rule(fixture["query"])), encoding="utf-8") + before = self.source.read_bytes(), parser.read_bytes() + document = build_xdr_document(self.source, self.solution, {}) + expected = convert_query(fixture["query"], {})[0].replace( + fixture["parser"], f"__xdr_inline_{fixture['parser']}" + ) + self.assertEqual(expected, document["properties"]["queryCondition"]["queryText"]) + self.assertEqual("disabled", document["properties"]["status"]) + provenance = document["contentProvenance"] + self.assertEqual(hashlib.sha256(fixture["query"].encode()).hexdigest(), + provenance["source"]["querySha256"]) + self.assertTrue(any("Renamed local parser binding" in warning + for warning in provenance["conversion"]["warnings"])) + self.assertEqual(before, (self.source.read_bytes(), parser.read_bytes())) + + def test_unsafe_rewrite_blocks_conversion_instead_of_changing_semantics(self) -> None: + self.parser("Parser") + query = "let Parser=1; datatable(x:long)[1] | project Parser" + self.source.write_text(yaml.safe_dump(rule(query)), encoding="utf-8") + document = build_xdr_document(self.source, self.solution, {}) + self.assertEqual("needsReview", document["contentProvenance"]["conversion"]["status"]) + self.assertTrue(any("output columns" in error + for error in document["contentProvenance"]["conversion"]["errors"])) + self.assertEqual(query, document["properties"]["queryCondition"]["queryText"]) + + @unittest.skipUnless(shutil.which("pwsh") and shutil.which("git"), "PowerShell and git are required") + def test_actual_shared_packager_normalizes_both_faces_without_editing_yaml(self) -> None: + subprocess.run(["git", "init", "--quiet", str(self.repository)], check=True, capture_output=True) + output = self.solution / "XDR Detections" + output.mkdir() + paths = [] + expected = [] + template = { + "parameters": {}, + "variables": {}, + "resources": [{ + "type": "Microsoft.OperationalInsights/workspaces/providers/contentPackages", + "properties": { + "dependencies": { + "operator": "AND", + "criteria": [{"kind": "AnalyticsRule", "contentId": "existing"}], + }, + }, + }], + } + for index, fixture in enumerate(FIXTURES): + self.parser(fixture["parser"]) + self.source.write_text(yaml.safe_dump(rule(fixture["query"])), encoding="utf-8") + document = build_xdr_document(self.source, self.solution, {}) + source_id = f"{index + 1:08d}-2222-3333-4444-555555555555" + document["properties"]["id"] = source_id + document["contentProvenance"]["source"]["id"] = source_id + template["variables"][f"source{index}"] = {f"_analyticRulecontentId{index}": source_id} + template["resources"].append({ + "name": f"[variables('source{index}')]", + "properties": {"contentKind": "AnalyticsRule"}, + }) + document["properties"]["queryCondition"]["queryText"] = fixture["query"] + # Isolate the packaging regression from unrelated conversion review reasons. + document["contentProvenance"]["conversion"].update( + status="converted", reviewRequired=False, errors=[], reviewReasons=[] + ) + path = output / f"Detection{index}.yaml" + path.write_text(yaml.safe_dump(document), encoding="utf-8") + paths.append(path) + properties = copy.deepcopy(document["properties"]) + packaged_id = f"xdr-{source_id}" + properties["id"] = f"[if(true(), '{packaged_id}', '{packaged_id}')]" + for mappings in properties["detectionAction"]["alertTemplate"]["entityMappings"].values(): + for mapping in mappings: + if mapping.get("id"): + mapping["id"] = f"[if(true(), '{mapping['id']}', '{mapping['id']}')]" + properties["queryCondition"]["queryText"] = fixture["query"].replace( + fixture["parser"], f"__xdr_inline_{fixture['parser']}" + ) + expected.append(properties) + native = list(self.parsers.glob("*.yaml")) + paths + before = {path: path.read_bytes() for path in native} + content = { + "Version": "1.0.0", + "Include XDR Content Registration": True, + "XDR Detections": [str(path.relative_to(self.solution)) for path in paths], + "TestTemplate": template, + } + script = f""" +$ErrorActionPreference = 'Stop' +$WarningPreference = 'SilentlyContinue' +. '{str(HELPERS).replace("'", "''")}' +$content = [Console]::In.ReadToEnd() | ConvertFrom-Json +$template = $content.TestTemplate +Add-XdrCustomDetectionsToSolution -SolutionName 'Sample' -ContentToImport $content -Template $template 6>$null | Out-Null +$install = @($template.resources | Where-Object type -eq 'Microsoft.Resources/deployments' | + ForEach-Object {{ $_.properties.template.resources.detectionRule.properties }}) +$registrations = @($template.resources | Where-Object {{ $_.properties.contentKind -eq 'CustomDetection' }} | + ForEach-Object {{ $_.properties.mainTemplate.resources.detectionRule.properties }}) +@{{ install = $install; registrations = $registrations }} | ConvertTo-Json -Depth 100 -Compress +""" + result = subprocess.run( + [shutil.which("pwsh"), "-NoProfile", "-NonInteractive", "-Command", script], + input=json.dumps(content), text=True, encoding="utf-8", capture_output=True, + cwd=self.repository, timeout=120, check=False, + ) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + actual = json.loads(result.stdout) + self.assertEqual(expected, actual["install"]) + self.assertEqual(expected, actual["registrations"]) + self.assertEqual(before, {path: path.read_bytes() for path in native}) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_preflight.py b/Tools/SentinelToXDRMigration/tests/test_preflight.py new file mode 100644 index 00000000000..c826f19ed7a --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_preflight.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +from sentinel_xdr_migration.artifacts import artifact_path, existing_artifact_path +from sentinel_xdr_migration.onboarding import configure_workspace, doctor +from sentinel_xdr_migration.preflight import run_preflight +from sentinel_xdr_migration.target_context import build_target +from sentinel_xdr_migration.workflow import initialize_workflow + + +TENANT_ID = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" +SUBSCRIPTION_ID = "42382e39-f157-46d1-a931-b8cfd779ece5" +CUSTOMER_ID = "756386d8-e2d4-4f09-905a-74b24313721f" +WORKSPACE = ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/DefaultResourceGroup-EUS/" + "providers/Microsoft.OperationalInsights/workspaces/OptimizeSentinelWorkspace" +) +OTHER_TENANT = "11111111-1111-1111-1111-111111111111" + + +class PreflightTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.solution = Path(self.temp.name) / "Sample" + self.solution.mkdir() + initialize_workflow( + self.solution, + workflow_profile="qualification", + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + @staticmethod + def _runner(command, **_kwargs): + if command[1:3] != ["version", "--output"]: + raise AssertionError(f"unexpected command: {command}") + return CompletedProcess(command, 0, "{}", "") + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_doctor_does_not_treat_independent_tokens_as_runtime_access( + self, _which: mock.Mock + ) -> None: + state_dir = Path(self.temp.name) / "state" + configure_workspace( + WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + state_dir=state_dir, + ) + commands = [] + + def runner(command, **_kwargs): + commands.append(command) + return CompletedProcess(command, 0, "{}", "") + + report = doctor( + state_dir=state_dir, + runner=runner, + environment={}, + ) + + self.assertFalse(report["runtimeValidationAvailable"]) + self.assertEqual("offline", report["mode"]) + self.assertEqual([["az", "version", "--output", "none"]], commands) + checks = {check["name"]: check for check in report["checks"]} + self.assertEqual("notRun", checks["sentinelAuthentication"]["status"]) + self.assertEqual("notRun", checks["advancedHunting"]["status"]) + self.assertEqual("passed", checks["sentinelWorkspace"]["status"]) + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_default_preflight_is_offline_and_does_not_materialize_target( + self, _which: mock.Mock + ) -> None: + target_path = artifact_path(self.solution, "qualification-target.json") + target_path.unlink() + + report = run_preflight( + self.solution, + runner=self._runner, + source_table="SecurityEvent", + ) + + self.assertEqual("unknown", report["overallStatus"]) + self.assertEqual("passed", report["target"]["status"]) + self.assertEqual("notRun", report["providers"]["source"]["tableAccess"]["status"]) + self.assertEqual("unknown", report["deploymentPermissions"]["status"]) + self.assertFalse(target_path.exists()) + self.assertEqual( + { + "writesPerformed": False, + "workspaceEnumerationPerformed": False, + "newAuthenticationPerformed": False, + }, + report["safety"], + ) + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_explicit_reads_keep_source_and_converted_provider_families_separate( + self, _which: mock.Mock + ) -> None: + requests = [] + + def executor(request): + requests.append(request) + return {"ok": True} + + report = run_preflight( + self.solution, + runner=self._runner, + read_executor=executor, + source_table="SecurityEvent", + source_query="SecurityEvent | take 0", + converted_table="DeviceEvents", + converted_query="DeviceEvents | take 0", + ) + + self.assertEqual( + [ + ("sentinel-source-table-read", "logAnalytics"), + ("sentinel-source-query-read", "logAnalytics"), + ("converted-graph-table-read", "graphAdvancedHunting"), + ("converted-graph-query-read", "graphAdvancedHunting"), + ], + [(request["scope"], request["provider"]) for request in requests], + ) + for request in requests: + expected = build_target( + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + ) + self.assertEqual( + { + key: expected[key] + for key in ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + }, + request["target"], + ) + self.assertEqual("passed", report["providers"]["source"]["tableAccess"]["status"]) + self.assertEqual("passed", report["providers"]["source"]["queryExecution"]["status"]) + self.assertEqual( + "passed", report["providers"]["converted"]["tableAccess"]["status"] + ) + self.assertEqual( + "passed", report["providers"]["converted"]["queryExecution"]["status"] + ) + self.assertEqual("unknown", report["deploymentPermissions"]["status"]) + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_locked_target_mismatch_prevents_all_runtime_reads( + self, _which: mock.Mock + ) -> None: + executor = mock.Mock(return_value={"ok": True}) + + report = run_preflight( + self.solution, + runner=self._runner, + tenant_id=OTHER_TENANT, + source_query="SecurityEvent | take 0", + read_executor=executor, + ) + + self.assertEqual("failed", report["target"]["status"]) + self.assertEqual("locked-target-identity-mismatch", report["target"]["reason"]) + executor.assert_not_called() + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_incomplete_locked_identity_prevents_all_runtime_reads( + self, _which: mock.Mock + ) -> None: + state_path = existing_artifact_path(self.solution, "workflow-state.json") + state = json.loads(state_path.read_text(encoding="utf-8")) + state["context"]["workspaceCustomerId"] = None + state_path.write_text(json.dumps(state), encoding="utf-8") + executor = mock.Mock(return_value={"ok": True}) + + report = run_preflight( + self.solution, + runner=self._runner, + source_query="SecurityEvent | take 0", + read_executor=executor, + ) + + self.assertEqual("failed", report["target"]["status"]) + self.assertEqual("locked-target-incomplete", report["target"]["reason"]) + executor.assert_not_called() + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_expired_and_denied_provider_responses_are_not_token_readiness( + self, _which: mock.Mock + ) -> None: + for status_code, auth_status, authz_status in ( + (401, "failed", "notRun"), + (403, "notRun", "failed"), + ): + with self.subTest(status_code=status_code): + report = run_preflight( + self.solution, + runner=self._runner, + source_query="SecurityEvent | take 0", + read_executor=lambda _request: { + "ok": False, + "statusCode": status_code, + }, + ) + provider = report["providers"]["source"] + self.assertEqual(auth_status, provider["authentication"]["status"]) + self.assertEqual(authz_status, provider["authorization"]["status"]) + self.assertEqual("failed", provider["queryExecution"]["status"]) + self.assertEqual("unknown", report["deploymentPermissions"]["status"]) + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_offline_and_throttled_reads_remain_unknown(self, _which: mock.Mock) -> None: + for executor in ( + lambda _request: (_ for _ in ()).throw(ConnectionError()), + lambda _request: {"ok": False, "statusCode": 429}, + ): + report = run_preflight( + self.solution, + runner=self._runner, + converted_query="DeviceEvents | take 0", + read_executor=executor, + ) + provider = report["providers"]["converted"] + self.assertEqual("unknown", provider["queryExecution"]["status"]) + self.assertEqual("notRun", provider["authentication"]["status"]) + self.assertEqual("unknown", report["deploymentPermissions"]["status"]) + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value="az") + def test_missing_table_is_failed_and_no_write_query_is_dispatched( + self, _which: mock.Mock + ) -> None: + executor = mock.Mock( + return_value={ + "ok": False, + "tableAvailable": False, + "failureKind": "missing-table", + } + ) + report = run_preflight( + self.solution, + runner=self._runner, + converted_table="DeviceEvents", + read_executor=executor, + ) + self.assertEqual( + "failed", report["providers"]["converted"]["tableAccess"]["status"] + ) + self.assertEqual( + "table-unavailable", + report["providers"]["converted"]["tableAccess"]["reason"], + ) + + executor.reset_mock() + with self.assertRaisesRegex(ValueError, "read-only KQL"): + run_preflight( + self.solution, + runner=self._runner, + source_query=".set-or-replace Table <| T", + read_executor=executor, + ) + executor.assert_not_called() + + @mock.patch("sentinel_xdr_migration.onboarding.shutil.which", return_value=None) + def test_azure_cli_missing_is_distinct_from_unchecked_identity( + self, _which: mock.Mock + ) -> None: + report = run_preflight(self.solution, runner=self._runner) + + self.assertEqual("failed", report["localTools"]["azureCli"]["status"]) + self.assertEqual("notRun", report["providers"]["source"]["authentication"]["status"]) + self.assertEqual("notRun", report["providers"]["converted"]["authentication"]["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_report_dashboard.py b/Tools/SentinelToXDRMigration/tests/test_report_dashboard.py new file mode 100644 index 00000000000..08cd6b67f18 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_report_dashboard.py @@ -0,0 +1,840 @@ +from __future__ import annotations + +import ast +from copy import deepcopy +from html.parser import HTMLParser +from pathlib import Path +import unittest + +from sentinel_xdr_migration.report_dashboard import _chart_bucket, _findings, _findings_chart_html, _flow_stage, _rule_status, _safe_local_href, _tactic_details, render_dashboard + + +def rule(name: str = "Example") -> dict: + return { + "name": name, "sourceFile": "Analytic Rules/Example.yaml", + "outputFile": "XDR Detections/Example.yaml", + "analyticRule": {"id": "source-id", "runtime": [], "deploymentStatus": "not-recorded", "alertStatus": "not-run"}, + "customDetection": { + "id": "output-id", "conversionStatus": "converted", "structuralStatus": "passed", + "reviewRequired": False, "reviewReasons": [], "runtime": [], + "deploymentStatus": "not-run", "alertStatus": "not-run", + }, + "errors": [], "warnings": [], + "queryParity": {"status": "not-run", "analyticRuleRows": None, "customDetectionRows": None, "matchKey": None}, + "queries": {"source": "DeviceEvents\n| take 1", "converted": "DeviceEvents\n| take 2"}, + } + + +def report(*rules: dict) -> dict: + return { + "solution": "Example solution", "generatedAt": "2026-10-07", + "summary": {"converted": len(rules), "needsReview": 0, "deploymentReady": len(rules)}, + "workflow": { + "runId": "example-run", "profile": "authoring", + "profileSelectionConfirmed": True, "status": "blocked", + "workspaceConfigured": False, + "stages": { + "conversion": {"status": "blocked", "message": "Semantic decisions remain; runtime not attempted.", "evidence": []}, + "validation": {"status": "pending"}, + "deployment": {"status": "notRequired"}, + }, + }, + "rules": list(rules), + } + + +class Structure(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.ids: list[str] = [] + self.controls: list[str] = [] + self.scripts = 0 + self.statuses: list[str] = [] + self.flow_stations: list[str] = [] + self.flow_station_links: list[tuple[str, str, str]] = [] + self.flow_href: str | None = None + self.flow_label: str | None = None + + def handle_starttag(self, tag: str, attrs: list) -> None: + values = dict(attrs) + if "id" in values: + self.ids.append(values["id"]) + if "aria-controls" in values: + self.controls.append(values["aria-controls"]) + if tag == "script": + self.scripts += 1 + if tag == "tbody" and values.get("class") == "rule": + self.statuses.append(values["data-status"]) + if values.get("data-flow-station"): + self.flow_stations.append(values["data-flow-station"]) + if self.flow_href is not None: + self.flow_station_links.append(( + values["data-flow-station"], self.flow_href, + self.flow_label or "", + )) + if tag == "a" and "flow-link" in values.get("class", "").split(): + self.flow_href = values.get("href") + self.flow_label = values.get("aria-label") + + def handle_endtag(self, tag: str) -> None: + if tag == "a" and self.flow_href is not None: + self.flow_href = None + self.flow_label = None + + +class ReportDashboardTests(unittest.TestCase): + def test_python_311_syntax(self) -> None: + path = Path(__file__).parents[1] / "sentinel_xdr_migration" / "report_dashboard.py" + ast.parse(path.read_text(), feature_version=(3, 11)) + + def test_current_run_counts_do_not_claim_25_ready(self) -> None: + rules = [rule(f"Rule {index}") for index in range(40)] + for item in rules[:15]: + item["customDetection"].update( + conversionStatus="needsReview", reviewRequired=True, + reviewReasons=["Choose a tactic"], + ) + value = report(*rules) + value["summary"].update(converted=25, needsReview=15) + before = deepcopy(value) + html = render_dashboard(value) + self.assertEqual(value, before) + for text in ("All (40)", "Needs review (15)", "Ready for validation (0)", + "Readiness unconfirmed (25)", "Blocked / Failed (0)", + "40 local structural passes", "25 automated conversions"): + self.assertIn(text, html) + self.assertNotIn("Deployment ready", html) + self.assertNotIn("AR rows: None", html) + self.assertEqual(html.count("Semantic decisions remain; runtime not attempted."), 2) + self.assertNotIn("deployment: not-run", html) + + def test_status_precedence_and_recorded_gate(self) -> None: + item = rule() + workflow = report(item)["workflow"] + self.assertEqual(_rule_status(item, workflow), "unconfirmed") + workflow["stages"]["conversion"]["status"] = "passed" + self.assertEqual(_rule_status(item, workflow), "ready") + item["customDetection"]["reviewReasons"] = ["Confirm identity"] + self.assertEqual(_rule_status(item, workflow), "review") + item["customDetection"]["runtime"] = [{"status": "blocked"}] + self.assertEqual(_rule_status(item, workflow), "failed") + item["customDetection"]["runtime"] = [] + item["customDetection"]["deploymentStatus"] = "failed" + self.assertEqual(_rule_status(item, workflow), "failed") + item = rule() + item["customDetection"]["conversionStatus"] = "not-run" + self.assertEqual(_rule_status(item, workflow), "notstarted") + item["customDetection"]["conversionStatus"] = "excluded" + self.assertEqual(_rule_status(item, workflow), "excluded") + + def test_dedupe_keeps_diagnostic_stages_and_raw_details(self) -> None: + item = rule() + item["errors"] = [ + {"stage": "conversion", "message": "Required output missing", "code": "LOCAL", "details": {"original": True}}, + {"stage": "structural-validation", "message": "conversion: Required output missing", "code": "CHECK"}, + ] + item["customDetection"]["reviewReasons"] = ["Confirm entity"] + item["warnings"] = ["Confirm entity"] + findings = _findings(item) + self.assertEqual(len(findings), 2) + self.assertEqual(findings[0]["stages"], ["conversion", "structural-validation"]) + self.assertEqual(len(findings[0]["evidence"]), 2) + self.assertEqual(findings[0]["evidence"][0]["details"], {"original": True}) + self.assertEqual(findings[1]["severity"], "review") + html = render_dashboard(report(item)) + self.assertIn("1 distinct rule errors · 2 diagnostic occurrences", html) + + def test_provider_gap_is_visible_as_information_not_runtime_parity(self) -> None: + item = rule() + item["analyticRule"]["runtime"] = [{ + "provider": "log-analytics-cli", + "status": "passed", + "providerFallback": { + "mcpProvider": "triage-mcp", + "advertisedTool": "QueryWorkspace", + "fallbackProvider": "log-analytics-cli", + "queryFamily": "sentinel", + "scopeReference": "/subscriptions/example/resourceGroups/lab/providers/Microsoft.OperationalInsights/workspaces/sentinel", + "reasonCode": "authorization", + "reason": "MCP workspace-query tool was unavailable.", + "batchComplete": True, + "scopeVerified": True, + "permissionsVerified": True, + "kqlError": False, + }, + }] + findings = _findings(item) + self.assertTrue(any( + value["severity"] == "info" and "provider gap" in value["message"].lower() + for value in findings + )) + self.assertEqual("unconfirmed", _rule_status(item, report(item)["workflow"])) + html = render_dashboard(report(item)) + self.assertIn("MCP workspace-query tool was unavailable.", html) + self.assertNotIn("parity passed", html.lower()) + self.assertIn("Automated check overview", html) + self.assertIn("local structural checks", html) + + def test_common_findings_count_rules_and_preserve_original_override(self) -> None: + first, second = rule("First"), rule("Second") + for number, item in enumerate((first, second)): + text = f"Choose tactic in ruleOverrides.11111111-1111-1111-1111-11111111111{number}.tactic" + item["customDetection"]["reviewReasons"] = [text] + item["warnings"] = [text] + html = render_dashboard(report(first, second)) + self.assertIn("Show 2 affected rules", html) + self.assertIn("ruleOverrides.<rule-id>.tactic", html) + self.assertIn("ruleOverrides.11111111-1111-1111-1111-111111111110.tactic", html) + + def test_exceptions_visible_despite_not_required_and_zero_preserved(self) -> None: + item = rule() + item["customDetection"]["runtime"] = [ + {"provider": "graph", "status": "failed", "rowCount": 0}, + {"provider": "another-provider", "status": "blocked", "rowCount": None}, + ] + item["errors"] = [{"stage": "custom-detection-runtime-validation", "message": "Permission denied"}] + html = render_dashboard(report(item)) + self.assertIn("Deployment: Not required", html) + self.assertIn("Blocked / Failed (1)", html) + self.assertIn("graph: Failed · 0 rows", html) + self.assertIn("another-provider: Blocked · Row count unavailable", html) + self.assertIn("Permission denied", html) + + def test_missing_evidence_not_global_skip(self) -> None: + value = report(rule()) + value.pop("workflow") + html = render_dashboard(value) + self.assertIn("Workflow stage evidence unavailable", html) + self.assertNotIn("Deployment: Not required", html) + self.assertIn("Runtime evidence available for 0 of 1 rules", html) + + def test_escape_untrusted_text_and_accessible_structure(self) -> None: + payload = '' + item = rule(payload) + item["queries"]["converted"] = payload + item["warnings"] = [payload] + item["sourceFile"] = payload + value = report(item) + value["workflow"]["stages"]["conversion"]["message"] = payload + html = render_dashboard(value) + parser = Structure() + parser.feed(html) + self.assertEqual(parser.scripts, 3) + self.assertEqual(len(parser.ids), len(set(parser.ids))) + self.assertTrue(set(parser.controls).issubset(parser.ids)) + self.assertNotIn(payload, html) + self.assertIn("</script>", html) + self.assertIn('aria-live="polite"', html) + self.assertIn('type="radio" name="status" value="attention" checked', html) + self.assertIn('html[data-theme="dark"]', html) + self.assertIn("Unified text diff", html) + + def test_unchanged_and_absent_queries(self) -> None: + item = rule() + item["queries"]["converted"] = item["queries"]["source"] + self.assertIn("Query unchanged", render_dashboard(report(item))) + item["queries"]["converted"] = None + self.assertIn("Comparison unavailable", render_dashboard(report(item))) + item.pop("queries") + self.assertIn("Source and converted queries were not recorded", render_dashboard(report(item))) + + def test_query_pair_is_expanded_and_precedes_diagnostics_in_details(self) -> None: + html = render_dashboard(report(rule())) + query_position = html.index('
    ') + checks_position = html.index("

    Recorded checks

    ") + self.assertLess(query_position, checks_position) + self.assertNotIn("Source / converted queries", html) + + def test_empty_inventory_and_unrecorded_consent_panels_are_omitted(self) -> None: + value = report(rule()) + value["deploymentPlanning"] = { + "profile": "qualification", + "deploymentPlan": { + "selectedProfile": "qualification", + "status": "provisional", + "packageResourceInventory": [{}], + "planningConsent": {"status": "not-recorded"}, + "components": { + "empty": { + "status": "not-recorded", + "planningConsent": "not-recorded", + "resourceTypes": [], + }, + }, + }, + } + html = render_dashboard(value) + self.assertNotIn("Package resource inventory", html) + self.assertNotIn("Advance planning consent", html) + self.assertNotIn("Component plans and advance consent", html) + + def test_unrecorded_consent_is_summarized_only_when_a_stage_records_it_as_blocking(self) -> None: + value = report(rule()) + value["deploymentPlanning"] = { + "deploymentPlan": { + "planningConsent": {"status": "not-recorded"}, + }, + } + value["workflow"]["stages"]["deployment"] = { + "status": "blocked", + "message": "Advance planning consent is required before this stage.", + } + html = render_dashboard(value) + self.assertIn("Not recorded · blocking at deployment stage", html) + + def test_real_inventory_and_denied_or_stale_consent_remain_visible(self) -> None: + for status in ("denied", "stale"): + with self.subTest(status=status): + value = report(rule()) + value["deploymentPlanning"] = { + "deploymentPlan": { + "packageResourceInventory": [{ + "type": "Microsoft.Security/detectionRules", + "apiVersion": "2026-06-01-preview", + "count": 1, + }], + "planningConsent": {"status": status}, + }, + } + html = render_dashboard(value) + self.assertIn("Package resource inventory", html) + self.assertIn("Microsoft.Security/detectionRules", html) + self.assertIn("Advance planning consent", html) + self.assertIn(status, html) + + def test_warning_never_implies_error_review_or_failed_readiness(self) -> None: + item = rule() + item["warnings"] = ["source queryPeriod has no direct Custom Detection schedule field; verify the KQL lookback"] + value = report(item) + self.assertEqual(_rule_status(item, value["workflow"]), "unconfirmed") + value["workflow"]["stages"]["conversion"]["status"] = "passed" + self.assertEqual(_rule_status(item, value["workflow"]), "ready") + findings = _findings(item) + self.assertEqual([finding["severity"] for finding in findings], ["warning"]) + html = render_dashboard(value) + for text in ("Warnings (1 rules)", "Errors (0 rules)", "Reviews (0 rules)", + "Ready for validation (1)", 'data-findings="warning"', "nonblocking advisories"): + self.assertIn(text, html) + self.assertIn('data-evidence-kind="warning"', html) + + def test_actual_failed_and_blocked_checks_are_distinct(self) -> None: + failed, blocked = rule("Failed"), rule("Blocked") + failed["customDetection"]["runtime"] = [{"status": "failed", "provider": "graph", "rowCount": 0}] + blocked["customDetection"]["runtime"] = [{"status": "blocked", "provider": "graph"}] + blocked["errors"] = [{ + "stage": "custom-detection-runtime-validation", "provider": "graph", "message": "Missing prerequisite", + }] + self.assertEqual([finding["severity"] for finding in _findings(failed)], ["error"]) + self.assertEqual([finding["severity"] for finding in _findings(blocked)], ["blocked"]) + html = render_dashboard(report(failed, blocked)) + self.assertIn("Errors (1 rules)", html) + self.assertIn("Blocked prerequisites (1 rules)", html) + self.assertIn("Blocked — not an execution failure", html) + + def test_explicit_review_deduplicates_matching_warning_without_promoting_others(self) -> None: + item = rule() + item["customDetection"]["reviewReasons"] = ["Choose tactic"] + item["warnings"] = ["Choose tactic", "Verify schedule"] + item["errors"] = [{"stage": "conversion", "message": "Hard failure"}] + findings = _findings(item) + self.assertEqual([finding["severity"] for finding in findings], ["error", "review", "warning"]) + self.assertEqual(findings[1]["stages"], ["conversion-review", "conversion-warning"]) + html = render_dashboard(report(item)) + self.assertIn("Errors (1 rules)", html) + self.assertIn("Reviews (1 rules)", html) + self.assertIn("Warnings (1 rules)", html) + + def test_explicit_review_flag_without_reason_has_review_finding(self) -> None: + item = rule() + item["customDetection"]["reviewRequired"] = True + self.assertEqual(_findings(item)[0]["severity"], "review") + self.assertIn("Reviews (1 rules)", render_dashboard(report(item))) + + def test_information_has_no_readiness_or_attention_effect(self) -> None: + item = rule() + item["informational"] = ["Mapped entity equivalently", "Frequency and effective window match"] + value = report(item) + value["workflow"]["stages"]["conversion"]["status"] = "passed" + self.assertEqual(_rule_status(item, value["workflow"]), "ready") + self.assertEqual(_chart_bucket(item), "green") + html = render_dashboard(value) + for text in ("Needs attention (0)", "Warnings (0 rules)", "Errors (0 rules)", "Reviews (0 rules)", + "Information (1 rules)", 'data-evidence-kind="info"', 'data-chart="green"'): + self.assertIn(text, html) + item["warnings"] = ["Frequency and effective window match"] + self.assertEqual({finding["severity"] for finding in _findings(item)}, {"warning", "info"}) + self.assertEqual(_chart_bucket(item), "yellow") + + def test_chart_counts_each_rule_once_by_priority(self) -> None: + green, yellow, red, gray, blocked = [rule(name) for name in ("Green", "Yellow", "Red", "Gray", "Blocked")] + green["informational"] = ["Nonblocking mapping note"] + yellow["warnings"] = ["Different effective window"] + yellow["customDetection"]["reviewReasons"] = ["Select tactic"] + red["errors"] = [{"stage": "conversion", "message": "Hard error"}] + red["warnings"] = ["Warning too"] + red["customDetection"]["reviewReasons"] = ["Review too"] + gray["customDetection"].update(conversionStatus="not-run", structuralStatus="not-generated") + blocked["customDetection"]["runtime"] = [{"status": "blocked", "provider": "graph"}] + self.assertEqual([_chart_bucket(item) for item in (green, yellow, red, gray, blocked)], + ["green", "yellow", "red", "gray", "gray"]) + html = render_dashboard(report(green, yellow, red, gray, blocked)) + for text in ("Automated checks passed: 1 rules (20.0%)", "Warnings or review required: 1 rules (20.0%)", + "Errors: 1 rules (20.0%)", "Readiness unconfirmed: 2 rules (40.0%)", + 'class="chart-total" x="21" y="21">5'): + self.assertIn(text, html) + self.assertIn('tabindex="0" role="button"', html) + self.assertIn("Readiness unconfirmed in the status filter", html) + + def test_findings_pie_counts_findings_not_rules_and_keeps_green_separate(self) -> None: + mixed = rule("Mixed") + mixed["errors"] = [ + {"stage": "conversion", "message": "Failure"}, + {"stage": "structural-validation", "message": "conversion: Failure"}, + ] + mixed["customDetection"]["reviewReasons"] = ["Choose tactic"] + mixed["warnings"] = ["Choose tactic", "Window differs", "Window differs"] + mixed["informational"] = ["Equivalent", "Equivalent"] + clean, info, blocked = rule("Clean"), rule("Info"), rule("Blocked") + info["informational"] = ["Matched mapping"] + blocked["customDetection"]["runtime"] = [{"status": "blocked", "provider": "graph"}] + html = _findings_chart_html([mixed, clean, info, blocked]) + for text in ("5 distinct findings", "Errors: 1 findings (20.0%)", + "Warnings / reviews: 2 findings (40.0%)", "Informational: 2 findings (40.0%)", + "2 rules passed automated checks", "Blocked prerequisite findings (1)"): + self.assertIn(text, html) + self.assertNotIn('class="pie-green"', html) + self.assertIn('data-findings-chart-filter="attention"', html) + + def test_findings_pie_handles_empty_and_single_category(self) -> None: + self.assertIn("0 distinct findings", _findings_chart_html([])) + self.assertIn('class="pie-empty"', _findings_chart_html([])) + item = rule() + item["informational"] = ["Only info"] + html = _findings_chart_html([item]) + self.assertIn(' None: + item = rule("Three tactic source") + item["customDetection"]["reviewReasons"] = [ + "Custom Detections support one tactic; configure ruleOverrides.source-id.tactic and techniques" + ] + item["attackClassification"] = { + "sourceTactics": ["Execution", "Collection", "Exfiltration"], + "sourceTechniques": ["T1059", "T1005", "T1020"], + "originalTactics": ["Execution", "Collection", "Exfiltration"], + "originalTechniques": ["T1059", "T1005", "T1020"], + "draftTactics": [{"tactic": "Execution"}], + } + html = render_dashboard(report(item, deepcopy(item))) + self.assertEqual(html.count('id="tactic-selection-guide"'), 1) + for text in ("Unapproved fallback shape", "techniques omitted", "independent list", + "multiple compatible techniques and subtechniques", "not one technique only", + "Multiple tactics alone no longer require review", "T1059", "T1005", "T1020", + "Preserve source tactics; review historical single-tactic findings", "collection-shaped property", + "not a claim", "ARM was tested"): + self.assertIn(text, html) + self.assertIn("Errors (0 rules)", html) + self.assertIn("Reviews (2 rules)", html) + + def test_compatible_multiple_techniques_are_not_reported_as_fallback(self) -> None: + item = rule() + item["customDetection"]["reviewReasons"] = ["Custom Detections support one tactic"] + item["attackClassification"] = { + "originalTactics": ["Execution", "Collection"], + "draftTactics": [{"tactic": "Execution", "techniques": [ + {"technique": "T1059", "subTechniques": ["T1059.001"]}, + {"technique": "T1106"}, + ]}], + } + html = render_dashboard(report(item)) + self.assertNotIn("Unapproved fallback shape", html) + self.assertIn("T1059.001", html) + self.assertIn("T1106", html) + self.assertIn("Classification metadata not recorded", html) + self.assertIn("not treated as proof that a selection was approved", html) + + def test_tactic_metadata_and_rule_specific_example_are_escaped_not_selected(self) -> None: + class PreText(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.blocks = [] + self.active = False + + def handle_starttag(self, tag, attrs): + if tag == "pre": + self.active = True + self.blocks.append("") + + def handle_endtag(self, tag): + if tag == "pre": + self.active = False + + def handle_data(self, data): + if self.active: + self.blocks[-1] += data + + import json + + payload = '' + item = rule() + item["analyticRule"]["id"] = payload + item["customDetection"]["reviewReasons"] = ["Custom Detections support one tactic"] + item["attackClassification"] = {"sourceTactics": [payload], "draftTactics": [{"tactic": payload}]} + html = _tactic_details(item, _findings(item)) + self.assertNotIn(payload, html) + parser = PreText() + parser.feed(html) + example = json.loads(parser.blocks[-1]) + self.assertEqual(list(example["ruleOverrides"]), [payload]) + override = example["ruleOverrides"][payload] + self.assertEqual(override["tactic"], "") + self.assertEqual(len(override["techniques"]), 2) + self.assertTrue(all(value.startswith("<") for value in override["techniques"])) + self.assertIn("illustrative, not ready to apply", html) + + def test_authoring_flow_is_compact_and_never_omits_mandatory_validation(self) -> None: + value = report(rule()) + html = render_dashboard(value) + parser = Structure() + parser.feed(html) + self.assertIn("Authoring · no lab qualification", html) + self.assertIn("mandatory structural validation and packaging remain on this route", html) + self.assertIn('viewBox="0 0 1160 245"', html) + self.assertIn("Mandatory structural validation and recorded runtime checks.", html) + self.assertNotIn('data-flow-station="deployment"', html) + self.assertNotIn('data-flow-station="mockIngestion"', html) + self.assertNotIn('data-flow-station="alertParity"', html) + self.assertEqual( + parser.flow_stations, + ["setup", "discovery", "conversion", "validation", "packaging", "report", "publication"], + ) + self.assertEqual( + [name for name, _, _ in parser.flow_station_links], + parser.flow_stations, + ) + self.assertTrue(all( + href.startswith("#flow-evidence-") and label + for _, href, label in parser.flow_station_links + )) + self.assertTrue(all( + href[1:] in parser.ids for _, href, _ in parser.flow_station_links + )) + self.assertLess(html.index('class="panel subway-panel"'), html.index('id="deployment-planning"')) + self.assertLess(html.index('data-flow-station="setup"'), html.index('data-flow-station="discovery"')) + self.assertIn("Necessary setup / initial preflight", html) + self.assertIn( + "does not run doctor, authenticate, or make provider/network calls", + html, + ) + self.assertIn("Advance read-only runtime-query consent", html) + self.assertIn('data-flow-station="validation"', html) + self.assertIn("Informational snapshot · no write authorization", html) + self.assertNotIn("Advance planning consent", html) + self.assertIn("Exact-scope write approval", html) + + def test_planning_inventory_has_a_readable_scrolling_table_and_collapsed_hashes(self) -> None: + value = report(rule()) + value["deploymentPlanning"] = { + "profile": "qualification", + "deploymentPlan": { + "selectedProfile": "qualification", + "executionMode": "pairs", + "status": "provisional", + "target": {"workspaceResourceId": "/subscriptions/example/workspaces/demo"}, + "packageResourceInventory": [{ + "type": "Microsoft.Security/detectionRules", + "apiVersion": "2026-06-01-preview", + "count": 1, + "sourceForms": ["languageVersion 2 resource dictionary"], + "extensionResource": True, + }], + "planningConsent": {"status": "not-recorded"}, + "exactScopeWriteApproval": "not-recorded", + }, + } + html = render_dashboard(value) + self.assertIn("planning-summary-grid", html) + self.assertIn("planning-table-scroll", html) + self.assertIn("min-width: 940px", html) + self.assertIn("word-break: normal", html) + self.assertIn("Package hashes and plan provenance", html) + self.assertIn("Microsoft.Security/detectionRules", html) + self.assertIn("2026-06-01-preview", html) + self.assertIn("Qualification · direct AR/CD pairs first", html) + self.assertIn("/subscriptions/example/workspaces/demo", html) + self.assertLess(html.index('class="panel subway-panel"'), html.index('id="deployment-planning"')) + + def test_non_consentable_planning_coverage_shows_status_and_reason_without_approval(self) -> None: + value = report(rule()) + value["deploymentPlanning"] = { + "deploymentPlan": { + "components": { + "controlledAlertParity": { + "consentable": False, + "status": "provisional", + "advanceConsentSupported": False, + "eligiblePairRuleIds": ["reviewed-rule-id"], + "maximumRulePairCount": 2, + "maximumEnableWriteRequests": 4, + "maximumRestoreWriteRequests": 4, + "maximumWriteRequestsForOneBoundedRun": 8, + "timeWindow": {"status": "not-recorded"}, + "planningConsent": "approved", + }, + "ownedResourceCleanup": { + "consentable": False, + "status": "unsupported", + "advanceConsentSupported": False, + "deleteCap": 0, + "resourceIds": [], + "resourceTypes": [], + "ownershipEvidence": "not-recorded", + "existingResourceDeletionAllowed": False, + "unownedResourceDeletionAllowed": False, + }, + }, + }, + } + html = render_dashboard(value) + parity_card = html.split("

    controlledAlertParity

    ", 1)[1].split("
    ", 1)[0] + cleanup_card = html.split("

    ownedResourceCleanup

    ", 1)[1].split("", 1)[0] + self.assertIn("Coverage status: provisional", parity_card) + self.assertIn("not consentable and not deployment approval", parity_card) + self.assertIn("Advance consent support: No", parity_card) + self.assertIn("maximumWriteRequestsForOneBoundedRun", parity_card) + self.assertIn("not-recorded", parity_card) + self.assertIn("2", parity_card) + self.assertNotIn("Advance consent:", parity_card) + self.assertNotIn("approved", parity_card) + self.assertIn("Coverage status: unsupported", cleanup_card) + self.assertIn("deleteCap", cleanup_card) + self.assertIn("ownershipEvidence", cleanup_card) + self.assertIn("existingResourceDeletionAllowed", cleanup_card) + self.assertNotIn("Advance consent:", cleanup_card) + + def test_qualification_flow_adds_preflight_before_deployment_and_preserves_failure(self) -> None: + value = report(rule()) + value["workflow"].update( + profile="qualification", + profileSelectionConfirmed=True, + status="blocked", + packageFreshness={"status": "current", "reason": None}, + ) + value["workflow"]["stages"].update({ + "packaging": {"status": "passed", "evidence": ["package-report.json"]}, + "deployment": { + "status": "blocked", "message": "Local full-suite validation failed; Graph500011.", + "evidence": ["preflight-report.json"], "attempts": 1, + "startedAt": "2026-10-11T10:00:00+00:00", + "completedAt": "2026-10-11T10:00:02+00:00", + }, + "mockIngestion": {"status": "pending"}, + "alertParity": {"status": "pending"}, + }) + html = render_dashboard(value) + parser = Structure() + parser.feed(html) + self.assertIn("Qualification · lab validation", html) + self.assertIn("Recorded workflow status: Blocked", html) + self.assertIn("Local full-suite validation failed; Graph500011.", html) + self.assertIn("Package freshness: current", html) + self.assertIn("Elapsed: 2.0 seconds", html) + self.assertIn("Evidence is recorded but unavailable or non-portable", html) + self.assertIn('id="flow-evidence-preflight"', html) + self.assertLess(parser.flow_stations.index("packaging"), parser.flow_stations.index("deployment")) + self.assertLess(parser.flow_stations.index("packaging"), parser.flow_stations.index("preflight")) + self.assertLess(parser.flow_stations.index("packaging"), parser.flow_stations.index("validationSuite")) + self.assertLess(parser.flow_stations.index("validationSuite"), parser.flow_stations.index("preflight")) + self.assertLess(parser.flow_stations.index("preflight"), parser.flow_stations.index("deployment")) + self.assertLess(parser.flow_stations.index("deployment"), parser.flow_stations.index("mockIngestion")) + self.assertLess(parser.flow_stations.index("mockIngestion"), parser.flow_stations.index("queryValidation")) + self.assertLess(parser.flow_stations.index("queryValidation"), parser.flow_stations.index("alertParity")) + self.assertLess(parser.flow_stations.index("alertParity"), parser.flow_stations.index("report")) + self.assertLess(parser.flow_stations.index("alertParity"), parser.flow_stations.index("packageAcceptance")) + self.assertLess(parser.flow_stations.index("packageAcceptance"), parser.flow_stations.index("report")) + self.assertLess(parser.flow_stations.index("report"), parser.flow_stations.index("publication")) + self.assertIn('class="flow-station state-blocked"', html) + self.assertIn('data-flow-station="preflight"', html) + self.assertIn("Graph preflight not recorded", html) + self.assertIn("Current full-suite result not recorded", html) + self.assertIn("Full-package deployment and read-back are separate", html) + + def test_offline_validation_label_requires_passed_stage(self) -> None: + value = report(rule()) + value["runtimeAssessment"] = { + "runtimeStatus": "failed", + "fullyRuntimeQualified": False, + } + value["rules"][0]["analyticRule"]["runtime"] = [{ + "status": "failed", + "provider": "triage-mcp", + }] + validation = value["workflow"]["stages"]["validation"] + validation.update({ + "status": "blocked", + "attempts": 1, + "artifacts": { + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + "message": "A current runtime validation failed; offline completion was invalidated.", + }) + html = render_dashboard(value) + self.assertIn("Validation: Blocked", html) + self.assertIn("A current runtime validation failed; offline completion was invalidated.", html) + self.assertNotIn("Passed — offline Authoring; runtime not run", html) + + for status, expected in ( + ("failed", "Validation: Failed"), + ("running", "Validation: Running"), + ("pending", "Validation: Pending"), + ): + validation["status"] = status + html = render_dashboard(value) + self.assertIn(expected, html) + self.assertNotIn("Passed — offline Authoring; runtime not run", html) + + validation["status"] = "passed" + html = render_dashboard(value) + self.assertIn("Passed — offline Authoring; runtime not run", html) + + def test_unknown_mode_is_not_inferred_and_attempted_qualification_failures_stay_visible(self) -> None: + value = report(rule()) + value["workflow"].pop("profileSelectionConfirmed") + value["workflow"]["stages"]["deployment"] = { + "status": "failed", "message": "Recorded deployment failure" + } + html = render_dashboard(value) + self.assertIn("Mode not recorded · qualification evidence present", html) + self.assertNotIn("Authoring · no lab qualification", html) + self.assertIn("Recorded deployment failure", html) + self.assertIn('data-flow-station="deployment"', html) + self.assertIn("class=\"subway qualification\"", html) + + def test_legacy_missing_mode_and_stage_metadata_remain_unknown(self) -> None: + value = report(rule()) + value["workflow"].pop("profileSelectionConfirmed") + value["workflow"].pop("profile") + value["workflow"].pop("stages") + html = render_dashboard(value) + self.assertIn("Mode not recorded", html) + self.assertNotIn("no lab qualification", html) + self.assertIn('class="flow-station state-unknown"', html) + self.assertIn("Evidence unavailable", html) + self.assertNotIn('data-flow-station="deployment"', html) + + def test_stale_or_unverified_package_never_appears_completed(self) -> None: + value = report(rule()) + value["workflow"]["stages"]["packaging"] = {"status": "passed"} + value["workflow"]["packageFreshness"] = { + "status": "stale", "reason": "package inputs changed", + } + html = render_dashboard(value) + self.assertIn("Stale package", html) + self.assertIn("Package freshness: stale", html) + self.assertIn("package inputs changed", html) + value["workflow"].pop("packageFreshness") + html = render_dashboard(value) + self.assertIn("Freshness unverified", html) + + def test_flow_statuses_keep_running_unknown_and_recorded_details_distinct(self) -> None: + running = _flow_stage({ + "status": "running", "attempts": 2, + "startedAt": "2026-10-11T10:00:00+00:00", + "updatedAt": "2026-10-11T10:01:00+00:00", + "evidence": ["stage-evidence.json"], + }) + self.assertEqual(running["state"], "running") + self.assertEqual(running["display"], "In progress") + self.assertEqual(running["attempts"], "2") + self.assertEqual(running["evidence"], ["stage-evidence.json"]) + self.assertEqual(running["timing"], "Started: 2026-10-11T10:00:00+00:00") + self.assertEqual(_flow_stage({"status": "unrecognized"})["state"], "unknown") + self.assertEqual(_flow_stage({"status": "notRequired"})["display"], "Not required") + + def test_flow_is_data_driven_and_escapes_evidence_without_run_specific_facts(self) -> None: + value = report(rule()) + hostile = '' + value["workflow"]["stages"]["conversion"].update( + status="running", message=hostile, evidence=[hostile] + ) + html = render_dashboard(value) + self.assertNotIn(hostile, html) + self.assertIn("</script><img", html) + self.assertNotIn("Cisco", html) + self.assertNotIn("3.1.1", html) + self.assertIn('data-flow-station="conversion"', html) + + def test_preflight_details_use_persisted_checks_without_claiming_write_approval(self) -> None: + value = report(rule()) + value["qualificationPreflight"] = { + "status": "blocked", + "graphRecorded": True, + "authentication": { + "status": "acquired", "method": "azure-cli", + "requestedScope": "graph-resource-default", "tenantMatch": "unknown", + }, + "permissions": { + "readAccess": "unknown", "writeScopeInToken": "unknown", + "consent": "unknown", "writeAuthorization": "not-tested", + }, + "requiredManagementPermission": "CustomDetection.ReadWrite.All", + "managementPermissionGrant": { + "status": "missing", "grantType": "delegated", + "claimType": "scp", "permission": "CustomDetection.ReadWrite.All", + "credentialRequestScope": "graph-resource-default", + }, + "deploymentReadiness": "action-required", + "effectiveRoleStatus": "unknown", + "tokenPermissionClaims": {"status": "present", "claimType": "scp"}, + "readChecks": [{"status": "blocked", "httpStatus": 500}], + "dcr": { + "status": "action-required", "authenticationStatus": "unavailable", + "targetVerificationStatus": "not-checked", "localReadinessStatus": "ready", + "workspaceQueryPermissionStatus": "unknown", "contractCount": 1, + }, + } + html = render_dashboard(value) + self.assertIn('data-flow-station="preflight"', html) + self.assertIn('class="flow-station state-blocked"', html) + self.assertIn("Selected cached-auth method: azure-cli", html) + self.assertIn("Exact-rule GET checks: blocked · 500", html) + self.assertIn("DCR preflight: action-required", html) + self.assertIn("Required management permission: CustomDetection.ReadWrite.All", html) + self.assertIn("Required write-grant status: missing", html) + self.assertIn("Deployment readiness assessment: action-required", html) + self.assertIn("Effective management role: unknown", html) + self.assertIn("Read access: unknown", html) + self.assertIn("Preflight is diagnostic only", html) + self.assertIn("not deployment or write approval", html.lower()) + self.assertIn("Query-permission preflight is not semantic query parity", html) + + def test_station_artifact_links_reject_untrusted_urls_and_preserve_informational_targets(self) -> None: + value = report(rule()) + value["flowEvidence"] = { + "packaging": [ + {"available": True, "label": "package & report.json", "href": "artifacts/package%20report.json"}, + {"available": True, "label": "", "href": "javascript:alert(1)"}, + {"available": True, "label": "traversal", "href": "%2e%2e/private.json"}, + ], + } + html = render_dashboard(value) + self.assertIn('href="artifacts/package%20report.json"', html) + self.assertIn("package & report.json", html) + self.assertNotIn('href="javascript:', html) + self.assertNotIn('href="%2e%2e/private.json"', html) + self.assertNotIn("", html) + self.assertNotIn("onerror=alert(1)", html) + for unsafe in ( + "javascript:alert(1)", "data:text/html,hi", "//host/file", + "/etc/passwd", "../outside.json", "%2e%2e/outside.json", + "folder/%5c%5cserver", "folder/%0aheader", + ): + self.assertIsNone(_safe_local_href(unsafe), unsafe) + for safe in ("evidence/check.json", "Reports/run/file%20name.json"): + self.assertEqual(safe, _safe_local_href(safe)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_report_review.py b/Tools/SentinelToXDRMigration/tests/test_report_review.py new file mode 100644 index 00000000000..18213d78423 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_report_review.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from copy import deepcopy +import unittest + +from sentinel_xdr_migration.report import render_transformation_report, render_runtime_validation_report +from sentinel_xdr_migration.report_review import render_bulk_review, review_candidates +from test_report_dashboard import report, rule + + +def review_rule(name: str, rule_id: str, tactics: list[str], techniques: list[str]) -> dict: + item = rule(name) + item["analyticRule"]["id"] = rule_id + item["sourceSha256"] = "a" * 64 + item["customDetection"]["reviewReasons"] = ["Custom Detections support one tactic"] + item["attackClassification"] = {"sourceTactics": tactics, "sourceTechniques": techniques} + return item + + +class ReportReviewTests(unittest.TestCase): + def test_eligibility_requires_unique_ids_source_choices_and_snapshot_hash(self) -> None: + first = review_rule("First", "a", ["Execution", "Collection"], ["T1059", "T1005"]) + value = report(first) + before = deepcopy(value) + self.assertTrue(review_candidates(value)[0]["eligible"]) + self.assertEqual(value, before) + self.assertFalse(review_candidates(report(first, deepcopy(first)))[0]["eligible"]) + first.pop("sourceSha256") + self.assertIn("hash", review_candidates(report(first))[0]["reason"]) + first["sourceSha256"] = "a" * 64 + first["attackClassification"]["sourceTechniques"] = ["not-a-technique"] + self.assertFalse(review_candidates(report(first))[0]["eligible"]) + + def test_export_controls_are_only_for_explicit_tactic_review(self) -> None: + item = rule() + item["warnings"] = ["Unsupported mapping loses evidence", "Frequency mismatch"] + item["customDetection"]["reviewReasons"] = ["Confirm account mapping"] + self.assertEqual(review_candidates(report(item)), []) + self.assertEqual(render_bulk_review(report(item), []), "") + + def test_payload_is_escaped_and_export_contract_is_explicit(self) -> None: + payload = '' + item = review_rule(payload, payload, ["Execution"], ["T1059"]) + value = report(item) + html = render_bulk_review(value, review_candidates(value)) + self.assertNotIn(payload, html) + self.assertIn("</script>", html) + for text in ("Pending export decisions", "individual", "no local write service", "source-file SHA-256", + "does not validate this review manifest", "--config", "--overwrite", "do not"): + self.assertIn(text.lower(), html.lower()) + self.assertNotIn("acceptReviewReasons", html) + + def test_transformation_information_is_separate_nonblocking_and_escaped(self) -> None: + value = { + "solution": "Example", "total": 1, "converted": 1, "needsReview": 0, "conflicts": 0, + "results": [{"source": "rule.yaml", "output": "draft.yaml", "status": "converted", + "informational": [""], "warnings": [], "errors": []}], + } + before = deepcopy(value) + html = render_transformation_report(value) + self.assertEqual(value, before) + self.assertIn("Information — nonblocking", html) + self.assertIn("Rules with information1", html) + self.assertIn("<script>alert(1)</script>", html) + self.assertIn("--cp-bg: #f7f4ef", html) + self.assertNotIn("", html) + value["results"][0].pop("informational") + self.assertIn("Rules with information0", render_transformation_report(value)) + + def test_runtime_theme_preserves_results(self) -> None: + html = render_runtime_validation_report({ + "platform": "Example", "solution": "Example", "provider": "local", + "total": 1, "valid": 1, "invalid": 0, "blocked": 0, + "results": [{"detection": "Rule", "status": "passed", "rowCount": 0, "schemaColumnCount": 1}], + }) + self.assertIn("--cp-bg: #f7f4ef", html) + self.assertIn("0", html) diff --git a/Tools/SentinelToXDRMigration/tests/test_report_review_browser.py b/Tools/SentinelToXDRMigration/tests/test_report_review_browser.py new file mode 100644 index 00000000000..d20953a7398 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_report_review_browser.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import tempfile +import unittest + +from sentinel_xdr_migration.report_dashboard import render_dashboard +from sentinel_xdr_migration.converter import load_config +from test_report_dashboard import report, rule +from test_report_review import review_rule + + +@unittest.skipUnless(os.environ.get("SENTINEL_REPORT_BROWSER_TESTS") == "1", "Opt-in installed-browser tests") +class ReportReviewBrowserTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + from playwright.sync_api import sync_playwright + + cls.playwright = sync_playwright().start() + cls.browser = cls.playwright.chromium.launch( + channel=os.environ.get("SENTINEL_REPORT_BROWSER_CHANNEL", "chrome"), headless=True + ) + + @classmethod + def tearDownClass(cls) -> None: + cls.browser.close() + cls.playwright.stop() + + def setUp(self) -> None: + self.context = self.browser.new_context(accept_downloads=True, viewport={"width": 1440, "height": 1000}) + self.page = self.context.new_page() + self.errors = [] + self.remote = [] + self.page.on("pageerror", lambda error: self.errors.append(str(error))) + self.page.on("request", lambda request: self.remote.append(request.url) if request.url.startswith(("http:", "https:")) else None) + first = review_rule("First", "a", ["Execution", "Collection"], ["T1059", "T1005"]) + second = review_rule("Second", "b", ["Collection", "Exfiltration"], ["T1005", "T1020"]) + green = rule("Info only") + green["analyticRule"]["id"] = "c" + green["informational"] = ["Equivalent entity mapping"] + red = rule("Failed") + red["analyticRule"]["id"] = "d" + red["errors"] = [{"stage": "conversion", "message": "Hard failure"}] + gray = rule("Unknown") + gray["analyticRule"]["id"] = "e" + gray["customDetection"].update(conversionStatus="not-run", structuralStatus="not-generated") + self.value = report(first, second, green, red, gray) + self.page.set_content(render_dashboard(self.value)) + + def tearDown(self) -> None: + self.assertFalse(self.errors, self.errors) + self.assertFalse(self.remote, self.remote) + self.context.close() + + def select_rules(self) -> None: + self.page.locator("#bulk-review > details > summary").click() + self.page.locator("#select-visible").click() + + def choose(self, rule_id: str, tactic: str, technique: str, *, confirm: bool = True) -> None: + section = self.page.locator(f'fieldset[data-decision-id="{rule_id}"]') + section.locator('select[data-choice="tactic"]').select_option(tactic) + section.locator(f'input[data-choice="technique"][value="{technique}"]').check() + if confirm: + section.locator('input[data-choice="confirmed"]').check() + + def test_details_opens_with_source_and_converted_queries_visible(self) -> None: + self.page.get_by_role("button", name="Details for First").click() + detail = self.page.locator("#rule-detail-0") + self.assertTrue(detail.is_visible()) + self.assertIn("Source Sentinel KQL", detail.inner_text()) + self.assertIn("Converted XDR KQL", detail.inner_text()) + self.assertIn("DeviceEvents", detail.inner_text()) + self.assertLess( + detail.inner_text().index("Source Sentinel KQL"), + detail.inner_text().index("Recorded checks"), + ) + self.assertFalse(detail.locator("details > summary", has_text="queries").count()) + self.assertIn("not proof of semantic equivalence", detail.inner_text()) + + def test_empty_inventory_and_unrecorded_consent_are_omitted_but_denial_is_visible(self) -> None: + self.value["deploymentPlanning"] = { + "deploymentPlan": { + "packageResourceInventory": [], + "planningConsent": {"status": "not-recorded"}, + }, + } + self.page.set_content(render_dashboard(self.value)) + self.assertEqual(self.page.get_by_role("heading", name="Package resource inventory").count(), 0) + self.assertEqual(self.page.locator(".planning-summary-card", has_text="Advance planning consent").count(), 0) + + self.value["deploymentPlanning"]["deploymentPlan"].update({ + "packageResourceInventory": [{ + "type": "Microsoft.Security/detectionRules", + "apiVersion": "2026-06-01-preview", + "count": 1, + }], + "planningConsent": {"status": "denied"}, + }) + self.page.set_content(render_dashboard(self.value)) + self.assertEqual(self.page.get_by_role("heading", name="Package resource inventory").count(), 1) + self.assertIn("Microsoft.Security/detectionRules", self.page.locator("#deployment-planning").inner_text()) + self.assertEqual(self.page.locator(".planning-summary-card", has_text="Advance planning consent").count(), 1) + self.assertIn("denied", self.page.locator("#deployment-planning").inner_text()) + + def test_bulk_refuses_incompatible_rules_and_requires_individual_confirmation(self) -> None: + self.select_rules() + self.page.locator("#bulk-tactic").select_option("Execution") + self.page.locator("#set-bulk-tactic").click() + self.assertIn("Second: Execution is not in the source tactic list", self.page.locator("#bulk-feedback").inner_text()) + self.assertEqual(self.page.locator('input[data-choice="technique"]:checked').count(), 0) + self.assertEqual(self.page.locator('input[data-choice="confirmed"]:checked').count(), 0) + self.page.locator("#new-config").check() + self.page.locator("#preview-decisions").click() + self.assertIn("at least one compatible technique", self.page.locator("#review-feedback").inner_text()) + self.choose("a", "Execution", "T1059", confirm=False) + self.choose("b", "Collection", "T1005") + self.page.locator("#preview-decisions").click() + self.assertIn("individual compatibility/behavior confirmation", self.page.locator("#review-feedback").inner_text()) + self.page.locator('fieldset[data-decision-id="a"] input[data-choice="confirmed"]').check() + self.page.locator("#preview-decisions").click() + self.assertTrue(self.page.locator("#review-preview").is_visible()) + self.assertEqual(self.page.locator('tbody.rule[data-status="review"]').count(), 2) + self.page.locator('fieldset[data-decision-id="a"] input[value="T1059"]').uncheck() + self.assertTrue(self.page.locator("#download-config").is_disabled()) + self.assertFalse(self.page.locator('fieldset[data-decision-id="a"] input[data-choice="confirmed"]').is_checked()) + + def test_import_merge_download_preserves_settings_and_prototype_keys_as_data(self) -> None: + self.select_rules() + self.choose("a", "Execution", "T1059") + self.choose("b", "Collection", "T1005") + config = { + "workspace": "retain", "__proto__": {"polluted": "no"}, "constructor": {"prototype": {"polluted": "no"}}, + "ruleOverrides": { + "a": {"tactic": "Collection", "techniques": ["T1005"], "entityMappings": {"custom": "preserve"}}, + "other": {"tactic": "Execution", "techniques": ["T1106"]}, + "__proto__": {"polluted": "no"}, + }, + } + self.page.locator("#review-config-file").set_input_files({ + "name": "existing.json", "mimeType": "application/json", "buffer": json.dumps(config).encode() + }) + self.page.get_by_text("Imported existing.json; unrelated settings will be preserved.", exact=True).wait_for() + self.page.locator("#preview-decisions").click() + merged = json.loads(self.page.locator("#config-preview").inner_text()) + self.assertEqual(merged["workspace"], "retain") + self.assertEqual(merged["ruleOverrides"]["other"], config["ruleOverrides"]["other"]) + self.assertEqual(merged["ruleOverrides"]["a"]["entityMappings"], {"custom": "preserve"}) + self.assertEqual(merged["ruleOverrides"]["a"]["tactic"], "Execution") + self.assertEqual(merged["__proto__"], config["__proto__"]) + self.assertEqual(merged["ruleOverrides"]["__proto__"], config["ruleOverrides"]["__proto__"]) + self.assertIsNone(self.page.evaluate("({}).polluted")) + manifest = json.loads(self.page.locator("#manifest-preview").inner_text()) + self.assertFalse(manifest["applied"]) + self.assertEqual(len(manifest["decisions"]), 2) + self.assertTrue(all(item["sourceSha256"] == "a" * 64 for item in manifest["decisions"])) + with tempfile.TemporaryDirectory() as folder: + for button, name, expected in ( + ("download-config", "migration-reviewed-overrides.json", merged), + ("download-manifest", "migration-reviewed-overrides.review.json", manifest), + ): + with self.page.expect_download() as event: + self.page.locator("#" + button).click() + download = event.value + self.assertEqual(download.suggested_filename, name) + target = Path(folder) / name + download.save_as(target) + self.assertEqual(json.loads(target.read_text()), expected) + if button == "download-config": + self.assertEqual(load_config(Path(folder), explicit=target), merged) + self.assertEqual(self.page.locator('tbody.rule[data-status="review"]').count(), 2) + self.page.locator("#clear-selection").click() + self.assertEqual(self.page.locator(".review-select:checked").count(), 0) + self.assertFalse(self.page.locator("#review-preview").is_visible()) + + def test_chart_keyboard_filters_info_green_and_mobile_theme(self) -> None: + for color, count in (("green", 1), ("yellow", 2), ("red", 1), ("gray", 1)): + self.page.locator(f'button[data-chart-filter="{color}"]').click() + self.assertEqual(self.page.locator("tbody.rule:visible").count(), count) + arc = self.page.locator('circle[data-chart-filter="green"]') + arc.focus() + self.page.keyboard.press("Enter") + self.assertEqual(self.page.locator("tbody.rule:visible").count(), 1) + self.assertIn("Info only", self.page.locator("tbody.rule:visible").inner_text()) + self.page.locator('input[name="finding"][value="info"]').check() + self.assertEqual(self.page.locator("tbody.rule:visible").count(), 1) + self.page.locator("#clear").click() + self.assertEqual(self.page.locator("tbody.rule:visible").count(), 5) + self.page.emulate_media(color_scheme="dark") + self.page.set_content(render_dashboard(self.value)) + self.assertEqual(self.page.locator("html").get_attribute("data-theme"), "dark") + self.page.set_viewport_size({"width": 390, "height": 844}) + self.assertTrue(self.page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")) + + def test_findings_chart_filters_warning_review_union_separately_from_rules_chart(self) -> None: + for kind, count in (("error", 1), ("attention", 2), ("info", 1)): + self.page.locator(f'button[data-findings-chart-filter="{kind}"]').click() + self.assertEqual(self.page.locator("tbody.rule:visible").count(), count) + segment = self.page.locator('path[data-findings-chart-filter="attention"]') + segment.focus() + self.page.keyboard.press(" ") + self.assertEqual(self.page.locator("tbody.rule:visible").count(), 2) + self.assertIn("Warnings / reviews", self.page.locator("#finding-chart-filter-label").inner_text()) + self.page.locator('button[data-chart-filter="green"]').click() + self.assertEqual(self.page.locator("tbody.rule:visible").count(), 1) + self.assertEqual(self.page.locator("#finding-chart-filter-label").inner_text(), "") + self.page.locator('button[data-findings-chart-filter="error"]').click() + self.assertEqual(self.page.locator("#chart-filter-label").inner_text(), "") + self.page.locator("#clear").click() + self.assertEqual(self.page.locator("tbody.rule:visible").count(), 5) + self.assertEqual(self.page.locator("#finding-chart-filter-label").inner_text(), "") + + def test_hostile_payload_and_invalid_import_are_not_executed(self) -> None: + self.value["rules"][0]["name"] = '' + self.page.set_content(render_dashboard(self.value)) + self.select_rules() + self.assertIsNone(self.page.evaluate("window.injected")) + self.page.locator("#review-config-file").set_input_files({ + "name": "invalid.json", "mimeType": "application/json", "buffer": b'{"ruleOverrides":{"a":null}}' + }) + self.page.get_by_text("Import rejected:", exact=False).wait_for() + self.page.locator("#preview-decisions").click() + self.assertIn("Import the existing configuration", self.page.locator("#review-feedback").inner_text()) + self.assertTrue(self.page.locator("#download-config").is_disabled()) + + def test_migration_flow_adapts_to_recorded_profile_and_station_evidence_links(self) -> None: + self.assertEqual( + self.page.locator("[data-flow-station]").evaluate_all( + "nodes => nodes.map(node => node.dataset.flowStation)" + ), + ["discovery", "conversion", "validation", "packaging", "report", "publication"], + ) + validation = self.page.locator('[data-flow-station="validation"]') + validation.focus() + self.page.keyboard.press("Enter") + self.assertEqual(self.page.evaluate("location.hash"), "#flow-evidence-validation") + self.assertIn("Mandatory structural validation", self.page.locator("#flow-evidence-validation").inner_text()) + + self.value["workflow"].update( + profile="qualification", + profileSelectionConfirmed=True, + status="blocked", + packageFreshness={"status": "current", "reason": None}, + ) + self.value["workflow"]["stages"].update({ + "packaging": {"status": "passed"}, + "deployment": {"status": "blocked", "message": "Local suite failed."}, + "mockIngestion": {"status": "pending"}, + "alertParity": {"status": "pending"}, + }) + self.page.set_content(render_dashboard(self.value)) + self.assertIn("Qualification · lab validation", self.page.locator("#flow-heading").inner_text()) + self.assertEqual( + self.page.locator("[data-flow-station]").evaluate_all( + "nodes => nodes.map(node => node.dataset.flowStation)" + ), + [ + "discovery", "conversion", "validation", "packaging", + "validationSuite", "preflight", "deployment", "mockIngestion", + "queryValidation", "alertParity", "packageAcceptance", + "report", "publication", + ], + ) + deployment = self.page.locator('[data-flow-station="deployment"]') + deployment.focus() + self.page.keyboard.press("Enter") + self.assertEqual(self.page.evaluate("location.hash"), "#flow-evidence-deployment") + self.assertIn("Local suite failed.", self.page.locator("#flow-evidence-deployment").inner_text()) + self.assertIn("Blocked", self.page.locator("#flow-evidence-deployment").inner_text()) + self.page.locator('[data-flow-station="publication"]').click() + self.assertEqual(self.page.evaluate("location.hash"), "#flow-evidence-publication") + self.assertIn("Not tracked", self.page.locator("#flow-evidence-publication").inner_text()) + self.assertIn("does not prove publication", self.page.locator("#flow-evidence-publication").inner_text()) + self.assertTrue(self.page.locator('[data-flow-station="packageAcceptance"]').is_visible()) + self.assertIn( + "full-package deployment and read-back are separate", + self.page.locator("#flow-evidence-packageAcceptance").inner_text().lower(), + ) + self.page.set_viewport_size({"width": 390, "height": 844}) + self.assertTrue(self.page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")) diff --git a/Tools/SentinelToXDRMigration/tests/test_rule_scoped_conversion.py b/Tools/SentinelToXDRMigration/tests/test_rule_scoped_conversion.py new file mode 100644 index 00000000000..d65fe95ea78 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_rule_scoped_conversion.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import hashlib +import io +import json +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.artifacts import artifact_path, using_run +from sentinel_xdr_migration.cli import main +from sentinel_xdr_migration.converter import convert_solution, validate_solution +from sentinel_xdr_migration.packaging import package_solution_v3_1 +from sentinel_xdr_migration.solution_report import build_solution_report +from sentinel_xdr_migration.workflow import ( + complete_workflow_stage, + initialize_workflow, + start_workflow_stage, + workflow_status, +) +from test_connector_metadata import CONNECTORS +from test_converter import RULE + + +SOURCE_ID = "ABCDEF12-2222-3333-4444-555555555555" +OTHER_ID = "abcdef12-2222-3333-4444-666666666666" +UNKNOWN_ID = "abcdef12-2222-3333-4444-777777777777" + + +class RuleScopedConversionTests(unittest.TestCase): + def setUp(self): + self.fixture = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.fixture.cleanup) + self.solution = Path(self.fixture.name) + self.source = self.solution / "Analytic Rules/Execution/Rule.YML" + self.other_source = self.solution / "Analytic Rules/Discovery/Rule.yaml" + for path, rule_id in ((self.source, SOURCE_ID), (self.other_source, OTHER_ID)): + path.parent.mkdir(parents=True) + path.write_text(RULE.replace("11111111-2222-3333-4444-555555555555", rule_id)) + self.output = self.solution / "XDR Detections/Execution/Rule.YML" + self.other_output = self.solution / "XDR Detections/Discovery/Rule.yaml" + + def snapshot(self): + return { + path.relative_to(self.solution).as_posix(): path.read_bytes() if path.is_file() else None + for path in self.solution.rglob("*") + } + + def config(self, exclusions): + path = self.solution / "config.json" + path.write_text(json.dumps({"excludedRuleIds": exclusions})) + return path + + def test_new_selected_output_preserves_nested_unselected_exclusion(self): + self.other_output.parent.mkdir(parents=True) + draft = b"# unrelated draft retained exactly\r\nmanual: true\r\n" + self.other_output.write_bytes(draft) + config = self.config({OTHER_ID: "not part of pilot"}) + sources_before = self.source.read_bytes(), self.other_source.read_bytes() + result = convert_solution( + self.solution, rule_id=SOURCE_ID.lower(), config_path=config, overwrite=True, + ) + self.assertEqual(1, result["total"]) + self.assertEqual(1, result["converted"]) + self.assertEqual(0, result["excluded"]) + self.assertEqual(draft, self.other_output.read_bytes()) + self.assertEqual(sources_before, (self.source.read_bytes(), self.other_source.read_bytes())) + document = yaml.safe_load(self.output.read_text()) + self.assertEqual(f"xdr-{SOURCE_ID}", document["properties"]["id"]) + self.assertEqual("disabled", document["properties"]["status"]) + self.assertEqual({ + "kind": "rule", "ruleIds": [SOURCE_ID], "sourceTotal": 2, "selectedTotal": 1, + }, result["scope"]) + manifest = json.loads(Path(result["manifest"]).read_text()) + self.assertEqual(result["scope"], manifest["scope"]) + self.assertEqual("XDR Detections/Execution/Rule.YML", manifest["results"][0]["outputRelativePath"]) + self.assertIn("not full-solution", Path(result["transformationReport"]).read_text()) + + def test_refresh_preserves_unselected_bytes_version_identity_and_connectors(self): + convert_solution(self.solution) + original = yaml.safe_load(self.output.read_text()) + original["version"] = "3.1.7" + self.output.write_text(yaml.safe_dump(original, sort_keys=False)) + source = yaml.safe_load(self.source.read_text()) + source["name"] = "Renamed pilot" + source["requiredDataConnectors"] = CONNECTORS + source["query"] += "| where AccountUpn != ''\n" + self.source.write_text(yaml.safe_dump(source, sort_keys=False)) + before = self.output.read_bytes(), self.other_output.read_bytes() + config = self.config({OTHER_ID: "leave draft alone"}) + result = convert_solution(self.solution, rule_id=SOURCE_ID, config_path=config) + self.assertEqual(1, result["conflicts"]) + self.assertEqual(before, (self.output.read_bytes(), self.other_output.read_bytes())) + result = convert_solution(self.solution, rule_id=SOURCE_ID, config_path=config, overwrite=True) + self.assertEqual(0, result["conflicts"]) + self.assertEqual(before[1], self.other_output.read_bytes()) + updated = yaml.safe_load(self.output.read_text()) + self.assertEqual("3.1.7", updated["version"]) + self.assertEqual(f"xdr-{SOURCE_ID}", updated["properties"]["id"]) + self.assertEqual(CONNECTORS, updated["requiredDataConnectors"]) + self.assertEqual( + hashlib.sha256(source["query"].encode()).hexdigest(), + updated["contentProvenance"]["source"]["querySha256"], + ) + self.assertNotEqual( + original["contentProvenance"]["source"]["querySha256"], + updated["contentProvenance"]["source"]["querySha256"], + ) + updated["requiredDataConnectors"][0]["extension"]["labels"].append("local edit") + self.assertEqual(CONNECTORS, yaml.safe_load(self.source.read_text())["requiredDataConnectors"]) + self.assertEqual(1, len(json.loads(Path(result["manifest"]).read_text())["results"])) + self.assertEqual("rule", validate_solution(self.solution)["conversionScope"]["kind"]) + + def test_invalid_selector_is_atomic_with_and_without_existing_artifacts(self): + for existing in (False, True): + if existing: + convert_solution(self.solution) + for rule_id in ("", "not-a-guid", SOURCE_ID.replace("-", ""), SOURCE_ID + " ", UNKNOWN_ID): + with self.subTest(existing=existing, rule_id=rule_id): + before = self.snapshot() + with self.assertRaises(ValueError): + convert_solution(self.solution, rule_id=rule_id, overwrite=True) + self.assertEqual(before, self.snapshot()) + with redirect_stderr(io.StringIO()), redirect_stdout(io.StringIO()): + self.assertEqual(2, main([ + "convert", "--solution", str(self.solution), + "--rule-id", rule_id, "--overwrite", + ])) + self.assertEqual(before, self.snapshot()) + + def test_duplicate_case_variant_and_zero_match_fail_before_writes(self): + self.other_source.write_text(self.source.read_text().replace(SOURCE_ID, SOURCE_ID.lower())) + before = self.snapshot() + with self.assertRaisesRegex(ValueError, "found 2"): + convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + self.assertEqual(before, self.snapshot()) + self.source.unlink() + self.other_source.unlink() + before = self.snapshot() + with self.assertRaisesRegex(ValueError, "found 0"): + convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + self.assertEqual(before, self.snapshot()) + + def test_cli_rejects_repeated_selectors_before_writes(self): + for second in (SOURCE_ID, OTHER_ID): + before = self.snapshot() + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error: + main(["convert", "--solution", str(self.solution), + "--rule-id", SOURCE_ID, "--rule-id", second]) + self.assertEqual(2, error.exception.code) + self.assertEqual(before, self.snapshot()) + + def test_selected_exclusion_is_never_deleted_or_reported_success(self): + for existing in (False, True): + if existing: + convert_solution(self.solution) + for excluded_id in (SOURCE_ID, SOURCE_ID.lower()): + for reason in ("not supported", None): + config = self.config({excluded_id: reason}) + before = self.snapshot() + with self.assertRaisesRegex(ValueError, "excluded rule"): + convert_solution( + self.solution, rule_id=SOURCE_ID, config_path=config, overwrite=True, + ) + self.assertEqual(before, self.snapshot()) + with redirect_stderr(io.StringIO()), redirect_stdout(io.StringIO()): + self.assertEqual(2, main([ + "convert", "--solution", str(self.solution), "--rule-id", SOURCE_ID, + "--config", str(config), "--overwrite", + ])) + self.assertEqual(before, self.snapshot()) + + def test_unselected_output_identity_and_provenance_collisions_still_block(self): + convert_solution(self.solution) + other = yaml.safe_load(self.other_output.read_text()) + for field in ("id", "provenance"): + document = json.loads(json.dumps(other)) + if field == "id": + document["properties"]["id"] = SOURCE_ID.lower() + else: + document["contentProvenance"]["source"]["id"] = SOURCE_ID + self.other_output.write_text(yaml.safe_dump(document)) + before = self.output.read_bytes(), self.other_output.read_bytes() + result = convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + self.assertEqual(1, result["conflicts"]) + self.assertEqual(before, (self.output.read_bytes(), self.other_output.read_bytes())) + + def test_relocated_and_custom_identity_outputs_are_not_replaced(self): + convert_solution(self.solution) + relocated = self.output.parents[1] / self.output.name + self.output.rename(relocated) + result = convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + self.assertEqual(1, result["conflicts"]) + self.assertFalse(self.output.exists()) + relocated.rename(self.output) + document = yaml.safe_load(self.output.read_text()) + document["properties"]["id"] = "hand-authored-id" + self.output.write_text(yaml.safe_dump(document)) + before = self.output.read_bytes() + self.assertEqual(1, convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True)["conflicts"]) + self.assertEqual(before, self.output.read_bytes()) + + def test_default_full_conversion_retains_exclusion_behavior(self): + convert_solution(self.solution, rule_id=SOURCE_ID) + result = convert_solution(self.solution) + self.assertEqual(2, result["total"]) + self.assertEqual("solution", result["scope"]["kind"]) + self.assertTrue(self.other_output.exists()) + result = convert_solution(self.solution, config_path=self.config({OTHER_ID: "exclude"}), overwrite=True) + self.assertEqual(1, result["converted"]) + self.assertEqual(1, result["excluded"]) + self.assertFalse(self.other_output.exists()) + self.assertTrue(self.output.exists()) + + def test_solution_report_exposes_partial_scope_and_unselected_not_run(self): + convert_solution(self.solution) + convert_solution(self.solution, rule_id=SOURCE_ID) + report = build_solution_report(self.solution) + self.assertEqual("rule", report["summary"]["conversionScope"]["kind"]) + self.assertEqual(2, report["summary"]["rules"]) + self.assertEqual(1, report["summary"]["converted"]) + self.assertEqual( + ["converted", "not-run"], + sorted(rule["customDetection"]["conversionStatus"] for rule in report["rules"]), + ) + self.assertIn("Rule-scoped conversion only", Path(report["htmlReport"]).read_text()) + self.assertIn(SOURCE_ID, Path(report["htmlReport"]).read_text()) + + def test_scoped_legacy_id_transition_preserves_release_and_connector_metadata(self): + convert_solution(self.solution) + document = yaml.safe_load(self.output.read_text()) + document["properties"]["id"] = "xdr-suspicious-test-activity-" + SOURCE_ID[:8] + document["version"] = "3.1.9" + self.output.write_text(yaml.safe_dump(document, sort_keys=False)) + before = self.other_output.read_bytes() + result = convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + self.assertEqual(0, result["conflicts"]) + document = yaml.safe_load(self.output.read_text()) + self.assertEqual(f"xdr-{SOURCE_ID}", document["properties"]["id"]) + self.assertEqual("3.1.9", document["version"]) + self.assertEqual("local-artifact-only", document["contentProvenance"]["conversion"]["identityChange"]["scope"]) + self.assertEqual(before, self.other_output.read_bytes()) + for connectors in (CONNECTORS, [], None, "absent"): + with self.subTest(connectors=connectors): + source = yaml.safe_load(self.source.read_text()) + if connectors == "absent": + source.pop("requiredDataConnectors", None) + else: + source["requiredDataConnectors"] = connectors + self.source.write_text(yaml.safe_dump(source, sort_keys=False)) + result = convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + self.assertEqual(int(connectors is None), result["needsReview"]) + updated = yaml.safe_load(self.output.read_text()) + self.assertEqual(connectors != "absent", "requiredDataConnectors" in updated) + if connectors != "absent": + self.assertEqual(connectors, updated["requiredDataConnectors"]) + self.assertEqual(before, self.other_output.read_bytes()) + + def test_cli_selection_preserves_run_and_returns_scoped_manifest(self): + initialized = initialize_workflow(self.solution, workflow_profile="authoring") + second = initialize_workflow(self.solution, workflow_profile="authoring", new_run=True) + second_state = Path(second["statePath"]).read_bytes() + with using_run(self.solution, initialized["runId"]): + config = artifact_path(self.solution, "migration-config.yaml") + config.write_text(yaml.safe_dump({"excludedRuleIds": {OTHER_ID: "not part of pilot"}})) + output = io.StringIO() + with redirect_stdout(output): + code = main([ + "convert", "--solution", str(self.solution), "--run-id", initialized["runId"], + "--rule-id", SOURCE_ID, + ]) + self.assertEqual(0, code) + result = json.loads(output.getvalue()) + self.assertEqual("rule", result["scope"]["kind"]) + self.assertIn(initialized["runId"], result["manifest"]) + self.assertFalse(self.other_output.exists()) + self.assertEqual(second_state, Path(second["statePath"]).read_bytes()) + self.assertFalse((Path(second["statePath"]).parent / "manifest.json").exists()) + + def test_scoped_results_block_workflow_completion_and_packaging(self): + initialize_workflow(self.solution, workflow_profile="authoring") + convert_solution(self.solution) + for stage in ("discovery", "conversion", "validation"): + start_workflow_stage(self.solution, stage) + artifacts = None + message = None + if stage == "validation": + artifacts = {"runtimeStatus": "environment-blocked"} + message = "Runtime validation was not run." + complete_workflow_stage( + self.solution, + stage, + status="passed", + message=message, + artifacts=artifacts, + evidence=["prior evidence"], + ) + convert_solution(self.solution, rule_id=SOURCE_ID, overwrite=True) + state = workflow_status(self.solution) + self.assertEqual("blocked", state["workflowStatus"]) + self.assertEqual("passed", state["stages"]["discovery"]["status"]) + self.assertEqual("blocked", state["stages"]["conversion"]["status"]) + self.assertEqual("blocked", state["stages"]["validation"]["status"]) + self.assertEqual(["prior evidence"], state["stages"]["validation"]["evidence"]) + self.assertEqual("rule", state["conversionScope"]["kind"]) + start_workflow_stage(self.solution, "conversion") + before = self.snapshot() + with self.assertRaisesRegex(ValueError, "rule-scoped"): + complete_workflow_stage(self.solution, "conversion", status="passed") + for stage in ("validation", "packaging", "report"): + with self.assertRaisesRegex(ValueError, "rule-scoped"): + complete_workflow_stage(self.solution, stage, status="passed") + with self.assertRaisesRegex(ValueError, "rule-scoped"): + start_workflow_stage(self.solution, "packaging") + with mock.patch("sentinel_xdr_migration.packaging.subprocess.run") as process: + with self.assertRaisesRegex(ValueError, "rule-scoped"): + package_solution_v3_1(self.solution, version_bump="patch") + process.assert_not_called() + self.assertEqual(before, self.snapshot()) + convert_solution(self.solution, overwrite=True) + complete_workflow_stage(self.solution, "conversion", status="passed") + start_workflow_stage(self.solution, "validation") + complete_workflow_stage( + self.solution, + "validation", + status="passed", + message="Runtime validation was not run.", + artifacts={"runtimeStatus": "environment-blocked"}, + ) + self.assertEqual("solution", workflow_status(self.solution)["conversionScope"]["kind"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_run_scoping.py b/Tools/SentinelToXDRMigration/tests/test_run_scoping.py new file mode 100644 index 00000000000..b18f66ddf01 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_run_scoping.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import hashlib +import io +import json +import logging +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from copy import deepcopy +from pathlib import Path + +from sentinel_xdr_migration.artifacts import ( + MIGRATION_RECEIPT, RUN_MARKER, artifact_path, existing_artifact_path, + list_runs, migrate_reports, portable_artifact, report_directory, reports_base, + resolve_artifact_reference, using_run, write_json_artifact, +) +from sentinel_xdr_migration.cli import main +from sentinel_xdr_migration.converter import configure_logging +from sentinel_xdr_migration.workflow import ( + initialize_workflow, workflow_status, start_workflow_stage, complete_workflow_stage, +) + + +class RunScopingTests(unittest.TestCase): + def setUp(self): + self.storage = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.storage.cleanup) + self.solution = Path(self.storage.name) / "Solutions" / "Space Solution" + self.solution.mkdir(parents=True) + + def init(self, **kwargs): + return initialize_workflow(self.solution, workflow_profile="authoring", **kwargs) + + def legacy_flat(self): + state = self.init() + path = Path(state["statePath"]) + document = json.loads(path.read_text()) + document.pop("runId") + document["workflowStatus"] = "blocked" + document["stages"]["discovery"]["status"] = "passed" + document["stages"]["conversion"]["status"] = "blocked" + document["stages"]["conversion"]["message"] = "22 drafts require review" + document["stages"]["conversion"]["artifacts"] = { + "manifest": "Logs/sentinel-xdr-migration/manifest.json", + } + flat = reports_base(self.solution) + path.unlink() + (path.parent / RUN_MARKER).unlink() + remaining = {item.name for item in path.parent.iterdir()} + self.assertEqual(remaining, {".workflow-state.lock"}) + (path.parent / ".workflow-state.lock").unlink() + path.parent.rmdir() + (flat / "workflow-state.json").write_text(json.dumps(document)) + (flat / "manifest.json").write_text(json.dumps({"converted": 18, "needsReview": 22})) + return flat, document + + def cli(self, *args): + output, errors = io.StringIO(), io.StringIO() + with redirect_stdout(output), redirect_stderr(errors): + status = main(list(args)) + return status, output.getvalue(), errors.getvalue() + + def test_new_workflow_new_id_and_resume_same_id(self): + first = self.init() + resumed = self.init() + self.assertEqual(first["runId"], resumed["runId"]) + self.assertTrue(resumed["resumed"]) + second = self.init(new_run=True) + self.assertNotEqual(first["runId"], second["runId"]) + self.assertFalse(second["resumed"]) + self.assertEqual(2, len(list_runs(self.solution))) + self.assertEqual(first["runId"], Path(first["statePath"]).parent.name) + self.assertEqual(second["runId"], Path(second["statePath"]).parent.name) + + def test_ambiguous_default_fails_and_explicit_selection_is_isolated(self): + first, second = self.init(), self.init(new_run=True) + with self.assertRaisesRegex(ValueError, "multiple migration runs"): + workflow_status(self.solution) + with self.assertRaisesRegex(ValueError, "multiple migration runs"): + report_directory(self.solution, create=True) + with using_run(self.solution, first["runId"]): + start_workflow_stage(self.solution, "discovery") + complete_workflow_stage(self.solution, "discovery", status="blocked", message="first only") + manifest = artifact_path(self.solution, "manifest.json") + write_json_artifact(self.solution, manifest, {"run": "first"}) + with using_run(self.solution, second["runId"]): + self.assertEqual("pending", workflow_status(self.solution)["stages"]["discovery"]["status"]) + self.assertFalse(existing_artifact_path(self.solution, "manifest.json").exists()) + with using_run(self.solution, first["runId"]): + self.assertEqual("blocked", workflow_status(self.solution)["workflowStatus"]) + + def test_unknown_and_traversal_run_ids_cannot_write(self): + with self.assertRaisesRegex(ValueError, "safe directory"): + with using_run(self.solution, "../escape"): + self.init() + with using_run(self.solution, "unknown"): + with self.assertRaisesRegex(ValueError, "does not exist"): + report_directory(self.solution, create=True) + self.assertEqual([], list_runs(self.solution)) + + def test_invalid_profile_does_not_allocate_a_run(self): + with self.assertRaises(ValueError): + initialize_workflow(self.solution, new_run=True) + self.assertEqual([], list_runs(self.solution)) + + def test_missing_marker_cannot_fall_back_to_stale_flat_state(self): + self.legacy_flat() + migrated = migrate_reports(self.solution, apply=True) + marker = reports_base(self.solution) / migrated["runId"] / RUN_MARKER + marker.unlink() + with self.assertRaisesRegex(ValueError, "marker is missing"): + workflow_status(self.solution) + + def test_flat_migration_preview_apply_preserves_status_and_originals(self): + flat, state = self.legacy_flat() + originals = {path.name: path.read_bytes() for path in flat.iterdir() if path.is_file()} + with self.assertWarns(UserWarning): + self.assertEqual("blocked", workflow_status(self.solution)["workflowStatus"]) + preview = migrate_reports(self.solution) + self.assertEqual([], list_runs(self.solution)) + with using_run(self.solution, preview["runId"]): + result = migrate_reports(self.solution, apply=True) + migrated = workflow_status(self.solution) + self.assertEqual("blocked", migrated["workflowStatus"]) + self.assertEqual(state["stages"]["conversion"]["message"], migrated["stages"]["conversion"]["message"]) + manifest = migrated["stages"]["conversion"]["artifacts"]["manifest"] + self.assertEqual(artifact_path(self.solution, "manifest.json"), + resolve_artifact_reference(self.solution, manifest)) + self.assertIn(preview["runId"], manifest) + self.assertEqual([], migrate_reports(self.solution, apply=True)["files"]) + self.assertEqual(preview["destination"], result["destination"]) + for name, value in originals.items(): + self.assertEqual(value, (flat / name).read_bytes()) + self.assertTrue((flat / result["runId"] / MIGRATION_RECEIPT).is_file()) + + def test_prior_flat_receipt_preserves_newer_flat_state_over_archived_root(self): + flat, state = self.legacy_flat() + root_reports = self.solution.parents[1] / "Reports" / self.solution.name / "sentinel-xdr-migration" + root_reports.mkdir(parents=True) + old_state = deepcopy(state) + old_state["stages"]["discovery"]["status"] = "blocked" + old = root_reports / "workflow-state.json" + old.write_text(json.dumps(old_state)) + (flat / MIGRATION_RECEIPT).write_text(json.dumps({ + old.name: [hashlib.sha256(old.read_bytes()).hexdigest()], + })) + migrate_reports(self.solution, apply=True) + self.assertEqual("passed", workflow_status(self.solution)["stages"]["discovery"]["status"]) + self.assertEqual("blocked", json.loads(old.read_text())["stages"]["discovery"]["status"]) + old.write_text("{}") + with self.assertRaisesRegex(ValueError, "changed after migration"): + workflow_status(self.solution) + + def test_unreceipted_root_and_flat_conflict_blocks_without_creating_run(self): + flat, _ = self.legacy_flat() + root_reports = self.solution.parents[1] / "Reports" / self.solution.name / "sentinel-xdr-migration" + root_reports.mkdir(parents=True) + (root_reports / "workflow-state.json").write_text("{}") + with self.assertRaisesRegex(ValueError, "conflicting"): + migrate_reports(self.solution, apply=True) + self.assertEqual([], list_runs(self.solution)) + self.assertTrue((flat / "workflow-state.json").is_file()) + + def test_archived_legacy_does_not_leak_into_new_run(self): + self.legacy_flat() + imported = migrate_reports(self.solution, apply=True)["runId"] + fresh = self.init(new_run=True) + with using_run(self.solution, fresh["runId"]): + self.assertFalse(existing_artifact_path(self.solution, "manifest.json").exists()) + self.assertEqual("pending", workflow_status(self.solution)["stages"]["conversion"]["status"]) + with using_run(self.solution, imported): + self.assertEqual("blocked", workflow_status(self.solution)["stages"]["conversion"]["status"]) + + def test_cross_run_packaging_references_are_rejected(self): + first, second = self.init(), self.init(new_run=True) + with using_run(self.solution, second["runId"]): + reference = f"Logs/sentinel-xdr-migration/{first['runId']}/packaging.v3_1.json" + with self.assertRaisesRegex(ValueError, "different migration run"): + resolve_artifact_reference(self.solution, reference) + with self.assertRaisesRegex(ValueError, "selected migration run"): + write_json_artifact(self.solution, Path(first["statePath"]), {}) + + def test_cli_run_selection_and_migration_reuse_preview_id(self): + self.legacy_flat() + code, output, _ = self.cli("migrate-reports", "--solution", str(self.solution)) + self.assertEqual(0, code) + run_id = json.loads(output)["runId"] + code, _, error = self.cli("migrate-reports", "--solution", str(self.solution), "--run-id", run_id, "--apply") + self.assertEqual(0, code, error) + self.init(new_run=True) + code, _, error = self.cli("workflow-status", "--solution", str(self.solution)) + self.assertEqual(2, code) + self.assertIn("multiple migration runs", error) + code, output, error = self.cli("workflow-status", "--solution", str(self.solution), "--run-id", run_id) + self.assertEqual(0, code, error) + self.assertEqual(run_id, json.loads(output)["runId"]) + code, output, _ = self.cli("workflow-runs", "--solution", str(self.solution)) + self.assertEqual(2, len(json.loads(output)["runs"])) + + def test_logging_switches_only_toolkit_handlers_between_runs(self): + first, second = self.init(), self.init(new_run=True) + try: + for item, message in [(first, "FIRST RUN"), (second, "SECOND RUN")]: + with using_run(self.solution, item["runId"]), redirect_stderr(io.StringIO()): + configure_logging(self.solution) + logging.info(message) + first_log = Path(first["statePath"]).parent / "sentinel-xdr-migration.log" + second_log = Path(second["statePath"]).parent / "sentinel-xdr-migration.log" + self.assertNotIn("SECOND RUN", first_log.read_text()) + self.assertNotIn("FIRST RUN", second_log.read_text()) + finally: + for handler in list(logging.getLogger().handlers): + if getattr(handler, "_sentinel_xdr_handler", False): + logging.getLogger().removeHandler(handler) + handler.close() diff --git a/Tools/SentinelToXDRMigration/tests/test_runtime_consent.py b/Tools/SentinelToXDRMigration/tests/test_runtime_consent.py new file mode 100644 index 00000000000..7438f814cb0 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_runtime_consent.py @@ -0,0 +1,1445 @@ +from __future__ import annotations + +import json +import hashlib +import threading +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import yaml + +from sentinel_xdr_migration import cli +from sentinel_xdr_migration.artifacts import artifact_path, existing_artifact_path +from sentinel_xdr_migration.runtime_consent import ( + build_runtime_query_plan, + confirm_primary_lab_setting, + persist_runtime_query_plan, + record_runtime_query_consent, + revoke_runtime_query_consent, + require_runtime_query_consent, + runtime_query_consent_status, +) +from sentinel_xdr_migration import runtime_consent +from sentinel_xdr_migration.runtime import ( + record_runtime_validation, + validate_advanced_hunting, + validate_sentinel_sdk, +) +from sentinel_xdr_migration.target_context import require_locked_target +from sentinel_xdr_migration.workflow import initialize_workflow + +TENANT_ID = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" +SUBSCRIPTION_ID = "42382e39-f157-46d1-a931-b8cfd779ece5" +CUSTOMER_ID = "756386d8-e2d4-4f09-905a-74b24313721f" +WORKSPACE = ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/QueryLab/" + "providers/Microsoft.OperationalInsights/workspaces/QueryWorkspace" +) +SOURCE = "sentinelSourceQueryFamily" +XDR = "defenderXdrQueryFamily" + + +class RuntimeQueryConsentTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.solution = self.base / "Solutions" / "QueryConsent" + self.solution.mkdir(parents=True) + self.state_dir = self.base / "auth-state" + patcher = mock.patch( + "sentinel_xdr_migration.runtime_consent._state_dir", + return_value=self.state_dir, + ) + patcher.start() + self.addCleanup(patcher.stop) + profile_patcher = mock.patch( + "sentinel_xdr_migration.runtime_consent.configured_azure_cli_profile", + return_value=str(self.base / "isolated-azure-profile"), + ) + profile_patcher.start() + self.addCleanup(profile_patcher.stop) + no_az = mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value=None, + ) + no_az.start() + self.addCleanup(no_az.stop) + self._write_source("SecurityEvent | take 5", "11111111-2222-3333-4444-555555555555") + self._write_xdr("DeviceEvents | take 5", "xdr-11111111-2222-3333-4444-555555555555") + initialize_workflow( + self.solution, + workflow_profile="qualification", + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + ) + self._confirm_primary(self.solution) + + def _confirm_primary(self, solution: Path, name: str = "qualification-primary") -> dict: + return confirm_primary_lab_setting( + solution, + actor="reviewer@example.test", + primary_setting_name=name, + confirmed=True, + ) + + def _write_source(self, query: str, rule_id: str, name: str = "Rule.yaml") -> Path: + path = self.solution / "Analytic Rules" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump({"id": rule_id, "query": query}), + encoding="utf-8", + ) + return path + + def _write_xdr(self, query: str, rule_id: str, name: str = "Rule.yaml") -> Path: + path = self.solution / "XDR Detections" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump( + { + "kind": "CustomDetection", + "id": rule_id, + "properties": { + "queryCondition": {"queryText": query}, + "detectionAction": {"alertTemplate": {}}, + }, + } + ), + encoding="utf-8", + ) + return path + + def _approve(self, *families: str, source_provider: str = "azure-monitor-sdk") -> dict: + if build_runtime_query_plan( + self.solution, + source_provider=source_provider, + )["labReadConsent"]["status"] == "requires-explicit-primary-setting-confirmation": + self._confirm_primary(self.solution) + plan = build_runtime_query_plan( + self.solution, + source_provider=source_provider, + ) + return record_runtime_query_consent( + self.solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer@example.test", + approved_families=list(families), + source_provider=source_provider, + ) + + def _latest_decision_record(self) -> dict: + paths = list( + (self.state_dir / "lab-runtime-access").glob( + "*.runtime-query-decision.json" + ) + ) + self.assertEqual(1, len(paths)) + return json.loads(paths[0].read_text(encoding="utf-8")) + + def test_plan_shows_exact_locked_scope_and_query_families(self) -> None: + plan = build_runtime_query_plan(self.solution) + self.assertEqual("qualification", plan["workflowProfile"]) + self.assertEqual("QueryLab", plan["target"]["resourceGroup"]) + self.assertEqual(WORKSPACE, plan["target"]["workspaceResourceId"]) + self.assertEqual(CUSTOMER_ID, plan["target"]["workspaceCustomerId"]) + self.assertEqual(1, plan["families"][SOURCE]["ruleCount"]) + self.assertEqual(1, plan["families"][XDR]["ruleCount"]) + self.assertEqual( + "SecurityEvent | take 5", + plan["families"][SOURCE]["queries"][0]["queryText"], + ) + self.assertEqual( + "azure-monitor-sdk", + plan["families"][SOURCE]["provider"]["provider"], + ) + self.assertFalse(plan["families"][SOURCE]["provider"]["authentication"]["interactiveSignInPermittedByThisConsent"]) + self.assertFalse(plan["authorizationBoundary"]["environmentWritesAuthorized"]) + + def test_both_and_each_independent_family_can_be_selected(self) -> None: + with mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value="/usr/bin/az", + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.subprocess.run", + return_value=mock.Mock( + returncode=0, + stdout=json.dumps({"tenantId": TENANT_ID}), + stderr="", + ), + ): + self.assertEqual( + sorted([SOURCE, XDR]), + self._approve(SOURCE, XDR)["approvedFamilies"], + ) + self.assertEqual("approved", require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + )["status"]) + self.assertEqual( + sorted([SOURCE, XDR]), + runtime_query_consent_status(self.solution)["approvedFamilies"], + ) + + for index, family in enumerate((SOURCE, XDR), start=1): + with self.subTest(family=family): + isolated = self.base / "Solutions" / f"Family{index}" + isolated.mkdir() + (isolated / "Analytic Rules").mkdir() + (isolated / "XDR Detections").mkdir() + (isolated / "Analytic Rules" / "Rule.yaml").write_text( + yaml.safe_dump( + { + "id": "11111111-2222-3333-4444-555555555555", + "query": "SecurityEvent | take 1", + } + ), + encoding="utf-8", + ) + (isolated / "XDR Detections" / "Rule.yaml").write_text( + yaml.safe_dump( + { + "kind": "CustomDetection", + "id": "xdr-11111111-2222-3333-4444-555555555555", + "properties": { + "queryCondition": {"queryText": "DeviceEvents | take 1"}, + "detectionAction": {"alertTemplate": {}}, + }, + } + ), + encoding="utf-8", + ) + initialize_workflow( + isolated, + workflow_profile="qualification", + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=( + WORKSPACE.replace("QueryWorkspace", f"QueryWorkspace{index}") + ), + workspace_customer_id=f"{CUSTOMER_ID[:-1]}{index}", + ) + self._confirm_primary(isolated) + plan = build_runtime_query_plan(isolated) + record_runtime_query_consent( + isolated, + expected_plan_sha256=plan["planSha256"], + actor="reviewer@example.test", + approved_families=[family], + ) + with self.assertRaisesRegex(ValueError, "does not approve"): + require_runtime_query_consent( + isolated, + family=XDR if family == SOURCE else SOURCE, + provider="graph" if family == SOURCE else "azure-monitor-sdk", + ) + + def test_decline_revision_and_no_selection_never_approve(self) -> None: + plan = build_runtime_query_plan(self.solution) + with self.assertRaisesRegex(ValueError, "select at least one"): + record_runtime_query_consent( + self.solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer", + approved_families=[], + ) + declined = record_runtime_query_consent( + self.solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer", + approved_families=[], + declined=True, + reason="Defer runtime queries.", + ) + self.assertEqual("declined", declined["status"]) + self.assertTrue(declined["decisionRecorded"]) + self.assertFalse(declined["consentRecorded"]) + decision = json.loads(Path(declined["decisionPath"]).read_text()) + self.assertEqual("reviewer", decision["actor"]) + self.assertEqual("Defer runtime queries.", decision["reason"]) + self.assertFalse(decision["authorizesRuntimeQueries"]) + self.assertFalse(decision["revokesExistingGrant"]) + self.assertEqual("declined", self._latest_decision_record()["status"]) + self.assertEqual("not-approved", runtime_query_consent_status(self.solution)["families"][SOURCE]["status"]) + with self.assertRaisesRegex(ValueError, "not approved"): + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + revised = record_runtime_query_consent( + self.solution, + expected_plan_sha256=build_runtime_query_plan(self.solution)["planSha256"], + actor="reviewer", + approved_families=[], + revised=True, + ) + self.assertFalse(revised["consentRecorded"]) + + def test_decline_preserves_an_existing_lab_grant(self) -> None: + self._approve(SOURCE) + plan = build_runtime_query_plan(self.solution) + declined = record_runtime_query_consent( + self.solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer@example.test", + approved_families=[], + declined=True, + reason="Declining to expand the currently approved read scope.", + ) + self.assertTrue(declined["existingGrantPreserved"]) + status = runtime_query_consent_status(self.solution) + self.assertEqual("approved", status["status"]) + self.assertEqual([SOURCE], status["approvedFamilies"]) + self.assertEqual("declined", status["latestDecision"]["status"]) + + def test_cli_requires_explicit_family_selection_and_reports_decision(self) -> None: + output = StringIO() + with redirect_stdout(output): + self.assertEqual( + 0, + cli.main( + [ + "plan-runtime-query-consent", + "--solution", + str(self.solution), + ] + ), + ) + plan = json.loads(output.getvalue()) + self.assertEqual( + [], + plan["consentChoices"]["preselectedFamilies"], + ) + self.assertIn("resourceGroup", plan["target"]) + self.assertIn("queries", plan["families"][SOURCE]) + + with redirect_stdout(StringIO()): + self.assertEqual( + 2, + cli.main( + [ + "review-runtime-query-consent", + "--solution", + str(self.solution), + "--expected-plan-sha256", + plan["planSha256"], + "--actor", + "reviewer", + ] + ), + ) + with redirect_stdout(StringIO()): + self.assertEqual( + 0, + cli.main( + [ + "review-runtime-query-consent", + "--solution", + str(self.solution), + "--expected-plan-sha256", + plan["planSha256"], + "--actor", + "reviewer", + "--approve-family", + SOURCE, + ] + ), + ) + status = runtime_query_consent_status(self.solution) + self.assertEqual("approved", status["families"][SOURCE]["status"]) + self.assertEqual("not-approved", status["families"][XDR]["status"]) + self.assertEqual("not-run", status["families"][SOURCE]["executionStatus"]) + source_query = plan["families"][SOURCE]["queries"][0] + artifact_path( + self.solution, + "runtime-validation.azure-monitor-sdk.json", + create_parent=True, + ).write_text( + json.dumps( + { + "provider": "azure-monitor-sdk", + "results": [ + { + "querySha256": source_query["querySha256"], + "status": "passed", + } + ], + } + ), + encoding="utf-8", + ) + status = runtime_query_consent_status(self.solution) + self.assertEqual("recorded", status["families"][SOURCE]["executionStatus"]) + + def test_lab_approval_reuses_across_solutions_and_refreshes_run_snapshots(self) -> None: + approval = self._approve(SOURCE) + original_recorded_at = json.loads(Path(approval["path"]).read_text())["recordedAt"] + original_plan = build_runtime_query_plan(self.solution) + source_path = self.solution / "Analytic Rules" / "Rule.yaml" + source = yaml.safe_load(source_path.read_text()) + source["query"] = "SecurityEvent | take 6" + source_path.write_text(yaml.safe_dump(source), encoding="utf-8") + changed_query_plan = build_runtime_query_plan(self.solution) + self.assertNotEqual(original_plan["planSha256"], changed_query_plan["planSha256"]) + status = runtime_query_consent_status(self.solution) + self.assertEqual("approved", status["status"]) + self.assertEqual(["sentinelSourceQueryFamily"], status["approvedFamilies"]) + self.assertEqual("not-current", status["runSnapshot"]["status"]) + self.assertTrue(status["runSnapshot"]["queryHashesAreConsentBindings"] is False) + + self._write_source("SigninLogs | take 1", "22222222-2222-3333-4444-555555555555", "Second.yaml") + status = runtime_query_consent_status(self.solution) + self.assertEqual("approved", status["status"]) + + other = self.base / "Solutions" / "NewSelectedSolution" + other.mkdir() + (other / "Analytic Rules").mkdir() + (other / "XDR Detections").mkdir() + (other / "Analytic Rules" / "Rule.yaml").write_text( + yaml.safe_dump( + {"id": "33333333-2222-3333-4444-555555555555", + "query": "AuditLogs | take 2"} + ), + encoding="utf-8", + ) + (other / "XDR Detections" / "Rule.yaml").write_text( + yaml.safe_dump( + { + "kind": "CustomDetection", + "id": "xdr-33333333-2222-3333-4444-555555555555", + "properties": { + "queryCondition": {"queryText": "DeviceEvents | take 2"}, + "detectionAction": {"alertTemplate": {}}, + }, + } + ), + encoding="utf-8", + ) + initialize_workflow( + other, + workflow_profile="qualification", + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + ) + other_plan = build_runtime_query_plan(other) + self.assertEqual(["sentinelSourceQueryFamily"], other_plan["labReadConsent"]["approvedFamilies"]) + self.assertNotEqual(original_plan["planSha256"], other_plan["planSha256"]) + with mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value="/usr/bin/az", + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.subprocess.run", + return_value=mock.Mock( + returncode=0, + stdout=json.dumps({"tenantId": TENANT_ID}), + stderr="", + ), + ): + result = require_runtime_query_consent( + other, + family=SOURCE, + provider="azure-monitor-sdk", + ) + self.assertEqual( + "current", + runtime_query_consent_status(other)["runSnapshot"]["status"], + ) + self.assertEqual("approved", result["status"]) + self.assertNotEqual(original_plan["planSha256"], result["planSha256"]) + self.assertEqual( + original_recorded_at, + json.loads(Path(approval["path"]).read_text())["recordedAt"], + ) + other_plan = build_runtime_query_plan(other) + expanded = record_runtime_query_consent( + other, + expected_plan_sha256=other_plan["planSha256"], + actor="reviewer@example.test", + approved_families=[XDR], + ) + self.assertEqual(sorted([SOURCE, XDR]), expanded["approvedFamilies"]) + self.assertEqual( + sorted([SOURCE, XDR]), + runtime_query_consent_status(self.solution)["approvedFamilies"], + ) + + def test_changed_lab_or_primary_setting_and_revocation_deny_reuse(self) -> None: + approval = self._approve(SOURCE) + self._confirm_primary(self.solution, name="different-primary-setting") + self.assertEqual("stale", runtime_query_consent_status(self.solution)["status"]) + with self.assertRaisesRegex(ValueError, "not approved"): + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + + self._approve(SOURCE) + revoked = revoke_runtime_query_consent( + self.solution, + actor="reviewer@example.test", + reason="The lab read grant is no longer needed.", + ) + self.assertEqual("revoked", revoked["status"]) + self.assertEqual("revoked", runtime_query_consent_status(self.solution)["status"]) + with self.assertRaisesRegex(ValueError, "not approved"): + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + + target_path = existing_artifact_path(self.solution, "workflow-state.json") + state = json.loads(target_path.read_text()) + state["context"]["workspaceCustomerId"] = "11111111-1111-1111-1111-111111111111" + target_path.write_text(json.dumps(state), encoding="utf-8") + self.assertNotEqual("approved", runtime_query_consent_status(self.solution)["status"]) + self.assertTrue(Path(approval["path"]).is_file()) + + def test_approval_and_revocation_are_serialized_without_resurrecting_scope(self) -> None: + self._approve(SOURCE) + current_plan = build_runtime_query_plan(self.solution) + read_started = threading.Event() + continue_approval = threading.Event() + revoke_started = threading.Event() + revoke_lock_requested = threading.Event() + original_load = runtime_consent._load_lab_consent + original_lock = runtime_consent._lab_lock + local_count = threading.local() + + def delayed_load(target, primary_setting): + result = original_load(target, primary_setting) + count = getattr(local_count, "count", 0) + 1 + local_count.count = count + if ( + threading.current_thread().name.startswith("lab-approval") + and count == 2 + ): + read_started.set() + if not continue_approval.wait(timeout=5): + raise TimeoutError("test did not release the serialized approval") + return result + + def revoke(): + revoke_started.set() + return revoke_runtime_query_consent( + self.solution, + actor="revoker@example.test", + reason="Concurrent revocation wins after the approval transaction.", + ) + + def tracked_lock(target): + if threading.current_thread().name.endswith("_1"): + revoke_lock_requested.set() + return original_lock(target) + + with mock.patch( + "sentinel_xdr_migration.runtime_consent._load_lab_consent", + side_effect=delayed_load, + ), mock.patch( + "sentinel_xdr_migration.runtime_consent._lab_lock", + side_effect=tracked_lock, + ): + with ThreadPoolExecutor( + max_workers=2, + thread_name_prefix="lab-approval", + ) as executor: + approval = executor.submit( + lambda: record_runtime_query_consent( + self.solution, + expected_plan_sha256=current_plan["planSha256"], + actor="approver@example.test", + approved_families=[XDR], + ) + ) + self.assertTrue(read_started.wait(timeout=5)) + revocation = executor.submit(revoke) + self.assertTrue(revoke_started.wait(timeout=5)) + self.assertTrue(revoke_lock_requested.wait(timeout=5)) + self.assertFalse(revocation.done()) + continue_approval.set() + approval.result(timeout=5) + revocation.result(timeout=5) + + status = runtime_query_consent_status(self.solution) + self.assertEqual("revoked", status["status"]) + self.assertEqual([], status["approvedFamilies"]) + with self.assertRaisesRegex(ValueError, "not approved"): + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + + def test_provider_and_auth_snapshots_refresh_without_new_lab_approval(self) -> None: + approval = self._approve(SOURCE) + original_recorded_at = json.loads(Path(approval["path"]).read_text())["recordedAt"] + with mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value="/usr/bin/az", + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.subprocess.run", + return_value=mock.Mock( + returncode=0, + stdout=json.dumps({"tenantId": TENANT_ID}), + stderr="", + ), + ): + plan = build_runtime_query_plan(self.solution) + self.assertEqual("approved", runtime_query_consent_status(self.solution)["status"]) + self.assertEqual( + "approved", + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + )["status"], + ) + self.assertEqual( + "current", + runtime_query_consent_status(self.solution)["runSnapshot"]["status"], + ) + self.assertEqual( + original_recorded_at, + json.loads(Path(approval["path"]).read_text())["recordedAt"], + ) + self.assertEqual( + "existing-cached-auth-available", + plan["families"][SOURCE]["provider"]["authentication"]["classification"], + ) + self.assertEqual("not-current", runtime_query_consent_status(self.solution)["runSnapshot"]["status"]) + + def test_cached_auth_allows_read_only_family_and_interactive_auth_does_not(self) -> None: + self.state_dir.mkdir(exist_ok=True) + with mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value="/usr/bin/az", + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.subprocess.run", + return_value=mock.Mock( + returncode=0, + stdout=json.dumps({"tenantId": TENANT_ID}), + stderr="", + ), + ): + plan = build_runtime_query_plan(self.solution) + self.assertEqual( + "existing-cached-auth-available", + plan["families"][SOURCE]["provider"]["authentication"]["classification"], + ) + record_runtime_query_consent( + self.solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer", + approved_families=[SOURCE], + ) + self.assertEqual( + "approved", + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + )["status"], + ) + + graph_plan = build_runtime_query_plan(self.solution) + record_runtime_query_consent( + self.solution, + expected_plan_sha256=graph_plan["planSha256"], + actor="reviewer", + approved_families=[XDR], + ) + with self.assertRaisesRegex(ValueError, "does not authorize interactive sign-in"): + require_runtime_query_consent( + self.solution, + family=XDR, + provider="graph", + ) + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value={ + "classification": "cached-auth-configured-unverified", + "cachedAuthConfigured": True, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "test-only cached auth classification", + }, + ): + graph_plan = build_runtime_query_plan(self.solution) + record_runtime_query_consent( + self.solution, + expected_plan_sha256=graph_plan["planSha256"], + actor="reviewer", + approved_families=[XDR], + ) + self.assertEqual( + "approved", + require_runtime_query_consent( + self.solution, + family=XDR, + provider="graph", + )["status"], + ) + + def test_direct_cli_runtime_validation_checks_consent_before_provider(self) -> None: + with mock.patch.object(cli, "configure_logging"), mock.patch( + "sentinel_xdr_migration.runtime.validate_advanced_hunting" + ) as graph_executor, mock.patch( + "sentinel_xdr_migration.runtime.validate_sentinel_sdk" + ) as sentinel_executor: + result = cli.main( + [ + "validate-advanced-hunting", + "--solution", + str(self.solution), + ] + ) + source_result = cli.main( + [ + "validate-sentinel-sdk", + "--solution", + str(self.solution), + ] + ) + imported_result = cli.main( + [ + "record-runtime-validation", + "--solution", + str(self.solution), + "--provider", + "triage-mcp", + "--results", + str(self.base / "not-yet-opened.json"), + ] + ) + self.assertEqual(2, result) + self.assertEqual(2, source_result) + self.assertEqual(2, imported_result) + graph_executor.assert_not_called() + sentinel_executor.assert_not_called() + + def _authoring_solution(self) -> Path: + solution = self.base / "Solutions" / "AuthoringQueries" + (solution / "XDR Detections").mkdir(parents=True) + initialize_workflow( + solution, + workflow_profile="authoring", + tenant_id=TENANT_ID, + ) + (solution / "XDR Detections" / "Rule.yaml").write_text( + yaml.safe_dump( + { + "kind": "CustomDetection", + "id": "xdr-11111111-2222-3333-4444-555555555555", + "properties": { + "queryCondition": {"queryText": "DeviceEvents | take 5"}, + "detectionAction": {"alertTemplate": {}}, + }, + } + ), + encoding="utf-8", + ) + return solution + + @staticmethod + def _cached_graph_authentication() -> dict: + return { + "classification": "cached-auth-configured-unverified", + "cachedAuthConfigured": True, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "fixture authentication record is bound to the selected tenant", + } + + def test_authoring_advanced_hunting_requires_exact_run_consent_and_keeps_schema(self) -> None: + solution = self._authoring_solution() + cached = self._cached_graph_authentication() + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ): + plan = build_runtime_query_plan(solution) + self.assertEqual("authoring", plan["workflowProfile"]) + self.assertEqual({"tenantId", "fingerprintSha256", "workspaceSelected"}, + set(plan["target"])) + self.assertFalse(plan["target"]["workspaceSelected"]) + self.assertEqual( + "qualification-only", + plan["families"][SOURCE]["status"], + ) + self.assertEqual( + ["defenderXdrQueryFamily"], + plan["consentChoices"]["availableFamilies"], + ) + with mock.patch.object(cli, "configure_logging"), mock.patch( + "sentinel_xdr_migration.runtime.validate_advanced_hunting" + ) as graph_executor: + status = cli.main( + [ + "validate-advanced-hunting", + "--solution", + str(solution), + ] + ) + self.assertEqual(2, status) + graph_executor.assert_not_called() + with self.assertRaisesRegex(ValueError, "not current"): + require_runtime_query_consent( + solution, + family=XDR, + provider="graph", + ) + approval = record_runtime_query_consent( + solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer@example.test", + approved_families=[XDR], + ) + self.assertEqual("one-run-authoring-query-only", approval["consentScope"]) + source_query = "SecurityEvent | take 5" + source_entry = runtime_consent._query_entry( + rule_id="11111111-2222-3333-4444-555555555555", + path="Analytic Rules/Rule.yaml", + query=source_query, + ) + source_family = { + **plan["families"][SOURCE], + "ruleCount": 1, + "queryCount": 1, + "queries": [source_entry], + "status": "ready", + } + source_family.pop("familySha256", None) + source_family["familySha256"] = runtime_consent._canonical_sha256( + source_family + ) + full_inventory_plan = { + **plan, + "families": {**plan["families"], SOURCE: source_family}, + } + full_inventory_plan["planSha256"] = ( + runtime_consent._runtime_query_plan_sha256(full_inventory_plan) + ) + with mock.patch( + "sentinel_xdr_migration.runtime_consent.build_runtime_query_plan", + return_value=full_inventory_plan, + ), self.assertRaisesRegex( + ValueError, + "Authoring runtime validation is limited to explicitly approved " + "Defender XDR Advanced Hunting queries", + ): + require_runtime_query_consent( + solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + + with mock.patch( + "sentinel_xdr_migration.runtime._assert_graph_auth_tenant_scope" + ) as auth_scope, mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token", + return_value="token", + ), mock.patch( + "sentinel_xdr_migration.runtime.run_advanced_hunting_query", + return_value={ + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [{"name": "Timestamp", "type": "DateTime"}], + "error": None, + "errorDetails": None, + }, + ) as run_query: + result = validate_advanced_hunting( + solution, + state_dir=self.state_dir, + tenant_id=TENANT_ID, + ) + self.assertEqual(1, result["valid"]) + self.assertEqual( + [{"name": "Timestamp", "type": "DateTime"}], + result["results"][0]["schema"], + ) + run_query.assert_called_once_with( + "DeviceEvents | take 5", + _token="token", + ) + auth_scope.assert_called_once() + self.assertEqual( + TENANT_ID, + auth_scope.call_args.kwargs["locked_tenant_id"], + ) + self.assertEqual( + "authoring", + result["runtimeQueryConsent"]["workflowProfile"], + ) + self.assertFalse( + result["runtimeQueryConsent"]["environmentWritesAuthorized"] + ) + self.assertEqual( + "approved", + runtime_query_consent_status(solution)["status"], + ) + + def test_authoring_advanced_hunting_consent_is_stale_for_query_or_run_change(self) -> None: + solution = self._authoring_solution() + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=self._cached_graph_authentication(), + ): + plan = build_runtime_query_plan(solution) + record_runtime_query_consent( + solution, + expected_plan_sha256=plan["planSha256"], + actor="reviewer@example.test", + approved_families=[XDR], + ) + rule_path = solution / "XDR Detections" / "Rule.yaml" + rule = yaml.safe_load(rule_path.read_text(encoding="utf-8")) + rule["properties"]["queryCondition"]["queryText"] = "DeviceEvents | take 6" + rule_path.write_text(yaml.safe_dump(rule), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "stale"): + require_runtime_query_consent( + solution, + family=XDR, + provider="graph", + ) + + current_plan = build_runtime_query_plan(solution) + record_runtime_query_consent( + solution, + expected_plan_sha256=current_plan["planSha256"], + actor="reviewer@example.test", + approved_families=[XDR], + ) + state_path = existing_artifact_path(solution, "workflow-state.json") + state = json.loads(state_path.read_text(encoding="utf-8")) + state["runId"] = "other-explicit-run" + state_path.write_text(json.dumps(state), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "stale"): + require_runtime_query_consent( + solution, + family=XDR, + provider="graph", + ) + + def test_azure_cli_account_classification_uses_only_configured_profile(self) -> None: + ambient = str(self.base / "ambient-profile") + isolated = str(self.base / "isolated-profile") + with mock.patch.dict("os.environ", {"AZURE_CONFIG_DIR": ambient}), mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value="/usr/bin/az", + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.configured_azure_cli_profile", + return_value=isolated, + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.subprocess.run", + return_value=mock.Mock( + returncode=0, + stdout=json.dumps({"tenantId": TENANT_ID}), + stderr="", + ), + ) as run: + result = runtime_consent._authentication_requirement( + "azure-monitor-sdk", + {"tenantId": TENANT_ID}, + ) + self.assertEqual("existing-cached-auth-available", result["classification"]) + self.assertEqual(isolated, run.call_args.kwargs["env"]["AZURE_CONFIG_DIR"]) + self.assertNotEqual( + ambient, + run.call_args.kwargs["env"]["AZURE_CONFIG_DIR"], + ) + + with mock.patch( + "sentinel_xdr_migration.runtime_consent.shutil.which", + return_value="/usr/bin/az", + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.configured_azure_cli_profile", + return_value=None, + ), mock.patch( + "sentinel_xdr_migration.runtime_consent.subprocess.run" + ) as run: + result = runtime_consent._authentication_requirement( + "azure-monitor-sdk", + {"tenantId": TENANT_ID}, + ) + self.assertEqual("separate-interactive-sign-in-required", result["classification"]) + run.assert_not_called() + + def _prepare_sdk_detection(self): + query = "SecurityEvent | take 5" + source = self.solution / "Analytic Rules" / "Rule.yaml" + source.write_text( + yaml.safe_dump( + { + "id": "11111111-2222-3333-4444-555555555555", + "query": query, + } + ), + encoding="utf-8", + ) + detection = self.solution / "XDR Detections" / "Rule.yaml" + detection.write_text( + yaml.safe_dump( + { + "kind": "CustomDetection", + "id": "xdr-11111111-2222-3333-4444-555555555555", + "contentProvenance": { + "source": { + "path": "Analytic Rules/Rule.yaml", + "querySha256": hashlib.sha256(query.encode()).hexdigest(), + } + }, + "properties": { + "queryCondition": {"queryText": query}, + "detectionAction": {"alertTemplate": {}}, + }, + } + ), + encoding="utf-8", + ) + cached = { + "classification": "existing-cached-auth-available", + "cachedAuthConfigured": True, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "test cached credential classification", + } + + class Client: + closed = False + + def close(self): + self.closed = True + + return query, cached, Client() + + def test_sdk_provider_failure_records_report_and_consent_snapshot(self) -> None: + query, cached, client = self._prepare_sdk_detection() + + def provider_failure_during_decline(*_args, **_kwargs): + current_plan = build_runtime_query_plan(self.solution) + record_runtime_query_consent( + self.solution, + expected_plan_sha256=current_plan["planSha256"], + actor="reviewer@example.test", + approved_families=[], + declined=True, + reason="Declining a separate, non-revoking Qualification step.", + ) + raise RuntimeError("test provider failure") + + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ): + self._approve(SOURCE) + with mock.patch( + "sentinel_xdr_migration.logs_query.query_family", + side_effect=provider_failure_during_decline, + ): + report = validate_sentinel_sdk( + self.solution, + target=require_locked_target( + self.solution, + materialize_missing=False, + ), + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=lambda _credential, **_kwargs: client, + ) + self.assertEqual(1, report["notRun"]) + self.assertEqual("provider", report["providerFailure"]["failureKind"]) + self.assertEqual("approved", report["runtimeQueryConsent"]["status"]) + self.assertTrue(client.closed) + persisted = json.loads( + existing_artifact_path( + self.solution, + "runtime-validation.azure-monitor-sdk.json", + ).read_text() + ) + self.assertEqual("approved", persisted["runtimeQueryConsent"]["status"]) + dispatch_snapshot = persisted["runtimeQueryDispatchSnapshot"] + self.assertEqual( + dispatch_snapshot["planSha256"], + persisted["runtimeQueryConsent"]["planSha256"], + ) + self.assertEqual("declined", self._latest_decision_record()["status"]) + self.assertEqual( + "lab-wide-across-solutions-and-runs", + persisted["runtimeQueryConsent"]["consentScope"], + ) + self.assertEqual( + report["runtimeQueryConsent"]["labConsentSha256"], + persisted["runtimeQueryConsent"]["labConsentSha256"], + ) + self.assertEqual("not-run", persisted["results"][0]["status"]) + + def test_sdk_success_evidence_survives_concurrent_nonrevoking_defer(self) -> None: + query, cached, client = self._prepare_sdk_detection() + + def successful_dispatch_with_defer(*_args, **_kwargs): + current_plan = build_runtime_query_plan(self.solution) + record_runtime_query_consent( + self.solution, + expected_plan_sha256=current_plan["planSha256"], + actor="reviewer@example.test", + approved_families=[], + revised=True, + reason="Defer independent follow-up work.", + ) + return [ + { + "detection": "Rule.yaml", + "status": "passed", + "valid": True, + "querySha256": hashlib.sha256(query.encode()).hexdigest(), + "statusCode": 200, + "rowCount": 1, + "schema": [{"name": "Account", "type": "string"}], + "error": None, + "errorDetails": None, + } + ] + + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ): + self._approve(SOURCE) + with mock.patch( + "sentinel_xdr_migration.logs_query.query_family", + side_effect=successful_dispatch_with_defer, + ): + report = validate_sentinel_sdk( + self.solution, + target=require_locked_target( + self.solution, + materialize_missing=False, + ), + _scope_verifier=lambda _target: None, + _credential_factory=lambda _target: object(), + _client_factory=lambda _credential, **_kwargs: client, + ) + self.assertEqual(1, report["valid"]) + self.assertEqual(0, report["notRun"]) + persisted = json.loads( + existing_artifact_path( + self.solution, + "runtime-validation.azure-monitor-sdk.json", + ).read_text() + ) + self.assertEqual("passed", persisted["results"][0]["status"]) + self.assertEqual("revised", self._latest_decision_record()["status"]) + self.assertEqual( + persisted["runtimeQueryDispatchSnapshot"]["planSha256"], + persisted["runtimeQueryConsent"]["planSha256"], + ) + + def test_import_guard_is_independent_of_cli_and_precedes_file_read(self) -> None: + missing_results = self.base / "not-yet-read-runtime-results.json" + with self.assertRaisesRegex(ValueError, "not approved"): + record_runtime_validation( + self.solution, + provider="triage-mcp", + results_path=missing_results, + ) + self.assertFalse( + existing_artifact_path( + self.solution, + "runtime-validation.triage-mcp.json", + ).exists() + ) + + def test_dispatch_snapshot_fails_closed_after_revoke_target_or_query_change(self) -> None: + self._prepare_sdk_detection() + cached = { + "classification": "existing-cached-auth-available", + "cachedAuthConfigured": True, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "test cached credential classification", + } + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ): + self._approve(SOURCE) + revoked_snapshot = require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + revoke_runtime_query_consent( + self.solution, + actor="revoker@example.test", + reason="Revoke before evidence import.", + ) + with self.assertRaisesRegex(ValueError, "not approved"): + record_runtime_validation( + self.solution, + provider="azure-monitor-sdk", + results_data=[], + runtime_query_consent=revoked_snapshot, + ) + + self._approve(SOURCE) + stale_query_snapshot = require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + source_path = self.solution / "Analytic Rules" / "Rule.yaml" + source = yaml.safe_load(source_path.read_text(encoding="utf-8")) + source["query"] = "SecurityEvent | take 99" + source_path.write_text(yaml.safe_dump(source), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "dispatch snapshot"): + record_runtime_validation( + self.solution, + provider="azure-monitor-sdk", + results_data=[], + runtime_query_consent=stale_query_snapshot, + ) + + self._approve(SOURCE) + stale_target_snapshot = require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + state_path = existing_artifact_path(self.solution, "workflow-state.json") + state = json.loads(state_path.read_text(encoding="utf-8")) + state["context"]["workspaceCustomerId"] = ( + "11111111-1111-1111-1111-111111111111" + ) + state_path.write_text(json.dumps(state), encoding="utf-8") + with self.assertRaises(ValueError): + record_runtime_validation( + self.solution, + provider="azure-monitor-sdk", + results_data=[], + runtime_query_consent=stale_target_snapshot, + ) + self.assertFalse( + existing_artifact_path( + self.solution, + "runtime-validation.azure-monitor-sdk.json", + ).exists() + ) + + def test_runtime_entrypoints_reject_conflicting_locked_and_auth_targets(self) -> None: + locked = require_locked_target(self.solution, materialize_missing=False) + conflicting = {**locked, "workspaceCustomerId": "11111111-1111-1111-1111-111111111111"} + verifier = mock.Mock() + with self.assertRaisesRegex(ValueError, "SDK target conflicts"): + validate_sentinel_sdk( + self.solution, + target=conflicting, + _scope_verifier=verifier, + _credential_factory=mock.Mock(), + _client_factory=mock.Mock(), + ) + verifier.assert_not_called() + + with mock.patch("sentinel_xdr_migration.runtime._advanced_hunting_token") as token: + with self.assertRaisesRegex(ValueError, "requested Graph tenant"): + validate_advanced_hunting( + self.solution, + tenant_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + token.assert_not_called() + + cached = { + "classification": "cached-auth-configured-unverified", + "cachedAuthConfigured": True, + "interactiveSignInPermittedByThisConsent": False, + "evidence": "test cached credential classification", + } + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ): + self._approve(XDR) + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ), mock.patch( + "sentinel_xdr_migration.runtime._load_auth_record", + return_value=SimpleNamespace( + tenant_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ), + ), mock.patch( + "sentinel_xdr_migration.runtime._read_json", + return_value={"tenantId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, + ), mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token" + ) as token: + with self.assertRaisesRegex(ValueError, "authentication record tenant"): + validate_advanced_hunting( + self.solution, + state_dir=self.base / "other-tenant-auth", + ) + token.assert_not_called() + with mock.patch( + "sentinel_xdr_migration.runtime_consent._authentication_requirement", + return_value=cached, + ), mock.patch( + "sentinel_xdr_migration.runtime._load_auth_record", + return_value=SimpleNamespace(tenant_id=TENANT_ID), + ), mock.patch( + "sentinel_xdr_migration.runtime._read_json", + return_value={ + "tenantId": TENANT_ID, + "workspaceResourceId": WORKSPACE.replace( + "QueryWorkspace", + "OtherWorkspace", + ), + }, + ), mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token" + ) as token: + with self.assertRaisesRegex(ValueError, "workspaceResourceId"): + validate_advanced_hunting( + self.solution, + state_dir=self.base / "other-workspace-auth", + ) + token.assert_not_called() + + def test_consent_never_infers_write_or_packaging_authority(self) -> None: + approval = self._approve(SOURCE, XDR) + path = Path(approval["path"]) + stored = json.loads(path.read_text()) + self.assertEqual("shared-lab-read-only-runtime-query", stored["consentType"]) + self.assertFalse(stored["authorizationBoundary"]["environmentWritesAuthorized"]) + self.assertFalse(stored["authorizationBoundary"]["interactiveAuthenticationAuthorized"]) + self.assertEqual( + {"sentinelSourceQueryFamily", "defenderXdrQueryFamily"}, + set(stored["approvedFamilies"]), + ) + self.assertEqual( + { + "crossSolution": True, + "crossRun": True, + "sourceQueryHashesAreSnapshotsOnly": True, + "xdrQueryHashesAreSnapshotsOnly": True, + "querySnapshotsAreNotConsentBindings": True, + }, + stored["scopePolicy"], + ) + self.assertEqual( + { + "interactiveAuthenticationAuthorized": False, + "adminConsentAuthorized": False, + "permissionChangesAuthorized": False, + "roleAssignmentsAuthorized": False, + "environmentWritesAuthorized": False, + "packageOrDeploymentAuthorized": False, + "ingestionOrParityAuthorized": False, + "ruleEnablementOrCleanupAuthorized": False, + }, + stored["authorizationBoundary"], + ) + stored["authorizationBoundary"]["environmentWritesAuthorized"] = True + path.write_text(json.dumps(stored), encoding="utf-8") + self.assertEqual("invalid", runtime_query_consent_status(self.solution)["status"]) + with self.assertRaisesRegex(ValueError, "not approved"): + require_runtime_query_consent( + self.solution, + family=SOURCE, + provider="azure-monitor-sdk", + ) + require_locked_target(self.solution) + + def test_primary_setting_is_explicit_user_attestation_not_service_proof(self) -> None: + another = self.base / "Solutions" / "Unconfirmed" + another.mkdir() + (another / "Analytic Rules").mkdir() + (another / "Analytic Rules" / "Rule.yaml").write_text( + yaml.safe_dump( + { + "id": "44444444-2222-3333-4444-555555555555", + "query": "SecurityEvent | take 1", + } + ), + encoding="utf-8", + ) + initialize_workflow( + another, + workflow_profile="qualification", + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=WORKSPACE.replace("QueryWorkspace", "UnconfirmedWorkspace"), + workspace_customer_id="11111111-1111-1111-1111-111111111111", + ) + with self.assertRaisesRegex(ValueError, "not been explicitly confirmed"): + record_runtime_query_consent( + another, + expected_plan_sha256=build_runtime_query_plan(another)["planSha256"], + actor="reviewer", + approved_families=[SOURCE], + ) + with redirect_stderr(StringIO()): + self.assertEqual( + 2, + cli.main( + [ + "confirm-primary-lab-setting", + "--solution", + str(another), + "--actor", + "reviewer@example.test", + "--primary-setting-name", + "qualification-primary", + ] + ), + ) + confirmation_output = StringIO() + with redirect_stdout(confirmation_output): + self.assertEqual( + 0, + cli.main( + [ + "confirm-primary-lab-setting", + "--solution", + str(another), + "--actor", + "reviewer@example.test", + "--primary-setting-name", + "qualification-primary", + "--confirm-primary-setting", + ] + ), + ) + confirmed = json.loads(confirmation_output.getvalue()) + self.assertFalse(confirmed["serviceVerified"]) + setting_path = Path(confirmed["path"]) + setting = json.loads(setting_path.read_text()) + self.assertEqual("explicit-user-attestation", setting["primarySetting"]["confirmationType"]) + self.assertFalse(setting["primarySetting"]["serviceVerified"]) + setting["primarySetting"]["serviceVerified"] = True + setting_path.write_text(json.dumps(setting), encoding="utf-8") + self.assertEqual( + "requires-explicit-primary-setting-confirmation", + build_runtime_query_plan(another)["labReadConsent"]["status"], + ) + + def test_read_only_post_operations_are_not_write_authority(self) -> None: + plan = build_runtime_query_plan(self.solution) + self.assertEqual("POST", plan["labReadConsent"]["operationFamilies"][SOURCE]["httpMethod"]) + self.assertEqual("read-only query execution", plan["labReadConsent"]["operationFamilies"][SOURCE]["operationSemantics"]) + self.assertFalse(plan["labReadConsent"]["operationFamilies"][SOURCE]["resourceMutation"]) + self.assertEqual("POST", plan["labReadConsent"]["operationFamilies"][XDR]["httpMethod"]) + self.assertFalse(plan["labReadConsent"]["operationFamilies"][XDR]["resourceMutation"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_runtime_fallback.py b/Tools/SentinelToXDRMigration/tests/test_runtime_fallback.py new file mode 100644 index 00000000000..6e47a6f94f7 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_runtime_fallback.py @@ -0,0 +1,977 @@ +from __future__ import annotations + +import json +import hashlib +import io +import threading +from pathlib import Path +import tempfile +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from email.message import Message +from unittest import mock +import urllib.error +import unittest + +import yaml + +from sentinel_xdr_migration import runtime_consent +import sentinel_xdr_migration.runtime as runtime_module +from sentinel_xdr_migration.runtime import ( + _advanced_hunting_token, + _load_provider_fallback, + record_runtime_validation, + run_advanced_hunting_query, + validate_advanced_hunting, +) +from sentinel_xdr_migration.workflow import initialize_workflow + + +def _successful_response(payload=None): + response = mock.MagicMock() + response.status = 200 + response.read.return_value = json.dumps( + payload + if payload is not None + else { + "results": [], + "schema": [{"name": "DeviceId", "type": "String"}], + } + ).encode() + context = mock.MagicMock() + context.__enter__.return_value = response + context.__exit__.return_value = False + return context + + +def _http_error(status, *, retry_after=None, message="provider failed"): + headers = Message() + if retry_after is not None: + headers["Retry-After"] = retry_after + body = json.dumps({"error": {"message": message}}).encode() + return urllib.error.HTTPError( + runtime_module.ADVANCED_HUNTING_ENDPOINT, + status, + "request failed", + headers, + io.BytesIO(body), + ) + + +class _FakeClock: + def __init__(self, wall_time=1_800_000_000): + self.elapsed = 0.0 + self.wall_time = wall_time + + def monotonic(self): + return self.elapsed + + def time(self): + return self.wall_time + self.elapsed + + def sleep(self, seconds): + self.elapsed += seconds + + +class RuntimeFallbackEvidenceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.solution = Path(self.temp.name) / "Example" + output = self.solution / "XDR Detections" + output.mkdir(parents=True) + source = self.solution / "Analytic Rules/Rule.yaml" + source.parent.mkdir(parents=True) + source_query = "DeviceEvents | where TimeGenerated > ago(4h)" + source.write_text(yaml.safe_dump({ + "id": "11111111-2222-3333-4444-555555555555", + "query": source_query, + }), encoding="utf-8") + self.query_hash = hashlib.sha256(source_query.encode()).hexdigest() + (output / "Rule.yaml").write_text(yaml.safe_dump({ + "kind": "CustomDetection", + "contentProvenance": {"source": {"path": "Analytic Rules/Rule.yaml"}}, + "properties": { + "queryCondition": {"queryText": "DeviceEvents | where Timestamp > ago(4h)"}, + "detectionAction": {"alertTemplate": {}}, + }, + }), encoding="utf-8") + self.results = Path(self.temp.name) / "results.json" + self.results.write_text(json.dumps([{ + "detection": "Rule.yaml", + "status": "passed", + "statusCode": 200, + "rowCount": 0, + "schema": [], + "querySha256": self.query_hash, + }]), encoding="utf-8") + self.evidence = Path(self.temp.name) / "provider-gap.json" + self.evidence.write_text(json.dumps({ + "mcpProvider": "triage-mcp", + "advertisedTool": "QueryWorkspace", + "queryFamily": "sentinel", + "fallbackProvider": "log-analytics-cli", + "scopeReference": "/subscriptions/example/resourceGroups/lab/providers/Microsoft.OperationalInsights/workspaces/sentinel", + "reasonCode": "authorization", + "reason": "Triage MCP workspace-query permission was unavailable.", + "batchComplete": True, + "scopeVerified": True, + "permissionsVerified": True, + "kqlError": False, + }), encoding="utf-8") + + def test_advanced_hunting_token_accepts_required_scope_superset(self): + tenant_id = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" + claims = { + "tid": tenant_id, + "aud": "https://graph.microsoft.com", + "scp": "ThreatHunting.Read.All User.Read email openid profile", + } + credential = mock.Mock() + credential.get_token.return_value.token = "synthetic-access-token" + record = mock.Mock(tenant_id=tenant_id) + state_root = Path(self.temp.name) / "private-state" + with ( + mock.patch("azure.identity.DeviceCodeCredential", return_value=credential), + mock.patch("azure.identity.TokenCachePersistenceOptions"), + mock.patch( + "sentinel_xdr_migration.runtime.resolve_graph_client_id", + return_value="11111111-2222-3333-4444-555555555555", + ), + mock.patch( + "sentinel_xdr_migration.runtime.graph_auth_record_path", + return_value=state_root / "auth-record.json", + ), + mock.patch( + "sentinel_xdr_migration.runtime._load_auth_record", + return_value=record, + ), + mock.patch( + "sentinel_xdr_migration.runtime._read_json", + return_value={"tenantId": tenant_id}, + ), + mock.patch( + "sentinel_xdr_migration.runtime.verify_graph_auth_binding" + ), + mock.patch( + "sentinel_xdr_migration.graph_readiness._token_claims", + return_value=claims, + ), + ): + token = _advanced_hunting_token(state_root, tenant_id=tenant_id) + self.assertEqual("synthetic-access-token", token) + credential.get_token.assert_called_once_with( + "https://graph.microsoft.com/ThreatHunting.Read.All" + ) + + def test_complete_fallback_evidence_is_persisted_without_promoting_status(self): + result = record_runtime_validation( + self.solution, + provider="log-analytics-cli", + results_path=self.results, + provider_gap_evidence=self.evidence, + ) + expected = { + "mcpProvider": "triage-mcp", + "advertisedTool": "QueryWorkspace", + "queryFamily": "sentinel", + "fallbackProvider": "log-analytics-cli", + "scopeReference": "/subscriptions/example/resourceGroups/lab/providers/Microsoft.OperationalInsights/workspaces/sentinel", + "reasonCode": "authorization", + "reason": "Triage MCP workspace-query permission was unavailable.", + "batchComplete": True, + "scopeVerified": True, + "permissionsVerified": True, + "kqlError": False, + } + self.assertEqual(expected, result["providerFallback"]) + self.assertEqual("passed", result["results"][0]["status"]) + self.assertNotIn("validated", result["results"][0]) + saved = json.loads(Path(result["jsonReport"]).read_text()) + self.assertEqual(expected, saved["providerFallback"]) + self.assertIn("provider gap", Path(result["htmlReport"]).read_text().lower()) + + def test_passed_import_requires_exact_current_source_query_hash(self): + original = json.loads(self.results.read_text()) + for entry in ( + {key: value for key, value in original[0].items() if key != "querySha256"}, + {**original[0], "querySha256": "0" * 64}, + ): + with self.subTest(querySha256=entry.get("querySha256")): + self.results.write_text(json.dumps([entry])) + recorded = record_runtime_validation( + self.solution, provider="triage-mcp", results_path=self.results, + ) + self.assertEqual(1, recorded["blocked"]) + self.assertEqual("blocked", recorded["results"][0]["status"]) + self.assertIn("query hash", recorded["results"][0]["error"].lower()) + + self.results.write_text(json.dumps(original)) + source = self.solution / "Analytic Rules/Rule.yaml" + document = yaml.safe_load(source.read_text()) + document["query"] += " | take 1" + source.write_text(yaml.safe_dump(document)) + recorded = record_runtime_validation( + self.solution, provider="triage-mcp", results_path=self.results, + ) + self.assertEqual(1, recorded["blocked"]) + self.assertIn("stale", recorded["results"][0]["error"].lower()) + + def test_kql_failure_stays_failure_and_provider_gap_cannot_mask_it(self): + failure = { + "detection": "Rule.yaml", + "status": "failed", + "error": "Failed to resolve table 'MissingTable'", + "querySha256": self.query_hash, + } + self.results.write_text(json.dumps([failure])) + recorded = record_runtime_validation( + self.solution, provider="triage-mcp", results_path=self.results, + ) + self.assertEqual(1, recorded["invalid"]) + self.assertEqual("failed", recorded["results"][0]["status"]) + self.assertIn("Failed to resolve table", recorded["results"][0]["error"]) + with self.assertRaisesRegex(ValueError, "fallback is not allowed"): + record_runtime_validation( + self.solution, + provider="log-analytics-cli", + results_path=self.results, + provider_gap_evidence=self.evidence, + ) + + def test_fallback_evidence_rejects_kql_errors_incomplete_batches_and_wrong_provider(self): + for changes, provider in ( + ({"kqlError": True}, "log-analytics-cli"), + ({"batchComplete": False}, "log-analytics-cli"), + ({"permissionsVerified": False}, "log-analytics-cli"), + ({"reasonCode": "kql-semantic-error"}, "log-analytics-cli"), + ({"fallbackProvider": "graph"}, "log-analytics-cli"), + ): + with self.subTest(changes=changes): + original = json.loads(self.evidence.read_text()) + original.update(changes) + self.evidence.write_text(json.dumps(original)) + with self.assertRaisesRegex(ValueError, "provider-gap evidence"): + _load_provider_fallback( + self.evidence, + query_family="sentinel", + fallback_provider=provider, + ) + self.evidence.write_text(json.dumps({ + "mcpProvider": "triage-mcp", + "advertisedTool": "QueryWorkspace", + "queryFamily": "sentinel", + "fallbackProvider": "log-analytics-cli", + "scopeReference": "/subscriptions/example/resourceGroups/lab/providers/Microsoft.OperationalInsights/workspaces/sentinel", + "reasonCode": "authorization", + "reason": "Triage MCP workspace-query permission was unavailable.", + "batchComplete": True, + "scopeVerified": True, + "permissionsVerified": True, + "kqlError": False, + })) + + def test_provider_gap_contract_is_query_family_scoped(self): + with self.assertRaisesRegex(ValueError, "provider-gap evidence"): + _load_provider_fallback( + self.evidence, + query_family="advanced-hunting", + fallback_provider="graph", + ) + + def test_fallback_batch_must_cover_every_detection(self): + second = self.solution / "XDR Detections" / "Other.yaml" + second.write_text("kind: CustomDetection\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "missing detections"): + record_runtime_validation( + self.solution, + provider="log-analytics-cli", + results_path=self.results, + provider_gap_evidence=self.evidence, + ) + + +class AdvancedHuntingProviderBoundsTests(unittest.TestCase): + def test_exact_query_response_retains_typed_schema_and_bounded_rows(self): + query = "DeviceEvents | where DeviceId == 'synthetic-device'" + schema = [ + {"name": "DeviceId", "type": "String"}, + {"name": "Timestamp", "type": "DateTime"}, + ] + rows = [{"DeviceId": f"synthetic-{index}"} for index in range(12)] + urlopen = mock.Mock( + return_value=_successful_response({"schema": schema, "results": rows}) + ) + with mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen): + result = run_advanced_hunting_query(query, _token="cached-test-token") + + request = urlopen.call_args.args[0] + self.assertEqual(1, urlopen.call_count) + self.assertEqual({"Query": query}, json.loads(request.data)) + self.assertTrue(request.get_header("Authorization").startswith("Bearer ")) + self.assertEqual(schema, result["schema"]) + self.assertEqual(12, result["rowCount"]) + self.assertEqual(rows[:10], result["sampleRows"]) + self.assertTrue(result["sampleRowsTruncated"]) + + def test_missing_or_invalid_provider_schema_is_not_success(self): + invalid_bodies = ( + {"results": []}, + {"schema": [{"name": "DeviceId"}], "results": []}, + { + "schema": [ + {"name": "DeviceId", "type": "String"}, + {"name": "deviceid", "type": "String"}, + ], + "results": [], + }, + { + "schema": [{"name": "DeviceId", "type": "String"}], + "results": [{"OtherColumn": "value"}], + }, + {"schema": [{"name": "DeviceId", "type": "String"}]}, + { + "error": {"message": "provider rejected the query"}, + "schema": [], + "results": [], + }, + ) + for body in invalid_bodies: + with self.subTest(body=body): + urlopen = mock.Mock(return_value=_successful_response(body)) + with mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen): + result = run_advanced_hunting_query( + "DeviceEvents", _token="cached-test-token" + ) + self.assertFalse(result["ok"]) + self.assertEqual("provider", result["failureKind"]) + self.assertEqual(200, result["statusCode"]) + self.assertIn("Graph Advanced Hunting", result["error"]) + self.assertEqual(1, urlopen.call_count) + + def test_retry_after_seconds_and_http_date_are_honored(self): + clock = _FakeClock() + retry_date = datetime.fromtimestamp(clock.wall_time, timezone.utc) + timedelta(seconds=2) + urlopen = mock.Mock( + side_effect=[ + _http_error(429, retry_after="1"), + _http_error(503, retry_after=retry_date.strftime("%a, %d %b %Y %H:%M:%S GMT")), + _successful_response(), + ] + ) + with ( + mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen), + mock.patch.object(runtime_module.time, "monotonic", clock.monotonic), + mock.patch.object(runtime_module.time, "time", clock.time), + mock.patch.object(runtime_module.time, "sleep", clock.sleep), + ): + result = run_advanced_hunting_query( + "DeviceEvents | take 1", _token="test-token", timeout=900, retries=99 + ) + + self.assertTrue(result["ok"]) + self.assertEqual(3, urlopen.call_count) + self.assertEqual(2.0, clock.elapsed) + self.assertEqual([300.0, 299.0, 298.0], [ + call.kwargs["timeout"] for call in urlopen.call_args_list + ]) + + def test_transient_retry_count_is_capped_at_three(self): + urlopen = mock.Mock(side_effect=[_http_error(503)] * 4) + with ( + mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen), + mock.patch.object(runtime_module.time, "sleep"), + ): + result = run_advanced_hunting_query( + "DeviceEvents | take 1", _token="test-token", retries=99 + ) + + self.assertFalse(result["ok"]) + self.assertEqual("provider", result["failureKind"]) + self.assertEqual(4, urlopen.call_count) + + def test_request_duration_consumes_retry_budget_and_bounds_next_timeout(self): + clock = _FakeClock() + + def slow_transient(*_args, **_kwargs): + clock.elapsed += 298 + raise _http_error(503, retry_after="1") + + def slow_transient_then_success(*args, **kwargs): + if urlopen.call_count == 1: + return slow_transient(*args, **kwargs) + return _successful_response() + + urlopen = mock.Mock(side_effect=slow_transient_then_success) + with ( + mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen), + mock.patch.object(runtime_module.time, "monotonic", clock.monotonic), + mock.patch.object(runtime_module.time, "sleep", clock.sleep), + ): + result = run_advanced_hunting_query( + "DeviceEvents | take 1", _token="test-token", timeout=900, retries=3 + ) + + self.assertTrue(result["ok"]) + self.assertEqual([300, 1], [ + call.kwargs["timeout"] for call in urlopen.call_args_list + ]) + self.assertEqual(299, clock.elapsed) + + def test_exhausted_wall_clock_budget_does_not_retry(self): + clock = _FakeClock() + + def exhaust_budget(*_args, **_kwargs): + clock.elapsed += 300 + raise _http_error(503, retry_after="1") + + urlopen = mock.Mock(side_effect=exhaust_budget) + sleeper = mock.Mock() + with ( + mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen), + mock.patch.object(runtime_module.time, "monotonic", clock.monotonic), + mock.patch.object(runtime_module.time, "sleep", sleeper), + ): + result = run_advanced_hunting_query( + "DeviceEvents | take 1", _token="test-token", retries=3 + ) + + self.assertFalse(result["ok"]) + self.assertEqual("provider", result["failureKind"]) + self.assertEqual(1, urlopen.call_count) + sleeper.assert_not_called() + + def test_retry_after_larger_than_remaining_budget_is_not_followed(self): + clock = _FakeClock() + + def slow_transient(*_args, **_kwargs): + clock.elapsed += 299 + raise _http_error(429, retry_after="2") + + urlopen = mock.Mock(side_effect=slow_transient) + sleeper = mock.Mock() + with ( + mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen), + mock.patch.object(runtime_module.time, "monotonic", clock.monotonic), + mock.patch.object(runtime_module.time, "sleep", sleeper), + ): + result = run_advanced_hunting_query( + "DeviceEvents | take 1", _token="test-token", retries=3 + ) + + self.assertFalse(result["ok"]) + self.assertEqual(1, urlopen.call_count) + sleeper.assert_not_called() + + @mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token", + return_value="test-token", + ) + @mock.patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_graph_validation_uses_converted_query_and_its_exact_hash( + self, run_query, _token + ): + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + solution = Path(temp) / "Example" + source_query = "DeviceEvents | where TimeGenerated > ago(4h)" + converted_query = "DeviceEvents | where Timestamp > ago(4h)" + source = solution / "Analytic Rules" / "Rule.yaml" + source.parent.mkdir(parents=True) + source.write_text( + yaml.safe_dump({"query": source_query}), encoding="utf-8" + ) + output = solution / "XDR Detections" + output.mkdir() + (output / "Rule.yaml").write_text( + yaml.safe_dump({ + "contentProvenance": { + "source": {"path": "Analytic Rules/Rule.yaml"} + }, + "properties": { + "queryCondition": {"queryText": converted_query} + }, + }), + encoding="utf-8", + ) + run_query.return_value = { + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [{"name": "Timestamp", "type": "DateTime"}], + "sampleRows": [], + "sampleRowsTruncated": False, + "error": None, + "errorDetails": None, + } + + report = validate_advanced_hunting(solution) + + self.assertEqual("graph", report["provider"]) + self.assertEqual( + [converted_query], + [call.args[0] for call in run_query.call_args_list], + ) + self.assertEqual( + hashlib.sha256(converted_query.encode()).hexdigest(), + report["results"][0]["querySha256"], + ) + + def test_authoring_validation_uses_tenant_only_auth_scope(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + root = Path(temp) / "Example" + output = root / "XDR Detections" + output.mkdir(parents=True) + tenant_id = "11111111-1111-4111-8111-111111111111" + query = "DeviceEvents | take 1" + (output / "Rule.yaml").write_text( + yaml.safe_dump({ + "properties": {"queryCondition": {"queryText": query}}, + }), + encoding="utf-8", + ) + initialize_workflow( + root, + tenant_id=tenant_id, + workflow_profile="authoring", + ) + state_root = Path(temp) / "auth-state" + state_root.mkdir() + (state_root / runtime_module.CONFIG_NAME).write_text( + json.dumps({"tenantId": tenant_id}), + encoding="utf-8", + ) + run_query = mock.Mock(return_value={ + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [{"name": "DeviceId", "type": "String"}], + "sampleRows": [], + "sampleRowsTruncated": False, + "error": None, + "errorDetails": None, + }) + with ( + mock.patch.object( + runtime_module, + "_load_auth_record", + return_value=mock.Mock(tenant_id=tenant_id), + ), + mock.patch.object( + runtime_module, + "require_runtime_query_consent", + return_value={ + "status": "approved", + "family": "defenderXdrQueryFamily", + "provider": "graph", + "workflowProfile": "authoring", + }, + ), + mock.patch.object( + runtime_module, + "_advanced_hunting_token", + return_value="cached-test-token", + ), + mock.patch.object( + runtime_module, + "run_advanced_hunting_query", + run_query, + ), + mock.patch.object( + runtime_module, + "_write_runtime_artifacts", + return_value={"provider": "graph", "invalid": 0, "blocked": 0}, + ), + ): + report = validate_advanced_hunting( + root, + state_dir=state_root, + ) + + self.assertEqual("graph", report["provider"]) + run_query.assert_called_once_with(query, _token="cached-test-token") + + def test_qualification_auth_scope_still_checks_complete_target_configuration(self) -> None: + tenant_id = "11111111-1111-4111-8111-111111111111" + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + state_root = Path(temp) + (state_root / runtime_module.CONFIG_NAME).write_text( + json.dumps({ + "tenantId": tenant_id, + "subscriptionId": "different-subscription", + }), + encoding="utf-8", + ) + target = { + "tenantId": tenant_id, + "subscriptionId": "locked-subscription", + "workspaceResourceId": "/subscriptions/locked/workspaces/locked", + "workspaceCustomerId": "locked-customer", + } + with mock.patch.object( + runtime_module, + "_load_auth_record", + return_value=mock.Mock(tenant_id=tenant_id), + ): + with self.assertRaisesRegex( + ValueError, + "configuration subscriptionId does not match", + ): + runtime_module._assert_graph_auth_scope( + state_root, + locked_target=target, + requested_tenant_id=tenant_id, + ) + + def test_authentication_and_kql_errors_are_not_retried(self): + for status, failure_kind in ((401, "provider"), (403, "provider"), (400, "query")): + with self.subTest(status=status): + urlopen = mock.Mock(side_effect=_http_error(status)) + with ( + mock.patch.object(runtime_module.urllib.request, "urlopen", urlopen), + mock.patch.object(runtime_module.time, "sleep") as sleeper, + ): + result = run_advanced_hunting_query( + "DeviceEvents | take 1", _token="test-token", retries=3 + ) + self.assertFalse(result["ok"]) + self.assertEqual(failure_kind, result["failureKind"]) + self.assertEqual(1, urlopen.call_count) + sleeper.assert_not_called() + + def test_at_most_five_graph_requests_are_in_flight(self): + lock = threading.Lock() + active = 0 + peak = 0 + calls = 0 + five_started = threading.Event() + release_requests = threading.Event() + + def blocked_urlopen(*_args, **_kwargs): + nonlocal active, peak, calls + with lock: + calls += 1 + active += 1 + peak = max(peak, active) + if active == 5: + five_started.set() + if not release_requests.wait(5): + raise AssertionError("test did not release blocked request") + with lock: + active -= 1 + return _successful_response() + + with mock.patch.object( + runtime_module.urllib.request, "urlopen", blocked_urlopen + ): + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [ + executor.submit( + run_advanced_hunting_query, + "DeviceEvents | take 1", + _token="test-token", + ) + for _ in range(10) + ] + self.assertTrue(five_started.wait(3)) + with lock: + self.assertEqual(5, active) + threading.Event().wait(0.05) + with lock: + self.assertEqual(5, calls) + release_requests.set() + results = [future.result(timeout=5) for future in futures] + + self.assertTrue(all(result["ok"] for result in results)) + self.assertEqual(5, peak) + self.assertEqual(10, calls) + + @mock.patch( + "sentinel_xdr_migration.runtime._advanced_hunting_token", + return_value="test-token", + ) + @mock.patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_shared_provider_failure_is_not_reported_as_rule_error( + self, run_query, _token + ): + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + solution = Path(temp) / "Example" + output = solution / "XDR Detections" + output.mkdir(parents=True) + query = "DeviceEvents | where Timestamp > ago(4h)" + (output / "Rule.yaml").write_text( + yaml.safe_dump({ + "properties": {"queryCondition": {"queryText": query}}, + }), + encoding="utf-8", + ) + run_query.return_value = { + "ok": False, + "statusCode": 503, + "rowCount": 0, + "schema": [], + "error": "Graph is temporarily unavailable", + "errorDetails": "shared provider outage", + "failureKind": "provider", + } + + report = validate_advanced_hunting(solution) + + self.assertEqual(0, report["invalid"]) + self.assertEqual(0, report["blocked"]) + self.assertEqual(1, report["notRun"]) + self.assertEqual("graph", report["providerFailure"]["provider"]) + self.assertEqual("not-run", report["results"][0]["status"]) + self.assertIsNone(report["results"][0]["error"]) + self.assertEqual( + hashlib.sha256(query.encode()).hexdigest(), + report["results"][0]["querySha256"], + ) + self.assertEqual([query], [ + call.args[0] for call in run_query.call_args_list + ]) + + +class _SyntheticDetection: + def __init__(self, name, query): + self.name = name + self.document = { + "id": f"xdr-{name}", + "properties": {"queryCondition": {"queryText": query}}, + } + + def _contents(self): + return yaml.safe_dump(self.document) + + def open(self, *_, **__): + return io.StringIO(self._contents()) + + def read_text(self, *_, **__): + return self._contents() + + def relative_to(self, _): + return Path(self.name) + + +class ExactAdvancedHuntingInventoryTests(unittest.TestCase): + def setUp(self): + self.queries = [ + f"DeviceEvents | where DeviceId == 'synthetic-{index:02d}'" + for index in range(21) + ] + self.detections = [ + _SyntheticDetection(f"Rule{index:02d}.yaml", query) + for index, query in enumerate(self.queries) + ] + + def test_plan_inventory_and_hash_cover_only_authored_queries(self): + batches = { + "Windows Security Events": 21, + "Azure Activity": 14, + "Syslog": 7, + "Endpoint Threat Protection Essentials": 15, + } + target = { + "tenantId": "tenant", + "subscriptionId": "subscription", + "resourceGroup": "group", + "workspaceResourceId": "workspace", + "workspaceName": "primary-workspace", + "workspaceCustomerId": "customer", + } + total_queries = 0 + for batch, count in batches.items(): + with self.subTest(batch=batch): + queries = [ + f"DeviceEvents | where DeviceId == '{batch}-{index:02d}'" + for index in range(count) + ] + source_queries = [ + runtime_consent._query_entry( + rule_id=f"source-{index:02d}", + path=f"Analytic Rules/Rule{index:02d}.yaml", + query=f"SecurityEvent | where RuleIndex == {index}", + ) + for index in range(count) + ] + detections = [ + _SyntheticDetection(f"Rule{index:02d}.yaml", query) + for index, query in enumerate(queries) + ] + root = Path.cwd() / f"offline-plan-{count}" + with ( + mock.patch.object( + runtime_consent, + "xdr_detection_files", + return_value=detections, + ), + mock.patch.object( + runtime_consent, "_run_context", return_value=({}, "run-21") + ), + mock.patch.object( + runtime_consent, + "require_locked_target", + return_value=target, + ), + mock.patch.object( + runtime_consent, "_load_primary_setting", return_value=None + ), + mock.patch.object( + runtime_consent, + "_source_inventory", + return_value=source_queries, + ), + mock.patch.object( + runtime_consent, + "_authentication_requirement", + return_value={}, + ), + ): + rules, probes = runtime_consent._xdr_inventory(root) + with mock.patch.object( + runtime_consent, + "_xdr_inventory", + return_value=(rules, probes), + ): + plan = runtime_consent.build_runtime_query_plan(root) + + family = plan["families"]["defenderXdrQueryFamily"] + source_family = plan["families"]["sentinelSourceQueryFamily"] + self.assertEqual(count, len(rules)) + self.assertEqual([], probes) + self.assertEqual(count, family["ruleCount"]) + self.assertEqual(count, family["queryCount"]) + self.assertEqual(count, source_family["ruleCount"]) + self.assertEqual(count, source_family["queryCount"]) + self.assertEqual(queries, [item["queryText"] for item in family["queries"]]) + self.assertTrue(all( + item["kind"] == "rule-query" for item in family["queries"] + )) + self.assertTrue(all( + item["querySha256"] + == hashlib.sha256(item["queryText"].encode()).hexdigest() + for item in family["queries"] + )) + unhashed = { + key: value for key, value in family.items() if key != "familySha256" + } + self.assertEqual( + family["familySha256"], + runtime_consent._canonical_sha256(unhashed), + ) + total_queries += family["queryCount"] + self.assertEqual(57, total_queries) + + def test_dispatch_runs_each_authored_query_once_without_extra_probes(self): + dispatched = [] + + def fake_query(query, **kwargs): + dispatched.append((query, kwargs)) + return { + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [{"name": "DeviceId", "type": "String"}], + "sampleRows": [], + "sampleRowsTruncated": False, + "error": None, + "errorDetails": None, + } + + def capture_report(root, provider, results, **kwargs): + return {"solution": str(root), "provider": provider, "results": results} + + with ( + mock.patch.object( + runtime_module, + "_detection_files", + return_value=self.detections, + ), + mock.patch.object( + runtime_module, + "require_runtime_query_consent", + return_value={"status": "approved", "family": "defenderXdrQueryFamily"}, + ), + mock.patch.object( + runtime_module, + "_advanced_hunting_token", + return_value="cached-test-token", + ), + mock.patch.object(runtime_module, "run_advanced_hunting_query", side_effect=fake_query), + mock.patch.object(runtime_module, "_write_runtime_artifacts", side_effect=capture_report), + ): + report = validate_advanced_hunting( + Path.cwd() / "offline-dispatch-fixture", + state_dir=Path.cwd(), + ) + + self.assertEqual(21, len(dispatched)) + self.assertEqual(self.queries, [query for query, _ in dispatched]) + self.assertTrue(all(kwargs["_token"] == "cached-test-token" for _, kwargs in dispatched)) + self.assertEqual(21, len(report["results"])) + self.assertEqual( + [hashlib.sha256(query.encode()).hexdigest() for query in self.queries], + [item["querySha256"] for item in report["results"]], + ) + self.assertTrue(all(item["schema"] == [ + {"name": "DeviceId", "type": "String"} + ] for item in report["results"])) + + def test_locked_target_and_stale_consent_fail_before_dispatch(self): + target = { + "tenantId": "locked-tenant", + "subscriptionId": "locked-subscription", + "resourceGroup": "locked-group", + "workspaceResourceId": "locked-workspace", + "workspaceCustomerId": "locked-customer", + } + workflow_state = mock.Mock() + workflow_state.is_file.return_value = True + workflow_state.read_text.return_value = json.dumps({ + "context": {"workflowProfile": "qualification"} + }) + dispatch = mock.Mock() + token = mock.Mock() + with ( + mock.patch.object( + runtime_module, + "existing_artifact_path", + return_value=workflow_state, + ), + mock.patch.object(runtime_module, "require_locked_target", return_value=target), + mock.patch.object(runtime_module, "_advanced_hunting_token", token), + mock.patch.object(runtime_module, "run_advanced_hunting_query", dispatch), + mock.patch.object( + runtime_module, + "require_runtime_query_consent", + ) as consent, + ): + with self.assertRaisesRegex(ValueError, "locked Qualification tenant"): + validate_advanced_hunting( + Path.cwd() / "locked-target-fixture", + tenant_id="different-tenant", + state_dir=Path.cwd(), + ) + self.assertFalse(consent.called) + self.assertFalse(token.called) + dispatch.assert_not_called() + + with ( + mock.patch.object( + runtime_module, + "require_runtime_query_consent", + side_effect=ValueError("runtime query inventory or consent is stale"), + ), + mock.patch.object(runtime_module, "_advanced_hunting_token", token), + mock.patch.object(runtime_module, "run_advanced_hunting_query", dispatch), + ): + with self.assertRaisesRegex(ValueError, "inventory or consent is stale"): + validate_advanced_hunting( + Path.cwd() / "stale-consent-fixture", + state_dir=Path.cwd(), + ) + token.assert_not_called() + dispatch.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_runtime_report_references.py b/Tools/SentinelToXDRMigration/tests/test_runtime_report_references.py new file mode 100644 index 00000000000..b9c23327ff8 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_runtime_report_references.py @@ -0,0 +1,533 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration.artifacts import artifact_path, report_directory, using_run +from sentinel_xdr_migration.runtime_report_references import ( + apply_runtime_report_reference_repair, + plan_runtime_report_reference_repair, +) +from sentinel_xdr_migration.packaging_selection import _current_target +from sentinel_xdr_migration.packaging_selection import _source_runtime_reference_status +from sentinel_xdr_migration.target_context import TARGET_FILE_NAME +from sentinel_xdr_migration.workflow import ( + _load, + _write_unlocked, + complete_workflow_stage, + initialize_workflow, +) + + +class RuntimeReportReferenceRepairTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.solution = Path(self.temp.name) / "ReferenceRepair" + self.solution.mkdir() + detection = self.solution / "XDR Detections" / "Keep.yaml" + detection.parent.mkdir(parents=True) + detection.write_text("kind: CustomDetection\n", encoding="utf-8") + self.run_id = initialize_workflow( + self.solution, workflow_profile="authoring", + )["runId"] + with using_run(self.solution, self.run_id): + self.run_directory = report_directory(self.solution, create=True) + self.source_report = self.run_directory / "runtime-validation.azure-monitor-sdk.json" + self.graph_report = self.run_directory / "runtime-validation.graph.json" + self.plan_report = self.run_directory / "runtime-query-plan.json" + self.consent_report = self.run_directory / "runtime-query-consent.json" + for path, payload in ( + (self.source_report, {"provider": "azure-monitor-sdk", "results": []}), + (self.graph_report, {"provider": "graph", "results": []}), + (self.plan_report, {"runId": self.run_id}), + (self.consent_report, {"status": "approved"}), + ): + path.write_text(json.dumps(payload), encoding="utf-8") + self.state_path, state = _load(self.solution) + validation = state["stages"]["validation"] + validation.update({ + "status": "passed", + "attempts": 1, + "startedAt": "2026-10-11T20:00:00Z", + "completedAt": "2026-10-11T20:01:00Z", + "message": None, + "artifacts": { + "sentinelRuntimeReport": self.source_report.relative_to(self.solution).as_posix(), + "advancedHuntingRuntimeReport": self.graph_report.relative_to(self.solution).as_posix(), + "runtimeStatus": "passed", + }, + "evidence": ["historical-validation-failure"], + }) + _write_unlocked(self.state_path, state) + self.state_before = self.state_path.read_bytes() + self.validation_before = state["stages"]["validation"].copy() + self.binding = mock.patch( + "sentinel_xdr_migration.runtime_report_references._validate_complete_runtime_evidence", + return_value=( + {"provider": "azure-monitor-sdk", "results": []}, + {"provider": "azure-monitor-sdk"}, + "f" * 64, + ), + ) + self.binding.start() + self.addCleanup(self.binding.stop) + + def _state(self) -> dict: + return _load(self.solution)[1] + + def _set_references(self, *, canonical: str | None, legacy: str | None) -> None: + path, state = _load(self.solution) + artifacts = state["stages"]["validation"]["artifacts"] + artifacts.pop("sentinelRuntimeJson", None) + artifacts.pop("sentinelRuntimeReport", None) + if canonical is not None: + artifacts["sentinelRuntimeJson"] = canonical + if legacy is not None: + artifacts["sentinelRuntimeReport"] = legacy + _write_unlocked(path, state) + + def test_legacy_alias_on_passed_stage_plans_canonical_reference(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertTrue(plan["eligible"], plan.get("reason")) + self.assertEqual("add-canonical-reference", plan["action"]) + self.assertEqual("passed", plan["workflowStageStatus"]) + self.assertEqual( + self.source_report.relative_to(self.solution).as_posix(), + plan["desiredCanonicalReference"], + ) + + def test_blocked_validation_stage_is_not_repaired(self) -> None: + path, state = _load(self.solution) + state["stages"]["validation"]["status"] = "blocked" + _write_unlocked(path, state) + before = path.read_bytes() + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertFalse(plan["eligible"]) + self.assertEqual("rejected", plan["action"]) + self.assertIn("blocked", plan["reason"]) + self.assertEqual(before, path.read_bytes()) + + def test_blocked_stage_with_current_canonical_reference_is_noop(self) -> None: + path, state = _load(self.solution) + validation = state["stages"]["validation"] + validation["status"] = "blocked" + validation["artifacts"]["sentinelRuntimeJson"] = ( + self.source_report.relative_to(self.solution).as_posix() + ) + _write_unlocked(path, state) + before = path.read_bytes() + validator = mock.patch( + "sentinel_xdr_migration.runtime_report_references._validate_complete_runtime_evidence", + side_effect=AssertionError("blocked no-op must not claim complete validation"), + ) + with validator: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertFalse(plan["eligible"]) + self.assertTrue(plan["noChange"]) + self.assertEqual("already-current-noop", plan["action"]) + self.assertEqual("blocked", plan["stageStatusPreserved"]) + no_op = apply_runtime_report_reference_repair( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual("already-current-noop", no_op["action"]) + self.assertTrue(no_op["noChange"]) + self.assertEqual(before, path.read_bytes()) + + def test_absent_reference_can_be_repaired_only_after_complete_proof(self) -> None: + self._set_references(canonical=None, legacy=None) + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertTrue(plan["eligible"], plan.get("reason")) + self.assertEqual("add-canonical-reference", plan["action"]) + + def test_matching_alias_and_canonical_are_accepted(self) -> None: + reference = self.source_report.relative_to(self.solution).as_posix() + self._set_references(canonical=reference, legacy=reference) + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertTrue(plan["eligible"], plan.get("reason")) + self.assertEqual("already-current", plan["action"]) + + def test_conflicting_alias_and_canonical_are_rejected(self) -> None: + self._set_references( + canonical=self.source_report.relative_to(self.solution).as_posix(), + legacy="Logs/sentinel-xdr-migration/other-run/runtime-validation.azure-monitor-sdk.json", + ) + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertFalse(plan["eligible"]) + self.assertIn("reference", plan["reason"]) + + def test_runtime_reference_traversal_is_rejected_before_resolution(self) -> None: + path, state = _load(self.solution) + state["stages"]["validation"]["artifacts"]["sentinelRuntimeJson"] = ( + f"../{self.run_id}/runtime-validation.azure-monitor-sdk.json" + ) + _write_unlocked(path, state) + valid, reason = _source_runtime_reference_status( + self.solution, + self.run_directory, + _load(self.solution)[1], + provider="azure-monitor-sdk", + ) + self.assertFalse(valid) + self.assertEqual("source-runtime-report-reference-unsafe-path", reason) + + def test_incomplete_or_unbound_runtime_proof_is_rejected_without_state_change(self) -> None: + blocking_reasons = ( + "stale query hash", + "wrong locked target", + "SDK execution binding missing", + "Graph execution binding unsigned", + "missing source-family coverage", + "later applicable runtime failure", + ) + for reason in blocking_reasons: + with self.subTest(reason=reason): + with mock.patch( + "sentinel_xdr_migration.runtime_report_references._validate_complete_runtime_evidence", + side_effect=ValueError(reason), + ): + result = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertFalse(result["eligible"]) + self.assertIn(reason, result["reason"]) + self.assertEqual(self.state_before, self.state_path.read_bytes()) + + def test_dry_run_failure_creates_or_changes_no_artifacts(self) -> None: + before = { + path.relative_to(self.solution).as_posix(): path.read_bytes() + for path in self.solution.rglob("*") + if path.is_file() + } + with mock.patch( + "sentinel_xdr_migration.runtime_report_references._validate_complete_runtime_evidence", + side_effect=ValueError("injected missing current target"), + ): + result = plan_runtime_report_reference_repair(self.solution, self.run_id) + after = { + path.relative_to(self.solution).as_posix(): path.read_bytes() + for path in self.solution.rglob("*") + if path.is_file() + } + self.assertFalse(result["eligible"]) + self.assertEqual(before, after) + + def test_apply_is_idempotent_and_preserves_stage_and_failure_history(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + first = apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + second = apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual("canonical-reference-added", first["action"]) + self.assertEqual(first, second) + backup_path = self.solution / first["backupPath"] + receipt_path = self.solution / first["receiptPath"] + self.assertEqual(self.state_before, backup_path.read_bytes()) + self.assertTrue(receipt_path.is_file()) + self.assertEqual(first["planSha256"], json.loads(receipt_path.read_text())["planSha256"]) + after = self._state()["stages"]["validation"] + self.assertEqual(self.validation_before["status"], after["status"]) + self.assertEqual(self.validation_before["evidence"], after["evidence"]) + self.assertEqual( + self.validation_before["artifacts"]["sentinelRuntimeReport"], + after["artifacts"]["sentinelRuntimeReport"], + ) + self.assertIn("sentinelRuntimeJson", after["artifacts"]) + + def test_apply_selects_the_exact_run_before_acquiring_the_state_lock(self) -> None: + other = initialize_workflow( + self.solution, + workflow_profile="authoring", + new_run=True, + )["runId"] + self.assertNotEqual(self.run_id, other) + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + result = apply_runtime_report_reference_repair( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual(self.run_id, result["runId"]) + self.assertIn(self.run_id, result["receiptPath"]) + with using_run(self.solution, other): + other_state = _load(self.solution)[1] + other_artifacts = other_state["stages"]["validation"].get("artifacts") or {} + self.assertEqual("pending", other_state["stages"]["validation"]["status"]) + self.assertNotIn("sentinelRuntimeJson", other_artifacts) + + def test_receipt_write_failure_is_recovered_before_retry_reports_success(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + module = "sentinel_xdr_migration.runtime_report_references" + original_atomic = __import__(module, fromlist=["_atomic_json"])._atomic_json + + def fail_receipt(path: Path, payload: dict) -> None: + if path.name.startswith("receipt."): + raise OSError("injected receipt failure") + original_atomic(path, payload) + + with mock.patch(f"{module}._atomic_json", side_effect=fail_receipt): + with self.assertRaisesRegex(RuntimeError, "pending recovery"): + apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + repair_dir = self.source_report.parent / "runtime-report-reference-repair" + self.assertTrue(list(repair_dir.glob("pending.*.json"))) + self.assertFalse(list(repair_dir.glob("receipt.*.json"))) + retry_plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertEqual("already-current", retry_plan["action"]) + recovered = apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=retry_plan["planSha256"], + ) + self.assertEqual("canonical-reference-added", recovered["action"]) + self.assertTrue(list(repair_dir.glob("receipt.*.json"))) + self.assertFalse(list(repair_dir.glob("pending.*.json"))) + + def test_blocked_stage_does_not_hide_unreceipted_pending_transaction(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + pending_path = ( + self.source_report.parent + / "runtime-report-reference-repair" + / f"pending.{plan['planSha256']}.json" + ) + pending_path.parent.mkdir(exist_ok=True) + pending_path.write_text( + json.dumps( + { + "schemaVersion": "1.0.0", + "runId": self.run_id, + "planSha256": plan["planSha256"], + "status": "prepared", + } + ), + encoding="utf-8", + ) + self._set_references( + canonical=self.source_report.relative_to(self.solution).as_posix(), + legacy=None, + ) + state_path, state = _load(self.solution) + state["stages"]["validation"]["status"] = "blocked" + _write_unlocked(state_path, state) + blocked_plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + self.assertEqual("already-current-noop", blocked_plan["action"]) + with self.assertRaisesRegex(ValueError, "cannot be recovered"): + apply_runtime_report_reference_repair( + self.solution, + self.run_id, + expected_plan_sha256=blocked_plan["planSha256"], + ) + + def test_prepared_transaction_recovers_if_state_write_fails_before_mutation(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + original_state = self.state_path.read_bytes() + with mock.patch( + "sentinel_xdr_migration.runtime_report_references._write_unlocked", + side_effect=OSError("injected workflow state write failure"), + ): + with self.assertRaisesRegex(RuntimeError, "pending recovery"): + apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual(original_state, self.state_path.read_bytes()) + pending = list( + (self.source_report.parent / "runtime-report-reference-repair").glob("pending.*.json") + ) + self.assertEqual(1, len(pending)) + recovered = apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual("canonical-reference-added", recovered["action"]) + self.assertFalse(list(pending[0].parent.glob("pending.*.json"))) + + def test_stale_state_plan_does_not_overwrite_a_concurrent_change(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + path, state = _load(self.solution) + state["stages"]["validation"]["artifacts"]["operatorNote"] = "concurrent update" + _write_unlocked(path, state) + changed = path.read_bytes() + with self.assertRaisesRegex(ValueError, "stale"): + apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual(changed, path.read_bytes()) + + def test_stale_report_or_authorization_evidence_invalidates_the_plan(self) -> None: + for evidence_path in ( + self.source_report, + self.graph_report, + self.plan_report, + self.consent_report, + ): + with self.subTest(path=evidence_path.name): + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + evidence_path.write_text( + evidence_path.read_text(encoding="utf-8") + " ", + encoding="utf-8", + ) + state_before = self.state_path.read_bytes() + with self.assertRaisesRegex(ValueError, "stale"): + apply_runtime_report_reference_repair( + self.solution, + self.run_id, + expected_plan_sha256=plan["planSha256"], + ) + self.assertEqual(state_before, self.state_path.read_bytes()) + + def test_commit_lock_rechecks_state_after_plan_construction(self) -> None: + plan = plan_runtime_report_reference_repair(self.solution, self.run_id) + module = "sentinel_xdr_migration.runtime_report_references" + original_build = __import__(module, fromlist=["_build_reference_repair_plan"])._build_reference_repair_plan + + def edit_after_plan(solution: str | Path, run_id: str) -> dict: + built = original_build(solution, run_id) + path, state = _load(solution) + state["stages"]["validation"]["artifacts"]["operatorNote"] = "commit-boundary edit" + _write_unlocked(path, state) + return built + + with mock.patch( + f"{module}._build_reference_repair_plan", + side_effect=edit_after_plan, + ): + with self.assertRaisesRegex(ValueError, "changed since repair planning"): + apply_runtime_report_reference_repair( + self.solution, self.run_id, expected_plan_sha256=plan["planSha256"], + ) + + def test_future_passed_stage_writer_emits_canonical_and_legacy_references(self) -> None: + path, state = _load(self.solution) + state["stages"]["validation"]["status"] = "running" + _write_unlocked(path, state) + evidence = { + "passed": True, + "sentinelProvider": "azure-monitor-sdk", + "sentinelReport": self.source_report.relative_to(self.solution).as_posix(), + "graphReport": self.graph_report.relative_to(self.solution).as_posix(), + } + with ( + mock.patch("sentinel_xdr_migration.workflow.require_solution_conversion_scope"), + mock.patch( + "sentinel_xdr_migration.runtime.current_runtime_evidence", + return_value=evidence, + ), + ): + complete_workflow_stage( + self.solution, "validation", status="passed", artifacts={}, + ) + artifacts = self._state()["stages"]["validation"]["artifacts"] + self.assertEqual(artifacts["sentinelRuntimeReport"], artifacts["sentinelRuntimeJson"]) + self.assertEqual( + self.source_report.relative_to(self.solution).as_posix(), + artifacts["sentinelRuntimeJson"], + ) + + def test_future_source_waiver_stage_writer_emits_canonical_reference(self) -> None: + path, state = _load(self.solution) + state["stages"]["validation"]["status"] = "running" + _write_unlocked(path, state) + evidence = { + "sourceWaiverOnlyBlocker": True, + "sourceWhitelist": [{ + "table": "IdentityInfo", + "rawFailure": {"status": "failed"}, + "applied": True, + }], + "sentinelProvider": "azure-monitor-sdk", + "sentinelReport": self.source_report.relative_to(self.solution).as_posix(), + "graphReport": self.graph_report.relative_to(self.solution).as_posix(), + } + with ( + mock.patch("sentinel_xdr_migration.workflow._validate_source_waiver_conversion_gates"), + mock.patch( + "sentinel_xdr_migration.workflow._source_whitelist_stage_artifacts", + return_value={}, + ), + mock.patch( + "sentinel_xdr_migration.runtime.current_runtime_evidence", + return_value=evidence, + ), + ): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={"runtimeStatus": "source-waived"}, + ) + artifacts = self._state()["stages"]["validation"]["artifacts"] + self.assertEqual( + self.source_report.relative_to(self.solution).as_posix(), + artifacts["sentinelRuntimeJson"], + ) + self.assertEqual( + artifacts["sentinelRuntimeReport"], + artifacts["sentinelRuntimeJson"], + ) + + def test_qualification_target_lookup_does_not_materialize_missing_target(self) -> None: + qualification = self.solution.parent / "ReadOnlyTarget" + qualification.mkdir() + run_id = initialize_workflow( + qualification, + workflow_profile="qualification", + tenant_id="39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + subscription_id="42382e39-f157-46d1-a931-b8cfd779ece5", + workspace_resource_id=( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/QueryLab/providers/Microsoft.OperationalInsights/" + "workspaces/QueryWorkspace" + ), + workspace_customer_id="756386d8-e2d4-4f09-905a-74b24313721f", + )["runId"] + target_path = artifact_path(qualification, TARGET_FILE_NAME) + target_path.unlink(missing_ok=True) + state_path, state = _load(qualification) + with mock.patch( + "sentinel_xdr_migration.target_context.write_target", + side_effect=AssertionError("read-only target lookup attempted a write"), + ) as write_target: + target, fingerprint = _current_target( + qualification, state, materialize_missing=False, + ) + write_target.assert_not_called() + self.assertTrue(target) + self.assertTrue(fingerprint) + self.assertFalse(target_path.exists()) + state["stages"]["validation"].update({ + "status": "passed", + "attempts": 1, + "startedAt": "2026-10-11T20:00:00Z", + "completedAt": "2026-10-11T20:01:00Z", + "message": None, + }) + _write_unlocked(state_path, state) + state_before = state_path.read_bytes() + files_before = { + path.relative_to(qualification).as_posix(): path.read_bytes() + for path in qualification.rglob("*") + if path.is_file() + } + with using_run(qualification, run_id), mock.patch( + "sentinel_xdr_migration.target_context.write_target", + side_effect=AssertionError("read-only planning attempted a target write"), + ) as write_target: + result = plan_runtime_report_reference_repair(qualification, run_id) + self.assertFalse(result["eligible"]) + self.assertIn("runtime-validation.azure-monitor-sdk.json", result["reason"]) + write_target.assert_not_called() + files_after = { + path.relative_to(qualification).as_posix(): path.read_bytes() + for path in qualification.rglob("*") + if path.is_file() + } + self.assertEqual(files_before, files_after) + self.assertEqual(state_before, artifact_path(qualification, "workflow-state.json").read_bytes()) + self.assertFalse(target_path.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_search_review.py b/Tools/SentinelToXDRMigration/tests/test_search_review.py new file mode 100644 index 00000000000..410d3e4aef4 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_search_review.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import io +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +from sentinel_xdr_migration.converter import build_xdr_document, convert_query + + +SEARCH_REASON = "search queries should be replaced with explicit tables" +REPOSITORY = Path(__file__).resolve().parents[3] + + +class SearchReviewTests(unittest.TestCase): + def test_comments_literals_and_identifiers_do_not_request_search_review(self): + queries = [ + '// search *\nDeviceEvents | count', + 'DeviceEvents // | search "needle"', + '/* search *\n| search "needle" */ DeviceEvents | count', + 'DeviceEvents | where ActionType == "search"', + "DeviceEvents | where ActionType == 'search'", + 'DeviceEvents | extend Message="http://example/search /* search */"', + r'''DeviceEvents | extend Message="escaped \" search // text"''', + r"""DeviceEvents | extend Message='escaped \' search /* text'""", + 'DeviceEvents | extend Message=@"quote "" search // text"', + "DeviceEvents | extend Message=@'quote '' search /* text'", + 'DeviceEvents | extend Message=```multiline\n" search // text\n```', + 'DeviceEvents | extend Message=h"search"', + 'DeviceEvents | project search, SearchResult', + 'DeviceEvents | extend search=ActionType | where search == "needle"', + 'DeviceEvents | extend Alias=search | project Alias', + 'DeviceEvents | where (search == "needle")', + 'DeviceEvents | where (search has "needle")', + 'DeviceEvents | extend Found=search contains "needle"', + 'DeviceEvents | where (search and Enabled)', + "DeviceEvents | project ['search']", + 'DeviceEvents | where Fields.search == "needle"', + 'let search = DeviceEvents; search | count', + 'let Alias = search; Alias | count', + ] + for query in queries: + with self.subTest(query=query): + self.assertNotIn(SEARCH_REASON, convert_query(query, {})[1]) + + def test_executable_search_still_requests_review(self): + queries = [ + 'search "needle"', + 'SEARCH kind=case_sensitive "needle"', + 'search in (DeviceEvents) "needle"', + 'search *', + 'search ActionType == "needle"', + 'search ActionType has "needle"', + 'DeviceEvents | search "needle"', + 'DeviceEvents | search (ActionType == "needle")', + 'let Events = search "needle"; Events | count', + 'let Events = (search "needle"); Events | count', + 'let Events = () { search "needle" }; Events()', + 'union (search "needle"), DeviceEvents', + 'DeviceEvents | join (search "needle") on DeviceId', + '/* search in a comment */ search "needle"', + 'DeviceEvents | /* multiline\ncomment */ search /* predicate */ "needle"', + 'DeviceEvents // search in comment\n| search "needle"', + 'DeviceEvents | extend Message="https://example" | search "needle"', + 'DeviceEvents | extend Message="/* not a comment" | search "needle"', + r'''DeviceEvents | extend Message=@"C:\" | search "needle"''', + r"""DeviceEvents | extend Message=@'C:\' | search "needle" """, + 'DeviceEvents | extend Message=```" // multiline\n``` | search "needle"', + ] + for query in queries: + with self.subTest(query=query): + self.assertEqual(1, convert_query(query, {})[1].count(SEARCH_REASON)) + + def test_actual_c2_namedpipe_comment_is_not_an_operator(self): + source = REPOSITORY / ( + "Solutions/Microsoft Defender XDR/Analytic Rules/" + "Command and Control/C2-NamedPipe.yaml" + ) + query = yaml.safe_load(source.read_text(encoding="utf-8"))["query"] + self.assertIn("search uses has_any", query.splitlines()[0]) + self.assertTrue(query.splitlines()[0].lstrip().startswith("//")) + self.assertIn("| where ParsedFields.PipeName has_any (badPipeNames)", query) + self.assertNotIn(SEARCH_REASON, convert_query(query, {})[1]) + + def test_document_review_gate_distinguishes_comment_from_operator(self): + root = Path("search-review-fixture") + rule = { + "id": "11111111-2222-3333-4444-555555555555", + "name": "Search review fixture", + "queryFrequency": "1h", + "queryPeriod": "4h", + "entityMappings": [{ + "entityType": "Host", + "fieldMappings": [{"identifier": "DeviceId", "columnName": "DeviceId"}], + }], + } + for query, needs_review in ( + ('// search "needle"\nDeviceEvents', False), + ('DeviceEvents | search "needle"', True), + ): + with self.subTest(query=query): + rule["query"] = query + original_open = Path.open + + def open_input(path, *args, **kwargs): + expected_path = root / "Analytic Rules/Rule.yaml" + if path == expected_path: + return io.StringIO(yaml.safe_dump(rule)) + return original_open(path, *args, **kwargs) + + with patch.object(Path, "open", autospec=True, side_effect=open_input), \ + patch("sentinel_xdr_migration.converter.normalize_parser_bindings", + side_effect=lambda text, _: (text, [])): + document = build_xdr_document(root / "Analytic Rules/Rule.yaml", root, {}) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual(needs_review, conversion["reviewRequired"]) + self.assertEqual("needsReview" if needs_review else "converted", conversion["status"]) + self.assertEqual([SEARCH_REASON] if needs_review else [], conversion["reviewReasons"]) + self.assertFalse(conversion["errors"]) + self.assertEqual("disabled", document["properties"]["status"]) + + def test_other_kql_gates_are_unchanged(self): + for query, message, index in ( + ('workspace("other").DeviceEvents', "cross-workspace queries require manual redesign", 2), + ('externaldata(Value:string) ["https://example"]', + "externaldata availability must be verified by runtime validation", 1), + ('union isfuzzy=true DeviceEvents, DeviceInfo', + "isfuzzy unions can still fail semantic binding in Advanced Hunting", 1), + ('_Im_Test()', "ASIM parser availability must be verified in Advanced Hunting", 1), + ): + with self.subTest(query=query): + self.assertIn(message, convert_query(query, {})[index]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_solution_report.py b/Tools/SentinelToXDRMigration/tests/test_solution_report.py new file mode 100644 index 00000000000..6a5a4b357f1 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_solution_report.py @@ -0,0 +1,1106 @@ +from __future__ import annotations + +import ast +import hashlib +import json +import tempfile +import unittest +from html.parser import HTMLParser +from pathlib import Path +from unittest.mock import Mock, patch + +import yaml + +from sentinel_xdr_migration.converter import convert_solution +from sentinel_xdr_migration.artifacts import existing_artifact_path, report_directory +from sentinel_xdr_migration import deployment_planning +from sentinel_xdr_migration.report_dashboard import _deployment_planning_html +from sentinel_xdr_migration.solution_report import ( + _partial_selection_parity_matches, + _package_resource_inventory, + _planning_summary, + _preflight_projection, + _selected_packaging_report, + build_solution_report, +) +from test_converter import RULE + + +class SolutionReportTests(unittest.TestCase): + def test_partial_qualification_requires_exact_current_parity_scope(self) -> None: + plan = { + "planSha256": "plan-current", + "selection": { + "xdrDetections": [ + "XDR Detections/A.yaml", + "XDR Detections/B.yaml", + ] + }, + "xdrDetections": [ + {"included": True, "sourceId": "source-a"}, + {"included": True, "sourceId": "source-b"}, + ], + } + applied = {"planSha256": "plan-current"} + parity = { + "status": "passed", + "qualificationScopeVerified": True, + "selectionPlanSha256": "plan-current", + "testedDetectionPaths": ["A.yaml", "B.yaml"], + "testedSourceIds": ["source-a", "source-b"], + "qualificationScope": {"selectionComplete": True}, + } + self.assertTrue( + _partial_selection_parity_matches( + plan, parity, applied, stage_passed=True + )[0] + ) + self.assertFalse( + _partial_selection_parity_matches( + plan, parity, applied, stage_passed=False + )[0] + ) + + narrower = dict(parity, testedDetectionPaths=["A.yaml"], testedSourceIds=["source-a"]) + self.assertFalse( + _partial_selection_parity_matches( + plan, narrower, applied, stage_passed=True + )[0] + ) + stale = dict(parity, selectionPlanSha256="old-plan") + self.assertFalse( + _partial_selection_parity_matches( + plan, stale, applied, stage_passed=True + )[0] + ) + incomplete = dict( + parity, + qualificationScope={"selectionComplete": False}, + ) + self.assertFalse( + _partial_selection_parity_matches( + plan, incomplete, applied, stage_passed=True + )[0] + ) + + def test_runtime_evidence_unavailable_does_not_hide_structural_errors(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "Example" + analytic = root / "Analytic Rules" + analytic.mkdir(parents=True) + (analytic / "Rule.yaml").write_text(RULE, encoding="utf-8") + convert_solution(root) + target = root / "XDR Detections" / "Rule.yaml" + target.write_text( + yaml.safe_dump({"kind": "InvalidCustomDetection", "properties": {}}), + encoding="utf-8", + ) + + with patch( + "sentinel_xdr_migration.runtime.current_runtime_evidence", + side_effect=ValueError("invalid current XDR runtime target"), + ): + report = build_solution_report(root) + + self.assertTrue(any( + error["stage"] == "structural-validation" + for error in report["rules"][0]["errors"] + )) + self.assertEqual( + "invalid current XDR runtime target", + report["runtimeAssessment"]["runtimeUnavailable"], + ) + self.assertEqual("unavailable", report["runtimeAssessment"]["sourceStatus"]) + + def test_recorded_planning_consent_is_projected_without_write_authority(self) -> None: + digest = "a" * 64 + template_hash = "1" * 64 + artifact_hashes = { + "mainTemplate": template_hash, + "createUiDefinition": "2" * 64, + "testParameters": "3" * 64, + "zip": "4" * 64, + } + target = { + "tenantId": "tenant", + "subscriptionId": "subscription", + "resourceGroup": "resource-group", + "workspaceResourceId": "/subscriptions/example/workspaces/lab", + "workspaceCustomerId": "workspace-customer", + "solution": "Example", + "version": "3.1.1", + } + resource_inventory = [{"type": "Microsoft.Resources/deployments"}] + resource_type = "Microsoft.Security/detectionRules@2026-06-01-preview" + package_resource_type = "Microsoft.Resources/deployments" + flags = { + "DeployAnalyticsRule": True, + "DeployPlaybook": True, + "DeployWorkbook": True, + "DeployDataConnector": True, + "DeployCustomDetection": True, + } + plan = { + "schemaVersion": "1.0.0", + "status": "provisional", + "planSha256": "c" * 64, + "target": target, + "packageResourceInventory": resource_inventory, + "components": { + "sentinelAlertRules": { + "maximumCount": 1, + "resourceTypes": [resource_type], + }, + "xdrCustomDetections": { + "maximumCount": 1, + "maximumAdditionalCount": 0, + "resourceTypes": [resource_type], + }, + "fullPackageAcceptance": { + "status": "available", + "availableFlags": flags, + "resourceTypes": [package_resource_type], + }, + }, + "planningConsent": {"status": "not-recorded", "approvedComponents": []}, + "packageAcceptancePlan": { + "status": "proposed", + "approvalStatus": "not-approved", + "packageIdentity": {"status": "current", "version": "3.1.1"}, + }, + } + recorded: dict[str, dict] = {} + + def capture_record(_root, _path, document): + recorded["consent"] = document + + with ( + patch.object(deployment_planning, "build_deployment_plan", return_value=plan), + patch.object( + deployment_planning, + "artifact_path", + return_value=Path("deployment-planning-consent.json"), + ), + patch.object(deployment_planning, "write_json_artifact", side_effect=capture_record), + ): + result = deployment_planning.record_planning_consent( + Path("/selected-run"), + expected_plan_sha256=plan["planSha256"], + actor="reviewer", + approved_components=[ + "fullPackageAcceptance", + "sentinelAlertRules", + "xdrCustomDetections", + ], + max_additional_custom_detections=0, + package_flags=flags, + ) + self.assertEqual("approved", result["status"]) + self.assertFalse(result["environmentWritesAuthorized"]) + consent_record = recorded["consent"] + + package = { + "packageInputs": {"after": {"sha256": digest}}, + "packageArtifactHashes": artifact_hashes, + "packageArtifactsSha256": "d" * 64, + "packageReport": "Logs/run/packaging.v3_1.attempt.json", + "version": "3.1.1", + "deploymentParameters": {}, + } + workflow = { + "profile": "qualification", + "profileSelectionConfirmed": True, + "packageFreshness": { + "status": "current", + "currentPackageInputSha256": digest, + }, + } + persisted_consent_path = Path("deployment-planning-consent.json") + original_read_text = Path.read_text + + def read_consent(path, *args, **kwargs): + if path == persisted_consent_path: + return json.dumps(consent_record) + return original_read_text(path, *args, **kwargs) + + with ( + patch( + "sentinel_xdr_migration.solution_report._read_local_package_template", + return_value=({"resources": []}, template_hash), + ), + patch( + "sentinel_xdr_migration.solution_report._deployment_planning_input", + return_value=plan, + ), + patch.object( + deployment_planning, + "existing_artifact_path", + return_value=persisted_consent_path, + ), + patch.object(Path, "read_text", autospec=True, side_effect=read_consent), + ): + validated = deployment_planning._consent_state( + Path("/selected-run"), + plan["planSha256"], + components=plan["components"], + package={"status": "current", "resourceInventory": resource_inventory}, + target=target, + ) + self.assertEqual("approved", validated["status"]) + projected = _planning_summary(Path("/selected-run"), package, workflow, {}) + + self.assertEqual("approved", projected["deploymentPlan"]["planningConsent"]["status"]) + self.assertEqual("both", projected["deploymentPlan"]["executionMode"]) + self.assertEqual( + ["fullPackageAcceptance", "sentinelAlertRules", "xdrCustomDetections"], + projected["deploymentPlan"]["planningConsent"]["componentKeys"], + ) + self.assertEqual( + "approved", + projected["deploymentPlan"]["packageAcceptancePlan"]["approvalStatus"], + ) + self.assertEqual( + flags, + projected["deploymentPlan"]["packageAcceptancePlan"]["selectedFlags"], + ) + self.assertEqual("not-recorded", projected["deploymentPlan"]["exactScopeWriteApproval"]) + self.assertFalse(consent_record["environmentWritesAuthorized"]) + + def test_selected_package_report_is_bound_to_current_workflow_attempt(self) -> None: + report_bytes = b"current package report" + report_hash = hashlib.sha256(report_bytes).hexdigest() + package_input_hash = "a" * 64 + artifact_hashes = { + name: (value * 64)[:64] + for name, value in ( + ("mainTemplate", "1"), + ("createUiDefinition", "2"), + ("testParameters", "3"), + ("zip", "4"), + ) + } + attempt_id = "current-attempt" + selected_reference = "Logs/run/packaging.v3_1.current-attempt.json" + package_document = { + "packager": "V3.1", + "packageAttemptId": attempt_id, + "workflowRunId": "selected-run", + "packageArtifactsSha256": "b" * 64, + "packageInputs": {"after": {"sha256": package_input_hash}}, + "packageArtifactHashes": artifact_hashes, + } + workflow = { + "runId": "selected-run", + "stages": { + "packaging": { + "status": "passed", + "artifacts": { + "packageReport": selected_reference, + "packageAttemptId": attempt_id, + "packageReportSha256": report_hash, + }, + }, + }, + "packaging": { + "lastSuccessful": { + "packageSucceeded": True, + "attemptId": attempt_id, + "packageReport": selected_reference, + "packageReportSha256": report_hash, + "packageArtifactsSha256": "b" * 64, + "packageInputSha256": package_input_hash, + }, + }, + } + resolved = Mock() + resolved.resolve.return_value = Path("/selected-run/current-package.json") + resolved.read_bytes.return_value = report_bytes + with ( + patch("sentinel_xdr_migration.solution_report.report_directory", return_value=Path("/selected-run/reports")), + patch("sentinel_xdr_migration.solution_report.resolve_artifact_reference", return_value=resolved), + patch("sentinel_xdr_migration.solution_report._read_run_json", return_value=package_document), + ): + selected, reference = _selected_packaging_report(Path("/selected-run"), workflow) + self.assertEqual(package_document, selected) + self.assertEqual(selected_reference, reference) + + workflow["packaging"]["lastSuccessful"]["packageReport"] = "Logs/run/packaging.v3_1.json" + with ( + patch("sentinel_xdr_migration.solution_report.report_directory", return_value=Path("/selected-run/reports")), + patch("sentinel_xdr_migration.solution_report.resolve_artifact_reference", return_value=resolved), + patch("sentinel_xdr_migration.solution_report._read_run_json", return_value=package_document), + ): + selected, reference = _selected_packaging_report(Path("/selected-run"), workflow) + self.assertEqual({}, selected) + self.assertIsNone(reference) + + def test_embedded_planning_consent_without_recorded_artifact_is_not_trusted(self) -> None: + digest = "a" * 64 + contract = { + "schemaVersion": "1.0.0", + "status": "provisional", + "selectedProfile": "qualification", + "executionMode": "pairs", + "target": {"workspaceResourceId": ""}, + "planSha256": digest, + "components": { + "disabledRulePairValidation": { + "planningConsent": {"status": "approved"}, + "resourceTypes": ["Microsoft.Security/detectionRules@2026-06-01-preview"], + "selectedFlags": {"disabledOnly": True}, + }, + "mockTelemetryIngestion": { + "planningConsent": {"status": "unknown"}, + "conditional": True, + }, + }, + "planningConsent": { + "status": "approved", + "planSha256": digest, + "componentKeys": ["disabledRulePairValidation"], + "resourceTypes": ["Microsoft.Security/detectionRules@2026-06-01-preview"], + "selectedFlags": {"disabledOnly": True}, + }, + "exactScopeWriteApproval": "not-recorded", + "readiness": "not-evaluated", + "deploymentEvidence": "not-recorded", + } + with patch.object( + deployment_planning, + "existing_artifact_path", + return_value=Path("/selected-run/deployment-planning-consent.json"), + ): + projected = _planning_summary( + Path("/selected-run"), + {}, + {"packageFreshness": {"status": "current"}}, + {"deploymentPlanning": contract}, + ) + + self.assertEqual("unknown", projected["deploymentPlan"]["status"]) + self.assertEqual("not-recorded", projected["deploymentPlan"]["planningConsent"]["status"]) + self.assertEqual("not-recorded", projected["deploymentPlan"]["exactScopeWriteApproval"]) + self.assertEqual("pairs", projected["deploymentPlan"]["executionMode"]) + self.assertEqual( + ["Microsoft.Security/detectionRules@2026-06-01-preview"], + projected["deploymentPlan"]["components"]["disabledRulePairValidation"]["resourceTypes"], + ) + self.assertEqual( + "not-recorded", + projected["deploymentPlan"]["components"]["disabledRulePairValidation"]["planningConsent"], + ) + self.assertEqual( + "unknown", + projected["deploymentPlan"]["components"]["mockTelemetryIngestion"]["planningConsent"], + ) + html = _deployment_planning_html({"deploymentPlanning": projected}) + self.assertIn("Qualification", html) + self.assertIn("disabledRulePairValidation", html) + self.assertIn("Microsoft.Security/detectionRules", html) + self.assertIn("<script>alert(1)</script>", html) + self.assertNotIn("", html) + + def test_plan_approval_and_identity_follow_current_package_freshness(self) -> None: + digest = "a" * 64 + changed_digest = "b" * 64 + artifact_hashes = { + "mainTemplate": "1" * 64, + "createUiDefinition": "2" * 64, + "testParameters": "3" * 64, + "zip": "4" * 64, + } + resource_type = "Microsoft.Security/detectionRules" + package_resource_type = "Microsoft.Resources/deployments" + target = { + "tenantId": "tenant", + "subscriptionId": "subscription", + "resourceGroup": "resource-group", + "workspaceResourceId": "/subscriptions/example/workspaces/lab", + "solution": "Example", + "version": "3.1.1", + } + plan_template = { + "schemaVersion": "1.0.0", + "status": "current", + "selectedProfile": "qualification", + "executionMode": "pairs", + "target": target, + "planSha256": "c" * 64, + "packageInputSha256": digest, + "packageArtifactHashes": artifact_hashes, + "recordedPackageArtifactHashes": artifact_hashes, + "packageIdentity": {"status": "current", "version": "3.1.1"}, + "components": { + "sentinelAlertRules": { + "resourceTypes": [resource_type], + "planningConsent": {"status": "approved"}, + }, + "fullPackageAcceptance": { + "resourceTypes": [package_resource_type], + "availableFlags": {"DeployWorkbook": True}, + "planningConsent": {"status": "approved"}, + }, + }, + "planningConsent": { + "status": "approved", + "planSha256": "c" * 64, + "componentKeys": ["sentinelAlertRules"], + "resourceTypes": [resource_type], + "selectedFlags": {"disabledOnly": True}, + }, + "packageAcceptancePlan": { + "status": "proposed", + "approvalStatus": "approved", + "packageIdentity": {"status": "current", "version": "3.1.1"}, + }, + } + package = { + "packageInputs": {"after": {"sha256": digest}}, + "packageArtifactHashes": artifact_hashes, + "packageArtifactsSha256": "d" * 64, + "packageReport": "Logs/run/packaging.v3_1.attempt.json", + "version": "3.1.1", + "deploymentParameters": {}, + } + template = {"resources": []} + + def project(freshness: dict) -> dict: + workflow = { + "profile": "qualification", + "profileSelectionConfirmed": True, + "packageFreshness": freshness, + } + with ( + patch( + "sentinel_xdr_migration.solution_report._read_local_package_template", + return_value=(template, artifact_hashes["mainTemplate"]), + ), + patch( + "sentinel_xdr_migration.solution_report._deployment_planning_input", + return_value=plan_template, + ), + patch( + "sentinel_xdr_migration.solution_report._validated_planning_consent", + return_value={ + "status": "approved", + "planSha256": plan_template["planSha256"], + "approvedComponents": ["fullPackageAcceptance", "sentinelAlertRules"], + "resourceTypes": [package_resource_type, resource_type], + "packageFlags": {"DeployWorkbook": True}, + "executionMode": "both", + }, + ), + ): + return _planning_summary(Path("/selected-run"), package, workflow, {}) + + stale = project({ + "status": "stale", + "currentPackageInputSha256": changed_digest, + }) + self.assertEqual("stale", stale["packageFreshness"]) + self.assertEqual("stale", stale["packageIdentity"]) + self.assertEqual(changed_digest, stale["currentPackageInputSha256"]) + self.assertEqual("stale", stale["deploymentPlan"]["status"]) + self.assertEqual("stale", stale["deploymentPlan"]["packageIdentity"]["status"]) + self.assertEqual("stale", stale["deploymentPlan"]["planningConsent"]["status"]) + self.assertEqual( + "stale", + stale["deploymentPlan"]["components"]["sentinelAlertRules"]["planningConsent"], + ) + self.assertEqual("stale", stale["deploymentPlan"]["packageAcceptancePlan"]["approvalStatus"]) + self.assertEqual( + "stale", + stale["deploymentPlan"]["packageAcceptancePlan"]["packageIdentity"]["status"], + ) + + unknown = project({"status": "unverifiable"}) + self.assertEqual("unknown", unknown["packageFreshness"]) + self.assertEqual("unknown", unknown["packageIdentity"]) + self.assertEqual("unknown", unknown["deploymentPlan"]["status"]) + self.assertEqual("unknown", unknown["deploymentPlan"]["planningConsent"]["status"]) + self.assertEqual( + "unknown", + unknown["deploymentPlan"]["components"]["sentinelAlertRules"]["planningConsent"], + ) + self.assertEqual("unknown", unknown["deploymentPlan"]["packageAcceptancePlan"]["approvalStatus"]) + + missing_current_identity = project({"status": "current"}) + self.assertEqual("unknown", missing_current_identity["packageFreshness"]) + self.assertEqual("unknown", missing_current_identity["packageIdentity"]) + self.assertEqual("unknown", missing_current_identity["deploymentPlan"]["status"]) + self.assertEqual("unknown", missing_current_identity["deploymentPlan"]["planningConsent"]["status"]) + + current = project({ + "status": "current", + "currentPackageInputSha256": digest, + }) + self.assertEqual("current", current["packageFreshness"]) + self.assertEqual(digest, current["currentPackageInputSha256"]) + self.assertEqual("verified current", current["packageIdentity"]) + self.assertEqual("current", current["deploymentPlan"]["status"]) + self.assertEqual("approved", current["deploymentPlan"]["planningConsent"]["status"]) + self.assertEqual( + "approved", + current["deploymentPlan"]["components"]["sentinelAlertRules"]["planningConsent"], + ) + self.assertEqual("approved", current["deploymentPlan"]["packageAcceptancePlan"]["approvalStatus"]) + + def test_package_inventory_reads_nested_arrays_and_language_version_two_resources(self) -> None: + inventory = _package_resource_inventory({ + "resources": { + "nested": { + "type": "Microsoft.Resources/deployments", + "properties": { + "template": { + "resources": [ + {"type": "Microsoft.Security/detectionRules@2026-06-01-preview"}, + ], + }, + }, + }, + "alertRule": { + "type": "Microsoft.SecurityInsights/alertRules", + "apiVersion": "2024-03-01", + }, + }, + }) + by_type = {item["type"]: item for item in inventory} + self.assertEqual("2026-06-01-preview", by_type["Microsoft.Security/detectionRules"]["apiVersion"]) + self.assertEqual( + "Microsoft.Security/detectionRules@2026-06-01-preview", + by_type["Microsoft.Security/detectionRules"]["originalType"], + ) + self.assertEqual("2024-03-01", by_type["Microsoft.SecurityInsights/alertRules"]["apiVersion"]) + self.assertIn("languageVersion 2 resource dictionary", by_type["Microsoft.SecurityInsights/alertRules"]["sourceForms"]) + self.assertIn("nested ARM resources array", by_type["Microsoft.Security/detectionRules"]["sourceForms"]) + + def test_preflight_projection_keeps_management_grant_separate_from_read_and_get(self) -> None: + projected = _preflight_projection({ + "status": "ready", + "requiredManagementPermission": "CustomDetection.ReadWrite.All", + "managementPermissionGrant": { + "status": "missing", + "grantType": "delegated", + "claimType": "scp", + "permission": "CustomDetection.ReadWrite.All", + "credentialRequestScope": "graph-resource-default", + }, + "deploymentReadiness": "action-required", + "authentication": { + "status": "acquired", + "tokenPermissionClaims": {"status": "present", "claimType": "scp"}, + }, + "permissions": { + "readAccess": "confirmed", + "writeAuthorization": "not-tested", + "effectiveRole": {"status": "unknown"}, + }, + "readChecks": [{"status": "confirmed", "httpStatus": 200}], + }) + self.assertEqual("confirmed", projected["permissions"]["readAccess"]) + self.assertEqual(200, projected["readChecks"][0]["httpStatus"]) + self.assertEqual("missing", projected["managementPermissionGrant"]["status"]) + self.assertEqual("action-required", projected["deploymentReadiness"]) + self.assertEqual("unknown", projected["effectiveRoleStatus"]) + self.assertEqual("scp", projected["tokenPermissionClaims"]["claimType"]) + + def test_runtime_pass_is_blocked_when_current_advanced_hunting_query_hash_differs(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "Example" + analytic = root / "Analytic Rules" + analytic.mkdir(parents=True) + (analytic / "Rule.yaml").write_text(RULE, encoding="utf-8") + convert_solution(root) + detection_path = root / "XDR Detections/Rule.yaml" + detection = yaml.safe_load(detection_path.read_text()) + target_query = detection["properties"]["queryCondition"]["queryText"] + report_directory(root, create=True).joinpath( + "runtime-validation.graph.json" + ).write_text(json.dumps({ + "provider": "graph", + "platform": "Microsoft Defender XDR Advanced Hunting", + "results": [{ + "detection": "Rule.yaml", + "status": "passed", + "rowCount": 1, + "querySha256": hashlib.sha256(b"DeviceEvents").hexdigest(), + }], + })) + + report = build_solution_report(root) + runtime = report["rules"][0]["customDetection"]["runtime"][0] + self.assertEqual("blocked", runtime["status"]) + self.assertEqual("stale-or-missing", runtime["queryHashStatus"]) + self.assertTrue(any( + error["stage"] == "custom-detection-runtime-validation" + and "exact current query was not validated" in error["message"] + for error in report["rules"][0]["errors"] + )) + + runtime_hash = hashlib.sha256(target_query.encode()).hexdigest() + report_path = report_directory(root) / "runtime-validation.graph.json" + result_document = json.loads(report_path.read_text()) + result_document["results"][0]["querySha256"] = runtime_hash + report_path.write_text(json.dumps(result_document)) + refreshed = build_solution_report(root) + self.assertEqual( + "passed", + refreshed["rules"][0]["customDetection"]["runtime"][0]["status"], + ) + + def test_source_waiver_is_visible_without_hiding_failure_or_blocked_validation(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "Example" + analytic = root / "Analytic Rules" + analytic.mkdir(parents=True) + for index in range(14): + rule = yaml.safe_load(RULE) + rule["id"] = str(__import__("uuid").UUID(int=index + 1)) + rule["name"] = f"Example rule {index + 1}" + rule["query"] = ( + "DeviceEvents\n" + f"| where DeviceId == '{index + 1}'\n" + "| project TimeGenerated, AccountUpn, IPAddress" + ) + (analytic / f"Rule{index + 1:02}.yaml").write_text( + yaml.safe_dump(rule), encoding="utf-8" + ) + convert_solution(root) + report_dir = report_directory(root) + source_results = [] + for path in sorted(analytic.glob("*.yaml")): + rule = yaml.safe_load(path.read_text(encoding="utf-8")) + result = { + "detection": path.name, + "status": "passed", + "rowCount": 1, + "querySha256": hashlib.sha256( + rule["query"].encode("utf-8") + ).hexdigest(), + } + if path.name == "Rule01.yaml": + diagnostic = ( + "'summarize' operator: Failed to resolve table or " + "column expression named 'IdentityInfo'" + ) + result.update({ + "status": "failed", + "statusCode": 400, + "failureKind": "query", + "error": { + "code": "BadArgumentError", + "message": diagnostic, + "innererror": { + "code": "SemanticError", + "innererror": { + "code": "SEM0100", + "message": diagnostic, + }, + }, + }, + "errorDetails": { + "code": "BadArgumentError", + "innererror": { + "code": "SemanticError", + "innererror": { + "code": "SEM0100", + "message": diagnostic, + }, + }, + }, + }) + source_results.append(result) + (report_dir / "runtime-validation.azure-monitor-sdk.json").write_text( + json.dumps({ + "provider": "azure-monitor-sdk", + "querySurface": "log-analytics-workspace", + "platform": "Microsoft Sentinel Log Analytics", + "total": 14, + "familyComplete": True, + "results": source_results, + }), + encoding="utf-8", + ) + graph_results = [] + for path in sorted((root / "XDR Detections").glob("*.yaml")): + detection = yaml.safe_load(path.read_text(encoding="utf-8")) + target_query = detection["properties"]["queryCondition"]["queryText"] + graph_results.append({ + "detection": path.name, + "status": "not-run", + "statusCode": 0, + "rowCount": None, + "querySha256": hashlib.sha256( + target_query.encode("utf-8") + ).hexdigest(), + "error": "XDR runtime validation was not run.", + }) + (report_dir / "runtime-validation.graph.json").write_text( + json.dumps({ + "provider": "graph", + "querySurface": "defender-advanced-hunting", + "platform": "Defender Advanced Hunting", + "total": 14, + "notRun": 14, + "results": graph_results, + }), + encoding="utf-8", + ) + workflow_path = existing_artifact_path(root, "workflow-state.json") + workflow_path.write_text( + json.dumps({ + "runId": report_dir.parent.name, + "workflowStatus": "active", + "stages": { + "validation": { + "status": "blocked", + "message": "Graph validation remains not run.", + } + }, + }), + encoding="utf-8", + ) + report = build_solution_report(root) + + rule = next( + item for item in report["rules"] + if any( + runtime["status"] == "failed" + for runtime in item["analyticRule"]["runtime"] + ) + ) + source_runtime = next( + runtime for runtime in rule["analyticRule"]["runtime"] + if runtime["status"] == "failed" + ) + self.assertEqual("failed", source_runtime["status"]) + self.assertEqual("IdentityInfo", source_runtime["sourceWhitelist"]["table"]) + self.assertEqual( + "source-waived", source_runtime["sourceWhitelist"]["status"] + ) + self.assertEqual( + "failed", source_runtime["sourceWhitelist"]["rawFailure"]["status"] + ) + self.assertEqual(14, len(report["rules"])) + self.assertEqual( + 14, + sum( + runtime["status"] == "not-run" + for item in report["rules"] + for runtime in item["customDetection"]["runtime"] + ), + ) + self.assertEqual("not-run", rule["customDetection"]["runtime"][0]["status"]) + self.assertTrue(any( + error["stage"] == "analytic-rule-runtime-validation" + for error in rule["errors"] + )) + self.assertEqual("blocked", report["runtimeAssessment"]["validationStageStatus"]) + self.assertFalse(report["runtimeAssessment"]["fullyRuntimeQualified"]) + self.assertEqual("source-waived", report["runtimeAssessment"]["sourceStatus"]) + self.assertEqual(1, len(report["runtimeAssessment"]["sourceWhitelist"])) + self.assertEqual(13, report["summary"]["analyticRuntimePassed"]) + self.assertEqual(0, report["summary"]["customRuntimePassed"]) + self.assertEqual(1, report["summary"]["errors"]) + self.assertEqual(0, report["summary"]["strictParityPassed"]) + self.assertEqual(0, report["summary"]["queryParityPassed"]) + self.assertIn("does not qualify", rule["warnings"][-1]) + self.assertEqual(14, report["summary"]["rules"]) + persisted = json.loads(Path(report["jsonReport"]).read_text(encoding="utf-8")) + self.assertEqual( + "source-waived", + persisted["runtimeAssessment"]["sourceWhitelist"][0]["status"], + ) + html = Path(report["htmlReport"]).read_text(encoding="utf-8") + self.assertIn("Source runtime whitelist", html) + self.assertIn("IdentityInfo", html) + + def test_information_uses_manifest_with_provenance_fallback_without_status_changes(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "Example" + analytic = root / "Analytic Rules" + analytic.mkdir(parents=True) + (analytic / "Rule.yaml").write_text(yaml.safe_dump({ + "id": "11111111-1111-1111-1111-111111111111", "name": "Example", + "description": "Example", "severity": "High", "queryFrequency": "1h", "queryPeriod": "4h", + "query": "DeviceEvents | project Timestamp, DeviceId, DeviceName", + "entityMappings": [{"entityType": "Host", "fieldMappings": [{"identifier": "HostName", "columnName": "DeviceName"}]}], + }), encoding="utf-8") + convert_solution(root) + reports = report_directory(root) + manifest_path = reports / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["results"][0].pop("informational", None) + manifest_path.write_text(json.dumps(manifest)) + draft_path = root / "XDR Detections" / "Rule.yaml" + draft = yaml.safe_load(draft_path.read_text()) + draft["contentProvenance"]["conversion"]["informational"] = ["Provenance fallback"] + draft_path.write_text(yaml.safe_dump(draft)) + fallback = build_solution_report(root) + self.assertEqual(fallback["rules"][0]["informational"], ["Provenance fallback"]) + self.assertEqual(len(fallback["rules"][0]["sourceSha256"]), 64) + manifest["results"][0]["informational"] = [] + manifest_path.write_text(json.dumps(manifest)) + empty = build_solution_report(root) + self.assertEqual(empty["rules"][0]["informational"], []) + self.assertEqual(empty["summary"]["informational"], 0) + self.assertEqual(fallback["summary"]["informational"], 1) + self.assertEqual(fallback["summary"]["rulesWithInformation"], 1) + self.assertEqual( + {key: value for key, value in empty["summary"].items() if key not in {"informational", "rulesWithInformation"}}, + {key: value for key, value in fallback["summary"].items() if key not in {"informational", "rulesWithInformation"}}, + ) + manifest["results"][0]["informational"] = ["Manifest "] + manifest_path.write_text(json.dumps(manifest)) + explicit = build_solution_report(root) + self.assertEqual(explicit["rules"][0]["informational"], ["Manifest "]) + self.assertEqual(explicit["summary"], fallback["summary"]) + self.assertIn("Manifest <information>", Path(explicit["htmlReport"]).read_text()) + + def test_report_renderer_parses_as_python_311(self) -> None: + source_path = ( + Path(__file__).parents[1] + / "sentinel_xdr_migration" + / "solution_report.py" + ) + ast.parse( + source_path.read_text(encoding="utf-8"), + filename=str(source_path), + feature_version=(3, 11), + ) + + def test_report_contains_rule_status_and_entity_recommendation(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) / "Example" + rules = root / "Analytic Rules" + rules.mkdir(parents=True) + source = { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Example rule", + "description": "Example", + "severity": "High", + "tactics": ["Execution"], + "relevantTechniques": ["T1059"], + "queryFrequency": "1h", + "queryPeriod": "1h", + "query": "Example_CL | project User", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + {"identifier": "Name", "columnName": "User"} + ], + } + ], + } + (rules / "Rule.yaml").write_text( + yaml.safe_dump(source, sort_keys=False), encoding="utf-8" + ) + convert_solution(root) + output = root / "XDR Detections" + reports = report_directory(root, create=True) + (reports / "runtime-validation.log-analytics-cli.json").write_text( + json.dumps( + { + "platform": "Microsoft Sentinel Log Analytics", + "provider": "log-analytics-cli", + "results": [ + { + "detection": "Rule.yaml", + "status": "passed", + "rowCount": 1, + "querySha256": hashlib.sha256( + source["query"].encode() + ).hexdigest(), + } + ], + } + ), + encoding="utf-8", + ) + (reports / "runtime-validation.graph.json").write_text( + json.dumps( + { + "platform": "Microsoft Defender XDR Advanced Hunting", + "provider": "graph", + "results": [ + { + "detection": "Rule.yaml", + "status": "blocked", + "rowCount": 0, + "querySha256": hashlib.sha256( + yaml.safe_load( + (output / "Rule.yaml").read_text() + )["properties"]["queryCondition"]["queryText"].encode() + ).hexdigest(), + "error": "table unavailable", + } + ], + } + ), + encoding="utf-8", + ) + (reports / "mock-query-parity.json").write_text( + json.dumps( + { + "reportPath": str(reports / "mock-query-parity.json"), + "comparisons": [ + { + "detection": "Rule.yaml", + "passed": True, + "matchKey": {"IPAddress": "192.0.2.1"}, + "analyticRuleRows": 1, + "customDetectionRows": 1, + } + ], + } + ), + encoding="utf-8", + ) + (reports / "packaging.v3_1.json").write_text( + json.dumps({ + "packageReport": str(reports / "packaging.v3_1.json"), + "tacticProjection": [{ + "detectionId": f"xdr-{source['id']}", + "authoredTactics": ["Execution", "Persistence"], + "packagedTactics": ["Execution"], + "omittedTactics": ["Persistence"], + }], + }), + encoding="utf-8", + ) + + report = build_solution_report(root) + + self.assertEqual(report["solution"], "Example") + self.assertEqual(report["summary"]["rules"], 1) + self.assertEqual(report["rules"][0]["queries"]["source"], source["query"]) + self.assertTrue(report["rules"][0]["queries"]["converted"]) + self.assertIn("workflow", report) + classification = report["rules"][0]["attackClassification"] + self.assertEqual(classification["sourceTactics"], ["Execution"]) + self.assertEqual(classification["sourceTechniques"], ["T1059"]) + self.assertEqual(classification["originalTactics"], ["Execution"]) + self.assertEqual(classification["originalTechniques"], ["T1059"]) + self.assertEqual(classification["draftTactics"], [ + {"tactic": "Execution", "techniques": [{"technique": "T1059"}]} + ]) + self.assertEqual( + report["rules"][0]["customDetection"]["conversionStatus"], + "converted", + ) + self.assertTrue( + report["rules"][0]["customDetection"]["reviewRequired"] + ) + self.assertIn("complete UPN", report["rules"][0]["entityRecommendation"]) + self.assertEqual( + report["rules"][0]["analyticRule"]["runtime"][0]["status"], "passed" + ) + self.assertEqual( + report["rules"][0]["customDetection"]["runtime"][0]["status"], + "blocked", + ) + self.assertEqual(report["summary"]["analyticRuntimePassed"], 1) + self.assertEqual(report["summary"]["customRuntimePassed"], 0) + self.assertEqual(report["summary"]["queryParityPassed"], 1) + self.assertEqual(report["summary"]["packagedTacticOmissions"], 1) + self.assertEqual( + report["rules"][0]["packagingProjection"]["omittedTactics"], + ["Persistence"], + ) + self.assertTrue(any( + "not parity" in message for message in report["rules"][0]["informational"] + )) + self.assertEqual(report["rules"][0]["queryParity"]["status"], "passed") + self.assertTrue(Path(report["jsonReport"]).exists()) + self.assertTrue(Path(report["htmlReport"]).exists()) + self.assertEqual(reports, Path(report["jsonReport"]).parent) + self.assertEqual( + ["Rule.yaml"], + sorted(path.name for path in output.iterdir()), + ) + html = Path(report["htmlReport"]).read_text(encoding="utf-8") + self.assertIn("log-analytics-cli", html) + self.assertIn("graph", html) + self.assertIn("Query parity", html) + + def test_report_json_and_html_preserve_source_and_converted_kql_exactly(self) -> None: + class SourceQueryParser(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.heading = None + self.next_pre_is_source = False + self.capture = False + self.current = [] + self.queries = [] + + def handle_starttag(self, tag, attrs): + if tag == "h4": + self.heading = [] + elif tag == "pre" and self.next_pre_is_source: + self.capture = True + self.current = [] + self.next_pre_is_source = False + + def handle_data(self, data): + if self.heading is not None: + self.heading.append(data) + if self.capture: + self.current.append(data) + + def handle_endtag(self, tag): + if tag == "h4" and self.heading is not None: + self.next_pre_is_source = "".join(self.heading) == "Source Sentinel KQL" + self.heading = None + elif tag == "pre" and self.capture: + self.queries.append("".join(self.current)) + self.capture = False + + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) / "Example" + rules = root / "Analytic Rules" + rules.mkdir(parents=True) + query = ( + "DeviceProcessEvents\n" + '| where NewProcessName endswith "\\\\\\\\certutil.exe"\n' + '| extend SourcePath = "C:\\\\\\\\Program Files\\\\\\\\source\\\\\\\\agent.exe"\n' + "| join kind=inner (DeviceNetworkEvents | project DeviceId, RemoteUrl) on DeviceId\n" + '| where RemoteUrl contains "https://example.test/source?mode=join"\n' + "| project TimeGenerated, DeviceId, SourcePath" + ) + source = { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Query fidelity", + "description": "Report serialization fidelity fixture.", + "severity": "High", + "queryFrequency": "1h", + "queryPeriod": "1h", + "query": query, + } + source_path = rules / "Rule.yaml" + source_path.write_text(yaml.safe_dump(source, sort_keys=False), encoding="utf-8") + loaded_source = yaml.safe_load(source_path.read_text(encoding="utf-8")) + self.assertEqual(query, loaded_source["query"]) + convert_solution(root) + target_path = root / "XDR Detections" / "Rule.yaml" + target_query = yaml.safe_load(target_path.read_text(encoding="utf-8"))[ + "properties" + ]["queryCondition"]["queryText"] + + report = build_solution_report(root) + rule = report["rules"][0] + self.assertEqual(loaded_source["query"], rule["queries"]["source"]) + self.assertEqual(target_query, rule["queries"]["converted"]) + persisted = json.loads(Path(report["jsonReport"]).read_text(encoding="utf-8")) + self.assertEqual(loaded_source["query"], persisted["rules"][0]["queries"]["source"]) + self.assertEqual(target_query, persisted["rules"][0]["queries"]["converted"]) + self.assertEqual("Analytic Rules/Rule.yaml", persisted["rules"][0]["sourceFile"]) + self.assertEqual("XDR Detections/Rule.yaml", persisted["rules"][0]["outputFile"]) + + parser = SourceQueryParser() + parser.feed(Path(report["htmlReport"]).read_text(encoding="utf-8")) + self.assertEqual([loaded_source["query"]], parser.queries) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_source_runtime_waiver.py b/Tools/SentinelToXDRMigration/tests/test_source_runtime_waiver.py new file mode 100644 index 00000000000..abb1a133767 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_source_runtime_waiver.py @@ -0,0 +1,843 @@ +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +import uuid +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.artifacts import ( + artifact_path, + existing_artifact_path, + using_run, +) +from sentinel_xdr_migration.report_dashboard import _flow_stage +from sentinel_xdr_migration.runtime import ( + current_runtime_evidence, + record_runtime_validation, +) +from sentinel_xdr_migration.source_runtime_waiver import ( + MISSING_SCHEMA_TABLES, + missing_schema_whitelist_match, +) +from sentinel_xdr_migration.workflow import ( + complete_workflow_stage, + initialize_workflow, + start_workflow_stage, + workflow_status, +) +from sentinel_xdr_migration import workflow as workflow_module + + +TABLE_DIAGNOSTIC = ( + "'summarize' operator: Failed to resolve table or column expression " + "named 'IdentityInfo'" +) +TABLE_QUERY = "IdentityInfo | summarize Count=count()" + + +def _sha256(value: bytes | str) -> str: + if isinstance(value, str): + value = value.encode("utf-8") + return hashlib.sha256(value).hexdigest() + + +def _sdk_failure(query: str, *, table_name: str = "IdentityInfo") -> dict: + diagnostic = TABLE_DIAGNOSTIC.replace("IdentityInfo", table_name) + sdk_error = { + "code": "BadArgumentError", + "message": diagnostic, + "innererror": { + "code": "SemanticError", + "innererror": { + "code": "SEM0100", + "message": diagnostic, + }, + }, + } + return { + "status": "failed", + "statusCode": 400, + "failureKind": "query", + "querySha256": _sha256(query), + "error": sdk_error, + "errorDetails": sdk_error, + "partial": False, + } + + +class IdentityInfoSourceWhitelistTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def _create_solution( + self, + name: str, + queries: list[str], + *, + provider: str = "azure-monitor-sdk", + source_failure_indices: tuple[int, ...] = (0,), + graph_status: str = "passed", + graph_schema_overrides: dict[int, object] | None = None, + source_overrides: dict[int, dict] | None = None, + provider_failure: dict | None = None, + family_complete: bool = True, + ) -> tuple[Path, str, dict[str, str]]: + solution = self.root / name + source_root = solution / "Analytic Rules" + detection_root = solution / "XDR Detections" + source_root.mkdir(parents=True) + detection_root.mkdir() + run_id = initialize_workflow( + solution, + workflow_profile="authoring", + version_bump="none", + )["runId"] + query_hashes: dict[str, str] = {} + source_results: list[dict] = [] + graph_results: list[dict] = [] + surface = { + "triage-mcp": "microsoft-sentinel-triage", + "azure-monitor-sdk": "log-analytics-workspace", + "log-analytics-cli": "log-analytics-workspace", + }[provider] + + for index, query in enumerate(queries): + name_on_disk = f"Rule{index:02}.yaml" + rule_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{name}/{index}")) + source_doc = { + "id": rule_id, + "name": f"Rule {index}", + "query": query, + } + source_path = source_root / name_on_disk + source_bytes = yaml.safe_dump(source_doc, sort_keys=False).encode() + source_path.write_bytes(source_bytes) + target_query = f"DeviceInfo | project DeviceName | take {index + 1}" + detection_doc = { + "kind": "CustomDetection", + "contentProvenance": { + "source": { + "path": f"Analytic Rules/{name_on_disk}", + "id": rule_id, + }, + "conversion": { + "status": "converted", + "reviewRequired": False, + "errors": [], + }, + }, + "properties": { + "id": rule_id, + "queryCondition": {"queryText": target_query}, + "detectionAction": {"alertTemplate": {}}, + }, + } + (detection_root / name_on_disk).write_text( + yaml.safe_dump(detection_doc, sort_keys=False), + encoding="utf-8", + ) + query_hashes[name_on_disk] = _sha256(query) + + if index in source_failure_indices: + result = _sdk_failure(query) + else: + result = { + "status": "passed", + "statusCode": 200, + "failureKind": None, + "querySha256": _sha256(query), + "rowCount": 0, + } + result["detection"] = name_on_disk + if source_overrides and index in source_overrides: + result.update(source_overrides[index]) + source_results.append(result) + + graph_entry = { + "detection": name_on_disk, + "status": graph_status, + "statusCode": 200 if graph_status == "passed" else 0, + "rowCount": 0, + "querySha256": _sha256(target_query), + "schemaColumnCount": 1, + "error": None if graph_status == "passed" else "XDR query not run.", + } + if graph_status == "passed": + graph_entry["schema"] = [{"name": "DeviceName", "type": "String"}] + if graph_schema_overrides and index in graph_schema_overrides: + schema = graph_schema_overrides[index] + if schema is None: + graph_entry.pop("schema", None) + else: + graph_entry["schema"] = schema + graph_results.append(graph_entry) + + source_report_path = artifact_path( + solution, + f"runtime-validation.{provider}.json", + create_parent=True, + ) + source_report = { + "provider": provider, + "querySurface": surface, + "total": len(source_results), + "valid": sum(item["status"] == "passed" for item in source_results), + "invalid": sum(item["status"] == "failed" for item in source_results), + "blocked": sum(item["status"] == "blocked" for item in source_results), + "notRun": sum(item["status"] == "not-run" for item in source_results), + "familyComplete": family_complete, + "results": source_results, + } + if provider_failure: + source_report["providerFailure"] = provider_failure + source_report_path.write_text(json.dumps(source_report), encoding="utf-8") + graph_path = artifact_path(solution, "runtime-validation.graph.json") + graph_path.write_text(json.dumps({ + "provider": "graph", + "querySurface": "defender-advanced-hunting", + "total": len(graph_results), + "familyComplete": True, + "results": graph_results, + }), encoding="utf-8") + return solution, run_id, query_hashes + + def _evidence(self, solution: Path, run_id: str) -> dict: + with using_run(solution, run_id): + return current_runtime_evidence(solution) + + def test_global_whitelist_across_solutions_and_rules_keeps_raw_failures(self) -> None: + self.assertEqual(("IdentityInfo",), MISSING_SCHEMA_TABLES) + first, first_run, _ = self._create_solution( + "SolutionOne", + [ + TABLE_QUERY, + "DeviceEvents | take 1", + "identityinfo | summarize Count=count()", + ], + source_failure_indices=(0, 2), + ) + second, second_run, _ = self._create_solution( + "SolutionTwo", + [ + "IDENTITYINFO | project AccountUPN", + "DeviceEvents | take 2", + ], + provider="triage-mcp", + source_failure_indices=(0,), + ) + + for solution, run_id, count in ( + (first, first_run, 2), + (second, second_run, 1), + ): + provider = "triage-mcp" if solution == second else "azure-monitor-sdk" + raw_path = artifact_path( + solution, f"runtime-validation.{provider}.json" + ) + raw_before = raw_path.read_bytes() + evidence = self._evidence(solution, run_id) + self.assertFalse(evidence["passed"]) + self.assertEqual("source-waived", evidence["sourceStatus"]) + self.assertTrue(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual(count, len(evidence["sourceWhitelist"])) + self.assertTrue(all( + item["table"] == "IdentityInfo" + and item["status"] == "source-waived" + and item["applied"] is True + and item["rawFailure"]["status"] == "failed" + and item["sourceExecutionPassed"] is False + and item["runtimeQualified"] is False + for item in evidence["sourceWhitelist"] + )) + raw_after = json.loads(raw_path.read_bytes()) + self.assertEqual(raw_before, raw_path.read_bytes()) + self.assertTrue(all( + item["status"] == "failed" + for item in raw_after["results"] + if item["detection"] in { + assessment["detection"] + for assessment in evidence["sourceWhitelist"] + } + )) + self.assertEqual( + len(raw_after["results"]), + sum(item["status"] == "passed" for item in raw_after["results"]) + + sum(item["status"] == "failed" for item in raw_after["results"]), + ) + + def test_yolo_runtime_observations_keep_eligible_failure_nonblocking_and_raw(self) -> None: + solution, run_id, _ = self._create_solution( + "YoloObservation", + [TABLE_QUERY], + source_failure_indices=(0,), + ) + source_path = solution / "Analytic Rules" / "Rule00.yaml" + source = yaml.safe_load(source_path.read_text(encoding="utf-8")) + target_path = solution / "XDR Detections" / "Rule00.yaml" + target = yaml.safe_load(target_path.read_text(encoding="utf-8")) + target_query = target["properties"]["queryCondition"]["queryText"] + raw_report_path = artifact_path( + solution, "runtime-validation.azure-monitor-sdk.json" + ) + raw_before = raw_report_path.read_bytes() + inventory = [{ + "ruleId": source["id"], + "querySha256": _sha256(source["query"]), + }] + results = [{ + "ruleId": source["id"], + "status": "migrated", + "target": { + "path": "XDR Detections/Rule00.yaml", + "querySha256": _sha256(target_query), + "structurallyValid": True, + }, + }] + + with using_run(solution, run_id): + findings, environment_errors, _ = workflow_module._runtime_observations( + solution, raw_report_path.parent, inventory, results + ) + + self.assertEqual([], environment_errors) + waiver_finding = findings[source["id"]][0] + self.assertFalse(waiver_finding["unresolved"]) + self.assertIn("source-waived for IdentityInfo", waiver_finding["message"]) + self.assertIn("Raw provider status remains 'failed'", waiver_finding["message"]) + self.assertEqual(raw_before, raw_report_path.read_bytes()) + + def test_exact_nested_azure_sdk_sem0100_diagnostic_is_recognized(self) -> None: + match = missing_schema_whitelist_match( + _sdk_failure(TABLE_QUERY), + provider="azure-monitor-sdk", + expected_query_sha256=_sha256(TABLE_QUERY), + ) + self.assertIsNotNone(match) + self.assertEqual("IdentityInfo", match["table"]) + self.assertEqual("failed", match["rawFailure"]["status"]) + self.assertFalse(match["sourceExecutionPassed"]) + self.assertFalse(match["runtimeQualified"]) + + def test_case_and_equivalent_explicit_table_diagnostic_are_supported(self) -> None: + for spelling in ("identityinfo", "IDENTITYINFO"): + query = f"{spelling} | take 1" + failure = _sdk_failure(query, table_name=spelling) + match = missing_schema_whitelist_match( + failure, + provider="log-analytics-cli", + expected_query_sha256=_sha256(query), + ) + self.assertIsNotNone(match) + self.assertEqual("IdentityInfo", match["table"]) + + query = "IdentityInfo | count" + failure = { + "status": "failed", + "statusCode": 400, + "failureKind": "query", + "querySha256": _sha256(query), + "error": "SEM0100: Failed to resolve table 'IdentityInfo'.", + "errorDetails": "SEM0100: Failed to resolve table 'IdentityInfo'.", + } + self.assertIsNotNone( + missing_schema_whitelist_match( + failure, + provider="triage-mcp", + expected_query_sha256=_sha256(query), + ) + ) + + def test_missing_columns_other_tables_mixed_or_multiple_errors_are_not_whitelisted(self) -> None: + query = TABLE_QUERY + failures = [ + {"error": "SEM0100: Failed to resolve column 'IdentityInfo'."}, + {"error": "SEM0100: Failed to resolve table 'DeviceEvents'."}, + { + "error": ( + "SEM0100: Failed to resolve table 'IdentityInfo'; " + "SEM0200: Failed to resolve column 'AccountUPN'." + ) + }, + { + "error": ( + "SEM0100: Failed to resolve table 'IdentityInfo'; " + "SEM0100: Failed to resolve table 'DeviceEvents'." + ) + }, + { + "error": "SEM0100: Failed to resolve table 'IdentityInfo'.", + "errorDetails": "SEM0200: Failed to resolve column 'AccountUPN'.", + }, + {"error": "KQL parse error near IdentityInfo."}, + {"error": "SEM0100: Failed to resolve table 'IdentityInfo'; authorization denied."}, + ] + for override in failures: + with self.subTest(override=override): + entry = { + "status": "failed", + "statusCode": 400, + "failureKind": "query", + "querySha256": _sha256(query), + "errorDetails": "SEM0100", + **override, + } + self.assertIsNone( + missing_schema_whitelist_match( + entry, + provider="azure-monitor-sdk", + expected_query_sha256=_sha256(query), + ) + ) + + def test_partial_authentication_provider_and_stale_failures_are_not_whitelisted(self) -> None: + query = TABLE_QUERY + variants = [ + {"partial": True}, + {"partialError": {"message": "some rows failed"}}, + {"failureKind": "authentication"}, + {"failureKind": "authorization", "statusCode": 403}, + {"failureKind": "provider"}, + {"statusCode": 408, "failureKind": "timeout"}, + {"querySha256": "0" * 64}, + ] + for override in variants: + with self.subTest(override=override): + entry = {**_sdk_failure(query), **override} + self.assertIsNone( + missing_schema_whitelist_match( + entry, + provider="azure-monitor-sdk", + expected_query_sha256=_sha256(query), + ) + ) + self.assertIsNone( + missing_schema_whitelist_match( + _sdk_failure(query), + provider="graph", + expected_query_sha256=_sha256(query), + ) + ) + + def test_provider_or_partial_family_failure_prevents_application(self) -> None: + solution, run_id, _ = self._create_solution( + "ProviderFailure", + [TABLE_QUERY, "DeviceEvents | take 1"], + provider_failure={"failureKind": "timeout", "message": "provider timed out"}, + ) + evidence = self._evidence(solution, run_id) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual("candidate-blocked", evidence["sourceWhitelist"][0]["status"]) + + partial, partial_run, _ = self._create_solution( + "PartialFamily", + [TABLE_QUERY, "DeviceEvents | take 1"], + family_complete=False, + ) + evidence = self._evidence(partial, partial_run) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual("candidate-blocked", evidence["sourceWhitelist"][0]["status"]) + + blocked, blocked_run, _ = self._create_solution( + "AuthFailure", + [TABLE_QUERY, "DeviceEvents | take 1"], + source_overrides={ + 1: { + "status": "blocked", + "statusCode": 401, + "failureKind": "authentication", + "error": "authentication failed", + } + }, + ) + evidence = self._evidence(blocked, blocked_run) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual("candidate-blocked", evidence["sourceWhitelist"][0]["status"]) + self.assertEqual([], evidence["currentQueryFailures"]) + + def test_stale_source_query_binding_blocks_whitelist(self) -> None: + solution, run_id, hashes = self._create_solution( + "StaleHash", + [TABLE_QUERY], + ) + source_path = artifact_path( + solution, "runtime-validation.azure-monitor-sdk.json" + ) + report = json.loads(source_path.read_text()) + report["results"][0]["querySha256"] = "0" * 64 + source_path.write_text(json.dumps(report), encoding="utf-8") + evidence = self._evidence(solution, run_id) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual([], evidence["sourceWhitelist"]) + self.assertTrue(any("stale" in issue.lower() for issue in evidence["issues"])) + self.assertNotEqual("0" * 64, hashes["Rule00.yaml"]) + + def test_recorded_stale_identityinfo_failure_is_candidate_only(self) -> None: + for stale in (False, True): + with self.subTest(stale=stale): + solution, run_id, hashes = self._create_solution( + f"RecordedStale{stale}", + [TABLE_QUERY], + ) + failure = _sdk_failure(TABLE_QUERY) + failure["detection"] = "Rule00.yaml" + if stale: + failure["querySha256"] = "0" * 64 + with using_run(solution, run_id): + report = record_runtime_validation( + solution, + provider="azure-monitor-sdk", + results_data=[failure], + family_complete=True, + ) + evidence = current_runtime_evidence(solution) + assessment = report["sourceWhitelist"][0] + self.assertEqual("failed", report["results"][0]["status"]) + self.assertEqual("0" * 64 if stale else hashes["Rule00.yaml"], report["results"][0]["querySha256"]) + self.assertEqual(not stale, assessment["applied"]) + self.assertEqual( + "candidate-blocked" if stale else "source-waived", + assessment["status"], + ) + if stale: + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual([], evidence["sourceWhitelist"]) + self.assertTrue(any("stale" in issue.lower() for issue in evidence["issues"])) + else: + self.assertTrue(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual("source-waived", evidence["sourceWhitelist"][0]["status"]) + + def test_changed_detection_inventory_blocks_source_whitelist_application(self) -> None: + solution, run_id, _ = self._create_solution( + "ChangedInventory", + [TABLE_QUERY], + ) + source_root = solution / "Analytic Rules" + detection_root = solution / "XDR Detections" + new_id = str(uuid.uuid5(uuid.NAMESPACE_URL, "changed-inventory-added-rule")) + (source_root / "Rule99.yaml").write_text( + yaml.safe_dump({ + "id": new_id, + "name": "Added after runtime", + "query": "IdentityInfo | take 1", + }), + encoding="utf-8", + ) + (detection_root / "Rule99.yaml").write_text( + yaml.safe_dump({ + "kind": "CustomDetection", + "contentProvenance": { + "source": {"path": "Analytic Rules/Rule99.yaml", "id": new_id}, + "conversion": { + "status": "converted", + "reviewRequired": False, + "errors": [], + }, + }, + "properties": { + "id": new_id, + "queryCondition": {"queryText": "DeviceInfo | take 1"}, + "detectionAction": {"alertTemplate": {}}, + }, + }), + encoding="utf-8", + ) + evidence = self._evidence(solution, run_id) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertEqual("candidate-blocked", evidence["sourceWhitelist"][0]["status"]) + self.assertIn("not a complete detection family", " ".join(evidence["issues"])) + + def test_xdr_unrun_schema_count_only_and_custom_details_remain_blockers(self) -> None: + unrun, unrun_id, _ = self._create_solution( + "XdrUnrun", + [TABLE_QUERY], + graph_status="not-run", + ) + evidence = self._evidence(unrun, unrun_id) + self.assertEqual("source-waived", evidence["sourceStatus"]) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertFalse(evidence["passed"]) + self.assertTrue(any("not-run" in issue for issue in evidence["issues"])) + + count_only, count_only_id, _ = self._create_solution( + "SchemaCountOnly", + [TABLE_QUERY], + graph_schema_overrides={0: None}, + ) + evidence = self._evidence(count_only, count_only_id) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertTrue(any("schemaColumnCount is not sufficient" in issue for issue in evidence["issues"])) + + custom, custom_id, _ = self._create_solution( + "CustomDetails", + [TABLE_QUERY], + graph_schema_overrides={0: [{"name": "OtherColumn", "type": "String"}]}, + ) + detection_path = custom / "XDR Detections/Rule00.yaml" + detection = yaml.safe_load(detection_path.read_text()) + detection["properties"]["detectionAction"]["alertTemplate"]["customDetails"] = { + "account": "DeviceName" + } + detection_path.write_text(yaml.safe_dump(detection), encoding="utf-8") + evidence = self._evidence(custom, custom_id) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertTrue(any("customDetails 'account' column `DeviceName` is absent" in issue for issue in evidence["issues"])) + + def test_xdr_actual_schema_requires_named_typed_columns(self) -> None: + for index, schema in enumerate( + ([], [{}], [{"name": "DeviceName"}], [{"name": "", "type": "String"}]) + ): + with self.subTest(schema=schema): + solution, run_id, _ = self._create_solution( + f"MalformedSchema{index}", + [TABLE_QUERY], + graph_schema_overrides={0: schema}, + ) + evidence = self._evidence(solution, run_id) + self.assertFalse(evidence["sourceWaiverOnlyBlocker"]) + self.assertFalse(evidence["passed"]) + self.assertTrue(any( + "usable, uniquely named columns with types" in issue + for issue in evidence["issues"] + )) + + def _prepare_direct_completion( + self, + name: str, + *, + graph_status: str = "passed", + graph_schema_overrides: dict[int, object] | None = None, + review_required: bool = False, + technical_error: bool = False, + ) -> tuple[Path, str]: + solution, run_id, _ = self._create_solution( + name, + [TABLE_QUERY, "DeviceEvents | take 1"], + graph_status=graph_status, + graph_schema_overrides=graph_schema_overrides, + ) + manifest_results = [] + for index in range(2): + filename = f"Rule{index:02}.yaml" + manifest_results.append({ + "sourceRelativePath": f"Analytic Rules/{filename}", + "outputRelativePath": f"XDR Detections/{filename}", + "status": "needsReview" if review_required and index == 1 else "converted", + "reviewRequired": bool(review_required and index == 1), + "errors": ["unrelated technical validation failure"] + if technical_error and index == 1 + else [], + }) + if review_required and index == 1: + detection_path = solution / "XDR Detections" / filename + detection = yaml.safe_load(detection_path.read_text()) + detection["contentProvenance"]["conversion"]["reviewRequired"] = True + detection_path.write_text(yaml.safe_dump(detection), encoding="utf-8") + manifest = { + "scope": {"kind": "solution", "sourceTotal": 2, "selectedTotal": 2}, + "total": 2, + "converted": 2, + "excluded": 0, + "needsReview": 0, + "reviewRequired": 0, + "conflicts": 0, + "results": manifest_results, + } + manifest_path = artifact_path(solution, "manifest.json", create_parent=True) + manifest_bytes = json.dumps(manifest, sort_keys=True).encode() + manifest_path.write_bytes(manifest_bytes) + for stage in ("discovery", "conversion"): + start_workflow_stage(solution, stage) + complete_workflow_stage( + solution, + stage, + status="passed", + artifacts=( + { + "manifest": "manifest.json", + "manifestSha256": _sha256(manifest_bytes), + } + if stage == "conversion" + else {} + ), + ) + start_workflow_stage(solution, "validation") + return solution, run_id + + def _complete_source_waived(self, solution: Path, *, structural_valid: bool = True) -> None: + with mock.patch.object( + workflow_module, + "validate_solution", + return_value={ + "conversionScope": {"kind": "solution"}, + "total": 2, + "invalid": 0 if structural_valid else 1, + "results": [{"valid": structural_valid}] * 2, + }, + ): + complete_workflow_stage( + solution, + "validation", + status="passed", + artifacts={"runtimeStatus": "source-waived"}, + ) + + def test_direct_validation_completion_rechecks_xdr_and_review_gates(self) -> None: + unrun, _ = self._prepare_direct_completion( + "DirectUnrun", + graph_status="not-run", + ) + with self.assertRaisesRegex(ValueError, "every other exact runtime gate"): + self._complete_source_waived(unrun) + complete_workflow_stage( + unrun, "validation", status="blocked", message="XDR is not run." + ) + blocked_status = workflow_status(unrun) + self.assertEqual("blocked", blocked_status["stages"]["validation"]["status"]) + self.assertEqual("source-waived", blocked_status["runtimeAssessment"]["sourceStatus"]) + self.assertFalse(blocked_status["runtimeAssessment"]["fullyRuntimeQualified"]) + self.assertEqual( + "IdentityInfo", + blocked_status["stages"]["validation"]["artifacts"][ + "sourceRuntimeWhitelistTable" + ], + ) + self.assertEqual( + "failed", + blocked_status["stages"]["validation"]["artifacts"][ + "sourceRuntimeWhitelistRawStatus" + ], + ) + + schema_count, _ = self._prepare_direct_completion( + "DirectSchemaCountOnly", + graph_schema_overrides={0: None}, + ) + with self.assertRaisesRegex(ValueError, "every other exact runtime gate"): + self._complete_source_waived(schema_count) + + custom_details, _ = self._prepare_direct_completion( + "DirectCustomDetails", + graph_schema_overrides={0: [{"name": "OtherColumn", "type": "String"}]}, + ) + detection_path = custom_details / "XDR Detections/Rule00.yaml" + detection = yaml.safe_load(detection_path.read_text()) + detection["properties"]["detectionAction"]["alertTemplate"]["customDetails"] = { + "account": "DeviceName" + } + detection_path.write_text(yaml.safe_dump(detection), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "every other exact runtime gate"): + self._complete_source_waived(custom_details) + + review, _ = self._prepare_direct_completion( + "DirectReview", + review_required=True, + ) + with self.assertRaisesRegex(ValueError, "unrelated conversion or review findings"): + self._complete_source_waived(review) + + technical, _ = self._prepare_direct_completion( + "DirectTechnical", + technical_error=True, + ) + with self.assertRaisesRegex(ValueError, "unrelated conversion or review findings"): + self._complete_source_waived(technical) + + structural, _ = self._prepare_direct_completion("DirectStructural") + with self.assertRaisesRegex(ValueError, "structural validation to pass"): + self._complete_source_waived(structural, structural_valid=False) + + def test_environment_blocked_cannot_bypass_source_whitelist_xdr_gates(self) -> None: + cases = ( + ("EnvBlockedXdrUnrun", "not-run", None), + ("EnvBlockedSchemaMissing", "passed", None), + ("EnvBlockedMalformedSchema", "passed", []), + ) + for name, graph_status, schema in cases: + with self.subTest(name=name): + solution, _ = self._prepare_direct_completion( + name, + graph_status=graph_status, + graph_schema_overrides=( + {0: schema} if graph_status == "passed" else None + ), + ) + with self.assertRaisesRegex( + ValueError, "cannot be relabeled environment-blocked" + ): + complete_workflow_stage( + solution, + "validation", + status="passed", + message="runtime provider unavailable", + artifacts={"runtimeStatus": "environment-blocked"}, + ) + + def test_runtime_pending_stage_completes_source_waiver_without_scope_error(self) -> None: + solution, _ = self._prepare_direct_completion("PendingStageSourceWaived") + complete_workflow_stage( + solution, + "validation", + status="blocked", + message="Runtime validation is not complete.", + ) + state_path, state = workflow_module._load(solution) + structural = { + "conversionScope": {"kind": "solution"}, + "total": 2, + "invalid": 0, + "results": [{"valid": True}, {"valid": True}], + } + with mock.patch.object( + workflow_module, "validate_solution", return_value=structural + ): + updated = workflow_module._complete_runtime_pending_stage( + solution, state_path, state, {"results": []} + ) + self.assertEqual("passed", updated["stages"]["validation"]["status"]) + self.assertEqual( + "source-waived", + updated["stages"]["validation"]["artifacts"]["runtimeStatus"], + ) + + def test_direct_validation_records_source_waived_not_passed_query(self) -> None: + solution, run_id = self._prepare_direct_completion("DirectSuccess") + self._complete_source_waived(solution) + with using_run(solution, run_id): + evidence = current_runtime_evidence(solution) + state = workflow_status(solution) + validation = state["stages"]["validation"] + raw = json.loads( + artifact_path( + solution, "runtime-validation.azure-monitor-sdk.json" + ).read_text() + ) + self.assertEqual("passed", validation["status"]) + self.assertEqual("source-waived", validation["artifacts"]["runtimeStatus"]) + self.assertEqual("IdentityInfo", validation["artifacts"]["sourceRuntimeWhitelistTable"]) + self.assertEqual("failed", validation["artifacts"]["sourceRuntimeWhitelistRawStatus"]) + self.assertFalse(evidence["passed"]) + self.assertEqual( + "failed", + next(item for item in raw["results"] if item["detection"] == "Rule00.yaml")["status"], + ) + flow = _flow_stage({ + "status": "passed", + "artifacts": { + "runtimeStatus": "source-waived", + "sourceRuntimeWhitelistTable": "IdentityInfo", + }, + "message": validation["message"], + }) + self.assertIn("Source waived", flow["display"]) + self.assertIn("not fully runtime-qualified", flow["display"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_supplemental_custom_details.py b/Tools/SentinelToXDRMigration/tests/test_supplemental_custom_details.py new file mode 100644 index 00000000000..ab671e2fe7b --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_supplemental_custom_details.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import yaml + +from sentinel_xdr_migration.converter import ( + build_xdr_document, + convert_solution, + custom_detail_binding_warnings, + custom_detail_schema_errors, + runtime_validation_plan, + validate_document, + validate_solution, +) +from sentinel_xdr_migration.deployment import graph_detection_payload +from sentinel_xdr_migration.runtime import record_runtime_validation, validate_advanced_hunting +from sentinel_xdr_migration.solution_report import build_solution_report +from test_conversion_semantics import HOST_QUERY, entity +from test_converter import RULE + + +class SupplementalCustomDetailsTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.solution = Path(self.directory.name) / "Example" + self.source = self.solution / "Analytic Rules/Rule.yaml" + self.source.parent.mkdir(parents=True) + self.rule = yaml.safe_load(RULE) + self.rule["query"] = ( + "DeviceEvents | where TimeGenerated > ago(1h) " + "| project TimeGenerated, DeviceName, HostName, SourceIP, Evidence, Other" + ) + self.rule["entityMappings"] = [ + entity("Host", FullName="DeviceName", HostName="HostName"), + entity("IP", Address="SourceIP"), + ] + + def build(self, override=None, config=None): + self.source.write_text(yaml.safe_dump(self.rule, sort_keys=False)) + settings = deepcopy(config or {}) + settings["ruleOverrides"] = {self.rule["id"]: override or {}} + return build_xdr_document(self.source, self.solution, settings) + + @staticmethod + def alert(document): + return document["properties"]["detectionAction"]["alertTemplate"] + + @staticmethod + def conversion(document): + return document["contentProvenance"]["conversion"] + + def test_fullname_is_supplemental_not_an_entity_replacement(self): + document = self.build() + alert = self.alert(document) + self.assertEqual(alert["customDetails"], {"HostFullName": "DeviceName"}) + self.assertEqual(alert["entityMappings"]["hosts"][0]["nameColumn"], "HostName") + self.assertEqual(alert["entityMappings"]["ips"][0]["addressColumn"], "SourceIP") + self.assertNotIn("fullNameColumn", alert["entityMappings"]["hosts"][0]) + conversion = self.conversion(document) + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue(any( + "Best-effort entity mapping requires review:" in reason + and "hosts.nameColumn `HostName`" in reason + for reason in conversion["reviewReasons"] + )) + self.assertTrue(any( + "Host.FullName" in note and "customDetails.HostFullName" in note + and "do not restore entity identity" in note for note in conversion["informational"] + )) + self.assertEqual(conversion["supplementalEntityDetails"], [{ + "entityType": "Host", "entityIndex": 1, "identifier": "FullName", + "sourceColumn": "DeviceName", "column": "DeviceName", + "status": "available", "customDetailKey": "HostFullName", + }]) + self.assertEqual(validate_document(document), []) + with self.assertRaisesRegex(ValueError, "no review required"): + graph_detection_payload(document) + + reason = conversion["reviewReasons"][0] + accepted = self.build({"reviewDecisions": [{ + "reason": reason, + "decision": "accepted", + **conversion["reviewScope"], + }]}) + payload = graph_detection_payload(accepted) + self.assertEqual( + payload["detectionAction"]["alertTemplate"]["customDetails"], + alert["customDetails"], + ) + self.assertNotIn("contentProvenance", payload) + + def test_best_effort_fullname_maps_to_supported_host_name_and_keeps_original(self): + self.rule["query"] = ( + "DeviceEvents | where TimeGenerated > ago(1h) " + "| project TimeGenerated, DeviceName, SourceIP" + ) + self.rule["entityMappings"] = [ + entity("Host", FullName="DeviceName"), + entity("IP", Address="SourceIP"), + ] + + document = self.build() + conversion = self.conversion(document) + host = self.alert(document)["entityMappings"]["hosts"][0] + + self.assertEqual(host["nameColumn"], "DeviceName") + self.assertEqual(self.alert(document)["customDetails"], {"HostFullName": "DeviceName"}) + self.assertTrue(conversion["reviewRequired"]) + self.assertEqual(conversion["status"], "converted") + self.assertEqual(validate_document(document), []) + self.assertEqual(document, self.build()) + self.assertEqual( + conversion["supplementalEntityDetails"][0]["customDetailKey"], + "HostFullName", + ) + + def test_missing_fullname_output_stays_reviewable_without_inventing_mapping(self): + self.rule["query"] = "DeviceEvents | project TimeGenerated, SourceIP" + self.rule["entityMappings"] = [ + entity("Host", FullName="DeviceName"), + entity("IP", Address="SourceIP"), + ] + + document = self.build() + conversion = self.conversion(document) + + self.assertNotIn("hosts", self.alert(document)["entityMappings"]) + self.assertNotIn("customDetails", self.alert(document)) + self.assertTrue(conversion["reviewRequired"]) + self.assertEqual(conversion["status"], "converted") + self.assertTrue(any("not available in the query output" in reason + for reason in conversion["reviewReasons"])) + self.assertTrue(any("not preserved" in warning + for warning in conversion["warnings"])) + self.assertEqual(validate_document(document), []) + + def test_best_effort_review_decision_expires_when_query_changes(self): + document = self.build() + conversion = self.conversion(document) + decision = { + "reason": conversion["reviewReasons"][0], + "decision": "accepted", + **conversion["reviewScope"], + } + self.rule["query"] += " | where SourceIP != ''" + + refreshed = self.build({"reviewDecisions": [decision]}) + current = self.conversion(refreshed) + + self.assertTrue(current["reviewRequired"]) + self.assertTrue(any("reviewDecision" in error and "stale" in error + for error in current["errors"])) + + def test_explicit_mapping_override_still_preserves_original_values(self): + override = {"entityMappings": { + "hosts": [{"id": "host1", "nameColumn": "Other"}], + "ips": [{"id": "ip1", "addressColumn": "SourceIP"}], + }} + document = self.build(override) + self.assertEqual(self.alert(document)["entityMappings"], override["entityMappings"]) + self.assertEqual(self.alert(document)["customDetails"], { + "HostFullName": "DeviceName", "HostHostName": "HostName", + }) + self.assertFalse(any("Host.HostName" in value for value in self.conversion(document)["warnings"])) + self.assertTrue(any("Host.HostName" in value for value in self.conversion(document)["informational"])) + + def test_proven_equivalent_fullname_needs_no_duplicate_detail(self): + self.rule["query"] = HOST_QUERY + self.rule["entityMappings"] = [ + entity("Host", FullName="DeviceName", HostName="HostName", DnsDomain="DnsDomain"), + ] + document = self.build() + self.assertNotIn("customDetails", self.alert(document)) + self.assertNotIn("supplementalEntityDetails", self.conversion(document)) + + def test_missing_projected_away_or_renamed_columns_do_not_add_invalid_bindings(self): + for query in ( + "DeviceEvents | project HostName, SourceIP", + "DeviceEvents | project DeviceName, HostName, SourceIP | project HostName, SourceIP", + "DeviceEvents | project DeviceName, HostName, SourceIP | project-away DeviceName", + "DeviceEvents | project DeviceName, HostName, SourceIP | project-rename Other=DeviceName", + "DeviceEvents | project DeviceName, HostName, SourceIP | sort by SourceIP | project HostName, SourceIP", + ): + with self.subTest(query=query): + self.rule["query"] = query + document = self.build() + self.assertNotIn("customDetails", self.alert(document)) + self.assertTrue(any("not preserved" in value for value in self.conversion(document)["warnings"])) + self.assertEqual(self.conversion(document)["supplementalEntityDetails"][0]["status"], "not-preserved") + self.assertEqual(document["properties"]["queryCondition"]["queryText"], query) + + def test_unknown_output_emits_pending_binding_not_false_proof(self): + self.rule["query"] = "DeviceEvents" + document = self.build() + self.assertEqual(self.alert(document)["customDetails"], {"HostFullName": "DeviceName"}) + self.assertEqual(self.conversion(document)["supplementalEntityDetails"][0]["status"], "runtime-binding-pending") + self.assertTrue(any("requires runtime output-schema" in value for value in self.conversion(document)["warnings"])) + self.assertTrue(custom_detail_schema_errors(document, [{"Name": "Other"}])) + self.assertEqual(custom_detail_schema_errors(document, [{"name": "DeviceName"}]), []) + + def test_sftp_aggregate_bindings_are_warnings_and_survive_validation_and_reports(self): + query = ( + "let fileRows = SftpFileEvents\n" + "| where TimeGenerated > ago(1h)\n" + "| project DeviceId, DeviceName, SourceIP, FilePath, FolderPath;\n" + "let joinedRows = SftpProcessEvents\n" + "| join kind=inner (fileRows) on DeviceId\n" + "| project-away ProcessId;\n" + "joinedRows\n" + "| summarize count_distinct_filepath=dcount(FilePath), " + "fileslist=make_set(FilePath), count_distinct_dirpath=dcount(FolderPath), " + "dirlist=make_set(FolderPath) by DeviceName, SourceIP" + ) + details = { + "FilePathCount": "count_distinct_filepath", + "FileList": "fileslist", + "FolderPathCount": "count_distinct_dirpath", + "FolderList": "dirlist", + } + self.rule["query"] = query + self.rule["customDetails"] = details + self.rule["entityMappings"] = [entity("IP", Address="SourceIP")] + + document = self.build() + conversion = self.conversion(document) + alert = self.alert(document) + warning = "static analysis cannot establish its output" + + self.assertEqual(alert["customDetails"], details) + self.assertEqual(document["properties"]["queryCondition"]["queryText"], query) + self.assertEqual(conversion["status"], "converted") + self.assertFalse(conversion["reviewRequired"]) + self.assertFalse(conversion["errors"]) + self.assertEqual(validate_document(document), []) + self.assertEqual(len(custom_detail_binding_warnings(document)), 4) + unproven_warnings = [ + value for value in conversion["warnings"] + if "unproven output binding" in value + ] + self.assertEqual(len(unproven_warnings), 4) + self.assertTrue(all(warning in value for value in unproven_warnings)) + self.assertTrue(custom_detail_schema_errors(document, [{"Name": "Other"}])) + self.assertEqual( + custom_detail_schema_errors( + document, + [{"Name": column} for column in details.values()], + ), + [], + ) + + conversion_result = convert_solution(self.solution) + self.assertEqual(conversion_result["converted"], 1) + validation = validate_solution(self.solution) + self.assertEqual(validation["valid"], 1) + self.assertTrue(any(warning in item for item in validation["results"][0]["warnings"])) + report = build_solution_report(self.solution) + self.assertEqual(report["rules"][0]["customDetection"]["structuralStatus"], "passed") + self.assertTrue(any(warning in item for item in report["rules"][0]["warnings"])) + self.assertTrue(any("4 KB per-alert limit" in item for item in report["rules"][0]["warnings"])) + + def test_missing_final_project_project_away_and_rename_remain_errors(self): + for query in ( + "DeviceEvents | project DeviceName, SourceIP", + "DeviceEvents | project DeviceName, SourceIP, Evidence | project-away Evidence", + "DeviceEvents | project DeviceName, SourceIP, Evidence | project-rename Other=Evidence", + ): + with self.subTest(query=query): + self.rule["query"] = query + self.rule["customDetails"] = {"OriginalEvidence": "Evidence"} + self.rule["entityMappings"] = [entity("IP", Address="SourceIP")] + document = self.build() + conversion = self.conversion(document) + self.assertEqual( + self.alert(document)["customDetails"], + {"OriginalEvidence": "Evidence"}, + ) + self.assertTrue(any("is missing from the final query output" in item + for item in conversion["errors"])) + self.assertFalse(any("unproven output binding" in item + for item in conversion["warnings"])) + self.assertTrue(any("is missing from the final query output" in item + for item in validate_document(document))) + + @patch("sentinel_xdr_migration.runtime._advanced_hunting_token", return_value="test-only") + @patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_runtime_missing_schema_column_remains_error_for_unproven_explicit_binding( + self, run_query, _token, + ): + self.rule["query"] = ( + "let fileRows = SftpFileEvents | project DeviceId, SourceIP, FilePath;\n" + "let joinedRows = SftpProcessEvents\n" + "| join kind=inner (fileRows) on DeviceId\n" + "| project-away ProcessId;\n" + "joinedRows | summarize count_distinct_filepath=dcount(FilePath) by DeviceId, SourceIP" + ) + self.rule["customDetails"] = {"FilePathCount": "count_distinct_filepath"} + self.rule["entityMappings"] = [entity("IP", Address="SourceIP")] + document = self.build() + self.assertEqual(self.conversion(document)["status"], "converted") + self.assertTrue(any( + "unproven output binding" in value + for value in self.conversion(document)["warnings"] + )) + convert_solution(self.solution) + run_query.return_value = { + "ok": True, + "statusCode": 200, + "rowCount": 0, + "schema": [{"Name": "DeviceId"}], + "error": None, + "errorDetails": None, + } + + result = validate_advanced_hunting(self.solution) + + self.assertEqual(result["invalid"], 1) + self.assertIn("FilePathCount", result["results"][0]["error"]) + self.assertIn("absent from the runtime output schema", result["results"][0]["error"]) + + def test_collision_suffixes_existing_keys_and_duplicate_values(self): + self.rule["customDetails"] = {"HostFullName": "Evidence", "Original": "Other"} + self.rule["entityMappings"].insert(1, entity("Host", FullName="Other")) + self.rule["entityMappings"].insert(2, entity("Host", FullName="DeviceName")) + document = self.build({"customDetails": {"Reviewed": "SourceIP"}}) + self.assertEqual(self.alert(document)["customDetails"], { + "HostFullName": "Evidence", "Original": "Other", "Reviewed": "SourceIP", + "HostFullName_2": "DeviceName", + }) + records = self.conversion(document)["supplementalEntityDetails"] + self.assertEqual([entry["customDetailKey"] for entry in records], ["HostFullName_2", "Original", "HostFullName_2"]) + self.assertEqual([entry["entityIndex"] for entry in records], [1, 2, 3]) + before = deepcopy(self.rule) + self.assertEqual(document, self.build({"customDetails": {"Reviewed": "SourceIP"}})) + self.assertEqual(self.rule, before) + + def test_conflicting_explicit_key_is_blocking_without_overwrite(self): + self.rule["customDetails"] = {"Keep": "Evidence"} + document = self.build({"customDetails": {"Keep": "Other", "Add": "SourceIP"}}) + self.assertEqual(self.alert(document)["customDetails"]["Keep"], "Evidence") + self.assertEqual(self.alert(document)["customDetails"]["Add"], "SourceIP") + self.assertTrue(any("conflicts" in error for error in self.conversion(document)["errors"])) + self.assertTrue(validate_document(document)) + + def test_source_and_configured_column_renames_apply_to_details_and_entities(self): + self.rule["query"] = "IdentityInfo | project AccountUPN, SourceIP, Evidence" + self.rule["entityMappings"] = [entity("Process", CommandLine="AccountUPN"), entity("IP", Address="SourceIP")] + self.rule["customDetails"] = {"Existing": "AccountUPN"} + document = self.build(config={"columnMappings": {"Evidence": "Renamed"}}) + self.assertEqual(self.alert(document)["customDetails"], {"Existing": "AccountUpn"}) + self.assertEqual(self.conversion(document)["supplementalEntityDetails"][0]["sourceColumn"], "AccountUPN") + self.assertEqual(self.conversion(document)["supplementalEntityDetails"][0]["column"], "AccountUpn") + self.assertIn("AccountUpn", document["properties"]["queryCondition"]["queryText"]) + self.assertIn("Renamed", document["properties"]["queryCondition"]["queryText"]) + + def test_every_unmapped_entity_family_uses_same_preservation_path(self): + for kind, identifier in [ + ("DNS", "DnsServerIp"), ("File", "Directory"), ("FileHash", "Value"), + ("Process", "ProcessId"), ("RegistryValue", "Value"), ("SecurityGroup", "Name"), + ("Account", "DisplayName"), ("CloudApplication", "AppType"), ("Mailbox", "DisplayName"), + ("MailMessage", "Body"), ("MailCluster", "Count"), ("AzureResource", "SubscriptionId"), + ("IP", "Location"), ("URL", "Domain"), ("UnknownFamily", "Identifier"), + ]: + with self.subTest(kind=kind): + self.rule["entityMappings"] = [entity(kind, **{identifier: "Evidence"}), entity("IP", Address="SourceIP")] + document = self.build() + self.assertEqual(self.alert(document)["customDetails"], {kind + identifier: "Evidence"}) + + def test_ambiguous_account_mapping_keeps_review_and_original_value(self): + self.rule["query"] = "DeviceEvents | project ShortName" + self.rule["entityMappings"] = [entity("Account", Name="ShortName")] + document = self.build() + self.assertTrue(self.conversion(document)["reviewRequired"]) + self.assertEqual(self.alert(document)["customDetails"], {"AccountName": "ShortName"}) + self.assertEqual(self.alert(document)["entityMappings"]["accounts"][0]["upnColumn"], "ShortName") + + def test_malformed_explicit_details_and_pair_limit_fail_without_truncation(self): + for details in (None, [], "value", {"Bad": 1}, {"Bad": ""}, {"": "Evidence"}): + with self.subTest(details=details): + self.rule["customDetails"] = details + document = self.build() + self.assertTrue(self.conversion(document)["errors"]) + self.assertTrue(validate_document(document)) + self.rule["customDetails"] = {f"Key{index}": "Evidence" for index in range(20)} + document = self.build() + self.assertEqual(len(self.alert(document)["customDetails"]), 21) + self.assertTrue(any("20-pair limit" in error for error in self.conversion(document)["errors"])) + self.assertTrue(validate_document(document)) + self.assertTrue(any("4 KB" in value for value in self.conversion(document)["warnings"])) + self.rule.pop("customDetails") + self.assertTrue(self.conversion(self.build({"customDetails": "bad"}))["errors"]) + self.rule["customDetails"] = {"A" * 4097: "Evidence"} + document = self.build() + self.assertTrue(any("keys alone exceed" in error for error in validate_document(document))) + + def test_empty_details_and_empty_entities_do_not_invent_supplements(self): + self.rule["customDetails"] = {} + self.rule["entityMappings"] = [entity("IP", Address="SourceIP")] + document = self.build() + self.assertEqual(self.alert(document)["customDetails"], {}) + self.assertNotIn("supplementalEntityDetails", self.conversion(document)) + self.rule["entityMappings"] = [] + self.assertEqual(self.alert(self.build())["customDetails"], {}) + + def test_reconversion_is_stable_and_source_is_unchanged(self): + self.build() + before = self.source.read_bytes() + first = convert_solution(self.solution) + output = self.solution / "XDR Detections/Rule.yaml" + draft = output.read_bytes() + second = convert_solution(self.solution) + self.assertEqual(first["converted"], 1) + self.assertEqual(second["conflicts"], 0) + self.assertEqual(draft, output.read_bytes()) + self.assertEqual(before, self.source.read_bytes()) + plan = runtime_validation_plan(self.solution) + self.assertEqual(plan["rules"][0]["customDetailBindings"], {"HostFullName": "DeviceName"}) + + def test_source_runtime_schema_is_not_mistaken_for_target_output_schema(self): + self.build() + convert_solution(self.solution) + results = self.solution / "provider-results.json" + plan = runtime_validation_plan(self.solution)["rules"][0] + entry = { + "detection": "Rule.yaml", "status": "passed", "schemaColumnCount": 3, + "querySha256": plan["sentinelQuerySha256"], + } + results.write_text(json.dumps([entry])) + result = record_runtime_validation(self.solution, provider="triage-mcp", results_path=results) + self.assertEqual(result["valid"], 1) + entry["schema"] = [{"Name": "Other"}] + results.write_text(json.dumps([entry])) + result = record_runtime_validation(self.solution, provider="triage-mcp", results_path=results) + self.assertEqual(result["valid"], 1) + + @patch("sentinel_xdr_migration.runtime._advanced_hunting_token", return_value="test-only") + @patch("sentinel_xdr_migration.runtime.run_advanced_hunting_query") + def test_runtime_query_success_requires_detail_column_bindings(self, run_query, _token): + self.build() + convert_solution(self.solution) + run_query.return_value = { + "ok": True, "statusCode": 200, "rowCount": 0, + "schema": [{"Name": "Other"}], "error": None, "errorDetails": None, + } + result = validate_advanced_hunting(self.solution) + self.assertEqual(result["invalid"], 1) + self.assertIn("HostFullName", result["results"][0]["error"]) + run_query.return_value["schema"] = [{"Name": "DeviceName"}] + result = validate_advanced_hunting(self.solution) + self.assertEqual(result["valid"], 1) diff --git a/Tools/SentinelToXDRMigration/tests/test_tactic_preservation.py b/Tools/SentinelToXDRMigration/tests/test_tactic_preservation.py new file mode 100644 index 00000000000..f7f44847e45 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_tactic_preservation.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import yaml +from jsonschema import Draft202012Validator + +from sentinel_xdr_migration.converter import ( + XDR_SCHEMA_PATH, + build_xdr_document, + convert_solution, + validate_document, +) +from sentinel_xdr_migration.deployment import graph_detection_payload +from sentinel_xdr_migration.graph_readiness import graph_detection_payload_with_projection +from sentinel_xdr_migration.report_review import review_candidates, render_bulk_review +from sentinel_xdr_migration.solution_report import build_solution_report +from test_converter import RULE +from test_report_dashboard import report +from test_report_review import review_rule + + +class TacticPreservationTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.solution = Path(self.directory.name) / "Example" + self.source = self.solution / "Analytic Rules/Rule.yaml" + self.source.parent.mkdir(parents=True) + self.rule = yaml.safe_load(RULE) + + def build(self, tactics=None, techniques=None, override=None): + self.rule["tactics"] = tactics if tactics is not None else ["Persistence", "CommandAndControl", "Discovery"] + self.rule["relevantTechniques"] = techniques if techniques is not None else ["T1505.003", "T1071", "T1087"] + self.source.write_text(yaml.safe_dump(self.rule, sort_keys=False)) + return build_xdr_document( + self.source, self.solution, {"ruleOverrides": {self.rule["id"]: override or {}}}, + ) + + @staticmethod + def tactics(document): + return document["properties"]["detectionAction"]["alertTemplate"].get("tactics", []) + + def test_three_tactics_preserve_order_compatible_techniques_and_provenance(self): + document = self.build() + self.assertEqual(self.tactics(document), [ + {"tactic": "Persistence", "techniques": [{"technique": "T1505", "subTechniques": ["T1505.003"]}]}, + {"tactic": "CommandAndControl", "techniques": [{"technique": "T1071"}]}, + {"tactic": "Discovery", "techniques": [{"technique": "T1087"}]}, + ]) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual(conversion["originalTactics"], self.rule["tactics"]) + self.assertEqual(conversion["originalTechniques"], self.rule["relevantTechniques"]) + self.assertFalse(conversion["reviewRequired"]) + self.assertEqual(validate_document(document), []) + schema = json.loads(XDR_SCHEMA_PATH.read_text()) + self.assertEqual(list(Draft202012Validator(schema).iter_errors(document)), []) + self.assertEqual(document["properties"]["status"], "disabled") + self.assertEqual(f"xdr-{self.rule['id']}", document["properties"]["id"]) + self.assertEqual(document["version"], "3.1.0") + self.assertEqual(document["contentProvenance"]["source"]["version"], "1.0.0") + self.assertEqual( + document["contentProvenance"]["source"]["querySha256"], + hashlib.sha256(self.rule["query"].encode()).hexdigest(), + ) + + def test_documented_shared_technique_only_goes_to_compatible_tactics(self): + document = self.build( + ["Discovery", "Persistence", "PrivilegeEscalation"], + ["T1078.004", "T1087", "T1098"], + ) + self.assertEqual(self.tactics(document)[0]["techniques"], [{"technique": "T1087"}]) + self.assertEqual(self.tactics(document)[1]["techniques"], self.tactics(document)[2]["techniques"]) + self.assertEqual(validate_document(document), []) + + def test_no_techniques_is_not_a_multi_tactic_review(self): + document = self.build(["Discovery", "Persistence"], []) + self.assertEqual(self.tactics(document), [{"tactic": "Discovery"}, {"tactic": "Persistence"}]) + self.assertFalse(document["contentProvenance"]["conversion"]["reviewRequired"]) + + def test_legacy_override_does_not_narrow_or_reorder(self): + document = self.build(override={"tactic": "CommandAndControl", "techniques": ["T1071.001"]}) + self.assertEqual([item["tactic"] for item in self.tactics(document)], self.rule["tactics"]) + self.assertEqual(self.tactics(document)[1]["techniques"], [{"technique": "T1071", "subTechniques": ["T1071.001"]}]) + self.assertEqual(self.tactics(document)[0]["techniques"], [{"technique": "T1505", "subTechniques": ["T1505.003"]}]) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual(conversion["originalTechniques"], ["T1505.003", "T1071", "T1087"]) + self.assertTrue(any("not narrowed or reordered" in value for value in conversion["warnings"])) + self.assertFalse(conversion["reviewRequired"]) + + def test_invalid_classification_and_override_remain_blocking(self): + for tactics, techniques, override in ( + (["Discovery", "Invalid"], ["T1087"], {}), + (["Discovery", "Persistence"], ["T9999"], {}), + (["Discovery", "Persistence"], ["T1087.abc"], {}), + (["Discovery", "Persistence"], ["T1071"], {}), + (["Discovery", "Persistence"], ["T1087"], {"tactic": "Execution", "techniques": ["T1059"]}), + (["Discovery", "Persistence"], ["T1087"], {"tactic": "Discovery", "techniques": ["T1071"]}), + (["Discovery", "Persistence"], ["T1087"], {"tactic": "Discovery"}), + ("Discovery", ["T1087"], {}), + ): + with self.subTest(tactics=tactics, techniques=techniques, override=override): + document = self.build(tactics, techniques, override) + conversion = document["contentProvenance"]["conversion"] + self.assertEqual(conversion["status"], "needsReview") + self.assertTrue(conversion["errors"]) + self.assertTrue(validate_document(document)) + if isinstance(tactics, list): + self.assertEqual([item["tactic"] for item in self.tactics(document)], tactics) + + def test_missing_catalog_fails_closed(self): + with patch("sentinel_xdr_migration.converter.MITRE_CATALOG_PATH", self.solution / "absent"): + document = self.build() + self.assertEqual(document["contentProvenance"]["conversion"]["status"], "needsReview") + self.assertTrue(validate_document(document)) + + def test_authored_tactic_validation_still_rejects_invalid_nested_metadata(self): + original = self.build() + for change in ( + {"tactic": "Invalid"}, + {"tactic": "Discovery", "techniques": [{"technique": "T1071"}]}, + {"tactic": "Discovery", "techniques": [{"technique": "T1087", "subTechniques": ["T1071.001"]}]}, + {"tactic": "Discovery", "techniques": 1}, + {"tactic": "Discovery", "techniques": [{"technique": "T1087", "subTechniques": 1}]}, + ): + document = deepcopy(original) + self.tactics(document)[0] = change + self.assertTrue(validate_document(document)) + + def test_repeated_conversion_is_idempotent_source_untouched_not_graph_ready(self): + self.build() + before = self.source.read_bytes() + first = convert_solution(self.solution) + target = self.solution / "XDR Detections/Rule.yaml" + draft = target.read_bytes() + second = convert_solution(self.solution) + self.assertEqual(first["converted"], 1) + self.assertEqual(first["needsReview"], 0) + self.assertEqual(first["deploymentReady"], 0) + self.assertFalse(first["results"][0]["directGraphDeploymentReady"]) + self.assertEqual(second["conflicts"], 0) + self.assertEqual(target.read_bytes(), draft) + self.assertEqual(self.source.read_bytes(), before) + solution_report = build_solution_report(self.solution) + self.assertEqual(solution_report["summary"]["deploymentReady"], 0) + self.assertEqual(solution_report["rules"][0]["customDetection"]["structuralStatus"], "passed") + + def test_graph_projects_multiple_tactics_without_mutating_authored_document(self): + document = self.build() + before = deepcopy(document) + payload, projection = graph_detection_payload_with_projection(document) + self.assertEqual(document, before) + self.assertEqual( + payload["detectionAction"]["alertTemplate"]["tactics"], + before["properties"]["detectionAction"]["alertTemplate"]["tactics"][:1], + ) + self.assertEqual( + projection["projectedTactics"], + ["Persistence"], + ) + self.assertEqual( + projection["omittedTactics"], + ["CommandAndControl", "Discovery"], + ) + self.assertEqual(projection["classificationParity"], "not-established") + single = self.build(["Discovery"], ["T1087"]) + self.assertEqual(graph_detection_payload(single)["detectionAction"], single["properties"]["detectionAction"]) + + def test_legacy_export_explains_authoring_preservation(self): + item = review_rule("Legacy", "source", ["Discovery", "Persistence"], ["T1087", "T1505"]) + value = report(item) + html = render_bulk_review(value, review_candidates(value)) + self.assertIn("multiple source tactics no longer require selection of one tactic", html) + self.assertIn("do not narrow or reorder tactics", html) + self.assertNotIn("Primary tactic for compatible selected rules", html) + item["customDetection"]["reviewReasons"] = [] + self.assertEqual(review_candidates(value), []) diff --git a/Tools/SentinelToXDRMigration/tests/test_target_config.py b/Tools/SentinelToXDRMigration/tests/test_target_config.py new file mode 100644 index 00000000000..60e6049bf87 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_target_config.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import io +import json +import subprocess +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration.cli import main +from sentinel_xdr_migration import target_context +from sentinel_xdr_migration.target_config import ( + doctor_solution_target, + read_solution_target_config, + resolve_qualification_target, +) + +TENANT_ID = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" +SUBSCRIPTION_ID = "42382e39-f157-46d1-a931-b8cfd779ece5" +CUSTOMER_ID = "756386d8-e2d4-4f09-905a-74b24313721f" +WORKSPACE_ID = ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/Approved-RG/" + "providers/Microsoft.OperationalInsights/workspaces/Approved-Workspace" +) + + +class TargetConfigTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.addCleanup(self.temp.cleanup) + self.solution = Path(self.temp.name) / "Solutions" / "Sample" + self.solution.mkdir(parents=True) + self.env_file = self.solution / ".env" + + def _write_config( + self, + tenant: str = TENANT_ID, + workspace: str = WORKSPACE_ID, + ) -> None: + self.env_file.write_text( + f"AZURE_TENANT_ID={tenant}\n" + f"SENTINEL_WORKSPACE_RESOURCE_ID={workspace}\n", + encoding="utf-8", + ) + + def _mock_exact_workspace(self): + return mock.patch( + "sentinel_xdr_migration.target_context._run_az", + side_effect=[ + subprocess.CompletedProcess( + ["az"], + 0, + f'{{"tenantId":"{TENANT_ID}","id":"{SUBSCRIPTION_ID}"}}', + "", + ), + subprocess.CompletedProcess( + ["az"], + 0, + f'{{"id":"{WORKSPACE_ID}","properties":{{"customerId":"{CUSTOMER_ID}"}}}}', + "", + ), + ], + ) + + def test_absent_config_is_optional_for_non_qualification(self) -> None: + self.assertEqual({}, read_solution_target_config(self.solution)) + + def test_authoring_initialization_remains_offline_without_env(self) -> None: + output = io.StringIO() + with redirect_stdout(output): + result = main( + [ + "workflow-init", + "--solution", + str(self.solution), + "--workflow-profile", + "authoring", + ] + ) + self.assertEqual(0, result) + self.assertEqual( + "authoring", json.loads(output.getvalue())["context"]["workflowProfile"] + ) + + def test_doctor_displays_configured_target_without_resolving_customer_id(self) -> None: + self._write_config() + status = doctor_solution_target(self.solution) + self.assertEqual("configured", status["status"]) + self.assertEqual(TENANT_ID, status["tenantId"]) + self.assertEqual(WORKSPACE_ID, status["workspaceResourceId"]) + self.assertEqual(SUBSCRIPTION_ID, status["subscriptionId"]) + self.assertIn( + "only after explicit target confirmation", + status["workspaceCustomerId"], + ) + + def test_shared_example_placeholders_are_rejected_as_unconfigured_values(self) -> None: + example = ( + Path(__file__).resolve().parents[1] / ".env.example" + ).read_text(encoding="utf-8") + self.env_file.write_text(example, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "replace the placeholder"): + read_solution_target_config(self.solution) + + def test_literal_config_parses_and_derives_subscription(self) -> None: + self._write_config() + self.assertEqual( + {"tenantId": TENANT_ID, "workspaceResourceId": WORKSPACE_ID}, + read_solution_target_config(self.solution), + ) + with self._mock_exact_workspace() as run_az: + target = resolve_qualification_target( + self.solution, confirm_configured_target=True + ) + self.assertEqual(TENANT_ID, target["tenantId"]) + self.assertEqual(SUBSCRIPTION_ID, target["subscriptionId"]) + self.assertEqual(WORKSPACE_ID, target["workspaceResourceId"]) + self.assertEqual(CUSTOMER_ID, target["workspaceCustomerId"]) + self.assertEqual(("account", "show"), run_az.call_args_list[0].args[0]) + self.assertEqual( + ( + "resource", + "show", + "--ids", + WORKSPACE_ID, + "--api-version", + "2023-09-01", + ), + run_az.call_args_list[1].args[0], + ) + self.assertEqual(2, run_az.call_count) + + def test_config_requires_explicit_confirmation_before_azure_access(self) -> None: + self._write_config() + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with self.assertRaisesRegex(ValueError, "doctor --solution"): + resolve_qualification_target(self.solution) + run_az.assert_not_called() + + def test_missing_workspace_configuration_is_actionable_and_offline(self) -> None: + self.env_file.write_text(f"AZURE_TENANT_ID={TENANT_ID}\n", encoding="utf-8") + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with self.assertRaisesRegex(ValueError, "SENTINEL_WORKSPACE_RESOURCE_ID"): + resolve_qualification_target( + self.solution, confirm_configured_target=True + ) + run_az.assert_not_called() + + def test_explicit_cli_tenant_and_workspace_confirm_config_target(self) -> None: + self._write_config() + with self._mock_exact_workspace() as run_az: + target = resolve_qualification_target( + self.solution, + tenant_id=TENANT_ID, + workspace_resource_id=WORKSPACE_ID, + ) + self.assertEqual(CUSTOMER_ID, target["workspaceCustomerId"]) + self.assertEqual(2, run_az.call_count) + + def test_cli_initializes_and_resumes_with_the_locked_configured_target(self) -> None: + self._write_config() + output = io.StringIO() + with self._mock_exact_workspace(): + with redirect_stdout(output): + result = main( + [ + "workflow-init", + "--solution", + str(self.solution), + "--workflow-profile", + "qualification", + "--confirm-configured-target", + "--version-bump", + "none", + ] + ) + self.assertEqual(0, result) + initialized = json.loads(output.getvalue()) + context = initialized["context"] + self.assertEqual(TENANT_ID, context["tenantId"]) + self.assertEqual(SUBSCRIPTION_ID, context["subscriptionId"]) + self.assertEqual(WORKSPACE_ID, context["workspaceResourceId"]) + self.assertEqual(CUSTOMER_ID, context["workspaceCustomerId"]) + state_path = Path(initialized["statePath"]) + state_before = state_path.read_bytes() + + self.env_file.write_text( + f"AZURE_TENANT_ID={TENANT_ID}\n" + "SENTINEL_WORKSPACE_RESOURCE_ID=/subscriptions/" + "11111111-2222-4333-8444-555555555555/resourceGroups/Other/" + "providers/Microsoft.OperationalInsights/workspaces/Other\n", + encoding="utf-8", + ) + error = io.StringIO() + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with redirect_stderr(error): + result = main( + [ + "workflow-init", + "--solution", + str(self.solution), + "--workflow-profile", + "qualification", + "--version-bump", + "none", + ] + ) + self.assertEqual(2, result) + self.assertIn("conflicts with the locked Qualification run", error.getvalue()) + self.assertEqual(state_before, state_path.read_bytes()) + run_az.assert_not_called() + + def test_cli_config_conflict_fails_without_echoing_values_or_cloud_access(self) -> None: + self._write_config() + different_tenant = "11111111-2222-4333-8444-555555555555" + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with self.assertRaisesRegex(ValueError, "conflicts with solution .env") as error: + resolve_qualification_target( + self.solution, + tenant_id=different_tenant, + confirm_configured_target=True, + ) + self.assertNotIn(different_tenant, str(error.exception)) + run_az.assert_not_called() + + def test_rejects_missing_duplicate_unknown_malformed_and_expansion_values(self) -> None: + cases = ( + ("AZURE_TENANT_ID=\n", "non-empty"), + ( + f"AZURE_TENANT_ID={TENANT_ID}\nAZURE_TENANT_ID={TENANT_ID}\n", + "duplicate AZURE_TENANT_ID", + ), + ("AZURE_CLIENT_SECRET=never-a-supported-setting\n", "unsupported entry"), + ("AZURE_TENANT_ID=$(read-secret)\n", "unsupported syntax"), + ( + "AZURE_TENANT_ID='39768270-33ce-4b90-a1a6-e0caeb3ba0ab'\n", + "unsupported syntax", + ), + ) + for contents, expected in cases: + with self.subTest(expected=expected): + self.env_file.write_text(contents, encoding="utf-8") + with self.assertRaisesRegex(ValueError, expected) as error: + read_solution_target_config(self.solution) + self.assertNotIn("read-secret", str(error.exception)) + + def test_rejects_unsafe_workspace_path_segment(self) -> None: + self._write_config( + workspace=( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/Bad Group/" + "providers/Microsoft.OperationalInsights/workspaces/Approved-Workspace" + ) + ) + with self.assertRaisesRegex(ValueError, "unsupported syntax"): + read_solution_target_config(self.solution) + + def test_workspace_subscription_argument_must_match_arm_id(self) -> None: + self._write_config() + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with self.assertRaisesRegex(ValueError, "does not match subscription-id"): + resolve_qualification_target( + self.solution, + subscription_id="11111111-2222-4333-8444-555555555555", + confirm_configured_target=True, + ) + run_az.assert_not_called() + + def test_authenticated_scope_must_match_before_exact_get(self) -> None: + self._write_config() + with mock.patch( + "sentinel_xdr_migration.target_context._run_az", + return_value=subprocess.CompletedProcess( + ["az"], 0, '{"tenantId":"11111111-2222-4333-8444-555555555555",' + f'"id":"{SUBSCRIPTION_ID}"}}', "" + ), + ) as run_az: + with self.assertRaisesRegex( + RuntimeError, "does not match AZURE_TENANT_ID" + ): + resolve_qualification_target( + self.solution, confirm_configured_target=True + ) + self.assertEqual(1, run_az.call_count) + + def test_authenticated_subscription_must_match_before_exact_get(self) -> None: + self._write_config() + with mock.patch( + "sentinel_xdr_migration.target_context._run_az", + return_value=subprocess.CompletedProcess( + ["az"], + 0, + '{"tenantId":"' + TENANT_ID + + '","id":"11111111-2222-4333-8444-555555555555"}', + "", + ), + ) as run_az: + with self.assertRaisesRegex(RuntimeError, "active Azure subscription"): + resolve_qualification_target( + self.solution, confirm_configured_target=True + ) + self.assertEqual(1, run_az.call_count) + + def test_locked_run_cannot_be_retargeted_by_changed_env_or_cli(self) -> None: + self._write_config() + locked = { + "tenantId": TENANT_ID, + "subscriptionId": SUBSCRIPTION_ID, + "workspaceResourceId": WORKSPACE_ID, + "workspaceCustomerId": CUSTOMER_ID, + } + self.env_file.write_text( + f"AZURE_TENANT_ID={TENANT_ID}\n" + "SENTINEL_WORKSPACE_RESOURCE_ID=/subscriptions/" + "11111111-2222-4333-8444-555555555555/resourceGroups/Other/" + "providers/Microsoft.OperationalInsights/workspaces/Other\n", + encoding="utf-8", + ) + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with self.assertRaisesRegex(ValueError, "conflicts with the locked"): + resolve_qualification_target( + self.solution, locked_context=locked + ) + run_az.assert_not_called() + + self._write_config() + different_workspace = ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/Other/" + "providers/Microsoft.OperationalInsights/workspaces/Other" + ) + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + with self.assertRaisesRegex(ValueError, "conflicts with solution .env"): + resolve_qualification_target( + self.solution, + workspace_resource_id=different_workspace, + locked_context=locked, + ) + run_az.assert_not_called() + + def test_locked_target_resumes_without_environment_call(self) -> None: + self._write_config() + locked = { + "tenantId": TENANT_ID, + "subscriptionId": SUBSCRIPTION_ID, + "workspaceResourceId": WORKSPACE_ID, + "workspaceCustomerId": CUSTOMER_ID, + } + with mock.patch("sentinel_xdr_migration.target_context._run_az") as run_az: + target = resolve_qualification_target( + self.solution, locked_context=locked + ) + self.assertEqual(locked, target) + run_az.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_target_context.py b/Tools/SentinelToXDRMigration/tests/test_target_context.py new file mode 100644 index 00000000000..e32b6ab6e08 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_target_context.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +from sentinel_xdr_migration.artifacts import artifact_path +from sentinel_xdr_migration.target_context import ( + build_target, + diagnose_target, + repair_target, + require_locked_target, +) +from sentinel_xdr_migration.workflow import initialize_workflow, workflow_status + + +TENANT_ID = "39768270-33ce-4b90-a1a6-e0caeb3ba0ab" +SUBSCRIPTION_ID = "42382e39-f157-46d1-a931-b8cfd779ece5" +CUSTOMER_ID = "756386d8-e2d4-4f09-905a-74b24313721f" +OLD_WORKSPACE = ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/DefaultResourceGroup-EUS/" + "providers/Microsoft.OperationalInsights/workspaces/OptimizeSentinelWorkspace" +) +NEW_WORKSPACE = ( + f"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/OptimizeSentinelResourceGroup/" + "providers/Microsoft.OperationalInsights/workspaces/OptimizeSentinelWorkspace" +) + + +class TargetContextTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.solution = Path(self.temp.name) / "Sample" + self.solution.mkdir() + + def tearDown(self) -> None: + self.temp.cleanup() + + def _initialize(self) -> None: + initialize_workflow( + self.solution, + workflow_profile="qualification", + tenant_id=TENANT_ID, + subscription_id=SUBSCRIPTION_ID, + workspace_resource_id=OLD_WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + ) + + def test_build_target_rejects_workspace_subscription_mismatch(self) -> None: + with self.assertRaisesRegex(ValueError, "does not match subscription-id"): + build_target( + tenant_id=TENANT_ID, + subscription_id="11111111-1111-1111-1111-111111111111", + workspace_resource_id=OLD_WORKSPACE, + workspace_customer_id=CUSTOMER_ID, + ) + + def test_locked_target_rejects_supplied_workspace_mismatch(self) -> None: + self._initialize() + + with self.assertRaisesRegex(ValueError, "does not match"): + require_locked_target( + self.solution, + workspace_resource_id=NEW_WORKSPACE, + ) + + def test_locked_target_rejects_artifact_state_mismatch(self) -> None: + self._initialize() + target_path = artifact_path(self.solution, "qualification-target.json") + target = json.loads(target_path.read_text(encoding="utf-8")) + target["workspaceCustomerId"] = "11111111-1111-1111-1111-111111111111" + target_path.write_text(json.dumps(target), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "does not match workflow state"): + require_locked_target(self.solution) + + def test_locked_target_rejects_unlocked_artifact(self) -> None: + self._initialize() + target_path = artifact_path(self.solution, "qualification-target.json") + target = json.loads(target_path.read_text(encoding="utf-8")) + target["locked"] = False + target_path.write_text(json.dumps(target), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "locked-target identity"): + require_locked_target(self.solution) + + def test_locked_target_can_be_read_without_materializing_missing_artifact(self) -> None: + self._initialize() + target_path = artifact_path(self.solution, "qualification-target.json") + target_path.unlink() + + target = require_locked_target(self.solution, materialize_missing=False) + + self.assertEqual(OLD_WORKSPACE, target["workspaceResourceId"]) + self.assertFalse(target_path.exists()) + + @mock.patch( + "sentinel_xdr_migration.target_context.shutil.which", + return_value="az", + ) + def test_diagnose_and_repair_use_one_exact_match( + self, + _which: mock.Mock, + ) -> None: + self._initialize() + commands: list[list[str]] = [] + + def runner(command, **_kwargs): + commands.append(command) + if command[1:3] == ["account", "show"]: + return CompletedProcess( + command, + 0, + json.dumps( + { + "tenantId": TENANT_ID, + "id": SUBSCRIPTION_ID, + } + ), + "", + ) + if command[1:3] == ["resource", "show"]: + return CompletedProcess( + command, + 3, + "", + "ResourceGroupNotFound", + ) + if command[1:3] == ["graph", "query"]: + return CompletedProcess( + command, + 0, + json.dumps( + { + "data": [ + { + "id": NEW_WORKSPACE, + "customerId": CUSTOMER_ID, + } + ] + } + ), + "", + ) + raise AssertionError(command) + + diagnostics = diagnose_target(self.solution, runner=runner) + + self.assertEqual("repair-available", diagnostics["status"]) + graph_command = next( + command for command in commands if command[1:3] == ["graph", "query"] + ) + self.assertIn(SUBSCRIPTION_ID, graph_command) + graph_query = graph_command[graph_command.index("-q") + 1] + self.assertIn(CUSTOMER_ID, graph_query) + self.assertNotIn("project-away", graph_query) + + with self.assertRaisesRegex(ValueError, "approve-target-update"): + repair_target( + self.solution, + approve_target_update=False, + state_dir=Path(self.temp.name) / "config", + ) + + repaired = repair_target( + self.solution, + approve_target_update=True, + state_dir=Path(self.temp.name) / "config", + ) + + self.assertEqual(NEW_WORKSPACE, repaired["target"]["workspaceResourceId"]) + state = workflow_status(self.solution) + self.assertEqual(NEW_WORKSPACE, state["context"]["workspaceResourceId"]) + self.assertEqual(TENANT_ID, state["context"]["tenantId"]) + self.assertEqual(SUBSCRIPTION_ID, state["context"]["subscriptionId"]) + self.assertEqual(CUSTOMER_ID, state["context"]["workspaceCustomerId"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_version_policy.py b/Tools/SentinelToXDRMigration/tests/test_version_policy.py new file mode 100644 index 00000000000..b8054bd2832 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_version_policy.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import json +import shutil +import unittest + +import test_customer_usage_attribution as attribution + + +@unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") +class VersionPolicyTests(unittest.TestCase): + setUp = attribution.AttributionTests.setUp + run_ps = attribution.AttributionTests.run_ps + prepare_integration = attribution.AttributionTests.prepare_integration + package = attribution.AttributionTests.package + write_metadata = attribution.AttributionTests.write_metadata + + def fixture(self, version="3.1.0", *, xdr=True): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + self.update_data(Version=version) + if not xdr: + self.update_data(**{"XDR Detections": []}) + return entry + + def update_data(self, **changes): + document = json.loads(self.data.read_text()) + document.update(changes) + self.data.write_text(json.dumps(document)) + + def update_detection(self, version, *, registration=False, fallback=None): + path = self.solution / "XDR Detections/Test.yaml" + document = json.loads(path.read_text().removeprefix("---\n")) + if version is None: + document.pop("version", None) + else: + document["version"] = version + path.write_text("---\n" + json.dumps(document)) + changes = {"Include XDR Content Registration": registration} + if fallback is not None: + changes["XDR Detection Version"] = fallback + self.update_data(**changes) + + def snapshot(self): + return {str(path): path.read_bytes() for path in self.solution.rglob("*") if path.is_file()} + + def assert_rejected_unchanged(self, entry, *, pipeline=False, **kwargs): + before = self.snapshot() + result = self.package(entry, pipeline, **kwargs) + self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("V3.1 version policy", result.stdout + result.stderr) + self.assertEqual(before, self.snapshot(), "Rejected packaging mutated source or outputs") + return result + + def test_solution_boundaries_without_xdr_local_and_pipeline(self): + entry = self.fixture(xdr=False) + for version, accepted in (("3.0.99", False), ("3.1.0", True), + ("3.9.99", True), ("4.0.0", False)): + for pipeline in (False, True): + with self.subTest(version=version, pipeline=pipeline): + self.update_data(Version=version) + if accepted: + result = self.package(entry, pipeline, calculated_version=version) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + self.assertEqual(version, template["variables"]["_solutionVersion"]) + self.assertTrue((self.solution / f"Package/{version}.zip").is_file()) + else: + result = self.assert_rejected_unchanged( + entry, pipeline=pipeline, calculated_version=version) + self.assertIn(version, result.stdout + result.stderr) + + def test_xdr_boundaries_even_without_registration(self): + entry = self.fixture() + for version, accepted in (("3.0.99", False), ("3.1.0", True), + ("3.9.99", True), ("4.0.0", False)): + for registration in (False, True): + for pipeline in (False, True): + with self.subTest(version=version, registration=registration, pipeline=pipeline): + self.update_detection(version, registration=registration) + if accepted: + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + registered = [r for r in template["resources"] + if r.get("properties", {}).get("contentKind") == "CustomDetection"] + self.assertEqual(1, len(registered)) + if registration: + properties = registered[0]["properties"] + self.assertEqual(version, properties["version"]) + self.assertIn(f"'-','{version}'", properties["contentProductId"]) + self.assertNotIn("'-','1.0.0'", properties["contentProductId"]) + self.assertEqual(properties["contentProductId"], properties["id"]) + self.assertEqual("3.0.0", properties["contentSchemaVersion"]) + else: + result = self.assert_rejected_unchanged(entry, pipeline=pipeline) + self.assertIn("CustomDetection 'XDR Detections/Test.yaml'", result.stdout + result.stderr) + + def test_analytic_versions_keep_legacy_floor(self): + entry = self.fixture() + path = self.solution / "Analytic Rules/Test.yaml" + document = json.loads(path.read_text().removeprefix("---\n")) + for version in ("1.0.0", "2.0.0", "3.9.99", None, "4.0.0"): + for pipeline in (False, True): + with self.subTest(version=version, pipeline=pipeline): + if version is None: + document.pop("version", None) + else: + document["version"] = version + path.write_text("---\n" + json.dumps(document)) + if version == "4.0.0": + result = self.assert_rejected_unchanged(entry, pipeline=pipeline) + self.assertIn("AnalyticsRule", result.stdout + result.stderr) + else: + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_bumps_persist_only_accepted_effective_versions(self): + entry = self.fixture("3.1.0") + for bump, expected in (("none", "3.1.0"), ("patch", "3.1.1"), ("minor", "3.2.0")): + with self.subTest(bump=bump): + self.update_data(Version="3.1.0") + result = self.package(entry, False, version_bump=bump) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(expected, json.loads(self.data.read_text())["Version"]) + self.assertTrue((self.solution / f"Package/{expected}.zip").is_file()) + for bump, current in (("major", "3.1.0"), ("minor", "3.99.0"), ("patch", "3.9.99")): + self.update_data(Version=current) + if bump == "major": + self.assert_rejected_unchanged(entry, version_bump=bump) + else: + result = self.package(entry, False, version_bump=bump) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.update_data(Version="3.1.0") + self.update_detection("1.0.0") + self.assert_rejected_unchanged(entry, version_bump="patch") + + def test_explicit_target_version_uses_v31_backend_and_rejects_out_of_range_targets(self): + entry = self.fixture("3.0.7") + + def package_target(target): + return self.run_ps( + f"& {attribution.ps_literal(entry)} " + f"-SolutionDataFolderPath {attribution.ps_literal(self.data.parent)} " + f"-VersionMode local -VersionBump none " + f"-TargetVersion {attribution.ps_literal(target)}" + ) + + result = package_target("3.1.2") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual("3.1.2", json.loads(self.data.read_text())["Version"]) + self.assertEqual("3.1.2", json.loads(self.metadata.read_text())["version"]) + self.assertTrue((self.solution / "Package" / "3.1.2.zip").is_file()) + + result = package_target("3.999.999") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual("3.999.999", json.loads(self.data.read_text())["Version"]) + self.assertEqual("3.999.999", json.loads(self.metadata.read_text())["version"]) + self.assertTrue((self.solution / "Package" / "3.999.999.zip").is_file()) + + for version in ("3.0.999", "4.0.0", "3.1", "3.1.0-preview", "03.1.0"): + with self.subTest(version=version): + before = self.snapshot() + rejected = package_target(version) + self.assertNotEqual(0, rejected.returncode, rejected.stdout + rejected.stderr) + self.assertIn("V3.1 target version", rejected.stdout + rejected.stderr) + self.assertEqual(before, self.snapshot()) + + def test_pipeline_mismatch_and_missing_solution_default_fail(self): + entry = self.fixture(xdr=False) + self.assert_rejected_unchanged(entry, pipeline=True, calculated_version="3.2.0") + document = json.loads(self.data.read_text()) + document.pop("Version") + self.data.write_text(json.dumps(document)) + for pipeline in (False, True): + with self.subTest(pipeline=pipeline): + self.assert_rejected_unchanged(entry, pipeline=pipeline) + + def test_xdr_own_version_is_required_and_fallbacks_cannot_override_it(self): + entry = self.fixture() + for pipeline in (False, True): + for registration in (False, True): + with self.subTest(pipeline=pipeline, registration=registration): + for version in ("1.0.0", "", None): + self.update_detection(version, registration=registration, fallback="3.1.0") + result = self.assert_rejected_unchanged(entry, pipeline=pipeline) + self.assertIn("major.minor.patch", result.stdout + result.stderr) + self.assertIn("reconvert with --overwrite", result.stdout + result.stderr) + self.update_detection("3.1.1", registration=registration, fallback="4.0.0") + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + detection = json.loads((self.solution / "XDR Detections/Test.yaml").read_text().removeprefix("---\n")) + self.assertEqual("1.0.0", detection["contentProvenance"]["source"]["version"]) + self.assertEqual("3.1.1", detection["version"]) + self.update_detection(None, registration=registration) + document = json.loads(self.data.read_text()) + document.pop("XDR Detection Version") + self.data.write_text(json.dumps(document)) + self.assert_rejected_unchanged(entry, pipeline=pipeline) + + def test_xdr_release_requires_three_numeric_components(self): + policy = attribution.ps_literal(attribution.PACKAGER / "common/versionPolicy.ps1") + for version in ("3.1", "3.1.0.1", "3.01.0", "3.1.0-preview", "3.1.0\n"): + with self.subTest(version=version): + result = self.run_ps( + f". {policy}; " + "$v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() }; " + f"Assert-V31ReleaseVersion -Version {attribution.ps_literal(version)} " + "-ContentKind CustomDetection -ContentPath 'test'" + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("major.minor.patch", result.stderr) + + def test_catalog_result_is_guarded_without_source_mutation(self): + entry = self.fixture(xdr=False) + catalog = self.repository / ".script/package-automation/catalogAPI.ps1" + catalog.parent.mkdir(parents=True) + for version in ("3.0.99", "3.1.0", "4.0.0"): + # Isolate only the external catalog response, not any packaging or guard logic. + catalog.write_text( + "function GetCatalogDetails { return @{} }\n" + f"function GetPackageVersion {{ return '{version}' }}\n" + ) + if version != "3.1.0": + self.assert_rejected_unchanged(entry, version_mode="catalog") + else: + before = self.data.read_bytes(), self.metadata.read_bytes() + result = self.package(entry, False, version_mode="catalog") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(before, (self.data.read_bytes(), self.metadata.read_bytes())) + + def test_legacy_context_is_unrestricted_and_checks_cannot_be_swallowed(self): + policy = attribution.ps_literal(attribution.PACKAGER / "common/versionPolicy.ps1") + result = self.run_ps( + f". {policy}; " + "Assert-V31ReleaseVersion -Version '4.0.0' -ContentKind Solution -ContentPath 'Legacy'; " + "$v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() }; " + "try { Assert-V31ReleaseVersion -Version '4.0.0' -ContentKind Workbook -ContentPath 'Example.json' } catch {}; " + "Assert-V31VersionChecksPassed" + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("Workbook 'Example.json'", result.stderr) + self.assertNotIn("Solution 'Legacy'", result.stderr) + + def test_numeric_bounds_do_not_apply_floor_to_other_content_kinds(self): + policy = attribution.ps_literal(attribution.PACKAGER / "common/versionPolicy.ps1") + kinds = ("Parser", "Workbook", "Playbook", "DataConnector", "LogicAppsCustomConnector", + "AzureFunction", "SummaryRule", "Watchlist", "CCF DataConnector/Connections") + for kind in kinds: + with self.subTest(kind=kind): + result = self.run_ps( + f". {policy}; " + "$v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() }; " + f"foreach ($v in @('1.0', '1.0.0', '2.0.0', '3.9.99')) {{ Assert-V31ReleaseVersion -Version $v -ContentKind '{kind}' -ContentPath 'test' }}; " + f"Assert-V31ReleaseVersion -Version '4.0' -ContentKind '{kind}' -ContentPath 'test'" + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("'4.0'", result.stderr) + self.assertIn("required < 4.0.0", result.stderr) + + def test_other_generators_reject_their_effective_release_versions(self): + entry = self.fixture(xdr=False) + base_data = json.loads(self.data.read_text()) + wrapper = self.repository / "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1" + with wrapper.open("a") as stream: + stream.write("function Invoke-WebRequest { return '{\"items\":[]}' }\n") + cases = [ + ("Hunting Queries", "Hunting Queries/Test.yaml", { + "id": "33333333-2222-3333-4444-555555555555", "name": "Hunt", + "description": "Test", "query": "print Test=1", "version": "4.0.0", + "requiredDataConnectors": [], + }, "HuntingQuery"), + ("Parsers", "Parsers/Test.yaml", { + "FunctionName": "TestParser", "FunctionQuery": "print Test=1", + "Function": {"Title": "Test", "Version": "4.0.0"}, + }, "Parser"), + ("Playbooks", "Playbooks/Test/azuredeploy.json", { + "parameters": {}, "variables": {}, "resources": [{ + "type": "Microsoft.Logic/workflows", "apiVersion": "2019-05-01", + "name": "test", "tags": {"hidden-SentinelTemplateVersion": "4.0.0"}, + "properties": {"definition": {"actions": {}, "triggers": {}}}, + }], + }, "Playbook"), + ("Data Connectors", "Data Connectors/Test.json", { + "id": "TestConnector", "title": "Test", "publisher": "Test", + "descriptionMarkdown": "Test", "graphQueries": [], "dataTypes": [], + "sampleQueries": [], "instructionSteps": [], "permissions": {}, + "metadata": {"version": "4.0.0"}, + }, "DataConnector"), + ] + for field, relative, document, kind in cases: + for pipeline in (False, True): + with self.subTest(kind=kind, pipeline=pipeline): + path = self.solution / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(("---\n" if relative.endswith(".yaml") else "") + json.dumps(document)) + data = dict(base_data) + data[field] = [relative] + self.data.write_text(json.dumps(data)) + result = self.assert_rejected_unchanged(entry, pipeline=pipeline) + self.assertIn(kind, result.stdout + result.stderr) + + def test_workbook_effective_metadata_version_is_checked(self): + common = attribution.ps_literal(attribution.PACKAGER / "common/commonFunctions.ps1") + metadata = self.solution / "Workbooks/WorkbooksMetadata.json" + metadata.parent.mkdir() + metadata.write_text(json.dumps([{ + "templateRelativePath": "Test.json", "workbookKey": "Test", + "title": "Test", "description": "Test", "version": "4.0.0", + "dataTypesDependencies": [], "dataConnectorsDependencies": [], + }])) + result = self.run_ps( + f". {common}; " + "$v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() }; " + "$contentToImport = [pscustomobject]@{TemplateSpec=$true;Name='Sample';Version='3.1.0'}; " + "$rawData = '{\"version\":\"Notebook/1.0\",\"items\":[]}'; " + "GetWorkbookDataMetadata -file 'Workbooks/Test.json' -isPipelineRun $false " + "-contentResourceDetails (returnContentResources '3.1.0') " + f"-baseFolderPath {attribution.ps_literal(str(self.solution) + '/')} " + "-contentToImport $contentToImport" + ) + self.assertNotEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("Workbook 'Workbooks/Test.json'", result.stderr) + self.assertIn("'4.0.0'", result.stderr) + + def test_ccf_fallback_and_shared_connection_version(self): + common = attribution.ps_literal(attribution.PACKAGER / "common/commonFunctions.ps1") + ccf = attribution.ps_literal(attribution.PACKAGER / "common/createCCPConnector.ps1") + for version in ("1.0.0", "3.9.99", "4.0.0"): + with self.subTest(version=version): + result = self.run_ps( + f". {common}; . {ccf}; " + "$v31VersionPolicy = @{ Errors = [System.Collections.Generic.List[string]]::new() }; " + "$data = [pscustomobject]@{ Name='Sample'; Version='3.1.0'; " + f"DataConnectorCCFVersion='{version}' }}; " + "createCCPConnectorResources -dataFileMetadata $data " + "-solutionFileMetadata ([pscustomobject]@{publisherId='test';offerId='sample'}) " + "-solutionName Sample -dcFolderName 'Data Connectors' -ccpDict @(); " + "Assert-V31VersionChecksPassed" + ) + if version == "4.0.0": + self.assertNotEqual(0, result.returncode) + self.assertIn("DataConnectorCCFVersion", result.stderr) + else: + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_legacy_entry_points_do_not_enable_v31_policy(self): + entry = self.fixture("4.0.0", xdr=False) + legacy = entry.with_name("createSolutionV3.ps1") + shutil.copyfile(attribution.PACKAGER / "V3/createSolutionV3.ps1", legacy) + result = self.package(legacy, False, version_bump="patch") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn("V3.1 version policy", result.stdout + result.stderr) + self.assertTrue((self.solution / "Package/4.0.1.zip").is_file()) + pipeline = entry.with_name("legacyPipeline.ps1") + shutil.copyfile(attribution.PACKAGER / "pipeline/createSolutionV4.ps1", pipeline) + result = self.package(pipeline, True, calculated_version="4.0.1") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn("V3.1 version policy", result.stdout + result.stderr) + + def test_provider_and_conversion_versions_are_not_release_versions(self): + entry = self.fixture() + self.update_data(**{"XDR Extension Version": "9.0.0"}) + detection = self.solution / "XDR Detections/Test.yaml" + document = json.loads(detection.read_text().removeprefix("---\n")) + document["contentProvenance"]["conversion"] = {"version": "9.0.0", "schemaVersion": "9.0.0"} + detection.write_text("---\n" + json.dumps(document)) + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + template = json.loads((self.solution / "Package/mainTemplate.json").read_text()) + xdr = next(r for r in template["resources"] if r["type"] == "Microsoft.Resources/deployments" + and r.get("condition") == "[parameters('DeployCustomDetection')]") + self.assertEqual("9.0.0", xdr["properties"]["template"]["imports"]["MicrosoftSecurity"]["version"]) + self.assertEqual("Microsoft.Security/detectionRules@2025-06-01", + xdr["properties"]["template"]["resources"]["detectionRule"]["type"]) + self.assertEqual("1.0.0", document["schemaVersion"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_workflow.py b/Tools/SentinelToXDRMigration/tests/test_workflow.py new file mode 100644 index 00000000000..cc9ab1296ce --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_workflow.py @@ -0,0 +1,3730 @@ +from __future__ import annotations + +import json +import hashlib +import io +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest import mock + +import yaml + +from sentinel_xdr_migration.workflow import ( + complete_workflow_stage, + initialize_workflow, + next_workflow_stage, + package_workflow_v3_1, + plan_new_package_release, + plan_release_notes_refresh, + plan_same_version_content_rebuild, + _yolo_package_readiness, + read_authoring_contract, + refresh_yolo_authoring, + recover_conversion_evidence, + run_yolo_authoring, + start_workflow_stage, + workflow_status, +) +from sentinel_xdr_migration.artifacts import ( + artifact_path, + create_run, + list_runs, + migrate_reports, + using_run, +) +from sentinel_xdr_migration.budget import ( + BudgetExhausted, + BudgetReceiptError, + begin_operation, + budget_summary, + extend_budget, + finish_operation, +) +from sentinel_xdr_migration.converter import convert_solution +from sentinel_xdr_migration.packaging import package_input_file_hashes, package_input_identity +from sentinel_xdr_migration import cli as cli_module +from sentinel_xdr_migration import runtime as runtime_module +from sentinel_xdr_migration.report_dashboard import _run_html +from sentinel_xdr_migration.solution_report import build_solution_report +from test_converter import RULE +from sentinel_xdr_migration import workflow as workflow_module + + +class WorkflowTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir=Path.cwd()) + self.solution = Path(self.temp.name) / "Sample" + self.solution.mkdir() + + def tearDown(self) -> None: + self.temp.cleanup() + + def _packaging_artifacts( + self, + version_bump: str = "none", + version: str = "1.0.0", + ) -> dict[str, str]: + package = self.solution / "Package" + package.mkdir(exist_ok=True) + paths = { + "mainTemplate": package / "mainTemplate.json", + "createUiDefinition": package / "createUiDefinition.json", + "testParameters": package / "testParameters.json", + "zip": package / f"{version}.zip", + } + for path in paths.values(): + path.write_text("{}", encoding="utf-8") + report = artifact_path(self.solution, "packaging.v3_1.json", create_parent=True) + report.write_text( + json.dumps({ + "packager": "V3.1", + "versionBump": version_bump, + **{name: str(path) for name, path in paths.items()}, + }), + encoding="utf-8", + ) + return { + "packager": "V3.1", + "packageReport": str(report), + **{name: str(path) for name, path in paths.items()}, + } + + def test_package_input_hash_preserves_legacy_path_ordering(self) -> None: + (self.solution / "Data").mkdir() + (self.solution / "Data" / "foo").write_bytes(b"data\n") + (self.solution / "Data Connectors").mkdir() + (self.solution / "Data Connectors" / "foo").write_bytes(b"connector\n") + + self.assertEqual( + "a1619ceb3e74e25fecf64a29ebcf9714d89cc13917ce1e63c2b0e091cbd90960", + package_input_identity(self.solution)["sha256"], + ) + + def _mock_v3_1_package(self, solution: Path, **kwargs: str) -> dict: + input_before = package_input_identity(solution) + target_version = kwargs.get("target_version") + if target_version: + data_files = sorted((solution / "Data").glob("Solution_*.json")) + if len(data_files) != 1: + raise AssertionError("target-version mock requires one solution data file") + data = json.loads(data_files[0].read_text(encoding="utf-8")) + data["Version"] = target_version + data_files[0].write_text(json.dumps(data), encoding="utf-8") + version = workflow_module.solution_data_version(solution) or "1.0.0" + artifacts = self._packaging_artifacts(kwargs["version_bump"], version) + package_hashes = { + name: hashlib.sha256(Path(artifacts[name]).read_bytes()).hexdigest() + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip") + } + inputs = package_input_identity(solution) + input_files = package_input_file_hashes(solution) + if ( + workflow_module._identity_from_file_hashes(input_files) != inputs["sha256"] + or len(input_files) != inputs["fileCount"] + ): + raise AssertionError("mock V3.1 input manifest does not match its identity") + attempt_id = kwargs["package_attempt_id"] + report = { + "status": "passed", + "packager": "V3.1", + "version": version, + "versionBump": kwargs["version_bump"], + "requestedVersionBump": kwargs["requested_version_bump"], + "effectiveVersionBump": kwargs["version_bump"], + "requestedTargetVersion": target_version, + "effectiveTargetVersion": target_version, + "packageAttemptId": attempt_id, + "workflowRunId": kwargs["workflow_run_id"], + "packageInputs": { + "before": input_before, + "after": {**inputs, "files": input_files}, + }, + "packageArtifactHashes": package_hashes, + "packageArtifactsSha256": hashlib.sha256( + json.dumps(package_hashes, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + **{ + name: artifacts[name] + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip") + }, + } + selection_plan = kwargs.get("selection_plan") + if selection_plan is not None: + summary = selection_plan["summary"] + report.update({ + "selectionOutcome": ( + "partial-package-validated" + if summary["outcome"] == "partial" + else "package-validated" + ), + "selectionPlanSha256": selection_plan["planSha256"], + "selectionSummary": summary, + "selectionVerification": { + "planSha256": selection_plan["planSha256"], + "outcome": summary["outcome"], + "analyticRuleIncluded": summary.get("analyticRuleIncluded"), + "xdrDetectionIncluded": summary.get("xdrDetectionIncluded"), + "coverageNumerator": summary.get("coverageNumerator"), + "coverageDenominator": summary.get("coverageDenominator"), + "partialPublicationAllowed": summary.get("partialPublicationAllowed"), + "mainTemplateVerified": True, + "zipVerified": True, + }, + }) + report_path = Path(artifacts["packageReport"]) + report_path.write_text(json.dumps(report), encoding="utf-8") + artifacts["packageReport"] = str(report_path) + return { + **report, + "solution": str(solution), + "packageReport": str(report_path), + "workflowArtifacts": { + "packager": "V3.1", + "packageReport": str(report_path), + **{ + name: artifacts[name] + for name in ("mainTemplate", "createUiDefinition", "testParameters", "zip") + }, + }, + } + + def _mock_v3_1_patch_package(self, solution: Path, **kwargs: str) -> dict: + before = package_input_identity(solution) + if kwargs.get("version_bump") != "patch": + raise AssertionError("patch test helper requires the patch version action") + data_files = sorted((solution / "Data").glob("Solution_*.json")) + if len(data_files) != 1: + raise AssertionError("patch test helper requires one solution data file") + data = json.loads(data_files[0].read_text(encoding="utf-8")) + major, minor, patch = (int(part) for part in str(data["Version"]).split(".")) + data["Version"] = f"{major}.{minor}.{patch + 1}" + data_files[0].write_text(json.dumps(data), encoding="utf-8") + result = self._mock_v3_1_package(solution, **kwargs) + report_path = Path(result["packageReport"]) + report = json.loads(report_path.read_text(encoding="utf-8")) + report["packageInputs"]["before"] = before + report_path.write_text(json.dumps(report), encoding="utf-8") + result["packageInputs"]["before"] = before + return result + + def _prepare_workflow_for_package(self, version_bump: str = "patch") -> str: + initialized = initialize_workflow( + self.solution, + version_bump=version_bump, + workflow_profile="authoring", + campaign_id="test-campaign", + ) + for stage in ("discovery", "conversion", "validation"): + self._complete_passed_stage(stage) + return initialized["runId"] + + def _prepare_offline_authoring_validation( + self, + *, + campaign_id: str | None = "test-campaign", + ) -> tuple[dict, Path]: + self._create_source_rules() + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="authoring", + campaign_id=campaign_id, + ) + with using_run(self.solution, initialized["runId"]): + start_workflow_stage(self.solution, "discovery") + complete_workflow_stage(self.solution, "discovery", status="passed") + start_workflow_stage(self.solution, "conversion") + conversion = convert_solution(self.solution) + manifest_path = Path(conversion["manifest"]) + complete_workflow_stage( + self.solution, + "conversion", + status="passed", + artifacts={ + "manifest": "manifest.json", + "manifestSha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), + }, + ) + start_workflow_stage(self.solution, "validation") + return initialized, manifest_path + + def _qualification_args(self) -> dict[str, str]: + return { + "tenant_id": "39768270-33ce-4b90-a1a6-e0caeb3ba0ab", + "subscription_id": "42382e39-f157-46d1-a931-b8cfd779ece5", + "workspace_resource_id": ( + "/subscriptions/42382e39-f157-46d1-a931-b8cfd779ece5/" + "resourceGroups/rg/providers/Microsoft.OperationalInsights/" + "workspaces/ws" + ), + "workspace_customer_id": "756386d8-e2d4-4f09-905a-74b24313721f", + } + + def _complete_passed_stage(self, stage: str) -> None: + start_workflow_stage(self.solution, stage) + artifacts = self._packaging_artifacts() if stage == "packaging" else None + message = None + if stage == "validation": + context = workflow_status(self.solution)["context"] + if context["workflowProfile"] == "authoring": + artifacts = {"runtimeStatus": "environment-blocked"} + message = ( + "Runtime KQL validation was not run; no runtime-qualified " + "claim is made." + ) + else: + self._create_runtime_evidence() + complete_workflow_stage( + self.solution, + stage, + status="passed", + message=message, + artifacts=artifacts, + ) + + def _create_runtime_evidence(self) -> None: + source_path = self.solution / "Analytic Rules" / "Rule.yaml" + output_path = self.solution / "XDR Detections" / "Rule.yaml" + if not source_path.exists(): + source_path.parent.mkdir(parents=True) + source_path.write_text(RULE, encoding="utf-8") + source = yaml.safe_load(source_path.read_text()) + if not output_path.exists(): + output_path.parent.mkdir(parents=True) + output_path.write_text(yaml.safe_dump({ + "kind": "CustomDetection", + "contentProvenance": { + "source": {"path": "Analytic Rules/Rule.yaml"}, + }, + "properties": { + "queryCondition": { + "queryText": "DeviceEvents | where Timestamp > ago(1h)", + }, + }, + }), encoding="utf-8") + target = yaml.safe_load(output_path.read_text()) + for provider, query in ( + ("triage-mcp", source["query"]), + ("graph", target["properties"]["queryCondition"]["queryText"]), + ): + query_hash = hashlib.sha256(query.encode()).hexdigest() + artifact_path( + self.solution, + f"runtime-validation.{provider}.json", + create_parent=True, + ).write_text(json.dumps({ + "provider": provider, + "platform": ( + "Microsoft Sentinel Triage MCP" + if provider == "triage-mcp" + else "Microsoft Defender XDR Advanced Hunting" + ), + "results": [{ + "detection": "Rule.yaml", + "status": "passed", + "rowCount": 0, + "querySha256": query_hash, + }], + }), encoding="utf-8") + + def _create_source_rules(self, count: int = 1) -> list[Path]: + paths = [] + for index in range(count): + path = self.solution / "Analytic Rules" / "Nested" / f"Rule{index}.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + document = yaml.safe_load(RULE) + document["id"] = f"11111111-1111-1111-1111-{index + 1:012d}" + document["name"] = f"Rule {index}" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + paths.append(path) + return paths + + def _run_yolo(self, profile: str = "authoring", count: int = 1) -> tuple[dict, dict]: + self._create_source_rules(count) + initialized = initialize_workflow( + self.solution, + workflow_profile=profile, + yolo=True, + campaign_id="test-campaign", + **(self._qualification_args() if profile == "qualification" else {}), + ) + with using_run(self.solution, initialized["runId"]): + result = run_yolo_authoring(self.solution) + contract = read_authoring_contract(self.solution) + return result, contract + + def _runtime_reports(self, contract: dict, *, failed_graph_rules: set[str] | None = None) -> None: + failed_graph_rules = failed_graph_rules or set() + run = self.solution / "Logs" / "sentinel-xdr-migration" / contract["runId"] + source_rules = {rule["ruleId"]: rule for rule in contract["inventory"]["rules"]} + outputs = { + result["ruleId"]: result["target"] + for result in contract["results"] + if result.get("target") + } + graph = [] + source = [] + for rule_id, target in outputs.items(): + detection = Path(target["path"]).relative_to("XDR Detections").as_posix() + graph.append({ + "detection": detection, + "status": "failed" if rule_id in failed_graph_rules else "passed", + "querySha256": target["querySha256"], + "error": "Synthetic KQL failure" if rule_id in failed_graph_rules else None, + }) + source.append({ + "detection": detection, + "status": "passed", + "querySha256": source_rules[rule_id]["querySha256"], + "error": None, + }) + run.mkdir(parents=True, exist_ok=True) + (run / "runtime-validation.graph.json").write_text( + json.dumps({"provider": "graph", "results": graph}), + encoding="utf-8", + ) + (run / "runtime-validation.triage-mcp.json").write_text( + json.dumps({"provider": "triage-mcp", "results": source}), + encoding="utf-8", + ) + + def test_profile_selection_is_required(self) -> None: + with self.assertRaisesRegex(ValueError, "profile selection is required"): + initialize_workflow(self.solution) + + def test_initialize_creates_resumable_state(self) -> None: + created = initialize_workflow( + self.solution, + workspace_resource_id="/subscriptions/test/workspaces/sample", + version_bump="patch", + workflow_profile="authoring", + ) + resumed = initialize_workflow( + self.solution, + workspace_resource_id="/subscriptions/test/workspaces/sample", + version_bump="patch", + workflow_profile="authoring", + ) + + self.assertFalse(created["resumed"]) + self.assertTrue(resumed["resumed"]) + self.assertEqual(resumed["next"], "discovery") + self.assertTrue( + artifact_path(self.solution, "workflow-state.json").is_file() + ) + self.assertEqual( + resumed["stages"]["mockIngestion"]["status"], + "notRequired", + ) + self.assertTrue(resumed["context"]["profileSelectionConfirmed"]) + + def test_authoring_can_record_tenant_without_qualification_target(self) -> None: + tenant_id = "11111111-1111-4111-8111-111111111111" + created = initialize_workflow( + self.solution, + tenant_id=tenant_id, + workflow_profile="authoring", + ) + + context = created["context"] + self.assertEqual(tenant_id, context["tenantId"]) + self.assertIsNone(context.get("subscriptionId")) + self.assertIsNone(context.get("workspaceResourceId")) + self.assertIsNone(context.get("workspaceCustomerId")) + self.assertFalse(context["targetContextLocked"]) + + def test_stage_gate_requires_dependencies(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + + with self.assertRaisesRegex(ValueError, "blocked by: discovery"): + start_workflow_stage(self.solution, "conversion") + + def test_legacy_state_is_read_only_until_explicit_copy(self) -> None: + created = initialize_workflow(self.solution, workflow_profile="authoring") + preferred = Path(created["statePath"]) + legacy = self.solution / "XDR Detections" / "workflow-state.json" + legacy.parent.mkdir(parents=True, exist_ok=True) + preferred.replace(legacy) + + with self.assertWarnsRegex(UserWarning, "reading legacy"): + resumed = workflow_status(self.solution) + self.assertEqual(legacy, Path(resumed["statePath"])) + self.assertFalse(preferred.is_file()) + with self.assertRaisesRegex(ValueError, "migrate-reports"): + start_workflow_stage(self.solution, "discovery") + migrate_reports(self.solution, apply=True) + self.assertTrue(preferred.is_file()) + self.assertTrue(legacy.exists()) + start_workflow_stage(self.solution, "discovery") + self.assertEqual("running", workflow_status(self.solution)["stages"]["discovery"]["status"]) + + def test_failed_stage_can_be_retried(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + start_workflow_stage(self.solution, "discovery") + complete_workflow_stage( + self.solution, + "discovery", + status="failed", + message="source metadata is incomplete", + ) + + retry = start_workflow_stage(self.solution, "discovery") + + self.assertEqual(retry["attempts"], 2) + self.assertEqual(retry["status"], "running") + + def test_complete_stage_persists_contract_and_unlocks_next(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + start_workflow_stage(self.solution, "discovery") + result = complete_workflow_stage( + self.solution, + "discovery", + status="passed", + message="one source rule discovered", + artifacts={"inspection": "Reports/Sample/sentinel-xdr-migration/inspection.json"}, + evidence=["Analytic Rules/Sample.yaml"], + ) + + self.assertEqual(result["next"], "conversion") + self.assertEqual(next_workflow_stage(self.solution)["next"], "conversion") + persisted = workflow_status(self.solution) + discovery = persisted["stages"]["discovery"] + self.assertEqual( + discovery["artifacts"]["inspection"], + artifact_path(self.solution, "inspection.json").relative_to(self.solution).as_posix(), + ) + self.assertEqual(discovery["evidence"], ["Analytic Rules/Sample.yaml"]) + + def test_context_mismatch_is_rejected(self) -> None: + initialize_workflow( + self.solution, + version_bump="patch", + workflow_profile="authoring", + ) + + with self.assertRaisesRegex(ValueError, "workflow already uses"): + initialize_workflow( + self.solution, + version_bump="minor", + workflow_profile="authoring", + ) + + def test_authoring_profile_moves_from_packaging_to_report(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + for stage in ("discovery", "conversion", "validation", "packaging"): + self._complete_passed_stage(stage) + + self.assertEqual(next_workflow_stage(self.solution)["next"], "report") + + def test_qualification_profile_requires_internal_test_stages(self) -> None: + initialized = initialize_workflow( + self.solution, + workflow_profile="qualification", + **self._qualification_args(), + ) + self.assertEqual(initialized["stages"]["mockIngestion"]["status"], "pending") + for stage in ("discovery", "conversion", "validation", "packaging"): + self._complete_passed_stage(stage) + + self.assertEqual(next_workflow_stage(self.solution)["next"], "deployment") + + def test_authoring_profile_can_change_to_qualification_before_live_stages(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + for stage in ("discovery", "conversion", "validation", "packaging"): + self._complete_passed_stage(stage) + + changed = initialize_workflow( + self.solution, + workflow_profile="qualification", + **self._qualification_args(), + ) + + self.assertEqual("qualification", changed["context"]["workflowProfile"]) + self.assertEqual("pending", changed["stages"]["deployment"]["status"]) + self.assertEqual("deployment", changed["next"]) + + def test_ordinary_authoring_new_runs_remain_unbudgeted_across_inventory_changes(self) -> None: + first = initialize_workflow(self.solution, workflow_profile="authoring") + rules = self.solution / "Analytic Rules" + rules.mkdir() + (rules / "Rule.yaml").write_text("first inventory", encoding="utf-8") + + second = initialize_workflow( + self.solution, + workflow_profile="authoring", + new_run=True, + ) + + self.assertNotIn("budget", first) + self.assertNotIn("budget", second) + self.assertFalse( + (self.solution / "Logs" / "sentinel-xdr-migration-campaigns").exists() + ) + for run_id in (first["runId"], second["runId"]): + run_directory = ( + self.solution / "Logs" / "sentinel-xdr-migration" / run_id + ) + self.assertFalse((run_directory / "campaigns").exists()) + + def test_packaging_cannot_pass_without_v3_1_evidence(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + for stage in ("discovery", "conversion", "validation"): + self._complete_passed_stage(stage) + start_workflow_stage(self.solution, "packaging") + + with self.assertRaisesRegex(ValueError, "requires packager=V3.1"): + complete_workflow_stage( + self.solution, + "packaging", + status="passed", + ) + + def test_partial_qualification_progresses_without_fabricating_full_validation(self) -> None: + initialized = initialize_workflow( + self.solution, + version_bump="patch", + workflow_profile="qualification", + **self._qualification_args(), + ) + for stage in ("discovery", "conversion"): + self._complete_passed_stage(stage) + start_workflow_stage(self.solution, "validation") + complete_workflow_stage( + self.solution, + "validation", + status="blocked", + message="One item failed current runtime validation.", + artifacts={"runtimeStatus": "blocked"}, + ) + plan = { + "planSha256": "a" * 64, + "summary": { + "outcome": "partial", + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": 1, + "originalSourceRuleIds": ["11111111-1111-1111-1111-111111111111"], + "validatedMigratedSourceRuleCount": 1, + "validatedMigratedSourceRuleIds": ["11111111-1111-1111-1111-111111111111"], + "coverageNumerator": 1, + "coverageDenominator": 1, + "partialPublicationAllowed": True, + }, + } + selection = { + "plan": plan, + "planPath": "Logs/plan.json", + "receiptPath": "Logs/applied.json", + } + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + result = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + with self.assertRaisesRegex(ValueError, "still match the successful V3.1 package"): + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + + state = workflow_status(self.solution) + next_stage = next_workflow_stage(self.solution) + started = start_workflow_stage(self.solution, "deployment") + complete_workflow_stage(self.solution, "deployment", status="passed") + start_workflow_stage(self.solution, "mockIngestion") + complete_workflow_stage(self.solution, "mockIngestion", status="passed") + start_workflow_stage(self.solution, "alertParity") + complete_workflow_stage(self.solution, "alertParity", status="passed") + start_workflow_stage(self.solution, "report") + complete_workflow_stage(self.solution, "report", status="passed") + completed_state = workflow_status(self.solution) + self.assertEqual("partial-package-validated", result["selectionOutcome"]) + self.assertEqual("passed", state["stages"]["packaging"]["status"]) + self.assertEqual("blocked", state["stages"]["validation"]["status"]) + self.assertEqual("inProgress", state["workflowStatus"]) + self.assertEqual("inProgress", result["workflowStatus"]) + self.assertEqual(plan["planSha256"], state["stages"]["packaging"]["artifacts"]["selectionPlanSha256"]) + self.assertEqual("partial-package-validated", state["partialSolution"]["status"]) + self.assertFalse(state["partialSolution"]["fullyRuntimeQualified"]) + self.assertEqual("deployment", next_stage["next"]) + self.assertEqual("running", started["status"]) + self.assertEqual("blocked", completed_state["stages"]["validation"]["status"]) + self.assertEqual("partialCompleted", completed_state["workflowStatus"]) + self.assertEqual("passed", completed_state["stages"]["alertParity"]["status"]) + + def test_blocked_validation_cannot_package_without_applied_selection(self) -> None: + self._create_source_rules() + initialize_workflow(self.solution, workflow_profile="authoring") + for stage in ("discovery", "conversion"): + self._complete_passed_stage(stage) + start_workflow_stage(self.solution, "validation") + complete_workflow_stage( + self.solution, + "validation", + status="blocked", + message="Runtime evidence is incomplete.", + artifacts={"runtimeStatus": "blocked"}, + ) + + with self.assertRaisesRegex(ValueError, "requires passed validation"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + + def test_yolo_readiness_never_uses_partial_selection_exception(self) -> None: + state = { + "authoring": {"status": "active"}, + "stages": {"validation": {"status": "blocked"}}, + } + with self.assertRaisesRegex(ValueError, "existing validation stage to pass"): + _yolo_package_readiness( + self.solution, + self.solution / "workflow-state.json", + state, + allow_partial_selection=True, + ) + + def test_old_report_is_not_relabelled_as_v3_1(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + for stage in ("discovery", "conversion", "validation"): + self._complete_passed_stage(stage) + start_workflow_stage(self.solution, "packaging") + artifacts = self._packaging_artifacts() + report = Path(artifacts["packageReport"]) + document = json.loads(report.read_text()) + document["packager"] = "V4" + report.write_text(json.dumps(document)) + before = report.read_bytes() + with self.assertRaisesRegex(ValueError, "does not prove V3.1"): + complete_workflow_stage( + self.solution, "packaging", status="passed", artifacts=artifacts + ) + self.assertEqual(before, report.read_bytes()) + + def test_consumed_bump_retries_with_none_and_fresh_v3_1_evidence(self) -> None: + run_id = self._prepare_workflow_for_package("patch") + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + first = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + first_report = json.loads(Path(first["packageReport"]).read_text()) + retry = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + retry_report = json.loads(Path(retry["packageReport"]).read_text()) + + self.assertEqual(["patch", "none"], [ + call.kwargs["version_bump"] for call in package.call_args_list + ]) + self.assertEqual("patch", first_report["effectiveVersionBump"]) + self.assertEqual("none", retry_report["requestedVersionBump"]) + self.assertEqual("none", retry_report["effectiveVersionBump"]) + self.assertNotEqual( + first_report["packageAttemptId"], + retry_report["packageAttemptId"], + ) + state = workflow_status(self.solution) + self.assertEqual(2, state["stages"]["packaging"]["attempts"]) + self.assertTrue(state["packaging"]["lastConsumedAction"]["bumpConsumed"]) + self.assertEqual("none", state["packaging"]["lastConsumedAction"]["requestedVersionBump"]) + self.assertEqual("passed", state["stages"]["packaging"]["status"]) + self.assertEqual( + 2, + state["budgetSummary"]["usage"]["packageBuilds"], + ) + + def test_changed_package_inputs_reject_none_and_require_a_new_bump(self) -> None: + run_id = self._prepare_workflow_for_package("patch") + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + package_workflow_v3_1(self.solution, requested_version_bump="patch") + changed_input = self.solution / "XDR Detections" / "Detection.yaml" + changed_input.parent.mkdir(parents=True, exist_ok=True) + changed_input.write_text("kind: CustomDetection\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "select an explicit new release action"): + package_workflow_v3_1(self.solution, requested_version_bump="none") + package.assert_called_once() + + identity = package_input_identity(self.solution)["sha256"] + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=identity, + ) + package_workflow_v3_1(self.solution, requested_version_bump="patch") + self.assertEqual(2, package.call_count) + state = workflow_status(self.solution) + self.assertEqual( + "patch", + state["packaging"]["lastConsumedAction"]["effectiveVersionBump"], + ) + + def test_initial_none_action_can_plan_first_partial_release(self) -> None: + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="qualification", + **self._qualification_args(), + ) + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + start_workflow_stage(self.solution, "validation") + complete_workflow_stage( + self.solution, + "validation", + status="blocked", + message="An excluded detection failed current validation.", + artifacts={"runtimeStatus": "blocked"}, + ) + plan = { + "planSha256": "b" * 64, + "summary": { + "outcome": "partial", + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": 3, + "originalSourceRuleIds": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + ], + "validatedMigratedSourceRuleCount": 2, + "validatedMigratedSourceRuleIds": [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ], + "coverageNumerator": 2, + "coverageDenominator": 3, + "partialPublicationAllowed": True, + }, + } + selection = { + "plan": plan, + "planPath": "Logs/plan.json", + "receiptPath": "Logs/applied.json", + } + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + with self.assertRaisesRegex(ValueError, "differs from the workflow's selected action"): + package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + before_plan = workflow_status(self.solution) + self.assertEqual("none", before_plan["context"]["versionBump"]) + self.assertIsNone((before_plan.get("packaging") or {}).get("pendingReleaseAction")) + self.assertEqual(0, package.call_count) + + identity = package_input_identity(self.solution)["sha256"] + self.assertEqual(0, cli_module.main([ + "workflow-plan-package-release", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--version-bump", "patch", + "--expected-package-input-sha256", identity, + ])) + after_plan = workflow_status(self.solution) + self.assertEqual("none", after_plan["context"]["versionBump"]) + self.assertEqual("patch", after_plan["packaging"]["pendingReleaseAction"]["versionBump"]) + self.assertEqual(identity, after_plan["packaging"]["pendingReleaseAction"]["packageInputSha256"]) + self.assertEqual("packaging", next_workflow_stage(self.solution)["next"]) + self.assertEqual(identity, package_input_identity(self.solution)["sha256"]) + self.assertEqual({}, after_plan["packaging"]["releaseActions"][-1]["historicalEvidence"]) + + packaged = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + + final = workflow_status(self.solution) + self.assertEqual("patch", packaged["effectiveVersionBump"]) + self.assertEqual("none", final["context"]["versionBump"]) + self.assertEqual(identity, final["packaging"]["lastConsumedAction"]["packageInputSha256"]) + self.assertEqual("consumed", final["packaging"]["releaseActions"][-1]["status"]) + self.assertIsNone(final["packaging"]["pendingReleaseAction"]) + + def _assert_initial_none_action_can_plan_first_complete_release( + self, + rule_count: int, + ) -> None: + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="qualification", + **self._qualification_args(), + ) + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + self._create_runtime_evidence() + self._complete_passed_stage("validation") + rule_ids = [ + f"00000000-0000-0000-0000-{index:012d}" + for index in range(1, rule_count + 1) + ] + summary = { + "outcome": "complete", + "coveragePolicy": "strictly-greater-than-50-percent-of-original-sentinel-rules", + "originalSourceRuleCount": rule_count, + "originalSourceRuleIds": rule_ids, + "validatedMigratedSourceRuleCount": rule_count, + "validatedMigratedSourceRuleIds": rule_ids, + "coverageNumerator": rule_count, + "coverageDenominator": rule_count, + "partialPublicationAllowed": True, + "analyticRuleIncluded": rule_count, + "xdrDetectionIncluded": rule_count, + } + plan = {"planSha256": "c" * 64, "summary": summary} + selection = { + "plan": plan, + "planPath": "Logs/plan.json", + "receiptPath": "Logs/applied.json", + } + + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + with self.assertRaisesRegex(ValueError, "differs from the workflow's selected action"): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + package.assert_not_called() + + identity = package_input_identity(self.solution)["sha256"] + self.assertEqual(0, cli_module.main([ + "workflow-plan-package-release", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--version-bump", "patch", + "--expected-package-input-sha256", identity, + ])) + planned = workflow_status(self.solution) + self.assertEqual("none", planned["context"]["versionBump"]) + self.assertEqual( + "patch", + planned["packaging"]["pendingReleaseAction"]["versionBump"], + ) + self.assertEqual( + identity, + planned["packaging"]["pendingReleaseAction"]["packageInputSha256"], + ) + self.assertEqual("packaging", next_workflow_stage(self.solution)["next"]) + + result = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + + final = workflow_status(self.solution) + self.assertEqual("patch", result["effectiveVersionBump"]) + self.assertEqual("none", final["context"]["versionBump"]) + self.assertEqual("passed", final["stages"]["validation"]["status"]) + self.assertEqual("passed", final["stages"]["packaging"]["status"]) + self.assertEqual("consumed", final["packaging"]["releaseActions"][-1]["status"]) + + def test_initial_none_action_plans_first_complete_21_rule_release(self) -> None: + self._assert_initial_none_action_can_plan_first_complete_release(21) + + def test_initial_none_action_plans_first_complete_15_rule_release(self) -> None: + self._assert_initial_none_action_can_plan_first_complete_release(15) + + def test_first_release_requires_current_applied_complete_selection_and_exact_hash(self) -> None: + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="qualification", + **self._qualification_args(), + ) + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + self._create_runtime_evidence() + self._complete_passed_stage("validation") + current_hash = package_input_identity(self.solution)["sha256"] + + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=None, + ): + with self.assertRaisesRegex(ValueError, "first release planning without a prior"): + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=current_hash, + ) + + rule_ids = ["11111111-1111-1111-1111-111111111111"] + selection = { + "plan": { + "planSha256": "d" * 64, + "summary": { + "outcome": "complete", + "originalSourceRuleCount": 1, + "originalSourceRuleIds": rule_ids, + "validatedMigratedSourceRuleCount": 1, + "validatedMigratedSourceRuleIds": rule_ids, + "coverageNumerator": 1, + "coverageDenominator": 1, + "partialPublicationAllowed": True, + }, + }, + "planPath": "Logs/plan.json", + "receiptPath": "Logs/applied.json", + } + state_path = Path(workflow_status(self.solution)["statePath"]) + before = state_path.read_bytes() + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ): + with self.assertRaisesRegex(ValueError, "package inputs changed after release action selection"): + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256="0" * 64, + ) + self.assertEqual(before, state_path.read_bytes()) + + def test_offline_authoring_validation_and_first_patch_release(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + validation = complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + state = workflow_status(self.solution) + self.assertEqual("passed", validation["status"]) + self.assertEqual("not-run", state["stages"]["validation"]["artifacts"]["runtimeStatus"]) + self.assertEqual("not-run", state["runtimeAssessment"]["runtimeStatus"]) + self.assertFalse(state["runtimeAssessment"]["fullyRuntimeQualified"]) + self.assertEqual("packaging", next_workflow_stage(self.solution)["next"]) + self.assertIn( + "Passed — offline Authoring; runtime not run", + _run_html(build_solution_report(self.solution)), + ) + + package_hash = package_input_identity(self.solution)["sha256"] + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_hash, + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + + final = workflow_status(self.solution) + self.assertEqual("patch", package["effectiveVersionBump"]) + self.assertEqual("passed", final["stages"]["packaging"]["status"]) + self.assertEqual("consumed", final["packaging"]["releaseActions"][-1]["status"]) + self.assertEqual("current", final["packageFreshness"]["status"]) + + def test_same_release_notes_refresh_rebuilds_only_approved_notes_row(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.2"}), encoding="utf-8") + aligned_header = ( + "| **Version** | **Date Modified (DD-MM-YYYY)** | " + "**Change History** |" + ) + aligned_separator = ( + "|-------------|--------------------------------|" + "------------------------------------------------------------------------------|" + ) + notes = self.solution / "ReleaseNotes.md" + notes.write_text( + aligned_header + "\n" + + aligned_separator + "\n" + + "| 3.1.1 | 01-10-2026 | Previous hybrid package refresh for reviewed detections. |\n" + + "| 3.1.0 | 01-09-2026 | Added reviewed source-rule mappings. |\n" + + "| 3.0.9 | 01-08-2026 | Improved detection migration provenance. |\n" + + "| 3.0.0 | 01-07-2026 | Initial hybrid solution release. |\n", + encoding="utf-8", + ) + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + legacy_receipt_input = package_input_identity(self.solution)["sha256"] + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + first = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + self.assertEqual( + legacy_receipt_input, + workflow_status(self.solution)["packaging"]["lastSuccessful"]["packageInputSha256"], + ) + version_before = json.loads(data.read_text(encoding="utf-8"))["Version"] + base_hash = package_input_identity(self.solution)["sha256"] + self.assertEqual(0, cli_module.main([ + "workflow-plan-release-notes-refresh", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--release-date", "12-10-2026", + "--change-history", ( + "Refreshed the V3.1 hybrid package for three disabled Defender XDR " + "Custom Detections with reviewed Host mappings and source-rule provenance." + ), + "--expected-package-input-sha256", base_hash, + ])) + planned = workflow_status(self.solution)["packaging"]["pendingReleaseAction"] + expected_row = ( + "| 3.1.2 | 12-10-2026 | Refreshed the V3.1 hybrid package for three disabled " + "Defender XDR Custom Detections with reviewed Host mappings and source-rule " + "provenance. |" + ) + original_notes = notes.read_bytes() + separator_bytes = aligned_separator.encode("utf-8") + b"\n" + self.assertIn(separator_bytes, original_notes) + inserted_notes = original_notes.replace( + separator_bytes, + separator_bytes + expected_row.encode("utf-8") + b"\n", + 1, + ) + notes.write_bytes(inserted_notes) + self.assertEqual( + hashlib.sha256(inserted_notes).hexdigest(), + planned["releaseNotesExpectedSha256"], + ) + with self.assertRaisesRegex(ValueError, "must be rebuilt with --version-bump none"): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + refreshed = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + package.assert_called_once() + self.assertEqual("none", refreshed["effectiveVersionBump"]) + self.assertEqual("3.1.2", version_before) + self.assertEqual(version_before, json.loads(data.read_text(encoding="utf-8"))["Version"]) + self.assertEqual(first["version"], refreshed["version"]) + self.assertEqual(inserted_notes, notes.read_bytes()) + self.assertNotEqual( + planned["basePackageInputSha256"], + planned["packageInputSha256"], + ) + state = workflow_status(self.solution) + self.assertEqual("current", state["packageFreshness"]["status"]) + self.assertEqual( + "consumed", + state["packaging"]["releaseActions"][-1]["status"], + ) + consumed_patch_action = state["packaging"]["lastConsumedAction"]["attemptId"] + start_workflow_stage(self.solution, "report") + complete_workflow_stage(self.solution, "report", status="passed") + final = workflow_status(self.solution) + self.assertEqual("passed", final["stages"]["report"]["status"]) + self.assertEqual("current", final["packageFreshness"]["status"]) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as repeat_package: + repeated = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + repeat_package.assert_called_once() + repeated_state = workflow_status(self.solution) + self.assertEqual("none", repeated["effectiveVersionBump"]) + self.assertEqual( + planned["packageInputSha256"], + repeated_state["packaging"]["lastSuccessful"]["packageInputSha256"], + ) + self.assertNotEqual( + consumed_patch_action, + repeated_state["packaging"]["lastConsumedAction"]["attemptId"], + ) + self.assertEqual("current", repeated_state["packageFreshness"]["status"]) + with self.assertRaisesRegex(ValueError, "already exists for unchanged inputs"): + package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + drift = self.solution / "README.md" + drift.write_text("non-notes drift\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "explicit new release action"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + release_plan = plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + self.assertEqual("patch", release_plan["versionBump"]) + + def test_same_release_notes_refresh_rejects_non_notes_changes_and_wrong_row(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "1.0.0"}), encoding="utf-8") + notes = self.solution / "ReleaseNotes.md" + notes.write_text( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** |\n" + "|---|---|---|\n" + "| 0.9.0 | 01-10-2026 | Prior release |\n", + encoding="utf-8", + ) + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + plan_release_notes_refresh( + self.solution, + release_date="12-10-2026", + change_history="Approved description", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + unrelated = self.solution / "README.md" + unrelated.write_text("unexpected change\n", encoding="utf-8") + notes.write_text( + notes.read_text(encoding="utf-8").replace( + "|---|---|---|\n", + "|---|---|---|\n| 1.0.0 | 12-10-2026 | Different description |\n", + 1, + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "package inventory changed|only the exact planned"): + package_workflow_v3_1(self.solution, requested_version_bump="none") + unrelated.unlink() + with self.assertRaisesRegex(ValueError, "does not match the exact approved refresh"): + package_workflow_v3_1(self.solution, requested_version_bump="none") + + def test_same_release_notes_refresh_repeat_build_without_campaign(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.2"}), encoding="utf-8") + notes = self.solution / "ReleaseNotes.md" + notes.write_text( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** |\n" + "|---|---|---|\n" + "| 3.1.1 | 01-10-2026 | Previous release |\n", + encoding="utf-8", + ) + initialized, _ = self._prepare_offline_authoring_validation(campaign_id=None) + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + base_hash = package_input_identity(self.solution)["sha256"] + action = plan_release_notes_refresh( + self.solution, + release_date="12-10-2026", + change_history="Approved same-release note", + expected_package_input_sha256=base_hash, + ) + state = workflow_status(self.solution) + pending = state["packaging"]["pendingReleaseAction"] + notes.write_text( + notes.read_text(encoding="utf-8").replace( + "|---|---|---|\n", + "|---|---|---|\n| 3.1.2 | 12-10-2026 | Approved same-release note |\n", + 1, + ), + encoding="utf-8", + ) + self.assertEqual(action["packageInputSha256After"], pending["packageInputSha256"]) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="none") + repeated = package_workflow_v3_1(self.solution, requested_version_bump="none") + self.assertEqual("none", repeated["effectiveVersionBump"]) + self.assertEqual("current", workflow_status(self.solution)["packageFreshness"]["status"]) + + def test_same_version_content_rebuild_archives_inputs_and_builds_without_bumping(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.1"}), encoding="utf-8") + readme = self.solution / "README.md" + readme.write_text("approved package content before the first build\n", encoding="utf-8") + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_patch_package, + ): + first = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + self.assertEqual("3.1.2", first["version"]) + original_input = readme.read_bytes() + readme.write_text("explicitly approved non-notes content fix\n", encoding="utf-8") + approved_input = readme.read_bytes() + approved_hash = package_input_identity(self.solution)["sha256"] + prior_zip = Path(first["zip"]) + prior_zip_bytes = prior_zip.read_bytes() + prior_zip.write_bytes(b"stale prior package") + with self.assertRaisesRegex(ValueError, "artifact is stale: zip"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=approved_hash, + ) + prior_zip.write_bytes(prior_zip_bytes) + planned = plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=approved_hash, + ) + state = workflow_status(self.solution) + action = state["packaging"]["pendingReleaseAction"] + self.assertEqual("same-version-content-rebuild", action["actionType"]) + self.assertEqual("none", action["versionBump"]) + self.assertEqual("3.1.2", action["targetVersion"]) + self.assertEqual(approved_hash, action["packageInputSha256"]) + self.assertEqual( + hashlib.sha256(approved_input).hexdigest(), + action["approvedChangedInputs"]["README.md"], + ) + archived_root = self.solution / planned["inputBackupRoot"] + self.assertEqual(approved_input, (archived_root / "README.md").read_bytes()) + for artifact in ( + "packageReport", "mainTemplate", "createUiDefinition", "testParameters", "zip", + ): + self.assertIn(artifact, planned["historicalEvidence"]) + self.assertTrue( + (self.solution / planned["historicalEvidence"][artifact]).is_file() + ) + + readme.write_bytes(approved_input + b"stale plan drift\n") + with self.assertRaisesRegex(ValueError, "inputs changed or no longer match"): + package_workflow_v3_1(self.solution, requested_version_bump="none") + readme.write_bytes(approved_input) + backup_readme = archived_root / "README.md" + backup_readme.write_bytes(original_input) + with self.assertRaisesRegex(ValueError, "input backup is missing or modified"): + package_workflow_v3_1(self.solution, requested_version_bump="none") + backup_readme.write_bytes(approved_input) + + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + rebuilt = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + package.assert_called_once() + self.assertEqual("none", rebuilt["effectiveVersionBump"]) + self.assertEqual("3.1.2", rebuilt["version"]) + self.assertEqual("3.1.2", json.loads(data.read_text(encoding="utf-8"))["Version"]) + self.assertEqual(approved_input, readme.read_bytes()) + state = workflow_status(self.solution) + self.assertEqual("consumed", state["packaging"]["releaseActions"][-1]["status"]) + self.assertEqual("current", state["packageFreshness"]["status"]) + + first_rebuild_attempt = state["packaging"]["lastConsumedAction"]["attemptId"] + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as repeat_package: + repeated = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + repeat_package.assert_called_once() + self.assertEqual("3.1.2", repeated["version"]) + self.assertNotEqual( + first_rebuild_attempt, + workflow_status(self.solution)["packaging"]["lastConsumedAction"]["attemptId"], + ) + self.assertEqual("current", workflow_status(self.solution)["packageFreshness"]["status"]) + + def test_same_version_content_rebuild_retry_and_scope_guards(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.1"}), encoding="utf-8") + readme = self.solution / "README.md" + readme.write_text("before\n", encoding="utf-8") + release_notes = self.solution / "ReleaseNotes.md" + release_notes.write_text("baseline notes\n", encoding="utf-8") + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_patch_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + readme.write_text("approved correction\n", encoding="utf-8") + expected_hash = package_input_identity(self.solution)["sha256"] + with self.assertRaisesRegex(ValueError, "exactly match the current solution version"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.3", + changed_inputs=["README.md"], + expected_package_input_sha256=expected_hash, + ) + with self.assertRaisesRegex(ValueError, "current input identity"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256="0" * 64, + ) + with self.assertRaisesRegex(ValueError, "ReleaseNotes"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["ReleaseNotes.md"], + expected_package_input_sha256=expected_hash, + ) + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=expected_hash, + ) + + def mutate_notes_after_v31(solution: Path, **kwargs: str) -> dict: + result = self._mock_v3_1_package(solution, **kwargs) + release_notes.write_text("unapproved during-build notes edit\n", encoding="utf-8") + return result + + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=mutate_notes_after_v31, + ): + with self.assertRaisesRegex(ValueError, "inputs changed or no longer match"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + release_notes.write_text("baseline notes\n", encoding="utf-8") + + failed_state = workflow_status(self.solution) + self.assertEqual( + expected_hash, + failed_state["packaging"]["pendingReleaseAction"]["packageInputSha256"], + ) + self.assertEqual("failed", failed_state["packaging"]["currentAttempt"]["status"]) + self.assertEqual("3.1.2", json.loads(data.read_text(encoding="utf-8"))["Version"]) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + retried = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + self.assertEqual("3.1.2", retried["version"]) + self.assertEqual("consumed", workflow_status(self.solution)["packaging"]["releaseActions"][-1]["status"]) + + def test_same_version_rebuild_requires_exact_receipt_to_current_path_diff(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.1"}), encoding="utf-8") + contents = { + "README.md": b"before\n", + "ReleaseNotes.md": b"historical notes\n", + "Hidden.txt": b"hidden before\n", + "Unchanged.txt": b"unchanged\n", + "Removed.txt": b"removed before\n", + } + for name, value in contents.items(): + (self.solution / name).write_bytes(value) + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_patch_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + + (self.solution / "README.md").write_bytes(b"approved\n") + (self.solution / "ReleaseNotes.md").write_bytes(b"unapproved notes change\n") + (self.solution / "Hidden.txt").write_bytes(b"hidden after\n") + (self.solution / "Added.txt").write_bytes(b"added\n") + (self.solution / "Removed.txt").unlink() + current_hash = package_input_identity(self.solution)["sha256"] + with self.assertRaisesRegex(ValueError, "does not permit any ReleaseNotes change"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=current_hash, + ) + + (self.solution / "ReleaseNotes.md").write_bytes(contents["ReleaseNotes.md"]) + current_hash = package_input_identity(self.solution)["sha256"] + with self.assertRaisesRegex(ValueError, "scope must exactly match"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=current_hash, + ) + + (self.solution / "Hidden.txt").write_bytes(contents["Hidden.txt"]) + (self.solution / "Added.txt").unlink() + (self.solution / "Removed.txt").write_bytes(contents["Removed.txt"]) + current_hash = package_input_identity(self.solution)["sha256"] + with self.assertRaisesRegex(ValueError, "scope must exactly match"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md", "Unchanged.txt"], + expected_package_input_sha256=current_hash, + ) + + planned = plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=current_hash, + ) + self.assertEqual( + {"README.md": hashlib.sha256(b"approved\n").hexdigest()}, + workflow_status(self.solution)["packaging"]["pendingReleaseAction"][ + "approvedChangedInputs" + ], + ) + self.assertEqual("blocked", planned["workflowStatus"]) + + def test_same_version_rebuild_reconstructs_receipt_bound_legacy_baseline(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.1"}), encoding="utf-8") + readme = self.solution / "README.md" + readme.write_bytes(b"legacy baseline\n") + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + + def legacy_package(solution: Path, **kwargs: str) -> dict: + result = self._mock_v3_1_patch_package(solution, **kwargs) + report_path = Path(result["packageReport"]) + report = json.loads(report_path.read_text(encoding="utf-8")) + report["packageInputs"]["after"].pop("files") + report_path.write_text(json.dumps(report), encoding="utf-8") + return result + + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=legacy_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + + state = workflow_status(self.solution) + prior_identity = state["packaging"]["lastSuccessful"]["packageInputSha256"] + prior_files = package_input_file_hashes(self.solution) + readme.write_bytes(b"legacy content correction\n") + current_files = package_input_file_hashes(self.solution) + current_identity = package_input_identity(self.solution)["sha256"] + evidence_path = ( + Path("Logs") + / "same-version-content-rebuild" + / "legacy-baseline-comparison.json" + ) + evidence_file = self.solution / evidence_path + evidence_file.parent.mkdir(parents=True) + evidence = { + "priorReceiptIdentity": prior_identity, + "currentIdentity": current_identity, + "priorFileCount": len(prior_files), + "currentFileCount": len(current_files), + "changedInputs": [{ + "path": "README.md", + "beforeSha256": prior_files["README.md"], + "currentSha256": current_files["README.md"], + }], + } + evidence_file.write_text(json.dumps(evidence), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "provide exact receipt-bound"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=current_identity, + ) + bad_evidence = dict(evidence) + bad_evidence["changedInputs"] = [] + (evidence_file.parent / "bad-comparison.json").write_text( + json.dumps(bad_evidence), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "not bound to the successful receipt"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=current_identity, + legacy_baseline_evidence=( + evidence_path.parent / "bad-comparison.json" + ).as_posix(), + ) + planned = plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256=current_identity, + legacy_baseline_evidence=evidence_path.as_posix(), + ) + action = workflow_status(self.solution)["packaging"]["pendingReleaseAction"] + self.assertEqual( + { + "path": evidence_path.as_posix(), + "sha256": hashlib.sha256(evidence_file.read_bytes()).hexdigest(), + }, + action["legacyBaselineEvidence"], + ) + valid_evidence_bytes = evidence_file.read_bytes() + evidence_file.write_text("changed after planning", encoding="utf-8") + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + with self.assertRaisesRegex(ValueError, "evidence changed after the rebuild plan"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + package.assert_not_called() + evidence_file.write_bytes(valid_evidence_bytes) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + rebuilt = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + self.assertEqual("3.1.2", rebuilt["version"]) + self.assertEqual("consumed", workflow_status(self.solution)["packaging"][ + "releaseActions" + ][-1]["status"]) + receipt = json.loads( + Path(rebuilt["packageReport"]).read_text(encoding="utf-8") + ) + self.assertEqual( + package_input_file_hashes(self.solution), + receipt["packageInputs"]["after"]["files"], + ) + self.assertEqual("blocked", planned["workflowStatus"]) + verified = workflow_module.verify_same_version_content_rebuild(self.solution) + self.assertEqual("verified", verified["status"]) + self.assertTrue(verified["receiptVerified"]) + self.assertFalse(verified["stateChanged"]) + + def test_same_version_content_rebuild_is_authoring_only_and_version_bound(self) -> None: + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="qualification", + **self._qualification_args(), + ) + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "only for Authoring"): + plan_same_version_content_rebuild( + self.solution, + target_version="3.1.2", + changed_inputs=["README.md"], + expected_package_input_sha256="a" * 64, + ) + + def test_same_release_notes_refresh_is_authoring_only_and_does_not_reconsume_patch(self) -> None: + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="qualification", + **self._qualification_args(), + ) + run_id = initialized["runId"] + with using_run(self.solution, run_id): + with self.assertRaisesRegex(ValueError, "only for Authoring"): + plan_release_notes_refresh( + self.solution, + release_date="12-10-2026", + change_history="Approved release note", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + + def test_same_release_notes_refresh_requires_fresh_package_receipt(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "1.0.0"}), encoding="utf-8") + notes = self.solution / "ReleaseNotes.md" + notes.write_text( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** |\n" + "|---|---|---|\n" + "| 0.9.0 | 01-10-2026 | Prior release |\n", + encoding="utf-8", + ) + initialized, _ = self._prepare_offline_authoring_validation() + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package = package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + Path(package["zip"]).write_text("tampered", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "artifact is stale: zip"): + plan_release_notes_refresh( + self.solution, + release_date="12-10-2026", + change_history="Approved release note", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + + def test_release_notes_refresh_parses_aligned_header_and_rejects_malformed_history(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.2"}), encoding="utf-8") + notes = self.solution / "ReleaseNotes.md" + aligned_header = ( + "| **Version** | **Date Modified (DD-MM-YYYY)** | " + "**Change History** |" + ) + aligned_separator = ( + "|-------------|--------------------------------|" + "------------------------------------------------------------------------------|" + ) + valid_row = "| 3.1.1 | 01-10-2026 | Existing valid history |" + malformed_cases = [ + ( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Wrong History** |", + aligned_separator, + valid_row, + "required three-column table header", + ), + ( + "| **Date Modified (DD-MM-YYYY)** | **Version** | **Change History** |", + aligned_separator, + valid_row, + "required three-column table header", + ), + ( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** | **Extra** |", + "|---|---|---|---|", + valid_row, + "required three-column table header", + ), + ( + aligned_header, + "|---|broken|---|", + valid_row, + "table separator is invalid", + ), + ( + aligned_header, + aligned_separator, + "| 3.1.1 | 32-10-2026 | Invalid date |", + "invalid DD-MM-YYYY date", + ), + ( + aligned_header, + aligned_separator, + "| 3.1.x | 01-10-2026 | Invalid version |", + "invalid semantic version", + ), + ( + aligned_header, + aligned_separator, + "| 3.1.1 | 01-10-2026 | First |\n| 3.1.1 | 01-09-2026 | Duplicate |", + "duplicate version", + ), + ( + aligned_header, + aligned_separator, + "| 3.0.0 | 01-10-2026 | Older first |\n| 3.1.1 | 01-09-2026 | Newer second |", + "not in descending order", + ), + ] + for header, separator, rows, message in malformed_cases: + with self.subTest(message=message, header=header): + notes.write_text(f"{header}\n{separator}\n{rows}\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, message): + workflow_module._release_notes_refresh_proposal( + self.solution, + release_date="12-10-2026", + change_history="Approved release history", + ) + + def test_offline_authoring_rejects_changed_source_inventory(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + source = self.solution / "Analytic Rules" / "Nested" / "Rule0.yaml" + document = yaml.safe_load(source.read_text(encoding="utf-8")) + document["query"] += "\n| where Severity == 'High'\n" + source.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "stale or unresolved|review or conversion findings"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + def test_offline_authoring_rejects_missing_converted_query(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + target = self.solution / "XDR Detections" / "Nested" / "Rule0.yaml" + document = yaml.safe_load(target.read_text(encoding="utf-8")) + document["properties"]["queryCondition"].pop("queryText") + target.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "no Advanced Hunting query"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + def test_offline_authoring_rejects_new_unconverted_inventory_item(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + self._create_source_rules(count=2) + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "complete source inventory"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + def test_offline_authoring_requires_conversion_manifest(self) -> None: + initialized, manifest = self._prepare_offline_authoring_validation() + manifest.unlink() + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "manifest"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + def _remove_conversion_manifest_hash(self, state_path: Path) -> None: + state = json.loads(state_path.read_text(encoding="utf-8")) + state["stages"]["conversion"]["artifacts"].pop("manifestSha256", None) + state_path.write_text(json.dumps(state), encoding="utf-8") + + def test_legacy_conversion_manifest_is_recovered_via_explicit_run_command(self) -> None: + initialized, manifest = self._prepare_offline_authoring_validation() + state_path = Path(initialized["statePath"]) + self._remove_conversion_manifest_hash(state_path) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr), mock.patch.object( + cli_module, + "configure_logging", + ): + result = cli_module.main([ + "workflow-recover-conversion-evidence", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + ]) + + self.assertEqual(0, result, stderr.getvalue()) + recovered = json.loads(stdout.getvalue()) + self.assertEqual("recovered", recovered["status"]) + self.assertEqual( + hashlib.sha256(manifest.read_bytes()).hexdigest(), + recovered["manifestSha256"], + ) + state = workflow_status(self.solution) + conversion_artifacts = state["stages"]["conversion"]["artifacts"] + self.assertEqual(recovered["manifestSha256"], conversion_artifacts["manifestSha256"]) + self.assertEqual( + "offline-authoring-full-revalidation", + conversion_artifacts["manifestEvidenceRecovery"], + ) + self.assertEqual("running", state["stages"]["validation"]["status"]) + + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + self.assertEqual("passed", workflow_status(self.solution)["stages"]["validation"]["status"]) + + def test_conversion_evidence_recovery_does_not_replace_a_bound_mismatch(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + state_path = Path(initialized["statePath"]) + state = json.loads(state_path.read_text(encoding="utf-8")) + state["stages"]["conversion"]["artifacts"]["manifestSha256"] = "0" * 64 + state_path.write_text(json.dumps(state), encoding="utf-8") + before = state_path.read_bytes() + + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "will not replace a bound digest"): + recover_conversion_evidence(self.solution) + + self.assertEqual(before, state_path.read_bytes()) + + def test_conversion_evidence_recovery_rejects_scoped_manifest(self) -> None: + initialized, manifest_path = self._prepare_offline_authoring_validation() + self._remove_conversion_manifest_hash(Path(initialized["statePath"])) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["scope"]["kind"] = "rule" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + state_path = Path(initialized["statePath"]) + state_before = state_path.read_bytes() + + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "complete source inventory"): + recover_conversion_evidence(self.solution) + + self.assertEqual(state_before, state_path.read_bytes()) + + def test_conversion_evidence_recovery_rejects_missing_manifest(self) -> None: + initialized, manifest_path = self._prepare_offline_authoring_validation() + state_path = Path(initialized["statePath"]) + self._remove_conversion_manifest_hash(state_path) + manifest_path.unlink() + state_before = state_path.read_bytes() + + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "manifest is missing or unsafe"): + recover_conversion_evidence(self.solution) + + self.assertEqual(state_before, state_path.read_bytes()) + + def test_conversion_evidence_recovery_rejects_inventory_drift(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + self._remove_conversion_manifest_hash(Path(initialized["statePath"])) + self._create_source_rules(count=2) + state_path = Path(initialized["statePath"]) + state_before = state_path.read_bytes() + + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "complete source inventory"): + recover_conversion_evidence(self.solution) + + self.assertEqual(state_before, state_path.read_bytes()) + + def test_conversion_evidence_recovery_rejects_source_and_technical_drift(self) -> None: + initialized, manifest_path = self._prepare_offline_authoring_validation() + state_path = Path(initialized["statePath"]) + self._remove_conversion_manifest_hash(state_path) + source = self.solution / "Analytic Rules" / "Nested" / "Rule0.yaml" + source_doc = yaml.safe_load(source.read_text(encoding="utf-8")) + source_doc["query"] += "\n| where Severity == 'High'\n" + source.write_text(yaml.safe_dump(source_doc, sort_keys=False), encoding="utf-8") + state_before = state_path.read_bytes() + + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "stale or unresolved"): + recover_conversion_evidence(self.solution) + self.assertEqual(state_before, state_path.read_bytes()) + + source_doc["query"] = yaml.safe_load(RULE)["query"] + source.write_text(yaml.safe_dump(source_doc, sort_keys=False), encoding="utf-8") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["results"][0]["errors"] = ["synthetic conversion error"] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "technical"): + recover_conversion_evidence(self.solution) + self.assertEqual(state_before, state_path.read_bytes()) + + def test_conversion_evidence_recovery_rejects_review_and_structural_drift(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + self._remove_conversion_manifest_hash(Path(initialized["statePath"])) + target = self.solution / "XDR Detections" / "Nested" / "Rule0.yaml" + document = yaml.safe_load(target.read_text(encoding="utf-8")) + document["contentProvenance"]["conversion"]["reviewRequired"] = True + target.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + state_path = Path(initialized["statePath"]) + state_before = state_path.read_bytes() + + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "review or conversion findings"): + recover_conversion_evidence(self.solution) + self.assertEqual(state_before, state_path.read_bytes()) + + document["contentProvenance"]["conversion"]["reviewRequired"] = False + document["properties"].pop("status", None) + target.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "structural validation to pass"): + recover_conversion_evidence(self.solution) + self.assertEqual(state_before, state_path.read_bytes()) + + def test_offline_authoring_rejects_missing_conversion_target(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + (self.solution / "XDR Detections" / "Nested" / "Rule0.yaml").unlink() + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "target is missing|target does not exist"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + def test_yolo_without_authoring_consent_does_not_dispatch_advanced_hunting(self) -> None: + with mock.patch.object( + runtime_module, + "validate_advanced_hunting", + side_effect=AssertionError("Advanced Hunting must not dispatch without consent"), + ) as graph_validation, mock.patch.object( + runtime_module, + "validate_sentinel_sdk", + side_effect=AssertionError("Sentinel validation must not dispatch in offline Authoring"), + ) as sentinel_validation: + result, _ = self._run_yolo(profile="authoring") + + self.assertEqual("passed", result["validationStatus"]) + self.assertEqual("not-run", result["runtimeStatus"]) + graph_validation.assert_not_called() + sentinel_validation.assert_not_called() + + def test_offline_authoring_rejects_unresolved_review_and_structural_failure(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + target = self.solution / "XDR Detections" / "Nested" / "Rule0.yaml" + document = yaml.safe_load(target.read_text(encoding="utf-8")) + document["contentProvenance"]["conversion"]["reviewRequired"] = True + target.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "review or conversion findings"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + document["contentProvenance"]["conversion"]["reviewRequired"] = False + document["properties"].pop("status", None) + target.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "structural validation to pass"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + + def test_offline_authoring_does_not_override_runtime_failure_reports(self) -> None: + initialized, _ = self._prepare_offline_authoring_validation() + target = self.solution / "XDR Detections" / "Nested" / "Rule0.yaml" + target_doc = yaml.safe_load(target.read_text(encoding="utf-8")) + with using_run(self.solution, initialized["runId"]): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + runtime_module._write_runtime_artifacts( + self.solution, + "graph", + [{ + "detection": "Nested/Rule0.yaml", + "status": "failed", + "querySha256": hashlib.sha256( + target_doc["properties"]["queryCondition"]["queryText"].encode() + ).hexdigest(), + "error": "Synthetic current KQL failure", + }], + ) + state = workflow_status(self.solution) + self.assertEqual("blocked", state["stages"]["validation"]["status"]) + self.assertIn( + "new graph provider evidence", + state["stages"]["validation"]["message"], + ) + self.assertEqual("blocked", state["runtimeAssessment"]["runtimeStatus"]) + + def test_qualification_cannot_use_offline_authoring_validation_mode(self) -> None: + initialized = initialize_workflow( + self.solution, + workflow_profile="qualification", + **self._qualification_args(), + ) + with using_run(self.solution, initialized["runId"]): + start_workflow_stage(self.solution, "discovery") + complete_workflow_stage(self.solution, "discovery", status="passed") + start_workflow_stage(self.solution, "conversion") + complete_workflow_stage(self.solution, "conversion", status="passed") + start_workflow_stage(self.solution, "validation") + with self.assertRaisesRegex(ValueError, "only for Authoring"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + with self.assertRaisesRegex(ValueError, "complete Sentinel and Advanced Hunting"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + ) + + def test_conversion_evidence_recovery_rejects_qualification(self) -> None: + initialized = initialize_workflow( + self.solution, + workflow_profile="qualification", + **self._qualification_args(), + ) + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "restricted to explicitly selected Authoring"): + recover_conversion_evidence(self.solution) + + def test_consumed_initial_release_cannot_be_replanned_and_retries_with_none(self) -> None: + data_path = self.solution / "Data" / "Solution_Sample.json" + data_path.parent.mkdir(parents=True) + data_path.write_text( + json.dumps({"Name": "Sample", "Version": "1.2.3"}), + encoding="utf-8", + ) + initialized = initialize_workflow( + self.solution, + version_bump="none", + workflow_profile="qualification", + **self._qualification_args(), + ) + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + self._create_runtime_evidence() + self._complete_passed_stage("validation") + rule_ids = ["11111111-1111-1111-1111-111111111111"] + selection = { + "plan": { + "planSha256": "e" * 64, + "summary": { + "outcome": "complete", + "originalSourceRuleCount": 1, + "originalSourceRuleIds": rule_ids, + "validatedMigratedSourceRuleCount": 1, + "validatedMigratedSourceRuleIds": rule_ids, + "coverageNumerator": 1, + "coverageDenominator": 1, + "partialPublicationAllowed": True, + "analyticRuleIncluded": 1, + "xdrDetectionIncluded": 1, + }, + }, + "planPath": "Logs/plan.json", + "receiptPath": "Logs/applied.json", + } + + def fail_after_consuming_initial_action(solution: Path, **_kwargs: str) -> dict: + data = json.loads(data_path.read_text(encoding="utf-8")) + data["Version"] = "1.2.4" + data_path.write_text(json.dumps(data), encoding="utf-8") + raise RuntimeError("synthetic first-build failure after version update") + + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + identity = package_input_identity(self.solution)["sha256"] + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=identity, + ) + + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=fail_after_consuming_initial_action, + ): + with self.assertRaisesRegex(RuntimeError, "first-build failure"): + package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + consumed = workflow_status(self.solution)["packaging"]["lastConsumedAction"] + self.assertTrue(consumed["bumpConsumed"]) + self.assertFalse(consumed["packageSucceeded"]) + + with self.assertRaisesRegex(ValueError, "initial version action was consumed by a failed build"): + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + + with using_run(self.solution, initialized["runId"]), mock.patch.object( + workflow_module, + "verify_applied_package_selection", + return_value=selection, + ), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + retry = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + self.assertEqual("none", retry["effectiveVersionBump"]) + package.assert_called_once() + + def test_stale_package_status_is_read_only_and_new_release_preserves_history(self) -> None: + run_id = self._prepare_workflow_for_package("none") + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + first = package_workflow_v3_1(self.solution, requested_version_bump="none") + original_report = Path(first["packageReport"]).read_bytes() + original_zip = Path(first["zip"]).read_bytes() + changed_input = self.solution / "XDR Detections" / "Detection.yaml" + changed_input.parent.mkdir(parents=True, exist_ok=True) + changed_input.write_text("kind: CustomDetection\n", encoding="utf-8") + + state_path = Path(workflow_status(self.solution)["statePath"]) + state_before = state_path.read_bytes() + freshness = workflow_status(self.solution)["packageFreshness"] + self.assertEqual("stale", freshness["status"]) + self.assertEqual(state_before, state_path.read_bytes()) + next_stage = next_workflow_stage(self.solution) + self.assertEqual("packaging", next_stage["next"]) + self.assertTrue(next_stage["releaseActionRequired"]) + + changed_identity = package_input_identity(self.solution)["sha256"] + with self.assertRaisesRegex(ValueError, "changed after release action selection"): + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256="0" * 64, + ) + planned = plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=changed_identity, + ) + state = workflow_status(self.solution) + self.assertEqual("blocked", state["stages"]["packaging"]["status"]) + self.assertEqual("blocked", state["stages"]["report"]["status"]) + self.assertEqual("selected", state["packaging"]["releaseActions"][-1]["status"]) + evidence = state["packaging"]["releaseActions"][-1]["historicalEvidence"] + self.assertEqual(original_report, (self.solution / evidence["packageReport"]).read_bytes()) + self.assertEqual(original_zip, (self.solution / evidence["zip"]).read_bytes()) + + rebuilt = package_workflow_v3_1(self.solution, requested_version_bump="patch") + self.assertEqual("patch", rebuilt["effectiveVersionBump"]) + self.assertIsNone(workflow_status(self.solution)["packaging"]["pendingReleaseAction"]) + self.assertEqual("consumed", workflow_status(self.solution)["packaging"]["releaseActions"][-1]["status"]) + self.assertEqual("current", workflow_status(self.solution)["packageFreshness"]["status"]) + self.assertEqual(changed_identity, planned["packageInputSha256"]) + + def test_failed_new_release_consumption_retries_without_second_bump(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir() + data.write_text(json.dumps({"Name": "Sample", "Version": "1.2.3"}), encoding="utf-8") + run_id = self._prepare_workflow_for_package("none") + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="none") + changed_input = self.solution / "XDR Detections" / "Detection.yaml" + changed_input.parent.mkdir(parents=True, exist_ok=True) + changed_input.write_text( + "kind: CustomDetection\n", encoding="utf-8" + ) + plan_new_package_release( + self.solution, + version_bump="patch", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + + def fail_after_consuming_release(solution: Path, **kwargs: str) -> dict: + document = json.loads(data.read_text(encoding="utf-8")) + document["Version"] = "1.2.4" + data.write_text(json.dumps(document), encoding="utf-8") + raise RuntimeError("synthetic failed release after version update") + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=fail_after_consuming_release, + ): + with self.assertRaisesRegex(RuntimeError, "failed release"): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + state = workflow_status(self.solution) + self.assertIsNone(state["packaging"]["pendingReleaseAction"]) + self.assertEqual("consumed", state["packaging"]["releaseActions"][-1]["status"]) + self.assertFalse(state["packaging"]["lastConsumedAction"]["packageSucceeded"]) + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + retry = package_workflow_v3_1(self.solution, requested_version_bump="none") + self.assertEqual("none", retry["effectiveVersionBump"]) + package.assert_called_once() + + def test_failed_packager_consuming_bump_allows_only_hash_matching_none_retry(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir() + data.write_text(json.dumps({"Name": "Sample", "Version": "1.2.3"}), encoding="utf-8") + run_id = self._prepare_workflow_for_package("patch") + + def fail_after_bumping(solution: Path, **kwargs: str) -> dict: + document = json.loads(data.read_text(encoding="utf-8")) + document["Version"] = "1.2.4" + data.write_text(json.dumps(document), encoding="utf-8") + raise RuntimeError("synthetic package failure after version update") + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=fail_after_bumping, + ): + with self.assertRaisesRegex(RuntimeError, "synthetic package failure"): + package_workflow_v3_1(self.solution, requested_version_bump="patch") + state = workflow_status(self.solution) + self.assertTrue(state["packaging"]["lastConsumedAction"]["bumpConsumed"]) + self.assertFalse(state["packaging"]["lastConsumedAction"]["packageSucceeded"]) + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + result = package_workflow_v3_1(self.solution, requested_version_bump="none") + self.assertEqual("none", result["effectiveVersionBump"]) + package.assert_called_once() + + def test_explicit_target_version_survives_resume_and_rebuilds_without_reapplying(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir() + data.write_text(json.dumps({"Name": "Sample", "Version": "3.0.7"}), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "cannot be combined"): + initialize_workflow( + self.solution, + version_bump="patch", + target_version="3.1.2", + workflow_profile="authoring", + ) + initialized = initialize_workflow( + self.solution, + version_bump="none", + target_version="3.1.2", + workflow_profile="authoring", + campaign_id="target-version-campaign", + ) + run_id = initialized["runId"] + for stage in ("discovery", "conversion", "validation"): + self._complete_passed_stage(stage) + + resumed = initialize_workflow( + self.solution, + version_bump="none", + target_version="3.1.2", + workflow_profile="authoring", + campaign_id="target-version-campaign", + ) + self.assertTrue(resumed["resumed"]) + self.assertEqual("3.1.2", resumed["context"]["targetVersion"]) + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + built = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.2", + ) + self.assertEqual("3.1.2", json.loads(data.read_text())["Version"]) + self.assertEqual("3.1.2", built["version"]) + self.assertEqual("3.1.2", workflow_status(self.solution)["packaging"]["lastConsumedAction"]["effectiveTargetVersion"]) + package.assert_called_once() + self.assertEqual("3.1.2", package.call_args.kwargs["target_version"]) + + resumed = initialize_workflow( + self.solution, + version_bump="none", + target_version="3.1.2", + workflow_profile="authoring", + campaign_id="target-version-campaign", + ) + self.assertEqual("3.1.2", resumed["context"]["targetVersion"]) + rebuilt = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + self.assertEqual("3.1.2", rebuilt["version"]) + self.assertIsNone(package.call_args.kwargs["target_version"]) + self.assertEqual("3.1.2", json.loads(data.read_text())["Version"]) + with self.assertRaisesRegex(ValueError, "version action was already consumed"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.3", + ) + + def test_target_release_plan_binds_exact_version_and_current_inputs(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir() + data.write_text(json.dumps({"Name": "Sample", "Version": "3.1.0"}), encoding="utf-8") + run_id = self._prepare_workflow_for_package("none") + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + package_workflow_v3_1(self.solution, requested_version_bump="none") + changed_input = self.solution / "XDR Detections" / "Detection.yaml" + changed_input.parent.mkdir(parents=True, exist_ok=True) + changed_input.write_text("kind: CustomDetection\n", encoding="utf-8") + original = changed_input.read_bytes() + planned = plan_new_package_release( + self.solution, + target_version="3.1.2", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + self.assertEqual("3.1.2", planned["targetVersion"]) + self.assertEqual("target-version", workflow_status(self.solution)["packaging"]["pendingReleaseAction"]["actionType"]) + with self.assertRaisesRegex(ValueError, "exactly one version bump or target version"): + plan_new_package_release( + self.solution, + version_bump="patch", + target_version="3.1.3", + expected_package_input_sha256=planned["packageInputSha256"], + ) + with self.assertRaisesRegex(ValueError, "differs from the explicitly selected"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.3", + ) + changed_input.write_text("kind: CustomDetection\n# input changed\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "differs from the explicitly selected"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.2", + ) + changed_input.write_bytes(original) + built = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.2", + ) + self.assertEqual("3.1.2", built["version"]) + state = workflow_status(self.solution)["packaging"] + self.assertEqual("consumed", state["releaseActions"][-1]["status"]) + self.assertEqual("3.1.2", state["releaseActions"][-1]["targetVersion"]) + + def test_target_version_remains_compatible_with_campaign_and_notes_refresh(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.0.7"}), encoding="utf-8") + notes = self.solution / "ReleaseNotes.md" + separator = "|---------|-------------------------------|----------------|\n" + notes.write_text( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** |\n" + + separator + + "| 3.1.1 | 01-10-2026 | Prior release. |\n" + + "| 3.1.0 | 01-09-2026 | Older release. |\n", + encoding="utf-8", + ) + initialized, _ = self._prepare_offline_authoring_validation() + initialized = initialize_workflow( + self.solution, + version_bump="none", + target_version="3.1.2", + workflow_profile="authoring", + campaign_id="test-campaign", + ) + run_id = initialized["runId"] + with using_run(self.solution, run_id): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={ + "runtimeStatus": "not-run", + "validationMode": "offline-authoring", + }, + ) + plan_new_package_release( + self.solution, + target_version="3.1.2", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + first = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.2", + ) + self.assertEqual("3.1.2", first["version"]) + base_hash = package_input_identity(self.solution)["sha256"] + refresh = plan_release_notes_refresh( + self.solution, + release_date="12-10-2026", + change_history="Approved target release notes.", + expected_package_input_sha256=base_hash, + ) + row = "| 3.1.2 | 12-10-2026 | Approved target release notes. |\n" + notes.write_text(notes.read_text(encoding="utf-8").replace( + separator, separator + row, 1 + ), encoding="utf-8") + self.assertEqual( + refresh["packageInputSha256After"], + package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + refreshed = package_workflow_v3_1( + self.solution, + requested_version_bump="none", + ) + self.assertEqual(first["version"], refreshed["version"]) + self.assertEqual("3.1.2", json.loads(data.read_text())["Version"]) + state = workflow_status(self.solution)["packaging"] + self.assertEqual("consumed", state["releaseActions"][-1]["status"]) + + def test_failed_target_version_write_retries_with_none_without_second_change(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir() + data.write_text(json.dumps({"Name": "Sample", "Version": "3.0.7"}), encoding="utf-8") + initialized = initialize_workflow( + self.solution, + version_bump="none", + target_version="3.1.2", + workflow_profile="authoring", + campaign_id="target-version-retry-campaign", + ) + run_id = initialized["runId"] + for stage in ("discovery", "conversion", "validation"): + self._complete_passed_stage(stage) + + def fail_after_target_write(solution: Path, **kwargs: str) -> dict: + data.write_text( + json.dumps({"Name": "Sample", "Version": kwargs["target_version"]}), + encoding="utf-8", + ) + raise RuntimeError("synthetic failure after target version write") + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=fail_after_target_write, + ): + with self.assertRaisesRegex(RuntimeError, "after target version write"): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.1.2", + ) + consumed = workflow_status(self.solution)["packaging"]["lastConsumedAction"] + self.assertTrue(consumed["bumpConsumed"]) + self.assertTrue(consumed["targetConsumed"]) + self.assertEqual("3.1.2", consumed["effectiveTargetVersion"]) + self.assertFalse(consumed["packageSucceeded"]) + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ) as package: + retry = package_workflow_v3_1(self.solution, requested_version_bump="none") + self.assertEqual("3.1.2", retry["version"]) + package.assert_called_once() + self.assertIsNone(package.call_args.kwargs["target_version"]) + self.assertEqual("3.1.2", json.loads(data.read_text())["Version"]) + + def test_stale_package_artifact_blocks_next_workflow_stage(self) -> None: + run_id = self._prepare_workflow_for_package("none") + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=self._mock_v3_1_package, + ): + result = package_workflow_v3_1(self.solution, requested_version_bump="none") + Path(result["zip"]).write_text("stale package", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "package artifact is stale: zip"): + start_workflow_stage(self.solution, "report") + + def test_v4_package_report_cannot_pass_current_v3_1_attempt(self) -> None: + run_id = self._prepare_workflow_for_package("patch") + + def old_v4_report(solution: Path, **kwargs: str) -> dict: + result = self._mock_v3_1_package(solution, **kwargs) + report_path = Path(result["packageReport"]) + report = json.loads(report_path.read_text()) + report["packager"] = "V4" + report_path.write_text(json.dumps(report), encoding="utf-8") + return result + + with using_run(self.solution, run_id), mock.patch.object( + workflow_module, + "package_solution_v3_1", + side_effect=old_v4_report, + ): + with self.assertRaisesRegex(ValueError, "does not prove V3.1"): + package_workflow_v3_1( + self.solution, + requested_version_bump="patch", + ) + state = workflow_status(self.solution) + self.assertEqual("failed", state["stages"]["packaging"]["status"]) + self.assertIsNone(state["packaging"]["lastSuccessful"]) + + def test_yolo_package_cli_routes_through_workflow_gates(self) -> None: + self._create_source_rules() + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "package_workflow_v3_1", + return_value={"packager": "V3.1"}, + ) as workflow_package: + result = cli_module.main([ + "package-v3-1", + "--solution", str(self.solution), + "--toolkit-root", str(self.solution.parent), + "--run-id", initialized["runId"], + "--version-bump", "patch", + "--legacy-baseline-evidence", + "Logs/same-version-content-rebuild/baseline.json", + ]) + + self.assertEqual(0, result) + workflow_package.assert_called_once_with( + str(self.solution), + requested_version_bump="patch", + requested_target_version=None, + toolkit_root=str(self.solution.parent), + legacy_baseline_evidence=( + "Logs/same-version-content-rebuild/baseline.json" + ), + ) + + def test_release_plan_cli_requires_run_and_routes_exact_action_identity(self) -> None: + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + ) + expected_hash = "a" * 64 + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "plan_new_package_release", + return_value={"versionBump": "patch", "packageInputSha256": expected_hash}, + ) as plan: + result = cli_module.main([ + "workflow-plan-package-release", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--version-bump", "patch", + "--expected-package-input-sha256", expected_hash, + ]) + + self.assertEqual(0, result) + plan.assert_called_once_with( + str(self.solution), + version_bump="patch", + target_version=None, + expected_package_input_sha256=expected_hash, + ) + + def test_target_version_cli_options_route_to_workflow_apis(self) -> None: + expected_hash = "b" * 64 + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "workflow_status", + return_value={"context": {"workflowProfile": "authoring"}}, + ), mock.patch.object( + cli_module, + "package_workflow_v3_1", + return_value={"packager": "V3.1"}, + ) as workflow_package: + result = cli_module.main([ + "package-v3-1", + "--solution", str(self.solution), + "--version-bump", "none", + "--target-version", "3.1.2", + ]) + self.assertEqual(0, result) + workflow_package.assert_called_once_with( + str(self.solution), + requested_version_bump="none", + requested_target_version="3.1.2", + toolkit_root=None, + legacy_baseline_evidence=None, + ) + + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + ) + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "plan_new_package_release", + return_value={"targetVersion": "3.1.2", "packageInputSha256": expected_hash}, + ) as release_plan: + result = cli_module.main([ + "workflow-plan-package-release", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--target-version", "3.1.2", + "--expected-package-input-sha256", expected_hash, + ]) + self.assertEqual(0, result) + release_plan.assert_called_once_with( + str(self.solution), + version_bump=None, + target_version="3.1.2", + expected_package_input_sha256=expected_hash, + ) + + def test_same_version_rebuild_cli_requires_run_and_exact_input_scope(self) -> None: + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + ) + expected_hash = "c" * 64 + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "plan_same_version_content_rebuild", + return_value={"version": "3.1.2", "packageInputSha256": expected_hash}, + ) as plan: + result = cli_module.main([ + "workflow-plan-same-version-content-rebuild", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--target-version", "3.1.2", + "--expected-package-input-sha256", expected_hash, + "--changed-input", "XDR Detections\\Rule.yaml", + "--changed-input", "Data\\Solution_Sample.json", + "--legacy-baseline-evidence", + "Logs\\same-version-content-rebuild\\baseline.json", + ]) + + self.assertEqual(0, result) + plan.assert_called_once_with( + str(self.solution), + target_version="3.1.2", + changed_inputs=[ + "XDR Detections\\Rule.yaml", + "Data\\Solution_Sample.json", + ], + expected_package_input_sha256=expected_hash, + legacy_baseline_evidence=( + "Logs\\same-version-content-rebuild\\baseline.json" + ), + ) + + def test_same_version_rebuild_verification_cli_requires_explicit_run(self) -> None: + initialized = initialize_workflow(self.solution, workflow_profile="authoring") + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "verify_same_version_content_rebuild", + return_value={"status": "verified", "stateChanged": False}, + ) as verify: + result = cli_module.main([ + "workflow-verify-same-version-content-rebuild", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--legacy-baseline-evidence", "Logs/baseline.json", + ]) + self.assertEqual(0, result) + verify.assert_called_once_with( + str(self.solution), + legacy_baseline_evidence="Logs/baseline.json", + ) + + def test_yolo_review_required_rule_blocks_package_without_clearing_it(self) -> None: + self._create_source_rules() + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + state = workflow_status(self.solution) + state["stages"]["validation"]["status"] = "passed" + record = { + "inventory": {"rules": [{"ruleId": "source-rule"}]}, + "results": [{ + "ruleId": "source-rule", + "status": "migrated", + "conversionStatus": "converted", + "validationStatus": "passed", + "reviewRequired": True, + "target": {"structurallyValid": True}, + "findings": [], + }], + } + with mock.patch.object( + workflow_module, + "_current_yolo_record", + return_value=(record, b""), + ): + with self.assertRaisesRegex(ValueError, "explicit-review"): + _yolo_package_readiness( + self.solution, + Path(initialized["statePath"]), + state, + ) + self.assertTrue(record["results"][0]["reviewRequired"]) + + detection_path = self.solution / "XDR Detections" / "Pending.yaml" + detection_path.parent.mkdir(parents=True) + detection_path.write_text(yaml.safe_dump({ + "contentProvenance": { + "conversion": { + "status": "converted", + "reviewRequired": True, + }, + }, + }), encoding="utf-8") + state.pop("authoring") + with self.assertRaisesRegex(ValueError, "unresolved detection review flags"): + _yolo_package_readiness( + self.solution, + Path(initialized["statePath"]), + state, + ) + + def test_yolo_best_effort_entity_mapping_remains_reviewed_but_runtime_testable(self) -> None: + source_path = self._create_source_rules()[0] + source = yaml.safe_load(source_path.read_text()) + source["query"] = ( + "DeviceEvents | where TimeGenerated > ago(1h) " + "| project TimeGenerated, DeviceName, IPAddress" + ) + source["entityMappings"] = [ + { + "entityType": "Host", + "fieldMappings": [ + {"identifier": "FullName", "columnName": "DeviceName"}, + ], + }, + { + "entityType": "IP", + "fieldMappings": [ + {"identifier": "Address", "columnName": "IPAddress"}, + ], + }, + ] + source_path.write_text(yaml.safe_dump(source, sort_keys=False), encoding="utf-8") + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + with using_run(self.solution, initialized["runId"]): + run_yolo_authoring(self.solution) + contract = read_authoring_contract(self.solution) + + result = contract["results"][0] + self.assertEqual(result["status"], "migrated") + self.assertEqual(result["conversionStatus"], "converted") + self.assertEqual(result["validationStatus"], "passed") + self.assertTrue(result["reviewRequired"]) + self.assertTrue(result["mappingAssumptions"]) + self.assertTrue(any( + finding["findingId"].startswith("review-pending-") + for finding in result["findings"] + )) + runtime_status, _, _ = workflow_module._current_runtime_status( + self.solution, "authoring", contract + ) + self.assertEqual(runtime_status, "not-run") + + def test_blocked_stage_requires_explanation(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + start_workflow_stage(self.solution, "discovery") + + with self.assertRaisesRegex(ValueError, "require a message"): + complete_workflow_stage( + self.solution, + "discovery", + status="blocked", + ) + + def test_validation_cannot_pass_without_current_exact_query_evidence(self) -> None: + initialize_workflow(self.solution, workflow_profile="qualification", **self._qualification_args()) + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + self._create_runtime_evidence() + graph_report = artifact_path(self.solution, "runtime-validation.graph.json") + report = json.loads(graph_report.read_text()) + report["results"][0]["querySha256"] = "0" * 64 + graph_report.write_text(json.dumps(report)) + start_workflow_stage(self.solution, "validation") + + with self.assertRaisesRegex(ValueError, "every current query"): + complete_workflow_stage(self.solution, "validation", status="passed") + + def test_environment_blocked_authoring_cannot_hide_current_kql_failure(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + self._create_runtime_evidence() + source_report = artifact_path(self.solution, "runtime-validation.triage-mcp.json") + report = json.loads(source_report.read_text()) + report["results"][0]["status"] = "failed" + report["results"][0]["error"] = "Failed to resolve table" + source_report.write_text(json.dumps(report)) + start_workflow_stage(self.solution, "validation") + + with self.assertRaisesRegex(ValueError, "cannot be relabeled environment-blocked"): + complete_workflow_stage( + self.solution, + "validation", + status="passed", + message="environment unavailable", + artifacts={"runtimeStatus": "environment-blocked"}, + ) + + def test_lookback_rewrite_invalidates_completed_runtime_and_downstream_stages(self) -> None: + initialize_workflow(self.solution, workflow_profile="qualification", **self._qualification_args()) + source_path = self.solution / "Analytic Rules" / "Rule.yaml" + source_path.parent.mkdir() + source = yaml.safe_load(RULE) + source["queryPeriod"] = "4h" + source["query"] = ( + "DeviceEvents | summarize Count=count() " + "by TimeGenerated, AccountUpn, IPAddress" + ) + source_path.write_text(yaml.safe_dump(source, sort_keys=False), encoding="utf-8") + convert_solution(self.solution) + self._complete_passed_stage("discovery") + self._complete_passed_stage("conversion") + self._create_runtime_evidence() + self._complete_passed_stage("validation") + self._complete_passed_stage("packaging") + + source = yaml.safe_load(source_path.read_text()) + source["queryPeriod"] = "2h" + source_path.write_text(yaml.safe_dump(source, sort_keys=False), encoding="utf-8") + converted = convert_solution(self.solution, overwrite=True) + self.assertEqual(1, converted["converted"]) + target_query = yaml.safe_load( + (self.solution / "XDR Detections/Rule.yaml").read_text() + )["properties"]["queryCondition"]["queryText"] + self.assertIn("Timestamp >= ago(2h)", target_query) + + stages = workflow_status(self.solution)["stages"] + self.assertEqual("blocked", stages["validation"]["status"]) + self.assertIn("previous runtime evidence is historical", stages["validation"]["message"]) + self.assertEqual("blocked", stages["packaging"]["status"]) + report = build_solution_report(self.solution) + runtime = report["rules"][0]["customDetection"]["runtime"] + self.assertTrue(runtime) + self.assertEqual("blocked", runtime[0]["status"]) + sentinel_runtime = report["rules"][0]["analyticRule"]["runtime"] + self.assertEqual("passed", sentinel_runtime[0]["status"]) + self.assertEqual("matches", sentinel_runtime[0]["queryHashStatus"]) + + def test_yolo_freezes_complete_nested_inventory_and_keeps_authoring_opt_in(self) -> None: + paths = self._create_source_rules(2) + with self.assertRaisesRegex(ValueError, "profile selection is required"): + initialize_workflow(self.solution, yolo=True) + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + self.assertTrue(initialized["resumed"] is False) + self.assertEqual(1, initialized["authoring"]["currentContract"]["revision"]) + with using_run(self.solution, initialized["runId"]): + record = read_authoring_contract(self.solution) + self.assertEqual(2, record["coverage"]["N"]) + self.assertEqual( + {path.relative_to(self.solution).as_posix() for path in paths}, + {rule["path"] for rule in record["inventory"]["rules"]}, + ) + self.assertEqual("unprocessed", record["results"][0]["status"]) + self.assertEqual(2, record["coverage"]["untested"]) + self.assertFalse(record["operationEligibility"]["packagingAuthorized"]) + + def test_yolo_budget_stop_preserves_coverage_and_cannot_be_cleared_by_extension(self) -> None: + self._create_source_rules() + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + with using_run(self.solution, initialized["runId"]): + original_measured_operation = workflow_module.measured_operation + + def fail_conversion_budget(*args, **kwargs): + if kwargs.get("dimension") == "conversionPass": + raise BudgetExhausted("conversion pass ceiling reached") + return original_measured_operation(*args, **kwargs) + + with mock.patch.object( + workflow_module, + "measured_operation", + side_effect=fail_conversion_budget, + ), mock.patch.object(workflow_module, "convert_solution") as converter: + result = run_yolo_authoring(self.solution) + converter.assert_not_called() + self.assertEqual("budget-exhausted", result["status"]) + self.assertFalse(result["workPerformed"]) + state = workflow_status(self.solution) + self.assertEqual("blocked", state["stages"]["conversion"]["status"]) + self.assertEqual("stopped", state["authoring"]["status"]) + self.assertEqual(0, state["authoringSummary"]["coverage"]["E"]) + self.assertEqual(1, state["authoringSummary"]["coverage"]["N"]) + self.assertEqual( + 0, + budget_summary(self.solution, "test-campaign")["usage"]["conversionPasses"], + ) + extend_budget( + self.solution, + "test-campaign", + dimension="conversionPasses", + new_limit=4, + actor="owner", + reason="approved budget extension", + evidence_reference="approval-4", + ) + self.assertEqual("budget-exhausted", run_yolo_authoring(self.solution)["status"]) + + def test_yolo_replacement_run_links_the_persisted_campaign(self) -> None: + self._create_source_rules() + original = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + replacement_directory = create_run(self.solution) + with using_run(self.solution, replacement_directory.name): + replacement = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + state = workflow_status(self.solution) + self.assertEqual(original["budget"]["inventorySha256"], replacement["budget"]["inventorySha256"]) + self.assertEqual( + [original["runId"], replacement["runId"]], + state["budgetSummary"]["linkedRunIds"], + ) + self.assertEqual( + state["budgetSummary"]["campaignId"], + replacement["budget"]["campaignId"], + ) + operation = begin_operation( + self.solution, + "test-campaign", + inventory_sha256=replacement["budget"]["inventorySha256"], + run_id=replacement["runId"], + dimension="conversionPass", + ) + finish_operation( + self.solution, + "test-campaign", + operation["operationId"], + status="failed", + elapsed_seconds=0.25, + ) + with using_run(self.solution, replacement["runId"]): + self.assertEqual( + 1, + workflow_status(self.solution)["budgetSummary"]["usage"]["conversionPasses"], + ) + + def test_yolo_campaign_inventory_conflict_does_not_create_a_partial_run(self) -> None: + self._create_source_rules() + initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + before = list_runs(self.solution) + source = self.solution / "Analytic Rules" / "Nested" / "Rule0.yaml" + source.write_text(source.read_text(encoding="utf-8") + "\n# changed\n", encoding="utf-8") + + with self.assertRaisesRegex(BudgetReceiptError, "different frozen source inventory"): + initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + new_run=True, + ) + + self.assertEqual(before, list_runs(self.solution)) + + def test_qualification_profile_is_explicit_and_retains_locked_target(self) -> None: + self._create_source_rules() + with self.assertRaisesRegex(ValueError, "locked target context"): + initialize_workflow( + self.solution, + workflow_profile="qualification", + yolo=True, + campaign_id="test-campaign", + ) + created = initialize_workflow( + self.solution, + workflow_profile="qualification", + yolo=True, + campaign_id="test-campaign", + **self._qualification_args(), + ) + self.assertTrue(created["context"]["profileSelectionConfirmed"]) + self.assertTrue(created["context"]["targetContextLocked"]) + self.assertEqual("qualification", created["context"]["workflowProfile"]) + + def test_existing_legacy_run_is_not_implicitly_upgraded_to_yolo(self) -> None: + initialize_workflow(self.solution, workflow_profile="authoring") + self.assertNotIn("authoring", workflow_status(self.solution)) + with self.assertRaisesRegex(ValueError, "cannot be enabled on an existing legacy run"): + initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + resumed = initialize_workflow(self.solution, workflow_profile="authoring") + self.assertTrue(resumed["resumed"]) + self.assertNotIn("authoring", resumed) + + def test_changed_frozen_source_inventory_is_rejected_without_overwriting_edits(self) -> None: + self._create_source_rules() + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + source = self.solution / "Analytic Rules" / "Nested" / "Rule0.yaml" + edited = source.read_text(encoding="utf-8") + "\n# preserve owner edit\n" + source.write_text(edited, encoding="utf-8") + with using_run(self.solution, initialized["runId"]): + with self.assertRaisesRegex(ValueError, "frozen YOLO source inventory is stale"): + run_yolo_authoring(self.solution) + self.assertEqual(edited, source.read_text(encoding="utf-8")) + self.assertFalse((self.solution / "XDR Detections" / "Nested" / "Rule0.yaml").exists()) + + def test_changed_generated_target_is_rejected_and_preserved_on_refresh(self) -> None: + _, record = self._run_yolo(count=1) + target = self.solution / record["results"][0]["target"]["path"] + edited = target.read_text(encoding="utf-8") + "\n# preserve owner edit\n" + target.write_text(edited, encoding="utf-8") + with using_run(self.solution, record["runId"]): + with self.assertRaisesRegex(ValueError, "generated target hash is stale"): + refresh_yolo_authoring(self.solution) + self.assertEqual(edited, target.read_text(encoding="utf-8")) + + def test_synthetic_yolo_flow_reuses_existing_converter_validator_and_never_packages(self) -> None: + result, record = self._run_yolo(count=1) + self.assertEqual(1, record["coverage"]["N"]) + self.assertEqual(1, record["coverage"]["M"]) + self.assertEqual("migrated", record["results"][0]["status"]) + self.assertEqual("passed", result["validationStatus"]) + self.assertEqual("packaging", result["next"]) + self.assertFalse(result["packageAuthorized"]) + self.assertFalse(result["qualificationAuthorized"]) + self.assertFalse(result["prCreationAuthorized"]) + stages = workflow_status( + self.solution + )["stages"] + self.assertEqual("passed", stages["conversion"]["status"]) + self.assertEqual("passed", stages["validation"]["status"]) + self.assertEqual("pending", stages["packaging"]["status"]) + + def test_yolo_stop_is_durable_after_later_results_improve(self) -> None: + result, record = self._run_yolo(count=3) + self.assertEqual(3, record["coverage"]["M"]) + self.assertEqual("active", result["status"]) + with using_run(self.solution, record["runId"]): + self._runtime_reports( + record, + failed_graph_rules={ + record["results"][0]["ruleId"], + record["results"][1]["ruleId"], + }, + ) + stopped = refresh_yolo_authoring(self.solution) + self.assertEqual("stopped", stopped["status"]) + self.assertEqual((3, 1, 2), ( + stopped["coverage"]["N"], + stopped["coverage"]["M"], + stopped["coverage"]["E"], + )) + first_state = workflow_status(self.solution) + self.assertEqual("blocked", first_state["workflowStatus"]) + self.assertIsNone(first_state["next"]) + self._runtime_reports(record) + improved = refresh_yolo_authoring(self.solution) + self.assertEqual((3, 3, 0), ( + improved["coverage"]["N"], + improved["coverage"]["M"], + improved["coverage"]["E"], + )) + self.assertTrue(improved["coverage"]["migrationThresholdMet"]) + self.assertEqual("stopped", improved["status"]) + self.assertEqual("blocked", improved["workflowStatus"]) + self.assertIsNone(improved["next"]) + no_resume = run_yolo_authoring(self.solution) + self.assertFalse(no_resume["workPerformed"]) + self.assertEqual("stopped", no_resume["status"]) + + def test_shared_provider_error_is_recorded_without_inventing_rule_errors(self) -> None: + result, record = self._run_yolo(count=2) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + (run / "runtime-validation.graph.json").write_text( + json.dumps({ + "provider": "graph", + "providerFailure": {"failureKind": "provider", "error": "Graph unavailable"}, + "results": [], + }), + encoding="utf-8", + ) + with using_run(self.solution, record["runId"]): + refreshed = refresh_yolo_authoring(self.solution) + stored = read_authoring_contract(self.solution) + self.assertEqual(1, refreshed["environmentErrorCount"]) + self.assertEqual(0, stored["coverage"]["E"]) + self.assertEqual(2, stored["coverage"]["N"]) + self.assertEqual(1, len(stored["environmentErrors"])) + + def test_yolo_refresh_uses_complete_sdk_source_family(self) -> None: + _, record = self._run_yolo(count=1) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + self._runtime_reports(record) + triage_path = run / "runtime-validation.triage-mcp.json" + sdk_report = json.loads(triage_path.read_text(encoding="utf-8")) + triage_path.unlink() + sdk_report.update({ + "provider": "azure-monitor-sdk", + "platform": "Microsoft Sentinel Log Analytics (Azure Monitor Query SDK)", + "familyComplete": True, + }) + (run / "runtime-validation.azure-monitor-sdk.json").write_text( + json.dumps(sdk_report), + encoding="utf-8", + ) + with using_run(self.solution, record["runId"]): + refreshed = refresh_yolo_authoring(self.solution) + stored = read_authoring_contract(self.solution) + self.assertEqual("active", refreshed["status"]) + self.assertEqual(0, refreshed["environmentErrorCount"]) + self.assertEqual("passed", stored["results"][0]["validationStatus"]) + + def test_yolo_identityinfo_failure_is_nonblocking_but_not_runtime_passed(self) -> None: + _, record = self._run_yolo(count=1) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + self._runtime_reports(record) + triage_path = run / "runtime-validation.triage-mcp.json" + source_report = json.loads(triage_path.read_text(encoding="utf-8")) + diagnostic = ( + "'summarize' operator: Failed to resolve table or column " + "expression named 'IdentityInfo'" + ) + source_report.update({ + "querySurface": "microsoft-sentinel-triage", + "familyComplete": True, + }) + source_report["results"][0].update({ + "status": "failed", + "statusCode": 400, + "failureKind": "query", + "partial": False, + "error": { + "code": "BadArgumentError", + "message": diagnostic, + "innererror": { + "code": "SEM0100", + "message": diagnostic, + }, + }, + }) + raw_before = json.dumps(source_report, sort_keys=True).encode() + triage_path.write_bytes(raw_before) + + graph_path = run / "runtime-validation.graph.json" + graph_report = json.loads(graph_path.read_text(encoding="utf-8")) + graph_report.update({ + "querySurface": "defender-advanced-hunting", + "familyComplete": True, + }) + graph_report["results"][0]["schema"] = [ + {"name": "Timestamp", "type": "DateTime"} + ] + graph_path.write_text(json.dumps(graph_report), encoding="utf-8") + + with using_run(self.solution, record["runId"]): + refreshed = refresh_yolo_authoring(self.solution) + stored = read_authoring_contract(self.solution) + evidence = workflow_module._current_runtime_status( + self.solution, "authoring", stored + )[2] + result = stored["results"][0] + self.assertEqual("active", refreshed["status"]) + self.assertEqual("passed", workflow_status(self.solution)["stages"]["validation"]["status"]) + self.assertEqual( + "not-run", + workflow_status(self.solution)["stages"]["validation"]["artifacts"]["runtimeStatus"], + ) + self.assertEqual( + "source-waived", + workflow_status(self.solution)["runtimeAssessment"]["runtimeStatus"], + ) + self.assertFalse(evidence["passed"]) + self.assertEqual("source-waived", evidence["sourceStatus"]) + self.assertTrue(any( + finding["kind"] == "error" + and finding["unresolved"] is False + and "IdentityInfo" in finding["message"] + for finding in result["findings"] + )) + self.assertEqual(raw_before, triage_path.read_bytes()) + self.assertEqual("failed", json.loads(triage_path.read_text())["results"][0]["status"]) + + def test_direct_yolo_source_waiver_rejects_changed_target_review_state(self) -> None: + _, record = self._run_yolo(count=1) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + self._runtime_reports(record) + triage_path = run / "runtime-validation.triage-mcp.json" + source_report = json.loads(triage_path.read_text(encoding="utf-8")) + diagnostic = ( + "'summarize' operator: Failed to resolve table or column " + "expression named 'IdentityInfo'" + ) + source_report.update({ + "querySurface": "microsoft-sentinel-triage", + "familyComplete": True, + }) + source_report["results"][0].update({ + "status": "failed", + "statusCode": 400, + "failureKind": "query", + "partial": False, + "error": { + "code": "BadArgumentError", + "message": diagnostic, + "innererror": {"code": "SEM0100", "message": diagnostic}, + }, + }) + triage_path.write_text(json.dumps(source_report), encoding="utf-8") + graph_path = run / "runtime-validation.graph.json" + graph_report = json.loads(graph_path.read_text(encoding="utf-8")) + graph_report.update({ + "querySurface": "defender-advanced-hunting", + "familyComplete": True, + }) + graph_report["results"][0]["schema"] = [ + {"name": "Timestamp", "type": "DateTime"} + ] + graph_path.write_text(json.dumps(graph_report), encoding="utf-8") + + target = Path(record["results"][0]["target"]["path"]) + target_path = self.solution / target + target_document = yaml.safe_load(target_path.read_text(encoding="utf-8")) + target_query = target_document["properties"]["queryCondition"]["queryText"] + target_document["contentProvenance"]["conversion"].update({ + "reviewRequired": True, + "errors": ["new current-target conversion error"], + }) + target_path.write_text(yaml.safe_dump(target_document), encoding="utf-8") + self.assertEqual( + target_query, + yaml.safe_load(target_path.read_text(encoding="utf-8"))[ + "properties" + ]["queryCondition"]["queryText"], + ) + + with using_run(self.solution, record["runId"]): + with self.assertRaisesRegex(ValueError, "generated target hash is stale"): + workflow_module._validate_current_yolo_targets( + self.solution, + record, + ) + + def test_yolo_sdk_source_family_rejects_stale_query_hash(self) -> None: + _, record = self._run_yolo(count=1) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + self._runtime_reports(record) + triage_path = run / "runtime-validation.triage-mcp.json" + sdk_report = json.loads(triage_path.read_text(encoding="utf-8")) + triage_path.unlink() + sdk_report.update({ + "provider": "azure-monitor-sdk", + "familyComplete": True, + }) + sdk_report["results"][0]["querySha256"] = "0" * 64 + (run / "runtime-validation.azure-monitor-sdk.json").write_text( + json.dumps(sdk_report), + encoding="utf-8", + ) + with using_run(self.solution, record["runId"]): + with self.assertRaisesRegex(ValueError, "stale azure-monitor-sdk query hash"): + refresh_yolo_authoring(self.solution) + + def test_concurrent_refresh_serializes_contract_writers(self) -> None: + _, record = self._run_yolo(count=1) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + (run / "runtime-validation.graph.json").write_text( + json.dumps({ + "provider": "graph", + "providerFailure": {"failureKind": "provider", "error": "Graph unavailable"}, + "results": [], + }), + encoding="utf-8", + ) + + def refresh(): + with using_run(self.solution, record["runId"]): + return refresh_yolo_authoring(self.solution) + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(lambda _: refresh(), range(2))) + self.assertEqual({3}, {item["authoringContract"]["revision"] for item in outcomes}) + snapshots = list((run / "authoring-contracts").glob("*.json")) + self.assertEqual(3, len(snapshots)) + + def test_interrupted_snapshot_commit_recovers_without_losing_stop_or_results(self) -> None: + _, record = self._run_yolo(count=1) + run = self.solution / "Logs" / "sentinel-xdr-migration" / record["runId"] + (run / "runtime-validation.graph.json").write_text( + json.dumps({ + "provider": "graph", + "providerFailure": {"failureKind": "provider", "error": "Graph unavailable"}, + "results": [], + }), + encoding="utf-8", + ) + original_write = workflow_module._atomic_write + writes = 0 + + def fail_state_after_snapshot(path: Path, content: bytes): + nonlocal writes + writes += 1 + if writes == 2: + raise OSError("simulated crash before workflow pointer update") + return original_write(path, content) + + with using_run(self.solution, record["runId"]): + with mock.patch.object(workflow_module, "_atomic_write", side_effect=fail_state_after_snapshot): + with self.assertRaisesRegex(OSError, "simulated crash"): + refresh_yolo_authoring(self.solution) + recovered = refresh_yolo_authoring(self.solution) + self.assertEqual(3, recovered["authoringContract"]["revision"]) + state = workflow_status(self.solution) + self.assertEqual(recovered["authoringContract"], state["authoring"]["currentContract"]) + self.assertEqual(1, recovered["environmentErrorCount"]) + + def test_atomic_state_write_failure_leaves_previous_state_and_no_partial_file(self) -> None: + self._create_source_rules() + created = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="test-campaign", + ) + with using_run(self.solution, created["runId"]): + state_path = Path(workflow_status(self.solution)["statePath"]) + before = state_path.read_bytes() + with mock.patch.object(workflow_module.os, "replace", side_effect=OSError("replace failed")): + with self.assertRaisesRegex(OSError, "replace failed"): + start_workflow_stage(self.solution, "discovery") + self.assertEqual(before, state_path.read_bytes()) + self.assertEqual([], list(state_path.parent.glob(".workflow-state.json.*.tmp"))) + + def test_cli_yolo_is_explicit_and_requires_an_exact_run_id(self) -> None: + from sentinel_xdr_migration.cli import main + + self._create_source_rules() + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + init_code = main([ + "workflow-init", + "--solution", str(self.solution), + "--workflow-profile", "authoring", + "--yolo", + "--campaign-id", "test-campaign", + ]) + self.assertEqual(0, init_code, stderr.getvalue()) + initialized = json.loads(stdout.getvalue()) + self.assertEqual("yolo", initialized["authoring"]["mode"]) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + missing_run_code = main([ + "workflow-yolo", + "--solution", str(self.solution), + ]) + self.assertEqual(2, missing_run_code) + self.assertIn("explicit --run-id", stderr.getvalue()) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + run_code = main([ + "workflow-yolo", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + ]) + self.assertEqual(0, run_code) + outcome = json.loads(stdout.getvalue()) + self.assertEqual("active", outcome["status"]) + self.assertEqual(1, outcome["coverage"]["M"]) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr), mock.patch( + "sentinel_xdr_migration.cli.export_evidence", + return_value={"exported": True}, + ) as export: + export_code = main([ + "export-evidence", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--include-authoring-contract", + ]) + self.assertEqual(0, export_code, stderr.getvalue()) + self.assertTrue(export.call_args.kwargs["authoring_contract"]["inventory"]["complete"]) + self.assertEqual({"exported": True}, json.loads(stdout.getvalue())) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_xdr_description_counts.py b/Tools/SentinelToXDRMigration/tests/test_xdr_description_counts.py new file mode 100644 index 00000000000..57b2953c850 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_xdr_description_counts.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import re +import shutil +import unittest +import zipfile + +import test_customer_usage_attribution as attribution + + +@unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") +class XdrDescriptionCountTests(unittest.TestCase): + setUp = attribution.AttributionTests.setUp + run_ps = attribution.AttributionTests.run_ps + prepare_integration = attribution.AttributionTests.prepare_integration + package = attribution.AttributionTests.package + write_metadata = attribution.AttributionTests.write_metadata + + def configure(self, xdr_count, *, analytics=True, hunting=False, registration=False): + data = json.loads(self.data.read_text()) + data["Analytic Rules"] = ["Analytic Rules/Test.yaml"] if analytics else [] + data["Hunting Queries"] = ["Hunting Queries/Test.yaml"] if hunting else [] + data["Include XDR Content Registration"] = registration + # Count-like author text must not be mistaken for the generated inventory. + data["Description"] = "Test package: **Analytic Rules:** 99, keep this text." + detection = json.loads( + (self.solution / "XDR Detections/Test.yaml").read_text().removeprefix("---\n") + ) + data["XDR Detections"] = [] + rule = json.loads( + (self.solution / "Analytic Rules/Test.yaml").read_text().removeprefix("---\n") + ) + if xdr_count: + data["Analytic Rules"] = [] + for index in range(xdr_count): + detection["properties"]["id"] = f"{index + 2:08d}-2222-3333-4444-555555555555" + detection["contentProvenance"]["source"]["id"] = detection["properties"]["id"] + rule["id"] = detection["properties"]["id"] + source = f"Analytic Rules/Count{index}.yaml" + (self.solution / source).write_text("---\n" + json.dumps(rule)) + data["Analytic Rules"].append(source) + relative = f"XDR Detections/Count{index}.yaml" + (self.solution / relative).write_text("---\n" + json.dumps(detection)) + data["XDR Detections"].append(relative) + path = self.solution / "Hunting Queries/Test.yaml" + path.parent.mkdir(exist_ok=True) + path.write_text("---\n" + json.dumps({ + "id": "33333333-2222-3333-4444-555555555555", "name": "Test hunt", + "description": "Packaging test.", "query": "print Test=1", + "version": "1.0.0", "requiredDataConnectors": [], + })) + self.data.write_text(json.dumps(data)) + + def assert_summary(self, expected): + package = self.solution / "Package" + ui = json.loads((package / "createUiDefinition.json").read_text(encoding="utf-8-sig")) + template = json.loads((package / "mainTemplate.json").read_text(encoding="utf-8-sig")) + description = ui["parameters"]["config"]["basics"]["description"] + data = json.loads(self.data.read_text()) + self.assertIn(data["Description"] + "\n\n" + expected + "\n\n[Learn more", description) + self.assertNotRegex(description, r"\{\{|\}\}|, \n|, ,") + self.assertNotIn("XDR Custom Detections:", description) + content_packages = [ + resource for resource in template["resources"] + if resource["type"] == "Microsoft.OperationalInsights/workspaces/providers/contentPackages" + ] + self.assertTrue(content_packages) + for resource in content_packages: + html = resource["properties"]["descriptionHtml"] + expected_html = re.sub(r"\*\*(.+?)\*\*", r"\1", expected) + if expected: + self.assertIn(f"

    {expected_html}

    ", html) + else: + self.assertNotIn("XDR Detections:", html) + self.assertNotRegex(html, r"\{\{|\}\}|,

    |, ,") + self.assertIn("keep this text.", html) + self.assertNotIn("xdr", json.dumps([ + step for step in ui["parameters"]["steps"] if step.get("name") != "contentSelection" + ]).lower()) + self.assertNotIn("E5Flavor", json.dumps(ui)) + with zipfile.ZipFile(package / f"{template['variables']['_solutionVersion']}.zip") as archive: + self.assertEqual(ui, json.loads(archive.read("createUiDefinition.json").decode("utf-8-sig"))) + self.assertEqual(template, json.loads(archive.read("mainTemplate.json").decode("utf-8-sig"))) + return description, [r["properties"]["descriptionHtml"] for r in content_packages], template + + def test_local_and_pipeline_counts_and_registration(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + cases = ( + (0, True, True), + (0, False, False), + (1, True, False), + (3, True, True), + (3, True, False), + ) + for count, analytics, hunting in cases: + expected = ", ".join( + f"**{label}:** {number}" + for label, number in ( + ("Analytic Rules", max(int(analytics), count)), ("XDR Detections", count), + ("Hunting Queries", int(hunting)), + ) if number + ) + for registration in (False, True): + self.configure(count, analytics=analytics, hunting=hunting, registration=registration) + sources = { + path: path.read_bytes() for path in self.solution.rglob("*") + if path.is_file() and "Package" not in path.parts + } + for pipeline in (False, True): + with self.subTest(count=count, analytics=analytics, hunting=hunting, + registration=registration, pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + _, _, template = self.assert_summary(expected) + deployments = [r for r in template["resources"] + if r["type"] == "Microsoft.Resources/deployments"] + self.assertEqual(count + 1, len(deployments)) + self.assertIn(attribution.AttributionTests.marker(attribution.TRACKING_ID), + deployments) + registered = [r for r in template["resources"] + if r.get("properties", {}).get("contentKind") == "CustomDetection"] + self.assertEqual(count, len(registered)) + for deployment in deployments: + if deployment["name"] != attribution.TRACKING_ID: + self.assertEqual("disabled", deployment["properties"]["template"] + ["resources"]["detectionRule"]["properties"]["status"]) + self.assertEqual(sources, {path: path.read_bytes() for path in sources}) + + def test_zero_and_legacy_descriptions_are_unchanged(self): + entry = self.prepare_integration() + self.write_metadata() + self.configure(0, hunting=True) + expected = "**Analytic Rules:** 1, **Hunting Queries:** 1" + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + baseline = self.assert_summary(expected)[:2] + data = json.loads(self.data.read_text()) + data.pop("XDR Detections") + self.data.write_text(json.dumps(data)) + for pipeline in (False, True): + with self.subTest(absent_xdr=True, pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(baseline, self.assert_summary(expected)[:2]) + for relative, pipeline in ( + ("V3/createSolutionV3.ps1", False), + ("pipeline/createSolutionV4.ps1", True), + ): + legacy = entry.with_name("legacy.ps1") + shutil.copyfile(attribution.PACKAGER / relative, legacy) + # Even with XDR inputs present, legacy paths must not add this display count. + self.configure(1, hunting=True) + with self.subTest(entry=relative): + result = self.package(legacy, pipeline, version_bump="patch", + calculated_version="3.1.1") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(baseline, self.assert_summary(expected)[:2]) + + def test_failed_detection_generation_does_not_publish_a_partial_count(self): + entry = self.prepare_integration() + self.write_metadata(attribution.TRACKING_ID) + self.configure(1) + result = self.package(entry, False) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assert_summary("**Analytic Rules:** 1, **XDR Detections:** 1") + outputs = {path: path.read_bytes() for path in (self.solution / "Package").iterdir()} + data = json.loads(self.data.read_text()) + data["XDR Detections"].append("XDR Detections/Missing.yaml") + self.data.write_text(json.dumps(data)) + for pipeline in (False, True): + with self.subTest(pipeline=pipeline): + result = self.package(entry, pipeline) + self.assertNotEqual(0, result.returncode) + self.assertIn("Custom Detection file not found", result.stdout + result.stderr) + self.assertEqual(outputs, {path: path.read_bytes() for path in outputs}) + + def test_summary_insertion_handles_empty_and_trailing_categories(self): + cases = ( + ("", "**XDR Detections:** 2"), + ("**Analytic Rules:** 15", "**Analytic Rules:** 15, **XDR Detections:** 2"), + ("**Hunting Queries:** 15", "**XDR Detections:** 2, **Hunting Queries:** 15"), + ("**Data Connectors:** 1, **Parsers:** 2, **Workbooks:** 3, ", + "**Data Connectors:** 1, **Parsers:** 2, **Workbooks:** 3, **XDR Detections:** 2"), + ("**Analytic Rules:** 15, **Hunting Queries:** 15, **Watchlists:** 1, ", + "**Analytic Rules:** 15, **XDR Detections:** 2, **Hunting Queries:** 15, **Watchlists:** 1"), + ("**Summary Rules:** 1, **Playbooks:** 2", + "**XDR Detections:** 2, **Summary Rules:** 1, **Playbooks:** 2"), + ("**Custom Azure Logic Apps Connectors:** 1, **Function Apps:** 2, **Playbooks:** 3", + "**XDR Detections:** 2, **Custom Azure Logic Apps Connectors:** 1, **Function Apps:** 2, **Playbooks:** 3"), + ) + common = attribution.ps_literal(attribution.PACKAGER / "common/commonFunctions.ps1") + for summary, expected in cases: + for newline in ("\n", "\r\n"): + with self.subTest(summary=summary, newline=repr(newline)): + prefix = "keep before" + newline * 2 + "{{SolutionDescription}}" + newline * 2 + suffix = newline * 2 + "keep after" + result = self.run_ps( + f". {common}; " + "$global:baseCreateUiDefinition.parameters.config.basics.description = " + f"{attribution.ps_literal(prefix + summary + suffix)}; " + "Update-XdrDescriptionCount -Count 2; " + "$global:baseCreateUiDefinition.parameters.config.basics.description | ConvertTo-Json" + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(prefix + expected + suffix, json.loads(result.stdout)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SolutionMigration/clean_xdr_detection_outputs.py b/Tools/SolutionMigration/clean_xdr_detection_outputs.py new file mode 100644 index 00000000000..871840fae8d --- /dev/null +++ b/Tools/SolutionMigration/clean_xdr_detection_outputs.py @@ -0,0 +1,166 @@ +"""Move non-detection artifacts out of solution XDR Detections directories. + +Only YAML files with a supported deployable detection shape remain. Reports, +manifests, workflow state, deployment captures, validation evidence, and +migration configuration are moved to the solution's ignored Logs directory. +""" +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path +from typing import Iterable, List + +import yaml + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPOSITORY_ROOT / "Tools" / "SentinelToXDRMigration")) +from sentinel_xdr_migration.artifacts import ( + create_run, new_run_id, report_directory, reports_base, using_run, +) +DETECTION_DIRECTORY = "XDR Detections" +REPORT_DIRECTORY = "sentinel-xdr-migration" + + +class CleanupError(RuntimeError): + """The output layout cannot be cleaned safely.""" + + +def is_detection_yaml(path: Path) -> bool: + if path.suffix.lower() not in {".yaml", ".yml"}: + return False + try: + document = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + except (OSError, yaml.YAMLError) as exc: + raise CleanupError(f"Cannot read {path}: {exc}") from exc + if not isinstance(document, dict): + return False + if all(document.get(field) for field in ("id", "name", "query")): + return True + properties = document.get("properties") + query_condition = ( + properties.get("queryCondition") if isinstance(properties, dict) else None + ) + return bool( + document.get("resourceType") == "Microsoft.Security/detectionRules" + and isinstance(properties, dict) + and properties.get("id") + and properties.get("displayName") + and isinstance(query_condition, dict) + and query_condition.get("queryText") + ) + + +def find_xdr_directories(repository_root: Path) -> Iterable[Path]: + solutions_root = repository_root / "Solutions" + if not solutions_root.is_dir(): + return [] + return sorted( + path + for path in solutions_root.glob(f"*/{DETECTION_DIRECTORY}") + if path.is_dir() + ) + + +def plan_cleanup(repository_root: Path) -> List[dict]: + plan: List[dict] = [] + for xdr_dir in find_xdr_directories(repository_root): + solution = xdr_dir.parent.name + for path in sorted(item for item in xdr_dir.rglob("*") if item.is_file()): + if is_detection_yaml(path): + continue + plan.append({ + "solution": solution, + "xdrDirectory": xdr_dir, + "source": path, + "relativePath": path.relative_to(xdr_dir), + }) + return plan + + +def apply_cleanup( + plan: List[dict], + run_id: str | None = None, +) -> dict: + moved: List[dict] = [] + destinations: dict[Path, Path] = {} + for item in plan: + solution = Path(item["xdrDirectory"]).parent + if solution not in destinations: + with using_run(solution, run_id): + directory = report_directory(solution) + destinations[solution] = ( + directory / new_run_id() if directory == reports_base(solution) else directory + ) + destination = destinations[solution] / item["relativePath"] + if destination.exists(): + raise CleanupError(f"Cleanup destination already exists: {destination}") + for solution, directory in destinations.items(): + if not directory.exists(): + create_run(solution, run_id=directory.name, migrating=True) + for item in plan: + source = item["source"] + destination = destinations[Path(item["xdrDirectory"]).parent] / item["relativePath"] + if destination.exists(): + raise CleanupError(f"Cleanup destination already exists: {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + moved.append({ + "solution": item["solution"], + "source": str(source), + "destination": str(destination), + }) + + xdr_roots = {Path(item["xdrDirectory"]) for item in plan} + for xdr_root in xdr_roots: + for directory in sorted( + (path for path in xdr_root.rglob("*") if path.is_dir()), + key=lambda path: len(path.parts), + reverse=True, + ): + if not any(directory.iterdir()): + directory.rmdir() + + return {"moved": moved, "movedCount": len(moved)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository-root", type=Path, default=REPOSITORY_ROOT) + parser.add_argument("--run-id", help="Select the existing run; required when a solution has multiple runs.") + parser.add_argument( + "--apply", + action="store_true", + help="Move files. Without this flag, print the planned changes only.", + ) + args = parser.parse_args() + + try: + plan = plan_cleanup(args.repository_root) + if args.apply: + result = apply_cleanup(plan, args.run_id) + else: + result = { + "mode": "dry-run", + "moveCount": len(plan), + "moves": [ + { + "solution": item["solution"], + "source": str(item["source"]), + "relativePath": str(item["relativePath"]), + } + for item in plan + ], + } + except (CleanupError, ValueError) as exc: + raise SystemExit(str(exc)) + + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/SolutionMigration/generate_mock_scenario.py b/Tools/SolutionMigration/generate_mock_scenario.py new file mode 100644 index 00000000000..bd809dd318b --- /dev/null +++ b/Tools/SolutionMigration/generate_mock_scenario.py @@ -0,0 +1,1569 @@ +"""Generate a shared AR/CD mock payload from Azure-Sentinel repository content. + +The tool is standalone within Azure-Sentinel. It reads a source Analytic Rule, +the converted Custom Detection, checked-in Standard-table base events, and the +solution package's DCR/transform/parser definitions. It never writes to Azure. + +Existing valid scenarios are reused by default. Automatic generation is limited +to simple decisive predicates. Complex behavior requires a reviewed scenario +JSON containing maliciousRecords and benignRecords. Only the malicious records +are persisted as mock.json for the shared one-time AR/CD ingestion. Benign +records remain validation controls and are not a second ingestion payload. +""" +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import random +import re +import sys +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +import yaml + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +TABLE_ROOT = REPOSITORY_ROOT / "Sample Data" / "Tables" +MOCK_ROOT = REPOSITORY_ROOT / "Sample Data" / "Solutions" / "Mock" +STANDARD_CONTRACT_ROOT = ( + REPOSITORY_ROOT / "Tools" / "AzureMonitorLogsIngestion" / "contracts" +) +INGESTION_TOOL_ROOT = REPOSITORY_ROOT / "Tools" / "AzureMonitorLogsIngestion" +if str(INGESTION_TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(INGESTION_TOOL_ROOT)) +from azure_monitor_logs_ingestion.registry import ( + find_capability, + resolve_contract as resolve_registered_contract, +) + +SCENARIO_VERSION = 1 +SUPPORTED_TYPES = {"string", "datetime", "dynamic", "bool", "boolean", "int", "long", "real", "double"} + + +class ScenarioError(RuntimeError): + """A scenario cannot be generated or reused safely.""" + + +def _safe_name(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") or "unnamed" + + +def _resolved_directory(path: Path, label: str, *, required: bool = False) -> Path: + path = Path(path).expanduser() + if path.is_symlink(): + raise ScenarioError(f"{label} must not be a symlink: {path}") + resolved = path.resolve() + if required and not resolved.is_dir(): + raise ScenarioError(f"{label} is not a directory: {resolved}") + if resolved.exists() and not resolved.is_dir(): + raise ScenarioError(f"{label} is not a directory: {resolved}") + return resolved + + +def _reject_symlink_path(path: Path, label: str) -> Path: + path = Path(path).expanduser() + if path.is_symlink(): + raise ScenarioError(f"{label} must not be a symlink: {path}") + return path.resolve() + + +def _assert_no_symlink_below(root: Path, path: Path, label: str) -> Path: + root = root.resolve() + absolute_path = path.absolute() + try: + relative = absolute_path.relative_to(root) + except ValueError: + relative = None + if relative is not None: + current = root + for part in relative.parts: + current = current / part + if current.is_symlink(): + raise ScenarioError(f"Symlink {label} paths are not supported: {current}") + resolved_path = path.resolve() + if not resolved_path.is_relative_to(root): + raise ScenarioError(f"{label} escapes its root {root}: {path}") + return resolved_path + + +def _solution_directory(source_root: Path, solution: str) -> Path: + if solution in {"", ".", ".."} or "/" in solution or "\\" in solution: + raise ScenarioError("Solution must be a single folder name.") + root = source_root / "Solutions" + if root.is_symlink(): + raise ScenarioError(f"Symlink solution paths are not supported: {root}") + directory = root / solution + if directory.is_symlink(): + raise ScenarioError(f"Symlink solution paths are not supported: {directory}") + _assert_no_symlink_below(root, directory, "solution") + if not directory.is_dir(): + raise ScenarioError(f"Solution folder was not found: {directory}") + return directory + + +def _source_file(selector: str, source_root: Path, label: str) -> Optional[Path]: + supplied = Path(selector).expanduser() + candidate = supplied if supplied.is_absolute() else source_root / supplied + if not candidate.is_file(): + return None + solution_root = source_root.resolve() + resolved = _assert_no_symlink_below(solution_root, candidate, label) + if not resolved.is_file(): + return None + return resolved + + +def _load_yaml(path: Path) -> dict: + try: + value = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + except (OSError, yaml.YAMLError) as exc: + raise ScenarioError(f"Cannot read YAML {path}: {exc}") from exc + if not isinstance(value, dict): + raise ScenarioError(f"YAML file must contain an object: {path}") + return value + + +def _load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ScenarioError(f"Cannot read JSON {path}: {exc}") from exc + + +def _query(document: dict) -> str: + for key in ("query", "customDetectionQuery", "queryCondition"): + value = document.get(key) + if isinstance(value, str) and value.strip(): + return value + properties = document.get("properties") + if isinstance(properties, dict): + condition = properties.get("queryCondition") + if isinstance(condition, dict): + value = condition.get("queryText") + if isinstance(value, str) and value.strip(): + return value + raise ScenarioError("The content file does not contain a query.") + + +def _detection_property(document: dict, name: str) -> Any: + value = document.get(name) + if value is not None: + return value + properties = document.get("properties") + return properties.get(name) if isinstance(properties, dict) else None + + +def _detection_source_id(document: dict) -> str: + for key in ("sourceId", "sourceRuleId", "analyticRuleId"): + value = document.get(key) + if value: + return str(value) + source = (document.get("contentProvenance") or {}).get("source") or {} + return str(source.get("id") or "") + + +def _content_yaml_files(folder: Path) -> List[Path]: + if folder.is_symlink(): + raise ScenarioError(f"Symlink content paths are not supported: {folder}") + files = [] + for path in folder.rglob("*"): + if any(part.lower() in {"logs", "reports"} for part in path.relative_to(folder).parts): + continue + if path.is_symlink(): + raise ScenarioError(f"Symlink content paths are not supported: {path}") + if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}: + if not path.resolve().is_relative_to(folder.resolve()): + raise ScenarioError(f"Content path escapes its root: {path}") + files.append(path) + return sorted(files, key=lambda path: path.relative_to(folder).as_posix()) + + +def find_analytic_rule( + solution: str, + selector: str, + source_root: Optional[Path] = None, +) -> Tuple[Path, dict]: + source_root = Path(source_root or REPOSITORY_ROOT).resolve() + supplied = _source_file(selector, source_root, "Analytic Rule") + if supplied: + return supplied, _load_yaml(supplied) + + candidates: List[Tuple[Path, dict]] = [] + solution_root = _solution_directory(source_root, solution) + for folder_name in ("Analytic Rules", "Analytics Rules"): + folder = solution_root / folder_name + if not folder.is_dir(): + continue + for path in _content_yaml_files(folder): + rule = _load_yaml(path) + if ( + selector == str(rule.get("id")) + or selector == str(rule.get("name")) + or selector == path.name + or selector == path.stem + or selector.replace("\\", "/") == path.relative_to(folder).as_posix() + or selector.replace("\\", "/") == path.relative_to(solution_root).as_posix() + ): + candidates.append((path, rule)) + if not candidates: + raise ScenarioError( + f"Analytic Rule {selector!r} was not found under solution {solution!r}." + ) + if len(candidates) > 1: + raise ScenarioError(f"Analytic Rule selector {selector!r} is ambiguous.") + return candidates[0] + + +def find_custom_detection( + solution: str, + selector: Optional[str], + source_rule: dict, + source_root: Optional[Path] = None, +) -> Tuple[Path, dict]: + source_root = Path(source_root or REPOSITORY_ROOT).resolve() + supplied = _source_file(selector, source_root, "Custom Detection") if selector else None + if supplied: + return supplied, _load_yaml(supplied) + + folder = _solution_directory(source_root, solution) / "XDR Detections" + if not folder.is_dir(): + raise ScenarioError( + f"Converted Custom Detection folder does not exist: {folder}. " + "Run the conversion stage before generating rule-specific mock data." + ) + + source_id = str(source_rule.get("id") or "") + source_name = str(source_rule.get("name") or "") + matches: List[Tuple[Path, dict]] = [] + for path in _content_yaml_files(folder): + if path.name.lower() in {"migration-config.yaml", "migration-config.yml"}: + continue + detection = _load_yaml(path) + identifiers = { + _detection_source_id(detection), + str(_detection_property(detection, "displayName") or ""), + str(_detection_property(detection, "name") or ""), + path.name, + path.stem, + path.relative_to(folder).as_posix(), + path.relative_to(folder.parent).as_posix(), + } + wanted = selector.replace("\\", "/") if selector else source_id or source_name + if wanted in identifiers: + matches.append((path, detection)) + if not matches: + raise ScenarioError( + f"No converted Custom Detection matched Analytic Rule {source_name!r} in {folder}." + ) + if len(matches) > 1: + raise ScenarioError( + f"Multiple Custom Detections matched Analytic Rule {source_name!r}; " + "pass --custom-detection with an exact path." + ) + return matches[0] + + +def scenario_directory( + solution: str, + rule: dict, + output_root: Optional[Path] = None, +) -> Path: + if solution in {"", ".", ".."} or "/" in solution or "\\" in solution: + raise ScenarioError("Solution must be a single folder name.") + rule_id = _safe_name(str(rule.get("id") or rule.get("name") or "rule")) + root = _resolved_directory(output_root or MOCK_ROOT, "Output root") + return root / solution / rule_id + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def validate_existing_scenario( + path: Path, + solution: str, + rule: dict, + rule_path: Path, + detection: dict, + detection_path: Path, + registry_path: Optional[Path] = None, + scenario_input: Optional[Path] = None, + scenario_input_kind: Optional[str] = None, + scenario_input_hash: Optional[str] = None, +) -> dict: + scenario_path = path / "scenario.json" + mock_path = path / "mock.json" + if not all(candidate.is_file() for candidate in (scenario_path, mock_path)): + raise ScenarioError(f"Existing scenario folder is incomplete: {path}") + scenario = _load_json(scenario_path) + mock_records = _load_json(mock_path) + if not isinstance(scenario, dict): + raise ScenarioError(f"Scenario manifest must be an object: {scenario_path}") + if scenario.get("solution") != solution: + raise ScenarioError(f"Scenario solution does not match {solution!r}: {scenario_path}") + if str(scenario.get("ruleId")) != str(rule.get("id")): + raise ScenarioError(f"Scenario rule ID does not match the selected rule: {scenario_path}") + expected_hashes = { + "analyticRuleFileSha256": _sha256_bytes(rule_path.read_bytes()), + "analyticRuleQuerySha256": _sha256_bytes(_query(rule).encode("utf-8")), + "customDetectionFileSha256": _sha256_bytes(detection_path.read_bytes()), + "customDetectionQuerySha256": _sha256_bytes( + _query(detection).encode("utf-8") + ), + } + if scenario.get("sourceHashes") != expected_hashes: + raise ScenarioError( + "Existing scenario source hashes are stale or absent; review and " + "regenerate it before reuse." + ) + stored_scenario_review = scenario.get("scenarioReview") or {} + if scenario_input: + if ( + stored_scenario_review.get("inputKind") != scenario_input_kind + or stored_scenario_review.get("inputSha256") != scenario_input_hash + ): + raise ScenarioError( + "Existing scenario input hash or review state does not match the " + "supplied scenario input; regenerate only after reviewing the prior fixture." + ) + expected_review_status = ( + "review-pending" if scenario_input_kind == "candidate" else "reviewed" + ) + if stored_scenario_review.get("status") != expected_review_status: + raise ScenarioError( + "Existing scenario review status does not match the supplied input." + ) + stored_resolution = (scenario.get("ingestion") or {}).get("contractResolution") or {} + if stored_resolution.get("schemaFingerprint"): + if not registry_path or not Path(registry_path).is_file(): + raise ScenarioError( + "The toolkit registry required to verify this scenario is missing." + ) + current_resolution = resolve_registered_contract( + str(scenario.get("sourceTable") or ""), + registry_path=registry_path or STANDARD_CONTRACT_ROOT.parent / "registry.yaml", + ) + if ( + stored_resolution.get("schemaFingerprint") + != current_resolution.get("schemaFingerprint") + or stored_resolution.get("transformFingerprint") + != current_resolution.get("transformFingerprint") + ): + raise ScenarioError( + "Existing scenario contract fingerprints are stale; review and " + "regenerate it before reuse." + ) + if not isinstance(mock_records, list) or not mock_records: + raise ScenarioError(f"Mock payload must be a non-empty array: {mock_path}") + validation = scenario.get("validation") or {} + if validation.get("schemaValidation") != "passed": + raise ScenarioError(f"Existing scenario has not passed schema validation: {scenario_path}") + return { + "status": "existing-scenario", + "scenarioPath": str(scenario_path), + "mockPath": str(mock_path), + "scenario": scenario, + } + + +def _walk_resources(resources: Sequence[dict]) -> Iterable[dict]: + for resource in resources: + if not isinstance(resource, dict): + continue + yield resource + nested = resource.get("resources") + if isinstance(nested, list): + yield from _walk_resources(nested) + properties = resource.get("properties") or {} + for template_key in ("template", "mainTemplate"): + template = properties.get(template_key) or {} + if isinstance(template, dict): + yield from _walk_resources(template.get("resources") or []) + + +def load_solution_contract(solution: str, source_root: Optional[Path] = None) -> dict: + source_root = Path(source_root or REPOSITORY_ROOT).resolve() + solution_root = _solution_directory(source_root, solution) + package = solution_root / "Package" / "mainTemplate.json" + _assert_no_symlink_below(solution_root, package, "solution package") + if not package.is_file(): + raise ScenarioError(f"Solution package was not found: {package}") + template = _load_json(package) + dcrs: List[dict] = [] + tables: Dict[str, List[dict]] = {} + for resource in _walk_resources(template.get("resources") or []): + resource_type = str(resource.get("type") or "").lower() + properties = resource.get("properties") or {} + if resource_type == "microsoft.insights/datacollectionrules": + dcrs.append({ + "name": resource.get("name"), + "streamDeclarations": properties.get("streamDeclarations") or {}, + "dataFlows": properties.get("dataFlows") or [], + }) + elif resource_type == "microsoft.operationalinsights/workspaces/tables": + raw_name = str(resource.get("name") or "").split("/")[-1].strip("[]'\"") + schema = properties.get("schema") or {} + if raw_name and isinstance(schema.get("columns"), list): + tables[raw_name] = schema["columns"] + + parsers: Dict[str, str] = {} + parser_root = solution_root / "Parsers" + if parser_root.is_dir(): + for path in _content_yaml_files(parser_root): + document = _load_yaml(path) + alias = document.get("FunctionAlias") or document.get("FunctionName") + query = document.get("FunctionQuery") + if alias and isinstance(query, str): + parsers[str(alias)] = query + return {"package": package, "dcrs": dcrs, "tables": tables, "parsers": parsers} + + +def _split_top_level(text: str, separators: str = ",") -> List[str]: + output: List[str] = [] + current: List[str] = [] + depth = 0 + quote: Optional[str] = None + for char in text: + if quote: + current.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + current.append(char) + continue + if char in "([": + depth += 1 + elif char in ")]": + depth = max(0, depth - 1) + if char in separators and depth == 0: + output.append("".join(current)) + current = [] + else: + current.append(char) + if current: + output.append("".join(current)) + return output + + +_ASSIGNMENT_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$", re.S) +_STAGE_RE = re.compile(r"^\s*(extend|project-rename|project)\s+(.*)$", re.I | re.S) +_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$") +_CAST_RE = re.compile( + r"^to(?:string|int|long|real|double|bool|datetime|dynamic)\(\s*(.+)\s*\)$", + re.I | re.S, +) +_COLUMN_IF_EXISTS_RE = re.compile( + r"^column_ifexists\(\s*['\"]([^'\"]+)['\"]\s*,.+\)$", + re.I | re.S, +) + + +def _assignments(kql: str) -> Tuple[Dict[str, str], Dict[str, str]]: + project: Dict[str, str] = {} + extend: Dict[str, str] = {} + for segment in _split_top_level(kql, separators="|;"): + match = _STAGE_RE.match(segment) + if not match: + continue + target = extend if match.group(1).lower() == "extend" else project + for item in _split_top_level(match.group(2)): + item = item.strip() + assignment = _ASSIGNMENT_RE.match(item) + if assignment: + target[assignment.group(1)] = assignment.group(2).strip() + elif _NAME_RE.match(item): + target[item] = item + return project, extend + + +def _resolve_expression( + expression: str, + value: Any, + project: Dict[str, str], + extend: Dict[str, str], + seen: Optional[set] = None, +) -> Tuple[str, Any]: + seen = seen or set() + expression = expression.strip() + cast = _CAST_RE.match(expression) + if cast: + return _resolve_expression(cast.group(1), value, project, extend, seen) + column_if_exists = _COLUMN_IF_EXISTS_RE.match(expression) + if column_if_exists: + return column_if_exists.group(1), value + if _NAME_RE.match(expression): + if expression in seen: + return expression, value + seen.add(expression) + next_expression = project.get(expression, extend.get(expression, expression)) + if next_expression != expression: + return _resolve_expression(next_expression, value, project, extend, seen) + return expression, value + raise ScenarioError(f"Expression cannot be reverse-mapped safely: {expression}") + + +def resolve_column(kql: str, column: str, value: Any) -> Tuple[str, Any]: + project, extend = _assignments(kql) + expression = project.get(column, extend.get(column, column)) + return _resolve_expression(expression, value, project, extend) + + +_EQUALITY_RE = re.compile( + r"\b([A-Za-z_][A-Za-z0-9_.]*)\s*(==|=~|!=|!~|>=|<=|>|<)\s*" + r"(datetime\([^)]*\)|['\"][^'\"]*['\"]|-?\d+(?:\.\d+)?|true|false)", + re.I, +) +_ISEMPTY_RE = re.compile(r"\b(isempty|isnotempty)\(\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\)", re.I) +_IN_RE = re.compile( + r"\b([A-Za-z_][A-Za-z0-9_.]*)\s+(in~?|has_any)\s*\(([^)]*)\)", + re.I, +) +_DYNAMIC_LIST_LET_RE = re.compile( + r"\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*dynamic\s*\(\s*\[([^\]]*)\]\s*\)\s*;", + re.I, +) + + +def _literal(raw: str) -> Any: + value = raw.strip() + if value.lower().startswith("datetime("): + return value[value.find("(") + 1:value.rfind(")")].strip("'\"") + if value[:1] in {"'", '"'} and value[-1:] == value[:1]: + return value[1:-1] + if value.lower() in {"true", "false"}: + return value.lower() == "true" + try: + return float(value) if "." in value else int(value) + except ValueError: + return value + + +def extract_predicates(kql: str) -> List[dict]: + predicates: List[dict] = [] + dynamic_lists = { + match.group(1): [ + _literal(item) + for item in _split_top_level(match.group(2)) + if item.strip() + ] + for match in _DYNAMIC_LIST_LET_RE.finditer(kql) + } + for match in _EQUALITY_RE.finditer(kql): + predicates.append({ + "column": match.group(1), + "operator": match.group(2), + "value": _literal(match.group(3)), + }) + for match in _ISEMPTY_RE.finditer(kql): + predicates.append({ + "column": match.group(2), + "operator": match.group(1).lower(), + "value": "" if match.group(1).lower() == "isempty" else "synthetic-present", + }) + for match in _IN_RE.finditer(kql): + raw_values = match.group(3).strip() + values = dynamic_lists.get(raw_values) + if values is None: + values = [ + _literal(item) + for item in _split_top_level(raw_values) + if item.strip() + ] + if values: + predicates.append({ + "column": match.group(1), + "operator": match.group(2).lower(), + "value": values[0], + }) + unique: List[dict] = [] + seen = set() + for predicate in predicates: + key = (predicate["column"], predicate["operator"], repr(predicate["value"])) + if key not in seen: + seen.add(key) + unique.append(predicate) + return unique + + +def assess_query(query: str, rule: dict) -> dict: + lower = query.lower() + reasons: List[str] = [] + if str(rule.get("triggerOperator") or "").lower() == "equal" and rule.get("triggerThreshold") == 0: + return { + "status": "unsupported-payload", + "reasons": ["The rule triggers on zero matching rows."], + } + checks = ( + (r"\|\s*join\b|\bunion\b", "The query correlates multiple result sets."), + (r"\b(dcount|countif|make_set|make_list)\s*\(", "The query requires aggregation."), + (r"\bsummarize\b", "The query summarizes multiple events."), + (r"_getwatchlist|externaldata", "The query depends on external reference data."), + (r"\bseries_|anomal", "The query depends on anomaly or baseline behavior."), + ( + r"\bparse_xml\s*\(|\bparse_json\s*\(|\|\s*mv-expand\b|" + r"\|\s*evaluate\s+(?:bag_unpack|pivot)\b", + "The query parses or expands structured event content.", + ), + ( + r"\b(?:contains|has_all|has_any|has|startswith|endswith|matches\s+regex)\b", + "The query uses a content predicate that automatic fixtures do not model.", + ), + ) + for pattern, reason in checks: + if re.search(pattern, lower): + reasons.append(reason) + return { + "status": "manual-review-required" if reasons else "simple", + "reasons": reasons, + } + + +def _first_query_source(query: str) -> Optional[str]: + without_lets = re.sub(r"(?is)\blet\s+.*?;", "", query) + match = re.search(r"(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\||$)", without_lets) + return match.group(1) if match else None + + +def _table_from_stream(stream: str) -> str: + value = stream + for prefix in ("Custom-", "Microsoft-"): + if value.startswith(prefix): + value = value[len(prefix):] + return value + + +def _load_table_base( + table: str, + table_root: Optional[Path] = None, +) -> Tuple[dict, Dict[str, str]]: + if table in {"", ".", ".."} or "/" in table or "\\" in table: + raise ScenarioError(f"Invalid source table name: {table!r}") + table_root = Path(table_root or TABLE_ROOT).resolve() + folder = table_root / table + _assert_no_symlink_below(table_root, folder, "table sample") + base_path = folder / "base-event.json" + metadata_path = folder / "metadata.json" + if not base_path.is_file() or not metadata_path.is_file(): + raise ScenarioError( + f"Neutral base event was not found for table {table!r} under {table_root}." + ) + _assert_no_symlink_below(table_root, base_path, "table sample") + _assert_no_symlink_below(table_root, metadata_path, "table sample") + base = _load_json(base_path) + metadata = _load_json(metadata_path) + columns = metadata.get("columns") if isinstance(metadata, dict) else None + if not isinstance(base, dict) or not isinstance(columns, dict): + raise ScenarioError(f"Invalid table base contract for {table!r}.") + return base, {str(name): str(kind) for name, kind in columns.items()} + + +def _load_standard_ingestion_contract( + table: str, + registry_path: Optional[Path] = None, +) -> Optional[dict]: + registry_path = registry_path or STANDARD_CONTRACT_ROOT.parent / "registry.yaml" + if not Path(registry_path).is_file(): + return None + capability = find_capability(table, registry_path) + if capability is None: + return None + value = _load_yaml(capability.contract_path) + destination = value.get("destination") or {} + if ( + value.get("version") != 1 + or destination.get("kind") != "standard" + or destination.get("table") != capability.table + or destination.get("outputStream") != capability.output_stream + or not str(value.get("inputStream") or "").startswith("Custom-") + ): + raise ScenarioError( + f"Registered standard-table contract does not match {table!r}: " + f"{capability.contract_path}" + ) + return value + + +def _query_fields_from_schema(query: str, schema: Dict[str, str]) -> Dict[str, str]: + """Extract only exact schema identifiers; this is not a general KQL parser.""" + required: Dict[str, str] = {} + aliases = _query_output_aliases(query) + for name, kind in schema.items(): + if name.casefold() in aliases: + continue + if re.search(r"(? set[str]: + project, extend = _assignments(query) + aliases = set() + for name, expression in {**project, **extend}.items(): + if expression.strip().casefold() != name.casefold(): + aliases.add(name.casefold()) + return aliases + + +def _query_predicate_fields( + query: str, + schema: Dict[str, str], +) -> Tuple[Dict[str, Optional[str]], List[str]]: + """Resolve simple predicate identifiers without treating query aliases as source fields.""" + aliases = _query_output_aliases(query) + schema_names = {name.casefold(): name for name in schema} + required: Dict[str, Optional[str]] = {} + unresolved_aliases: List[str] = [] + for predicate in extract_predicates(query): + field = str(predicate["column"]) + if field.casefold() in aliases: + if field not in unresolved_aliases: + unresolved_aliases.append(field) + continue + canonical_name = schema_names.get(field.casefold(), field) + required[canonical_name] = schema.get(canonical_name) + return required, unresolved_aliases + + +def _default_value(kind: str) -> Any: + normalized = kind.lower() + if normalized == "datetime": + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + if normalized in {"int", "long"}: + return 0 + if normalized in {"real", "double"}: + return 0.0 + if normalized in {"bool", "boolean"}: + return False + if normalized == "dynamic": + return {} + return "" + + +def _set_path(record: dict, path: str, value: Any) -> None: + parts = path.split(".") + node = record + for part in parts[:-1]: + child = node.get(part) + if not isinstance(child, dict): + child = {} + node[part] = child + node = child + node[parts[-1]] = value + + +def _get_path(record: dict, path: str) -> Any: + node: Any = record + for part in path.split("."): + if not isinstance(node, dict) or part not in node: + raise ScenarioError(f"Field path {path!r} does not exist.") + node = node[part] + return node + + +def _benign_value(value: Any, operator: str) -> Any: + if operator == "isnotempty": + return "" + if operator == "isempty": + return "synthetic-benign" + if isinstance(value, bool): + return not value + if isinstance(value, int) and not isinstance(value, bool): + return value + 1 + if isinstance(value, float): + return value + 1.0 + if isinstance(value, str): + return "__benign_non_match__" + raise ScenarioError("A type-safe benign value could not be inferred.") + + +def _find_custom_stream( + contract: dict, + source_name: Optional[str], + predicates: Sequence[dict], + *, + allow_unmapped: bool = False, +) -> Optional[dict]: + parser_query = (contract.get("parsers") or {}).get(source_name or "") + parser_table = _first_query_source(parser_query) if parser_query else None + matches = [] + for dcr in contract.get("dcrs") or []: + declarations = dcr.get("streamDeclarations") or {} + for flow in dcr.get("dataFlows") or []: + transform = str(flow.get("transformKql") or "") + for stream in flow.get("streams") or []: + if stream not in declarations or not stream.startswith("Custom-"): + continue + if not predicates and ( + not allow_unmapped + or not parser_table + or _table_from_stream(stream) != parser_table + ): + continue + mappings = [] + success = True + for predicate in predicates: + column = predicate["column"] + value = predicate["value"] + try: + intermediate, value = ( + resolve_column(parser_query, column, value) + if parser_query + else (column, value) + ) + raw_path, raw_value = resolve_column(transform, intermediate, value) + except ScenarioError: + success = False + break + if raw_path.split(".")[0] not in { + item["name"] for item in declarations[stream].get("columns") or [] + }: + success = False + break + mappings.append((predicate, raw_path, raw_value)) + if success and (mappings or not predicates): + matches.append({ + "dcr": dcr.get("name"), + "stream": stream, + "outputStream": flow.get("outputStream") or stream, + "transformKql": transform, + "columns": declarations[stream].get("columns") or [], + "mappings": mappings, + }) + if len(matches) > 1: + raise ScenarioError( + f"Multiple custom DCR input streams match parser {source_name!r}; " + "the reviewed fixture route is ambiguous." + ) + if matches: + return matches[0] + return None + + +def _find_standard_table( + query: str, + custom_detection_query: str, + source_name: Optional[str], + table_root: Optional[Path] = None, +) -> str: + table_root = Path(table_root or TABLE_ROOT).resolve() + candidates = [source_name, _first_query_source(custom_detection_query)] + for candidate in candidates: + if candidate and (table_root / candidate / "base-event.json").is_file(): + return candidate + tokens = set(re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", f"{query}\n{custom_detection_query}")) + for path in sorted(table_root.iterdir()): + if path.name in tokens and (path / "base-event.json").is_file(): + return path.name + raise ScenarioError("No checked-in Standard-table base event matched the AR/CD queries.") + + +def _validate_value(value: Any, kind: str) -> bool: + normalized = kind.lower() + if value is None: + return True + if normalized == "string": + return isinstance(value, str) + if normalized == "datetime": + if not isinstance(value, str): + return False + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + return True + except ValueError: + return False + if normalized == "dynamic": + return isinstance(value, (dict, list, str, int, float, bool)) + if normalized in {"bool", "boolean"}: + return isinstance(value, bool) + if normalized in {"int", "long"}: + return isinstance(value, int) and not isinstance(value, bool) + if normalized in {"real", "double"}: + return isinstance(value, (int, float)) and not isinstance(value, bool) + return normalized in SUPPORTED_TYPES + + +def validate_records(records: Sequence[dict], columns: Dict[str, str]) -> None: + for index, record in enumerate(records): + unknown = sorted(set(record) - set(columns)) + if unknown: + raise ScenarioError( + f"Record {index} contains fields absent from the schema: {', '.join(unknown)}" + ) + for name, value in record.items(): + if not _validate_value(value, columns[name]): + raise ScenarioError( + f"Record {index} field {name!r} is invalid for type {columns[name]!r}." + ) + + +def _parse_assignment(raw: str) -> Tuple[str, Any]: + if "=" not in raw: + raise ScenarioError(f"Expected PATH=VALUE, got {raw!r}.") + path, value = raw.split("=", 1) + try: + parsed = json.loads(value) + except json.JSONDecodeError: + parsed = value + return path.strip(), parsed + + +def _leaf_paths(value: Any, prefix: str = "") -> Iterable[Tuple[str, Any]]: + if isinstance(value, dict): + for name, child in value.items(): + path = f"{prefix}.{name}" if prefix else name + yield from _leaf_paths(child, path) + else: + yield prefix, value + + +def _randomized_value( + path: str, + value: Any, + rng: random.Random, + index: int, + declared_type: Optional[str] = None, +) -> Any: + name = path.rsplit(".", 1)[-1].lower() + normalized_type = (declared_type or "").lower() + if normalized_type == "datetime": + if isinstance(value, str): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + elif isinstance(value, datetime): + parsed = value + else: + raise ScenarioError(f"Cannot randomize non-datetime value at {path!r}.") + return (parsed + timedelta(seconds=rng.randint(1, 86400) + index)).isoformat() + if normalized_type in {"int", "long"}: + return rng.randint(1, 65535) + if normalized_type in {"real", "double"}: + return round(rng.uniform(1.0, 1000.0), 3) + if normalized_type in {"bool", "boolean"}: + return bool(rng.getrandbits(1)) + if normalized_type == "dynamic": + return value + if isinstance(value, bool): + return bool(rng.getrandbits(1)) + if isinstance(value, int) and not isinstance(value, bool): + return rng.randint(1, 65535) + if isinstance(value, float): + return round(rng.uniform(1.0, 1000.0), 3) + if not isinstance(value, str): + return value + if re.search(r"(time|date|timestamp)", name): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return (parsed + timedelta(seconds=rng.randint(1, 86400) + index)).isoformat() + if "ip" in name: + return f"192.0.2.{rng.randint(1, 254)}" + if "email" in name or name.endswith("upn"): + return f"synthetic-{rng.randint(1000, 9999)}@example.invalid" + if re.search(r"(id|guid|session|request|correlation)", name): + return str(uuid.UUID(int=rng.getrandbits(128))) + return f"synthetic-{rng.randint(1000, 9999)}" + + +def randomized_copies( + records: Sequence[dict], + copies: int, + seed: int, + locked_paths: Sequence[str], + column_types: Optional[Dict[str, str]] = None, +) -> List[dict]: + if copies < 1: + raise ScenarioError("--copies must be at least 1.") + output: List[dict] = [] + for copy_index in range(copies): + for source_index, source in enumerate(records): + record = copy.deepcopy(source) + rng = random.Random(f"{seed}:{copy_index}:{source_index}") + for path, value in list(_leaf_paths(record)): + locked = any( + path == item + or path.startswith(f"{item}.") + or item.startswith(f"{path}.") + for item in locked_paths + ) + if path and not locked: + declared_type = (column_types or {}).get(path.split(".", 1)[0]) + _set_path( + record, + path, + _randomized_value( + path, + value, + rng, + copy_index, + declared_type, + ), + ) + output.append(record) + return output + + +def load_reviewed_scenario(path: Path) -> dict: + scenario = _load_json(path) + if not isinstance(scenario, dict): + raise ScenarioError("Scenario input must be a JSON object.") + for field in ("name", "hypothesis"): + if not isinstance(scenario.get(field), str) or not scenario[field].strip(): + raise ScenarioError(f"Scenario input requires {field}.") + for field in ("maliciousRecords", "benignRecords"): + records = scenario.get(field) + if not isinstance(records, list) or not records or not all( + isinstance(record, dict) for record in records + ): + raise ScenarioError(f"Scenario input requires non-empty {field}.") + locks = scenario.get("lockedPaths") or [] + if not isinstance(locks, list) or not all(isinstance(item, str) for item in locks): + raise ScenarioError("lockedPaths must be an array of strings.") + return scenario + + +def generate_scenario( + solution: str, + rule_selector: str, + *, + custom_detection_selector: Optional[str] = None, + force: bool = False, + reviewed_scenario: Optional[Path] = None, + candidate_scenario: Optional[Path] = None, + randomize: bool = False, + copies: int = 1, + seed: int = 20260917, + locks: Sequence[Tuple[str, Any]] = (), + benign_assignments: Sequence[Tuple[str, Any]] = (), + source_root: Optional[Path] = None, + toolkit_root: Optional[Path] = None, + sample_data_root: Optional[Path] = None, + output_root: Optional[Path] = None, +) -> dict: + if reviewed_scenario and candidate_scenario: + raise ScenarioError( + "Pass either --reviewed-scenario or --candidate-scenario, not both." + ) + scenario_input = reviewed_scenario or candidate_scenario + scenario_input_kind = ( + "candidate" if candidate_scenario else "reviewed" if reviewed_scenario else None + ) + if scenario_input: + scenario_input = _reject_symlink_path( + Path(scenario_input), + "Scenario input", + ) + if not scenario_input.is_file(): + raise ScenarioError(f"Scenario input was not found: {scenario_input}") + scenario_input_hash = ( + _sha256_bytes(Path(scenario_input).read_bytes()) if scenario_input else None + ) + source_root = _resolved_directory( + source_root or REPOSITORY_ROOT, + "Source repository root", + required=True, + ) + if toolkit_root is not None: + toolkit_root = _resolved_directory( + toolkit_root, + "Toolkit root", + required=True, + ) + registry_path = toolkit_root / "Tools" / "AzureMonitorLogsIngestion" / "registry.yaml" + default_sample_data_root = toolkit_root / "Sample Data" + else: + toolkit_root = REPOSITORY_ROOT.resolve() + registry_path = STANDARD_CONTRACT_ROOT.parent / "registry.yaml" + default_sample_data_root = toolkit_root / "Sample Data" + sample_data_root_explicit = sample_data_root is not None + if sample_data_root_explicit: + sample_data_root = _resolved_directory( + sample_data_root, + "Sample data root", + required=True, + ) + table_root = sample_data_root / "Tables" + else: + sample_data_root = _resolved_directory( + default_sample_data_root, + "Sample data root", + ) + table_root = sample_data_root / "Tables" + output_root = _resolved_directory(output_root or MOCK_ROOT, "Output root") + + rule_path, rule = find_analytic_rule(solution, rule_selector, source_root) + output_dir = scenario_directory(solution, rule, output_root) + _assert_no_symlink_below(output_root, output_dir, "output") + detection_path, detection = find_custom_detection( + solution, + custom_detection_selector, + rule, + source_root, + ) + if output_dir.exists() and force and candidate_scenario: + prior_manifest = output_dir / "scenario.json" + prior_scenario = _load_json(prior_manifest) if prior_manifest.is_file() else {} + prior_review = ( + (prior_scenario.get("scenarioReview") or {}).get("status") + if isinstance(prior_scenario, dict) + else None + ) + if prior_review != "reviewed": + raise ScenarioError( + "A review-pending or unreviewed scenario cannot be overwritten with " + "--force; review the existing fixture first." + ) + if output_dir.exists() and not force: + result = validate_existing_scenario( + output_dir, + solution, + rule, + rule_path, + detection, + detection_path, + registry_path, + scenario_input=scenario_input, + scenario_input_kind=scenario_input_kind, + scenario_input_hash=scenario_input_hash, + ) + if ( + (result.get("scenario") or {}).get("scenarioReview", {}).get("status") + == "review-pending" + ): + result["status"] = "existing-candidate" + result["paths"] = { + "sourceRoot": str(source_root), + "toolkitRoot": str(toolkit_root), + "sampleDataRoot": str(sample_data_root), + "outputRoot": str(output_root), + } + return result + + source_query = _query(rule) + destination_query = _query(detection) + assessment = assess_query(source_query, rule) + predicates = extract_predicates(source_query) + if not predicates and not scenario_input: + raise ScenarioError( + "No decisive simple predicate could be inferred. Supply a reviewed or candidate scenario input." + ) + if assessment["status"] != "simple" and not scenario_input: + raise ScenarioError( + f"Automatic generation is unsafe ({'; '.join(assessment['reasons'])}). " + "Supply --reviewed-scenario or --candidate-scenario with malicious and benign records." + ) + + contract = load_solution_contract(solution, source_root) + source_name = _first_query_source(source_query) + custom_stream = _find_custom_stream(contract, source_name, predicates) + if not custom_stream and scenario_input: + custom_stream = _find_custom_stream( + contract, + source_name, + [], + allow_unmapped=True, + ) + mappings: List[dict] = [] + generated_contract: Optional[dict] = None + contract_resolution: Optional[dict] = None + + if custom_stream: + columns = { + item["name"]: str(item.get("type") or "dynamic") + for item in custom_stream["columns"] + } + base_record = {name: _default_value(kind) for name, kind in columns.items()} + for predicate, raw_path, value in custom_stream["mappings"]: + _set_path(base_record, raw_path, value) + mappings.append({ + "ruleColumn": predicate["column"], + "operator": predicate["operator"], + "rawPath": raw_path, + "value": value, + }) + ingestion_mode = "custom-dcr" + source_table = _table_from_stream(str(custom_stream["outputStream"])) + stream = custom_stream["stream"] + output_stream = custom_stream["outputStream"] + else: + source_table = _find_standard_table( + source_query, + destination_query, + source_name, + table_root, + ) + base_record, columns = _load_table_base(source_table, table_root) + generated_contract = _load_standard_ingestion_contract( + source_table, + registry_path, + ) + if generated_contract: + contract_columns = { + str(item["name"]): str(item["type"]) + for item in generated_contract.get("inputColumns") or [] + if isinstance(item, dict) and item.get("name") and item.get("type") + } + if not contract_columns: + raise ScenarioError( + f"The registered {source_table} ingestion contract has no input columns." + ) + output_columns = { + str(item["name"]): str(item["type"]) + for item in (generated_contract.get("destination") or {}).get("columns") or [] + if isinstance(item, dict) and item.get("name") and item.get("type") + } or contract_columns + combined_query = source_query + "\n" + destination_query + required_fields = _query_fields_from_schema(combined_query, output_columns) + predicate_fields, unresolved_aliases = _query_predicate_fields( + combined_query, + columns, + ) + for name, kind in predicate_fields.items(): + if name.casefold() not in { + field.casefold() for field in required_fields + }: + required_fields[name] = kind + contract_resolution = resolve_registered_contract( + source_table, + required_fields, + registry_path=registry_path, + ) + if unresolved_aliases: + alias_blockers = [ + f"Decisive predicate field {name!r} is a query alias and cannot be " + "mapped to a source field safely." + for name in unresolved_aliases + ] + contract_resolution["blockers"] = list( + contract_resolution.get("blockers") or [] + ) + alias_blockers + contract_resolution["status"] = "blocked" + if contract_resolution["status"] == "blocked": + generated_contract = None + else: + base_record = { + name: copy.deepcopy( + base_record.get(name, _default_value(kind)) + ) + for name, kind in contract_columns.items() + } + columns = contract_columns + for predicate in predicates: + if predicate["column"] in columns: + base_record[predicate["column"]] = predicate["value"] + mappings.append({ + "ruleColumn": predicate["column"], + "operator": predicate["operator"], + "rawPath": predicate["column"], + "value": predicate["value"], + }) + if predicates and not mappings and not scenario_input: + raise ScenarioError( + "The decisive AR predicates could not be mapped to the Standard-table base event." + ) + if generated_contract: + ingestion_mode = "standard-dcr" + stream = str(generated_contract["inputStream"]) + output_stream = str(generated_contract["destination"]["outputStream"]) + else: + ingestion_mode = "offline-standard-table" + stream = None + output_stream = f"Microsoft-{source_table}" + + scenario_fixture = load_reviewed_scenario(scenario_input) if scenario_input else None + if scenario_fixture: + malicious = copy.deepcopy(scenario_fixture["maliciousRecords"]) + benign = copy.deepcopy(scenario_fixture["benignRecords"]) + locked_paths = list(scenario_fixture.get("lockedPaths") or []) + hypothesis = scenario_fixture["hypothesis"] + scenario_name = scenario_fixture["name"] + else: + malicious = [copy.deepcopy(base_record)] + benign_record = copy.deepcopy(base_record) + decisive = mappings[0] + _set_path( + benign_record, + decisive["rawPath"], + _benign_value(decisive["value"], decisive["operator"]), + ) + benign = [benign_record] + locked_paths = [item["rawPath"] for item in mappings] + hypothesis = f"Synthetic records exercise the decisive predicates in {rule.get('name')}." + scenario_name = "automatic-simple-predicate" + + for path, value in locks: + for record in malicious: + _set_path(record, path, value) + locked_paths.append(path) + for path, value in benign_assignments: + for record in benign: + _set_path(record, path, value) + locked_paths = sorted(set(locked_paths)) + + if randomize: + malicious = randomized_copies(malicious, copies, seed, locked_paths, columns) + benign = randomized_copies(benign, copies, seed + 1, locked_paths, columns) + elif copies != 1: + raise ScenarioError("--copies requires --randomize.") + + validate_records(malicious, columns) + validate_records(benign, columns) + + _assert_no_symlink_below(output_root, output_dir, "output") + output_dir.mkdir(parents=True, exist_ok=True) + _assert_no_symlink_below(output_root, output_dir, "output") + mock_path = output_dir / "mock.json" + mock_path.write_text(json.dumps(malicious, indent=2) + "\n", encoding="utf-8") + contract_path: Optional[Path] = None + if generated_contract: + contract_path = output_dir / "ingestion-contract.yaml" + contract_path.write_text( + yaml.safe_dump(generated_contract, sort_keys=False), + encoding="utf-8", + ) + + remaining_predicates = list(predicates) + for mapping in mappings: + mapping_key = ( + str(mapping["ruleColumn"]).casefold(), + str(mapping["operator"]).casefold(), + ) + for index, predicate in enumerate(remaining_predicates): + predicate_key = ( + str(predicate["column"]).casefold(), + str(predicate["operator"]).casefold(), + ) + if predicate_key == mapping_key: + remaining_predicates.pop(index) + break + unmapped_predicate_limitations = [ + f"Decisive predicate {item['column']!r} ({item['operator']}) is not represented " + "by an ingestion mapping." + for item in remaining_predicates + ] + status = ( + "qualification-ready" + if ingestion_mode in {"custom-dcr", "standard-dcr"} + and assessment["status"] == "simple" + and scenario_input_kind != "candidate" + and not unmapped_predicate_limitations + else "generated-offline-only" + ) + manifest = { + "version": SCENARIO_VERSION, + "generatedAt": datetime.now(timezone.utc).isoformat(), + "solution": solution, + "ruleId": rule.get("id"), + "ruleName": rule.get("name"), + "analyticRule": str(rule_path), + "sourceRoot": str(source_root), + "toolkitRoot": str(toolkit_root), + "sampleDataRoot": str(sample_data_root), + "customDetectionId": _detection_property(detection, "id"), + "customDetectionName": ( + _detection_property(detection, "displayName") + or _detection_property(detection, "name") + ), + "customDetection": str(detection_path), + "reviewedScenario": ( + str(scenario_input) if scenario_input_kind == "reviewed" else None + ), + "candidateScenario": ( + str(scenario_input) if scenario_input_kind == "candidate" else None + ), + "scenarioReview": { + "status": ( + "review-pending" + if scenario_input_kind == "candidate" + else "reviewed" + if scenario_input_kind == "reviewed" + else "not-required" + ), + "inputKind": scenario_input_kind, + "inputPath": str(scenario_input) if scenario_input else None, + "inputSha256": scenario_input_hash, + "semanticValidation": "not-run", + }, + "outputRoot": str(output_root), + "sourceHashes": { + "analyticRuleFileSha256": _sha256_bytes(rule_path.read_bytes()), + "analyticRuleQuerySha256": _sha256_bytes( + source_query.encode("utf-8") + ), + "customDetectionFileSha256": _sha256_bytes( + detection_path.read_bytes() + ), + "customDetectionQuerySha256": _sha256_bytes( + destination_query.encode("utf-8") + ), + }, + "scenarioName": scenario_name, + "hypothesis": hypothesis, + "generationStatus": status, + "queryAssessment": assessment, + "sourceTable": source_table, + "destinationTables": sorted( + set( + name + for name in [_first_query_source(destination_query)] + if name + ) + ), + "ingestion": { + "mode": ingestion_mode, + "dcr": custom_stream.get("dcr") if custom_stream else None, + "stream": stream, + "outputStream": output_stream, + "contract": str(contract_path) if contract_path else None, + "contractResolution": ( + { + "status": contract_resolution.get("status"), + "schemaFingerprint": contract_resolution.get("schemaFingerprint"), + "transformFingerprint": contract_resolution.get("transformFingerprint"), + "liveValidated": contract_resolution.get("liveValidated"), + "references": contract_resolution.get("references"), + "blockers": contract_resolution.get("blockers"), + } + if contract_resolution + else None + ), + "directLogsIngestionSupported": ingestion_mode + in {"custom-dcr", "standard-dcr"}, + }, + "mapping": { + "decisiveFields": mappings, + "lockedPaths": locked_paths, + }, + "fixtures": { + "mock": str(mock_path), + "mockRecords": len(malicious), + "benignValidationRecords": len(benign), + }, + "validation": { + "schemaValidation": "passed", + "semanticReview": ( + "pending" + if scenario_input_kind == "candidate" + else "reviewed" + if scenario_input_kind == "reviewed" + else "not-required" + ), + "semanticMatch": "not-run", + "sentinelSeededQuery": "not-run", + "advancedHuntingSeededQuery": "not-run", + "liveIngestion": "not-run", + "liveSentinel": "not-run", + "liveAdvancedHunting": "not-run", + "alertParity": "not-run", + "cleanup": "not-run", + }, + "limitations": ( + list((contract_resolution or {}).get("blockers") or []) + if status == "qualification-ready" + else ( + [ + "Candidate fixture input is review-pending; it is schema-checked only and cannot proceed to direct ingestion." + ] + if scenario_input_kind == "candidate" + else [ + "The query requires semantic review; generation does not establish that fixture records match it." + ] + if ingestion_mode in {"custom-dcr", "standard-dcr"} + else [ + "The selected table does not expose a declared Custom-* input stream " + "or a reviewed standard-table ingestion contract for direct Logs " + "Ingestion API testing." + ] + ) + list((contract_resolution or {}).get("blockers") or []) + ) + unmapped_predicate_limitations, + } + scenario_path = output_dir / "scenario.json" + scenario_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return { + "status": "generated-candidate" if scenario_input_kind == "candidate" else "generated", + "scenarioPath": str(scenario_path), + "mockPath": str(mock_path), + "paths": { + "sourceRoot": str(source_root), + "toolkitRoot": str(toolkit_root), + "sampleDataRoot": str(sample_data_root), + "outputRoot": str(output_root), + }, + "scenario": manifest, + } + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--solution", required=True) + parser.add_argument("--rule", required=True, help="Rule ID, exact name, filename, or YAML path.") + parser.add_argument("--custom-detection", help="Exact converted Custom Detection YAML path or selector.") + parser.add_argument( + "--source-root", + type=Path, + help="Azure-Sentinel repository containing Solutions/ (defaults to this toolkit repository).", + ) + parser.add_argument( + "--toolkit-root", + type=Path, + help="Toolkit repository containing the standard-table registry (defaults to this repository).", + ) + parser.add_argument( + "--sample-data-root", + type=Path, + help="Root containing Tables/ base events (defaults to the toolkit Sample Data folder).", + ) + parser.add_argument( + "--output-root", + type=Path, + help="Exact mock output root; solution and rule-ID folders are created beneath it.", + ) + parser.add_argument("--force", action="store_true", help="Regenerate an existing scenario.") + scenario_inputs = parser.add_mutually_exclusive_group() + scenario_inputs.add_argument( + "--reviewed-scenario", + type=Path, + help="Reviewed complex scenario JSON; only use after human review.", + ) + scenario_inputs.add_argument( + "--candidate-scenario", + type=Path, + help="Provisional scenario JSON; generation remains review-pending and offline-only.", + ) + parser.add_argument("--randomize", action="store_true") + parser.add_argument("--copies", type=int, default=1) + parser.add_argument("--seed", type=int, default=20260917) + parser.add_argument("--lock", action="append", default=[], metavar="PATH=VALUE") + parser.add_argument("--benign-set", action="append", default=[], metavar="PATH=VALUE") + args = parser.parse_args(argv) + + try: + result = generate_scenario( + args.solution, + args.rule, + custom_detection_selector=args.custom_detection, + force=args.force, + reviewed_scenario=args.reviewed_scenario, + candidate_scenario=args.candidate_scenario, + randomize=args.randomize, + copies=args.copies, + seed=args.seed, + locks=[_parse_assignment(item) for item in args.lock], + benign_assignments=[_parse_assignment(item) for item in args.benign_set], + source_root=args.source_root, + toolkit_root=args.toolkit_root, + sample_data_root=args.sample_data_root, + output_root=args.output_root, + ) + except ScenarioError as exc: + print(json.dumps({"status": "blocked", "error": str(exc)}, indent=2)) + return 1 + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/SolutionMigration/ingest_to_dcr.py b/Tools/SolutionMigration/ingest_to_dcr.py new file mode 100644 index 00000000000..182a084d6fa --- /dev/null +++ b/Tools/SolutionMigration/ingest_to_dcr.py @@ -0,0 +1,1154 @@ +"""Discover a workspace DCR and optionally ingest a reviewed JSON payload. + +This tool is intentionally standalone: it uses Azure CLI authentication and the +Python standard library only. Payload generation and rule validation are separate +steps. A successful HTTP response proves ingestion acceptance, not rule execution. +""" +from __future__ import annotations + +import argparse +import fnmatch +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +ARM_RESOURCE = "https://management.azure.com/" +MONITOR_RESOURCE = "https://monitor.azure.com/" +LOG_ANALYTICS_RESOURCE = "https://api.loganalytics.io/" +RESOURCE_GRAPH_URL = ( + "https://management.azure.com/providers/Microsoft.ResourceGraph/resources" + "?api-version=2021-03-01" +) +DCE_API_VERSION = "2023-03-11" +STREAM_API_VERSION = "2023-01-01" +WORKSPACE_API_VERSION = "2022-10-01" +PERMISSIONS_API_VERSION = "2022-04-01" +TABLE_API_VERSION = "2025-07-01" +INGESTION_ACTION = "Microsoft.Insights/Telemetry/Write" +TABLE_WRITE_ACTION = "Microsoft.OperationalInsights/workspaces/tables/write" +WORKSPACE_WRITE_ACTION = "Microsoft.OperationalInsights/workspaces/write" + + +class ToolError(RuntimeError): + """A user-actionable discovery or ingestion failure.""" + + +def _az_executable() -> str: + executable = shutil.which("az.cmd" if os.name == "nt" else "az") + if not executable: + executable = shutil.which("az") + if not executable: + raise ToolError("Azure CLI is not installed or is not available on PATH.") + return executable + + +def _run_az(*args: str) -> Any: + command = [_az_executable(), *args, "--output", "json", "--only-show-errors"] + try: + completed = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + except (FileNotFoundError, OSError) as exc: + raise ToolError("Azure CLI could not be started.") from exc + except subprocess.CalledProcessError as exc: + message = (exc.stderr or exc.stdout or "").strip() + raise ToolError(message or f"Azure CLI command failed: {' '.join(command)}") from exc + + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise ToolError("Azure CLI returned an invalid JSON response.") from exc + + +def _active_account(login: bool) -> dict: + try: + return _run_az("account", "show") + except ToolError: + if not login: + raise ToolError("Azure authentication is required. Run `az login` and retry.") + subprocess.run([_az_executable(), "login"], check=True) + return _run_az("account", "show") + + +def _load_target_context(path: Path) -> dict: + try: + target = json.loads(path.expanduser().read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise ToolError(f"Qualification target context is invalid: {path}: {exc}") from exc + required = ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", + ) + missing = [field for field in required if not str(target.get(field) or "").strip()] + if missing or target.get("locked") is not True: + raise ToolError( + "Qualification target context is not locked or is missing: " + + ", ".join(missing) + ) + workspace_id = str(target["workspaceResourceId"]).strip().rstrip("/") + parts = workspace_id.split("/") + if ( + len(parts) != 9 + or parts[1].lower() != "subscriptions" + or parts[3].lower() != "resourcegroups" + or parts[5].lower() != "providers" + or parts[6].lower() != "microsoft.operationalinsights" + or parts[7].lower() != "workspaces" + ): + raise ToolError("Qualification target has an invalid workspace ARM ID.") + if parts[2].lower() != str(target["subscriptionId"]).lower(): + raise ToolError( + "Qualification target workspace and subscription identifiers do not match." + ) + target["workspaceResourceId"] = workspace_id + return target + + +def _access_token(resource: str) -> str: + response = _run_az("account", "get-access-token", "--resource", resource) + token = response.get("accessToken") + if not token: + raise ToolError(f"Azure CLI did not return an access token for {resource}.") + return token + + +def _request_json( + method: str, + url: str, + token: str, + body: Optional[Any] = None, + accepted_error_codes: Sequence[int] = (), +) -> Tuple[int, Any]: + data = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + text = response.read().decode("utf-8") + return response.status, json.loads(text) if text else None + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8", errors="replace") + try: + detail = json.loads(text) + except json.JSONDecodeError: + detail = text + if exc.code in accepted_error_codes: + return exc.code, detail + raise ToolError(f"Azure request failed with HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise ToolError(f"Azure request failed: {exc.reason}") from exc + + +def _resource_graph_query(token: str, query: str, subscriptions: List[str]) -> List[dict]: + _, response = _request_json( + "POST", + RESOURCE_GRAPH_URL, + token, + {"query": query, "subscriptions": subscriptions}, + ) + return response.get("data", []) if isinstance(response, dict) else [] + + +def _workspace_query(selection: Optional[str]) -> str: + base = "Resources | where type =~ 'microsoft.operationalinsights/workspaces'" + if not selection: + return base + " | project id, name, subscriptionId, resourceGroup, properties" + escaped = selection.replace("'", "''") + return ( + base + + f" | where name =~ '{escaped}' or tostring(properties.customerId) =~ '{escaped}'" + + " | project id, name, subscriptionId, resourceGroup, properties" + ) + + +def discover_workspace( + token: str, + subscription_id: str, + requested_workspace: Optional[str], +) -> Tuple[dict, List[dict], str]: + selection = ( + requested_workspace + or os.getenv("AZURE_SENTINEL_WORKSPACE_ID") + or os.getenv("LA_WORKSPACE_ID") + ) + source = ( + "explicit" + if requested_workspace + else "agent-context" + if selection + else "active-subscription" + ) + + if selection and selection.lower().startswith("/subscriptions/"): + parts = selection.split("/") + if len(parts) < 9: + raise ToolError("The workspace ARM resource ID is malformed.") + selected = { + "id": selection, + "name": parts[-1], + "subscriptionId": parts[2], + "resourceGroup": parts[4], + "properties": {}, + } + return selected, [], source + + rows = _resource_graph_query( + token, + _workspace_query(selection), + [subscription_id], + ) + rows.sort(key=lambda row: (row.get("name") or "").lower()) + if not rows: + detail = f" matching {selection!r}" if selection else "" + raise ToolError( + f"No accessible Log Analytics workspace{detail} was found in the active subscription." + ) + if selection and len(rows) > 1: + matches = "\n".join(f"- {row['id']}" for row in rows) + raise ToolError( + f"Multiple workspaces match {selection!r}. Pass a full ARM resource ID:\n{matches}" + ) + return rows[0], [] if selection else rows, source + + +def find_dcr_for_stream(token: str, workspace: dict, stream: str) -> dict: + workspace_id = workspace["id"] + subscription_id = workspace["subscriptionId"] + rows = _resource_graph_query( + token, + "Resources | where type =~ 'microsoft.insights/datacollectionrules' " + "| project id, name, properties", + [subscription_id], + ) + + candidates = [] + for dcr in rows: + properties = dcr.get("properties") or {} + flow = next( + ( + candidate + for candidate in properties.get("dataFlows", []) + if stream in candidate.get("streams", []) + ), + None, + ) + if flow is None: + continue + destinations = (properties.get("destinations") or {}).get("logAnalytics", []) + if not any( + (destination.get("workspaceResourceId") or "").lower() == workspace_id.lower() + for destination in destinations + ): + continue + immutable_id = properties.get("immutableId") + endpoint_id = properties.get("dataCollectionEndpointId") + if not immutable_id or not endpoint_id: + continue + declared_streams = set( + (properties.get("streamDeclarations") or {}).keys() + ) + flow_streams = { + declared + for candidate in properties.get("dataFlows", []) + for declared in candidate.get("streams", []) + } + candidates.append( + { + "resource": dcr, + "properties": properties, + "flow": flow, + "endpointId": endpoint_id, + "isolated": ( + declared_streams == {stream} + and flow_streams == {stream} + ), + } + ) + + candidates.sort( + key=lambda candidate: ( + not candidate["isolated"], + str(candidate["resource"].get("name") or "").lower(), + ) + ) + if not candidates: + raise ToolError( + f"No DCR targeting workspace {workspace_id!r} declares stream {stream!r}." + ) + + candidate = candidates[0] + flow = candidate["flow"] + output_stream = str(flow.get("outputStream") or stream) + if output_stream.startswith("Microsoft-") and not candidate["isolated"]: + raise ToolError( + f"Only shared DCRs were found for standard-table stream {stream!r}. " + "Provision a contract-specific DCE/DCR pair with exactly one declared " + "input stream, then retry." + ) + dcr = candidate["resource"] + properties = candidate["properties"] + endpoint_id = candidate["endpointId"] + _, dce = _request_json( + "GET", + f"https://management.azure.com{endpoint_id}?api-version={DCE_API_VERSION}", + token, + ) + endpoint = ((dce or {}).get("properties") or {}).get( + "logsIngestion", {} + ).get("endpoint") + if endpoint: + return { + "id": dcr["id"], + "name": dcr["name"], + "immutableId": properties["immutableId"], + "endpoint": endpoint, + "outputStream": output_stream, + "outputTable": _custom_table_name(output_stream), + "isolated": candidate["isolated"], + } + + raise ToolError( + f"DCR {dcr['name']!r} does not expose a Logs Ingestion endpoint." + ) + + +def _workspace_customer_id(token: str, workspace: dict) -> str: + customer_id = str(((workspace.get("properties") or {}).get("customerId") or "")).strip() + if customer_id: + return customer_id + _, response = _request_json( + "GET", + ( + f"https://management.azure.com{workspace['id']}" + f"?api-version={WORKSPACE_API_VERSION}" + ), + token, + ) + customer_id = str(((response or {}).get("properties") or {}).get("customerId") or "").strip() + if not customer_id: + raise ToolError("The selected workspace did not expose its Log Analytics customer ID.") + return customer_id + + +def _permission_allows(permission_sets: List[dict], action: str) -> bool: + requested = action.lower() + for permission_set in permission_sets: + allowed = [ + str(pattern).lower() + for key in ("actions", "dataActions") + for pattern in permission_set.get(key, []) + ] + denied = [ + str(pattern).lower() + for key in ("notActions", "notDataActions") + for pattern in permission_set.get(key, []) + ] + if any(fnmatch.fnmatchcase(requested, pattern) for pattern in allowed) and not any( + fnmatch.fnmatchcase(requested, pattern) for pattern in denied + ): + return True + return False + + +def _custom_table_name(output_stream: str) -> Optional[str]: + if not output_stream.startswith("Custom-"): + return None + table_name = output_stream.removeprefix("Custom-") + return table_name if table_name.endswith("_CL") else None + + +def _walk_resources(resources: Iterable[dict]) -> Iterable[dict]: + for resource in resources: + if not isinstance(resource, dict): + continue + yield resource + nested = resource.get("resources") + if isinstance(nested, list): + yield from _walk_resources(nested) + properties = resource.get("properties") or {} + for template_key in ("template", "mainTemplate"): + template = properties.get(template_key) or {} + if isinstance(template, dict): + yield from _walk_resources(template.get("resources") or []) + + +def _solution_root(solution: str) -> Path: + requested = Path(solution).expanduser() + root = requested.resolve() if requested.is_dir() else REPOSITORY_ROOT / "Solutions" / solution + if not root.is_dir(): + raise ToolError(f"Solution folder does not exist: {root}") + return root + + +def _column_type(value: Any) -> str: + normalized = str(value or "").strip().lower() + aliases = { + "bool": "boolean", + "boolean": "boolean", + "datetime": "dateTime", + "date_time": "dateTime", + "dynamic": "dynamic", + "guid": "guid", + "int": "int", + "integer": "int", + "long": "long", + "real": "real", + "double": "real", + "string": "string", + } + if normalized not in aliases: + raise ToolError(f"Unsupported custom-table column type: {value!r}") + return aliases[normalized] + + +def load_custom_table_contract(solution: str, table_name: str) -> dict: + root = _solution_root(solution) + package = root / "Package" / "mainTemplate.json" + if not package.is_file(): + raise ToolError(f"Solution package was not found: {package}") + try: + template = json.loads(package.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise ToolError(f"Solution package is invalid: {package}: {exc}") from exc + + for resource in _walk_resources(template.get("resources") or []): + if str(resource.get("type") or "").lower() != ( + "microsoft.operationalinsights/workspaces/tables" + ): + continue + properties = resource.get("properties") or {} + schema = properties.get("schema") or {} + if str(schema.get("name") or "") != table_name: + continue + columns = schema.get("columns") + if not isinstance(columns, list) or not columns: + raise ToolError( + f"Packaged table {table_name!r} has no deployable schema columns: {package}" + ) + normalized_columns = [] + for column in columns: + if not isinstance(column, dict) or not column.get("name"): + raise ToolError(f"Packaged table {table_name!r} has an invalid column.") + normalized_columns.append({ + "name": str(column["name"]), + "type": _column_type(column.get("type")), + }) + table_properties: Dict[str, Any] = { + "schema": { + "name": table_name, + "columns": normalized_columns, + } + } + for name in ("plan", "retentionInDays", "totalRetentionInDays"): + if properties.get(name) is not None: + table_properties[name] = properties[name] + return { + "tableName": table_name, + "properties": table_properties, + "schemaSource": str(package), + "columnCount": len(normalized_columns), + "tablePlan": str(table_properties.get("plan") or "Analytics"), + "retentionInDays": table_properties.get("retentionInDays"), + "totalRetentionInDays": table_properties.get("totalRetentionInDays"), + } + + raise ToolError( + f"Solution package does not define custom table {table_name!r}: {package}" + ) + + +def _table_url(workspace: dict, table_name: str) -> str: + return ( + f"https://management.azure.com{workspace['id']}/tables/{table_name}" + f"?api-version={TABLE_API_VERSION}" + ) + + +def _schema_differences(contract: dict, table: dict) -> List[str]: + expected_columns = ( + ((contract.get("properties") or {}).get("schema") or {}).get("columns") or [] + ) + actual_schema = ((table.get("properties") or {}).get("schema") or {}) + actual_columns = [ + *actual_schema.get("columns", []), + *actual_schema.get("standardColumns", []), + ] + actual_by_name = { + str(column.get("name") or "").lower(): _column_type(column.get("type")) + for column in actual_columns + if isinstance(column, dict) and column.get("name") and column.get("type") + } + differences: List[str] = [] + for column in expected_columns: + name = str(column["name"]) + expected_type = _column_type(column["type"]) + actual_type = actual_by_name.get(name.lower()) + if actual_type is None: + differences.append(f"missing column {name}") + elif actual_type != expected_type: + differences.append( + f"column {name} is {actual_type}, expected {expected_type}" + ) + return differences + + +def inspect_custom_table( + arm_token: str, + workspace: dict, + table_name: str, + contract: Optional[dict], + contract_error: Optional[str] = None, +) -> dict: + status, response = _request_json( + "GET", + _table_url(workspace, table_name), + arm_token, + accepted_error_codes=(404,), + ) + if status == 404: + return { + "name": "customLogTable", + "status": "missing", + "detail": f"Custom table {table_name!r} does not exist in the selected workspace.", + "tableName": table_name, + "deploymentAvailable": contract is not None, + "schemaSource": contract.get("schemaSource") if contract else None, + "columnCount": contract.get("columnCount") if contract else None, + "tablePlan": contract.get("tablePlan") if contract else None, + "retentionInDays": contract.get("retentionInDays") if contract else None, + "totalRetentionInDays": ( + contract.get("totalRetentionInDays") if contract else None + ), + "schemaError": contract_error, + } + + if contract_error: + return { + "name": "customLogTable", + "status": "unknown", + "detail": ( + f"Custom table {table_name!r} exists, but its schema could not be " + f"compared with the solution package: {contract_error}" + ), + "tableName": table_name, + "deploymentAvailable": False, + "schemaCompatible": None, + } + + differences = _schema_differences(contract, response or {}) if contract else [] + if differences: + return { + "name": "customLogTable", + "status": "blocked", + "detail": ( + f"Custom table {table_name!r} exists but does not match the packaged schema: " + + "; ".join(differences) + ), + "tableName": table_name, + "deploymentAvailable": False, + "schemaCompatible": False, + } + return { + "name": "customLogTable", + "status": "ready", + "detail": f"Custom table {table_name!r} exists in the selected workspace.", + "tableName": table_name, + "deploymentAvailable": False, + "schemaCompatible": True if contract else None, + } + + +def _effective_permissions(arm_token: str, resource_id: str) -> List[dict]: + _, response = _request_json( + "GET", + ( + f"https://management.azure.com{resource_id}" + "/providers/Microsoft.Authorization/permissions" + f"?api-version={PERMISSIONS_API_VERSION}" + ), + arm_token, + ) + return (response or {}).get("value", []) + + +def _table_deployment_permissions(contract: dict) -> List[str]: + required = [TABLE_WRITE_ACTION] + if (contract.get("properties") or {}).get("plan") is not None: + required.append(WORKSPACE_WRITE_ACTION) + return required + + +def permission_preflight( + arm_token: str, + workspace: dict, + dcr: dict, + table_contract: Optional[dict] = None, + table_contract_error: Optional[str] = None, +) -> dict: + checks: List[dict] = [] + + try: + customer_id = _workspace_customer_id(arm_token, workspace) + query_token = _access_token(LOG_ANALYTICS_RESOURCE) + _request_json( + "POST", + f"https://api.loganalytics.io/v1/workspaces/{customer_id}/query", + query_token, + {"query": "print SentinelMigrationPermissionCheck=1"}, + ) + checks.append({ + "name": "sentinelQueryPermission", + "status": "ready", + "detail": "The selected workspace accepted a read-only Log Analytics query.", + }) + except ToolError as exc: + checks.append({ + "name": "sentinelQueryPermission", + "status": "blocked", + "detail": str(exc), + "requiredPermission": "Workspace query/read access", + }) + + try: + permission_sets = _effective_permissions(arm_token, dcr["id"]) + if _permission_allows(permission_sets, INGESTION_ACTION): + checks.append({ + "name": "logsIngestionPermission", + "status": "ready", + "detail": f"Effective DCR permissions include {INGESTION_ACTION}.", + }) + else: + checks.append({ + "name": "logsIngestionPermission", + "status": "blocked", + "detail": f"Effective DCR permissions do not include {INGESTION_ACTION}.", + "requiredPermission": INGESTION_ACTION, + }) + except ToolError as exc: + checks.append({ + "name": "logsIngestionPermission", + "status": "unknown", + "detail": ( + "Effective DCR permissions could not be inspected without writing data: " + f"{exc}" + ), + "requiredPermission": INGESTION_ACTION, + }) + + table_name = dcr.get("outputTable") + if table_name: + try: + table_check = inspect_custom_table( + arm_token, + workspace, + table_name, + table_contract, + table_contract_error, + ) + checks.append(table_check) + except ToolError as exc: + table_check = { + "name": "customLogTable", + "status": "unknown", + "detail": f"Custom table state could not be inspected: {exc}", + "tableName": table_name, + "deploymentAvailable": False, + "requiredPermission": ( + "Microsoft.OperationalInsights/workspaces/tables/read" + ), + } + checks.append(table_check) + + if table_check["status"] == "missing": + if table_contract is None: + checks.append({ + "name": "customTableDeploymentPermission", + "status": "blocked", + "detail": ( + "The table is missing, but no exact packaged schema was supplied. " + + ( + table_contract_error + if table_contract_error + else "Pass --solution to enable guarded deployment." + ) + ), + "requiredPermission": TABLE_WRITE_ACTION, + }) + else: + required_actions = _table_deployment_permissions(table_contract) + try: + permission_sets = _effective_permissions(arm_token, workspace["id"]) + missing_actions = [ + action + for action in required_actions + if not _permission_allows(permission_sets, action) + ] + if missing_actions: + checks.append({ + "name": "customTableDeploymentPermission", + "status": "blocked", + "detail": ( + "Effective workspace permissions do not allow creation " + f"of {table_name!r}." + ), + "requiredPermissions": missing_actions, + }) + else: + checks.append({ + "name": "customTableDeploymentPermission", + "status": "ready", + "detail": ( + "Effective workspace permissions allow creation of " + f"{table_name!r}." + ), + "requiredPermissions": required_actions, + }) + except ToolError as exc: + checks.append({ + "name": "customTableDeploymentPermission", + "status": "unknown", + "detail": ( + "Effective workspace table permissions could not be " + f"inspected without writing: {exc}" + ), + "requiredPermissions": required_actions, + }) + + return { + "status": ( + "ready" + if all(check["status"] in {"ready", "not-required"} for check in checks) + else "action-required" + ), + "checks": checks, + "writePerformed": False, + } + + +def deploy_custom_table( + arm_token: str, + workspace: dict, + contract: dict, + *, + attempts: int = 20, + delay_seconds: float = 3, +) -> dict: + table_name = contract["tableName"] + status, _ = _request_json( + "PUT", + _table_url(workspace, table_name), + arm_token, + {"properties": contract["properties"]}, + ) + if status not in {200, 201, 202}: + raise ToolError( + f"Custom table deployment returned unexpected HTTP status {status}." + ) + + for _ in range(attempts): + read_status, table = _request_json( + "GET", + _table_url(workspace, table_name), + arm_token, + accepted_error_codes=(404,), + ) + if read_status != 404: + provisioning = str( + ((table or {}).get("properties") or {}).get("provisioningState") or "" + ) + if provisioning.lower() in {"failed", "deleting"}: + raise ToolError( + f"Custom table {table_name!r} provisioning ended in {provisioning!r}." + ) + if provisioning.lower() in {"", "succeeded"}: + differences = _schema_differences(contract, table or {}) + if differences: + raise ToolError( + f"Custom table {table_name!r} was created with an unexpected schema: " + + "; ".join(differences) + ) + return { + "status": "created", + "httpStatus": status, + "tableName": table_name, + "tableResourceId": (table or {}).get("id"), + "schemaSource": contract["schemaSource"], + "columnCount": contract["columnCount"], + "tablePlan": contract.get("tablePlan") or "Analytics", + "retentionInDays": contract.get("retentionInDays"), + "totalRetentionInDays": contract.get("totalRetentionInDays"), + } + time.sleep(delay_seconds) + raise ToolError( + f"Timed out waiting for custom table {table_name!r} to finish provisioning." + ) + + +def load_payload(path: Path) -> List[Dict[str, Any]]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ToolError(f"Cannot read payload {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ToolError(f"Payload {path} is not valid JSON: {exc}") from exc + if not isinstance(payload, list) or not payload: + raise ToolError("The payload must be a non-empty JSON array.") + if not all(isinstance(record, dict) for record in payload): + raise ToolError("Every payload array item must be a JSON object.") + return payload + + +def resolve_payload_path( + payload: Optional[Path], + solution: Optional[str], + rule_id: Optional[str], + fixture: str, + mock_data_folder: Optional[Path], +) -> Path: + if payload: + return payload.resolve() + if not solution or not rule_id: + raise ToolError( + "Provide --payload, or provide both --solution and --rule-id for default discovery." + ) + + folder = ( + mock_data_folder.resolve() + if mock_data_folder + else REPOSITORY_ROOT + / "Sample Data" + / "Solutions" + / "Mock" + / solution + / rule_id + ) + candidate = folder / "mock.json" + if not candidate.is_file() and fixture in {"malicious", "benign"}: + candidate = folder / f"{fixture}.json" + if not candidate.is_file(): + raise ToolError( + f"Mock fixture was not found at {candidate}. Ask the user for the mock-data " + "folder and retry with --mock-data-folder, or pass the file with --payload." + ) + return candidate + + +def validate_scenario_for_ingestion( + payload_path: Path, + stream: str, + fixture: str, +) -> Path: + scenario_path = payload_path.parent / "scenario.json" + if not scenario_path.is_file(): + raise ToolError( + f"Scenario manifest was not found at {scenario_path}. Generate or provide a " + "reviewed scenario before ingestion." + ) + try: + scenario = json.loads(scenario_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ToolError(f"Scenario manifest is invalid: {scenario_path}: {exc}") from exc + if not isinstance(scenario, dict): + raise ToolError(f"Scenario manifest must be a JSON object: {scenario_path}") + + validation = scenario.get("validation") or {} + if validation.get("schemaValidation") != "passed": + raise ToolError("Scenario schema validation has not passed.") + status = scenario.get("generationStatus") + if status != "qualification-ready": + raise ToolError( + f"Scenario status is {status!r}, not 'qualification-ready'; " + "direct live ingestion is not approved for this fixture." + ) + ingestion = scenario.get("ingestion") or {} + if not ingestion.get("directLogsIngestionSupported"): + raise ToolError("Scenario does not support direct Logs Ingestion API testing.") + scenario_stream = ingestion.get("stream") + if scenario_stream != stream: + raise ToolError( + f"Scenario stream {scenario_stream!r} does not match requested stream {stream!r}." + ) + fixtures = scenario.get("fixtures") or {} + count_field = "mockRecords" if payload_path.name == "mock.json" else f"{fixture}Records" + if not isinstance(fixtures.get(count_field), int) or fixtures[count_field] < 1: + raise ToolError("Scenario has no reviewed mock records.") + return scenario_path + + +def ingest( + token: str, + stream: str, + payload: List[Dict[str, Any]], + immutable_id: str, + endpoint: str, +) -> int: + url = ( + f"{endpoint}/dataCollectionRules/{immutable_id}/streams/{stream}" + f"?api-version={STREAM_API_VERSION}" + ) + status, _ = _request_json("POST", url, token, payload) + return status + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", help="Workspace name, customer ID, or ARM resource ID.") + parser.add_argument( + "--target-context", + type=Path, + help="Locked qualification-target.json; disables workspace discovery and enforces tenant/subscription/workspace identity.", + ) + parser.add_argument("--stream", required=True, help="DCR input stream, such as Custom-Example_CL.") + parser.add_argument("--payload", type=Path, help="Explicit reviewed JSON array to ingest.") + parser.add_argument("--solution", help="Solution folder name used for default fixture discovery.") + parser.add_argument("--rule-id", help="Rule folder name or identifier used for default fixture discovery.") + parser.add_argument( + "--fixture", + choices=("mock", "malicious", "benign"), + default="mock", + help=( + "Payload to select during default folder discovery. mock.json is the " + "standard shared AR/CD payload; malicious and benign are legacy fallbacks." + ), + ) + parser.add_argument( + "--mock-data-folder", + type=Path, + help="Override folder containing the shared mock.json payload.", + ) + parser.add_argument( + "--discover-only", + action="store_true", + help="Resolve and display the workspace and DCR without writing data.", + ) + parser.add_argument( + "--deploy-missing-table", + action="store_true", + help="Create the missing packaged custom table, then rerun the preflight.", + ) + parser.add_argument( + "--approve-table-write", + action="store_true", + help="Required acknowledgement of exact workspace/table deployment approval.", + ) + parser.add_argument( + "--approve-write", + action="store_true", + help="Required acknowledgement that exact-scope user approval was obtained.", + ) + parser.add_argument( + "--login", + action="store_true", + help="Run `az login` if Azure CLI has no active authenticated account.", + ) + args = parser.parse_args(argv) + if args.solution and not args.target_context: + parser.error("--target-context is required when --solution is used") + target = _load_target_context(args.target_context) if args.target_context else None + if target: + if ( + args.workspace + and args.workspace.strip().rstrip("/").lower() + != target["workspaceResourceId"].lower() + ): + parser.error("--workspace does not match --target-context") + args.workspace = target["workspaceResourceId"] + + if args.deploy_missing_table: + if args.discover_only: + parser.error("--deploy-missing-table and --discover-only are separate modes") + if not args.approve_table_write: + parser.error("--approve-table-write is required for custom-table deployment") + if not args.solution: + parser.error("--solution is required for custom-table deployment") + if args.approve_write: + parser.error("table deployment and payload ingestion must be approved separately") + elif args.approve_table_write: + parser.error("--approve-table-write requires --deploy-missing-table") + elif not args.discover_only and not args.approve_write: + parser.error("--approve-write is required for ingestion") + + try: + account = _active_account(args.login) + if target: + if str(account.get("tenantId") or "").lower() != str( + target["tenantId"] + ).lower(): + raise ToolError( + "Authenticated tenant does not match the locked qualification target." + ) + if str(account.get("id") or "").lower() != str( + target["subscriptionId"] + ).lower(): + raise ToolError( + "Active subscription does not match the locked qualification target." + ) + arm_token = _access_token(ARM_RESOURCE) + workspace, alternatives, selection_source = discover_workspace( + arm_token, + account["id"], + args.workspace, + ) + dcr = find_dcr_for_stream( + arm_token, + workspace, + args.stream, + ) + workspace_customer_id = _workspace_customer_id(arm_token, workspace) + if target and workspace_customer_id.lower() != str( + target["workspaceCustomerId"] + ).lower(): + raise ToolError( + "Resolved workspace customer ID does not match the locked qualification target." + ) + workspace.setdefault("properties", {})["customerId"] = workspace_customer_id + table_contract = None + table_contract_error = None + if ( + args.solution + and dcr.get("outputTable") + and (args.discover_only or args.deploy_missing_table) + ): + try: + table_contract = load_custom_table_contract( + args.solution, + dcr["outputTable"], + ) + except ToolError as exc: + if args.deploy_missing_table: + raise + table_contract_error = str(exc) + result = { + "tenantId": account.get("tenantId"), + "subscriptionId": workspace["subscriptionId"], + "subscriptionName": account.get("name"), + "workspace": workspace["name"], + "workspaceResourceId": workspace["id"], + "workspaceCustomerId": workspace_customer_id, + "targetContext": str(args.target_context.resolve()) if args.target_context else None, + "workspaceSelectionSource": selection_source, + "workspaceAlternatives": [ + {"name": item.get("name"), "id": item.get("id")} for item in alternatives + ], + "stream": args.stream, + "dcr": dcr["name"], + "dcrResourceId": dcr["id"], + "dcrImmutableId": dcr["immutableId"], + "dceEndpoint": dcr["endpoint"], + "outputStream": dcr.get("outputStream"), + "outputTable": dcr.get("outputTable"), + "writeApproved": bool(args.approve_write), + "tableWriteApproved": bool(args.approve_table_write), + } + + if args.discover_only or args.deploy_missing_table: + result["preflight"] = permission_preflight( + arm_token, + workspace, + dcr, + table_contract, + table_contract_error, + ) + if args.discover_only: + result["status"] = result["preflight"]["status"] + print(json.dumps(result, indent=2)) + return 0 + if args.deploy_missing_table: + table_check = next( + ( + check + for check in result["preflight"]["checks"] + if check["name"] == "customLogTable" + ), + None, + ) + permission_check = next( + ( + check + for check in result["preflight"]["checks"] + if check["name"] == "customTableDeploymentPermission" + ), + None, + ) + if not table_check or table_check["status"] != "missing": + raise ToolError( + "Custom-table deployment is allowed only when preflight confirms " + "that the exact destination table is missing." + ) + if not permission_check or permission_check["status"] != "ready": + raise ToolError( + "Effective workspace permissions do not authorize custom-table deployment." + ) + result["tableDeployment"] = deploy_custom_table( + arm_token, + workspace, + table_contract, + ) + result["preflightAfterDeployment"] = permission_preflight( + arm_token, + workspace, + dcr, + table_contract, + table_contract_error, + ) + result["status"] = result["preflightAfterDeployment"]["status"] + print(json.dumps(result, indent=2)) + return 0 + + payload_path = resolve_payload_path( + args.payload, + args.solution, + args.rule_id, + args.fixture, + args.mock_data_folder, + ) + payload = load_payload(payload_path) + scenario_path = validate_scenario_for_ingestion( + payload_path, + args.stream, + args.fixture, + ) + monitor_token = _access_token(MONITOR_RESOURCE) + result["httpStatus"] = ingest( + monitor_token, + args.stream, + payload, + dcr["immutableId"], + dcr["endpoint"], + ) + result["recordCount"] = len(payload) + result["payload"] = str(payload_path) + result["scenario"] = str(scenario_path) + result["status"] = "accepted" + result["validation"] = "not-run" + print(json.dumps(result, indent=2)) + return 0 + except (ToolError, subprocess.CalledProcessError) as exc: + print(json.dumps({"status": "blocked", "error": str(exc)}, indent=2), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/SolutionMigration/resolve_ingestion_contracts.py b/Tools/SolutionMigration/resolve_ingestion_contracts.py new file mode 100644 index 00000000000..2bafc457c94 --- /dev/null +++ b/Tools/SolutionMigration/resolve_ingestion_contracts.py @@ -0,0 +1,582 @@ +"""Build a no-write ingestion plan over a complete inventory or current package selection.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +import yaml + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +INGESTION_TOOL_ROOT = REPOSITORY_ROOT / "Tools" / "AzureMonitorLogsIngestion" +if str(INGESTION_TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(INGESTION_TOOL_ROOT)) + +from azure_monitor_logs_ingestion.registry import ( # noqa: E402 + DEFAULT_REGISTRY, + find_capability, + resolve_contract, +) + +SOLUTION_GENERATOR_ROOT = REPOSITORY_ROOT / "Tools" / "SolutionMigration" +if str(SOLUTION_GENERATOR_ROOT) not in sys.path: + sys.path.insert(0, str(SOLUTION_GENERATOR_ROOT)) + +import generate_mock_scenario as scenario # noqa: E402 + +MIGRATION_TOOL_ROOT = REPOSITORY_ROOT / "Tools" / "SentinelToXDRMigration" +if str(MIGRATION_TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(MIGRATION_TOOL_ROOT)) + +from sentinel_xdr_migration.packaging_selection import ( # noqa: E402 + _partial_coverage_allowed, + verify_applied_package_selection, +) + +TARGET_FIELDS = ( + "tenantId", + "subscriptionId", + "workspaceResourceId", + "workspaceCustomerId", +) + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _canonical_hash(value: Any) -> str: + return _sha256( + json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + ) + + +def _load_target(path: str | Path) -> dict[str, str]: + target_path = Path(path) + value = json.loads(target_path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("qualification target context must be a JSON object") + target = {field: str(value.get(field, "")).strip() for field in TARGET_FIELDS} + if any(not item for item in target.values()): + raise ValueError( + "qualification target context must contain tenantId, subscriptionId, " + "workspaceResourceId, and workspaceCustomerId" + ) + workspace = target["workspaceResourceId"] + if not workspace.lower().startswith( + "/subscriptions/" + target["subscriptionId"].lower() + "/" + ): + raise ValueError( + "qualification workspace ARM ID does not match its subscriptionId" + ) + if not re.fullmatch( + r"/subscriptions/[^/]+/resourceGroups/[^/]+/" + r"providers/Microsoft\.OperationalInsights/workspaces/[^/]+", + workspace, + re.I, + ): + raise ValueError("qualification workspaceResourceId is not an exact workspace ARM ID") + return target + + +def _rule_files(solution_root: Path) -> list[Path]: + paths: list[Path] = [] + for folder_name in ("Analytic Rules", "Analytics Rules"): + folder = solution_root / folder_name + if folder.exists(): + paths.extend(scenario._content_yaml_files(folder)) + return sorted(set(paths), key=lambda item: item.relative_to(solution_root).as_posix()) + + +def _matching_detection( + solution_root: Path, source_rule: dict +) -> tuple[Path | None, dict | None, str | None]: + folder = solution_root / "XDR Detections" + if not folder.is_dir(): + return None, None, "Converted Custom Detection folder is missing." + source_id = str(source_rule.get("id") or "") + if not source_id: + return None, None, "Source rule has no stable ID for AR/CD pairing." + matches: list[tuple[Path, dict]] = [] + for path in scenario._content_yaml_files(folder): + if path.name.lower() in {"migration-config.yaml", "migration-config.yml"}: + continue + document = scenario._load_yaml(path) + if scenario._detection_source_id(document) == source_id: + matches.append((path, document)) + if len(matches) != 1: + reason = ( + "No converted Custom Detection matches the source rule." + if not matches + else "Multiple converted Custom Detections match the source rule." + ) + return None, None, reason + return matches[0][0], matches[0][1], None + + +def _source_tables(query: str, registry_path: str | Path) -> list[str]: + registry = yaml.safe_load(Path(registry_path).read_text(encoding="utf-8")) + tables = [ + str(item["table"]) + for item in registry.get("tables", []) + if isinstance(item, dict) and item.get("table") + ] + return [ + table + for table in tables + if re.search( + r"(? tuple[dict[str, Any], dict[str, dict[str, Any]]]: + relative_path = path.relative_to(solution_root).as_posix() + file_hash = _sha256(path.read_bytes()) + result: dict[str, Any] = { + "sourcePath": relative_path, + "sourceFileSha256": file_hash, + "ruleId": (document or {}).get("id"), + "ruleName": (document or {}).get("name"), + "status": "blocked", + "blockers": [], + } + if parse_error: + result["blockers"].append(parse_error) + return result, {} + try: + source_query = scenario._query(document) + source_query_hash = _sha256(source_query.encode("utf-8")) + result["sourceQuerySha256"] = source_query_hash + target_query = "" + if selected_detection_path is None: + detection_path, detection, detection_error = _matching_detection( + solution_root, document + ) + else: + detection_path = selected_detection_path.resolve() + output_root = (solution_root / "XDR Detections").resolve() + if ( + not detection_path.is_relative_to(output_root) + or detection_path.is_symlink() + or not detection_path.is_file() + ): + raise ValueError("selected Custom Detection path is missing or unsafe") + detection = scenario._load_yaml(detection_path) + detection_error = ( + None + if scenario._detection_source_id(detection) + == str(document.get("id") or "") + else "Selected Custom Detection does not match the source rule ID." + ) + if detection_error: + result["blockers"].append(detection_error) + else: + target_query = scenario._query(detection) + result["customDetectionPath"] = detection_path.relative_to( + solution_root + ).as_posix() + result["customDetectionFileSha256"] = _sha256( + detection_path.read_bytes() + ) + result["customDetectionQuerySha256"] = _sha256( + target_query.encode("utf-8") + ) + + source_tables = _source_tables(source_query, registry_path) + direct_source = scenario._first_query_source(source_query) + if len(source_tables) > 1: + result["blockers"].append( + "Source query does not resolve to exactly one registered table; " + "review multi-table or unresolved KQL manually." + ) + return result, {} + table = source_tables[0] if source_tables else direct_source + if not table: + result["blockers"].append( + "Source table could not be resolved from the existing query lineage." + ) + return result, {} + target_tables = _source_tables(target_query, registry_path) if target_query else [] + if target_query and ( + len(target_tables) != 1 + or target_tables[0].casefold() != table.casefold() + ): + result["blockers"].append( + "AR and CD queries do not resolve to the same single registered source table." + ) + result["sourceTable"] = table + capability = find_capability(table, registry_path) + if not capability: + result["blockers"].append( + f"No reviewed ingestion contract is registered for {table!r}." + ) + return result, {} + contract = scenario._load_yaml(capability.contract_path) + output_columns = { + str(item["name"]): str(item["type"]) + for item in (contract.get("destination") or {}).get("columns") or [] + if isinstance(item, dict) and item.get("name") and item.get("type") + } + if not output_columns: + output_columns = { + str(item["name"]): str(item["type"]) + for item in contract.get("inputColumns") or [] + if isinstance(item, dict) and item.get("name") and item.get("type") + } + combined_query = source_query + "\n" + target_query + required_fields = scenario._query_fields_from_schema(combined_query, output_columns) + predicate_fields, unresolved_aliases = scenario._query_predicate_fields( + combined_query, + output_columns, + ) + for name, kind in predicate_fields.items(): + if name.casefold() not in { + field.casefold() for field in required_fields + }: + required_fields[name] = kind + resolution = resolve_contract( + table, + required_fields, + registry_path=registry_path, + ) + if unresolved_aliases: + resolution["blockers"] = list(resolution.get("blockers") or []) + [ + f"Decisive predicate field {name!r} is a query alias and cannot be " + "mapped to a source field safely." + for name in unresolved_aliases + ] + resolution["status"] = "blocked" + result["requiredSourceFields"] = required_fields + result["contractResolution"] = { + "status": resolution["status"], + "schemaFingerprint": resolution.get("schemaFingerprint"), + "transformFingerprint": resolution.get("transformFingerprint"), + "liveValidated": resolution.get("liveValidated"), + "references": resolution.get("references"), + "blockers": resolution.get("blockers", []), + } + assessment = scenario.assess_query( + source_query, document if isinstance(document, dict) else {} + ) + result["queryAssessment"] = assessment + blockers = list(result["blockers"]) + blockers.extend(resolution.get("blockers") or []) + if assessment.get("status") != "simple": + blockers.append( + "Scenario behavior requires reviewed fixture generation: " + + "; ".join(assessment.get("reasons") or [assessment.get("status", "review")]) + ) + if target_query: + detection_assessment = scenario.assess_query( + target_query, document if isinstance(document, dict) else {} + ) + result["customDetectionQueryAssessment"] = detection_assessment + if detection_assessment.get("status") != "simple": + blockers.append( + "Converted Custom Detection requires reviewed fixture generation: " + + "; ".join( + detection_assessment.get("reasons") + or [detection_assessment.get("status", "review")] + ) + ) + result["blockers"] = blockers + result["status"] = ( + "ready-for-fixture-review" + if not blockers and resolution["status"] == "provisioning-plan" + else "manual-review-required" + ) + groups: dict[str, dict[str, Any]] = {} + if resolution["status"] == "provisioning-plan": + identity = _canonical_hash( + { + "table": table, + "schemaFingerprint": resolution["schemaFingerprint"], + "transformFingerprint": resolution["transformFingerprint"], + } + ) + groups[identity] = { + "table": table, + "schemaFingerprint": resolution["schemaFingerprint"], + "transformFingerprint": resolution["transformFingerprint"], + "route": resolution["route"], + "outputStream": resolution["outputStream"], + "contractPath": capability.contract_path.relative_to( + Path(registry_path).resolve().parent + ).as_posix(), + "ruleIds": [result.get("ruleId")], + } + return result, groups + except (OSError, ValueError, KeyError, TypeError, RuntimeError) as exc: + result["blockers"].append(str(exc)) + return result, {} + + +def build_plan( + solution_root: str | Path, + target_context: dict[str, str], + *, + registry_path: str | Path = DEFAULT_REGISTRY, + expected_inventory_sha256: str | None = None, + expected_target_sha256: str | None = None, + selection_plan_path: str | Path | None = None, + expected_selection_plan_sha256: str | None = None, +) -> dict[str, Any]: + root = Path(solution_root).resolve() + if not root.is_dir(): + raise ValueError(f"solution folder does not exist: {root}") + rule_paths = _rule_files(root) + if not rule_paths: + raise ValueError(f"no recursive Analytic Rule YAML files found under {root}") + + selection = None + selected_detection_by_rule: dict[str, Path] = {} + selection_scope: dict[str, Any] | None = None + if selection_plan_path is not None: + selection_path = Path(selection_plan_path).expanduser().resolve() + try: + selection = json.loads(selection_path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read applied package-selection plan: {exc}") from exc + run_id = selection.get("runId") if isinstance(selection, dict) else None + plan_sha = selection.get("planSha256") if isinstance(selection, dict) else None + if ( + not isinstance(run_id, str) + or not run_id + or not isinstance(plan_sha, str) + or expected_selection_plan_sha256 != plan_sha + ): + raise ValueError("package-selection plan hash and run binding are required") + applied = verify_applied_package_selection(root, run_id) + if ( + not applied + or applied["plan"].get("planSha256") != plan_sha + or (root / applied["planPath"]).resolve() != selection_path + ): + raise ValueError("qualification requires the current applied package-selection plan") + selection = applied["plan"] + selection_summary = selection.get("summary") or {} + if ( + selection_summary.get("outcome") not in {"partial", "complete"} + or not _partial_coverage_allowed(selection_summary) + ): + raise ValueError("applied package selection does not meet validated detection coverage") + for item in selection.get("xdrDetections") or []: + if item.get("included") is not True: + continue + rule_id = str(item.get("sourceId") or "").casefold() + relative = str(item.get("path") or "").replace("\\", "/") + if not rule_id or not relative.startswith("XDR Detections/"): + raise ValueError("applied selection has an invalid selected detection identity") + if rule_id in selected_detection_by_rule: + raise ValueError("applied selection contains multiple detections for one source rule") + selected_detection_by_rule[rule_id] = (root / relative).resolve() + selected_rule_ids = { + key for key in selected_detection_by_rule + } + declared_ids = { + str(value).casefold() + for value in selection_summary.get("validatedMigratedSourceRuleIds") or [] + } + if selected_rule_ids != declared_ids: + raise ValueError("package-selection coverage does not match selected Custom Detection pairs") + selection_scope = { + "kind": "applied-package-selection", + "planSha256": plan_sha, + "outcome": selection_summary.get("outcome"), + "originalSourceRuleCount": selection_summary.get("originalSourceRuleCount"), + "validatedMigratedSourceRuleCount": selection_summary.get( + "validatedMigratedSourceRuleCount" + ), + "coverageNumerator": selection_summary.get("coverageNumerator"), + "coverageDenominator": selection_summary.get("coverageDenominator"), + "partialPublicationAllowed": selection_summary.get("partialPublicationAllowed"), + "selectedDetectionPaths": sorted( + str(item.get("path")) + for item in selection.get("xdrDetections") or [] + if item.get("included") is True + ), + "excludedAnalyticRules": [ + { + "id": item.get("id"), + "path": item.get("path"), + "reasons": item.get("reasons") or [], + } + for item in selection.get("analyticRules") or [] + if item.get("included") is not True + ], + "excludedDetections": [ + { + "id": item.get("id"), + "sourceId": item.get("sourceId"), + "path": item.get("path"), + "reasons": item.get("reasons") or [], + } + for item in selection.get("xdrDetections") or [] + if item.get("included") is not True + ], + } + + rules: list[dict[str, Any]] = [] + grouped: dict[str, dict[str, Any]] = {} + inventory_material: list[dict[str, str]] = [] + seen_ids: dict[str, str] = {} + for path in rule_paths: + try: + document = scenario._load_yaml(path) + parse_error = None + except (OSError, ValueError, RuntimeError) as exc: + document = None + parse_error = str(exc) + rule_id = str((document or {}).get("id") or "").casefold() + if selection is not None and (parse_error or not rule_id): + raise ValueError( + f"complete source inventory cannot be bound to package selection: {path}" + ) + if selection is not None and rule_id not in selected_detection_by_rule: + rule_result = { + "sourcePath": path.relative_to(root).as_posix(), + "sourceFileSha256": _sha256(path.read_bytes()), + "ruleId": (document or {}).get("id"), + "ruleName": (document or {}).get("name"), + "status": "not-selected", + "blockers": ["excluded-by-current-applied-package-selection"], + } + groups = {} + else: + rule_result, groups = _rule_plan( + root, + path, + document, + parse_error, + registry_path, + selected_detection_by_rule.get(rule_id) if selection is not None else None, + ) + rule_id = str(rule_result.get("ruleId") or "") + if rule_id and rule_id in seen_ids: + rule_result["blockers"].append( + f"Duplicate source rule ID also appears in {seen_ids[rule_id]}." + ) + rule_result["status"] = "blocked" + groups = {} + elif rule_id: + seen_ids[rule_id] = rule_result["sourcePath"] + rules.append(rule_result) + inventory_material.append( + { + "sourcePath": rule_result["sourcePath"], + "sourceFileSha256": rule_result["sourceFileSha256"], + "sourceQuerySha256": rule_result.get("sourceQuerySha256", ""), + "customDetectionFileSha256": rule_result.get( + "customDetectionFileSha256", "" + ), + "customDetectionQuerySha256": rule_result.get( + "customDetectionQuerySha256", "" + ), + "ruleId": rule_id, + } + ) + for identity, group in groups.items(): + if identity not in grouped: + grouped[identity] = group + else: + grouped[identity]["ruleIds"].append(rule_result.get("ruleId")) + + inventory_sha256 = _canonical_hash(inventory_material) + target_sha256 = _canonical_hash(target_context) + stale_reasons = [] + if expected_inventory_sha256 and expected_inventory_sha256 != inventory_sha256: + stale_reasons.append("Current recursive source inventory hash differs from the expected plan.") + if expected_target_sha256 and expected_target_sha256 != target_sha256: + stale_reasons.append("Locked qualification target differs from the expected plan.") + counts: dict[str, int] = {} + for item in rules: + counts[item["status"]] = counts.get(item["status"], 0) + 1 + return { + "version": 1, + "status": "stale-plan" if stale_reasons else "planned", + "solution": root.name, + "target": target_context, + "targetSha256": target_sha256, + "qualificationScope": ( + selection_scope + if selection_scope is not None + else { + "kind": "complete-source-inventory", + "originalSourceRuleCount": len(rules), + "validatedMigratedSourceRuleCount": None, + } + ), + "inventory": { + "complete": True, + "recursiveRuleCount": len(rules), + "sha256": inventory_sha256, + "statuses": counts, + }, + "groups": sorted( + grouped.values(), + key=lambda item: (item["table"].casefold(), item["schemaFingerprint"]), + ), + "rules": rules, + "staleReasons": stale_reasons, + "writesPerformed": False, + "cloudCallsPerformed": False, + "scope": "structured mock ingestion only; no connector/parser validation", + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--solution", required=True, type=Path) + parser.add_argument("--target-context", required=True, type=Path) + parser.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY) + parser.add_argument("--expected-inventory-sha256") + parser.add_argument("--expected-target-sha256") + parser.add_argument("--selection-plan", type=Path) + parser.add_argument("--expected-selection-plan-sha256") + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + target = _load_target(args.target_context) + plan = build_plan( + args.solution, + target, + registry_path=args.registry, + expected_inventory_sha256=args.expected_inventory_sha256, + expected_target_sha256=args.expected_target_sha256, + selection_plan_path=args.selection_plan, + expected_selection_plan_sha256=args.expected_selection_plan_sha256, + ) + output = json.dumps(plan, indent=2, ensure_ascii=False) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + if args.output.exists(): + raise FileExistsError( + f"plan output already exists; choose a new evidence path: {args.output}" + ) + args.output.write_text(output, encoding="utf-8") + else: + print(output, end="") + return 1 if plan["status"] == "stale-plan" else 0 + except (OSError, ValueError, RuntimeError, yaml.YAMLError) as exc: + print(str(exc), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/SolutionMigration/tests/test_clean_xdr_detection_outputs.py b/Tools/SolutionMigration/tests/test_clean_xdr_detection_outputs.py new file mode 100644 index 00000000000..df5b3d63e85 --- /dev/null +++ b/Tools/SolutionMigration/tests/test_clean_xdr_detection_outputs.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import clean_xdr_detection_outputs as cleanup + + +DETECTION = """\ +id: 11111111-1111-1111-1111-111111111111 +name: Example detection +query: | + DeviceEvents + | where ActionType == "Example" +""" + +RICH_DETECTION = """\ +schemaVersion: 1.0.0 +kind: CustomDetection +resourceType: Microsoft.Security/detectionRules +properties: + id: xdr-example + displayName: Example rich detection + queryCondition: + queryText: | + DeviceEvents + | where ActionType == "Example" +""" + + +class XdrDetectionCleanupTests(unittest.TestCase): + def test_preserves_detection_yaml_and_moves_reports_and_config(self): + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + root = Path(temp) + xdr = root / "Solutions" / "Example" / "XDR Detections" + xdr.mkdir(parents=True) + (xdr / "ExampleDetection.yaml").write_text(DETECTION, encoding="utf-8") + (xdr / "RichDetection.yaml").write_text( + RICH_DETECTION, encoding="utf-8" + ) + (xdr / "migration-config.yaml").write_text("version: 1\n", encoding="utf-8") + (xdr / "migration-report.json").write_text("{}", encoding="utf-8") + (xdr / "runtime-validation.html").write_text("", encoding="utf-8") + + plan = cleanup.plan_cleanup(root) + result = cleanup.apply_cleanup(plan) + + self.assertEqual(3, result["movedCount"]) + self.assertTrue((xdr / "ExampleDetection.yaml").is_file()) + self.assertEqual( + ["ExampleDetection.yaml", "RichDetection.yaml"], + sorted(path.name for path in xdr.iterdir()), + ) + report_root = cleanup.report_directory(xdr.parent) + self.assertEqual("sentinel-xdr-migration", report_root.parent.name) + self.assertTrue((report_root / "migration-config.yaml").is_file()) + self.assertTrue((report_root / "migration-report.json").is_file()) + self.assertTrue((report_root / "runtime-validation.html").is_file()) + + def test_dry_run_plan_does_not_modify_files(self): + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + root = Path(temp) + xdr = root / "Solutions" / "Example" / "XDR Detections" + xdr.mkdir(parents=True) + report = xdr / "workflow-state.json" + report.write_text(json.dumps({"status": "pending"}), encoding="utf-8") + + plan = cleanup.plan_cleanup(root) + + self.assertEqual(1, len(plan)) + self.assertTrue(report.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SolutionMigration/tests/test_generate_mock_scenario.py b/Tools/SolutionMigration/tests/test_generate_mock_scenario.py new file mode 100644 index 00000000000..59c70f2b02c --- /dev/null +++ b/Tools/SolutionMigration/tests/test_generate_mock_scenario.py @@ -0,0 +1,1019 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import generate_mock_scenario as generator + + +RULE = { + "id": "rule-123", + "name": "Example - Delete activity", + "query": "ExampleParser\n| where EventMessage =~ 'Delete activity'", + "triggerOperator": "gt", + "triggerThreshold": 0, +} + +DETECTION = { + "id": "detection-123", + "sourceRuleId": "rule-123", + "name": "Example - Delete activity", + "query": "DeviceEvents\n| where ActionType =~ 'Delete activity'", +} + + +class QueryExtractionTests(unittest.TestCase): + def test_nested_rule_and_detection_selection_preserves_relative_identity(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.cwd()) as temp: + root = Path(temp) + solution = root / "Solutions" / "Example" + for index, relative in enumerate(("Execution/Deep/rule.yml", "Persistence/rule.yml", "ROOT.YAML")): + rule = dict(RULE, id=f"source-{index}") + detection = dict(DETECTION, sourceRuleId=rule["id"]) + _write_yaml(solution / "Analytic Rules" / relative, rule) + _write_yaml(solution / "XDR Detections" / relative, detection) + with mock.patch.object(generator, "REPOSITORY_ROOT", root): + source, rule = generator.find_analytic_rule("Example", "Execution\\Deep\\rule.yml") + detection, _ = generator.find_custom_detection("Example", None, rule) + self.assertEqual("Execution/Deep/rule.yml", source.relative_to(solution / "Analytic Rules").as_posix()) + self.assertEqual("Execution/Deep/rule.yml", detection.relative_to(solution / "XDR Detections").as_posix()) + selected, _ = generator.find_custom_detection("Example", "Execution/Deep/rule.yml", rule) + self.assertEqual(detection, selected) + self.assertEqual("ROOT.YAML", generator.find_analytic_rule("Example", "source-2")[0].name) + with self.assertRaisesRegex(generator.ScenarioError, "ambiguous"): + generator.find_analytic_rule("Example", "rule") + with self.assertRaisesRegex(generator.ScenarioError, "Multiple"): + generator.find_custom_detection("Example", "rule", rule) + + def test_reads_converted_custom_detection_query(self) -> None: + document = { + "properties": { + "queryCondition": { + "queryText": "DeviceEvents | take 1", + } + } + } + + self.assertEqual( + "DeviceEvents | take 1", + generator._query(document), + ) + + def test_extracts_first_value_from_dynamic_let_list(self) -> None: + query = ( + "let reputation = dynamic(['unknown', 'badHost']);\n" + "Cloudflare\n" + "| where ClientIPClass in~ (reputation)" + ) + + self.assertEqual( + [{ + "column": "ClientIPClass", + "operator": "in~", + "value": "unknown", + }], + generator.extract_predicates(query), + ) + + def test_structured_event_parsing_requires_review(self) -> None: + assessment = generator.assess_query( + "SecurityEvent | extend EventData=parse_xml(EventData) " + "| mv-expand EventData | evaluate bag_unpack(EventData)", + {}, + ) + + self.assertEqual("manual-review-required", assessment["status"]) + self.assertIn( + "The query parses or expands structured event content.", + assessment["reasons"], + ) + + def test_unmodeled_content_predicate_requires_review(self) -> None: + assessment = generator.assess_query( + 'SecurityEvent | where CommandLine has_all ("-r", "-s")', + {}, + ) + + self.assertEqual("manual-review-required", assessment["status"]) + self.assertIn( + "The query uses a content predicate that automatic fixtures do not model.", + assessment["reasons"], + ) + + def test_reads_converted_custom_detection_identity(self) -> None: + document = { + "contentProvenance": {"source": {"id": "rule-123"}}, + "properties": { + "id": "detection-123", + "displayName": "Example detection", + }, + } + + self.assertEqual("rule-123", generator._detection_source_id(document)) + self.assertEqual( + "detection-123", + generator._detection_property(document, "id"), + ) + + def test_query_alias_predicate_is_not_reported_as_a_source_field(self) -> None: + query = ( + "CommonSecurityLog\n" + "| extend EventClass = DeviceEventClassID\n" + "| where EventClass == '733101'" + ) + + required, aliases = generator._query_predicate_fields( + query, + {"DeviceEventClassID": "string", "EventClass": "string"}, + ) + + self.assertEqual({}, required) + self.assertEqual(["EventClass"], aliases) + self.assertEqual( + {"DeviceEventClassID": "string"}, + generator._query_fields_from_schema( + query, + {"DeviceEventClassID": "string", "EventClass": "string"}, + ), + ) + + +def _write_yaml(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(value, sort_keys=False), encoding="utf-8") + + +def _create_custom_solution(root: Path) -> None: + solution = root / "Solutions" / "Example" + _write_yaml(solution / "Analytic Rules" / "rule.yaml", RULE) + _write_yaml(solution / "XDR Detections" / "rule.yaml", DETECTION) + _write_yaml( + solution / "Parsers" / "ExampleParser.yaml", + { + "FunctionAlias": "ExampleParser", + "FunctionQuery": "Example_CL | project EventMessage", + }, + ) + dcr = { + "type": "Microsoft.Insights/dataCollectionRules", + "name": "ExampleDcr", + "properties": { + "streamDeclarations": { + "Custom-Example_CL": { + "columns": [ + {"name": "message", "type": "string"}, + {"name": "created", "type": "datetime"}, + {"name": "requestId", "type": "string"}, + ] + } + }, + "dataFlows": [ + { + "streams": ["Custom-Example_CL"], + "outputStream": "Custom-Example_CL", + "transformKql": ( + "source | project-rename EventMessage=message " + "| extend TimeGenerated=todatetime(created)" + ), + } + ], + }, + } + template = { + "resources": [ + { + "type": "Microsoft.Resources/templateSpecs/versions", + "properties": {"mainTemplate": {"resources": [dcr]}}, + } + ] + } + package = solution / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True, exist_ok=True) + package.write_text(json.dumps(template), encoding="utf-8") + + +class MockScenarioGeneratorTests(unittest.TestCase): + def test_routes_source_candidate_fixture_and_output_across_worktrees(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source_root = root / "azure-sentinel-source" + output_root = root / "azure-sentinel-target" / "Sample Data" / "Solutions" / "Mock" + _create_custom_solution(source_root) + (root / "toolkit-sample-data" / "Tables").mkdir(parents=True) + rule_path = source_root / "Solutions" / "Example" / "Analytic Rules" / "rule.yaml" + complex_rule = dict(RULE) + complex_rule["query"] = ( + "ExampleParser | where EventMessage == 'synthetic-match-a' " + "| summarize Events=count() by EventMessage " + "| where Events > 1" + ) + _write_yaml(rule_path, complex_rule) + detection_path = source_root / "Solutions" / "Example" / "XDR Detections" / "rule.yaml" + detection = dict(DETECTION, query=complex_rule["query"]) + _write_yaml(detection_path, detection) + candidate_path = root / "candidate-scenario.json" + candidate_path.write_text( + json.dumps({ + "name": "candidate-multi-event", + "hypothesis": "Two synthetic records are a provisional count-scenario candidate.", + "lockedPaths": ["requestId"], + "maliciousRecords": [ + { + "message": "synthetic-match-a", + "created": "2026-10-11T12:00:00Z", + "requestId": "campaign-unique-1", + }, + { + "message": "synthetic-match-a", + "created": "2026-10-11T12:01:00Z", + "requestId": "campaign-unique-1", + }, + ], + "benignRecords": [ + { + "message": "synthetic-benign", + "created": "2026-10-11T12:02:00Z", + "requestId": "benign-unique-1", + } + ], + }), + encoding="utf-8", + ) + + result = generator.generate_scenario( + "Example", + str(rule_path), + custom_detection_selector=str(detection_path), + candidate_scenario=candidate_path, + source_root=source_root, + toolkit_root=Path(generator.__file__).resolve().parents[2], + sample_data_root=root / "toolkit-sample-data", + output_root=output_root, + ) + reused = generator.generate_scenario( + "Example", + str(rule_path), + custom_detection_selector=str(detection_path), + candidate_scenario=candidate_path, + source_root=source_root, + toolkit_root=Path(generator.__file__).resolve().parents[2], + sample_data_root=root / "toolkit-sample-data", + output_root=output_root, + ) + payload = json.loads(Path(result["mockPath"]).read_text(encoding="utf-8")) + manifest = result["scenario"] + + self.assertEqual(output_root.resolve(), Path(result["paths"]["outputRoot"])) + self.assertEqual(source_root.resolve(), Path(result["paths"]["sourceRoot"])) + self.assertEqual( + Path(result["paths"]["toolkitRoot"]), + Path(manifest["toolkitRoot"]), + ) + self.assertEqual( + Path(result["paths"]["sampleDataRoot"]), + Path(manifest["sampleDataRoot"]), + ) + self.assertEqual(2, len(payload)) + self.assertEqual(1, manifest["fixtures"]["benignValidationRecords"]) + self.assertEqual("manual-review-required", manifest["queryAssessment"]["status"]) + self.assertEqual("generated-offline-only", manifest["generationStatus"]) + self.assertEqual("generated-candidate", result["status"]) + self.assertEqual("review-pending", manifest["scenarioReview"]["status"]) + self.assertEqual("candidate", manifest["scenarioReview"]["inputKind"]) + self.assertEqual("pending", manifest["validation"]["semanticReview"]) + self.assertEqual("not-run", manifest["validation"]["semanticMatch"]) + self.assertEqual("existing-candidate", reused["status"]) + with self.assertRaisesRegex(generator.ScenarioError, "review the existing fixture first"): + generator.generate_scenario( + "Example", + str(rule_path), + custom_detection_selector=str(detection_path), + candidate_scenario=candidate_path, + force=True, + source_root=source_root, + toolkit_root=Path(generator.__file__).resolve().parents[2], + sample_data_root=root / "toolkit-sample-data", + output_root=output_root, + ) + changed_candidate = json.loads(candidate_path.read_text(encoding="utf-8")) + changed_candidate["hypothesis"] = "Updated provisional hypothesis." + candidate_path.write_text(json.dumps(changed_candidate), encoding="utf-8") + with self.assertRaisesRegex(generator.ScenarioError, "input hash or review state"): + generator.generate_scenario( + "Example", + str(rule_path), + custom_detection_selector=str(detection_path), + candidate_scenario=candidate_path, + source_root=source_root, + toolkit_root=Path(generator.__file__).resolve().parents[2], + sample_data_root=root / "toolkit-sample-data", + output_root=output_root, + ) + self.assertEqual( + Path(result["scenarioPath"]).parents[2], + output_root.resolve(), + ) + self.assertFalse((source_root / "Sample Data" / "Solutions" / "Mock").exists()) + + def test_blocks_missing_or_symlinked_reviewed_scenario(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + _create_custom_solution(root) + candidate_path = root / "candidate.json" + candidate_path.write_text("{}", encoding="utf-8") + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + with self.assertRaisesRegex(generator.ScenarioError, "Scenario input was not found"): + generator.generate_scenario( + "Example", + "rule-123", + candidate_scenario=root / "missing.json", + ) + if hasattr(Path, "symlink_to"): + link = root / "candidate-link.json" + link.symlink_to(candidate_path) + with self.assertRaisesRegex(generator.ScenarioError, "must not be a symlink"): + generator.generate_scenario( + "Example", + "rule-123", + candidate_scenario=link, + ) + output_root = root / "mock-output" + output_root.mkdir() + target = root / "outside-target" + target.mkdir() + (output_root / "Example").symlink_to(target, target_is_directory=True) + with self.assertRaisesRegex(generator.ScenarioError, "Symlink output"): + generator.generate_scenario( + "Example", + "rule-123", + output_root=output_root, + ) + + def test_candidate_on_simple_rule_remains_offline_and_pending_review(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + _create_custom_solution(root) + candidate_path = root / "candidate.json" + candidate_path.write_text( + json.dumps({ + "name": "simple-rule-candidate", + "hypothesis": "Provisional simple predicate candidate.", + "lockedPaths": ["requestId"], + "maliciousRecords": [{ + "message": "Delete activity", + "created": "2026-10-11T12:00:00Z", + "requestId": "candidate-unique", + }], + "benignRecords": [{ + "message": "not the matching action", + "created": "2026-10-11T12:01:00Z", + "requestId": "benign-unique", + }], + }), + encoding="utf-8", + ) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + result = generator.generate_scenario( + "Example", + "rule-123", + candidate_scenario=candidate_path, + ) + with self.assertRaisesRegex(generator.ScenarioError, "not both"): + generator.generate_scenario( + "Example", + "rule-123", + reviewed_scenario=candidate_path, + candidate_scenario=candidate_path, + ) + + scenario = result["scenario"] + self.assertEqual("simple", scenario["queryAssessment"]["status"]) + self.assertEqual("generated-offline-only", scenario["generationStatus"]) + self.assertEqual("review-pending", scenario["scenarioReview"]["status"]) + self.assertEqual("pending", scenario["validation"]["semanticReview"]) + self.assertEqual("not-run", scenario["validation"]["semanticMatch"]) + self.assertNotEqual("qualification-ready", scenario["generationStatus"]) + + def test_randomization_uses_declared_types(self): + original = { + "created": "2026-09-17T00:00:00Z", + "count": 1, + "score": 1.5, + "enabled": True, + "details": {"source": "synthetic"}, + "message": "original", + } + records = generator.randomized_copies( + [original], + copies=1, + seed=17, + locked_paths=[], + column_types={ + "created": "datetime", + "count": "long", + "score": "real", + "enabled": "boolean", + "details": "dynamic", + "message": "string", + }, + ) + + randomized = records[0] + repeated = generator.randomized_copies( + [original], + copies=1, + seed=17, + locked_paths=[], + column_types={ + "created": "datetime", + "count": "long", + "score": "real", + "enabled": "boolean", + "details": "dynamic", + "message": "string", + }, + ) + generator.validate_records(records, { + "created": "datetime", + "count": "long", + "score": "real", + "enabled": "boolean", + "details": "dynamic", + "message": "string", + }) + self.assertEqual(records, repeated) + self.assertEqual(original["details"], randomized["details"]) + self.assertNotEqual(original["message"], randomized["message"]) + + def test_generates_custom_dcr_scenario_and_reuses_it(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + _create_custom_solution(root) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + result = generator.generate_scenario( + "Example", + "rule-123", + randomize=True, + copies=2, + seed=7, + ) + existing = generator.generate_scenario("Example", "rule-123") + + scenario = result["scenario"] + mock_records = json.loads(Path(result["mockPath"]).read_text(encoding="utf-8")) + + self.assertEqual("qualification-ready", scenario["generationStatus"]) + self.assertEqual("Custom-Example_CL", scenario["ingestion"]["stream"]) + self.assertEqual("message", scenario["mapping"]["decisiveFields"][0]["rawPath"]) + self.assertEqual(2, len(mock_records)) + self.assertTrue(all(item["message"] == "Delete activity" for item in mock_records)) + self.assertEqual(2, scenario["fixtures"]["benignValidationRecords"]) + self.assertEqual("existing-scenario", existing["status"]) + + def test_generates_standard_table_offline_scenario(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + solution = root / "Solutions" / "Example" + standard_rule = { + "id": "rule-standard", + "name": "Device action", + "query": "DeviceEvents\n| where ActionType == 'ExampleAction'", + "triggerOperator": "gt", + "triggerThreshold": 0, + } + _write_yaml(solution / "Analytic Rules" / "rule.yaml", standard_rule) + _write_yaml( + solution / "XDR Detections" / "rule.yaml", + { + "id": "cd-standard", + "sourceRuleId": "rule-standard", + "name": "Device action", + "query": "DeviceEvents\n| where ActionType == 'ExampleAction'", + }, + ) + package = solution / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True) + package.write_text('{"resources":[]}', encoding="utf-8") + table = root / "Sample Data" / "Tables" / "DeviceEvents" + table.mkdir(parents=True) + (table / "base-event.json").write_text( + json.dumps({"ActionType": "synthetic", "Timestamp": "2026-09-17T00:00:00Z"}), + encoding="utf-8", + ) + (table / "metadata.json").write_text( + json.dumps({ + "table": "DeviceEvents", + "schemaSource": "Microsoft public table schema", + "columns": {"ActionType": "string", "Timestamp": "datetime"}, + }), + encoding="utf-8", + ) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + result = generator.generate_scenario("Example", "rule-standard") + + self.assertEqual("generated-offline-only", result["scenario"]["generationStatus"]) + self.assertFalse(result["scenario"]["ingestion"]["directLogsIngestionSupported"]) + + def test_generates_supported_standard_table_ingestion_contract(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + solution = root / "Solutions" / "Example" + _write_yaml( + solution / "Analytic Rules" / "rule.yaml", + { + "id": "rule-event", + "name": "Synthetic Event", + "query": "Event\n| where EventID == 13", + "triggerOperator": "gt", + "triggerThreshold": 0, + }, + ) + _write_yaml( + solution / "XDR Detections" / "rule.yaml", + { + "id": "cd-event", + "sourceRuleId": "rule-event", + "name": "Synthetic Event", + "query": "Event\n| where EventID == 13", + }, + ) + package = solution / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True) + package.write_text('{"resources":[]}', encoding="utf-8") + table = root / "Sample Data" / "Tables" / "Event" + table.mkdir(parents=True) + (table / "base-event.json").write_text( + json.dumps({ + "Computer": "synthetic-host.example.invalid", + "EventID": 0, + "TenantId": "must-be-pruned", + "TimeGenerated": "2026-09-22T00:00:00Z", + }), + encoding="utf-8", + ) + (table / "metadata.json").write_text( + json.dumps({ + "table": "Event", + "schemaSource": "Microsoft public table schema", + "columns": { + "Computer": "string", + "EventID": "int", + "TenantId": "string", + "TimeGenerated": "datetime", + }, + }), + encoding="utf-8", + ) + contracts = root / "contracts" + _write_yaml( + contracts / "Event.yaml", + { + "version": 1, + "name": "event-mock", + "workspace": "qualification-target", + "inputStream": "Custom-EventMock", + "inputColumns": [ + {"name": "Computer", "type": "string"}, + {"name": "EventID", "type": "int"}, + {"name": "TimeGenerated", "type": "datetime"}, + ], + "transformKql": "source | project Computer, EventID, TimeGenerated", + "destination": { + "kind": "standard", + "table": "Event", + "outputStream": "Microsoft-Event", + }, + "resources": { + "dce": "event-mock-dce", + "dcr": "event-mock-dcr", + }, + }, + ) + _write_yaml( + root / "registry.yaml", + { + "version": 1, + "tables": [ + { + "table": "Event", + "contract": "contracts/Event.yaml", + "route": "logs-ingestion-api", + "inputKind": "custom-stream", + "outputKind": "standard-table", + "outputStream": "Microsoft-Event", + "schemaReference": "https://learn.microsoft.com/azure/azure-monitor/reference/tables/event", + "metadataReference": "https://api.loganalytics.io/v1/metadata", + "capabilityReference": "https://learn.microsoft.com/azure/azure-monitor/reference/tables-features", + "routeReference": "https://learn.microsoft.com/azure/azure-monitor/logs/logs-ingestion-api-overview", + "liveValidated": False, + } + ], + }, + ) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object( + generator, + "TABLE_ROOT", + root / "Sample Data" / "Tables", + ), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ), \ + mock.patch.object( + generator, + "STANDARD_CONTRACT_ROOT", + contracts, + ): + result = generator.generate_scenario("Example", "rule-event") + + mock_records = json.loads( + Path(result["mockPath"]).read_text(encoding="utf-8") + ) + contract_path = Path(result["scenario"]["ingestion"]["contract"]) + manifest = result["scenario"] + + self.assertEqual("qualification-ready", result["scenario"]["generationStatus"]) + self.assertTrue(result["scenario"]["ingestion"]["directLogsIngestionSupported"]) + self.assertEqual("standard-dcr", result["scenario"]["ingestion"]["mode"]) + self.assertEqual("Custom-EventMock", result["scenario"]["ingestion"]["stream"]) + self.assertTrue(contract_path.name == "ingestion-contract.yaml") + self.assertNotIn("TenantId", mock_records[0]) + self.assertEqual(root.resolve(), Path(manifest["toolkitRoot"])) + self.assertEqual( + (root / "Sample Data").resolve(), + Path(manifest["sampleDataRoot"]), + ) + + def test_unsupported_standard_predicate_blocks_qualification(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source_root = root / "source" + toolkit_root = Path(generator.__file__).resolve().parents[2] + solution = source_root / "Solutions" / "Example" + query = ( + 'CommonSecurityLog | where DeviceEventClassID == "733101" ' + "| where DestinationUserName == 'alice'" + ) + _write_yaml( + solution / "Analytic Rules" / "rule.yaml", + { + "id": "rule-csl-unsupported", + "name": "Common security event", + "query": query, + "triggerOperator": "gt", + "triggerThreshold": 0, + }, + ) + _write_yaml( + solution / "XDR Detections" / "rule.yaml", + { + "id": "cd-csl-unsupported", + "sourceRuleId": "rule-csl-unsupported", + "name": "Common security event", + "query": query, + }, + ) + package = solution / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True) + package.write_text('{"resources":[]}', encoding="utf-8") + table = root / "Sample Data" / "Tables" / "CommonSecurityLog" + table.mkdir(parents=True) + (table / "base-event.json").write_text( + json.dumps({ + "DeviceEventClassID": "0", + "DestinationUserName": "synthetic-user", + }), + encoding="utf-8", + ) + (table / "metadata.json").write_text( + json.dumps({ + "table": "CommonSecurityLog", + "schemaSource": "Microsoft public table schema", + "columns": { + "DeviceEventClassID": "string", + "DestinationUserName": "string", + }, + }), + encoding="utf-8", + ) + + result = generator.generate_scenario( + "Example", + "rule-csl-unsupported", + source_root=source_root, + toolkit_root=toolkit_root, + sample_data_root=root / "Sample Data", + output_root=root / "output", + ) + + scenario = result["scenario"] + record = json.loads( + Path(result["mockPath"]).read_text(encoding="utf-8") + )[0] + + self.assertEqual("generated-offline-only", scenario["generationStatus"]) + self.assertEqual("offline-standard-table", scenario["ingestion"]["mode"]) + self.assertFalse(scenario["ingestion"]["directLogsIngestionSupported"]) + self.assertEqual("733101", record["DeviceEventClassID"]) + self.assertEqual("alice", record["DestinationUserName"]) + self.assertTrue( + any( + "DestinationUserName" in blocker + for blocker in scenario["ingestion"]["contractResolution"]["blockers"] + ) + ) + + def test_explicit_toolkit_root_selects_its_sample_data_by_default(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source_root = root / "source" + toolkit_root = root / "toolkit" + solution = source_root / "Solutions" / "Example" + _write_yaml( + solution / "Analytic Rules" / "rule.yaml", + { + "id": "rule-toolkit-sample", + "name": "Synthetic event", + "query": "Event | where EventID == 13", + "triggerOperator": "gt", + "triggerThreshold": 0, + }, + ) + _write_yaml( + solution / "XDR Detections" / "rule.yaml", + { + "id": "cd-toolkit-sample", + "sourceRuleId": "rule-toolkit-sample", + "name": "Synthetic event", + "query": "Event | where EventID == 13", + }, + ) + package = solution / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True) + package.write_text('{"resources":[]}', encoding="utf-8") + registry_root = toolkit_root / "Tools" / "AzureMonitorLogsIngestion" + _write_yaml( + registry_root / "contracts" / "Event.yaml", + { + "version": 1, + "name": "event-mock", + "workspace": "qualification-target", + "inputStream": "Custom-EventMock", + "inputColumns": [ + {"name": "EventID", "type": "int"}, + {"name": "Computer", "type": "string"}, + ], + "transformKql": "source | project EventID=toint(EventID), Computer=tostring(Computer)", + "destination": { + "kind": "standard", + "table": "Event", + "outputStream": "Microsoft-Event", + "columns": [ + {"name": "EventID", "type": "int"}, + {"name": "Computer", "type": "string"}, + ], + }, + "resources": {"dce": "event-dce", "dcr": "event-dcr"}, + }, + ) + _write_yaml( + registry_root / "registry.yaml", + { + "version": 1, + "tables": [ + { + "table": "Event", + "contract": "contracts/Event.yaml", + "route": "logs-ingestion-api", + "inputKind": "custom-stream", + "outputKind": "standard-table", + "outputStream": "Microsoft-Event", + "schemaReference": "https://example.invalid/schema", + "metadataReference": "https://example.invalid/metadata", + "capabilityReference": "https://example.invalid/capability", + "routeReference": "https://example.invalid/route", + "liveValidated": False, + } + ], + }, + ) + sample_table = toolkit_root / "Sample Data" / "Tables" / "Event" + sample_table.mkdir(parents=True) + (sample_table / "base-event.json").write_text( + json.dumps({"EventID": 0, "Computer": "toolkit-sample"}), + encoding="utf-8", + ) + (sample_table / "metadata.json").write_text( + json.dumps({ + "table": "Event", + "columns": {"EventID": "int", "Computer": "string"}, + }), + encoding="utf-8", + ) + decoy_table_root = source_root / "Sample Data" / "Tables" + decoy_table_root.mkdir(parents=True) + + with mock.patch.object(generator, "TABLE_ROOT", decoy_table_root): + result = generator.generate_scenario( + "Example", + "rule-toolkit-sample", + source_root=source_root, + toolkit_root=toolkit_root, + output_root=root / "output", + ) + + manifest = result["scenario"] + record = json.loads( + Path(result["mockPath"]).read_text(encoding="utf-8") + )[0] + + self.assertEqual("qualification-ready", manifest["generationStatus"]) + self.assertEqual(toolkit_root.resolve(), Path(manifest["toolkitRoot"])) + self.assertEqual( + (toolkit_root / "Sample Data").resolve(), + Path(manifest["sampleDataRoot"]), + ) + self.assertEqual(13, record["EventID"]) + self.assertEqual("toolkit-sample", record["Computer"]) + + def test_common_security_log_scenario_uses_registered_contract_identity(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + solution = root / "Solutions" / "Example" + _write_yaml( + solution / "Analytic Rules" / "rule.yaml", + { + "id": "rule-csl", + "name": "Common security event", + "query": ( + 'CommonSecurityLog | where DeviceEventClassID == "733101" ' + "| where isempty(CommunicationDirection)" + ), + "triggerOperator": "gt", + "triggerThreshold": 0, + }, + ) + _write_yaml( + solution / "XDR Detections" / "rule.yaml", + { + "id": "cd-csl", + "sourceRuleId": "rule-csl", + "name": "Common security event", + "query": ( + 'CommonSecurityLog | where DeviceEventClassID == "733101" ' + "| where isempty(CommunicationDirection)" + ), + }, + ) + package = solution / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True) + package.write_text('{"resources":[]}', encoding="utf-8") + table = root / "Sample Data" / "Tables" / "CommonSecurityLog" + table.mkdir(parents=True) + (table / "base-event.json").write_text( + json.dumps( + { + "TimeGenerated": "2026-09-17T00:00:00Z", + "DeviceEventClassID": "0", + "CommunicationDirection": "1", + "DeviceName": "firewall.example.invalid", + "SourceIP": "192.0.2.10", + } + ), + encoding="utf-8", + ) + (table / "metadata.json").write_text( + json.dumps( + { + "table": "CommonSecurityLog", + "schemaSource": "Microsoft Learn CommonSecurityLog reference", + "columns": { + "TimeGenerated": "datetime", + "DeviceEventClassID": "string", + "CommunicationDirection": "string", + "DeviceName": "string", + "SourceIP": "string", + }, + } + ), + encoding="utf-8", + ) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + result = generator.generate_scenario( + "Example", + "rule-csl", + source_root=root, + toolkit_root=Path(generator.__file__).resolve().parents[2], + sample_data_root=root / "Sample Data", + output_root=root / "target" / "Sample Data" / "Solutions" / "Mock", + ) + + record = json.loads(Path(result["mockPath"]).read_text(encoding="utf-8"))[0] + ingestion = result["scenario"]["ingestion"] + + self.assertEqual("qualification-ready", result["scenario"]["generationStatus"]) + self.assertEqual("standard-dcr", ingestion["mode"]) + self.assertEqual("Custom-XDRMigrationCommonSecurityLogMock", ingestion["stream"]) + self.assertEqual( + "provisioning-plan", + ingestion["contractResolution"]["status"], + ) + self.assertFalse(ingestion["contractResolution"]["liveValidated"]) + self.assertTrue(ingestion["contractResolution"]["schemaFingerprint"]) + self.assertTrue(ingestion["contractResolution"]["transformFingerprint"]) + self.assertEqual("733101", record["DeviceEventClassID"]) + self.assertEqual("", record["CommunicationDirection"]) + self.assertEqual( + (root / "target" / "Sample Data" / "Solutions" / "Mock").resolve(), + Path(result["paths"]["outputRoot"]), + ) + self.assertFalse((root / "Sample Data" / "Solutions" / "Mock").exists()) + + def test_existing_scenario_rejects_changed_source_query_hash(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + _create_custom_solution(root) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + generator.generate_scenario("Example", "rule-123") + rule_path = root / "Solutions" / "Example" / "Analytic Rules" / "rule.yaml" + changed_rule = dict(RULE) + changed_rule["query"] = ( + "ExampleParser\n| where EventMessage =~ 'Different activity'" + ) + _write_yaml(rule_path, changed_rule) + + with self.assertRaisesRegex(generator.ScenarioError, "source hashes"): + generator.generate_scenario("Example", "rule-123") + + def test_complex_rule_requires_reviewed_scenario(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + _create_custom_solution(root) + rule_path = root / "Solutions" / "Example" / "Analytic Rules" / "rule.yaml" + complex_rule = dict(RULE) + complex_rule["query"] = ( + "ExampleParser | summarize Events=count() by EventMessage " + "| where Events > 3" + ) + _write_yaml(rule_path, complex_rule) + with mock.patch.object(generator, "REPOSITORY_ROOT", root), \ + mock.patch.object(generator, "TABLE_ROOT", root / "Sample Data" / "Tables"), \ + mock.patch.object( + generator, + "MOCK_ROOT", + root / "Sample Data" / "Solutions" / "Mock", + ): + with self.assertRaisesRegex(generator.ScenarioError, "reviewed-scenario"): + generator.generate_scenario("Example", "rule-123") + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SolutionMigration/tests/test_ingest_to_dcr.py b/Tools/SolutionMigration/tests/test_ingest_to_dcr.py new file mode 100644 index 00000000000..aaa54ab70c7 --- /dev/null +++ b/Tools/SolutionMigration/tests/test_ingest_to_dcr.py @@ -0,0 +1,711 @@ +from __future__ import annotations + +import json +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import ingest_to_dcr + + +class ScenarioHandoffTests(unittest.TestCase): + def test_target_context_rejects_subscription_mismatch(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "qualification-target.json" + path.write_text( + json.dumps( + { + "locked": True, + "tenantId": "tenant-id", + "subscriptionId": "expected-subscription", + "workspaceResourceId": ( + "/subscriptions/other-subscription/resourceGroups/rg/" + "providers/Microsoft.OperationalInsights/workspaces/ws" + ), + "workspaceCustomerId": "customer-id", + } + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex( + ingest_to_dcr.ToolError, + "workspace and subscription identifiers do not match", + ): + ingest_to_dcr._load_target_context(path) + + def test_target_context_forces_exact_workspace(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "qualification-target.json" + workspace_id = ( + "/subscriptions/subscription-id/resourceGroups/rg/providers/" + "Microsoft.OperationalInsights/workspaces/ws" + ) + path.write_text( + json.dumps( + { + "locked": True, + "tenantId": "tenant-id", + "subscriptionId": "subscription-id", + "workspaceResourceId": workspace_id, + "workspaceCustomerId": "customer-id", + } + ), + encoding="utf-8", + ) + + target = ingest_to_dcr._load_target_context(path) + + self.assertEqual(workspace_id, target["workspaceResourceId"]) + + @mock.patch.object(ingest_to_dcr.shutil, "which") + def test_windows_azure_cli_resolution_uses_command_shim( + self, + which: mock.Mock, + ): + which.return_value = r"C:\AzureCLI\az.cmd" + with mock.patch.object(ingest_to_dcr.os, "name", "nt"): + result = ingest_to_dcr._az_executable() + + self.assertEqual(r"C:\AzureCLI\az.cmd", result) + which.assert_called_once_with("az.cmd") + + @mock.patch.object(ingest_to_dcr, "_resource_graph_query") + def test_full_workspace_resource_id_never_lists_workspaces( + self, + resource_graph_query: mock.Mock, + ): + workspace_id = ( + "/subscriptions/subscription-id/resourceGroups/resource-group/providers/" + "Microsoft.OperationalInsights/workspaces/workspace-name" + ) + + selected, alternatives, source = ingest_to_dcr.discover_workspace( + "arm-token", + "active-subscription", + workspace_id, + ) + + self.assertEqual(workspace_id, selected["id"]) + self.assertEqual("subscription-id", selected["subscriptionId"]) + self.assertEqual([], alternatives) + self.assertEqual("explicit", source) + resource_graph_query.assert_not_called() + + @mock.patch.object(ingest_to_dcr, "_resource_graph_query") + def test_customer_id_uses_one_exact_filtered_resolution( + self, + resource_graph_query: mock.Mock, + ): + customer_id = "756386d8-e2d4-4f09-905a-74b24313721f" + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "name": "w", + "subscriptionId": "s", + "resourceGroup": "r", + "properties": {"customerId": customer_id}, + } + resource_graph_query.return_value = [workspace] + + selected, alternatives, source = ingest_to_dcr.discover_workspace( + "arm-token", + "active-subscription", + customer_id, + ) + + self.assertEqual(workspace, selected) + self.assertEqual([], alternatives) + self.assertEqual("explicit", source) + resource_graph_query.assert_called_once() + self.assertIn(customer_id, resource_graph_query.call_args.args[1]) + + def test_permission_matching_honors_wildcards_and_denies(self): + self.assertTrue(ingest_to_dcr._permission_allows( + [{"actions": ["Microsoft.Insights/*"], "notActions": []}], + ingest_to_dcr.INGESTION_ACTION, + )) + self.assertFalse(ingest_to_dcr._permission_allows( + [{ + "actions": ["Microsoft.Insights/*"], + "notActions": ["Microsoft.Insights/Telemetry/Write"], + }], + ingest_to_dcr.INGESTION_ACTION, + )) + + @mock.patch.object(ingest_to_dcr, "_request_json") + @mock.patch.object(ingest_to_dcr, "_resource_graph_query") + def test_dcr_discovery_prefers_isolated_standard_stream( + self, + resource_graph_query: mock.Mock, + request_json: mock.Mock, + ): + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "subscriptionId": "s", + } + resource_graph_query.return_value = [ + { + "id": "/shared", + "name": "shared", + "properties": { + "immutableId": "shared-id", + "dataCollectionEndpointId": "/shared-dce", + "streamDeclarations": { + "Custom-Example": {}, + "Custom-Other": {}, + }, + "destinations": { + "logAnalytics": [{ + "workspaceResourceId": workspace["id"], + }] + }, + "dataFlows": [ + { + "streams": ["Custom-Example"], + "outputStream": "Microsoft-Event", + }, + { + "streams": ["Custom-Other"], + "outputStream": "Microsoft-Syslog", + }, + ], + }, + }, + { + "id": "/isolated", + "name": "isolated", + "properties": { + "immutableId": "isolated-id", + "dataCollectionEndpointId": "/isolated-dce", + "streamDeclarations": {"Custom-Example": {}}, + "destinations": { + "logAnalytics": [{ + "workspaceResourceId": workspace["id"], + }] + }, + "dataFlows": [{ + "streams": ["Custom-Example"], + "outputStream": "Microsoft-Event", + }], + }, + }, + ] + request_json.return_value = ( + 200, + { + "properties": { + "logsIngestion": { + "endpoint": "https://isolated.ingest.monitor.azure.com" + } + } + }, + ) + + result = ingest_to_dcr.find_dcr_for_stream( + "arm-token", + workspace, + "Custom-Example", + ) + + self.assertEqual("isolated", result["name"]) + self.assertTrue(result["isolated"]) + self.assertIn("/isolated-dce", request_json.call_args.args[1]) + + @mock.patch.object(ingest_to_dcr, "_resource_graph_query") + def test_dcr_discovery_rejects_shared_standard_stream( + self, + resource_graph_query: mock.Mock, + ): + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "subscriptionId": "s", + } + resource_graph_query.return_value = [{ + "id": "/shared", + "name": "shared", + "properties": { + "immutableId": "shared-id", + "dataCollectionEndpointId": "/shared-dce", + "streamDeclarations": { + "Custom-Example": {}, + "Custom-Other": {}, + }, + "destinations": { + "logAnalytics": [{ + "workspaceResourceId": workspace["id"], + }] + }, + "dataFlows": [{ + "streams": ["Custom-Example"], + "outputStream": "Microsoft-Event", + }], + }, + }] + + with self.assertRaisesRegex( + ingest_to_dcr.ToolError, + "contract-specific DCE/DCR pair", + ): + ingest_to_dcr.find_dcr_for_stream( + "arm-token", + workspace, + "Custom-Example", + ) + + @mock.patch.object( + ingest_to_dcr, + "_access_token", + return_value="query-token", + ) + @mock.patch.object(ingest_to_dcr, "_request_json") + def test_permission_preflight_reports_ready_without_writing( + self, + request_json: mock.Mock, + _access_token: mock.Mock, + ): + request_json.side_effect = [ + (200, {"tables": []}), + ( + 200, + { + "value": [{ + "actions": ["Microsoft.Insights/Telemetry/Write"], + "notActions": [], + }] + }, + ), + ] + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "properties": {"customerId": "customer-id"}, + } + dcr = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.Insights/dataCollectionRules/d", + } + + result = ingest_to_dcr.permission_preflight("arm-token", workspace, dcr) + + self.assertEqual("ready", result["status"]) + self.assertFalse(result["writePerformed"]) + self.assertTrue(all(check["status"] == "ready" for check in result["checks"])) + + @mock.patch.object( + ingest_to_dcr, + "_access_token", + return_value="query-token", + ) + @mock.patch.object(ingest_to_dcr, "_request_json") + def test_permission_preflight_reports_query_and_ingestion_failures( + self, + request_json: mock.Mock, + _access_token: mock.Mock, + ): + request_json.side_effect = [ + ingest_to_dcr.ToolError("query forbidden"), + (200, {"value": [{"actions": [], "notActions": []}]}), + ] + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "properties": {"customerId": "customer-id"}, + } + dcr = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.Insights/dataCollectionRules/d", + } + + result = ingest_to_dcr.permission_preflight("arm-token", workspace, dcr) + + self.assertEqual("action-required", result["status"]) + self.assertEqual( + ["blocked", "blocked"], + [check["status"] for check in result["checks"]], + ) + + @mock.patch.object( + ingest_to_dcr, + "_access_token", + return_value="query-token", + ) + @mock.patch.object(ingest_to_dcr, "_request_json") + def test_permission_preflight_offers_approved_missing_table_recovery( + self, + request_json: mock.Mock, + _access_token: mock.Mock, + ): + request_json.side_effect = [ + (200, {"tables": []}), + ( + 200, + { + "value": [{ + "actions": ["Microsoft.Insights/Telemetry/Write"], + "notActions": [], + }] + }, + ), + (404, {"error": {"code": "ResourceNotFound"}}), + ( + 200, + { + "value": [{ + "actions": [ + "Microsoft.OperationalInsights/workspaces/tables/write" + ], + "notActions": [], + }] + }, + ), + ] + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "properties": {"customerId": "customer-id"}, + } + dcr = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.Insights/dataCollectionRules/d", + "outputTable": "Example_CL", + } + contract = { + "tableName": "Example_CL", + "schemaSource": "mainTemplate.json", + "columnCount": 2, + "properties": { + "schema": { + "name": "Example_CL", + "columns": [ + {"name": "TimeGenerated", "type": "dateTime"}, + {"name": "Message", "type": "string"}, + ], + } + }, + } + + result = ingest_to_dcr.permission_preflight( + "arm-token", + workspace, + dcr, + contract, + ) + + self.assertEqual("action-required", result["status"]) + checks = {check["name"]: check for check in result["checks"]} + self.assertEqual("missing", checks["customLogTable"]["status"]) + self.assertTrue(checks["customLogTable"]["deploymentAvailable"]) + self.assertEqual( + "ready", + checks["customTableDeploymentPermission"]["status"], + ) + self.assertFalse(result["writePerformed"]) + + def test_loads_exact_table_schema_from_packaged_solution(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + package = root / "Solutions" / "Sample" / "Package" / "mainTemplate.json" + package.parent.mkdir(parents=True) + package.write_text( + json.dumps({ + "resources": [{ + "type": "Wrapper", + "properties": { + "mainTemplate": { + "resources": [{ + "type": ( + "Microsoft.OperationalInsights/" + "workspaces/tables" + ), + "name": "Example_CL", + "properties": { + "schema": { + "name": "Example_CL", + "columns": [ + { + "name": "TimeGenerated", + "type": "datetime", + }, + { + "name": "Message", + "type": "string", + }, + ], + } + }, + }] + } + }, + }] + }), + encoding="utf-8", + ) + with mock.patch.object(ingest_to_dcr, "REPOSITORY_ROOT", root): + contract = ingest_to_dcr.load_custom_table_contract( + "Sample", + "Example_CL", + ) + + self.assertEqual("Example_CL", contract["tableName"]) + self.assertEqual(2, contract["columnCount"]) + self.assertEqual("Analytics", contract["tablePlan"]) + self.assertEqual( + "dateTime", + contract["properties"]["schema"]["columns"][0]["type"], + ) + + @mock.patch.object(ingest_to_dcr.time, "sleep") + @mock.patch.object(ingest_to_dcr, "_request_json") + def test_deploys_missing_table_and_verifies_schema( + self, + request_json: mock.Mock, + sleep: mock.Mock, + ): + request_json.side_effect = [ + (202, None), + ( + 200, + { + "id": "/subscriptions/s/tables/Example_CL", + "properties": { + "provisioningState": "Succeeded", + "schema": { + "columns": [ + {"name": "TimeGenerated", "type": "dateTime"}, + {"name": "Message", "type": "string"}, + ] + }, + }, + }, + ), + ] + workspace = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + } + contract = { + "tableName": "Example_CL", + "schemaSource": "mainTemplate.json", + "columnCount": 2, + "properties": { + "schema": { + "name": "Example_CL", + "columns": [ + {"name": "TimeGenerated", "type": "dateTime"}, + {"name": "Message", "type": "string"}, + ], + } + }, + } + + result = ingest_to_dcr.deploy_custom_table( + "arm-token", + workspace, + contract, + delay_seconds=0, + ) + + self.assertEqual("created", result["status"]) + self.assertEqual("Example_CL", result["tableName"]) + sleep.assert_not_called() + self.assertEqual("PUT", request_json.call_args_list[0].args[0]) + self.assertEqual( + {"properties": contract["properties"]}, + request_json.call_args_list[0].args[3], + ) + + def test_refuses_table_deployment_without_separate_approval(self): + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + ingest_to_dcr.main( + [ + "--stream", + "Custom-Example_CL", + "--solution", + "Sample", + "--deploy-missing-table", + ] + ) + + @mock.patch.object(ingest_to_dcr, "deploy_custom_table") + @mock.patch.object(ingest_to_dcr, "permission_preflight") + @mock.patch.object(ingest_to_dcr, "load_custom_table_contract") + @mock.patch.object(ingest_to_dcr, "find_dcr_for_stream") + @mock.patch.object(ingest_to_dcr, "discover_workspace") + @mock.patch.object(ingest_to_dcr, "_access_token", return_value="arm-token") + @mock.patch.object(ingest_to_dcr, "_active_account") + def test_approved_table_recovery_deploys_then_retries_preflight( + self, + active_account: mock.Mock, + _access_token: mock.Mock, + discover_workspace: mock.Mock, + find_dcr: mock.Mock, + load_contract: mock.Mock, + permission_preflight: mock.Mock, + deploy_table: mock.Mock, + ): + active_account.return_value = { + "id": "subscription-id", + "tenantId": "tenant-id", + "name": "subscription", + } + workspace = { + "id": "/subscriptions/subscription-id/resourceGroups/r/providers/" + "Microsoft.OperationalInsights/workspaces/w", + "name": "w", + "subscriptionId": "subscription-id", + "properties": {"customerId": "customer-id"}, + } + discover_workspace.return_value = (workspace, [workspace], "explicit") + dcr = { + "id": "/subscriptions/s/resourceGroups/r/providers/" + "Microsoft.Insights/dataCollectionRules/d", + "name": "d", + "immutableId": "dcr-id", + "endpoint": "https://example.ingest.monitor.azure.com", + "outputStream": "Custom-Example_CL", + "outputTable": "Example_CL", + } + find_dcr.return_value = dcr + contract = { + "tableName": "Example_CL", + "schemaSource": "mainTemplate.json", + "columnCount": 2, + "tablePlan": "Analytics", + "retentionInDays": None, + "totalRetentionInDays": None, + "properties": {"schema": {"name": "Example_CL", "columns": []}}, + } + load_contract.return_value = contract + permission_preflight.side_effect = [ + { + "status": "action-required", + "checks": [ + {"name": "customLogTable", "status": "missing"}, + { + "name": "customTableDeploymentPermission", + "status": "ready", + }, + ], + "writePerformed": False, + }, + { + "status": "ready", + "checks": [{"name": "customLogTable", "status": "ready"}], + "writePerformed": False, + }, + ] + deploy_table.return_value = { + "status": "created", + "tableName": "Example_CL", + } + + with tempfile.TemporaryDirectory() as temp: + target_path = Path(temp) / "qualification-target.json" + target_path.write_text( + json.dumps( + { + "locked": True, + "tenantId": "tenant-id", + "subscriptionId": "subscription-id", + "workspaceResourceId": workspace["id"], + "workspaceCustomerId": "customer-id", + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = ingest_to_dcr.main( + [ + "--target-context", + str(target_path), + "--stream", + "Custom-Example_CL", + "--solution", + "Sample", + "--deploy-missing-table", + "--approve-table-write", + ] + ) + + self.assertEqual(0, exit_code) + deploy_table.assert_called_once_with("arm-token", workspace, contract) + self.assertEqual("ready", json.loads(stdout.getvalue())["status"]) + + def test_accepts_qualification_ready_matching_stream(self): + with tempfile.TemporaryDirectory() as temp: + folder = Path(temp) + payload = folder / "mock.json" + payload.write_text('[{"message":"match"}]', encoding="utf-8") + scenario = { + "generationStatus": "qualification-ready", + "ingestion": { + "directLogsIngestionSupported": True, + "stream": "Custom-Example_CL", + }, + "fixtures": {"mockRecords": 1}, + "validation": {"schemaValidation": "passed"}, + } + (folder / "scenario.json").write_text(json.dumps(scenario), encoding="utf-8") + + result = ingest_to_dcr.validate_scenario_for_ingestion( + payload, + "Custom-Example_CL", + "mock", + ) + + self.assertEqual(folder / "scenario.json", result) + + def test_default_payload_discovery_uses_shared_mock_json(self): + with tempfile.TemporaryDirectory() as temp: + folder = Path(temp) + mock_path = folder / "mock.json" + mock_path.write_text('[{"message":"match"}]', encoding="utf-8") + + result = ingest_to_dcr.resolve_payload_path( + None, + "Sample", + "rule-id", + "mock", + folder, + ) + + self.assertEqual(mock_path, result) + + def test_rejects_offline_only_scenario(self): + with tempfile.TemporaryDirectory() as temp: + folder = Path(temp) + payload = folder / "mock.json" + payload.write_text('[{"message":"match"}]', encoding="utf-8") + scenario = { + "generationStatus": "generated-offline-only", + "ingestion": { + "directLogsIngestionSupported": False, + "stream": None, + }, + "fixtures": {"mockRecords": 1}, + "validation": {"schemaValidation": "passed"}, + } + (folder / "scenario.json").write_text(json.dumps(scenario), encoding="utf-8") + + with self.assertRaisesRegex(ingest_to_dcr.ToolError, "qualification-ready"): + ingest_to_dcr.validate_scenario_for_ingestion( + payload, + "Microsoft-DeviceEvents", + "mock", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SolutionMigration/tests/test_resolve_ingestion_contracts.py b/Tools/SolutionMigration/tests/test_resolve_ingestion_contracts.py new file mode 100644 index 00000000000..ad7349ac44d --- /dev/null +++ b/Tools/SolutionMigration/tests/test_resolve_ingestion_contracts.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from unittest import mock + +import yaml + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) +MIGRATION_ROOT = TOOLS.parent / "SentinelToXDRMigration" +sys.path.insert(0, str(MIGRATION_ROOT)) +INGESTION_ROOT = TOOLS.parent / "AzureMonitorLogsIngestion" +sys.path.insert(0, str(INGESTION_ROOT)) + +import resolve_ingestion_contracts as planner + + +TARGET = { + "tenantId": "tenant-id", + "subscriptionId": "subscription-id", + "workspaceResourceId": ( + "/subscriptions/subscription-id/resourceGroups/lab-rg/providers/" + "Microsoft.OperationalInsights/workspaces/lab-workspace" + ), + "workspaceCustomerId": "workspace-customer-id", +} + + +def _write_yaml(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(value, sort_keys=False), encoding="utf-8") + + +def _create_solution(root: Path, contract_root: Path) -> tuple[Path, Path]: + solution = root / "Solutions" / "Example" + rule_path = solution / "Analytic Rules" / "Nested" / "rule.yaml" + detection_path = solution / "XDR Detections" / "Nested" / "detection.yaml" + _write_yaml( + rule_path, + { + "id": "rule-1", + "name": "Synthetic network event", + "query": ( + 'CommonSecurityLog | where DeviceEventClassID == "733101"' + ), + }, + ) + _write_yaml( + detection_path, + { + "id": "detection-1", + "sourceRuleId": "rule-1", + "name": "Synthetic network event", + "query": ( + 'CommonSecurityLog | where DeviceEventClassID == "733101"' + ), + }, + ) + destination = contract_root / "contracts" / "CommonSecurityLog.yaml" + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile( + INGESTION_ROOT / "contracts" / "CommonSecurityLog.yaml", + destination, + ) + _write_yaml( + contract_root / "registry.yaml", + { + "version": 1, + "tables": [ + { + "table": "CommonSecurityLog", + "contract": "contracts/CommonSecurityLog.yaml", + "route": "logs-ingestion-api", + "inputKind": "custom-stream", + "outputKind": "standard-table", + "outputStream": "Microsoft-CommonSecurityLog", + "schemaReference": "https://learn.microsoft.com/azure/azure-monitor/reference/tables/commonsecuritylog", + "metadataReference": "https://api.loganalytics.io/v1/metadata", + "capabilityReference": "https://learn.microsoft.com/azure/azure-monitor/reference/tables-features", + "routeReference": "https://learn.microsoft.com/azure/azure-monitor/logs/logs-ingestion-api-overview", + "liveValidated": False, + } + ], + }, + ) + return solution, rule_path + + +def test_recursive_inventory_plan_groups_by_table_and_binds_exact_target(tmp_path): + solution, _ = _create_solution(tmp_path, tmp_path / "ingestion") + + plan = planner.build_plan( + solution, + TARGET, + registry_path=tmp_path / "ingestion" / "registry.yaml", + ) + + assert plan["status"] == "planned" + assert plan["writesPerformed"] is False + assert plan["cloudCallsPerformed"] is False + assert plan["target"] == TARGET + assert plan["inventory"]["complete"] is True + assert plan["inventory"]["recursiveRuleCount"] == 1 + assert plan["rules"][0]["status"] == "ready-for-fixture-review" + assert plan["rules"][0]["sourcePath"] == "Analytic Rules/Nested/rule.yaml" + assert plan["rules"][0]["customDetectionPath"] == ( + "XDR Detections/Nested/detection.yaml" + ) + assert plan["rules"][0]["requiredSourceFields"] == { + "DeviceEventClassID": "string" + } + assert len(plan["groups"]) == 1 + assert plan["groups"][0]["ruleIds"] == ["rule-1"] + + +def test_plan_blocks_predicate_fields_missing_from_registered_contract(tmp_path): + solution, rule_path = _create_solution(tmp_path, tmp_path / "ingestion") + query = ( + 'CommonSecurityLog | where DeviceEventClassID == "733101" ' + "| where DestinationUserName == 'alice'" + ) + rule = yaml.safe_load(rule_path.read_text(encoding="utf-8")) + rule["query"] = query + _write_yaml(rule_path, rule) + detection_path = solution / "XDR Detections" / "Nested" / "detection.yaml" + detection = yaml.safe_load(detection_path.read_text(encoding="utf-8")) + detection["query"] = query + _write_yaml(detection_path, detection) + + plan = planner.build_plan( + solution, + TARGET, + registry_path=tmp_path / "ingestion" / "registry.yaml", + ) + + planned_rule = plan["rules"][0] + assert planned_rule["status"] == "manual-review-required" + assert planned_rule["requiredSourceFields"] == { + "DeviceEventClassID": "string", + "DestinationUserName": None, + } + assert any( + "DestinationUserName" in blocker + for blocker in planned_rule["contractResolution"]["blockers"] + ) + + +def test_plan_does_not_report_output_alias_as_a_source_requirement(tmp_path): + solution, rule_path = _create_solution(tmp_path, tmp_path / "ingestion") + query = ( + "CommonSecurityLog | extend EventClass = DeviceEventClassID " + "| where EventClass == '733101'" + ) + rule = yaml.safe_load(rule_path.read_text(encoding="utf-8")) + rule["query"] = query + _write_yaml(rule_path, rule) + detection_path = solution / "XDR Detections" / "Nested" / "detection.yaml" + detection = yaml.safe_load(detection_path.read_text(encoding="utf-8")) + detection["query"] = query + _write_yaml(detection_path, detection) + + plan = planner.build_plan( + solution, + TARGET, + registry_path=tmp_path / "ingestion" / "registry.yaml", + ) + + planned_rule = plan["rules"][0] + assert planned_rule["status"] == "manual-review-required" + assert "EventClass" not in planned_rule["requiredSourceFields"] + assert any( + "EventClass" in blocker and "query alias" in blocker + for blocker in planned_rule["contractResolution"]["blockers"] + ) + + +def test_plan_rejects_stale_inventory_and_target_identity(tmp_path): + solution, rule_path = _create_solution(tmp_path, tmp_path / "ingestion") + registry_path = tmp_path / "ingestion" / "registry.yaml" + first = planner.build_plan(solution, TARGET, registry_path=registry_path) + expected_inventory = first["inventory"]["sha256"] + expected_target = first["targetSha256"] + + rule = yaml.safe_load(rule_path.read_text(encoding="utf-8")) + rule["query"] += "\n| where DeviceProduct == 'ASA'" + _write_yaml(rule_path, rule) + changed_target = dict(TARGET, workspaceCustomerId="different-customer") + stale = planner.build_plan( + solution, + changed_target, + registry_path=registry_path, + expected_inventory_sha256=expected_inventory, + expected_target_sha256=expected_target, + ) + + assert stale["status"] == "stale-plan" + assert len(stale["staleReasons"]) == 2 + assert stale["rules"][0]["sourceQuerySha256"] != first["rules"][0]["sourceQuerySha256"] + + +def test_planner_cli_requires_and_validates_locked_workspace_context(tmp_path, capsys): + solution, _ = _create_solution(tmp_path, tmp_path / "ingestion") + target_path = tmp_path / "qualification-target.json" + target_path.write_text(json.dumps(TARGET), encoding="utf-8") + output_path = tmp_path / "plan.json" + + result = planner.main( + [ + "--solution", + str(solution), + "--target-context", + str(target_path), + "--registry", + str(tmp_path / "ingestion" / "registry.yaml"), + "--output", + str(output_path), + ] + ) + + capsys.readouterr() + assert result == 0 + assert json.loads(output_path.read_text(encoding="utf-8"))["target"] == TARGET + + +def test_partial_selection_plans_only_selected_pairs_but_keeps_full_denominator(tmp_path): + solution, first_rule = _create_solution(tmp_path, tmp_path / "ingestion") + ids = [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + "33333333-3333-3333-3333-333333333333", + ] + paths = [] + for index, rule_id in enumerate(ids): + source_path = ( + first_rule if index == 0 + else solution / "Analytic Rules" / f"rule-{index}.yaml" + ) + detection_path = ( + solution / "XDR Detections" / "Nested" / "detection.yaml" + if index == 0 + else solution / "XDR Detections" / "Nested" / f"detection-{index}.yaml" + ) + source = { + "id": rule_id, + "name": f"rule-{index}", + "query": "CommonSecurityLog | where DeviceEventClassID == '733101'", + } + detection = { + "id": f"xdr-{rule_id}", + "sourceRuleId": rule_id, + "name": f"detection-{index}", + "query": "CommonSecurityLog | where DeviceEventClassID == '733101'", + } + _write_yaml(source_path, source) + _write_yaml(detection_path, detection) + paths.append((source_path, detection_path)) + + from sentinel_xdr_migration.packaging_selection import _coverage_summary + + summary = { + **_coverage_summary( + [{"id": rule_id} for rule_id in ids], + ids[:2], + ), + "outcome": "partial", + } + plan = { + "runId": "run-partial", + "planSha256": "a" * 64, + "summary": summary, + "selection": { + "analyticRules": [ + source.relative_to(solution).as_posix() + for source, _ in paths[:2] + ], + "xdrDetections": [ + detection.relative_to(solution).as_posix() + for _, detection in paths[:2] + ], + }, + "analyticRules": [ + {"id": rule_id, "path": source.relative_to(solution).as_posix(), "included": index < 2} + for index, (rule_id, (source, _)) in enumerate(zip(ids, paths)) + ], + "xdrDetections": [ + { + "id": f"xdr-{rule_id}", + "sourceId": rule_id, + "path": detection.relative_to(solution).as_posix(), + "included": index < 2, + } + for index, (rule_id, (_, detection)) in enumerate(zip(ids, paths)) + ], + } + selection_path = solution / "Logs" / "sentinel-xdr-migration" / "run-partial" / "package-selection-plan.json" + selection_path.parent.mkdir(parents=True) + selection_path.write_text(json.dumps(plan), encoding="utf-8") + + with mock.patch.object( + planner, + "verify_applied_package_selection", + return_value={ + "plan": plan, + "planPath": selection_path.relative_to(solution).as_posix(), + "receiptPath": "Logs/sentinel-xdr-migration/run-partial/package-selection-applied.json", + }, + ): + result = planner.build_plan( + solution, + TARGET, + registry_path=tmp_path / "ingestion" / "registry.yaml", + selection_plan_path=selection_path, + expected_selection_plan_sha256="a" * 64, + ) + + assert result["inventory"]["complete"] is True + assert result["inventory"]["recursiveRuleCount"] == 3 + assert result["qualificationScope"]["coverageNumerator"] == 2 + assert result["qualificationScope"]["coverageDenominator"] == 3 + assert result["qualificationScope"]["planSha256"] == "a" * 64 + assert [item["status"] for item in result["rules"]].count("not-selected") == 1 + assert {item.get("ruleId") for item in result["rules"] if item["status"] != "not-selected"} == set(ids[:2]) diff --git a/Tools/SolutionMigration/tests/test_skill_contracts.py b/Tools/SolutionMigration/tests/test_skill_contracts.py new file mode 100644 index 00000000000..664059c8aff --- /dev/null +++ b/Tools/SolutionMigration/tests/test_skill_contracts.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] + + +class SkillContractTests(unittest.TestCase): + def test_optional_testing_invokes_generation_skill_before_folder_fallback(self): + text = ( + ROOT + / ".github" + / "skills" + / "sentinel-solution-optional-testing" + / "SKILL.md" + ).read_text(encoding="utf-8") + + generation = text.index("sentinel-solution-mock-data-generation") + folder_fallback = text.index("--mock-data-folder") + self.assertLess(generation, folder_fallback) + self.assertIn("generationStatus=qualification-ready", text) + + def test_generation_skill_is_azure_sentinel_only(self): + text = ( + ROOT + / ".github" + / "skills" + / "sentinel-solution-mock-data-generation" + / "SKILL.md" + ).read_text(encoding="utf-8") + + self.assertIn("Never invoke CAT.Tools at runtime.", text) + self.assertIn("existing-scenario", text) + self.assertIn("generate_mock_scenario.py", text) + + def test_solution_manager_is_single_connected_agent(self): + text = ( + ROOT + / ".github" + / "agents" + / "xdr-solution-manager.agent.md" + ).read_text(encoding="utf-8") + + generation = text.index("sentinel-solution-mock-data-generation") + optional_testing = text.index("sentinel-solution-optional-testing") + self.assertLess(generation, optional_testing) + workflow = text.index("## Migration workflow") + self.assertLess(text.index("sentinel-xdr-migration doctor"), workflow) + self.assertLess( + text.index("At the start of optional qualification testing", workflow), + text.index("Reuse an existing reviewed scenario", workflow), + ) + self.assertIn("sentinel-xdr-migration setup", text) + self.assertIn("XDR Solution Manager", text) + self.assertIn("do not replace the original workflow", text) + self.assertIn("CAT.Tools is not a runtime dependency.", text) + self.assertIn("Pass `--approve-write` only after approval", text) + self.assertIn("Retry permission check", text) + self.assertIn("selectable buttons", text) + self.assertIn("## Automatic AR/CD qualification", text) + self.assertIn("Temporary five-minute AR/CD clones", text) + + agents = list((ROOT / ".github" / "agents").glob("*.agent.md")) + names = [ + path.read_text(encoding="utf-8").split("name:", 1)[1].splitlines()[0].strip() + for path in agents + ] + self.assertEqual(["XDR Solution Manager"], names) + + def test_optional_testing_requires_startup_before_target_preflight(self): + text = ( + ROOT + / ".github" + / "skills" + / "sentinel-solution-optional-testing" + / "SKILL.md" + ).read_text(encoding="utf-8") + + startup = text.index("sentinel-xdr-migration doctor") + target_preflight = text.index("target-specific DCR permission preflight") + fixture_review = text.index("## Fixture review") + self.assertLess(startup, target_preflight) + self.assertLess(target_preflight, fixture_review) + self.assertIn("## Automatic agent mode", text) + self.assertIn("scenario.ingestion.contract", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/package-lock.json b/package-lock.json index a203245d837..b366afdb965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "prettier": "^1.19.1", "simple-git": "3.36.0", "ts-node": "^10.9.2", - "typescript": "^4.1.6", + "typescript": "5.9.3", "xmlhttprequest": "^1.8.0" }, "devDependencies": { @@ -28,6 +28,7 @@ "@types/chai": "^4.2.11", "@types/chai-as-promised": "^7.1.2", "@types/lodash": "^4.14.167", + "@types/node": "22.20.4", "chai": "^4.2.0", "chai-as-promised": "^7.1.1" } @@ -115,6 +116,12 @@ "tslib": "^2.6.2" } }, + "node_modules/@azure/avocado/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-10.17.60.tgz", + "integrity": "sha1-NfPWIT2u2V2n8Pc+dbzGmA6QWXs=", + "license": "MIT" + }, "node_modules/@azure/avocado/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -746,6 +753,12 @@ "tslib": "^1.9.3" } }, + "node_modules/@ts-common/fs/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-10.17.60.tgz", + "integrity": "sha1-NfPWIT2u2V2n8Pc+dbzGmA6QWXs=", + "license": "MIT" + }, "node_modules/@ts-common/fs/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -966,9 +979,13 @@ "integrity": "sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w==" }, "node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + "version": "22.20.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/node-fetch": { "version": "2.6.13", @@ -1931,9 +1948,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", - "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0" @@ -2103,17 +2120,24 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "license": "MIT" + }, "node_modules/universal-github-app-jwt": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", diff --git a/package.json b/package.json index eee81321723..1d29d9aadba 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "prettier": "^1.19.1", "simple-git": "3.36.0", "ts-node": "^10.9.2", - "typescript": "^4.1.6", + "typescript": "5.9.3", "xmlhttprequest": "^1.8.0", "lodash": "^4.18.1" }, @@ -27,6 +27,7 @@ "@types/chai": "^4.2.11", "@types/chai-as-promised": "^7.1.2", "@types/lodash": "^4.14.167", + "@types/node": "22.20.4", "chai": "^4.2.0", "chai-as-promised": "^7.1.1" },