diff --git a/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md b/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md index 705c597a6e0..9871ecd9440 100644 --- a/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md +++ b/.github/skills/sentinel-xdr-end-to-end-orchestrator/SKILL.md @@ -84,3 +84,22 @@ 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. + +Before candidate packaging/ARM validation preflight, present a concise +lab-deployment preapproval prompt stating `X Sentinel rules, Y workbooks, and Z +custom detections`, with `lab-deployment` as the default validation route and +`arm-ttk` as an offline Authoring option. + +Enforce single-owner execution (`workflow-execution-owner`), safe hash-bound +approval forwarding (`workflow-forward-approval`), and evidence-bound status +reminders with no-change suppression (`workflow-status-reminder`): + +- Claim/release single-owner execution leases per `(runId, operation)` so + conflicting duplicate executors are blocked (`duplicate_executor_blocked`). +- Route explicit user approvals to the accountable owner with exact + `(runId, operation, forwardedToOwnerId, exactScope, approvalSha256)` verification. +- Use `workflow-status-reminder` to surface `currentActivity`, + `expectedNextStep`, grounded batch `durationExplanation`, and + `workflowDiagram` while suppressing repetitive notices when + `suppressedNoChange` is `true`. + diff --git a/.script/local-validation/README.md b/.script/local-validation/README.md index fd23d80b1cf..6a7bddf21ca 100644 --- a/.script/local-validation/README.md +++ b/.script/local-validation/README.md @@ -100,6 +100,8 @@ node .script/local-validation/validate.js --path Solutions/MySolution --json | `documents-link` | Documents Link | All | All | No locale codes in documentation URLs | | `solution` | Solution | `.json` | `Solutions/` | Domains/verticals, support, branding, ID | | `id-change` | ID Change | `.yaml`, `.yml`, `.json` | `Detections/`, `Solutions/` | Template IDs must not change | +| `xdr-detection` | XDR Detection | `.yaml`, `.yml`, `.json` | `Solutions//XDR Detections/`, `Data/`, `Package/` | `xdr-detection.schema.json`, `contentProvenance`, `requiredWorkloads`, `requiredDataConnectors`, source rule SHA-256 freshness, `Data/*.json` selection, and packaged `CustomDetection` resources | +| `xdr-workbook` | XDR Workbook | `.json` | `Solutions//XDR Workbooks/`, `Data/`, `Package/` | `xdr-workbook.schema.json`, `contentProvenance`, `requiredWorkloads`, `requiredDataConnectors`, `requiredWorkspaceFunctions`, query contracts, and packaged `DefenderWorkbook-*` resource & `dependsOn` integrity | | `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; scoped by the build runner) | diff --git a/.script/local-validation/build-and-validate.ps1 b/.script/local-validation/build-and-validate.ps1 index 2955afe4b2a..a4fb2a22cb3 100644 --- a/.script/local-validation/build-and-validate.ps1 +++ b/.script/local-validation/build-and-validate.ps1 @@ -264,7 +264,7 @@ foreach ($component in ([System.IO.Path]::GetRelativePath($toolkitRoot, $evidenc } } $evidenceRelative = [System.IO.Path]::GetRelativePath($toolkitRoot, $evidenceParent).Replace('\', '/') -$null = & git -C $toolkitRoot check-ignore --no-index --quiet -- $evidenceRelative 2>$null +$null = & git -C $toolkitRoot check-ignore --no-index --quiet -- ($evidenceRelative.TrimEnd('/') + '/') 2>$null if ($LASTEXITCODE -ne 0) { Write-Host "❌ EvidenceDirectory is not ignored by the toolkit checkout: '$evidenceRelative'." -ForegroundColor Red exit 2 @@ -609,6 +609,7 @@ if (-not $SkipValidation) { $vPassed = @($vResults | Where-Object { $_.passed -and -not $_.skipped }) $vFailed = @($vResults | Where-Object { -not $_.passed -and -not $_.skipped }) $vSkipped = @($vResults | Where-Object { $_.skipped }) + $skipReasonText = ($vSkipped | Where-Object { $_.skipReason } | Select-Object -ExpandProperty skipReason -Unique) -join "; " if ($vFailed.Count -gt 0) { Write-Host " ❌ $vName ($($vFailed.Count) failed, $($vPassed.Count) passed, $($vSkipped.Count) skipped)" -ForegroundColor Red @@ -617,6 +618,9 @@ if (-not $SkipValidation) { if ($f.error) { Write-Host " $($f.error)" -ForegroundColor Red } } } + elseif ($vPassed.Count -eq 0 -and $vSkipped.Count -gt 0) { + Write-Host " ⏭ $vName ($($vSkipped.Count) skipped$(if ($skipReasonText) { " — $skipReasonText" }))" -ForegroundColor Yellow + } else { Write-Host " ✅ $vName ($($vPassed.Count) passed, $($vSkipped.Count) skipped)" -ForegroundColor Green } @@ -624,7 +628,6 @@ if (-not $SkipValidation) { # Add report entry per validator $status = if ($vFailed.Count -gt 0) { "failed" } elseif ($vPassed.Count -eq 0 -and $vSkipped.Count -gt 0) { "skipped" } else { "passed" } $errors = @($vFailed | ForEach-Object { "$($_.filePath): $($_.error)" }) - $skipReasons = @($vSkipped | Where-Object { $_.skipReason } | Select-Object -First 1 -ExpandProperty skipReason) $null = $reportEntries.Add(@{ Step = 2 Name = $vName @@ -633,7 +636,7 @@ if (-not $SkipValidation) { Failed = $vFailed.Count Skipped = $vSkipped.Count Errors = $errors - SkipReason = if ($skipReasons) { $skipReasons } else { $null } + SkipReason = if ($skipReasonText) { $skipReasonText } else { $null } }) } @@ -697,6 +700,20 @@ if (-not $SkipValidation) { $dotnetAvailable = $false } + function Get-SolutionContentFiles { + param([string]$SolPath, [string[]]$SubDirs, [string[]]$Extensions) + $files = @() + foreach ($sub in $SubDirs) { + $dir = Join-Path $SolPath $sub + if (Test-Path $dir) { + foreach ($ext in $Extensions) { + $files += Get-ChildItem $dir -Filter $ext -Recurse -File + } + } + } + return $files + } + if (-not $dotnetAvailable) { Write-Host "⏭ .NET SDK not found — skipping Detection Schema, Non-ASCII, and KQL validators." -ForegroundColor Yellow Write-Host " Install .NET SDK to enable these checks: https://dotnet.microsoft.com/download" -ForegroundColor Yellow @@ -727,20 +744,6 @@ if (-not $SkipValidation) { # 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) - $files = @() - foreach ($sub in $SubDirs) { - $dir = Join-Path $SolPath $sub - if (Test-Path $dir) { - foreach ($ext in $Extensions) { - $files += Get-ChildItem $dir -Filter $ext -Recurse -File - } - } - } - return $files - } - function Build-DotnetTestFilter { param([System.IO.FileInfo[]]$Files, [switch]$UseEncodedPath) $parts = @() @@ -889,11 +892,18 @@ if (-not $SkipValidation) { } } else { - Write-Host " ⏭ Detection Schema validation skipped — no Analytic Rules found in $SolutionName." -ForegroundColor Yellow + $xdrDetCheckFiles = Get-SolutionContentFiles -SolPath $solutionPath -SubDirs @("XDR Detections") -Extensions @("*.yaml", "*.yml") + $noArReason = if ($xdrDetCheckFiles.Count -gt 0) { + "No legacy Sentinel Analytic Rules found (.NET Detection Schema only supports Sentinel Analytic Rules; XDR Detections are validated by XDR Detection validator)" + } + else { + "No Analytic Rules found" + } + Write-Host " ⏭ Detection Schema validation skipped — $noArReason." -ForegroundColor Yellow $null = $reportEntries.Add(@{ Step = 3; Name = "Detection Schema"; Status = "skipped" Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 - Duration = [TimeSpan]::Zero; SkipReason = "No Analytic Rules found" + Duration = [TimeSpan]::Zero; SkipReason = $noArReason }) } } @@ -1114,11 +1124,18 @@ if (-not $SkipValidation) { } } else { - Write-Host " ⏭ KQL validation skipped — no content files (Analytic Rules, Hunting Queries, Parsers, Data Connectors) found in $SolutionName." -ForegroundColor Yellow + $xdrKqlCheckFiles = Get-SolutionContentFiles -SolPath $solutionPath -SubDirs @("XDR Detections", "XDR Workbooks") -Extensions @("*.yaml", "*.yml", "*.json") + $noKqlReason = if ($xdrKqlCheckFiles.Count -gt 0) { + "No legacy Sentinel KQL files found (.NET KQL validator does not support XDR Detections or XDR Workbooks)" + } + else { + "No content files found" + } + Write-Host " ⏭ KQL validation skipped — $noKqlReason in $SolutionName." -ForegroundColor Yellow $null = $reportEntries.Add(@{ Step = 3; Name = "KQL"; Status = "skipped" Passed = 0; Failed = 0; Skipped = 0; FileCount = 0 - Duration = [TimeSpan]::Zero; SkipReason = "No content files found" + Duration = [TimeSpan]::Zero; SkipReason = $noKqlReason }) } } @@ -1143,6 +1160,18 @@ if (-not $SkipValidation) { Write-Host "" } + $xdrLiveKqlFiles = Get-SolutionContentFiles -SolPath $solutionPath -SubDirs @("XDR Detections", "XDR Workbooks") -Extensions @("*.yaml", "*.yml", "*.json") + if ($xdrLiveKqlFiles.Count -gt 0) { + $xdrLiveSkipReason = "Live Advanced Hunting / Defender Workbook KQL validation requires an approved live workspace/tenant and is skipped in offline local validation" + Write-Host " ⏭ XDR KQL (Live / Advanced Hunting) skipped — $xdrLiveSkipReason ($($xdrLiveKqlFiles.Count) XDR file(s))." -ForegroundColor Yellow + $null = $reportEntries.Add(@{ + Step = 3; Name = "XDR KQL (Live / Advanced Hunting)"; Status = "skipped" + Passed = 0; Failed = 0; Skipped = $xdrLiveKqlFiles.Count; FileCount = $xdrLiveKqlFiles.Count + Duration = [TimeSpan]::Zero; SkipReason = $xdrLiveSkipReason + }) + Write-Host "" + } + # ----------------------------------------------------------------- # ARM-TTK Validator (local — calls run-arm-ttk.ps1, same checks as entrypoint.ps1) # ----------------------------------------------------------------- @@ -1153,7 +1182,7 @@ if (-not $SkipValidation) { Step = 3; Name = "ARM-TTK"; Status = "skipped" Passed = 0; Failed = 0; Skipped = 1; FileCount = 0 Duration = [TimeSpan]::Zero - SkipReason = "Replaced only by pending approved lab deployment validation; not acceptance" + SkipReason = "SKIP (lab-deployment selected as ARM validation route); replaced only by pending approved lab deployment validation; not acceptance" }) } elseif (Test-Path $packageDir) { @@ -1812,9 +1841,12 @@ function Write-BuildReport { if ($e.Skipped -and $e.Skipped -gt 0) { $detailParts += "$($e.Skipped) skipped" } 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.SkipReason) { + $cleanSkip = ($e.SkipReason -replace '\|', '\|' -replace '[\r\n]+', ' ').Trim() + $detailParts += $cleanSkip + } if ($e.OutputLog) { $detailParts += "full output: $($e.OutputLog)" } - $detail = if ($detailParts.Count -gt 0) { $detailParts -join ", " } else { "-" } + $detail = if ($detailParts.Count -gt 0) { (($detailParts -join ", ") -replace '[\r\n]+', ' ').Trim() } else { "-" } Add-Line "| $stepLabel | $($e.Name) | $status | $detail |" if ($e.Status -eq "failed" -and $e.Errors) { @@ -1838,6 +1870,15 @@ function Write-BuildReport { Add-Line "" Add-Line "Total: $totalValidators validators | $totalPassed passed | $totalFailed failed | $totalSkipped skipped" Add-Line "" + $skippedValidators = @($allValidators | Where-Object { $_.Status -eq "skipped" }) + if ($skippedValidators.Count -gt 0) { + Add-Line "⚠️ Skipped / Unsupported Checks ($($skippedValidators.Count)):" + foreach ($sv in $skippedValidators) { + $svReason = if ($sv.SkipReason) { ($sv.SkipReason -replace '[\r\n]+', ' ').Trim() } else { "Skipped" } + Add-Line "- $($sv.Name): $svReason" + } + Add-Line "" + } if ($Success -and $ArmValidationMethod -eq "lab-deployment") { Add-Line "**CANDIDATE GENERATED — NOT ACCEPTED**" Add-Line "All reported static checks completed; approved deployment and workbook contract readback remain pending." diff --git a/.script/local-validation/validate-xdr-content.py b/.script/local-validation/validate-xdr-content.py new file mode 100644 index 00000000000..a2f675db2d9 --- /dev/null +++ b/.script/local-validation/validate-xdr-content.py @@ -0,0 +1,1370 @@ +#!/usr/bin/env python3 +"""Validate XDR Detections and Defender XDR Workbooks for local solution validation. + +Checks both content families: +- XDR Detections (`Solutions//XDR Detections/*.{yaml,yml}` + `Data/*.json` + + `Package/mainTemplate.json` `CustomDetection` resources) +- XDR Workbooks (`Solutions//XDR Workbooks/*.json` + `Data/*.json` + + `Package/mainTemplate.json` `DefenderWorkbook-*` resources & `dependsOn` integrity) + +Outputs a structured JSON report consumed by `.script/local-validation/validate.ts` +and `.script/local-validation/build-and-validate.ps1`. +""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +import yaml +from jsonschema import Draft202012Validator + + +GUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +CONTENT_PACKAGE_EXTENSION_DEP = ( + "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', " + "parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', " + "variables('_solutionId'))]" +) + + +def _setup_toolkit_imports(toolkit_root: Path) -> None: + migration_root = toolkit_root / "Tools" / "SentinelToXDRMigration" + if str(migration_root) not in sys.path: + sys.path.insert(0, str(migration_root)) + + +def _rel_path(path: Path, content_root: Path) -> str: + try: + return path.resolve().relative_to(content_root.resolve()).as_posix() + except ValueError: + return path.as_posix() + + +def _arm_copy(value: Any) -> Any: + if isinstance(value, str): + return "[" + value if value.startswith("[") else value + if isinstance(value, dict): + return {key: _arm_copy(item) for key, item in value.items()} + if isinstance(value, list): + return [_arm_copy(item) for item in value] + return value + + +def _discover_solutions( + content_root: Path, + solution_path: str | None, + files: list[str] | None, +) -> list[Path]: + solutions: list[Path] = [] + seen: set[Path] = set() + + def add_solution(candidate: Path) -> None: + resolved = candidate.resolve() + if resolved not in seen and resolved.is_dir(): + seen.add(resolved) + solutions.append(resolved) + + if solution_path: + candidate = Path(solution_path) + if not candidate.is_absolute(): + candidate = content_root / candidate + if candidate.is_dir(): + # Check if candidate is Solutions/ or a subfolder inside Solutions/ + try: + rel = candidate.resolve().relative_to(content_root.resolve()) + parts = rel.parts + if len(parts) >= 2 and parts[0] == "Solutions": + add_solution(content_root / "Solutions" / parts[1]) + else: + add_solution(candidate) + except ValueError: + add_solution(candidate) + + for raw_file in files or []: + file_path = Path(raw_file) + if not file_path.is_absolute(): + file_path = content_root / file_path + try: + rel = file_path.resolve().relative_to(content_root.resolve()) + parts = rel.parts + if len(parts) >= 2 and parts[0] == "Solutions": + add_solution(content_root / "Solutions" / parts[1]) + except ValueError: + pass + + return solutions + + +def _load_solution_data(solution_root: Path) -> tuple[Path | None, dict[str, Any] | None, str | None]: + from sentinel_xdr_migration.packaging import _read_json, _solution_data_file + + data_dir = solution_root / "Data" + if not data_dir.is_dir(): + return None, None, None + try: + data_file = _solution_data_file(data_dir) + except ValueError as exc: + return data_dir, None, str(exc) + try: + doc = _read_json(data_file) + return data_file, doc, None + except ValueError as exc: + return data_file, None, str(exc) + + +def _find_saved_search_dependency( + resources: list[dict[str, Any]], + variables: dict[str, Any], + function_alias: str, +) -> tuple[dict[str, Any] | None, str | None, str | None]: + """Locate the savedSearches resource for function_alias and compute its expected dependsOn expression.""" + saved = [ + res + for res in resources + if isinstance(res, dict) + and str(res.get("type") or "").lower() + == "microsoft.operationalinsights/workspaces/savedsearches" + and isinstance(res.get("properties"), dict) + and res["properties"].get("functionAlias") == function_alias + ] + if len(saved) != 1: + return ( + None, + None, + f"Required workspace function '{function_alias}' must have exactly one emitted " + f"Microsoft.OperationalInsights/workspaces/savedSearches resource in Package/mainTemplate.json " + f"(found {len(saved)})", + ) + resource = saved[0] + if "condition" in resource or "copy" in resource: + return ( + resource, + None, + f"Required workspace function '{function_alias}' savedSearches resource must be unconditional", + ) + name = resource.get("name") + if not isinstance(name, str) or not name: + return ( + resource, + None, + f"Required workspace function '{function_alias}' savedSearches resource has no valid name", + ) + + dependency: str | None = None + parser_match = re.fullmatch( + r"\[variables\(\s*'(parserObject([1-9][0-9]*))'\s*\)\.(?:_?parserName\2|_parserId\2)\]", + name, + ) + if parser_match: + parser_object_name, parser_index = parser_match.groups() + parser_object = variables.get(parser_object_name) if isinstance(variables, dict) else None + if not isinstance(parser_object, dict): + return ( + resource, + None, + f"Invalid required savedSearch ARM identity for '{function_alias}': missing variable {parser_object_name}", + ) + parser_id = parser_object.get(f"_parserId{parser_index}") + if not isinstance(parser_id, str) or not parser_id.strip(): + return ( + resource, + None, + f"Invalid required savedSearch ARM identity for '{function_alias}': missing _parserId{parser_index}", + ) + dependency = f"[variables('{parser_object_name}')._parserId{parser_index}]" + elif re.match(r"\A\[variables\(\s*'parserObject", name): + return ( + resource, + None, + f"Invalid required savedSearch ARM identity for '{function_alias}': {name}", + ) + elif isinstance(variables, dict): + for var_name, var_value in variables.items(): + idx_match = re.fullmatch(r"parserObject([1-9][0-9]*)", str(var_name)) + if idx_match and isinstance(var_value, dict): + idx = idx_match.group(1) + parser_id = var_value.get(f"_parserId{idx}") + if ( + isinstance(parser_id, str) + and parser_id.strip() + and name in (var_value.get(f"_parserName{idx}"), var_value.get(f"parserName{idx}")) + ): + dependency = f"[variables('{var_name}')._parserId{idx}]" + break + + if dependency is None: + if name.startswith("["): + if name.startswith("[[") or not name.endswith("]"): + return ( + resource, + None, + f"Invalid required savedSearch ARM identity for '{function_alias}': {name}", + ) + argument = name[1:-1] + else: + if len(name.split("/")) != 2 or not all(name.split("/")): + return ( + resource, + None, + f"Required savedSearch identity for '{function_alias}' must include workspace and resource name", + ) + argument = "'" + name.replace("'", "''") + "'" + dependency = f"[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', {argument})]" + + return resource, dependency, None + + +def _all_known_dependency_tokens( + resources: list[dict[str, Any]], + variables: dict[str, Any], +) -> set[str]: + """Collect valid ARM dependency expressions that resolve to resources in mainTemplate.json.""" + valid: set[str] = set() + has_content_packages = any( + isinstance(res, dict) + and str(res.get("type") or "").lower() + == "microsoft.operationalinsights/workspaces/providers/contentpackages" + for res in resources + ) + if has_content_packages: + valid.add(CONTENT_PACKAGE_EXTENSION_DEP) + valid.add( + "[resourceId('Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" + ) + + for res in resources: + if not isinstance(res, dict): + continue + res_type = str(res.get("type") or "") + res_name = str(res.get("name") or "") + if res_type and res_name: + valid.add(f"[resourceId('{res_type}', '{res_name}')]") + if res_name.startswith("[") and res_name.endswith("]"): + valid.add(f"[resourceId('{res_type}', {res_name[1:-1]})]") + if "/" in res_name: + parts = [f"'{p}'" for p in res_name.split("/")] + valid.add(f"[resourceId('{res_type}', {', '.join(parts)})]") + if res_type.lower() == "microsoft.operationalinsights/workspaces/savedsearches": + alias = (res.get("properties") or {}).get("functionAlias") if isinstance(res.get("properties"), dict) else None + if isinstance(alias, str) and alias: + _, dep, err = _find_saved_search_dependency(resources, variables, alias) + if dep and not err: + valid.add(dep) + + if isinstance(variables, dict): + for var_name, var_value in variables.items(): + idx_match = re.fullmatch(r"parserObject([1-9][0-9]*)", str(var_name)) + if idx_match and isinstance(var_value, dict): + idx = idx_match.group(1) + parser_id = var_value.get(f"_parserId{idx}") + parser_name = var_value.get(f"_parserName{idx}") or var_value.get(f"parserName{idx}") + if isinstance(parser_id, str) and parser_id.strip(): + # Verify a savedSearches resource actually uses this parserObject + if any( + isinstance(res, dict) + and str(res.get("type") or "").lower() + == "microsoft.operationalinsights/workspaces/savedsearches" + and res.get("name") in ( + parser_name, + f"[variables('{var_name}')._parserName{idx}]", + f"[variables('{var_name}').parserName{idx}]", + f"[variables('{var_name}')._parserId{idx}]", + ) + for res in resources + ): + valid.add(f"[variables('{var_name}')._parserId{idx}]") + + return valid + + +def _is_custom_detection_install_deployment(res: Any) -> bool: + if not isinstance(res, dict): + return False + if str(res.get("type") or "").casefold() != "microsoft.resources/deployments": + return False + name = str(res.get("name") or "") + if name.startswith("CustomDetection-") or "-CD-" in name: + return True + props = res.get("properties") + if isinstance(props, dict) and isinstance(props.get("template"), dict): + inner_res = props["template"].get("resources") + if isinstance(inner_res, list) and any( + isinstance(child, dict) + and str(child.get("type") or "").casefold() == "microsoft.security/detectionrules" + for child in inner_res + ): + return True + return False + + +def validate_solution_xdr_detections( + solution_root: Path, + content_root: Path, + toolkit_root: Path, +) -> dict[str, Any]: + from sentinel_xdr_migration.converter import ( + custom_detail_binding_warnings, + validate_document, + xdr_detection_files, + ) + from sentinel_xdr_migration.packaging import _read_json, _value, _xdr_references + + xdr_dir = solution_root / "XDR Detections" + authored_files = xdr_detection_files(xdr_dir) if xdr_dir.is_dir() else [] + + data_file, data_doc, data_err = _load_solution_data(solution_root) + has_data_xdr_key = bool( + isinstance(data_doc, dict) + and any( + key.casefold() in {"xdr detections", "custom detections"} + for key in data_doc + ) + ) + + main_template_path = solution_root / "Package" / "mainTemplate.json" + main_template_doc: dict[str, Any] | None = None + has_packaged_custom_detections = False + if main_template_path.is_file(): + try: + main_template_doc = _read_json(main_template_path) + mt_resources = main_template_doc.get("resources") or [] + mt_params = main_template_doc.get("parameters") or {} + has_packaged_custom_detections = ( + "DeployCustomDetection" in mt_params + or any( + isinstance(r, dict) + and ( + _is_custom_detection_install_deployment(r) + or ( + isinstance(r.get("properties"), dict) + and str(r["properties"].get("contentKind") or "").casefold() + == "customdetection" + ) + ) + for r in (mt_resources if isinstance(mt_resources, list) else []) + ) + ) + except ValueError: + main_template_doc = None + + applicable = bool(authored_files or has_data_xdr_key or has_packaged_custom_detections) + if not applicable: + return { + "applicable": False, + "liveKqlSkipped": False, + "results": [ + { + "validator": "XDR Detection", + "filePath": _rel_path(xdr_dir, content_root), + "passed": True, + "skipped": True, + "skipReason": "No XDR Detections or packaged CustomDetection resources found in solution", + } + ], + } + + results: list[dict[str, Any]] = [] + seen_ids: dict[str, list[Path]] = {} + parsed_docs: dict[Path, dict[str, Any]] = {} + + for path in authored_files: + file_errors: list[str] = [] + file_warnings: list[str] = [] + try: + raw_text = path.read_text(encoding="utf-8-sig") + if any(ord(ch) > 127 for ch in raw_text): + file_errors.append("XDR Detection YAML contains non-ASCII characters") + loaded = yaml.safe_load(raw_text) + if not isinstance(loaded, dict): + file_errors.append("XDR Detection YAML must contain a mapping object") + document: dict[str, Any] = {} + else: + document = loaded + except (OSError, yaml.YAMLError) as exc: + file_errors.append(f"Invalid YAML syntax: {exc}") + document = {} + + if document: + parsed_docs[path] = document + file_errors.extend(validate_document(document)) + file_warnings.extend(custom_detail_binding_warnings(document)) + + provenance = document.get("contentProvenance") + if isinstance(provenance, dict): + if provenance.get("dependencyReviewRequired") is not False: + file_errors.append( + "contentProvenance.dependencyReviewRequired must be false " + "(dependency review is unresolved)" + ) + conversion = provenance.get("conversion") + if isinstance(conversion, dict): + if conversion.get("status") != "converted": + file_errors.append( + f"contentProvenance.conversion.status must be 'converted', " + f"got {conversion.get('status')!r}" + ) + if conversion.get("reviewRequired") is not False: + reasons = conversion.get("reviewReasons") or [] + detail = f": {'; '.join(map(str, reasons))}" if reasons else "" + file_errors.append( + f"contentProvenance.conversion.reviewRequired must be false " + f"(conversion review is unresolved{detail})" + ) + source = provenance.get("source") + if isinstance(source, dict): + source_rel = source.get("path") + if isinstance(source_rel, str) and source_rel.strip(): + normalized_source = source_rel.replace("\\", "/") + source_candidate = (solution_root / normalized_source).resolve() + if not source_candidate.is_relative_to(solution_root.resolve()): + file_errors.append( + f"contentProvenance.source.path resolves outside solution: {source_rel!r}" + ) + elif not source_candidate.is_file(): + file_errors.append( + f"contentProvenance.source.path does not exist in solution: {source_rel!r}" + ) + else: + try: + src_doc = yaml.safe_load( + source_candidate.read_text(encoding="utf-8-sig") + ) + if isinstance(src_doc, dict): + src_id = str(src_doc.get("id") or "") + src_query = str(src_doc.get("query") or "") + expected_sha = hashlib.sha256( + src_query.encode("utf-8") + ).hexdigest() + if src_id and src_id != str(source.get("id") or ""): + file_errors.append( + f"contentProvenance.source.id {source.get('id')!r} does not " + f"match source analytic rule id {src_id!r}" + ) + if expected_sha != str(source.get("querySha256") or ""): + file_errors.append( + "contentProvenance.source.querySha256 is stale and does not " + "match the current source analytic rule query" + ) + except (OSError, yaml.YAMLError) as exc: + file_errors.append( + f"Failed to read source analytic rule {source_rel!r}: {exc}" + ) + + detection_id = ( + (document.get("properties") or {}).get("id") + if isinstance(document.get("properties"), dict) + else None + ) + if isinstance(detection_id, str) and detection_id: + seen_ids.setdefault(detection_id.casefold(), []).append(path) + + results.append( + { + "validator": "XDR Detection", + "filePath": _rel_path(path, content_root), + "passed": len(file_errors) == 0, + "error": "; ".join(dict.fromkeys(file_errors)) if file_errors else None, + "warnings": file_warnings, + } + ) + + for det_id, paths in seen_ids.items(): + if len(paths) > 1: + dup_msg = f"duplicate Custom Detection id {det_id!r}" + for item in results: + if item["filePath"] in {_rel_path(p, content_root) for p in paths}: + item["passed"] = False + item["error"] = ( + f"{item['error']}; {dup_msg}" if item.get("error") else dup_msg + ) + + # Validate Data/*.json XDR Detections references + selected_xdr_files: list[Path] = [] + if data_err and data_file is not None: + results.append( + { + "validator": "XDR Detection", + "filePath": _rel_path(data_file, content_root), + "passed": False, + "error": data_err, + } + ) + elif data_doc is not None and data_file is not None: + try: + selected_xdr_files = _xdr_references(solution_root, data_doc) + except ValueError as exc: + # Check if an applied package-selection plan intentionally excluded all XDR detections + selection_plan_path = solution_root / "Logs" / "package-selection.json" + excluded_by_plan = False + if selection_plan_path.is_file() and not has_data_xdr_key: + try: + plan_doc = json.loads(selection_plan_path.read_text(encoding="utf-8")) + if ( + isinstance(plan_doc, dict) + and isinstance(plan_doc.get("selection"), dict) + and plan_doc["selection"].get("xdrDetections") == [] + ): + excluded_by_plan = True + except (OSError, json.JSONDecodeError): + pass + if not excluded_by_plan: + results.append( + { + "validator": "XDR Detection", + "filePath": _rel_path(data_file, content_root), + "passed": False, + "error": str(exc), + } + ) + else: + selected_xdr_files = list(authored_files) + + # Validate Package/mainTemplate.json CustomDetection resources if present + if main_template_doc is not None and (selected_xdr_files or has_packaged_custom_detections): + pkg_errors = _validate_packaged_custom_detections( + main_template_doc, + selected_xdr_files, + parsed_docs, + ) + if pkg_errors or not authored_files: + results.append( + { + "validator": "XDR Detection", + "filePath": _rel_path(main_template_path, content_root), + "passed": len(pkg_errors) == 0, + "error": "; ".join(dict.fromkeys(pkg_errors)) if pkg_errors else None, + } + ) + + return { + "applicable": True, + "liveKqlSkipped": True, + "liveKqlSkipReason": ( + "Live Advanced Hunting KQL validation for XDR Detections requires an " + "approved Defender XDR / Log Analytics workspace and is skipped in offline local validation" + ), + "fileCount": len(authored_files), + "results": results, + } + + +def _validate_packaged_custom_detections( + template: dict[str, Any], + selected_xdr_files: list[Path], + parsed_docs: dict[Path, dict[str, Any]], +) -> list[str]: + errors: list[str] = [] + resources = template.get("resources") + if not isinstance(resources, list): + return ["Package/mainTemplate.json resources must be an array"] + variables = template.get("variables") if isinstance(template.get("variables"), dict) else {} + parameters = template.get("parameters") if isinstance(template.get("parameters"), dict) else {} + + installs = [ + r + for r in resources + if _is_custom_detection_install_deployment(r) + ] + registrations = [ + r + for r in resources + if isinstance(r, dict) + and str(r.get("type") or "").casefold() + == "microsoft.operationalinsights/workspaces/providers/contenttemplates" + and isinstance(r.get("properties"), dict) + and str(r["properties"].get("contentKind") or "").casefold() == "customdetection" + ] + + if selected_xdr_files: + deploy_param = parameters.get("DeployCustomDetection") + if ( + not isinstance(deploy_param, dict) + or deploy_param.get("type") != "bool" + or deploy_param.get("defaultValue") is not False + ): + errors.append( + "Package/mainTemplate.json must declare boolean parameter " + "DeployCustomDetection with defaultValue=false when XDR Detections are packaged" + ) + elif "DeployCustomDetection" in parameters and not installs and not registrations: + errors.append( + "Package/mainTemplate.json declares DeployCustomDetection without any packaged XDR Detections" + ) + + if selected_xdr_files and ( + len(installs) != len(selected_xdr_files) + or len(registrations) != len(selected_xdr_files) + ): + errors.append( + f"Package/mainTemplate.json CustomDetection install/registration count " + f"({len(installs)} install(s), {len(registrations)} registration(s)) " + f"differs from selected XDR Detections ({len(selected_xdr_files)})" + ) + + known_deps = _all_known_dependency_tokens(resources, variables) + + for install in installs: + name = str(install.get("name") or "") + if install.get("condition") != "[parameters('DeployCustomDetection')]": + errors.append( + f"CustomDetection deployment '{name}' must have condition " + "\"[parameters('DeployCustomDetection')]\"" + ) + deps = install.get("dependsOn") + if not isinstance(deps, list) or not deps: + errors.append(f"CustomDetection deployment '{name}' is missing dependsOn") + else: + for dep in deps: + if isinstance(dep, str) and dep not in known_deps: + errors.append( + f"CustomDetection deployment '{name}' has unresolved dependsOn reference: {dep}" + ) + + for reg in registrations: + reg_name = str(reg.get("name") or "") + if reg.get("condition") != "[parameters('DeployCustomDetection')]": + errors.append( + f"CustomDetection registration '{reg_name}' must have condition " + "\"[parameters('DeployCustomDetection')]\"" + ) + deps = reg.get("dependsOn") + if not isinstance(deps, list) or not deps: + errors.append(f"CustomDetection registration '{reg_name}' is missing dependsOn") + else: + for dep in deps: + if isinstance(dep, str) and dep not in known_deps: + errors.append( + f"CustomDetection registration '{reg_name}' has unresolved dependsOn reference: {dep}" + ) + + return errors + + +def validate_solution_xdr_workbooks( + solution_root: Path, + content_root: Path, + toolkit_root: Path, +) -> dict[str, Any]: + from sentinel_xdr_migration.packaging import _read_json, _value + from sentinel_xdr_migration.workbook_dependencies import dependency_validation_errors + from sentinel_xdr_migration.workbook_functions import ( + function_definition, + function_parameters, + validate_functions, + ) + from sentinel_xdr_migration.workbooks import ( + _content_release, + selected_workbook_files, + validate_workbooks, + ) + + wb_dir = solution_root / "XDR Workbooks" + authored_files = ( + sorted(p for p in wb_dir.rglob("*.json") if p.is_file()) + if wb_dir.is_dir() + else [] + ) + + data_file, data_doc, data_err = _load_solution_data(solution_root) + has_data_wb_key = bool( + isinstance(data_doc, dict) + and any(key.casefold() == "xdr workbooks" for key in data_doc) + ) + + main_template_path = solution_root / "Package" / "mainTemplate.json" + main_template_doc: dict[str, Any] | None = None + has_packaged_defender_workbooks = False + if main_template_path.is_file(): + try: + main_template_doc = _read_json(main_template_path) + mt_resources = main_template_doc.get("resources") or [] + mt_params = main_template_doc.get("parameters") or {} + has_packaged_defender_workbooks = ( + "DeployDefenderWorkbook" in mt_params + or any( + isinstance(r, dict) + and ( + str(r.get("name") or "").startswith("DefenderWorkbook-") + or ( + isinstance(r.get("properties"), dict) + and str(r["properties"].get("contentKind") or "") + == "DefenderWorkbook" + ) + ) + for r in (mt_resources if isinstance(mt_resources, list) else []) + ) + ) + except ValueError: + main_template_doc = None + + applicable = bool(authored_files or has_data_wb_key or has_packaged_defender_workbooks) + if not applicable: + return { + "applicable": False, + "liveKqlSkipped": False, + "results": [ + { + "validator": "XDR Workbook", + "filePath": _rel_path(wb_dir, content_root), + "passed": True, + "skipped": True, + "skipReason": "No XDR Workbooks or packaged DefenderWorkbook resources found in solution", + } + ], + } + + results: list[dict[str, Any]] = [] + schema_path = ( + toolkit_root + / "Tools" + / "SentinelToXDRMigration" + / "schema" + / "xdr-workbook.schema.json" + ) + workbook_schema = json.loads(schema_path.read_text(encoding="utf-8")) + schema_validator = Draft202012Validator(workbook_schema) + + valid_json_files: list[Path] = [] + parsed_workbooks: dict[Path, dict[str, Any]] = {} + for path in authored_files: + try: + raw_text = path.read_text(encoding="utf-8-sig") + loaded = json.loads(raw_text) + if not isinstance(loaded, dict): + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(path, content_root), + "passed": False, + "error": "XDR Workbook JSON must contain a top-level object", + } + ) + else: + valid_json_files.append(path) + parsed_workbooks[path] = loaded + except (OSError, json.JSONDecodeError) as exc: + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(path, content_root), + "passed": False, + "error": f"Invalid JSON syntax: {exc}", + } + ) + + if len(valid_json_files) == len(authored_files) and valid_json_files: + try: + wb_summary = validate_workbooks(solution_root, selected_only=False) + for item in wb_summary.get("results", []): + rel_item_path = str(item.get("path") or "") + item_errors = list(item.get("errors") or []) + # Ignore synthetic "Source workbook has no authored candidate." for unconverted legacy Workbooks + if ( + rel_item_path.startswith("Workbooks/") + and item_errors == ["Source workbook has no authored candidate."] + ): + continue + target_path = solution_root / rel_item_path + # Run explicit schema check against Tools/SentinelToXDRMigration/schema/xdr-workbook.schema.json + doc = parsed_workbooks.get(target_path) + if doc is not None: + for err in schema_validator.iter_errors(doc): + loc = "/".join(map(str, err.absolute_path)) or "" + msg = f"{loc}: {err.message}" + if msg not in item_errors: + item_errors.append(msg) + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(target_path, content_root), + "passed": len(item_errors) == 0, + "error": "; ".join(dict.fromkeys(item_errors)) if item_errors else None, + } + ) + except Exception as exc: + # Per-file fallback if validate_workbooks raised an unexpected exception + for path in valid_json_files: + doc = parsed_workbooks[path] + file_errors = [ + f"{'/'.join(map(str, e.absolute_path)) or ''}: {e.message}" + for e in schema_validator.iter_errors(doc) + ] + file_errors.extend(dependency_validation_errors(doc)) + file_errors.append(str(exc)) + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(path, content_root), + "passed": False, + "error": "; ".join(dict.fromkeys(file_errors)), + } + ) + elif valid_json_files: + # Some files had invalid JSON so validate_workbooks(solution_root) would fail on _document; + # still validate all valid JSON files against schema, dependencies, and workspace functions. + for path in valid_json_files: + doc = parsed_workbooks[path] + file_errors = [ + f"{'/'.join(map(str, e.absolute_path)) or ''}: {e.message}" + for e in schema_validator.iter_errors(doc) + ] + file_errors.extend(dependency_validation_errors(doc)) + provenance = doc.get("contentProvenance") + if isinstance(provenance, dict): + fn_records = provenance.get("requiredWorkspaceFunctions", []) + if isinstance(fn_records, list) and fn_records: + try: + validate_functions(solution_root, fn_records) + except (ValueError, TypeError) as exc: + file_errors.append(str(exc)) + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(path, content_root), + "passed": len(file_errors) == 0, + "error": "; ".join(dict.fromkeys(file_errors)) if file_errors else None, + } + ) + + # Validate Data/*.json XDR Workbooks selection + selected_wb_files: list[Path] = [] + if data_err and data_file is not None: + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(data_file, content_root), + "passed": False, + "error": data_err, + } + ) + elif data_doc is not None and data_file is not None: + if _value(data_doc, "XDR Workbooks") is None: + if authored_files: + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(data_file, content_root), + "passed": False, + "error": "solution Data is missing an explicit XDR Workbooks array", + } + ) + else: + try: + selected_wb_files = selected_workbook_files(solution_root) + except ValueError as exc: + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(data_file, content_root), + "passed": False, + "error": str(exc), + } + ) + else: + selected_wb_files = list(authored_files) + + # Validate Package/mainTemplate.json DefenderWorkbook-* resources and dependsOn integrity + if main_template_doc is not None and ( + selected_wb_files or authored_files or has_packaged_defender_workbooks + ): + expected_files = selected_wb_files if selected_wb_files else authored_files + pkg_errors = _validate_packaged_defender_workbooks( + solution_root, + main_template_path, + main_template_doc, + expected_files, + parsed_workbooks, + workbook_schema, + ) + if pkg_errors or not authored_files: + results.append( + { + "validator": "XDR Workbook", + "filePath": _rel_path(main_template_path, content_root), + "passed": len(pkg_errors) == 0, + "error": "; ".join(dict.fromkeys(pkg_errors)) if pkg_errors else None, + } + ) + + return { + "applicable": True, + "liveKqlSkipped": True, + "liveKqlSkipReason": ( + "Live Advanced Hunting / Defender Workbook query & portal rendering validation " + "requires an approved live tenant/workspace and is skipped in offline local validation" + ), + "fileCount": len(authored_files), + "results": results, + } + + +def _validate_packaged_defender_workbooks( + solution_root: Path, + main_template_path: Path, + template: dict[str, Any], + expected_files: list[Path], + parsed_workbooks: dict[Path, dict[str, Any]], + workbook_schema: dict[str, Any], +) -> list[str]: + from sentinel_xdr_migration.packaging import ( + _verify_workbook_package, + validate_v31_target_version, + ) + from sentinel_xdr_migration.workbook_dependencies import dependency_validation_errors + from sentinel_xdr_migration.workbook_functions import ( + function_definition, + function_parameters, + validate_functions, + ) + from sentinel_xdr_migration.workbooks import _content_release + + errors: list[str] = [] + resources = template.get("resources") + if not isinstance(resources, list): + return ["Package/mainTemplate.json resources must be an array"] + variables = template.get("variables") if isinstance(template.get("variables"), dict) else {} + parameters = template.get("parameters") if isinstance(template.get("parameters"), dict) else {} + + installs = [ + row + for row in resources + if isinstance(row, dict) + and row.get("type") == "Microsoft.Resources/deployments" + and str(row.get("name") or "").startswith("DefenderWorkbook-") + ] + registrations = [ + row + for row in resources + if isinstance(row, dict) + and isinstance(row.get("properties"), dict) + and row["properties"].get("contentKind") == "DefenderWorkbook" + ] + packages = [ + row + for row in resources + if isinstance(row, dict) + and row.get("type") + == "Microsoft.OperationalInsights/workspaces/providers/contentPackages" + ] + + has_any_wb = bool(expected_files or installs or registrations) + if has_any_wb: + deploy_param = parameters.get("DeployDefenderWorkbook") + if ( + not isinstance(deploy_param, dict) + or deploy_param.get("type") != "bool" + or deploy_param.get("defaultValue") is not False + ): + errors.append( + "Package/mainTemplate.json must declare boolean parameter " + "DeployDefenderWorkbook with defaultValue=false when Defender Workbooks are packaged" + ) + + if len(installs) != len(registrations): + errors.append( + f"Defender workbook install count ({len(installs)}) differs from " + f"contentTemplates registration count ({len(registrations)}) in Package/mainTemplate.json" + ) + + if expected_files and ( + len(installs) != len(expected_files) or len(registrations) != len(expected_files) + ): + errors.append( + f"Defender workbook install/registration count ({len(installs)} install(s), " + f"{len(registrations)} registration(s)) differs from selection ({len(expected_files)})" + ) + + if not installs and not registrations: + return errors + + if len(packages) != 1: + errors.append( + f"Defender workbook packaging requires exactly one contentPackages resource " + f"in Package/mainTemplate.json (found {len(packages)})" + ) + + package_id = variables.get("solutionId") + version = variables.get("_solutionVersion") + try: + validate_v31_target_version(version) + except ValueError as exc: + errors.append(f"Package/mainTemplate.json _solutionVersion invalid: {exc}") + if not isinstance(package_id, str) or not package_id or package_id.startswith("["): + errors.append("Defender workbook package identity (variables.solutionId) must be resolved") + + known_deps = _all_known_dependency_tokens(resources, variables) + + # Check every install & registration resource structurally (works both with and without authored XDR Workbooks) + for install in installs: + dep_name = str(install.get("name") or "") + if install.get("apiVersion") != "2025-04-01": + errors.append( + f"Defender workbook deployment '{dep_name}' must use apiVersion '2025-04-01'" + ) + if install.get("condition") != "[parameters('DeployDefenderWorkbook')]": + errors.append( + f"Defender workbook deployment '{dep_name}' must have condition " + "\"[parameters('DeployDefenderWorkbook')]\"" + ) + props = install.get("properties") if isinstance(install.get("properties"), dict) else {} + if (props.get("expressionEvaluationOptions") or {}).get("scope") != "inner": + errors.append( + f"Defender workbook deployment '{dep_name}' must set " + "properties.expressionEvaluationOptions.scope to 'inner'" + ) + inner = props.get("template") if isinstance(props.get("template"), dict) else {} + if inner.get("languageVersion") != "2.0" or inner.get("imports") != { + "MicrosoftSecurity": {"provider": "MicrosoftSecurity", "version": "1.0.0"} + }: + errors.append( + f"Defender workbook deployment '{dep_name}' inner template must declare " + "languageVersion '2.0' and MicrosoftSecurity 1.0.0 import" + ) + inner_resources = inner.get("resources") if isinstance(inner.get("resources"), dict) else {} + wb_res = inner_resources.get("workbook") if isinstance(inner_resources.get("workbook"), dict) else None + if not wb_res or wb_res.get("import") != "MicrosoftSecurity" or wb_res.get("type") != "Microsoft.Security/workbooks@2026-06-01-preview": + errors.append( + f"Defender workbook deployment '{dep_name}' inner template must contain " + "workbook resource of type 'Microsoft.Security/workbooks@2026-06-01-preview'" + ) + content_id = None + else: + wb_props = wb_res.get("properties") if isinstance(wb_res.get("properties"), dict) else {} + wb_id = wb_props.get("id") + wb_name = wb_props.get("name") + wb_content = wb_props.get("content") if isinstance(wb_props.get("content"), dict) else {} + if not isinstance(wb_id, str) or not GUID_RE.fullmatch(wb_id): + errors.append( + f"Defender workbook deployment '{dep_name}' properties.id must be a GUID" + ) + if not isinstance(wb_name, str) or not wb_name.strip(): + errors.append( + f"Defender workbook deployment '{dep_name}' properties.name is required" + ) + if "xdrMetadata" in wb_content: + errors.append( + f"Defender workbook deployment '{dep_name}' inner content must not retain xdrMetadata" + ) + inner_prov = wb_content.get("contentProvenance") + content_id = inner_prov.get("contentId") if isinstance(inner_prov, dict) else None + expected_quartet = { + "packageId": package_id, + "packageVersion": version, + "contentId": content_id, + "contentVersion": version, + } + if ( + not isinstance(inner_prov, dict) + or not isinstance(content_id, str) + or not GUID_RE.fullmatch(content_id) + or inner_prov != expected_quartet + ): + errors.append( + f"Defender workbook deployment '{dep_name}' inner contentProvenance must " + "equal the 4-field deployment quartet (packageId, packageVersion, contentId, contentVersion)" + ) + + # Verify install dependsOn integrity + install_deps = install.get("dependsOn") + if not isinstance(install_deps, list) or not install_deps: + errors.append( + f"Defender workbook deployment '{dep_name}' must declare a non-empty dependsOn array" + ) + else: + if CONTENT_PACKAGE_EXTENSION_DEP not in install_deps: + errors.append( + f"Defender workbook deployment '{dep_name}' dependsOn must include the " + "solution contentPackages extensionResourceId" + ) + for dep in install_deps: + if not isinstance(dep, str) or dep not in known_deps: + errors.append( + f"Defender workbook deployment '{dep_name}' has unresolved or invalid " + f"dependsOn reference: {dep!r}" + ) + + # Cross-check matching registration resource + if isinstance(content_id, str) and GUID_RE.fullmatch(content_id): + expected_dep_name = "DefenderWorkbook-" + hashlib.sha256( + f"{solution_root.name}/{content_id}".encode("utf-8") + ).hexdigest()[:32] + if dep_name != expected_dep_name: + errors.append( + f"Defender workbook deployment name '{dep_name}' does not match " + f"deterministic name '{expected_dep_name}' for contentId '{content_id}'" + ) + matching_regs = [ + r + for r in registrations + if (r.get("properties") or {}).get("contentId") == content_id + ] + if len(matching_regs) != 1: + errors.append( + f"Defender workbook deployment '{dep_name}' (contentId '{content_id}') " + f"must have exactly one matching DefenderWorkbook contentTemplates registration " + f"(found {len(matching_regs)})" + ) + else: + reg = matching_regs[0] + reg_props = reg.get("properties") or {} + expected_reg_deps = [ + f"[resourceId('Microsoft.Resources/deployments', '{dep_name}')]", + CONTENT_PACKAGE_EXTENSION_DEP, + ] + if reg.get("dependsOn") != expected_reg_deps: + errors.append( + f"Defender workbook registration for '{content_id}' has invalid dependsOn: " + f"expected {expected_reg_deps}, got {reg.get('dependsOn')}" + ) + reg_prov = reg_props.get("contentProvenance") + if not isinstance(reg_prov, dict): + errors.append( + f"Defender workbook registration for '{content_id}' is missing properties.contentProvenance" + ) + else: + for req_key in ( + "packageId", + "packageVersion", + "contentId", + "contentVersion", + "requiredWorkloads", + "requiredDataConnectors", + "requiredWorkspaceFunctions", + ): + if req_key not in reg_prov: + errors.append( + f"Defender workbook registration contentProvenance for '{content_id}' " + f"is missing required field '{req_key}'" + ) + # Check requiredWorkspaceFunctions savedSearches & dependsOn + fn_records = reg_prov.get("requiredWorkspaceFunctions") + if isinstance(fn_records, list): + expected_parser_deps: list[str] = [] + for fn_rec in fn_records: + if not isinstance(fn_rec, dict) or not isinstance(fn_rec.get("functionAlias"), str): + errors.append( + f"Defender workbook registration for '{content_id}' contains " + f"invalid requiredWorkspaceFunctions entry: {fn_rec!r}" + ) + continue + _, dep_expr, dep_err = _find_saved_search_dependency( + resources, variables, fn_rec["functionAlias"] + ) + if dep_err: + errors.append(dep_err) + elif dep_expr and dep_expr not in expected_parser_deps: + expected_parser_deps.append(dep_expr) + expected_install_deps = [ + CONTENT_PACKAGE_EXTENSION_DEP, + *expected_parser_deps, + ] + if install.get("dependsOn") != expected_install_deps: + errors.append( + f"Workbook installation '{dep_name}' must depend on exact mandatory " + f"contentPackages and workspace functions: expected {expected_install_deps}, " + f"got {install.get('dependsOn')}" + ) + + # If authored XDR Workbooks exist and all parsed as JSON, also run the full packaging verifier + if expected_files and all(p in parsed_workbooks for p in expected_files): + zip_candidates = sorted((solution_root / "Package").glob("*.zip")) + if zip_candidates: + try: + _verify_workbook_package( + solution_root, + expected_files, + template, + zip_candidates[-1], + ) + except (RuntimeError, ValueError) as exc: + if str(exc) not in errors: + errors.append(str(exc)) + else: + # Verify against authored files even when no .zip exists yet + for path in expected_files: + authored = parsed_workbooks[path] + metadata = authored.get("xdrMetadata") or {} + provenance = authored.get("contentProvenance") or {} + content_id = metadata.get("contentId") + fn_records = provenance.get("requiredWorkspaceFunctions") or [] + if isinstance(fn_records, list) and fn_records: + try: + validate_functions(solution_root, fn_records) + except (ValueError, TypeError) as exc: + if str(exc) not in errors: + errors.append(str(exc)) + for fn_rec in fn_records: + if not isinstance(fn_rec, dict): + continue + alias = fn_rec.get("functionAlias") + src_path = fn_rec.get("sourcePath") + if not isinstance(alias, str) or not isinstance(src_path, str): + continue + saved_res, _, dep_err = _find_saved_search_dependency( + resources, variables, alias + ) + if dep_err and dep_err not in errors: + errors.append(dep_err) + elif saved_res is not None: + try: + _, definition = function_definition(solution_root, src_path) + emitted_query = (saved_res.get("properties") or {}).get("query") + source_query = definition["FunctionQuery"] + if ( + not isinstance(emitted_query, str) + or emitted_query not in (source_query, f"{source_query}\n") + or (saved_res.get("properties") or {}).get("functionParameters", "") + != function_parameters(definition) + ): + errors.append( + "Required workspace function must be unconditional and match current source" + ) + except (ValueError, KeyError) as exc: + errors.append(str(exc)) + if isinstance(content_id, str) and packages: + criteria = ( + ((packages[0].get("properties") or {}).get("dependencies") or {}).get("criteria") + or [] + ) + wb_criteria = [row for row in criteria if isinstance(row, dict) and row.get("kind") == "DefenderWorkbook"] + if not any(row.get("contentId") == content_id for row in wb_criteria): + errors.append( + f"Defender workbook package dependency criteria is missing DefenderWorkbook '{content_id}'" + ) + + return errors + + +def run_validation( + *, + content_root: Path, + solution_paths: list[Path] | None = None, + solution_path: str | None = None, + files: list[str] | None = None, + family: str = "all", + toolkit_root: Path | None = None, +) -> dict[str, Any]: + resolved_toolkit = (toolkit_root or Path(__file__).resolve().parents[2]).expanduser().resolve() + resolved_content = content_root.expanduser().resolve() + _setup_toolkit_imports(resolved_toolkit) + + solutions = ( + [p.expanduser().resolve() for p in solution_paths] + if solution_paths is not None + else _discover_solutions(resolved_content, solution_path, files) + ) + all_results: list[dict[str, Any]] = [] + detections_reports: list[dict[str, Any]] = [] + workbooks_reports: list[dict[str, Any]] = [] + + if not solutions: + if family in ("all", "detections"): + all_results.append( + { + "validator": "XDR Detection", + "filePath": solution_path or "Solutions", + "passed": True, + "skipped": True, + "skipReason": "No solution scope found for XDR Detection validation", + } + ) + if family in ("all", "workbooks"): + all_results.append( + { + "validator": "XDR Workbook", + "filePath": solution_path or "Solutions", + "passed": True, + "skipped": True, + "skipReason": "No solution scope found for XDR Workbook validation", + } + ) + else: + for sol in solutions: + if family in ("all", "detections"): + det_report = validate_solution_xdr_detections(sol, resolved_content, resolved_toolkit) + detections_reports.append(det_report) + all_results.extend(det_report["results"]) + if family in ("all", "workbooks"): + wb_report = validate_solution_xdr_workbooks(sol, resolved_content, resolved_toolkit) + workbooks_reports.append(wb_report) + all_results.extend(wb_report["results"]) + + failed = sum(1 for r in all_results if not r.get("passed", True) and not r.get("skipped", False)) + passed = sum(1 for r in all_results if r.get("passed", True) and not r.get("skipped", False)) + skipped = sum(1 for r in all_results if r.get("skipped", False)) + + return { + "summary": { + "total": len(all_results), + "passed": passed, + "failed": failed, + "skipped": skipped, + }, + "detections": detections_reports, + "workbooks": workbooks_reports, + "results": all_results, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate XDR Detections and Defender Workbooks in a Sentinel solution." + ) + parser.add_argument( + "--toolkit-root", + default=str(Path(__file__).resolve().parents[2]), + help="Path to the toolkit repository root.", + ) + parser.add_argument( + "--content-root", + default=None, + help="Path to the content repository root (defaults to --toolkit-root).", + ) + parser.add_argument( + "--solution-path", + default=None, + help="Solution folder path (relative to --content-root or absolute).", + ) + parser.add_argument( + "--files", + nargs="*", + default=None, + help="Specific file paths to validate.", + ) + parser.add_argument( + "--family", + choices=("all", "detections", "workbooks"), + default="all", + help="Content family to validate.", + ) + args = parser.parse_args() + + toolkit_root = Path(args.toolkit_root).expanduser().resolve() + content_root = ( + Path(args.content_root).expanduser().resolve() + if args.content_root + else toolkit_root + ) + payload = run_validation( + content_root=content_root, + solution_path=args.solution_path, + files=args.files, + family=args.family, + toolkit_root=toolkit_root, + ) + sys.stdout.write(json.dumps(payload, indent=2) + "\n") + return 1 if payload["summary"]["failed"] > 0 else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.script/local-validation/validate.ts b/.script/local-validation/validate.ts index 16c3e771a8c..15f2e64a82f 100644 --- a/.script/local-validation/validate.ts +++ b/.script/local-validation/validate.ts @@ -39,7 +39,7 @@ * - For ARM-TTK: PowerShell with ARM-TTK module */ -import { execSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -637,19 +637,42 @@ async function validateWorkbookTemplate(filePath: string, _fileKind: string): Pr return { validator: "Workbook Template", filePath, passed: true, skipped: true, skipReason: "Metadata file" }; } + if (/(^|[\\/])XDR Workbooks[\\/]/.test(filePath)) { + return { + validator: "Workbook Template", + filePath, + passed: true, + skipped: true, + skipReason: "Legacy Sentinel Workbook Template validator does not apply to XDR Workbooks; validated by XDR Workbook validator", + }; + } + const workbookTemplateString = fs.readFileSync(filePath, "utf8"); const parsedWorkbookTemplate = JSON.parse(workbookTemplateString); - // Check if it's actually a workbook JSON + // Check if it's actually a Sentinel workbook JSON if (typeof parsedWorkbookTemplate.$schema !== "undefined" && String(parsedWorkbookTemplate.$schema).includes("schema/workbook.json") && typeof parsedWorkbookTemplate.version !== "undefined" && parsedWorkbookTemplate.version === "Notebook/1.0") { isFromTemplateIdNotSentinelUserWorkbook(parsedWorkbookTemplate); doesNotContainResourceInfo(workbookTemplateString); + return { validator: "Workbook Template", filePath, passed: true }; } - return { validator: "Workbook Template", filePath, passed: true }; + if (/(^|[\\/])Workbooks[\\/]/.test(filePath) && parsedWorkbookTemplate.version === "Notebook/1.0") { + isFromTemplateIdNotSentinelUserWorkbook(parsedWorkbookTemplate); + doesNotContainResourceInfo(workbookTemplateString); + return { validator: "Workbook Template", filePath, passed: true }; + } + + return { + validator: "Workbook Template", + filePath, + passed: true, + skipped: true, + skipReason: "Not a Sentinel Workbook Template", + }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return { validator: "Workbook Template", filePath, passed: false, error: msg }; @@ -784,6 +807,15 @@ async function validateDocumentsLink(filePath: string, _fileKind: string): Promi // ----- 11. Solution Validator ----- async function validateSolution(filePath: string, _fileKind: string): Promise { + if (!filePath.endsWith("mainTemplate.json") && !filePath.endsWith("createUiDefinition.json")) { + return { + validator: "Solution", + filePath, + passed: true, + skipped: true, + skipReason: "Not mainTemplate.json or createUiDefinition.json", + }; + } try { IsValidSolutionDomainsVerticals(filePath); IsValidSupportObject(filePath); @@ -803,25 +835,51 @@ async function validateIdChange(filePath: string, fileKind: string): Promise -1) { + return { validator: "ID Change", filePath, passed: true, skipped: true, skipReason: "ID in skip list" }; + } + return { + validator: "ID Change", + filePath, + passed: false, + error: `Template ID changed from ${removedPropId[1]} to ${addedPropId[1]} — IDs must not be modified`, + }; + } + return { validator: "ID Change", filePath, passed: true }; + } + const idPosition = diffSummary.search(new RegExp(templateIdRegex)); if (idPosition > 0) { - const regex = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; + const regex = /(?:xdr-)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; let match; let oldId = ""; let newId = ""; @@ -850,15 +908,163 @@ async function validateIdChange(filePath: string, fileKind: string): Promise Boolean(c && c.trim())); + + for (const candidate of candidates) { + try { + execFileSync(candidate, ["--version"], { encoding: "utf8", stdio: "pipe" }); + return candidate; + } catch { + // try next + } + } + return null; +} + +async function runXdrContentValidators( + files: ChangedFile[], + options: CLIOptions, + runDetections: boolean, + runWorkbooks: boolean, +): Promise { + const results: ValidationResult[] = []; + if (!runDetections && !runWorkbooks) { + return results; + } + + const scriptPath = path.join(toolkitRoot, ".script/local-validation/validate-xdr-content.py"); + if (!fs.existsSync(scriptPath)) { + if (runDetections) { + results.push({ + validator: "XDR Detection", + filePath: scriptPath, + passed: false, + error: `XDR validation script not found: ${scriptPath}`, + }); + } + if (runWorkbooks) { + results.push({ + validator: "XDR Workbook", + filePath: scriptPath, + passed: false, + error: `XDR validation script not found: ${scriptPath}`, + }); + } + return results; + } + + const pythonExe = resolvePythonExecutable(); + if (!pythonExe) { + const reason = "Python 3 interpreter not available to run XDR content validator"; + if (runDetections) { + results.push({ + validator: "XDR Detection", + filePath: scriptPath, + passed: true, + skipped: true, + skipReason: reason, + }); + } + if (runWorkbooks) { + results.push({ + validator: "XDR Workbook", + filePath: scriptPath, + passed: true, + skipped: true, + skipReason: reason, + }); + } + return results; + } + + const family = runDetections && runWorkbooks ? "all" : runDetections ? "detections" : "workbooks"; + const args = [ + scriptPath, + "--toolkit-root", + toolkitRoot, + "--content-root", + contentRoot, + "--family", + family, + ]; + if (options.mode === "path" && options.targetPath) { + args.push("--solution-path", options.targetPath); + } else { + args.push("--files", ...files.map(f => f.filePath)); + } + + let rawOutput = ""; + try { + rawOutput = execFileSync(pythonExe, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 120000, + }); + } catch (e: unknown) { + const errObj = e as { stdout?: string; stderr?: string; message?: string }; + if (errObj && typeof errObj.stdout === "string" && errObj.stdout.trim().startsWith("{")) { + rawOutput = errObj.stdout; + } else { + const msg = (errObj?.stderr || errObj?.message || String(e)).trim(); + if (runDetections) { + results.push({ + validator: "XDR Detection", + filePath: options.targetPath || "Solutions", + passed: false, + error: `XDR content validator process failed: ${msg}`, + }); + } + if (runWorkbooks) { + results.push({ + validator: "XDR Workbook", + filePath: options.targetPath || "Solutions", + passed: false, + error: `XDR content validator process failed: ${msg}`, + }); + } + return results; + } + } + + const parsed = JSON.parse(rawOutput); + if (Array.isArray(parsed.results)) { + for (const item of parsed.results) { + results.push({ + validator: String(item.validator), + filePath: String(item.filePath), + passed: Boolean(item.passed), + error: item.error ? String(item.error) : undefined, + skipped: Boolean(item.skipped), + skipReason: item.skipReason ? String(item.skipReason) : undefined, + }); + } + } + return results; +} + + // ----- 13–15. .NET Validators (subprocess invocation) ----- -async function runDotNetValidator(projectPath: string, validatorName: string, selectedContentRoot: string): Promise { +async function runDotNetValidator( + projectPath: string, + validatorName: string, + selectedContentRoot: string, + requiredRuntimeRegex?: RegExp, + requiredRuntimeLabel?: string, +): Promise { const results: ValidationResult[] = []; try { if (!fs.existsSync(projectPath)) { @@ -872,7 +1078,21 @@ async function runDotNetValidator(projectPath: string, validatorName: string, se return results; } - execSync(`dotnet test "${projectPath}" --configuration Release --verbosity minimal`, { + if (requiredRuntimeRegex) { + const runtimesOut = execSync("dotnet --list-runtimes", { encoding: "utf8", stdio: "pipe" }); + if (!requiredRuntimeRegex.test(runtimesOut)) { + results.push({ + validator: validatorName, + filePath: projectPath, + passed: true, + skipped: true, + skipReason: `${requiredRuntimeLabel || "Required .NET runtime"} not installed`, + }); + return results; + } + } + + const testOut = execSync(`dotnet test "${projectPath}" --configuration Release --verbosity minimal`, { encoding: "utf8", stdio: "pipe", timeout: 300000, // 5 minute timeout @@ -882,6 +1102,17 @@ async function runDotNetValidator(projectPath: string, validatorName: string, se }, }); + if (/Total:\s*0\b|No test is available/i.test(testOut)) { + results.push({ + validator: validatorName, + filePath: projectPath, + passed: true, + skipped: true, + skipReason: "No applicable tests executed by .NET test runner", + }); + return results; + } + results.push({ validator: validatorName, filePath: projectPath, passed: true }); } catch (e: unknown) { const msg = e instanceof Error ? (e as { stdout?: string }).stdout || e.message : String(e); @@ -900,10 +1131,19 @@ async function runArmTtkValidator(files: ChangedFile[]): Promise { activeValidators = activeValidators.filter(v => !options.skip.includes(v.id)); } + const shouldRunXdrDetection = (options.only.length === 0 || options.only.includes("xdr-detection")) + && !options.skip.includes("xdr-detection"); + const shouldRunXdrWorkbook = (options.only.length === 0 || options.only.includes("xdr-workbook")) + && !options.skip.includes("xdr-workbook"); + if (!options.jsonOutput) { - console.log(`🔧 Active validators: ${activeValidators.map(v => v.id).join(", ")}`); + const activeIds = [ + ...activeValidators.map(v => v.id), + ...(shouldRunXdrDetection ? ["xdr-detection"] : []), + ...(shouldRunXdrWorkbook ? ["xdr-workbook"] : []), + ]; + console.log(`🔧 Active validators: ${activeIds.join(", ")}`); } // --- Run Validations --- @@ -1256,11 +1509,40 @@ async function main(): Promise { allResults.push(...fileResults); } + // --- Run XDR Detections & XDR Workbooks Validators --- + if (shouldRunXdrDetection || shouldRunXdrWorkbook) { + const xdrResults = await runXdrContentValidators( + files, + options, + shouldRunXdrDetection, + shouldRunXdrWorkbook, + ); + allResults.push(...xdrResults); + } + // --- Run .NET Validators (if not skipped) --- const dotnetValidators = [ - { 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") }, + { + id: "kql", + name: "KQL Validation", + project: path.join(toolkitRoot, ".script/tests/KqlvalidationsTests/Kqlvalidations.Tests.csproj"), + runtimeRegex: /Microsoft\.NETCore\.App 8\./, + runtimeLabel: ".NET 8.0", + }, + { + id: "detection-schema", + name: "Detection Template Schema", + project: path.join(toolkitRoot, ".script/tests/detectionTemplateSchemaValidation/DetectionTemplateSchemaValidation.Tests.csproj"), + runtimeRegex: /Microsoft\.NETCore\.App 3\.1\./, + runtimeLabel: ".NET Core 3.1", + }, + { + id: "non-ascii", + name: "Non-ASCII", + project: path.join(toolkitRoot, ".script/tests/NonAsciiValidationsTests/NonAsciiValidations.Tests.csproj"), + runtimeRegex: /Microsoft\.NETCore\.App 3\.1\./, + runtimeLabel: ".NET Core 3.1", + }, ]; for (const dotnetValidator of dotnetValidators) { @@ -1293,7 +1575,13 @@ async function main(): Promise { } if (!dotnetAvailable) continue; - const dotnetResults = await runDotNetValidator(dotnetValidator.project, dotnetValidator.name, contentRoot); + const dotnetResults = await runDotNetValidator( + dotnetValidator.project, + dotnetValidator.name, + contentRoot, + dotnetValidator.runtimeRegex, + dotnetValidator.runtimeLabel, + ); for (const result of dotnetResults) { if (!result.passed && result.error && result.error.includes("GitHub App ID")) { result.passed = true; diff --git a/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 index 1ec1b6473f3..8ee5b9e9243 100644 --- a/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/common/customDetections.ps1 @@ -589,6 +589,10 @@ function Add-XdrCustomDetectionsToSolution { } $detectionDocument.properties.status = 'disabled' + if ($null -ne $detectionDocument.properties.schedule -and + [string]$detectionDocument.properties.schedule.frequency -ceq 'NRT') { + $detectionDocument.properties.schedule.frequency = 'PT0S' + } Set-CustomDetectionParserBindings -DetectionDocument $detectionDocument -ParserNames $parserNames $resourceType = "$($detectionDocument.resourceType)@$($detectionDocument.apiVersion)" $detectionResource = [pscustomobject]@{ diff --git a/Tools/Create-Azure-Sentinel-Solution/common/defenderWorkbooks.ps1 b/Tools/Create-Azure-Sentinel-Solution/common/defenderWorkbooks.ps1 index 3ec8af6e74e..95348739488 100644 --- a/Tools/Create-Azure-Sentinel-Solution/common/defenderWorkbooks.ps1 +++ b/Tools/Create-Azure-Sentinel-Solution/common/defenderWorkbooks.ps1 @@ -707,17 +707,57 @@ function Get-DefenderWorkbookParserDependencies { throw "Defender Workbook workspace function '$($record.functionAlias)' savedSearch is conditional or does not match current YAML." } $name = $resource.name - if ($name.StartsWith('[')) { - if ($name.StartsWith('[[') -or -not $name.EndsWith(']')) { throw 'Invalid savedSearch ARM name.' } - $argument = $name.Substring(1, $name.Length - 2) + $dependency = $null + if ($name -cmatch "\A\[variables\('(parserObject([1-9][0-9]*))'\)\.(?:_?parserName\2|_parserId\2)\]\z") { + $parserObjectName = $Matches[1] + $parserIndex = $Matches[2] + $parserObjectProperty = if ($null -ne $Template.variables) { + $Template.variables.PSObject.Properties[$parserObjectName] + } else { $null } + if ($null -eq $parserObjectProperty -or $parserObjectProperty.Value -isnot [pscustomobject]) { + throw 'Invalid savedSearch ARM name.' + } + $parserIdProperty = $parserObjectProperty.Value.PSObject.Properties["_parserId$parserIndex"] + if ($null -eq $parserIdProperty -or $parserIdProperty.Value -isnot [string] -or + [string]::IsNullOrWhiteSpace($parserIdProperty.Value)) { + throw 'Invalid savedSearch ARM name.' + } + $dependency = "[variables('$parserObjectName')._parserId$parserIndex]" + } + elseif ($name -cmatch "\A\[variables\('parserObject") { + throw 'Invalid savedSearch ARM name.' + } + elseif ($null -ne $Template.variables) { + foreach ($prop in $Template.variables.PSObject.Properties) { + if ($prop.Name -cmatch '\AparserObject([1-9][0-9]*)\z' -and $prop.Value -is [pscustomobject]) { + $idx = $Matches[1] + $idProp = $prop.Value.PSObject.Properties["_parserId$idx"] + $nameProp = $prop.Value.PSObject.Properties["_parserName$idx"] + $altNameProp = $prop.Value.PSObject.Properties["parserName$idx"] + $nameVal = if ($null -ne $nameProp) { $nameProp.Value } else { $null } + $altNameVal = if ($null -ne $altNameProp) { $altNameProp.Value } else { $null } + if ($null -ne $idProp -and $idProp.Value -is [string] -and + -not [string]::IsNullOrWhiteSpace($idProp.Value) -and + ($name -ceq $nameVal -or $name -ceq $altNameVal)) { + $dependency = "[variables('$($prop.Name)')._parserId$idx]" + break + } + } + } } - else { - if ($name.Split('/').Count -ne 2 -or @($name.Split('/') | Where-Object { -not $_ }).Count -gt 0) { - throw 'savedSearch name must include the workspace and resource name.' + if ($null -eq $dependency) { + if ($name.StartsWith('[')) { + if ($name.StartsWith('[[') -or -not $name.EndsWith(']')) { throw 'Invalid savedSearch ARM name.' } + $argument = $name.Substring(1, $name.Length - 2) + } + else { + if ($name.Split('/').Count -ne 2 -or @($name.Split('/') | Where-Object { -not $_ }).Count -gt 0) { + throw 'savedSearch name must include the workspace and resource name.' + } + $argument = "'" + $name.Replace("'", "''") + "'" } - $argument = "'" + $name.Replace("'", "''") + "'" + $dependency = "[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', $argument)]" } - $dependency = "[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', $argument)]" if ($dependencies.Add($dependency)) { $dependency } } } diff --git a/Tools/SentinelToXDRMigration/FOLLOW_UPS.md b/Tools/SentinelToXDRMigration/FOLLOW_UPS.md index 21149684e6c..a674b984088 100644 --- a/Tools/SentinelToXDRMigration/FOLLOW_UPS.md +++ b/Tools/SentinelToXDRMigration/FOLLOW_UPS.md @@ -15,90 +15,86 @@ 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 +## Recently completed in V1 PREVIEW -- **Make workbook depends on parsers during packaging.** Approved toolkit - implementation work: bind reviewed workbook function prerequisites to actual +- **Make workbook `dependsOn` reference canonical parser IDs during packaging.** + Bound reviewed workbook function prerequisites to actual `Microsoft.OperationalInsights/workspaces/savedSearches` resources in the - same enclosing ARM template and add workbook-installer `dependsOn` edges. - Required parser installation is mandatory; same-resource parser overwrites - are expected. Keep this tracked until packaging regressions confirm exact - resource mapping, prerequisite freshness, and unconditional installation. -- **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. -- **Inherit the solution release version for updated content.** Assign modified - analytic rules, hunting queries, and other versioned content the version of - the solution release containing the update. Leave unchanged content versions - intact and keep source provenance consistent with the actual source version. + same enclosing ARM template using canonical `[variables('parserObject')._parserId]` + references (with literal `resourceId(...)` fallback), enforced unconditional + parser installation, and verified exact resource mapping in packaging regressions. - **Default hourly Sentinel rules to the native XDR lookback.** For supported - rules with a one-hour Sentinel frequency and lookback, retain the hourly - Custom Detection frequency (`PT1H`) and accept the native four-hour - ingestion-based lookback without pausing for timing-only approval. Four - hours is the lookback, not the execution frequency. Preserve the query - without adding a time filter, and save the source/target timing, exact - source-document and target-query hashes, applied default, and timing or - aggregation differences as evidence. Do not claim parity or clear unrelated - mapping, dependency, KQL, validation, packaging, or environment-write gates. -- **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. -- **Add scheduled migration status reminders.** Consider a scheduled agent - that reminds the top coordinator to surface what is being worked on, what - comes next, and why a step is taking longer, including larger detection - batches. For example: "Applying your decisions to the XDR detections before - validation." Bind updates to actual workflow and subagent evidence; never - invent progress, completion estimates, or approvals. Coordinate with the - existing single-owner watchdog, avoid repetitive notices, and do not create - a second workflow executor. Review the cadence and design separately before - implementing an agent or enabling a schedule. -- **Collect review feedback in one consolidated batch.** Prefer one solution-wide - review packet and one feedback request over serial per-rule or per-workbook - approval prompts. Inventory all outstanding detection, workbook, dependency, - timing, entity, version, and content findings before asking. Group repeated - findings, retain exact affected identities, query pointers and hashes, and - offer accept/change/defer with per-item exceptions. Preserve previous current - decisions and record all new decisions as evidence before applying them - through the supported deterministic commands. Keep technical failures and - deferred items visible; batch approval does not establish runtime correctness - or parity, or authorize packaging or environment writes. This is the preferred - review experience, not a claim that a native unified batch-review CLI exists. -- **Guide users to the next migration blocker review.** End migration status - updates with a focused next-step recommendation, such as "Next migration step - can be reviewing blockers A and B." Identify the next blockers and recommended - review actions rather than overwhelm users with the full issue list, so they - can stay focused on progressing through the migration. -- **Make sure validation scripts handle XDR Detections and Defender Workbooks - correctly.** Verify that the official solution validation runner checks both - content families, reports applicable schema and provenance failures, and keeps - skipped or unsupported checks visible rather than treating them as passes. -- **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. -- **Change the validation tools to verify ARM templates through lab deployment.** - Make deployment of the generated `mainTemplate` to an explicitly approved - non-production lab the default ARM validation route for both Authoring and - Qualification, skipping ARM-TTK on that route and using deployment results - to verify ARM template deployment validity. Allow Authoring to select - ARM-TTK instead of lab deployment verification. Save deployment findings, - including failures and target/artifact bindings, as workflow evidence. - Ask in advance during preflight for preapproval to deploy the solution ARM - templates to the selected lab, stating the expected content items: - X Sentinel rules, Y workbooks, and Z custom detections. - Deployment verification alone does not establish functional/runtime - qualification or alert parity. + rules with a one-hour Sentinel frequency and lookback, retained the hourly + Custom Detection frequency (`PT1H`) and accepted the native four-hour + ingestion-based lookback (`PT4H`) by default without pausing for timing-only + approval, recording `eventTimeWindow` provenance and exact source/target + hashes while keeping `parityClaimed: false`. +- **Map eligible Sentinel `kind: NRT` rules to Continuous (`PT0S`) Custom Detections.** + Mapped eligible single-table `kind: NRT` rules to `kind: CustomDetection` + with `schedule.frequency: "PT0S"` (`Edm.Duration` zero duration, while + normalizing legacy `"NRT"` strings at Graph/ARM boundaries), surfacing + incompatible multi-table, join, union, or aggregate NRT queries for explicit + review. +- **Accept standard `_`-prefixed built-in ASIM parsers (`_ASim_*` and `_Im_*`).** + Recognized built-in unifying ASIM parsers starting with `_` as supported in + both Custom Detections and Defender Workbooks. +- **Inherit the solution release version for updated content & preserve lexical + YAML version text.** Assigned generated/updated detections the solution + release version in `[3.1.0, 4.0.0)` while preserving exact lexical + `contentProvenance.source.version` strings and validating content hash + identities. +- **Collect review feedback in one consolidated batch & guide users to the next + migration blocker review.** Added `review-packet` / `consolidated-review-plan` + CLI helpers and `nextStepRecommendation` / `nextBlockerReview` status and + report summaries. +- **Set release-note dates predictably.** Defaulted release-note dates to + today's date in `DD-MM-YYYY` format unless overridden. +- **Validate XDR Detections and Defender Workbooks in `.script/local-validation/`.** + Added `validate-xdr-content.py` and `xdr-detection` / `xdr-workbook` + validators to `build-and-validate.ps1` with explicit `SKIP` visibility. +- **Verify ARM templates through lab deployment by default (`lab-deployment`) with + preflight preapproval.** Defaulted ARM validation to `lab-deployment` for + Authoring and Qualification (with explicit `arm-ttk` selection supported in + Authoring), added preflight preapproval prompts stating `X Sentinel rules, + Y workbooks, and Z custom detections`, recorded target/SHA-256 deployment + validation findings (`functionalQualificationVerified: false`, + `alertParityVerified: false`), and surfaced `SKIP (lab-deployment selected as + ARM validation route)` in `build-and-validate.ps1`. +- **Reconcile Qualification readiness and private configuration/support exports.** + Added `assess_qualification_readiness` (separating `authorizationChecks`, + `deploymentPermissionReadiness`, and `ingestionPermissionReadiness`) and + `audit_private_config_and_support_export` to verify secret/private-target + hygiene before export. +- **Add operator visibility, scheduled migration status reminders, single-owner + execution leases, and safe approval forwarding.** Added + `workflow-status-reminder` (with `suppressedNoChange`, workflow diagram, and + batch duration explanation), `workflow-execution-owner` + (`duplicate_executor_blocked` lease protection), and + `workflow-forward-approval` (hash-bound `(runId, operation, approvedBy, + forwardedToOwnerId, exactScope, approvalSha256)` forwarding). + +## Candidate follow-ups + +- **Deliver review-gate notifications reliably.** Add immediate coordinator-owned + alerts when a solution needs review or release approval, with one consolidated + decision request. Persist pending, delivered, and acknowledged notification + states bound to the run ID and current review-packet hash. Generating a status + summary must not count as delivery. Reconcile pending notifications after + restart or model changes, and use one read-only fallback reminder schedule + without creating another workflow executor. Deduplicate delivered unchanged + alerts, but do not suppress undelivered requests. Test missed notifications, + delivery failures, restart recovery, changed review packets, acknowledgment, + and single-owner execution. The current status helper does not implement + end-to-end scheduled notification delivery; separate maintainer implementation + and verification are required. +- **Support automatic row-level query rewrite for `kind: NRT` rules with cosmetic + `summarize` wrappers.** While single-table `kind: NRT` rules already map to + `schedule.frequency: "PT0S"`, Sentinel NRT rules that include a trailing + `summarize min(TimeGenerated), max(TimeGenerated), count() by ...` wrapper + are flagged by Defender XDR's Continuous (NRT) engine restriction against + `summarize`. Add a deterministic rewrite option to strip cosmetic timestamp/count + aggregations into row-level projections when converting to `NRT`, falling back + to `PT1H` scheduled frequency when aggregation is required. Any future environment write remains separately gated by explicit approval immediately before the exact operation. No cloud action is authorized by this diff --git a/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md b/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md index 3f9168b91a9..0fa86b45d1a 100644 --- a/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md +++ b/Tools/SentinelToXDRMigration/agent-workflows/end-to-end-migration.md @@ -975,6 +975,38 @@ 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. +#### Scheduled status reminders, single-owner execution, and approval forwarding + +To improve operator visibility during longer stages or larger batches, use the +read-only status reminder helper (`workflow-status-reminder`). It reads actual +`workflow-state.json` and run artifacts—never inventing progress, completion +estimates, or approvals—and emits `currentActivity`, `expectedNextStep`, a +grounded batch `durationExplanation` (for example, when source Analytic Rules +`>= 15` or workbook queries `>= 50`), a stage `workflowDiagram`, and +`watchdogCoordination` (`readOnlyObserver: true`, `createsExecutor: false`, +`singleOwnerRespected: true`). When the deterministic `stateFingerprint` matches +`status-reminder-cursor.json`, it sets `suppressedNoChange: true` so repetitive +no-change notices are suppressed. + +Before candidate packaging/ARM validation preflight, present a concise +lab-deployment preapproval prompt stating the exact inventory scope: +`X Sentinel rules, Y workbooks, and Z custom detections`, with `lab-deployment` +as the default validation route and `arm-ttk` available as an offline Authoring +option. + +Maintain single-owner execution and safe approval forwarding with the CLI +helpers: + +- `workflow-execution-owner --action claim|release`: records the single + accountable owner (`ownerId`, `operation`, `scope`, `claimedAtUtc`) in + `execution-ownership.json` and blocks any duplicate executor + (`duplicate_executor_blocked`) with a different `ownerId` for the same + `(runId, operation)`. +- `workflow-forward-approval --action forward|verify`: binds `runId`, + `operation`, `approvedBy`, `forwardedToOwnerId`, `exactScope`, and + `approvalSha256` in `forwarded-approvals.json`, rejecting any scope + broadening, mismatched owner, or altered digest. + ## Stage gates ### Discovery diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/import-manifest.json b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/import-manifest.json index d67aa76819c..dbf99bf19cd 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/import-manifest.json +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/import-manifest.json @@ -75,7 +75,7 @@ }, { "component": "workbook.py", - "sha256": "283f707c82ac18ed686056177fdce4418c355a219ade1925b8cf87720b5ba4ae", + "sha256": "238701e7fec987ec6e7bb9ae89eaf98647c09e03802db9aac889a1a5772f28ac", "originalSha256": "eb931b23333994a56e94d3c8975c7663db258b9b8032b0ff171b9e456e1b3942" }, { diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/workbook.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/workbook.py index ee1496cd368..74d879a6839 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/workbook.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/_workbook_core/workbook.py @@ -404,6 +404,17 @@ def union_source_names(query: str) -> set[str]: return {name for name, _ in _union_operands(code)} - locals +_STANDARD_BUILTIN_ASIM_PARSER_RE = re.compile( + r"^_(?:asim|im)_(?!test$)[a-z0-9_]+$", + re.IGNORECASE, +) + + +def is_standard_builtin_asim_parser(name: str) -> bool: + """Return True for standard built-in ASIM parsers (_ASim_* and _Im_*).""" + return bool(isinstance(name, str) and _STANDARD_BUILTIN_ASIM_PARSER_RE.match(name.strip())) + + def _bare_source_identifiers(code: str) -> set[str]: code_no_flags = re.sub( r"\b(?:union|join|lookup)\b(?:\s+[A-Za-z_][\w.]*\s*=\s*[\w.*-]+)+", @@ -440,7 +451,10 @@ def _bare_source_identifiers(code: str) -> set[str]: bare.add(ident) bare.update(name for name, is_call in _union_operands(code) if not is_call) local_vars = set(re.findall(r"\blet\s+([A-Za-z_]\w*)\s*=", code)) - return bare - _BUILTINS - _STATEMENT_PREFIXES - local_vars + return { + name for name in (bare - _BUILTINS - _STATEMENT_PREFIXES - local_vars) + if not is_standard_builtin_asim_parser(name) + } def _parameter_source_or_call_tokens(query: str) -> set[str]: @@ -513,7 +527,7 @@ def opaque_functions(query: str, *, include_bare: bool = True) -> set[str]: bare = _bare_source_identifiers(code) | (quoted_bare - _BUILTINS - _STATEMENT_PREFIXES - local_vars) known_tables = set(migrator.mapping_catalog.load_mapping_catalog()["tables"]) result |= (bare - known_tables - local_vars) - return result + return {name for name in result if not is_standard_builtin_asim_parser(name)} def convert_workbook(source: dict, resolutions: dict | None = None, diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py index 4e24bc7fa27..d0c7b2a5ddf 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/cli.py @@ -59,10 +59,15 @@ from .workflow import ( STAGES, WORKFLOW_PROFILES, + build_consolidated_review_packet, + build_migration_status_reminder, + claim_execution_ownership, complete_workflow_stage, + forward_explicit_approval, initialize_workflow, next_workflow_stage, read_authoring_contract, + release_execution_ownership, package_workflow_v3_1, plan_new_package_release, plan_release_notes_refresh, @@ -71,6 +76,7 @@ refresh_yolo_authoring, run_yolo_authoring, start_workflow_stage, + verify_forwarded_approval, verify_same_version_content_rebuild, workflow_status, ) @@ -108,6 +114,24 @@ def _key_values(values: list[str] | None) -> dict[str, str]: return result +def _json_object_arg(raw: str | None, *, flag_name: str) -> dict: + if raw is None: + return {} + candidate = Path(raw).expanduser() + try: + text = ( + candidate.read_text(encoding="utf-8-sig") + if candidate.is_file() + else raw + ) + parsed = json.loads(text) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid JSON object for {flag_name}: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError(f"{flag_name} must decode to a JSON object") + return parsed + + def _package_batch_members(values: list[str]) -> list[dict[str, str]]: members: list[dict[str, str]] = [] for value in values: @@ -169,6 +193,37 @@ def main(argv: list[str] | None = None) -> int: "workbook-review", help="Apply complete hash-bound accept/change/defer decisions to run-local config and receipt.") for option in ("solution", "plan", "decisions", "plan-sha256", "actor", "reference", "output-dir"): workbook_review.add_argument("--" + option, required=True) + for review_cmd in ("review-packet", "consolidated-review-plan"): + review_packet_parser = subparsers.add_parser( + review_cmd, + help=( + "Build one consolidated, hash-bound review packet covering outstanding " + "detection and workbook review items." + ), + ) + review_packet_parser.add_argument("--solution", required=True) + review_packet_parser.add_argument( + "--results", + help="Exact-query-bound successful LA metadata results for detection dependencies.", + ) + review_packet_parser.add_argument( + "--workbook-results", + help="Exact-query-bound successful LA metadata results for workbook dependencies.", + ) + review_packet_parser.add_argument( + "--workspace-id", + help="Explicitly approved lab workspace customer ID.", + ) + review_packet_parser.add_argument( + "--rule", + action="append", + dest="rule_selectors", + help="Optional rule ID, filename, or stem filter; repeat to select multiple.", + ) + review_packet_parser.add_argument( + "--output", + help="Optional path to write the consolidated review packet JSON.", + ) 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.") @@ -665,7 +720,11 @@ def main(argv: list[str] | None = None) -> int: 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( + "--release-date", + default=None, + help="Release date in DD-MM-YYYY format (defaults to today's date in DD-MM-YYYY).", + ) notes_refresh.add_argument("--change-history", required=True) notes_refresh.add_argument( "--expected-package-input-sha256", @@ -861,6 +920,69 @@ def main(argv: list[str] | None = None) -> int: action="append", help="Evidence path or reference; repeat for multiple values.", ) + workflow_status_reminder = subparsers.add_parser( + "workflow-status-reminder", + help=( + "Emit a deterministic, evidence-bound operator status reminder with " + "stage diagram, batch duration explanation, and no-change suppression." + ), + ) + workflow_status_reminder.add_argument("--solution", required=True) + workflow_status_reminder.add_argument( + "--no-persist-cursor", + action="store_true", + help="Compute the reminder without updating status-reminder-cursor.json.", + ) + workflow_execution_owner = subparsers.add_parser( + "workflow-execution-owner", + help="Claim or release single-owner execution lease for a workflow operation.", + ) + workflow_execution_owner.add_argument("--solution", required=True) + workflow_execution_owner.add_argument( + "--action", + choices=["claim", "release"], + default="claim", + help="Claim/renew or release the execution lease for (runId, operation).", + ) + workflow_execution_owner.add_argument("--owner-id", required=True) + workflow_execution_owner.add_argument("--operation", required=True) + workflow_execution_owner.add_argument( + "--scope", + help="Optional JSON object string or file path describing the leased scope.", + ) + workflow_forward_approval = subparsers.add_parser( + "workflow-forward-approval", + help="Forward or verify an explicit, hash-bound operation approval for the accountable owner.", + ) + workflow_forward_approval.add_argument("--solution", required=True) + workflow_forward_approval.add_argument( + "--action", + choices=["forward", "verify"], + default="forward", + help="Forward a new approval receipt or verify an existing receipt.", + ) + workflow_forward_approval.add_argument("--operation", required=True) + workflow_forward_approval.add_argument( + "--owner-id", + "--forwarded-to-owner-id", + dest="forwarded_to_owner_id", + required=True, + help="Accountable owner ID receiving or verifying the forwarded approval.", + ) + workflow_forward_approval.add_argument( + "--approved-by", + help="Approving user/actor identity (required when --action forward).", + ) + workflow_forward_approval.add_argument( + "--exact-scope", + required=True, + help="JSON object string or file path binding exact target/artifact/hash scope.", + ) + workflow_forward_approval.add_argument( + "--approval-sha256", + help="Expected approvalSha256 when verifying a forwarded approval.", + ) + workflow_forward_approval.add_argument("--reference") coordinator_enroll_parser = subparsers.add_parser( "coordinator-enroll", help="Enroll a selected persisted run with an accountable owner; shadow mode only.", @@ -1058,7 +1180,7 @@ def main(argv: list[str] | None = None) -> int: 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 ( + if args.command not in {"migrate-reports", "workflow-status", "workflow-status-reminder", "workflow-execution-owner", "workflow-forward-approval", "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", "review-packet", "consolidated-review-plan", *isolated_permission_commands} and not ( args.command == "convert" and args.rule_id ): configure_logging(getattr(args, "solution", None)) @@ -1429,6 +1551,21 @@ def main(argv: list[str] | None = None) -> int: args.solution, plan_path=args.plan, decisions_path=args.decisions, expected_plan_sha256=args.plan_sha256, actor=args.actor, reference=args.reference, output_directory=args.output_dir) + elif args.command in {"review-packet", "consolidated-review-plan"}: + result = build_consolidated_review_packet( + args.solution, + detection_results_path=args.results, + workbook_results_path=args.workbook_results, + workspace_id=args.workspace_id, + rule_selectors=args.rule_selectors, + ) + if args.output: + output_path = Path(args.output).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) elif args.command == "inspect": from .workbook_workflow import inspect_content result = inspect_content(args.solution) @@ -1666,6 +1803,51 @@ def main(argv: list[str] | None = None) -> int: ) elif args.command == "workflow-next": result = next_workflow_stage(args.solution) + elif args.command == "workflow-status-reminder": + result = build_migration_status_reminder( + args.solution, + run_id=args.run_id, + persist_cursor=not args.no_persist_cursor, + ) + elif args.command == "workflow-execution-owner": + if args.action == "release": + result = release_execution_ownership( + args.solution, + owner_id=args.owner_id, + operation=args.operation, + run_id=args.run_id, + ) + else: + result = claim_execution_ownership( + args.solution, + owner_id=args.owner_id, + operation=args.operation, + scope=_json_object_arg(args.scope, flag_name="--scope"), + run_id=args.run_id, + ) + elif args.command == "workflow-forward-approval": + scope_dict = _json_object_arg(args.exact_scope, flag_name="--exact-scope") + if args.action == "verify": + result = verify_forwarded_approval( + args.solution, + owner_id=args.forwarded_to_owner_id, + operation=args.operation, + exact_scope=scope_dict, + approval_sha256=args.approval_sha256, + run_id=args.run_id, + ) + else: + if not args.approved_by: + raise ValueError("--approved-by is required when --action forward") + result = forward_explicit_approval( + args.solution, + operation=args.operation, + approved_by=args.approved_by, + forwarded_to_owner_id=args.forwarded_to_owner_id, + exact_scope=scope_dict, + run_id=args.run_id, + reference=args.reference, + ) elif args.command == "workflow-recover-conversion-evidence": result = recover_conversion_evidence(args.solution) elif args.command == "workflow-start-stage": diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py index 0d5ab03d4b2..aaf3641b560 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/converter.py @@ -56,8 +56,54 @@ 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", } +STANDARD_BUILTIN_ASIM_PARSER_RE = re.compile( + r"^_(?:asim|im)_(?:agentevent|alertevent|assetentity|auditevent|authentication|dhcp|dns|email|fileevent|networksession|process|registry|usermanagement|websession)[a-z0-9_]*$", + re.IGNORECASE, +) +NRT_SUPPORTED_TABLES = frozenset( + name.lower() + for name in ( + "AlertEvidence", + "CloudAppEvents", + "DeviceEvents", + "DeviceFileCertificateInfo", + "DeviceFileEvents", + "DeviceImageLoadEvents", + "DeviceInfo", + "DeviceLogonEvents", + "DeviceNetworkEvents", + "DeviceNetworkInfo", + "DeviceProcessEvents", + "DeviceRegistryEvents", + "EmailAttachmentInfo", + "EmailEvents", + "EmailPostDeliveryEvents", + "EmailUrlInfo", + "IdentityDirectoryEvents", + "IdentityLogonEvents", + "IdentityQueryEvents", + "UrlClickEvents", + "ABAPAuditLog_C", + "ABAPChangeDocsLog_CL", + "AuditLogs", + "AWSCloudTrail", + "AWSGuardDuty", + "AzureActivity", + "CommonSecurityLog", + "GCPAuditLogs", + "MicrosoftGraphActivityLogs", + "OfficeActivity", + "Okta_CL", + "OktaV2_CL", + "ProofpointPOD", + "ProofPointTAPClicksPermitted_CL", + "ProofPointTAPMessagesDelivered_CL", + "SecurityAlert", + "SecurityEvent", + "SigninLogs", + ) +) ENTITY_MAP: dict[str, tuple[str, dict[str, str | None]]] = { "Host": ( @@ -234,6 +280,8 @@ def slugify(value: str) -> str: def iso_duration(value: Any, default: str = "PT1H") -> tuple[str, str | None]: text = str(value or "").strip().lower() iso = text.upper() + if iso in {"NRT", "0", "PT0S"}: + return "PT0S", None if re.fullmatch( r"P(?:(?:\d+D)(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?|T(?:\d+H)?(?:\d+M)?(?:\d+S)?)", iso, @@ -391,6 +439,12 @@ def convert_query(query: str, config: dict[str, Any]) -> tuple[str, list[str], l for pattern, message in REVIEW_KQL_PATTERNS.items(): if re.search(pattern, converted, re.IGNORECASE): warnings.append(message) + asim_calls = [ + match.group(1) + for match in re.finditer(r"\b(_(?:[Ii]m|[Aa][Ss]im)_[A-Za-z0-9_]+)\s*\(", converted) + ] + if any(not STANDARD_BUILTIN_ASIM_PARSER_RE.match(name) for name in asim_calls): + warnings.append("ASIM parser availability must be verified in Advanced Hunting") if _has_search_operator(converted): warnings.append(SEARCH_REVIEW_REASON) if query_has_mixed_time_semantics(converted): @@ -798,12 +852,158 @@ def _format_kql_duration(seconds: int) -> str: return f"{seconds}s" +def _assess_nrt_eligibility(source_query: str, target_query: str) -> tuple[bool, list[str]]: + """Check Defender XDR Continuous (NRT) query and table eligibility.""" + reasons: list[str] = [] + from ._workbook_core import rewrite + + segments = rewrite._segments(target_query or "") + if any(kind == "comment" for kind, _ in segments) or "/*" in "".join( + text for kind, text in segments if kind == "code" + ): + reasons.append("Continuous (NRT) frequency does not support KQL comments") + code = "".join(text if kind == "code" else " " * len(text) for kind, text in segments) + if re.search(r"\bexternal_?data\s*\(", code, re.IGNORECASE): + reasons.append("Continuous (NRT) frequency does not support externaldata") + if re.search(r"\b(?:join|union|lookup)\b", code, re.IGNORECASE): + reasons.append("Continuous (NRT) frequency does not support join, union, or lookup operators") + if re.search(r"\b(?:summarize|make-series)\b|\|\s*count\b", code, re.IGNORECASE): + reasons.append("Continuous (NRT) frequency does not support summarize or aggregation operators") + + refs = query_table_references(target_query) + tables = [] + for ref in refs: + t = str(ref.get("table") or "").strip() + if t and t.lower() not in {x.lower() for x in tables}: + tables.append(t) + if len(tables) != 1: + reasons.append( + "Continuous (NRT) frequency requires a single supported table" + if not tables + else f"Continuous (NRT) frequency requires a single table, found {', '.join(tables)}" + ) + else: + table = tables[0] + if table.lower() not in NRT_SUPPORTED_TABLES: + reasons.append( + f"table {table} is not supported for Continuous (NRT) frequency in Defender XDR" + ) + elif table.lower() == "emailevents" and re.search( + r"\b(?:LatestDeliveryLocation|LatestDeliveryAction)\b", code, re.IGNORECASE + ): + reasons.append( + "EmailEvents columns LatestDeliveryLocation and LatestDeliveryAction are not supported in Continuous (NRT) frequency" + ) + return not reasons, reasons + + +_FILENAME_ONLY_HASH_COLUMNS = frozenset( + { + "filename", + "initiatingprocessfilename", + "folderpath", + "initiatingprocessfolderpath", + "filepath", + "targetfilename", + "sourcefilename", + "imagefile", + "process", + "processname", + "commandline", + "processcommandline", + "initiatingprocesscommandline", + } +) +_SYNTHETIC_HASH_FUNCTIONS = frozenset( + { + "hash", + "hash_sha1", + "hash_sha256", + "hash_md5", + "hash_xxhash64", + "hash_combine", + "hash_many", + "strcat", + } +) + + +def _is_non_hash_or_synthetic_hash_column(query: str, column: str) -> str | None: + """Return a rejection reason if column is a filename-only binding or synthetic text hash.""" + col = (column or "").strip() + if not col: + return None + if col.lower() in _FILENAME_ONLY_HASH_COLUMNS: + return "filename-only or non-hash binding" + facts = _linear_facts(query) + if facts: + assignments = facts[2] + rhs = assignments.get(col) + if rhs: + if len(rhs) == 1 and rhs[0].lower() in _FILENAME_ONLY_HASH_COLUMNS: + return "filename-only or non-hash binding" + if any(tok.lower() in _SYNTHETIC_HASH_FUNCTIONS for tok in rhs): + return "synthetic text hash" + from ._workbook_core import rewrite + + code = "".join(text if kind == "code" else " " * len(text) for kind, text in rewrite._segments(query or "")) + assign_pat = re.compile( + rf"\b{re.escape(col)}\s*=\s*(?:tostring\s*\(\s*)?({'|'.join(_SYNTHETIC_HASH_FUNCTIONS)})\s*\(", + re.IGNORECASE, + ) + if assign_pat.search(code): + return "synthetic text hash" + alias_pat = re.compile( + rf"\b{re.escape(col)}\s*=\s*({'|'.join(_FILENAME_ONLY_HASH_COLUMNS)})\b(?!\s*\()", + re.IGNORECASE, + ) + if alias_pat.search(code): + return "filename-only or non-hash binding" + return None + + 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) + if ( + str(doc.get("kind") or "").lower() != "nrt" + and source_frequency == 3600 + and source_period == 3600 + and target_frequency == 3600 + ): + has_aggregation = bool(re.search(r"\b(?:summarize|make-series)\b", target_query, re.IGNORECASE)) + metadata = { + "status": "default-native-lookback", + "appliedDefault": "hourly-1h-to-native-4h-lookback", + "queryFrequency": str(doc.get("queryFrequency") or "1h").strip(), + "queryPeriod": str(doc.get("queryPeriod") or "1h").strip(), + "targetFrequency": frequency, + "effectiveLookback": "PT4H", + "originalSentinelLookbackSeconds": source_period, + "effectiveEventWindowSeconds": 14400, + "xdrServiceWindowSeconds": 14400, + "filterInjected": False, + "parityClaimed": False, + "hasAggregation": has_aggregation, + "aggregationWindowDifference": has_aggregation, + "timingDifference": ( + "Four hours (PT4H / 14400s) is the native XDR ingestion-based lookback window " + "for PT1H execution frequency, not the execution frequency; original Sentinel " + "queryPeriod was 1h (3600s)." + ), + "aggregationDifference": ( + "Query contains summarize/make-series aggregation; evaluating over the native " + "4-hour XDR lookback window without an injected 1h event-time filter may " + "aggregate up to 4 hours of events per hourly run instead of 1 hour." + if has_aggregation + else "No summarize/make-series aggregation detected in query; native 4-hour XDR " + "lookback relies on Defender XDR alert deduplication across hourly runs." + ), + } + return target_query, metadata, None source_table = _native_pipeline_table(source_query) target_table = _native_pipeline_table(target_query) native_tokens = set(_tokens(source_query)) | set(_tokens(target_query)) @@ -927,6 +1127,17 @@ def assess_schedule( f"lookback={doc.get('queryPeriod') or 'unknown'}; target frequency={frequency}, " f"native lookback={str(target_period) + 's' if target_period else 'unknown/unconfigured'}" ) + if str(doc.get("kind") or "").lower() == "nrt" and frequency in {"PT0S", "NRT"}: + nrt_eligible, nrt_reasons = _assess_nrt_eligibility(source_query, target_query) + if nrt_eligible: + return [], [ + f"Schedule assessment: source kind=NRT; target frequency={frequency} (Continuous); " + f"single-table Continuous (NRT) execution is supported in Defender XDR. See {SCHEDULE_REFERENCE}" + ] + return [ + f"Schedule assessment: source kind=NRT; target frequency={frequency} (Continuous); " + f"query is not eligible for Continuous (NRT) execution ({'; '.join(nrt_reasons)}). See {SCHEDULE_REFERENCE}" + ], [] reason = None if str(doc.get("kind") or "").lower() == "nrt": reason = "source NRT timing is not preserved by the emitted scheduled frequency" @@ -1034,13 +1245,24 @@ def convert_entities( 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: + bad_hash_reason = ( + _is_non_hash_or_synthetic_hash_column(query, value_column) + if value_column + else None + ) + if target and value_column and columns is not None and value_column in columns and not bad_hash_reason: 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}" ) + elif bad_hash_reason: + warnings.append( + f"converter cannot map FileHash.Algorithm/Value: column `{value_column}` is a " + f"{bad_hash_reason}; a genuine content SHA-1/SHA-256 is required" + ) + unsupported = [value for value in unsupported if value not in {"Algorithm", "Value"}] else: warnings.append( "converter cannot map FileHash.Algorithm/Value: requires a query-proven " @@ -1442,9 +1664,80 @@ def custom_detail_binding_warnings(document: dict[str, Any]) -> list[str]: ] +def _extract_raw_yaml_version(raw_text: str, doc: dict[str, Any]) -> str: + """Preserve exact source YAML version lexical text (e.g., 1.0 vs 1.0.0).""" + for line in (raw_text or "").splitlines(): + match = re.match(r"^version\s*:\s*(.+?)\s*$", line) + if not match: + continue + raw_val = match.group(1).strip() + if not raw_val or raw_val.startswith("#"): + continue + if (raw_val.startswith('"') and raw_val.endswith('"')) or ( + raw_val.startswith("'") and raw_val.endswith("'") + ): + return raw_val[1:-1] + raw_val = re.sub(r"\s+#.*$", "", raw_val).strip() + if raw_val: + return raw_val + return str(doc.get("version") or "") + + +def _version_tuple(version: str) -> tuple[int, int, int]: + parts = str(version).split(".") + if len(parts) == 3 and all(part.isdigit() for part in parts): + return int(parts[0]), int(parts[1]), int(parts[2]) + return (0, 0, 0) + + +def _solution_release_version(solution_root: Path, config: dict[str, Any] | None = None) -> str: + """Resolve the solution release version in [3.1.0, 4.0.0) or default to INITIAL_XDR_VERSION.""" + if isinstance(config, dict): + for key in ("targetVersion", "version"): + candidate = config.get(key) + if isinstance(candidate, str) and re.fullmatch(XDR_VERSION_PATTERN, candidate.strip()): + return candidate.strip() + try: + from .artifacts import current_workflow_state + + state = current_workflow_state(solution_root) + if isinstance(state, dict): + target_ver = state.get("targetVersion") + if isinstance(target_ver, str) and re.fullmatch(XDR_VERSION_PATTERN, target_ver.strip()): + return target_ver.strip() + except Exception: + pass + candidates: list[str] = [] + data_dir = solution_root / "Data" + if data_dir.is_dir(): + for data_path in sorted(data_dir.glob("Solution_*.json")): + try: + payload = json.loads(data_path.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + continue + if isinstance(payload, dict): + ver = payload.get("Version") or payload.get("version") + if isinstance(ver, str) and re.fullmatch(XDR_VERSION_PATTERN, ver.strip()): + candidates.append(ver.strip()) + meta_path = solution_root / "SolutionMetadata.json" + if meta_path.is_file(): + try: + payload = json.loads(meta_path.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + payload = None + if isinstance(payload, dict): + ver = payload.get("version") or payload.get("Version") + if isinstance(ver, str) and re.fullmatch(XDR_VERSION_PATTERN, ver.strip()): + candidates.append(ver.strip()) + if candidates: + return max(candidates, key=_version_tuple) + return INITIAL_XDR_VERSION + + 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 {} + raw_source_text = handle.read() + doc = yaml.safe_load(raw_source_text) or {} if not isinstance(doc, dict): raise ValueError(f"analytic rule must contain a YAML object: {source}") @@ -1465,10 +1758,24 @@ def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any] warnings.extend(parser_warnings) except ValueError as exc: errors.append(str(exc)) + source_id = str(doc.get("id") or "") + rule_override = (config.get("ruleOverrides") or {}).get(source_id) or {} is_nrt = str(doc.get("kind") or "").lower() == "nrt" - frequency, frequency_warning = iso_duration( - doc.get("queryFrequency") or ("PT1H" if is_nrt else None) - ) + override_frequency = rule_override.get("frequency") if isinstance(rule_override, dict) else None + if is_nrt and not override_frequency and not doc.get("queryFrequency"): + frequency = "PT0S" + nrt_eligible, nrt_reasons = _assess_nrt_eligibility(source_query, converted_query) + frequency_warning = ( + None + if nrt_eligible + else ( + "source NRT rule is not eligible for Continuous (NRT) execution in Defender XDR " + f"({'; '.join(nrt_reasons)}); review schedule or configure a scheduled frequency override" + ) + ) + else: + raw_freq = override_frequency if override_frequency is not None else doc.get("queryFrequency") + frequency, frequency_warning = iso_duration(raw_freq or ("PT1H" if is_nrt else None)) if frequency_warning: warnings.append(frequency_warning) review_reasons.append(frequency_warning) @@ -1484,13 +1791,14 @@ def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any] ).hexdigest(), "targetQuerySha256": hashlib.sha256(converted_query.encode("utf-8")).hexdigest(), } + if isinstance(event_window, dict) and event_window.get("status") == "default-native-lookback": + event_window["sourceDocumentSha256"] = review_scope["sourceDocumentSha256"] + event_window["targetQuerySha256"] = review_scope["targetQuerySha256"] 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 = ( @@ -1536,6 +1844,7 @@ def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any] warning for warning in mapping_warnings if warning.startswith(NONBLOCKING_ENTITY_REVIEW_PREFIXES) + or "genuine content SHA-1/SHA-256 is required" in warning ] if any(reason.startswith(ENTITY_CONFIRMATION_PREFIX) for reason in entity_review_reasons) and REQUIRED_ASSET_COLLECTIONS.intersection( mappings @@ -1626,7 +1935,12 @@ def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any] 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: + is_legacy_hourly_lookback_decision = ( + isinstance(event_window, dict) + and event_window.get("status") == "default-native-lookback" + and reason.startswith("Original Sentinel lookback is shorter than the native XDR service window") + ) + if reason not in review_reasons and not is_legacy_hourly_lookback_decision: errors.append(f"reviewDecision reason does not match a current review finding: {reason}") continue recorded_review_decisions.append({ @@ -1674,7 +1988,7 @@ def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any] ) return { "schemaVersion": SCHEMA_VERSION, - "version": INITIAL_XDR_VERSION, + "version": _solution_release_version(solution_root, config), "kind": "CustomDetection", "resourceType": "Microsoft.Security/detectionRules", "apiVersion": DETECTION_API_VERSION, @@ -1692,7 +2006,7 @@ def build_xdr_document(source: Path, solution_root: Path, config: dict[str, Any] "kind": "AnalyticsRule", "id": source_id, "path": relative_source, - "version": str(doc.get("version") or ""), + "version": _extract_raw_yaml_version(raw_source_text, doc), "querySha256": hashlib.sha256(source_query.encode("utf-8")).hexdigest(), "schedule": { "queryFrequency": str(doc.get("queryFrequency") or ""), @@ -1857,8 +2171,37 @@ def validate_document(document: dict[str, Any]) -> list[str]: query = ((properties.get("queryCondition") or {}).get("queryText") or "") if not query: errors.append("properties.queryCondition.queryText is required") + schedule = properties.get("schedule") or {} + if isinstance(schedule, dict) and schedule.get("frequency") in {"PT0S", "NRT"}: + nrt_eligible, nrt_reasons = _assess_nrt_eligibility(query, query) + if not nrt_eligible and conversion.get("status") != "needsReview": + errors.extend(f"NRT schedule: {reason}" for reason in nrt_reasons) alert = ((properties.get("detectionAction") or {}).get("alertTemplate") or {}) mappings = alert.get("entityMappings") or {} + if isinstance(mappings, dict): + for collection_name in ("files", "processes"): + items = mappings.get(collection_name) + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + if collection_name == "processes" and not ( + item.get("sha1Column") or item.get("sha256Column") + ): + errors.append( + "entityMappings.processes requires a genuine sha1Column or sha256Column content hash; " + "filename-only bindings are not supported" + ) + for hash_field in ("sha1Column", "sha256Column"): + col = item.get(hash_field) + if isinstance(col, str) and col: + bad_reason = _is_non_hash_or_synthetic_hash_column(query, col) + if bad_reason: + errors.append( + f"entityMappings.{collection_name}.{hash_field} column `{col}` is a " + f"{bad_reason}; a genuine content SHA-1/SHA-256 is required" + ) 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: @@ -2087,7 +2430,8 @@ def convert_solution( ) ) continue - document["version"] = existing_document["version"] + if _version_tuple(existing_document["version"]) >= _version_tuple(document["version"]): + document["version"] = existing_document["version"] previous_id = existing_document["properties"]["id"] identity_change = ( (existing_document.get("contentProvenance") or {}).get("conversion") or {} diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dependencies.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dependencies.py index d20002e1a82..ac176fe24fc 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dependencies.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/dependencies.py @@ -101,6 +101,22 @@ def workload_records(tables: list[str]) -> tuple[list[dict[str, Any]], list[str] ], sorted(unknown) +STANDARD_BUILTIN_ASIM_PARSER_RE = re.compile( + r"\b(_(?:asim|im)_(?!test\b)[A-Za-z0-9_]+)\b", + re.IGNORECASE, +) + + +def extract_builtin_asim_parsers(query: str) -> list[str]: + from ._workbook_core import rewrite + code = "".join(text if kind == "code" else " " * len(text) + for kind, text in rewrite._segments(query or "")) + found: set[str] = set() + for match in STANDARD_BUILTIN_ASIM_PARSER_RE.finditer(code): + found.add(match.group(1)) + return sorted(found) + + def dependency_plan( source_query: str, *, @@ -116,6 +132,14 @@ def dependency_plan( for kind, text in rewrite._segments(source_query)) if re.search(r"\b_GetWatchlist\s*\(", code): source_types.add("Watchlist") + builtin_asim_parsers = extract_builtin_asim_parsers(source_query) + if not source_types and builtin_asim_parsers and isinstance(connectors, list): + for record in connectors: + if isinstance(record, dict) and isinstance(record.get("dataTypes"), list): + source_types.update( + table.strip() for table in record["dataTypes"] + if isinstance(table, str) and table.strip() + ) references = sorted(source_types & PLATFORM_REFERENCE_INPUTS) resolution["dataTypes"] = telemetry_data_types(source_types) mappings = table_mappings or {} @@ -125,7 +149,7 @@ def dependency_plan( target_tables = telemetry_data_types(target_table_names) workloads, unknown = workload_records(target_tables) reasons = [] - if not source_types: + if not source_types and not (builtin_asim_parsers and isinstance(connectors, list)): reasons.append("No source inputs resolved; provide source evidence and any telemetry connector prerequisites.") if unknown: reasons.append(f"Provide workload mappings for: {', '.join(unknown)}.") @@ -151,6 +175,7 @@ def dependency_plan( "reviewReasons": reasons, "runtimeParityVerified": False, **({"sourceDataTypes": sorted(source_types), "referenceInputs": references} if references else {}), + **({"builtinAsimParsers": builtin_asim_parsers} if builtin_asim_parsers else {}), } @@ -220,7 +245,7 @@ def live_dependency_check( ] if unknown: errors.append(f"Provide workload mappings for: {', '.join(unknown)}.") - if not plan["dataTypes"] and not plan.get("referenceInputs"): + if not plan["dataTypes"] and not plan.get("referenceInputs") and not plan.get("builtinAsimParsers"): errors.append("No data types resolved; provide the data types and required connectors.") connector_records = (document.get("contentProvenance") or {}).get("requiredDataConnectors") or [] connector_pairs = { diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py index 0c8e5bc1190..eecaa3ac87c 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/graph_readiness.py @@ -171,6 +171,8 @@ def graph_detection_payload_with_projection( ) projection = project_graph_tactics(alert) result = deepcopy(properties) + if isinstance(result.get("schedule"), dict) and result["schedule"].get("frequency") == "NRT": + result["schedule"]["frequency"] = "PT0S" result["@odata.type"] = "#microsoft.graph.security.detectionRule" result["status"] = "disabled" if package_context is not None: diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py index 05f82c28ba5..349b1078da6 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/packaging.py @@ -851,15 +851,45 @@ def arm_copy(value: Any) -> Any: name = resource.get("name") if not isinstance(name, str) or not name: raise RuntimeError("Required savedSearch has no exact resource identity") - if name.startswith("["): - if name.startswith("[[") or not name.endswith("]"): + dependency = None + parser_match = re.fullmatch( + r"\[variables\(\s*'(parserObject([1-9][0-9]*))'\s*\)\.(?:_?parserName\2|_parserId\2)\]", + name, + ) + if parser_match: + parser_object_name, parser_index = parser_match.groups() + parser_object = variables.get(parser_object_name) if isinstance(variables, dict) else None + if not isinstance(parser_object, dict): + raise RuntimeError("Invalid required savedSearch ARM identity") + parser_id = parser_object.get(f"_parserId{parser_index}") + if not isinstance(parser_id, str) or not parser_id.strip(): raise RuntimeError("Invalid required savedSearch ARM identity") - argument = name[1:-1] - else: - if len(name.split("/")) != 2 or not all(name.split("/")): - raise RuntimeError("Required savedSearch identity must include workspace and resource name") - argument = "'" + name.replace("'", "''") + "'" - dependency = f"[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', {argument})]" + dependency = f"[variables('{parser_object_name}')._parserId{parser_index}]" + elif re.match(r"\A\[variables\(\s*'parserObject", name): + raise RuntimeError("Invalid required savedSearch ARM identity") + elif isinstance(variables, dict): + for var_name, var_value in variables.items(): + idx_match = re.fullmatch(r"parserObject([1-9][0-9]*)", str(var_name)) + if idx_match and isinstance(var_value, dict): + idx = idx_match.group(1) + parser_id = var_value.get(f"_parserId{idx}") + if ( + isinstance(parser_id, str) + and parser_id.strip() + and name in (var_value.get(f"_parserName{idx}"), var_value.get(f"parserName{idx}")) + ): + dependency = f"[variables('{var_name}')._parserId{idx}]" + break + if dependency is None: + if name.startswith("["): + if name.startswith("[[") or not name.endswith("]"): + raise RuntimeError("Invalid required savedSearch ARM identity") + argument = name[1:-1] + else: + if len(name.split("/")) != 2 or not all(name.split("/")): + raise RuntimeError("Required savedSearch identity must include workspace and resource name") + argument = "'" + name.replace("'", "''") + "'" + dependency = f"[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', {argument})]" if dependency not in parser_dependencies: parser_dependencies.append(dependency) expected_install_dependencies = [ diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/release_notes.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/release_notes.py new file mode 100644 index 00000000000..15c28514a72 --- /dev/null +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/release_notes.py @@ -0,0 +1,139 @@ +"""Predictable release-notes date resolution and validation helpers.""" +from __future__ import annotations + +from datetime import date, datetime +import hashlib +from pathlib import Path +import re + +RELEASE_DATE_FORMAT = "%d-%m-%Y" +EXPECTED_RELEASE_NOTES_HEADER = [ + "**Version**", + "**Date Modified (DD-MM-YYYY)**", + "**Change History**", +] + + +def default_release_date(*, today: date | None = None) -> str: + """Return today's date in the repository-standard DD-MM-YYYY format.""" + current = today if today is not None else date.today() + return current.strftime(RELEASE_DATE_FORMAT) + + +def resolve_release_date( + release_date: str | None = None, + *, + today: date | None = None, +) -> str: + """Resolve an optional release date to a validated DD-MM-YYYY string.""" + if release_date is None or (isinstance(release_date, str) and not release_date.strip()): + return default_release_date(today=today) + if not isinstance(release_date, str): + raise ValueError("release date must be a valid DD-MM-YYYY date") + try: + parsed_date = datetime.strptime(release_date, RELEASE_DATE_FORMAT) + except ValueError as exc: + raise ValueError("release date must be a valid DD-MM-YYYY date") from exc + if parsed_date.strftime(RELEASE_DATE_FORMAT) != release_date: + raise ValueError("release date must use DD-MM-YYYY format") + return release_date + + +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("|")] + + +def release_notes_refresh_proposal( + solution: Path, + *, + version: str | None, + release_date: str | None = None, + change_history: str, + today: date | None = None, +) -> tuple[Path, str, str, str]: + """Validate ReleaseNotes.md and compute the expected single-row refresh digest.""" + if not version or not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError("current solution package version must use X.Y.Z format") + resolved_date = resolve_release_date(release_date, today=today) + 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) + + header_index = next( + ( + index + for index, line in enumerate(lines) + if _table_cells(line) == EXPECTED_RELEASE_NOTES_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], RELEASE_DATE_FORMAT) + except ValueError as exc: + raise ValueError("ReleaseNotes.md contains an invalid DD-MM-YYYY date") from exc + if date_value.strftime(RELEASE_DATE_FORMAT) != 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} | {resolved_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(), + resolved_date, + ) diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py index 7a0327d1031..e1b340cc655 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/report.py @@ -24,6 +24,103 @@ def _messages(values: list[str], empty: str) -> str: return "
    " + "".join(f"
  • {escape(value)}
  • " for value in values) + "
" +def summarize_report_blockers( + summary: dict[str, Any], + *, + kind: str = "transformation", +) -> dict[str, Any]: + existing = summary.get("nextStepRecommendation") or summary.get("nextBlockerReview") + if isinstance(existing, dict) and existing.get("summary"): + return existing + + blockers: list[dict[str, str]] = [] + if kind == "runtime": + whitelisted = { + str(item.get("detection") or "") + for item in summary.get("sourceWhitelist") or [] + if isinstance(item, dict) and item.get("applied") is True + } + for result in summary.get("results") or []: + if not isinstance(result, dict): + continue + detection = str(result.get("detection") or "") + status = str(result.get("status") or "") + if status in {"failed", "blocked"} and detection not in whitelisted: + reason = str(result.get("error") or status) + blockers.append( + { + "id": detection, + "label": detection or "runtime-validation", + "category": "runtimeValidation", + "reason": reason, + } + ) + else: + for result in summary.get("results") or []: + if not isinstance(result, dict): + continue + status = str(result.get("status") or "") + errors = list(result.get("errors") or []) + reviews = list(result.get("reviewReasons") or []) + review_required = bool(result.get("reviewRequired")) + if status == "conflict" or errors or review_required or status == "needsReview": + label = str( + result.get("displayName") + or Path(str(result.get("source") or result.get("output") or "rule")).stem + ) + reason = ( + str(errors[0]) + if errors + else str(reviews[0]) + if reviews + else f"status={status}" + ) + blockers.append( + { + "id": str(result.get("ruleId") or result.get("outputRelativePath") or label), + "label": label, + "category": "technicalFailure" if errors or status == "conflict" else "conversionReview", + "reason": reason, + } + ) + + top_blockers = blockers[:3] + labels = [item["label"] for item in top_blockers] + if len(labels) == 1: + text = f"Next migration step: review blocker {labels[0]}." + elif len(labels) >= 2: + text = f"Next migration step: review blockers {labels[0]} and {labels[1]}." + else: + text = "Next migration step: no remaining blockers in this report." + return { + "status": "blocked" if blockers else "ready", + "blockerCount": len(blockers), + "topBlockers": top_blockers, + "blockerLabels": labels, + "summary": text, + "message": text, + } + + +def _render_next_step_banner(recommendation: dict[str, Any]) -> str: + summary_text = str(recommendation.get("summary") or recommendation.get("message") or "") + top_blockers = recommendation.get("topBlockers") or [] + blocker_items = "".join( + f'
  • {escape(str(item.get("label") or item.get("id") or ""))}: ' + f'{escape(str(item.get("reason") or item.get("category") or ""))}
  • ' + for item in top_blockers + if isinstance(item, dict) + ) + details_html = f"
      {blocker_items}
    " if blocker_items else "" + return ( + '
    ' + f"{escape(summary_text)}" + f"{details_html}" + "
    " + ) + + + 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 {} @@ -59,6 +156,9 @@ def render_transformation_report(summary: dict[str, Any]) -> str: """ ) + recommendation_banner = _render_next_step_banner( + summarize_report_blockers(summary, kind="transformation") + ) return f""" @@ -73,6 +173,7 @@ def render_transformation_report(summary: dict[str, Any]) -> str:

    Sentinel to XDR transformation report

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

    {escape(scope_notice)}

    + {recommendation_banner}
    Rules attempted{summary["total"]}
    Converted{summary["converted"]}
    @@ -146,6 +247,9 @@ def render_runtime_validation_report(summary: dict[str, Any]) -> str: """ ) + recommendation_banner = _render_next_step_banner( + summarize_report_blockers(summary, kind="runtime") + ) return f""" @@ -159,6 +263,7 @@ def render_runtime_validation_report(summary: dict[str, Any]) -> str:

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

    {escape(str(summary["solution"]))} - Provider: {escape(str(summary["provider"]))} - Generated {generated_at}
    + {recommendation_banner}
    Rules tested{summary["total"]}
    Passed{summary["valid"]}
    diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workbook_workflow.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workbook_workflow.py index a167984f89a..096feb9fc65 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workbook_workflow.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workbook_workflow.py @@ -15,6 +15,13 @@ CAPABILITY_VERSION = 1 ARM_VALIDATION_METHODS = ("arm-ttk", "lab-deployment") +DEFAULT_ARM_VALIDATION_METHOD = "lab-deployment" + + +def default_arm_validation_method(workflow_profile: str = "authoring") -> str: + if workflow_profile not in ("authoring", "qualification"): + raise ValueError(f"unsupported workflow profile: {workflow_profile}") + return DEFAULT_ARM_VALIDATION_METHOD def enabled(state: dict[str, Any]) -> bool: @@ -513,7 +520,290 @@ def create_candidate(solution: str | Path, *, validation_receipt: str | Path | N "armCandidateSha256": hashlib.sha256(candidate_path.read_bytes()).hexdigest(), }) workflow._write_unlocked(state_path, state) - return {**candidate, "candidatePath": str(candidate_path), "officialReport": official_report} + preflight_summary = build_lab_deployment_preflight_prompt(root, state=state) + return { + **candidate, + "candidatePath": str(candidate_path), + "officialReport": official_report, + "expectedContentCounts": preflight_summary["expectedContentCounts"], + "labDeploymentPreflightPrompt": preflight_summary["labDeploymentPreflightPrompt"], + } + + +def expected_content_counts( + solution: str | Path, + state: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Compute expected content counts (Sentinel rules, workbooks breakdown, custom detections).""" + from .converter import analytic_rule_files, xdr_detection_files + from .packaging_selection import current_applied_package_selection + + root = Path(solution).expanduser().resolve() + if state is None: + state = current_state(root) + + data_doc: dict[str, Any] = {} + if (root / "Data").is_dir(): + try: + _, data_doc = _data(root) + except Exception: + data_doc = {} + + def _data_list(field_name: str) -> list[str] | None: + for key, value in data_doc.items(): + if key.casefold() == field_name.casefold(): + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return None + if isinstance(value, list): + return [str(item).strip() for item in value if isinstance(item, str) and str(item).strip()] + return None + + applied_selection = current_applied_package_selection(root) + selected_plan = applied_selection["plan"] if isinstance(applied_selection, dict) and isinstance(applied_selection.get("plan"), dict) else None + + if selected_plan and isinstance(selected_plan.get("selectedAnalyticRules"), list): + sentinel_rules_count = len(selected_plan["selectedAnalyticRules"]) + else: + data_rules = _data_list("Analytic Rules") or _data_list("Analytics Rules") + if data_rules is not None: + sentinel_rules_count = len(data_rules) + elif has_analytic_rules(root): + sentinel_rules_count = len(analytic_rule_files(root)) + else: + sentinel_rules_count = 0 + + if selected_plan and isinstance(selected_plan.get("selectedXdrDetections"), list): + custom_detections_count = len(selected_plan["selectedXdrDetections"]) + else: + data_xdr_det = _data_list("XDR Detections") + if data_xdr_det is not None: + custom_detections_count = len(data_xdr_det) + elif (root / "XDR Detections").is_dir(): + custom_detections_count = len(xdr_detection_files(root)) + else: + custom_detections_count = 0 + + data_wb = _data_list("Workbooks") + if data_wb is not None: + sentinel_workbooks_count = len(data_wb) + elif state and enabled(state): + sentinel_workbooks_count = len(state["workbooks"]["inventory"]) + else: + try: + sentinel_workbooks_count = len(_inventory(root)) + except Exception: + sentinel_workbooks_count = 0 + + data_xdr_wb = _data_list("XDR Workbooks") + if state and enabled(state): + defender_workbooks_count = len(state["workbooks"]["selection"]["included"]) + elif data_xdr_wb is not None: + defender_workbooks_count = len(data_xdr_wb) + elif (root / "XDR Workbooks").is_dir(): + defender_workbooks_count = sum( + 1 for path in (root / "XDR Workbooks").rglob("*.json") if path.is_file() + ) + else: + defender_workbooks_count = 0 + + workbooks_count = sentinel_workbooks_count + defender_workbooks_count + return { + "sentinelRulesCount": sentinel_rules_count, + "workbooksCount": workbooks_count, + "sentinelWorkbooksCount": sentinel_workbooks_count, + "defenderWorkbooksCount": defender_workbooks_count, + "workbooksBreakdown": { + "sentinel": sentinel_workbooks_count, + "defender": defender_workbooks_count, + }, + "customDetectionsCount": custom_detections_count, + } + + +def build_lab_deployment_preflight_prompt( + solution: str | Path, + *, + state: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build preflight preapproval summary for deploying solution ARM templates to the lab.""" + root = Path(solution).expanduser().resolve() + if state is None: + state = current_state(root) + counts = expected_content_counts(root, state=state) + x = counts["sentinelRulesCount"] + y = counts["workbooksCount"] + z = counts["customDetectionsCount"] + prompt = ( + "Requesting preflight preapproval to deploy the solution ARM templates to the " + "selected non-production lab to verify ARM template deployment validity " + f"(expected content items: {x} Sentinel rules, {y} workbooks, and {z} custom detections). " + "Deployment verification alone does not establish functional/runtime qualification or alert parity." + ) + method = ( + (state or {}).get("context", {}).get("armValidationMethod") + or default_arm_validation_method((state or {}).get("context", {}).get("workflowProfile", "authoring")) + ) + return { + "schemaVersion": 1, + "armValidationMethod": method, + "defaultArmValidationMethod": DEFAULT_ARM_VALIDATION_METHOD, + "skipsArmTtk": method == "lab-deployment", + "expectedContentCounts": counts, + "labDeploymentPreflightPrompt": prompt, + "functionalQualificationVerified": False, + "alertParityVerified": False, + } + + +def plan_lab_deployment_preflight( + solution: str | Path, + *, + run_id: str | None = None, + persist: bool = True, +) -> dict[str, Any]: + """Compute and optionally persist the lab deployment preflight preapproval artifact.""" + from .artifacts import using_run + + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + state = current_state(root) + payload = build_lab_deployment_preflight_prompt(root, state=state) + if state and state.get("runId"): + payload["runId"] = state["runId"] + if persist: + path = _write_run_json(root, "arm-deployment-preflight.json", payload) + payload["preflightPromptPath"] = path.relative_to(root).as_posix() + return payload + + +def record_lab_deployment_validation_findings( + solution: str | Path, + result: dict[str, Any], + *, + candidate: dict[str, Any] | None = None, + plan: dict[str, Any] | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + """Record lab deployment validation findings (including failures and target/artifact bindings) as durable workflow evidence.""" + from .artifacts import using_run + from . import workflow + + root = Path(solution).expanduser().resolve() + with using_run(root, run_id): + state = current_state(root) + if candidate is None: + try: + candidate = _read_run_json(root, "arm-candidate.json") + except Exception: + candidate = {} + if plan is None: + try: + plan = _read_run_json(root, "arm-validation-plan.json") + except Exception: + plan = {} + + status = str(result.get("status") or "unknown") + prov_state = str(result.get("provisioningState") or "").strip() + if status == "arm-accepted-contract-pending" or prov_state.casefold() == "succeeded": + outcome = "passed" + elif status in {"failed", "unknown"} or prov_state.casefold() in {"failed", "canceled"}: + outcome = "failed" + else: + outcome = "blocked" + + findings: list[dict[str, Any]] = [] + for reason in result.get("reasons") or []: + if isinstance(reason, str) and reason.strip(): + findings.append({"kind": "scope-or-gate-blocker", "severity": "error", "message": reason.strip()}) + identity_check = result.get("identityCheck") + if isinstance(identity_check, dict): + for reason in identity_check.get("reasons") or []: + if isinstance(reason, str) and reason.strip(): + findings.append({"kind": "identity-check", "severity": "error", "message": reason.strip()}) + if isinstance(result.get("error"), str) and result["error"].strip(): + findings.append({"kind": "deployment-execution", "severity": "error", "message": result["error"].strip()}) + reconciliation = result.get("reconciliation") + if isinstance(reconciliation, dict) and reconciliation.get("status") not in (None, "passed"): + findings.append({ + "kind": "reconciliation", + "severity": "error", + "message": f"reconciliation status: {reconciliation.get('status')}", + }) + if outcome == "passed" and not findings: + findings.append({ + "kind": "deployment-verification", + "severity": "info", + "message": "ARM incremental deployment succeeded in the selected non-production lab; deployment verification alone does not establish functional/runtime qualification or alert parity.", + }) + + pkg_identity = ( + result.get("packageIdentity") + or (plan or {}).get("packageIdentity") + or (candidate or {}).get("packageIdentity") + or {} + ) + artifact_bindings = { + "candidateSha256": result.get("candidateSha256") or (candidate or {}).get("candidateSha256"), + "planSha256": result.get("planSha256") or (plan or {}).get("planSha256"), + "mainTemplateSha256": result.get("mainTemplateSha256") or (plan or {}).get("mainTemplateSha256") or (candidate or {}).get("mainTemplateSha256"), + "packageArtifactsSha256": pkg_identity.get("packageArtifactsSha256") if isinstance(pkg_identity, dict) else None, + "packageReportSha256": pkg_identity.get("packageReportSha256") if isinstance(pkg_identity, dict) else None, + "packageInputSha256": pkg_identity.get("packageInputSha256") if isinstance(pkg_identity, dict) else None, + "artifactPaths": (plan or {}).get("artifactPaths") or (candidate or {}).get("artifactPaths") or {}, + "selectedFlags": (plan or {}).get("selectedFlags") or {}, + "parameters": result.get("parameters") or (plan or {}).get("parameters") or {}, + } + target_binding = result.get("target") or (plan or {}).get("target") or (plan or {}).get("scope", {}).get("lab") or {} + preflight_summary = build_lab_deployment_preflight_prompt(root, state=state) + evidence_record = { + "schemaVersion": 1, + "runId": (state or {}).get("runId") or (candidate or {}).get("runId") or (plan or {}).get("runId"), + "armValidationMethod": "lab-deployment", + "status": status, + "outcome": outcome, + "writeAttempted": bool(result.get("writeAttempted", False)), + "deploymentName": result.get("deploymentName"), + "deploymentId": result.get("deploymentId"), + "provisioningState": result.get("provisioningState"), + "commandExitCode": result.get("commandExitCode"), + "target": target_binding, + "artifactBindings": artifact_bindings, + "expectedContentCounts": preflight_summary["expectedContentCounts"], + "labDeploymentPreflightPrompt": preflight_summary["labDeploymentPreflightPrompt"], + "findings": findings, + "attemptPath": result.get("attemptPath"), + "reconciliation": reconciliation if isinstance(reconciliation, dict) else None, + "functionalQualificationVerified": False, + "alertParityVerified": False, + "installationVerified": False, + "registrationVerified": False, + "uiVerified": False, + "parityVerified": False, + "accepted": False, + } + if state and state.get("runId"): + evidence_path = _write_run_json(root, "arm-deployment-validation-evidence.json", evidence_record) + rel_path = evidence_path.relative_to(root).as_posix() + evidence_record["evidencePath"] = rel_path + try: + with workflow._state_writer_lock(root) as state_path: + latest_state = workflow._load(root)[1] + pkg_stage = latest_state.get("stages", {}).get("packaging") + if isinstance(pkg_stage, dict): + pkg_artifacts = pkg_stage.setdefault("artifacts", {}) + if isinstance(pkg_artifacts, dict): + pkg_artifacts["labArmDeploymentEvidence"] = rel_path + pkg_artifacts["labArmDeploymentStatus"] = status + pkg_artifacts["labArmDeploymentOutcome"] = outcome + pkg_artifacts["functionalQualificationVerified"] = "false" + pkg_artifacts["alertParityVerified"] = "false" + workflow._write_unlocked(state_path, latest_state) + except Exception: + pass + return evidence_record def plan_candidate(solution: str | Path, *, target: dict[str, Any], @@ -526,6 +816,8 @@ def plan_candidate(solution: str | Path, *, target: dict[str, Any], plan = (plan_workbook_contract_verification if contract_only else plan_lab_arm_validation)( context, candidate, target=target, selected_flags=selected_flags) _write_run_json(solution, "workbook-contract-plan.json" if contract_only else "arm-validation-plan.json", plan) + if not contract_only: + plan_lab_deployment_preflight(solution, persist=True) return plan @@ -581,6 +873,17 @@ def dispatch(command: list[str]): identity_check=identity_check or (lambda target: production_identity_check(solution, target, profile=profile)), command_runner=command_runner or dispatch, ) + findings_evidence = record_lab_deployment_validation_findings( + solution, result, candidate=candidate, plan=plan + ) + result = { + **result, + "functionalQualificationVerified": False, + "alertParityVerified": False, + "artifactBindings": findings_evidence["artifactBindings"], + "findings": findings_evidence["findings"], + "evidencePath": findings_evidence.get("evidencePath"), + } _write_run_json(solution, "arm-validation-result.json", result) return result @@ -595,6 +898,15 @@ def reconcile_attempt(solution: str | Path, *, attempt: str | Path, observation_path = content_path(root, observation) result = reconcile_lab_arm_attempt(attempt_path, observation=json.loads(observation_path.read_bytes()), actor=actor, reference=reference) + findings_evidence = record_lab_deployment_validation_findings(root, result) + result = { + **result, + "functionalQualificationVerified": False, + "alertParityVerified": False, + "artifactBindings": findings_evidence["artifactBindings"], + "findings": findings_evidence["findings"], + "evidencePath": findings_evidence.get("evidencePath"), + } _write_run_json(root, "arm-validation-result.json", result) return result @@ -775,12 +1087,16 @@ def summary(solution: str | Path, state: dict[str, Any]) -> dict[str, Any] | Non contract_support = "verified" except (OSError, ValueError, KeyError, TypeError): issues.append("previous workbook acceptance evidence is no longer current") + preflight_summary = build_lab_deployment_preflight_prompt(solution, state=state) return { "inventoryCount": len(workbook_state["inventory"]), "selectedCount": len(workbook_state["selection"]["included"]), "excluded": workbook_state["selection"]["excluded"], "inventorySha256": workbook_state["inventorySha256"], "armValidationMethod": state["context"]["armValidationMethod"], + "defaultArmValidationMethod": DEFAULT_ARM_VALIDATION_METHOD, + "expectedContentCounts": preflight_summary["expectedContentCounts"], + "labDeploymentPreflightPrompt": preflight_summary["labDeploymentPreflightPrompt"], "contractSupport": contract_support, "issues": issues, "sources": [{ @@ -791,3 +1107,369 @@ def summary(solution: str | Path, state: dict[str, Any]) -> dict[str, Any] | Non "manifest": str(manifest_path) if manifest_path.is_file() else None, "qualificationSeparateFromArmAndDetectionParity": True, } + + +def assess_qualification_readiness( + solution_path: str | Path, + *, + run_id: str | None = None, +) -> dict[str, Any]: + """Inspect current workflow state, authorization/consent records, and preflight receipts. + + Separately reports: + - authorizationChecks (target locked, runtime query consent, ARM preflight read consent, deployment consent) + - deploymentPermissionReadiness (ARM control-plane access, resource group/workspace write readiness, extension readiness) + - ingestionPermissionReadiness (DCR/DCE/Logs Ingestion readiness, kept strictly separate from deployment readiness) + """ + from .artifacts import using_run + from .target_context import workspace_identity + + root = Path(solution_path).expanduser().resolve() + with using_run(root, run_id): + state = current_state(root) + context = (state or {}).get("context") or {} + effective_run_id = (state or {}).get("runId") or run_id + run_dir = report_directory(root) if effective_run_id else None + + def _read_optional_run_json(name: str) -> dict[str, Any] | None: + if run_dir is None: + return None + candidate_file = run_dir / name + if candidate_file.is_symlink() or not candidate_file.is_file(): + return None + try: + loaded = json.loads(candidate_file.read_text(encoding="utf-8-sig")) + return loaded if isinstance(loaded, dict) else None + except Exception: + return None + + target_fields = ("tenantId", "subscriptionId", "workspaceResourceId", "workspaceCustomerId") + missing_target = [key for key in target_fields if not context.get(key)] + target_locked = bool(context.get("targetContextLocked") is True and not missing_target) + resource_group = None + if context.get("workspaceResourceId"): + try: + resource_group = workspace_identity(str(context["workspaceResourceId"]))["resourceGroup"] + except Exception: + resource_group = None + + runtime_consent = _read_optional_run_json("runtime-query-consent.json") or _read_optional_run_json( + "authoring-runtime-query-consent.json" + ) + runtime_consent_status = ( + "approved" + if isinstance(runtime_consent, dict) + and runtime_consent.get("status") in {"approved", "confirmed"} + and runtime_consent.get("withdrawn") is not True + and runtime_consent.get("revoked") is not True + else ("revoked" if isinstance(runtime_consent, dict) and (runtime_consent.get("withdrawn") or runtime_consent.get("revoked")) else "missing") + ) + + arm_preflight_consent = _read_optional_run_json("arm-preflight-consent.json") + arm_preflight_consent_status = ( + "approved" + if isinstance(arm_preflight_consent, dict) + and arm_preflight_consent.get("status") == "approved" + and arm_preflight_consent.get("revoked") is not True + else ( + "revoked" + if isinstance(arm_preflight_consent, dict) + and (arm_preflight_consent.get("status") == "revoked" or arm_preflight_consent.get("revoked") is True) + else "missing" + ) + ) + + deployment_consent = _read_optional_run_json("arm-validation-consent.json") + deployment_consent_status = ( + "approved" + if isinstance(deployment_consent, dict) + and deployment_consent.get("status") == "approved" + and deployment_consent.get("withdrawn") is not True + and deployment_consent.get("deploymentOnly") is True + else ( + "withdrawn" + if isinstance(deployment_consent, dict) + and (deployment_consent.get("status") == "withdrawn" or deployment_consent.get("withdrawn") is True) + else "missing" + ) + ) + + auth_ready = ( + target_locked + and runtime_consent_status == "approved" + and arm_preflight_consent_status == "approved" + and deployment_consent_status == "approved" + ) + authorization_checks = { + "status": "ready" if auth_ready else "blocked", + "targetLocked": { + "status": "ready" if target_locked else "blocked", + "locked": target_locked, + "missingFields": missing_target, + "resourceGroup": resource_group, + }, + "runtimeQueryConsent": { + "status": runtime_consent_status, + "ready": runtime_consent_status == "approved", + "approvedFamilies": (runtime_consent or {}).get("approvedFamilies", []), + }, + "armPreflightReadConsent": { + "status": arm_preflight_consent_status, + "ready": arm_preflight_consent_status == "approved", + "writesAuthorized": False, + }, + "deploymentConsent": { + "status": deployment_consent_status, + "ready": deployment_consent_status == "approved", + "deploymentOnly": bool((deployment_consent or {}).get("deploymentOnly") is True), + "ingestionAuthorized": False, + }, + } + + arm_preflight = _read_optional_run_json("arm-preflight-result.json") + arm_checks = (arm_preflight or {}).get("checks") if isinstance(arm_preflight, dict) else {} + if not isinstance(arm_checks, dict): + arm_checks = {} + + control_plane_ok = bool( + isinstance(arm_preflight, dict) + and arm_preflight.get("tenantMatches") is True + and arm_preflight.get("subscriptionMatches") is True + and arm_preflight.get("workspaceMatches") is True + and arm_preflight.get("nonProduction") is True + ) + rg_ws_write_ok = bool( + isinstance(arm_preflight, dict) + and arm_preflight.get("armDeploymentAllowed") is True + and arm_preflight.get("disabledContentVerified") is True + and arm_preflight.get("ownershipVerified") is True + ) + ext_check = arm_checks.get("extensionResourceWriteAllowed") if isinstance(arm_checks.get("extensionResourceWriteAllowed"), dict) else {} + ext_required = bool(ext_check.get("required", True)) if arm_preflight else True + ext_ok = bool( + isinstance(arm_preflight, dict) + and (not ext_required or arm_preflight.get("extensionResourceWriteAllowed") is True) + ) + deployment_ready = bool( + isinstance(arm_preflight, dict) + and arm_preflight.get("status") == "ready" + and control_plane_ok + and rg_ws_write_ok + and ext_ok + ) + deployment_permission_readiness = { + "status": ( + "ready" + if deployment_ready + else ("not-evaluated" if arm_preflight is None else str(arm_preflight.get("status") or "blocked")) + ), + "ready": deployment_ready, + "receiptPresent": arm_preflight is not None, + "armControlPlaneAccess": { + "status": "ready" if control_plane_ok else ("not-evaluated" if arm_preflight is None else "blocked"), + "tenantMatches": (arm_preflight or {}).get("tenantMatches"), + "subscriptionMatches": (arm_preflight or {}).get("subscriptionMatches"), + "workspaceMatches": (arm_preflight or {}).get("workspaceMatches"), + "nonProduction": (arm_preflight or {}).get("nonProduction"), + }, + "resourceGroupAndWorkspaceWriteReadiness": { + "status": "ready" if rg_ws_write_ok else ("not-evaluated" if arm_preflight is None else "blocked"), + "armDeploymentAllowed": (arm_preflight or {}).get("armDeploymentAllowed"), + "disabledContentVerified": (arm_preflight or {}).get("disabledContentVerified"), + "ownershipVerified": (arm_preflight or {}).get("ownershipVerified"), + }, + "extensionReadiness": { + "status": ( + "not-required" + if isinstance(arm_preflight, dict) and not ext_required + else ("ready" if ext_ok else ("not-evaluated" if arm_preflight is None else "blocked")) + ), + "required": ext_required, + "extensionResourceWriteAllowed": (arm_preflight or {}).get("extensionResourceWriteAllowed"), + }, + "reasons": list((arm_preflight or {}).get("reasons") or []) if isinstance(arm_preflight, dict) else ["ARM preflight receipt is missing"], + "impliesIngestionReadiness": False, + } + + dcr_preflight = _read_optional_run_json("qualification-dcr-preflight.json") + dcr_summary = (dcr_preflight or {}).get("summary") if isinstance(dcr_preflight, dict) else {} + if not isinstance(dcr_summary, dict): + dcr_summary = {} + dcr_candidates = (dcr_preflight or {}).get("candidates") if isinstance(dcr_preflight, dict) else [] + if not isinstance(dcr_candidates, list): + dcr_candidates = [] + + dcr_status = str((dcr_preflight or {}).get("status") or ("not-evaluated" if dcr_preflight is None else "blocked")) + dcr_dce_ready = bool( + isinstance(dcr_preflight, dict) + and bool(dcr_candidates) + and all( + isinstance(cand, dict) + and (cand.get("resourceProvisioningAuthorization") or {}).get("status") in {"ready", "not-needed"} + and (cand.get("tableSchemaCompatibility") or {}).get("status") == "compatible" + for cand in dcr_candidates + ) + ) + logs_ingestion_ready = bool( + isinstance(dcr_preflight, dict) + and bool(dcr_candidates) + and all( + isinstance(cand, dict) + and (cand.get("ingestionAuthorization") or {}).get("status") == "ready" + for cand in dcr_candidates + ) + ) + ingestion_ready = bool(dcr_dce_ready and logs_ingestion_ready and dcr_status in {"ready", "passed"}) + ingestion_permission_readiness = { + "status": "ready" if ingestion_ready else ("not-evaluated" if dcr_preflight is None else dcr_status), + "ready": ingestion_ready, + "receiptPresent": dcr_preflight is not None, + "dcrAndDceReadiness": { + "status": "ready" if dcr_dce_ready else ("not-evaluated" if dcr_preflight is None else "blocked"), + "candidateCount": len(dcr_candidates), + "summary": dcr_summary, + }, + "logsIngestionReadiness": { + "status": "ready" if logs_ingestion_ready else ("not-evaluated" if dcr_preflight is None else "blocked"), + "requiredAction": "Microsoft.Insights/Telemetry/Write", + }, + "queryPermission": (dcr_preflight or {}).get("queryPermission") if isinstance(dcr_preflight, dict) else None, + "blockers": list((dcr_preflight or {}).get("blockers") or []) if isinstance(dcr_preflight, dict) else ["DCR/ingestion preflight receipt is missing"], + "separateFromDeploymentReadiness": True, + } + + preflight_prompt = build_lab_deployment_preflight_prompt(root, state=state) + overall_ready = bool(auth_ready and deployment_ready and ingestion_ready) + return { + "schemaVersion": 1, + "solution": str(root), + "runId": effective_run_id, + "workflowProfile": context.get("workflowProfile"), + "status": "ready" if overall_ready else "blocked", + "authorizationChecks": authorization_checks, + "deploymentPermissionReadiness": deployment_permission_readiness, + "ingestionPermissionReadiness": ingestion_permission_readiness, + "deploymentAndIngestionSeparated": True, + "expectedContentCounts": preflight_prompt["expectedContentCounts"], + "labDeploymentPreflightPrompt": preflight_prompt["labDeploymentPreflightPrompt"], + } + + +def audit_private_config_and_support_export( + solution_path: str | Path, + *, + run_id: str | None = None, +) -> dict[str, Any]: + """Audit private target configuration and support/evidence export artifacts before making exposure claims.""" + import re + from .artifacts import using_run + from .target_config import ENV_FILE_NAME, read_solution_target_config + from .target_context import workspace_identity + + root = Path(solution_path).expanduser().resolve() + with using_run(root, run_id): + state = current_state(root) + context = (state or {}).get("context") or {} + effective_run_id = (state or {}).get("runId") or run_id + run_dir = report_directory(root) if effective_run_id else None + + env_path = root / ENV_FILE_NAME + env_present = env_path.is_file() and not env_path.is_symlink() + env_config: dict[str, str] = {} + env_error: str | None = None + if env_present: + try: + env_config = read_solution_target_config(root) + except Exception as exc: + env_error = str(exc) + + private_identifiers: dict[str, str] = {} + for key in ("tenantId", "subscriptionId", "workspaceResourceId", "workspaceCustomerId"): + val = context.get(key) or env_config.get(key) + if isinstance(val, str) and val.strip(): + private_identifiers[key] = val.strip() + + ws_res_id = private_identifiers.get("workspaceResourceId") + if ws_res_id: + try: + ws_id = workspace_identity(ws_res_id) + private_identifiers.setdefault("subscriptionId", ws_id["subscriptionId"]) + private_identifiers["resourceGroup"] = ws_id["resourceGroup"] + private_identifiers["workspaceName"] = ws_id["workspaceName"] + except Exception: + pass + + local_unexported_files: list[str] = [] + if env_present: + local_unexported_files.append(ENV_FILE_NAME) + if run_dir and run_dir.is_dir(): + for local_name in ( + "workflow-state.json", + "qualification-target.json", + "qualification-dcr-preflight.json", + "arm-validation-plan.json", + "arm-preflight-plan.json", + "arm-preflight-result.json", + ): + candidate_file = run_dir / local_name + if candidate_file.is_file(): + local_unexported_files.append(candidate_file.relative_to(root).as_posix()) + + export_files_checked: list[str] = [] + exposed_in_exports: list[dict[str, str]] = [] + evidence_root = root / "Evidence" + export_dirs: list[Path] = [] + if effective_run_id and (evidence_root / effective_run_id).is_dir(): + export_dirs.append(evidence_root / effective_run_id) + elif evidence_root.is_dir(): + export_dirs.extend(sorted(p for p in evidence_root.iterdir() if p.is_dir())) + + uuid_pattern = 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, + ) + for export_dir in export_dirs: + for file_path in sorted(export_dir.rglob("*")): + if not file_path.is_file() or file_path.is_symlink(): + continue + rel = file_path.relative_to(root).as_posix() + export_files_checked.append(rel) + try: + content = file_path.read_text(encoding="utf-8-sig") + except Exception: + continue + for id_kind, id_value in sorted(private_identifiers.items()): + if len(id_value) >= 4 and id_value.casefold() in content.casefold(): + exposed_in_exports.append({ + "file": rel, + "identifierKind": id_kind, + }) + for match in uuid_pattern.findall(content): + for id_kind in ("tenantId", "subscriptionId", "workspaceCustomerId"): + expected = private_identifiers.get(id_kind) + if expected and match.casefold() == expected.casefold(): + entry = {"file": rel, "identifierKind": id_kind} + if entry not in exposed_in_exports: + exposed_in_exports.append(entry) + + private_config_isolated = not exposed_in_exports + return { + "schemaVersion": 1, + "solution": str(root), + "runId": effective_run_id, + "status": "isolated" if private_config_isolated else "exposed-in-support-export", + "privateConfigIsolatedToLocalUnexportedConfig": private_config_isolated, + "supportExportsRedacted": private_config_isolated, + "exposureClaimVerified": not private_config_isolated, + "localConfig": { + "envFilePresent": env_present, + "envParseError": env_error, + "trackedIdentifierKinds": sorted(private_identifiers.keys()), + "localUnexportedArtifacts": local_unexported_files, + }, + "supportExports": { + "checkedFiles": export_files_checked, + "exposures": exposed_in_exports, + }, + } + diff --git a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py index 0fb673ae09d..de35ec9de8e 100644 --- a/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py +++ b/Tools/SentinelToXDRMigration/sentinel_xdr_migration/workflow.py @@ -3308,98 +3308,18 @@ def _validate_historical_package_receipt( def _release_notes_refresh_proposal( solution: Path, *, - release_date: str, + release_date: str | None = None, 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" - ) + from .release_notes import release_notes_refresh_proposal - 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(), + path, original_sha, expected_sha, _ = release_notes_refresh_proposal( + solution, + version=solution_data_version(solution), + release_date=release_date, + change_history=change_history, ) + return path, original_sha, expected_sha def _identity_from_file_hashes(files: dict[str, str]) -> str: @@ -4237,13 +4157,16 @@ def verify_same_version_content_rebuild( def plan_release_notes_refresh( solution: str | Path, *, - release_date: str, + release_date: str | None = None, change_history: str, expected_package_input_sha256: str, ) -> dict[str, Any]: """Authorize only an exact ReleaseNotes row refresh without incrementing package version.""" + from .release_notes import resolve_release_date + 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") + resolved_release_date = resolve_release_date(release_date) root = Path(solution).expanduser().resolve() with _state_writer_lock(root) as path: loaded_path, state = _load(root) @@ -4284,7 +4207,7 @@ def plan_release_notes_refresh( 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, + root, release_date=resolved_release_date, change_history=change_history, ) relative_notes = notes_path.relative_to(root).as_posix() expected_files = dict(files) @@ -4300,7 +4223,7 @@ def plan_release_notes_refresh( "releaseNotesPath": relative_notes, "releaseNotesOriginalSha256": original_notes_sha, "releaseNotesExpectedSha256": expected_notes_sha, - "releaseNotesDate": release_date, + "releaseNotesDate": resolved_release_date, "releaseNotesVersion": current_version, "releaseNotesChangeHistory": change_history.strip(), "packageInputFilesBefore": files, @@ -4332,7 +4255,8 @@ def plan_release_notes_refresh( "packageInputSha256Before": base_identity, "packageInputSha256After": expected_identity, "releaseNotesPath": relative_notes, - "releaseNotesDate": release_date, + "releaseDate": resolved_release_date, + "releaseNotesDate": resolved_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"], @@ -5704,28 +5628,38 @@ def workflow_status(solution: str | Path) -> dict[str, Any]: (state.get("stages") or {}).get("alertParity") or {} ).get("status"), } + workbook_summary = workbook_workflow.summary(solution, state) + runtime_assessment = { + "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 [], + } + recommendation = _build_next_step_recommendation( + solution, + state, + next_stage=next_stage, + freshness=freshness, + runtime_assessment=runtime_assessment, + workbook_summary=workbook_summary, + ) return { **state, - "workbookSummary": workbook_workflow.summary(solution, state), + "workbookSummary": workbook_summary, "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 [], - }, + "runtimeAssessment": runtime_assessment, "authoringSummary": authoring_summary, "partialSolution": partial_package_summary, "budgetSummary": ( @@ -5742,6 +5676,8 @@ def workflow_status(solution: str | Path) -> dict[str, Any]: "conversionScope": conversion_scope(solution), "statePath": str(existing_artifact_path(solution, STATE_FILE_NAME)), "next": next_stage, + "nextStepRecommendation": recommendation, + "nextBlockerReview": recommendation, } @@ -5913,6 +5849,13 @@ def next_workflow_stage(solution: str | Path) -> dict[str, Any]: ) if blocked_by: break + recommendation = _build_next_step_recommendation( + solution, + state, + next_stage=name, + freshness=freshness, + workbook_summary=workbook_workflow.summary(solution, state), + ) return { "runId": state.get("runId"), "conversionScope": conversion_scope(solution), @@ -5923,6 +5866,8 @@ def next_workflow_stage(solution: str | Path) -> dict[str, Any]: "blockedBy": blocked_by, "packageFreshness": freshness, "releaseActionRequired": release_action_required, + "nextStepRecommendation": recommendation, + "nextBlockerReview": recommendation, } @@ -6197,3 +6142,1808 @@ def _complete_workflow_stage_unlocked( "workflowStatus": state["workflowStatus"], "next": _next_stage_name(state), } + + +def _categorize_detection_finding(reason: str) -> str: + low = reason.casefold() + if any( + token in low + for token in ( + "nrt", + "frequency", + "period", + "lookback", + "window", + "schedule", + "timing", + ) + ): + return "timing" + if any( + token in low + for token in ( + "entity", + "host.", + "account.", + "ip.", + "mailbox", + "customdetails", + "custom-detail", + "asset", + ) + ): + return "entityMappings" + if "truncat" in low or "description" in low: + return "truncatedDescriptions" + if any( + token in low + for token in ( + "dependency", + "workload", + "connector", + "datatype", + "data type", + "unmapped", + ) + ): + return "dependencyGaps" + return "otherConversionFindings" + + +def _build_next_step_recommendation( + solution: str | Path, + state: dict[str, Any], + *, + next_stage: str | None = None, + freshness: dict[str, Any] | None = None, + runtime_assessment: dict[str, Any] | None = None, + workbook_summary: dict[str, Any] | None = None, +) -> dict[str, Any]: + root = Path(solution).expanduser().resolve() + blockers: list[dict[str, str]] = [] + seen_labels: set[str] = set() + + def add_blocker( + *, + blocker_id: str, + label: str, + category: str, + stage: str, + reason: str, + ) -> None: + normalized_label = label.strip() or blocker_id + if normalized_label in seen_labels: + return + seen_labels.add(normalized_label) + blockers.append( + { + "id": blocker_id, + "label": normalized_label, + "kind": category, + "category": category, + "stage": stage, + "reason": reason, + } + ) + + authoring = state.get("authoring") or {} + if isinstance(authoring, dict) and authoring.get("status") in { + "stopped", + "conflicted", + "budget-exhausted", + }: + status_label = str(authoring.get("status")) + add_blocker( + blocker_id=f"authoring-{status_label}", + label=f"authoring-{status_label}", + category="workflow", + stage="conversion", + reason=str( + authoring.get("stopReason") + or f"Authoring workflow is {status_label}" + ), + ) + + stages = state.get("stages") or {} + for stage_name in STAGES: + stage_info = stages.get(stage_name) or {} + if stage_info.get("status") in {"failed", "blocked"}: + add_blocker( + blocker_id=f"stage-{stage_name}", + label=stage_name, + category="stage", + stage=stage_name, + reason=str( + stage_info.get("message") + or f"Stage {stage_name} is {stage_info.get('status')}" + ), + ) + + detections_dir = root / "XDR Detections" + if detections_dir.is_dir(): + try: + detection_files = xdr_detection_files(detections_dir) + except (OSError, ValueError): + detection_files = sorted( + p + for p in detections_dir.rglob("*") + if p.is_file() and p.suffix.lower() in {".yaml", ".yml"} + ) + for target_path in detection_files: + try: + doc = yaml.safe_load(target_path.read_text(encoding="utf-8-sig")) or {} + except (OSError, yaml.YAMLError): + continue + if not isinstance(doc, dict): + continue + provenance = doc.get("contentProvenance") or {} + if not isinstance(provenance, dict): + continue + source_meta = provenance.get("source") or {} + conversion = provenance.get("conversion") or {} + rule_id = str(source_meta.get("id") or target_path.stem) + alert_title = str( + ( + (((doc.get("properties") or {}).get("detectionAction") or {}).get( + "alertTemplate" + ) + or {}) + ).get("title") + or target_path.stem + ) + errors = list(conversion.get("errors") or []) + review_reasons = list(conversion.get("reviewReasons") or []) + dep_review = bool(provenance.get("dependencyReviewRequired")) + if errors: + add_blocker( + blocker_id=rule_id, + label=alert_title, + category="technicalFailure", + stage="conversion", + reason=str(errors[0]), + ) + elif review_reasons or conversion.get("reviewRequired"): + add_blocker( + blocker_id=rule_id, + label=alert_title, + category="detectionReview", + stage="conversion", + reason=str( + review_reasons[0] + if review_reasons + else "Detection requires author review" + ), + ) + elif dep_review: + add_blocker( + blocker_id=f"{rule_id}:dependencies", + label=f"{alert_title} dependencies", + category="dependencyReview", + stage="conversion", + reason="Detection workload/connector dependencies require review", + ) + + if isinstance(workbook_summary, dict): + for issue in workbook_summary.get("issues") or []: + add_blocker( + blocker_id="workbook-inventory", + label="workbook-inventory", + category="workbookReview", + stage="conversion", + reason=str(issue), + ) + for source in workbook_summary.get("sources") or []: + if not isinstance(source, dict) or not source.get("selected"): + continue + wb_path = str(source.get("path") or "workbook") + wb_status = str(source.get("status") or "not-converted") + wb_issues = list(source.get("issues") or []) + if wb_status != "converted" or wb_issues: + add_blocker( + blocker_id=wb_path, + label=Path(wb_path).stem or wb_path, + category="workbookReview", + stage="conversion", + reason=str( + wb_issues[0] + if wb_issues + else f"Workbook status is {wb_status}" + ), + ) + + if ( + isinstance(runtime_assessment, dict) + and runtime_assessment.get("runtimeStatus") == "blocked" + and (stages.get("validation") or {}).get("status") != "passed" + ): + for index, issue in enumerate((runtime_assessment.get("issues") or [])[:2], start=1): + add_blocker( + blocker_id=f"runtime-validation-{index}", + label="runtime-validation" if index == 1 else f"runtime-validation-{index}", + category="runtimeValidation", + stage="validation", + reason=str(issue), + ) + + if isinstance(freshness, dict) and freshness.get("status") not in { + "current", + "notPackaged", + }: + add_blocker( + blocker_id="package-freshness", + label=f"package-freshness ({freshness.get('status')})", + category="packaging", + stage="packaging", + reason=str( + freshness.get("reason") + or f"Package freshness status is {freshness.get('status')}" + ), + ) + + top_blockers = blockers[:3] + labels = [item["label"] for item in top_blockers] + if len(labels) == 1: + summary_text = f"Next migration step: review blocker {labels[0]}." + elif len(labels) >= 2: + summary_text = f"Next migration step: review blockers {labels[0]} and {labels[1]}." + elif next_stage: + summary_text = f"Next migration step: run {next_stage} stage." + else: + summary_text = "Next migration step: workflow complete; no remaining blockers." + + required_stages = [ + name + for name in STAGES + if (stages.get(name) or {}).get("status") != "notRequired" + ] + passed_stages = [ + name + for name in required_stages + if (stages.get(name) or {}).get("status") == "passed" + ] + running_stages = [ + name + for name in required_stages + if (stages.get(name) or {}).get("status") == "running" + ] + current_phase = ( + running_stages[0] + if running_stages + else (next_stage or ("completed" if state.get("workflowStatus") == "completed" else "blocked")) + ) + recommended_command = ( + "review-packet" + if any( + item["category"] + in {"detectionReview", "dependencyReview", "workbookReview", "technicalFailure"} + for item in top_blockers + ) + else f"workflow-start-stage --stage {next_stage}" + if next_stage + else "workflow-status" + ) + return { + "nextStage": next_stage, + "status": ( + "blocked" + if blockers + else "ready" + if next_stage + else "complete" + ), + "blockerCount": len(blockers), + "topBlockers": top_blockers, + "blockerLabels": labels, + "summary": summary_text, + "message": summary_text, + "recommendedCommand": recommended_command, + "progress": { + "completedStages": len(passed_stages), + "totalRequiredStages": len(required_stages), + "passedStages": passed_stages, + "currentPhase": current_phase, + "phaseSummary": ( + f"{len(passed_stages)}/{len(required_stages)} required stages passed; " + f"current phase: {current_phase}" + ), + }, + } + + +def build_consolidated_review_packet( + solution: str | Path, + *, + results_path: str | Path | None = None, + detection_results_path: str | Path | None = None, + workbook_results_path: str | Path | None = None, + workspace_id: str | None = None, + selected_rules: list[str] | None = None, + rule_selectors: list[str] | None = None, +) -> dict[str, Any]: + """Build a deterministic, hash-bound one-shot review packet for detections and workbooks.""" + from .converter import load_config, validate_document + from .dependencies import dependency_plan, plan_solution_dependencies + from .workbook_dependencies import plan_workbook_dependencies + from .workbooks import inventory_workbooks + + if results_path is None and detection_results_path is not None: + results_path = detection_results_path + if selected_rules is None and rule_selectors is not None: + selected_rules = rule_selectors + + root = Path(solution).expanduser().resolve() + state_file = existing_artifact_path(root, STATE_FILE_NAME) + active_run_id: str | None = None + if state_file.is_file(): + try: + _, loaded_state = _load(root) + active_run_id = loaded_state.get("runId") + except (OSError, ValueError): + active_run_id = None + + config = load_config(root / "XDR Detections") + rule_overrides = config.get("ruleOverrides") if isinstance(config.get("ruleOverrides"), dict) else {} + table_mappings = config.get("tableMappings") if isinstance(config.get("tableMappings"), dict) else {} + + category_names = ( + "timing", + "entityMappings", + "truncatedDescriptions", + "dependencyGaps", + "workbookQueryGaps", + "technicalFailures", + "otherConversionFindings", + ) + categories: dict[str, list[dict[str, Any]]] = {name: [] for name in category_names} + items: list[dict[str, Any]] = [] + preserved_decisions: list[dict[str, Any]] = [] + detection_records: list[dict[str, Any]] = [] + workbook_records: list[dict[str, Any]] = [] + inventory_entries: list[dict[str, str]] = [] + + selected_filter = { + str(value).strip() + for value in (selected_rules or []) + if str(value).strip() + } + + def add_item( + *, + domain: str, + category: str, + severity: str, + target_id: str, + target_name: str, + source_path: str, + output_path: str, + query_pointer: str, + hashes: dict[str, str], + finding: str, + current_decision: str = "pending", + prior_decision: dict[str, Any] | None = None, + ) -> dict[str, Any]: + resolvable_by_batch = category != "technicalFailures" + decision_options = ( + ["change", "defer"] + if category == "technicalFailures" + else ["accept", "change", "defer"] + ) + canonical_id_payload = json.dumps( + { + "domain": domain, + "category": category, + "targetId": target_id, + "outputPath": output_path, + "queryPointer": query_pointer, + "finding": finding, + "hashes": hashes, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + item_id = hashlib.sha256(canonical_id_payload).hexdigest() + record: dict[str, Any] = { + "itemId": item_id, + "domain": domain, + "category": category, + "severity": severity, + "targetId": target_id, + "targetName": target_name, + "sourcePath": source_path, + "outputPath": output_path, + "queryPointer": query_pointer, + "hashes": hashes, + "finding": finding, + "currentDecision": current_decision, + "decisionState": ( + "decided" + if current_decision in {"accept", "change"} + else current_decision + ), + "decisionOptions": decision_options, + "resolvableByBatchAccept": resolvable_by_batch, + } + if prior_decision is not None: + record["priorDecision"] = prior_decision + categories[category].append(record) + items.append(record) + return record + + live_dep_by_detection: dict[str, dict[str, Any]] = {} + if results_path is not None: + dep_plan_result = plan_solution_dependencies( + root, results_path=results_path, workspace_id=workspace_id + ) + for row in dep_plan_result.get("results") or []: + if isinstance(row, dict) and isinstance(row.get("detection"), str): + live_dep_by_detection[row["detection"]] = row + + detections_dir = root / "XDR Detections" + detection_files: list[Path] = [] + if detections_dir.is_dir(): + try: + detection_files = xdr_detection_files(detections_dir) + except (OSError, ValueError): + detection_files = sorted( + p + for p in detections_dir.rglob("*") + if p.is_file() and p.suffix.lower() in {".yaml", ".yml"} + ) + + converted_source_paths: set[str] = set() + for target_path in detection_files: + output_rel = target_path.relative_to(root).as_posix() + raw_bytes = target_path.read_bytes() + detection_sha = hashlib.sha256(raw_bytes).hexdigest() + doc = yaml.safe_load(raw_bytes.decode("utf-8-sig")) or {} + if not isinstance(doc, dict): + continue + provenance = doc.get("contentProvenance") or {} + source_meta = provenance.get("source") or {} + conversion = provenance.get("conversion") or {} + rule_id = str(source_meta.get("id") or target_path.stem) + source_rel = str(source_meta.get("path") or "") + if source_rel: + converted_source_paths.add(source_rel) + if selected_filter and not ( + {rule_id, output_rel, source_rel, target_path.stem, target_path.name} + & selected_filter + ): + continue + + inventory_entries.append( + { + "kind": "detection", + "id": rule_id, + "path": output_rel, + "sha256": detection_sha, + } + ) + alert_template = ( + ((doc.get("properties") or {}).get("detectionAction") or {}).get( + "alertTemplate" + ) + or {} + ) + rule_name = str( + alert_template.get("title") + or (doc.get("properties") or {}).get("displayName") + or target_path.stem + ) + query_text = str( + ((doc.get("properties") or {}).get("queryCondition") or {}).get("queryText") + or "" + ) + review_scope = conversion.get("reviewScope") or {} + source_doc_sha = str( + review_scope.get("sourceDocumentSha256") + or source_meta.get("querySha256") + or "" + ) + target_query_sha = str( + review_scope.get("targetQuerySha256") + or hashlib.sha256(query_text.encode("utf-8")).hexdigest() + ) + item_hashes = { + "sourceDocumentSha256": source_doc_sha, + "targetQuerySha256": target_query_sha, + "detectionSha256": detection_sha, + } + + rule_override = ( + rule_overrides.get(rule_id) + if isinstance(rule_overrides.get(rule_id), dict) + else {} + ) + decision_lookup: dict[str, dict[str, Any]] = {} + for source_list in ( + rule_override.get("reviewDecisions") or [], + conversion.get("reviewDecisions") or [], + ): + if not isinstance(source_list, list): + continue + for dec in source_list: + if not isinstance(dec, dict) or not isinstance(dec.get("reason"), str): + continue + reason_key = dec["reason"] + hashes_match = ( + dec.get("sourceDocumentSha256") == source_doc_sha + and dec.get("targetQuerySha256") == target_query_sha + ) + normalized_choice = ( + "accept" + if dec.get("decision") in {"accepted", "accept"} + else "change" + if dec.get("decision") in {"rejected", "change"} + else "defer" + if dec.get("decision") in {"deferred", "defer"} + else str(dec.get("decision") or "pending") + ) + status_value = normalized_choice if hashes_match else "stale" + record_dec = { + "domain": "detection", + "targetId": rule_id, + "reason": reason_key, + "decision": normalized_choice, + "status": "current" if hashes_match else "stale", + "sourceDocumentSha256": str(dec.get("sourceDocumentSha256") or ""), + "targetQuerySha256": str(dec.get("targetQuerySha256") or ""), + } + decision_lookup[reason_key] = { + "currentDecision": status_value, + "priorDecision": record_dec, + } + if record_dec not in preserved_decisions: + preserved_decisions.append(record_dec) + + for accepted_reason in conversion.get("acceptedReviewReasons") or []: + if isinstance(accepted_reason, str) and accepted_reason not in decision_lookup: + rec = { + "domain": "detection", + "targetId": rule_id, + "reason": accepted_reason, + "decision": "accept", + "status": "current", + "sourceDocumentSha256": source_doc_sha, + "targetQuerySha256": target_query_sha, + } + decision_lookup[accepted_reason] = { + "currentDecision": "accept", + "priorDecision": rec, + } + if rec not in preserved_decisions: + preserved_decisions.append(rec) + + has_entity_override = isinstance(rule_override.get("entityMappings"), dict) + if has_entity_override: + ent_rec = { + "domain": "detection", + "targetId": rule_id, + "reason": "ruleOverrides.entityMappings", + "decision": "change", + "status": "current", + "sourceDocumentSha256": source_doc_sha, + "targetQuerySha256": target_query_sha, + } + if ent_rec not in preserved_decisions: + preserved_decisions.append(ent_rec) + + rule_items: list[dict[str, Any]] = [] + seen_rule_findings: set[tuple[str, str]] = set() + + conv_errors = list(conversion.get("errors") or []) + try: + structural_errors = validate_document(doc) + except (OSError, ValueError): + structural_errors = [] + for err in conv_errors + [e for e in structural_errors if e not in conv_errors]: + err_text = str(err) + if ("technicalFailures", err_text) in seen_rule_findings: + continue + seen_rule_findings.add(("technicalFailures", err_text)) + rule_items.append( + add_item( + domain="detection", + category="technicalFailures", + severity="error", + target_id=rule_id, + target_name=rule_name, + source_path=source_rel, + output_path=output_rel, + query_pointer="properties.queryCondition.queryText", + hashes=item_hashes, + finding=err_text, + current_decision="pending", + ) + ) + + source_doc: dict[str, Any] = {} + if source_rel: + try: + source_file = content_path(root, source_rel) + if source_file.is_file(): + loaded_src = yaml.safe_load(source_file.read_text(encoding="utf-8-sig")) + if isinstance(loaded_src, dict): + source_doc = loaded_src + except (OSError, ValueError, yaml.YAMLError): + source_doc = {} + + source_desc = str(source_doc.get("description") or "").strip() + override_desc = ( + rule_override.get("description").strip() + if isinstance(rule_override.get("description"), str) + and rule_override.get("description").strip() + else None + ) + if len(source_desc) > 600: + trunc_msg = ( + f"Source description ({len(source_desc)} chars) was truncated to " + "600 chars in alertTemplate.description." + ) + dec_info = decision_lookup.get(trunc_msg, {}) + if not dec_info and override_desc: + desc_rec = { + "domain": "detection", + "targetId": rule_id, + "reason": "ruleOverrides.description", + "decision": "change", + "status": "current", + "description": override_desc, + "sourceDocumentSha256": source_doc_sha, + "targetQuerySha256": target_query_sha, + } + if desc_rec not in preserved_decisions: + preserved_decisions.append(desc_rec) + dec_info = { + "currentDecision": "change", + "priorDecision": desc_rec, + } + seen_rule_findings.add(("truncatedDescriptions", trunc_msg)) + rule_items.append( + add_item( + domain="detection", + category="truncatedDescriptions", + severity="review", + target_id=rule_id, + target_name=rule_name, + source_path=source_rel, + output_path=output_rel, + query_pointer="properties.detectionAction.alertTemplate.description", + hashes=item_hashes, + finding=trunc_msg, + current_decision=dec_info.get("currentDecision", "pending"), + prior_decision=dec_info.get("priorDecision"), + ) + ) + source_title = str(source_doc.get("name") or "").strip() + if len(source_title) > 120: + trunc_title_msg = ( + f"Source name ({len(source_title)} chars) was truncated to " + "120 chars in alertTemplate.title." + ) + dec_info = decision_lookup.get(trunc_title_msg, {}) + seen_rule_findings.add(("truncatedDescriptions", trunc_title_msg)) + rule_items.append( + add_item( + domain="detection", + category="truncatedDescriptions", + severity="review", + target_id=rule_id, + target_name=rule_name, + source_path=source_rel, + output_path=output_rel, + query_pointer="properties.detectionAction.alertTemplate.title", + hashes=item_hashes, + finding=trunc_title_msg, + current_decision=dec_info.get("currentDecision", "pending"), + prior_decision=dec_info.get("priorDecision"), + ) + ) + + all_reasons = list(conversion.get("reviewReasons") or []) + for accepted_reason in conversion.get("acceptedReviewReasons") or []: + if isinstance(accepted_reason, str) and accepted_reason not in all_reasons: + all_reasons.append(accepted_reason) + for warning_msg in conversion.get("warnings") or []: + if isinstance(warning_msg, str) and warning_msg not in all_reasons: + cat = _categorize_detection_finding(warning_msg) + if cat in {"timing", "entityMappings", "truncatedDescriptions", "dependencyGaps"}: + all_reasons.append(warning_msg) + + for reason_msg in all_reasons: + reason_text = str(reason_msg) + cat = _categorize_detection_finding(reason_text) + if (cat, reason_text) in seen_rule_findings: + continue + seen_rule_findings.add((cat, reason_text)) + dec_info = decision_lookup.get(reason_text, {}) + default_dec = ( + "change" + if cat == "entityMappings" and has_entity_override and not dec_info + else dec_info.get("currentDecision", "pending") + ) + pointer = ( + "properties.detectionAction.alertTemplate.entityMappings" + if cat == "entityMappings" + else "properties.queryCondition" + if cat == "timing" + else "properties.queryCondition.queryText" + ) + rule_items.append( + add_item( + domain="detection", + category=cat, + severity="review", + target_id=rule_id, + target_name=rule_name, + source_path=source_rel, + output_path=output_rel, + query_pointer=pointer, + hashes=item_hashes, + finding=reason_text, + current_decision=default_dec, + prior_decision=dec_info.get("priorDecision"), + ) + ) + + rel_from_xdr = ( + target_path.relative_to(detections_dir).as_posix() + if target_path.is_relative_to(detections_dir) + else target_path.name + ) + dep_info = live_dep_by_detection.get(rel_from_xdr) + if dep_info is None: + source_query_text = str(source_doc.get("query") or query_text) + dep_info = dependency_plan( + source_query_text, + target_query=query_text, + connectors=source_doc.get("requiredDataConnectors") + or doc.get("requiredDataConnectors"), + table_mappings=table_mappings, + ) + dep_reasons = list(dep_info.get("reviewReasons") or []) + for unmapped_dt in dep_info.get("unmappedDataTypes") or []: + msg = f"Unmapped data type requires workload mapping: {unmapped_dt}" + if msg not in dep_reasons: + dep_reasons.append(msg) + if provenance.get("dependencyReviewRequired") and not dep_reasons: + dep_reasons.append("Detection workload/connector dependencies require author review.") + for dep_reason in dep_reasons: + dep_text = str(dep_reason) + if ("dependencyGaps", dep_text) in seen_rule_findings: + continue + seen_rule_findings.add(("dependencyGaps", dep_text)) + dec_info = decision_lookup.get(dep_text, {}) + rule_items.append( + add_item( + domain="detection", + category="dependencyGaps", + severity="review", + target_id=rule_id, + target_name=rule_name, + source_path=source_rel, + output_path=output_rel, + query_pointer="contentProvenance.requiredWorkloads", + hashes=item_hashes, + finding=dep_text, + current_decision=dec_info.get("currentDecision", "pending"), + prior_decision=dec_info.get("priorDecision"), + ) + ) + + detection_records.append( + { + "ruleId": rule_id, + "ruleName": rule_name, + "sourcePath": source_rel, + "outputFile": output_rel, + "hashes": item_hashes, + "status": str(conversion.get("status") or "converted"), + "reviewRequired": bool(conversion.get("reviewRequired")), + "dependencyReviewRequired": bool( + provenance.get("dependencyReviewRequired") + ), + "findingCount": len(rule_items), + "technicalFailureCount": sum( + 1 for item in rule_items if item["category"] == "technicalFailures" + ), + "itemIds": [item["itemId"] for item in rule_items], + } + ) + + prior_wb_query_decisions: dict[tuple[str, str], dict[str, Any]] = {} + prior_wb_dep_decisions: dict[str, dict[str, Any]] = {} + try: + reports_dir = report_directory(root) + receipt_candidates = [] + if reports_dir.is_dir(): + direct_receipt = reports_dir / "workbook-review-receipt.json" + if direct_receipt.is_file(): + receipt_candidates.append(direct_receipt) + for child in sorted(reports_dir.iterdir()): + if child.is_dir(): + nested = child / "workbook-review-receipt.json" + if nested.is_file(): + receipt_candidates.append(nested) + for receipt_path in receipt_candidates: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + decisions_obj = (receipt.get("decisions") or {}) if isinstance(receipt, dict) else {} + for q_dec in decisions_obj.get("queries") or []: + if isinstance(q_dec, dict) and isinstance(q_dec.get("workbook"), str) and isinstance(q_dec.get("path"), str): + prior_wb_query_decisions[(q_dec["workbook"], q_dec["path"])] = q_dec + for w_dec in decisions_obj.get("workbooks") or []: + if isinstance(w_dec, dict) and isinstance(w_dec.get("path"), str): + prior_wb_dep_decisions[w_dec["path"]] = w_dec + except (OSError, ValueError, json.JSONDecodeError): + pass + + wb_plan: dict[str, Any] | None = None + try: + wb_inventory = inventory_workbooks(root) + except (OSError, ValueError): + wb_inventory = [] + if wb_inventory or workbook_results_path is not None: + try: + wb_plan = plan_workbook_dependencies( + root, + results_path=workbook_results_path, + workspace_id=workspace_id, + ) + except (OSError, ValueError): + wb_plan = None + + if wb_plan is not None: + wb_by_path: dict[str, list[dict[str, Any]]] = {} + for row in wb_plan.get("inventory") or []: + if isinstance(row, dict) and isinstance(row.get("path"), str): + inventory_entries.append( + { + "kind": "workbook", + "id": row["path"], + "path": row["path"], + "sha256": str(row.get("sha256") or ""), + } + ) + wb_by_path[row["path"]] = [] + + for query_row in wb_plan.get("queries") or []: + if not isinstance(query_row, dict): + continue + wb_path = str(query_row.get("workbook") or "") + q_path = str(query_row.get("path") or "") + wb_sha = str(query_row.get("workbookSha256") or "") + q_sha = str(query_row.get("querySha256") or "") + wb_hashes = { + "workbookSha256": wb_sha, + "querySha256": q_sha, + } + prior_q = prior_wb_query_decisions.get((wb_path, q_path)) + prior_rec: dict[str, Any] | None = None + resolved_dec = "pending" + if prior_q is not None: + hash_matches = prior_q.get("querySha256") == q_sha + raw_choice = str(prior_q.get("decision") or "pending") + resolved_dec = raw_choice if hash_matches else "stale" + prior_rec = { + "domain": "workbook", + "targetId": wb_path, + "queryPointer": q_path, + "decision": raw_choice, + "status": "current" if hash_matches else "stale", + "querySha256": str(prior_q.get("querySha256") or ""), + } + if prior_rec not in preserved_decisions: + preserved_decisions.append(prior_rec) + + for blocker in query_row.get("technicalBlockers") or []: + item = add_item( + domain="workbook", + category="technicalFailures", + severity="error", + target_id=wb_path, + target_name=Path(wb_path).stem or wb_path, + source_path=wb_path, + output_path=wb_path, + query_pointer=q_path, + hashes=wb_hashes, + finding=str(blocker), + current_decision="pending", + ) + wb_by_path.setdefault(wb_path, []).append(item) + + gap_messages = list(query_row.get("gapExplanations") or []) + list( + query_row.get("externalSourceWarnings") or [] + ) + if (query_row.get("proposal") or {}).get("reviewRequired") and not gap_messages: + gap_messages.append("Workbook query dependencies require author review.") + for gap in gap_messages: + item = add_item( + domain="workbook", + category="workbookQueryGaps", + severity="review", + target_id=wb_path, + target_name=Path(wb_path).stem or wb_path, + source_path=wb_path, + output_path=wb_path, + query_pointer=q_path, + hashes=wb_hashes, + finding=str(gap), + current_decision=resolved_dec, + prior_decision=prior_rec, + ) + wb_by_path.setdefault(wb_path, []).append(item) + + for row in wb_plan.get("inventory") or []: + if not isinstance(row, dict) or not isinstance(row.get("path"), str): + continue + wb_path = row["path"] + wb_items = wb_by_path.get(wb_path, []) + prior_w = prior_wb_dep_decisions.get(wb_path) + if prior_w is not None: + w_rec = { + "domain": "workbook", + "targetId": wb_path, + "reason": "workbookDependencies", + "decision": str(prior_w.get("decision") or "pending"), + "status": "current", + "workbookSha256": str(row.get("sha256") or ""), + } + if w_rec not in preserved_decisions: + preserved_decisions.append(w_rec) + workbook_records.append( + { + "workbook": wb_path, + "workbookSha256": str(row.get("sha256") or ""), + "findingCount": len(wb_items), + "technicalFailureCount": sum( + 1 for i in wb_items if i["category"] == "technicalFailures" + ), + "itemIds": [i["itemId"] for i in wb_items], + } + ) + + grouped_map: dict[tuple[str, str, str], list[dict[str, Any]]] = {} + for item in items: + key = (item["domain"], item["category"], item["finding"]) + grouped_map.setdefault(key, []).append(item) + + grouped_findings: list[dict[str, Any]] = [] + for (domain, category, finding), members in sorted( + grouped_map.items(), key=lambda entry: entry[0] + ): + group_id = hashlib.sha256( + f"{domain}:{category}:{finding}".encode("utf-8") + ).hexdigest() + resolvable = category != "technicalFailures" + grouped_findings.append( + { + "groupId": group_id, + "domain": domain, + "category": category, + "severity": "error" if category == "technicalFailures" else "review", + "finding": finding, + "count": len(members), + "allowedDecisions": ( + ["change", "defer"] + if category == "technicalFailures" + else ["accept", "change", "defer"] + ), + "perItemExceptionsSupported": True, + "resolvableByBatchAccept": resolvable, + "affectedItems": [ + { + "itemId": m["itemId"], + "targetId": m["targetId"], + "targetName": m["targetName"], + "sourcePath": m["sourcePath"], + "outputPath": m["outputPath"], + "queryPointer": m["queryPointer"], + "hashes": m["hashes"], + "currentDecision": m["currentDecision"], + } + for m in members + ], + } + ) + + inventory_sha = hashlib.sha256( + json.dumps( + sorted(inventory_entries, key=lambda x: (x["kind"], x["path"], x["id"])), + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + technical_failures = list(categories["technicalFailures"]) + deferred_items = [item for item in items if item["currentDecision"] == "defer"] + pending_items = [ + item + for item in items + if item["currentDecision"] in {"pending", "stale", "defer"} + or item["category"] == "technicalFailures" + ] + + top_blocker_labels: list[str] = [] + for item in technical_failures + pending_items: + label = f"{item['targetName']} ({item['category']})" + if label not in top_blocker_labels: + top_blocker_labels.append(label) + if len(top_blocker_labels) >= 3: + break + if len(top_blocker_labels) == 1: + rec_text = f"Next migration step: review blocker {top_blocker_labels[0]}." + elif len(top_blocker_labels) >= 2: + rec_text = ( + f"Next migration step: review blockers {top_blocker_labels[0]} " + f"and {top_blocker_labels[1]}." + ) + else: + rec_text = "Next migration step: all review items resolved; ready for validation." + + summary_counts = { + "totalItems": len(items), + "pendingReviewCount": len(pending_items), + "preservedDecisionCount": len(preserved_decisions), + "technicalFailureCount": len(technical_failures), + "deferredItemCount": len(deferred_items), + "groupCount": len(grouped_findings), + "timingCount": len(categories["timing"]), + "entityMappingCount": len(categories["entityMappings"]), + "truncatedDescriptionCount": len(categories["truncatedDescriptions"]), + "dependencyGapCount": len(categories["dependencyGaps"]), + "workbookQueryGapCount": len(categories["workbookQueryGaps"]), + "otherConversionFindingCount": len(categories["otherConversionFindings"]), + "readyForValidation": len(pending_items) == 0 and len(technical_failures) == 0, + } + + packet: dict[str, Any] = { + "schemaVersion": "1.0.0", + "kind": "consolidated-review-packet", + "solution": str(root), + "solutionName": root.name, + "runId": active_run_id, + "conversionScope": conversion_scope(root), + "inventorySha256": inventory_sha, + "allowedDecisions": ["accept", "change", "defer"], + "perItemExceptionsSupported": True, + "authorityLimits": { + "establishesRuntimeCorrectness": False, + "establishesAlertParity": False, + "authorizesPackaging": False, + "authorizesEnvironmentWrites": False, + }, + "summary": summary_counts, + "nextStepRecommendation": { + "status": "blocked" if pending_items or technical_failures else "ready", + "blockerCount": len(pending_items), + "blockerLabels": top_blocker_labels, + "summary": rec_text, + "message": rec_text, + }, + "categories": categories, + "items": items, + "groupedFindings": grouped_findings, + "groups": grouped_findings, + "detections": detection_records, + "workbooks": workbook_records, + "preservedDecisions": preserved_decisions, + "technicalFailures": technical_failures, + "deferredItems": deferred_items, + } + packet_sha = hashlib.sha256( + json.dumps(packet, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode( + "utf-8" + ) + ).hexdigest() + return {**packet, "packetSha256": packet_sha} + + +plan_consolidated_review = build_consolidated_review_packet + +STATUS_REMINDER_CURSOR_FILE = "status-reminder-cursor.json" +EXECUTION_OWNERSHIP_FILE = "execution-ownership.json" +FORWARDED_APPROVALS_FILE = "forwarded-approvals.json" +LARGE_DETECTION_BATCH_THRESHOLD = 15 +LARGE_WORKBOOK_QUERY_BATCH_THRESHOLD = 50 + + +def _build_workflow_diagram(state: dict[str, Any], next_stage: str | None) -> str: + stages = state.get("stages") or {} + packaging_stage = stages.get("packaging") or {} + packaging_artifacts = packaging_stage.get("artifacts") or {} + candidate_generated = ( + packaging_artifacts.get("packageLifecycleStatus") == "candidate-generated" + ) + running = [ + name for name in STAGES if (stages.get(name) or {}).get("status") == "running" + ] + focus = running[0] if running else next_stage + + diagram_nodes = [ + ("discovery", "Discovery"), + ("conversion", "Conversion"), + ("validation", "Validation"), + ("candidatePackaging", "Candidate Packaging"), + ("armValidation", "ARM Validation"), + ("acceptedPackaging", "Accepted Packaging"), + ("report", "Report"), + ] + active_key: str | None = None + if focus in {"discovery", "conversion", "validation", "report"}: + active_key = focus + elif focus in {"packaging", "workflow-arm-candidate", "deployment", "mockIngestion", "alertParity"}: + if candidate_generated or focus in {"workflow-arm-candidate", "deployment"}: + active_key = "armValidation" + elif (packaging_stage.get("status") or "") == "passed": + active_key = "acceptedPackaging" + else: + active_key = "candidatePackaging" + elif state.get("workflowStatus") == "completed": + active_key = "report" + elif candidate_generated: + active_key = "armValidation" + + rendered: list[str] = [] + for key, label in diagram_nodes: + if key == active_key: + rendered.append(f"[> {label} <]") + else: + rendered.append(f"[{label}]") + return " -> ".join(rendered) + + +def _describe_current_activity( + state: dict[str, Any], + next_stage: str | None, + recommendation: dict[str, Any], +) -> str: + stages = state.get("stages") or {} + running = [ + name for name in STAGES if (stages.get(name) or {}).get("status") == "running" + ] + active_stage = running[0] if running else next_stage + top_blockers = recommendation.get("topBlockers") or [] + blocker_categories = {item.get("category") for item in top_blockers if isinstance(item, dict)} + + if blocker_categories & {"detectionReview", "dependencyReview", "workbookReview", "technicalFailure"}: + return "Applying reviewed decisions or converting detections before validation." + if active_stage == "discovery": + return "Inspecting solution source inventory and validating content structure before conversion." + if active_stage == "conversion": + return "Applying reviewed decisions or converting detections before validation." + if active_stage == "validation": + return "Validating converted XDR detections and workbooks before candidate packaging." + if active_stage in {"packaging", "workflow-arm-candidate"}: + packaging_artifacts = ((stages.get("packaging") or {}).get("artifacts")) or {} + if packaging_artifacts.get("packageLifecycleStatus") == "candidate-generated": + return "Validating candidate V3.1 package and ARM template readiness before accepted packaging." + return "Building and verifying the V3.1 solution package." + if active_stage == "deployment": + return "Deploying disabled detections and approved solution resources to the locked lab target." + if active_stage == "mockIngestion": + return "Ingesting reviewed mock telemetry into the locked lab workspace." + if active_stage == "alertParity": + return "Verifying alert parity between Sentinel Analytic Rules and Defender XDR Custom Detections." + if active_stage == "report": + return "Generating the consolidated solution migration report." + if state.get("workflowStatus") == "completed": + return "Migration workflow completed; all required stages passed." + return "Reviewing current workflow gates and blocker evidence before the next stage." + + +def _build_duration_explanation( + analytic_rule_count: int, + xdr_detection_count: int, + workbook_count: int, + workbook_query_count: int, +) -> tuple[str, bool]: + large_rules = analytic_rule_count >= LARGE_DETECTION_BATCH_THRESHOLD + large_queries = workbook_query_count >= LARGE_WORKBOOK_QUERY_BATCH_THRESHOLD + is_large = large_rules or large_queries + if large_rules and large_queries: + return ( + f"Large migration batch in progress: processing {analytic_rule_count} source analytic rules " + f"({xdr_detection_count} converted XDR detections) and {workbook_query_count} workbook queries " + f"across {workbook_count} workbooks; no completion time is estimated.", + True, + ) + if large_rules: + return ( + f"Large detection batch in progress: processing {analytic_rule_count} source analytic rules " + f"({xdr_detection_count} converted XDR detections); no completion time is estimated.", + True, + ) + if large_queries: + return ( + f"Large workbook query batch in progress: processing {workbook_query_count} workbook queries " + f"across {workbook_count} workbooks; no completion time is estimated.", + True, + ) + return ( + f"Processing {analytic_rule_count} source analytic rules ({xdr_detection_count} converted XDR " + f"detections) and {workbook_query_count} workbook queries across {workbook_count} workbooks; " + "no completion time is estimated.", + False, + ) + + +def build_migration_status_reminder( + solution_path: str | Path, + *, + run_id: str | None = None, + persist_cursor: bool = True, +) -> dict[str, Any]: + """Build a deterministic, evidence-bound status reminder with no-change suppression.""" + from .workbooks import inventory_workbooks + + root = Path(solution_path).expanduser().resolve() + if not root.is_dir(): + raise ValueError(f"solution folder does not exist: {root}") + + from contextlib import nullcontext + + run_ctx = using_run(root, run_id) if run_id else nullcontext() + with run_ctx: + state_path, state = _load(root) + status = workflow_status(root) + actual_run_id = str(status.get("runId") or state_path.parent.name) + + has_rule_folder = any( + (root / name).exists() or (root / name).is_symlink() + for name in ("Analytic Rules", "Analytics Rules") + ) + try: + analytic_rule_count = len(analytic_rule_files(root)) if has_rule_folder else 0 + except (OSError, ValueError): + analytic_rule_count = 0 + + detections_dir = root / "XDR Detections" + try: + xdr_detection_count = ( + len(xdr_detection_files(detections_dir)) + if detections_dir.is_dir() + else 0 + ) + except (OSError, ValueError): + xdr_detection_count = 0 + + try: + wb_inventory = inventory_workbooks(root) + except (OSError, ValueError): + wb_inventory = [] + workbook_count = len(wb_inventory) + workbook_query_count = sum( + len(wb.get("queries") or []) + for wb in wb_inventory + if isinstance(wb, dict) + ) + + recommendation = status["nextStepRecommendation"] + blocker_count = int(recommendation.get("blockerCount") or 0) + blocker_labels = list(recommendation.get("blockerLabels") or []) + next_stage = status.get("next") + + stage_statuses = { + stage_name: { + "status": (status["stages"].get(stage_name) or {}).get("status"), + "attempts": (status["stages"].get(stage_name) or {}).get("attempts", 0), + "message": (status["stages"].get(stage_name) or {}).get("message"), + } + for stage_name in STAGES + } + fingerprint_payload = { + "runId": actual_run_id, + "workflowStatus": status.get("workflowStatus"), + "nextStage": next_stage, + "stages": stage_statuses, + "itemCounts": { + "analyticRules": analytic_rule_count, + "xdrDetections": xdr_detection_count, + "workbooks": workbook_count, + "workbookQueries": workbook_query_count, + }, + "blockerCount": blocker_count, + "blockerLabels": blocker_labels, + "packageFreshnessStatus": (status.get("packageFreshness") or {}).get("status"), + } + state_fingerprint = hashlib.sha256( + json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + cursor_path = state_path.parent / STATUS_REMINDER_CURSOR_FILE + previous_fingerprint: str | None = None + if cursor_path.is_file(): + try: + cursor_doc = json.loads(cursor_path.read_text(encoding="utf-8-sig")) + if isinstance(cursor_doc, dict): + previous_fingerprint = str( + cursor_doc.get("stateFingerprint") + or cursor_doc.get("lastStateFingerprint") + or "" + ) or None + except (OSError, json.JSONDecodeError): + previous_fingerprint = None + + suppressed_no_change = previous_fingerprint == state_fingerprint + now = _utc_now() + if persist_cursor and not suppressed_no_change: + with _run_writer_lock(state_path.parent): + _atomic_json( + cursor_path, + { + "schemaVersion": "1.0.0", + "runId": actual_run_id, + "stateFingerprint": state_fingerprint, + "lastStateFingerprint": state_fingerprint, + "updatedAtUtc": now, + }, + ) + + current_activity = _describe_current_activity(state, next_stage, recommendation) + duration_explanation, is_large_batch = _build_duration_explanation( + analytic_rule_count, + xdr_detection_count, + workbook_count, + workbook_query_count, + ) + workflow_diagram = _build_workflow_diagram(state, next_stage) + watchdog_coordination = { + "readOnlyObserver": True, + "createsExecutor": False, + "singleOwnerRespected": True, + } + + return { + "schemaVersion": "1.0.0", + "kind": "migration-status-reminder", + "solution": str(root), + "solutionName": root.name, + "runId": actual_run_id, + "workflowStatus": status.get("workflowStatus"), + "stateFingerprint": state_fingerprint, + "previousStateFingerprint": previous_fingerprint, + "suppressedNoChange": suppressed_no_change, + "cursorPath": str(cursor_path), + "currentActivity": current_activity, + "expectedNextStep": recommendation["summary"], + "nextStepRecommendation": recommendation, + "durationExplanation": duration_explanation, + "isLargeBatch": is_large_batch, + "itemCounts": { + "analyticRules": analytic_rule_count, + "xdrDetections": xdr_detection_count, + "workbooks": workbook_count, + "workbookQueries": workbook_query_count, + }, + "blockerCount": blocker_count, + "workflowDiagram": workflow_diagram, + "watchdogCoordination": watchdog_coordination, + } + + +def claim_execution_ownership( + solution_path: str | Path, + owner_id: str | None = None, + operation: str | None = None, + *, + scope: dict[str, Any] | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + """Claim or renew single-owner execution lease for (runId, operation) in execution-ownership.json.""" + if not isinstance(owner_id, str) or not owner_id.strip(): + raise ValueError("ownerId must be a non-empty string") + if not isinstance(operation, str) or not operation.strip(): + raise ValueError("operation must be a non-empty string") + if scope is not None and not isinstance(scope, dict): + raise ValueError("scope must be a JSON object when provided") + + owner_clean = owner_id.strip() + operation_clean = operation.strip() + normalized_scope = dict(scope or {}) + root = Path(solution_path).expanduser().resolve() + + from contextlib import nullcontext + + run_ctx = using_run(root, run_id) if run_id else nullcontext() + with run_ctx: + state_path, state = _load(root) + actual_run_id = str(state.get("runId") or state_path.parent.name) + ownership_path = state_path.parent / EXECUTION_OWNERSHIP_FILE + with _run_writer_lock(state_path.parent): + doc: dict[str, Any] = {} + if ownership_path.is_file(): + try: + loaded = json.loads(ownership_path.read_text(encoding="utf-8-sig")) + if isinstance(loaded, dict): + doc = loaded + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid execution ownership file: {ownership_path}: {exc}") from exc + + leases = dict(doc.get("leases") or {}) + history = list(doc.get("history") or []) + existing = leases.get(operation_clean) + if ( + isinstance(existing, dict) + and existing.get("status") == "active" + and existing.get("ownerId") != owner_clean + ): + raise ValueError( + f"duplicate_executor_blocked: operation {operation_clean!r} in run " + f"{actual_run_id!r} is already owned by {existing.get('ownerId')!r}; " + f"conflicting claim by {owner_clean!r} rejected" + ) + + now = _utc_now() + renewed_count = ( + int(existing.get("renewedCount", 0)) + 1 + if isinstance(existing, dict) + and existing.get("status") == "active" + and existing.get("ownerId") == owner_clean + else 0 + ) + claimed_at = ( + str(existing.get("claimedAtUtc") or now) + if renewed_count > 0 and isinstance(existing, dict) + else now + ) + lease_record = { + "runId": actual_run_id, + "ownerId": owner_clean, + "operation": operation_clean, + "scope": normalized_scope, + "status": "active", + "claimedAtUtc": claimed_at, + "updatedAtUtc": now, + "releasedAtUtc": None, + "renewedCount": renewed_count, + "singleOwnerEnforced": True, + } + leases[operation_clean] = lease_record + history.append({ + "event": "renewed" if renewed_count > 0 else "claimed", + "runId": actual_run_id, + "ownerId": owner_clean, + "operation": operation_clean, + "scope": normalized_scope, + "timestampUtc": now, + }) + updated_doc = { + "schemaVersion": "1.0.0", + "runId": actual_run_id, + "solution": str(root), + "ownerId": owner_clean, + "operation": operation_clean, + "scope": normalized_scope, + "claimedAtUtc": claimed_at, + "updatedAtUtc": now, + "status": "active", + "leases": leases, + "history": history, + } + _atomic_json(ownership_path, updated_doc) + return { + **lease_record, + "ownershipPath": str(ownership_path), + } + + +def release_execution_ownership( + solution_path: str | Path, + owner_id: str | None = None, + operation: str | None = None, + *, + run_id: str | None = None, +) -> dict[str, Any]: + """Release an active single-owner execution lease held by owner_id for (runId, operation).""" + if not isinstance(owner_id, str) or not owner_id.strip(): + raise ValueError("ownerId must be a non-empty string") + if not isinstance(operation, str) or not operation.strip(): + raise ValueError("operation must be a non-empty string") + + owner_clean = owner_id.strip() + operation_clean = operation.strip() + root = Path(solution_path).expanduser().resolve() + + from contextlib import nullcontext + + run_ctx = using_run(root, run_id) if run_id else nullcontext() + with run_ctx: + state_path, state = _load(root) + actual_run_id = str(state.get("runId") or state_path.parent.name) + ownership_path = state_path.parent / EXECUTION_OWNERSHIP_FILE + with _run_writer_lock(state_path.parent): + if not ownership_path.is_file(): + raise ValueError( + f"execution ownership file does not exist for run {actual_run_id!r}" + ) + doc = json.loads(ownership_path.read_text(encoding="utf-8-sig")) + if not isinstance(doc, dict): + raise ValueError(f"invalid execution ownership file: {ownership_path}") + leases = dict(doc.get("leases") or {}) + history = list(doc.get("history") or []) + existing = leases.get(operation_clean) + if not isinstance(existing, dict) or existing.get("status") != "active": + raise ValueError( + f"no active execution ownership lease for operation {operation_clean!r} in run {actual_run_id!r}" + ) + if existing.get("ownerId") != owner_clean: + raise ValueError( + f"duplicate_executor_blocked: operation {operation_clean!r} in run " + f"{actual_run_id!r} is owned by {existing.get('ownerId')!r}; " + f"release by {owner_clean!r} rejected" + ) + now = _utc_now() + released_record = { + **existing, + "status": "released", + "releasedAtUtc": now, + "updatedAtUtc": now, + } + leases[operation_clean] = released_record + history.append({ + "event": "released", + "runId": actual_run_id, + "ownerId": owner_clean, + "operation": operation_clean, + "scope": existing.get("scope") or {}, + "timestampUtc": now, + }) + updated_doc = { + **doc, + "ownerId": owner_clean, + "operation": operation_clean, + "status": "released", + "updatedAtUtc": now, + "leases": leases, + "history": history, + } + _atomic_json(ownership_path, updated_doc) + return { + **released_record, + "ownershipPath": str(ownership_path), + } + + +def _compute_forwarded_approval_sha256( + *, + run_id: str, + operation: str, + approved_by: str, + forwarded_to_owner_id: str, + exact_scope: dict[str, Any], +) -> str: + payload = { + "runId": run_id, + "operation": operation, + "approvedBy": approved_by, + "forwardedToOwnerId": forwarded_to_owner_id, + "exactScope": exact_scope, + } + canonical_bytes = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + return hashlib.sha256(canonical_bytes).hexdigest() + + +def forward_explicit_approval( + solution_path: str | Path, + *, + operation: str, + approved_by: str, + forwarded_to_owner_id: str, + exact_scope: dict[str, Any], + run_id: str | None = None, + reference: str | None = None, +) -> dict[str, Any]: + """Bind runId, operation, approvedBy, forwardedToOwnerId, and exactScope into forwarded-approvals.json.""" + if not isinstance(operation, str) or not operation.strip(): + raise ValueError("operation must be a non-empty string") + if not isinstance(approved_by, str) or not approved_by.strip(): + raise ValueError("approvedBy must be a non-empty string") + if not isinstance(forwarded_to_owner_id, str) or not forwarded_to_owner_id.strip(): + raise ValueError("forwardedToOwnerId must be a non-empty string") + if not isinstance(exact_scope, dict) or not exact_scope: + raise ValueError("exactScope must be a non-empty JSON object") + + operation_clean = operation.strip() + approver_clean = approved_by.strip() + owner_clean = forwarded_to_owner_id.strip() + scope_copy = json.loads(json.dumps(exact_scope)) + root = Path(solution_path).expanduser().resolve() + + from contextlib import nullcontext + + run_ctx = using_run(root, run_id) if run_id else nullcontext() + with run_ctx: + state_path, state = _load(root) + actual_run_id = str(state.get("runId") or state_path.parent.name) + ownership_path = state_path.parent / EXECUTION_OWNERSHIP_FILE + receipt_path = state_path.parent / FORWARDED_APPROVALS_FILE + with _run_writer_lock(state_path.parent): + if ownership_path.is_file(): + ownership_doc = json.loads(ownership_path.read_text(encoding="utf-8-sig")) + if isinstance(ownership_doc, dict): + active_lease = (ownership_doc.get("leases") or {}).get(operation_clean) + if ( + isinstance(active_lease, dict) + and active_lease.get("status") == "active" + and active_lease.get("ownerId") != owner_clean + ): + raise ValueError( + f"duplicate_executor_blocked: cannot forward approval for operation " + f"{operation_clean!r} to {owner_clean!r} while active execution lease " + f"is held by {active_lease.get('ownerId')!r}" + ) + + approval_sha256 = _compute_forwarded_approval_sha256( + run_id=actual_run_id, + operation=operation_clean, + approved_by=approver_clean, + forwarded_to_owner_id=owner_clean, + exact_scope=scope_copy, + ) + now = _utc_now() + record: dict[str, Any] = { + "schemaVersion": "1.0.0", + "runId": actual_run_id, + "operation": operation_clean, + "approvedBy": approver_clean, + "forwardedToOwnerId": owner_clean, + "exactScope": scope_copy, + "approvalSha256": approval_sha256, + "reference": reference, + "forwardedAtUtc": now, + "status": "active", + } + doc: dict[str, Any] = {} + if receipt_path.is_file(): + try: + loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig")) + if isinstance(loaded, dict): + doc = loaded + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid forwarded approvals file: {receipt_path}: {exc}") from exc + + approvals = dict(doc.get("approvals") or {}) + history = list(doc.get("history") or []) + approvals[operation_clean] = record + history.append(record) + updated_doc = { + "schemaVersion": "1.0.0", + "runId": actual_run_id, + "solution": str(root), + "operation": operation_clean, + "approvedBy": approver_clean, + "forwardedToOwnerId": owner_clean, + "exactScope": scope_copy, + "approvalSha256": approval_sha256, + "forwardedAtUtc": now, + "approvals": approvals, + "history": history, + } + _atomic_json(receipt_path, updated_doc) + return { + **record, + "receiptPath": str(receipt_path), + } + + +def verify_forwarded_approval( + solution_path: str | Path, + *, + owner_id: str | None = None, + forwarded_to_owner_id: str | None = None, + operation: str, + exact_scope: dict[str, Any], + approval_sha256: str | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + """Verify byte-for-byte that recipient ownerId, runId, operation, and exactScope match approvalSha256.""" + recipient = owner_id if owner_id is not None else forwarded_to_owner_id + if not isinstance(recipient, str) or not recipient.strip(): + raise ValueError("ownerId must be a non-empty string") + if not isinstance(operation, str) or not operation.strip(): + raise ValueError("operation must be a non-empty string") + if not isinstance(exact_scope, dict) or not exact_scope: + raise ValueError("exactScope must be a non-empty JSON object") + + recipient_clean = recipient.strip() + operation_clean = operation.strip() + root = Path(solution_path).expanduser().resolve() + + from contextlib import nullcontext + + run_ctx = using_run(root, run_id) if run_id else nullcontext() + with run_ctx: + state_path, state = _load(root) + actual_run_id = str(state.get("runId") or state_path.parent.name) + receipt_path = state_path.parent / FORWARDED_APPROVALS_FILE + if not receipt_path.is_file(): + raise ValueError( + f"forwarded approval receipt does not exist: {receipt_path}" + ) + doc = json.loads(receipt_path.read_text(encoding="utf-8-sig")) + if not isinstance(doc, dict): + raise ValueError(f"invalid forwarded approvals file: {receipt_path}") + approvals = doc.get("approvals") or {} + stored = approvals.get(operation_clean) + if not isinstance(stored, dict): + raise ValueError( + f"no forwarded approval recorded for operation {operation_clean!r} in run {actual_run_id!r}" + ) + + stored_run_id = str(stored.get("runId") or "") + stored_operation = str(stored.get("operation") or "") + stored_approved_by = str(stored.get("approvedBy") or "") + stored_owner = str(stored.get("forwardedToOwnerId") or "") + stored_scope = stored.get("exactScope") + stored_sha = str(stored.get("approvalSha256") or "") + + if not isinstance(stored_scope, dict): + raise ValueError("stored forwarded approval has invalid exactScope") + + recomputed_stored_sha = _compute_forwarded_approval_sha256( + run_id=stored_run_id, + operation=stored_operation, + approved_by=stored_approved_by, + forwarded_to_owner_id=stored_owner, + exact_scope=stored_scope, + ) + if recomputed_stored_sha != stored_sha: + raise ValueError( + "forwarded_approval_tampered: stored receipt hash does not match its recorded payload" + ) + if stored_run_id != actual_run_id: + raise ValueError( + f"forwarded_approval_run_mismatch: receipt belongs to run {stored_run_id!r}, not {actual_run_id!r}" + ) + if stored_operation != operation_clean: + raise ValueError( + f"forwarded_approval_operation_mismatch: receipt binds {stored_operation!r}, not {operation_clean!r}" + ) + if stored_owner != recipient_clean: + raise ValueError( + f"forwarded_approval_owner_mismatch: approval was forwarded to {stored_owner!r}, " + f"not {recipient_clean!r}" + ) + + stored_scope_bytes = json.dumps( + stored_scope, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + requested_scope_bytes = json.dumps( + exact_scope, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + if stored_scope_bytes != requested_scope_bytes: + raise ValueError( + "forwarded_approval_scope_mismatch: requested scope differs from the approved exactScope" + ) + + candidate_sha = _compute_forwarded_approval_sha256( + run_id=actual_run_id, + operation=operation_clean, + approved_by=stored_approved_by, + forwarded_to_owner_id=recipient_clean, + exact_scope=exact_scope, + ) + if candidate_sha != stored_sha: + raise ValueError( + "forwarded_approval_hash_mismatch: computed approvalSha256 does not match receipt" + ) + if approval_sha256 is not None and approval_sha256.strip() != stored_sha: + raise ValueError( + f"forwarded_approval_hash_mismatch: expected {approval_sha256.strip()!r}, " + f"receipt has {stored_sha!r}" + ) + + return { + "verified": True, + "status": "verified", + "runId": actual_run_id, + "operation": operation_clean, + "approvedBy": stored_approved_by, + "forwardedToOwnerId": stored_owner, + "exactScope": stored_scope, + "approvalSha256": stored_sha, + "receiptPath": str(receipt_path), + } + diff --git a/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py b/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py index 4729f41521c..6833e1230af 100644 --- a/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py +++ b/Tools/SentinelToXDRMigration/tests/test_conversion_semantics.py @@ -460,6 +460,34 @@ def test_narrowed_event_window_is_recorded_without_parity_claim(self): for message in conversion["informational"] )) + def test_hourly_1h_1h_rule_defaults_to_native_4h_lookback_without_review(self): + query = "DeviceEvents | summarize Count=count() by DeviceName, HostName, DnsDomain" + document = self.document( + query=query, + queryFrequency="1h", + queryPeriod="1h", + entityMappings=[entity("Host", HostName="HostName")], + ) + conversion = document["contentProvenance"]["conversion"] + target_query = document["properties"]["queryCondition"]["queryText"] + self.assertEqual(target_query, query) + self.assertEqual(conversion["status"], "converted") + self.assertFalse(conversion["reviewRequired"]) + self.assertEqual(conversion["reviewReasons"], []) + window = conversion["eventTimeWindow"] + self.assertEqual(window["status"], "default-native-lookback") + self.assertEqual(window["originalSentinelLookbackSeconds"], 3600) + self.assertEqual(window["effectiveEventWindowSeconds"], 14400) + self.assertEqual(window["xdrServiceWindowSeconds"], 14400) + self.assertEqual(window["targetFrequency"], "PT1H") + self.assertEqual(window["effectiveLookback"], "PT4H") + self.assertFalse(window["filterInjected"]) + self.assertTrue(window["hasAggregation"]) + self.assertFalse(window["parityClaimed"]) + self.assertEqual(len(window["sourceDocumentSha256"]), 64) + self.assertEqual(len(window["targetQuerySha256"]), 64) + self.assertEqual([], validate_document(document)) + 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") diff --git a/Tools/SentinelToXDRMigration/tests/test_converter.py b/Tools/SentinelToXDRMigration/tests/test_converter.py index a7a0d7a7550..811dbccecf8 100644 --- a/Tools/SentinelToXDRMigration/tests/test_converter.py +++ b/Tools/SentinelToXDRMigration/tests/test_converter.py @@ -235,17 +235,32 @@ def test_detection_without_event_time_passes_conversion_and_structure(self) -> N 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"]) + self.assertEqual(1, result["converted"]) + self.assertEqual(0, 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.assertEqual("converted", conversion["status"]) + self.assertEqual("default-native-lookback", conversion["eventTimeWindow"]["status"]) + self.assertEqual("PT4H", conversion["eventTimeWindow"]["effectiveLookback"]) + self.assertFalse(conversion["eventTimeWindow"]["filterInjected"]) + + # For a non-1h lookback that is still narrower than the 4h service window, + # missing an event-time column continues to require review. + rule["queryPeriod"] = "2h" + source.write_text(yaml.safe_dump(rule), encoding="utf-8") + result_2h = convert_solution(self.solution, overwrite=True) + self.assertEqual(0, result_2h["converted"]) + self.assertEqual(1, result_2h["needsReview"]) + document_2h = yaml.safe_load(output.read_text(encoding="utf-8")) + self.assertEqual([], validate_document(document_2h)) + conversion_2h = document_2h["contentProvenance"]["conversion"] + self.assertEqual("needsReview", conversion_2h["status"]) self.assertTrue(any( "source query does not establish an event-time column" in reason - for reason in conversion["reviewReasons"] + for reason in conversion_2h["reviewReasons"] )) def test_other_query_blockers_remain_without_event_time(self) -> None: @@ -788,7 +803,92 @@ def test_nrt_uses_supported_default_schedule_without_review(self) -> None: output = yaml.safe_load( (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") ) - self.assertEqual(output["properties"]["schedule"]["frequency"], "PT1H") + self.assertEqual(output["kind"], "CustomDetection") + self.assertEqual(output["properties"]["schedule"]["frequency"], "PT0S") + self.assertEqual([], validate_document(output)) + + def test_nrt_incompatible_query_requires_review_instead_of_silent_schedule(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") + document["query"] = ( + "DeviceEvents | summarize Count = count() by AccountUpn, IPAddress, Timestamp" + ) + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + result = convert_solution(self.solution) + self.assertEqual(result["converted"], 0) + self.assertEqual(result["needsReview"], 1) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + self.assertEqual(output["properties"]["schedule"]["frequency"], "PT0S") + conversion = output["contentProvenance"]["conversion"] + self.assertEqual(conversion["status"], "needsReview") + self.assertTrue(conversion["reviewRequired"]) + self.assertTrue( + any("not eligible for Continuous (NRT)" in r for r in conversion["reviewReasons"]) + ) + + def test_standard_builtin_asim_parsers_allowed_in_detections(self) -> None: + for query in ( + "_ASim_NetworkSession | where TimeGenerated > ago(1h) | project AccountUpn=SrcUsername, IPAddress=SrcIpAddr", + "_Im_WebSession(url_has_any=dynamic(['example.com'])) | where TimeGenerated > ago(1h) | project AccountUpn=SrcUsername, IPAddress=SrcIpAddr", + ): + with self.subTest(query=query): + converted, warnings, errors = convert_query(query, {}) + self.assertEqual([], errors) + self.assertEqual([], warnings) + self.assertEqual(query, converted) + + def test_solution_release_version_inheritance_and_source_version_lexical_preservation(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + raw = path.read_text(encoding="utf-8").replace("version: 1.0.0", "version: 1.0") + path.write_text(raw, encoding="utf-8") + data_dir = self.solution / "Data" + data_dir.mkdir(exist_ok=True) + (data_dir / "Solution_Sample.json").write_text( + json.dumps({"Name": "Sample", "Version": "3.2.1"}), + encoding="utf-8", + ) + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(result["converted"], 1) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + self.assertEqual(output["version"], "3.2.1") + self.assertEqual(output["contentProvenance"]["source"]["version"], "1.0") + + def test_entity_identity_rejects_synthetic_hash_and_filename_only_hash_bindings(self) -> None: + path = self.solution / "Analytic Rules" / "SampleRule.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["query"] = ( + 'DeviceEvents\n' + '| where TimeGenerated > ago(1h)\n' + '| extend Algorithm = "SHA256"\n' + '| extend FileEntity = strcat("See_auditd_events_for_details_", tostring(hash(AccountUpn)))\n' + '| project Timestamp = TimeGenerated, AccountUpn, IPAddress, Algorithm, FileEntity, FileName\n' + ) + document["entityMappings"].append( + { + "entityType": "FileHash", + "fieldMappings": [ + {"identifier": "Algorithm", "columnName": "Algorithm"}, + {"identifier": "Value", "columnName": "FileEntity"}, + ], + } + ) + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + result = convert_solution(self.solution, overwrite=True) + self.assertEqual(result["converted"], 0) + self.assertEqual(result["needsReview"], 1) + output = yaml.safe_load( + (self.solution / "XDR Detections" / "SampleRule.yaml").read_text(encoding="utf-8") + ) + conversion = output["contentProvenance"]["conversion"] + self.assertTrue( + any("genuine content SHA-1/SHA-256 is required" in r for r in conversion["reviewReasons"]) + ) @mock.patch( "sentinel_xdr_migration.runtime._advanced_hunting_token", diff --git a/Tools/SentinelToXDRMigration/tests/test_defender_workbooks_packaging.py b/Tools/SentinelToXDRMigration/tests/test_defender_workbooks_packaging.py index 3c376deee47..ac3e5caed36 100644 --- a/Tools/SentinelToXDRMigration/tests/test_defender_workbooks_packaging.py +++ b/Tools/SentinelToXDRMigration/tests/test_defender_workbooks_packaging.py @@ -6,6 +6,7 @@ import hashlib import json from pathlib import Path +import re import shutil import subprocess import tempfile @@ -179,9 +180,20 @@ def add_function( "workspaceFunctions", [], ).append(alias) self.data.setdefault("Parsers", []).append(record["sourcePath"]) - self.template["variables"]["parserObject1"] = { - "_parserName1": "[concat(parameters('workspace'),'/','different-arm-name')]", - } + parser_match = re.fullmatch( + r"\[variables\('(parserObject([1-9][0-9]*))'\)\.(_?parserName\2)\]", + name, + ) + if parser_match: + obj_name, idx, name_prop = parser_match.groups() + arm_leaf = "different-arm-name" if idx == "1" else f"{alias}-arm-name" + self.template["variables"][obj_name] = { + name_prop: f"[concat(parameters('workspace'),'/','{arm_leaf}')]", + f"_parserId{idx}": ( + "[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', " + f"parameters('workspace'), '{arm_leaf}')]" + ), + } resource = { "type": "Microsoft.OperationalInsights/workspaces/savedSearches", "name": name, @@ -205,7 +217,7 @@ def test_required_parser_dependencies_use_emitted_names_and_preserve_provenance( self.assertEqual([ "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), " "'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]", - "[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', variables('parserObject1')._parserName1)]", + "[variables('parserObject1')._parserId1]", "[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', 'lab/arm-name-not-alias')]", ], install["dependsOn"]) registration = next(r for r in resources if r["type"].endswith("/contentTemplates")) @@ -224,6 +236,68 @@ def test_required_parser_dependencies_use_emitted_names_and_preserve_provenance( self.assertNotIn("condition", resource) self.assertEqual("*", resource["properties"]["eTag"]) + def test_syslog_two_parser_objects_emit_canonical_parser_ids_and_pass_python_verifier(self) -> None: + from sentinel_xdr_migration import packaging + + for prop_prefix in ("parserName", "_parserName"): + with self.subTest(prop_prefix=prop_prefix): + self.tearDown() + self.setUp() + self.notebook["xdrMetadata"]["version"] = self.data["Version"] + self.add_function( + "SyslogConnectorsOverallStatus", + name=f"[variables('parserObject1').{prop_prefix}1]", + ) + self.add_function( + "SyslogConnectorsEventVolumebyDeviceProduct", + name=f"[variables('parserObject2').{prop_prefix}2]", + ) + query = ( + "union (SyslogConnectorsOverallStatus() | take 1), " + "(SyslogConnectorsEventVolumebyDeviceProduct() | take 1)" + ) + self.notebook["items"][0]["content"]["query"] = query + self.notebook["contentProvenance"]["conversion"]["queries"][0]["targetSha256"] = ( + hashlib.sha256(query.encode("utf-8")).hexdigest() + ) + self.write() + (self.solution / "Data").mkdir(exist_ok=True) + (self.solution / "Data/Solution_Sample.json").write_text( + json.dumps({"Name": "Sample", **self.data}), encoding="utf-8" + ) + self.invoke(package=True) + package_dir = self.solution / "Package" + template = json.loads((package_dir / "mainTemplate.json").read_text(encoding="utf-8-sig")) + install = next(r for r in template["resources"] if r["type"] == "Microsoft.Resources/deployments") + self.assertEqual([ + "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), " + "'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]", + "[variables('parserObject1')._parserId1]", + "[variables('parserObject2')._parserId2]", + ], install["dependsOn"]) + receipt = packaging._verify_workbook_package( + self.solution, [self.target], template, package_dir / "3.2.7.zip" + ) + self.assertEqual(1, receipt["count"]) + tampered = copy.deepcopy(template) + tampered_install = next( + r for r in tampered["resources"] if r["type"] == "Microsoft.Resources/deployments" + ) + tampered_install["dependsOn"][1] = ( + f"[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', " + f"variables('parserObject1').{prop_prefix}1)]" + ) + with self.assertRaises(RuntimeError): + packaging._verify_workbook_package( + self.solution, [self.target], tampered, package_dir / "3.2.7.zip" + ) + missing_id = copy.deepcopy(template) + missing_id["variables"]["parserObject2"].pop("_parserId2") + with self.assertRaises(RuntimeError): + packaging._verify_workbook_package( + self.solution, [self.target], missing_id, package_dir / "3.2.7.zip" + ) + def test_function_dependency_ids_deduplicate_and_contract_references_work(self) -> None: self.add_function() self.add_function("SecondParser") @@ -267,7 +341,7 @@ def test_full_package_requires_parsers_and_linked_registrations_but_not_unrelate self.assertEqual("[parameters('DeployParser')]", package["resources"][5]["condition"]) installation = next(r for r in package["resources"] if r["type"] == "Microsoft.Resources/deployments") self.assertIn( - "[resourceId('Microsoft.OperationalInsights/workspaces/savedSearches', variables('parserObject1')._parserName1)]", + "[variables('parserObject1')._parserId1]", installation["dependsOn"], ) self.assertEqual(original, self.target.read_bytes()) diff --git a/Tools/SentinelToXDRMigration/tests/test_local_validation_xdr.py b/Tools/SentinelToXDRMigration/tests/test_local_validation_xdr.py new file mode 100644 index 00000000000..3ce7e4b67f4 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_local_validation_xdr.py @@ -0,0 +1,812 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the repository root. +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from uuid import uuid4 +import zipfile +import yaml + +from sentinel_xdr_migration import workbooks +from sentinel_xdr_migration._workbook_core import workbook as wb_core +from sentinel_xdr_migration.arm_acceptance import _parse_build_validation_receipt +from sentinel_xdr_migration.arm_acceptance_support import _canonical_sha256 +from sentinel_xdr_migration.converter import build_xdr_document +from sentinel_xdr_migration.packaging import ( + package_input_file_hashes, + package_input_identity, +) + + +REPO_ROOT = Path(__file__).resolve().parents[3] +VALIDATE_XDR_SCRIPT = REPO_ROOT / ".script/local-validation/validate-xdr-content.py" +VALIDATE_JS = REPO_ROOT / ".script/local-validation/validate.js" +BUILD_AND_VALIDATE_PS1 = REPO_ROOT / ".script/local-validation/build-and-validate.ps1" +COMMON_FUNCTIONS_PS1 = ( + REPO_ROOT / "Tools/Create-Azure-Sentinel-Solution/common/commonFunctions.ps1" +) + + +def _load_validate_xdr_module(): + spec = importlib.util.spec_from_file_location("validate_xdr_content", VALIDATE_XDR_SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +VALIDATE_XDR = _load_validate_xdr_module() + + +SAMPLE_ANALYTIC_RULE = """id: 7f8c3a12-4b5e-4d9a-8c1e-2f3a4b5c6d7e +name: Sample Device Process Rule +description: Detects suspicious process execution on endpoints. +severity: Medium +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - DeviceProcessEvents +queryFrequency: 1h +queryPeriod: 1h +triggerOperator: gt +triggerThreshold: 0 +tactics: + - Execution +relevantTechniques: + - T1059 +query: | + DeviceProcessEvents + | where TimeGenerated > ago(1h) + | where FileName =~ "cmd.exe" +entityMappings: + - entityType: Host + fieldMappings: + - identifier: HostName + columnName: DeviceName +version: 1.0.0 +kind: Scheduled +""" + + +SAMPLE_PARSER_YAML = """id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d +Function: + Title: HelperFunction parser + Version: '1.0.0' + LastUpdated: '2026-04-01' +Category: Microsoft Sentinel Parser +FunctionName: HelperFunction +FunctionAlias: HelperFunction +FunctionQuery: 'DeviceProcessEvents | where FileName =~ "cmd.exe"' +""" + + +class LocalValidationXdrTests(unittest.TestCase): + def setUp(self) -> None: + self.scratch = tempfile.TemporaryDirectory( + prefix="local-val-xdr-", dir=Path.cwd() + ) + self.root = Path(self.scratch.name) + self.solution = self.root / "Solutions" / "SampleSolution" + (self.solution / "Analytic Rules").mkdir(parents=True) + (self.solution / "Workbooks").mkdir(parents=True) + (self.solution / "Parsers").mkdir(parents=True) + (self.solution / "Data").mkdir(parents=True) + (self.solution / "Package").mkdir(parents=True) + + def tearDown(self) -> None: + self.scratch.cleanup() + + def _populate_valid_hybrid_solution(self, *, with_package: bool = True) -> dict[str, Path]: + # 1. Source Analytic Rule + converted XDR Detection + rule_path = self.solution / "Analytic Rules" / "SampleRule.yaml" + rule_path.write_text(SAMPLE_ANALYTIC_RULE, encoding="utf-8") + xdr_doc = build_xdr_document(rule_path, self.solution, {}) + xdr_det_dir = self.solution / "XDR Detections" + xdr_det_dir.mkdir(exist_ok=True) + xdr_det_path = xdr_det_dir / "SampleRule.yaml" + xdr_det_path.write_text( + yaml.safe_dump(xdr_doc, sort_keys=False), encoding="utf-8" + ) + + # 2. Source Parser + Source Workbook + converted XDR Workbook with requiredWorkspaceFunctions + parser_path = self.solution / "Parsers" / "HelperFunction.yaml" + parser_path.write_text(SAMPLE_PARSER_YAML, encoding="utf-8") + + wb_source = self.solution / "Workbooks" / "SampleWorkbook.json" + wb_source_doc = { + "version": "Notebook/1.0", + "fromTemplateId": "sentinel-SampleWorkbook", + "items": [ + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "HelperFunction() | summarize count() by DeviceName", + "queryType": 0, + "resourceType": wb_core.LA, + }, + } + ], + } + wb_source.write_text(json.dumps(wb_source_doc, indent=2), encoding="utf-8") + + data_file = self.solution / "Data" / "Solution_SampleSolution.json" + data_doc = { + "Name": "SampleSolution", + "Author": "Microsoft - support@microsoft.com", + "Description": "Sample hybrid Sentinel + Defender XDR solution.", + "Version": "3.2.1", + "BasePath": str(self.solution), + "Metadata": "SolutionMetadata.json", + "Analytic Rules": ["Analytic Rules/SampleRule.yaml"], + "Workbooks": ["Workbooks/SampleWorkbook.json"], + "Parsers": ["Parsers/HelperFunction.yaml"], + "XDR Detections": ["XDR Detections/SampleRule.yaml"], + "XDR Workbooks": ["XDR Workbooks/SampleWorkbook.json"], + } + data_file.write_text(json.dumps(data_doc, indent=2), encoding="utf-8") + + metadata_file = self.solution / "SolutionMetadata.json" + metadata_file.write_text( + json.dumps( + { + "publisherId": "azuresentinel", + "offerId": "azure-sentinel-solution-samplesolution", + "firstPublishDate": "2025-01-01", + "providers": ["Microsoft"], + "categories": {"domains": ["Security - Threat Protection"]}, + "support": { + "name": "Microsoft Corporation", + "email": "support@microsoft.com", + "tier": "Microsoft", + "link": "https://support.microsoft.com", + }, + }, + indent=2, + ), + encoding="utf-8", + ) + + query_text = wb_source_doc["items"][0]["content"]["query"] + settings = { + "workbookResolutions": { + "Workbooks/SampleWorkbook.json": { + "/items/0/content/query": { + "querySha256": wb_core.query_hash(query_text), + "tables": ["DeviceProcessEvents"], + "workspaceFunctions": ["HelperFunction"], + "evidence": "Reviewed parser HelperFunction and table DeviceProcessEvents", + } + } + }, + "workbookDependencies": { + "Workbooks/SampleWorkbook.json": { + "requiredWorkloads": [ + {"workload": "mde", "dataTypes": ["DeviceProcessEvents"]} + ], + "requiredDataConnectors": [], + "requiredWorkspaceFunctions": [ + { + "functionAlias": "HelperFunction", + "sourcePath": "Parsers/HelperFunction.yaml", + "sourceSha256": hashlib.sha256(parser_path.read_bytes()).hexdigest(), + } + ], + } + }, + } + conv_result = workbooks.convert_workbooks( + self.solution, + config=settings, + report_directory=self.solution / "Logs", + overwrite=True, + ) + self.assertEqual(conv_result["converted"], 1) + shutil.rmtree(self.solution / "Logs", ignore_errors=True) + xdr_wb_path = self.solution / "XDR Workbooks" / "SampleWorkbook.json" + self.assertTrue(xdr_wb_path.is_file()) + + if with_package: + self._build_packaged_main_template(data_doc) + + return { + "rule": rule_path, + "xdr_detection": xdr_det_path, + "parser": parser_path, + "workbook": wb_source, + "xdr_workbook": xdr_wb_path, + "data": data_file, + "main_template": self.solution / "Package" / "mainTemplate.json", + } + + def _build_packaged_main_template(self, data_doc: dict) -> None: + """Build a valid Package/mainTemplate.json containing CustomDetection, DefenderWorkbook-0, and savedSearches.""" + assert shutil.which("pwsh"), "pwsh is required to build packaged mainTemplate fixture" + template = { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspace": {"type": "string", "defaultValue": "sample-ws"}, + "workspace-location": {"type": "string", "defaultValue": "eastus"}, + }, + "variables": { + "solutionId": "azuresentinel.azure-sentinel-solution-samplesolution", + "_solutionId": "[variables('solutionId')]", + "_solutionVersion": "3.2.1", + "workspaceResourceId": "[resourceId('microsoft.OperationalInsights/workspaces', parameters('workspace'))]", + "uiuk": "sample-uiuk", + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/savedSearches", + "apiVersion": "2020-08-01", + "name": "[concat(parameters('workspace'), '/HelperFunction')]", + "properties": { + "category": "Microsoft Sentinel Parser", + "displayName": "HelperFunction parser", + "functionAlias": "HelperFunction", + "query": "DeviceProcessEvents | where FileName =~ \"cmd.exe\"", + "version": 2, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "apiVersion": "2023-04-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/', concat('HelperFunction-Parser-', variables('_solutionVersion')))]", + "properties": { + "contentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "displayName": "HelperFunction parser", + "contentKind": "Function", + "contentProductId": "sample-function-product-id", + "contentSchemaVersion": "3.0.0", + "packageId": "[variables('_solutionId')]", + "packageVersion": "[variables('_solutionVersion')]", + "source": {"kind": "Solution", "name": "SampleSolution", "sourceId": "[variables('_solutionId')]"}, + "author": {"name": "Microsoft"}, + "support": {"name": "Microsoft Corporation", "tier": "Microsoft"}, + "version": "1.0.0", + "mainTemplate": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/savedSearches", + "apiVersion": "2020-08-01", + "name": "[concat(parameters('workspace'), '/HelperFunction')]", + "properties": {"functionAlias": "HelperFunction"}, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/metadata", + "apiVersion": "2022-01-01-preview", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/Function-HelperFunction')]", + "properties": {"kind": "Function", "parentId": "sample"}, + }, + ], + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentPackages", + "apiVersion": "2023-04-01-preview", + "location": "[parameters('workspace-location')]", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/', variables('_solutionId'))]", + "properties": { + "version": "3.2.1", + "contentId": "[variables('_solutionId')]", + "contentProductId": "sample-pkg", + "displayName": "SampleSolution", + "contentKind": "Solution", + "descriptionHtml": "

    Sample

    ", + "icon": "", + "categories": {"domains": ["Security - Threat Protection"]}, + "support": { + "name": "Microsoft Corporation", + "email": "support@microsoft.com", + "tier": "Microsoft", + "link": "https://support.microsoft.com", + }, + "dependencies": {"criteria": []}, + }, + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "CustomDetection-1", + "condition": "[parameters('DeployCustomDetection')]", + "dependsOn": [ + "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": {"scope": "inner"}, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "imports": { + "MicrosoftSecurity": { + "provider": "MicrosoftSecurity", + "version": "1.0.0", + } + }, + "resources": { + "detectionRule": { + "import": "MicrosoftSecurity", + "type": "Microsoft.Security/detectionRules@2026-03-01-beta", + "properties": { + "id": "xdr-7f8c3a12-4b5e-4d9a-8c1e-2f3a4b5c6d7e", + "displayName": "Sample Device Process Rule", + "status": "disabled", + }, + } + }, + }, + }, + }, + { + "type": "Microsoft.OperationalInsights/workspaces/providers/contentTemplates", + "apiVersion": "2023-04-01-preview", + "location": "[parameters('workspace-location')]", + "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/', concat('xdr-7f8c3a12-4b5e-4d9a-8c1e-2f3a4b5c6d7e-cd-', variables('_solutionVersion')))]", + "condition": "[parameters('DeployCustomDetection')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'CustomDetection-1')]", + "[extensionResourceId(resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspace')), 'Microsoft.SecurityInsights/contentPackages', variables('_solutionId'))]", + ], + "properties": { + "version": "1.0.0", + "contentId": "xdr-7f8c3a12-4b5e-4d9a-8c1e-2f3a4b5c6d7e", + "contentProductId": "sample-cd-product-id", + "contentSchemaVersion": "3.0.0", + "displayName": "Sample Device Process Rule", + "contentKind": "CustomDetection", + "packageId": "[variables('_solutionId')]", + "packageVersion": "[variables('_solutionVersion')]", + "mainTemplate": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [], + }, + }, + }, + ], + } + ui = { + "parameters": { + "outputs": {}, + "steps": [], + "config": { + "basics": { + "description": "Sample description\n\n**Analytic Rules:** 1\n\n**Workbooks:** 1" + } + }, + } + } + payload_path = self.root / "pkg-input.json" + payload_path.write_text( + json.dumps({"data": data_doc, "template": template, "ui": ui}), + encoding="utf-8", + ) + ps_cmd = ( + f". '{COMMON_FUNCTIONS_PS1}'; " + f"$p = Get-Content -LiteralPath '{payload_path}' -Raw | ConvertFrom-Json -Depth 100; " + f"$template = $p.template; $ui = $p.ui; " + f"Register-SolutionRequiredResources -Resources @($template.resources[0], $template.resources[1]); " + f"$wbCount = Add-XdrDefenderWorkbooksToSolution -SolutionName 'SampleSolution' -ContentToImport $p.data -Template $template -RepositoryRoot '{self.root}'; " + f"Add-SolutionContentDeploymentParameters -Template $template -UiDefinition $ui; " + f"$template | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath '{self.solution / 'Package' / 'mainTemplate.json'}' -Encoding utf8" + ) + proc = subprocess.run( + ["pwsh", "-NoLogo", "-NoProfile", "-Command", ps_cmd], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout) + payload_path.unlink(missing_ok=True) + + def test_solution_without_xdr_content_reports_visible_skips(self) -> None: + # Solution with only native Sentinel rule and workbook + (self.solution / "Analytic Rules" / "SampleRule.yaml").write_text( + SAMPLE_ANALYTIC_RULE, encoding="utf-8" + ) + (self.solution / "Data" / "Solution_SampleSolution.json").write_text( + json.dumps( + { + "Name": "SampleSolution", + "Version": "3.0.0", + "Analytic Rules": ["Analytic Rules/SampleRule.yaml"], + } + ), + encoding="utf-8", + ) + + report = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="all", + ) + self.assertEqual(report["summary"]["failed"], 0) + self.assertEqual(report["summary"]["passed"], 0) + self.assertEqual(report["summary"]["skipped"], 2) + for item in report["results"]: + self.assertTrue(item["skipped"]) + self.assertTrue(item["passed"]) + self.assertIn("No XDR", item["skipReason"]) + + def test_valid_xdr_detections_and_defender_workbooks_pass_validation(self) -> None: + self._populate_valid_hybrid_solution(with_package=True) + report = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="all", + ) + self.assertEqual(report["summary"]["failed"], 0, json.dumps(report, indent=2)) + self.assertEqual(report["summary"]["skipped"], 0) + self.assertGreaterEqual(report["summary"]["passed"], 2) + self.assertTrue(report["detections"][0]["applicable"]) + self.assertTrue(report["detections"][0]["liveKqlSkipped"]) + self.assertTrue(report["workbooks"][0]["applicable"]) + self.assertTrue(report["workbooks"][0]["liveKqlSkipped"]) + + def test_xdr_detection_schema_and_provenance_failures_are_reported(self) -> None: + paths = self._populate_valid_hybrid_solution(with_package=False) + det_path = paths["xdr_detection"] + original_doc = yaml.safe_load(det_path.read_text(encoding="utf-8")) + + # Case 1: dependencyReviewRequired = True + conversion.reviewRequired = True + stale source querySha256 + broken_doc = copy.deepcopy(original_doc) + broken_doc["contentProvenance"]["dependencyReviewRequired"] = True + broken_doc["contentProvenance"]["conversion"]["reviewRequired"] = True + broken_doc["contentProvenance"]["conversion"]["status"] = "needs_review" + broken_doc["contentProvenance"]["source"]["querySha256"] = "0" * 64 + broken_doc["contentProvenance"]["requiredWorkloads"] = [ + {"workload": "invalid-workload", "dataTypes": ["DeviceProcessEvents"]} + ] + det_path.write_text(yaml.safe_dump(broken_doc, sort_keys=False), encoding="utf-8") + + report = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="detections", + ) + self.assertEqual(report["summary"]["failed"], 1) + joined = "\n".join(r.get("error") or "" for r in report["results"]) + self.assertIn("dependencyReviewRequired", joined) + self.assertIn("reviewRequired", joined) + self.assertIn("needs_review", joined) + self.assertIn("querySha256", joined) + self.assertIn("invalid-workload", joined) + + # Case 2: Data/*.json missing explicit "XDR Detections" array when XDR Detections/ exists + det_path.write_text(yaml.safe_dump(original_doc, sort_keys=False), encoding="utf-8") + data_doc = json.loads(paths["data"].read_text(encoding="utf-8")) + del data_doc["XDR Detections"] + paths["data"].write_text(json.dumps(data_doc, indent=2), encoding="utf-8") + + report_missing_data = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="detections", + ) + self.assertGreaterEqual(report_missing_data["summary"]["failed"], 1) + all_msgs = [ + res.get("error") or "" + for res in report_missing_data["results"] + if not res.get("passed", True) + ] + self.assertTrue( + any("missing an explicit XDR Detections array" in m for m in all_msgs), + all_msgs, + ) + + def test_xdr_workbook_schema_provenance_and_packaged_depends_on_failures_are_reported(self) -> None: + paths = self._populate_valid_hybrid_solution(with_package=True) + wb_path = paths["xdr_workbook"] + original_wb = json.loads(wb_path.read_text(encoding="utf-8")) + + # Case 1: Leaked deployment quartet + dependencyReviewRequired + broken requiredWorkspaceFunctions + broken_wb = copy.deepcopy(original_wb) + broken_wb["contentProvenance"]["packageId"] = "leaked.pkg" + broken_wb["contentProvenance"]["dependencyReviewRequired"] = True + broken_wb["contentProvenance"]["conversion"]["reviewRequired"] = True + broken_wb["contentProvenance"]["requiredWorkspaceFunctions"] = [ + { + "functionAlias": "HelperFunction", + "sourcePath": "Parsers/MissingParser.yaml", + "sourceSha256": "0" * 64, + } + ] + wb_path.write_text(json.dumps(broken_wb, indent=2), encoding="utf-8") + + report = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="workbooks", + ) + self.assertGreaterEqual(report["summary"]["failed"], 1) + all_msgs = "\n".join( + res.get("error") or "" + for res in report["results"] + if not res.get("passed", True) + ) + self.assertIn("packageId", all_msgs) + self.assertIn("dependencyReviewRequired", all_msgs) + self.assertIn("reviewRequired", all_msgs) + self.assertIn("MissingParser.yaml", all_msgs) + + # Case 2: Restore authored workbook, break packaged mainTemplate.json DefenderWorkbook dependsOn + wb_path.write_text(json.dumps(original_wb, indent=2), encoding="utf-8") + main_template_path = paths["main_template"] + main_template = json.loads(main_template_path.read_text(encoding="utf-8")) + for resource in main_template["resources"]: + if ( + resource.get("type") + == "Microsoft.OperationalInsights/workspaces/providers/contentTemplates" + and resource.get("properties", {}).get("contentKind") == "DefenderWorkbook" + ): + # Corrupt dependsOn so contentPackages extensionResourceId is missing + resource["dependsOn"] = [ + "[resourceId('Microsoft.Resources/deployments', 'DefenderWorkbook-0')]" + ] + main_template_path.write_text(json.dumps(main_template, indent=2), encoding="utf-8") + + pkg_report = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="workbooks", + ) + self.assertGreaterEqual(pkg_report["summary"]["failed"], 1) + pkg_msgs = "\n".join( + res.get("error") or "" + for res in pkg_report["results"] + if not res.get("passed", True) + ) + self.assertIn("dependsOn", pkg_msgs) + + # Case 3: Standalone DefenderWorkbook-* in Package/mainTemplate.json without XDR Workbooks/ folder + shutil.rmtree(self.solution / "XDR Workbooks") + standalone_report = VALIDATE_XDR.run_validation( + content_root=self.root, + solution_paths=[self.solution], + family="workbooks", + ) + self.assertGreaterEqual(standalone_report["summary"]["failed"], 1) + standalone_msgs = "\n".join( + res.get("error") or "" + for res in standalone_report["results"] + if not res.get("passed", True) + ) + self.assertIn("DefenderWorkbook", standalone_msgs) + + def test_validate_js_runs_xdr_validators_and_skips_legacy_workbook_template_on_xdr_workbooks(self) -> None: + self._populate_valid_hybrid_solution(with_package=True) + out_json = self.root / "validate-results.json" + proc = subprocess.run( + [ + "node", + str(VALIDATE_JS), + "--repository-root", + str(self.root), + "--path", + "Solutions/SampleSolution", + "--only", + "workbook-template,xdr-detection,xdr-workbook", + "--json-file", + str(out_json), + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout) + results = json.loads(out_json.read_text(encoding="utf-8")) + self.assertEqual(results["summary"]["failed"], 0, json.dumps(results, indent=2)) + + by_validator: dict[str, list[dict]] = {} + for item in results["results"]: + by_validator.setdefault(item["validator"], []).append(item) + + self.assertIn("XDR Detection", by_validator) + self.assertIn("XDR Workbook", by_validator) + self.assertTrue(all(r["passed"] and not r.get("skipped") for r in by_validator["XDR Detection"])) + self.assertTrue(all(r["passed"] and not r.get("skipped") for r in by_validator["XDR Workbook"])) + + # Ensure XDR Workbooks/*.json was marked skipped by legacy Workbook Template validator + wb_template_results = by_validator.get("Workbook Template", []) + xdr_wb_entries = [ + r for r in wb_template_results if "XDR Workbooks/" in r["filePath"] + ] + self.assertEqual(len(xdr_wb_entries), 1) + self.assertTrue(xdr_wb_entries[0].get("skipped")) + self.assertIn("XDR Workbook validator", xdr_wb_entries[0].get("skipReason", "")) + + @unittest.skipUnless(shutil.which("pwsh"), "PowerShell 7 is required") + def test_build_and_validate_ps1_surfaces_xdr_checks_and_visible_skipped_checks(self) -> None: + self._populate_valid_hybrid_solution(with_package=True) + subprocess.run( + ["git", "init", "-q"], + cwd=str(self.root), + check=True, + ) + + package_dir = self.solution / "Package" + (package_dir / "createUiDefinition.json").write_text( + json.dumps( + { + "$schema": "https://schema.management.azure.com/schemas/0.1.2-preview/CreateUIDefinition.MultiVm.json#", + "handler": "Microsoft.Azure.CreateUIDef", + "version": "0.1.2-preview", + "parameters": { + "config": {"isWizard": False, "basics": {"description": "Sample"}}, + "basics": [], + "steps": [], + "outputs": {}, + }, + }, + indent=2, + ), + encoding="utf-8", + ) + (package_dir / "testParameters.json").write_text( + json.dumps({"workspace": {"value": "ws"}}, indent=2), + encoding="utf-8", + ) + zip_path = package_dir / "3.2.1.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.write(package_dir / "mainTemplate.json", "mainTemplate.json") + archive.write(package_dir / "createUiDefinition.json", "createUiDefinition.json") + + logs_dir = self.solution / "Logs" + logs_dir.mkdir(exist_ok=True) + pkg_paths = { + "mainTemplate": "Package/mainTemplate.json", + "createUiDefinition": "Package/createUiDefinition.json", + "testParameters": "Package/testParameters.json", + "zip": "Package/3.2.1.zip", + } + pkg_hashes = { + name: hashlib.sha256((self.solution / rel).read_bytes()).hexdigest() + for name, rel in pkg_paths.items() + } + identity = package_input_identity(self.solution) + pkg_report = { + "packager": "V3.1", + "workflowRunId": "test-xdr-local-val-run", + "packageAttemptId": "attempt-1", + "version": "3.2.1", + "armValidationMethod": "lab-deployment", + "packageInputs": { + "after": {**identity, "files": package_input_file_hashes(self.solution)} + }, + "packageArtifactHashes": pkg_hashes, + "packageArtifactsSha256": _canonical_sha256(pkg_hashes), + **pkg_paths, + } + report_path = logs_dir / "packaging.v3_1.json" + report_path.write_text( + json.dumps(pkg_report, indent=2), encoding="utf-8" + ) + package_files = sorted( + ( + { + "path": path.relative_to(package_dir).as_posix(), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + for path in package_dir.rglob("*") + if path.is_file() + ), + key=lambda item: item["path"], + ) + package_identity = { + "version": "3.2.1", + "packageInputSha256": identity["sha256"], + "packageContentSha256": _canonical_sha256(package_files), + "packageReportSha256": hashlib.sha256(report_path.read_bytes()).hexdigest(), + "packageAttemptId": "attempt-1", + "workflowRunId": "test-xdr-local-val-run", + "packageArtifactsSha256": pkg_report["packageArtifactsSha256"], + "recordedPackageArtifactHashes": pkg_hashes, + } + + proc = subprocess.run( + [ + "pwsh", + "-NoLogo", + "-NoProfile", + "-File", + str(BUILD_AND_VALIDATE_PS1), + "-SolutionName", + "SampleSolution", + "-ContentRoot", + str(self.root), + "-SkipPackaging", + "-ArmValidationMethod", + "lab-deployment", + "-PackageReportPath", + "Logs/packaging.v3_1.json", + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=False, + ) + output = proc.stdout + evidence_dir: Path | None = None + for line in output.splitlines(): + if line.startswith("Evidence: "): + evidence_dir = Path(line.split("Evidence: ", 1)[1].strip()) + break + try: + self.assertEqual(proc.returncode, 0, proc.stderr or proc.stdout) + self.assertIn("[REPORT_START]", output) + self.assertIn("[REPORT_END]", output) + self.assertIn("| TS | XDR Detection | PASS |", output) + self.assertIn("| TS | XDR Workbook | PASS |", output) + self.assertIn("| .NET | XDR KQL (Live / Advanced Hunting) | SKIP |", output) + self.assertIn("Skipped / Unsupported Checks", output) + self.assertIsNotNone(evidence_dir) + + # Verify that _parse_build_validation_receipt from arm_acceptance.py validates the report & binding receipt + receipt = _parse_build_validation_receipt( + self.solution, + evidence_dir / "build-and-validate-report.md", + package_report_path=report_path, + package_identity=package_identity, + arm_validation_method="lab-deployment", + ) + self.assertEqual(receipt["validatorSummary"]["failed"], 0) + self.assertGreater(receipt["validatorSummary"]["skipped"], 0) + validators_by_name = { + row["validator"]: row["status"] for row in receipt["validatorResults"] + } + self.assertEqual(validators_by_name.get("XDR Detection"), "PASS") + self.assertEqual(validators_by_name.get("XDR Workbook"), "PASS") + self.assertEqual( + validators_by_name.get("XDR KQL (Live / Advanced Hunting)"), "SKIP" + ) + finally: + if evidence_dir and evidence_dir.exists(): + shutil.rmtree(evidence_dir, ignore_errors=True) + try: + evidence_dir.parent.rmdir() + except OSError: + pass + subprocess.run( + [ + "git", + "checkout", + "--", + ".script/SolutionValidations", + ".script/utils", + ".script/contentValidator.js.map", + ".script/dataConnectorValidator.js.map", + ".script/documentsLinkValidator.js.map", + ".script/idChangeValidator.js.map", + ".script/logoValidator.js.map", + ".script/playbooksValidator.js.map", + ".script/workbooksTemplateValidator.js.map", + ".script/yamlFileValidator.js.map", + ], + cwd=str(REPO_ROOT), + capture_output=True, + check=False, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_release_notes.py b/Tools/SentinelToXDRMigration/tests/test_release_notes.py new file mode 100644 index 00000000000..245a1e9d8d9 --- /dev/null +++ b/Tools/SentinelToXDRMigration/tests/test_release_notes.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import datetime +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from sentinel_xdr_migration import cli as cli_module +from sentinel_xdr_migration import release_notes as release_notes_module +from sentinel_xdr_migration import workflow as workflow_module +from sentinel_xdr_migration.artifacts import using_run +from sentinel_xdr_migration.packaging import package_input_identity +from sentinel_xdr_migration.release_notes import ( + default_release_date, + release_notes_refresh_proposal, + resolve_release_date, +) +from sentinel_xdr_migration.workflow import ( + complete_workflow_stage, + initialize_workflow, + package_workflow_v3_1, + plan_new_package_release, + plan_release_notes_refresh, +) + + +class ReleaseNotesTests(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 _write_release_notes(self) -> Path: + notes = self.solution / "ReleaseNotes.md" + separator = "|---------|-------------------------------|----------------|\n" + notes.write_text( + "| **Version** | **Date Modified (DD-MM-YYYY)** | **Change History** |\n" + + separator + + "| 3.2.1 | 01-10-2026 | Prior release. |\n" + + "| 3.2.0 | 01-09-2026 | Older release. |\n", + encoding="utf-8", + ) + return notes + + def test_default_release_date_returns_dd_mm_yyyy(self) -> None: + fixed_date = datetime.date(2026, 4, 19) + self.assertEqual("19-04-2026", default_release_date(today=fixed_date)) + self.assertEqual("19-04-2026", resolve_release_date(None, today=fixed_date)) + self.assertEqual("19-04-2026", resolve_release_date(" ", today=fixed_date)) + + def test_resolve_release_date_honors_explicit_override_and_rejects_invalid(self) -> None: + self.assertEqual("05-11-2026", resolve_release_date("05-11-2026")) + with self.assertRaisesRegex(ValueError, "release date must be a valid DD-MM-YYYY"): + resolve_release_date("2026-11-05") + with self.assertRaisesRegex(ValueError, "release date must be a valid DD-MM-YYYY"): + resolve_release_date("31-02-2026") + + def test_release_notes_refresh_proposal_defaults_to_today_and_supports_override(self) -> None: + notes = self._write_release_notes() + fixed_date = datetime.date(2026, 4, 19) + + path, before_sha, after_sha, resolved_date = release_notes_refresh_proposal( + self.solution, + version="3.2.2", + release_date=None, + change_history="Added converted Defender XDR detections.", + today=fixed_date, + ) + self.assertEqual(notes, path) + self.assertEqual("19-04-2026", resolved_date) + self.assertNotEqual(before_sha, after_sha) + + _, _, override_after_sha, override_date = release_notes_refresh_proposal( + self.solution, + version="3.2.2", + release_date="25-12-2026", + change_history="Added converted Defender XDR detections.", + today=fixed_date, + ) + self.assertEqual("25-12-2026", override_date) + self.assertNotEqual(after_sha, override_after_sha) + + def test_workflow_plan_release_notes_refresh_defaults_to_today_and_cli_allows_omitting_date(self) -> None: + data = self.solution / "Data" / "Solution_Sample.json" + data.parent.mkdir(parents=True) + data.write_text(json.dumps({"Name": "Sample", "Version": "3.2.1"}), encoding="utf-8") + self._write_release_notes() + from test_converter import RULE + source_dir = self.solution / "Analytic Rules" + source_dir.mkdir(parents=True) + source_rule = source_dir / "Rule.yaml" + source_rule.write_text(RULE, encoding="utf-8") + + initialized = initialize_workflow( + self.solution, + version_bump="none", + target_version="3.2.2", + workflow_profile="authoring", + campaign_id="release-notes-default-date", + ) + run_id = initialized["runId"] + + def fake_package(solution: Path, **kwargs: str) -> dict: + before_identity = package_input_identity(solution) + target_ver = kwargs.get("target_version") or "3.2.2" + data.write_text(json.dumps({"Name": "Sample", "Version": target_ver}), encoding="utf-8") + package_dir = solution / "Package" + package_dir.mkdir(exist_ok=True) + paths = { + "mainTemplate": package_dir / "mainTemplate.json", + "createUiDefinition": package_dir / "createUiDefinition.json", + "testParameters": package_dir / "testParameters.json", + "zip": package_dir / f"{target_ver}.zip", + } + for path in paths.values(): + path.write_text("{}", encoding="utf-8") + from sentinel_xdr_migration.artifacts import artifact_path + from sentinel_xdr_migration.packaging import package_input_file_hashes + report = artifact_path(solution, "packaging.v3_1.json", create_parent=True) + after_identity = package_input_identity(solution) + package_hashes = { + name: hashlib.sha256(path.read_bytes()).hexdigest() + for name, path in paths.items() + } + payload = { + "status": "passed", + "packager": "V3.1", + "version": target_ver, + "versionBump": kwargs.get("version_bump", "none"), + "requestedVersionBump": kwargs.get("requested_version_bump", "none"), + "effectiveVersionBump": kwargs.get("version_bump", "none"), + "requestedTargetVersion": target_ver, + "effectiveTargetVersion": target_ver, + "packageAttemptId": kwargs.get("package_attempt_id"), + "workflowRunId": kwargs.get("workflow_run_id"), + "packageInputs": { + "before": before_identity, + "after": {**after_identity, "files": package_input_file_hashes(solution)}, + }, + "packageArtifactHashes": package_hashes, + "packageArtifactsSha256": hashlib.sha256( + json.dumps(package_hashes, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + "packageReport": str(report), + "workflowArtifacts": { + "packager": "V3.1", + "packageReport": str(report), + **{k: str(v) for k, v in paths.items()}, + }, + **{k: str(v) for k, v in paths.items()}, + } + report.write_text(json.dumps(payload), encoding="utf-8") + return payload + + with using_run(self.solution, run_id): + from sentinel_xdr_migration.converter import convert_solution + import hashlib + workflow_module.start_workflow_stage(self.solution, "discovery") + complete_workflow_stage(self.solution, "discovery", status="passed") + workflow_module.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(), + }, + ) + workflow_module.start_workflow_stage(self.solution, "validation") + complete_workflow_stage( + self.solution, + "validation", + status="passed", + artifacts={"runtimeStatus": "not-run", "validationMode": "offline-authoring"}, + ) + plan_new_package_release( + self.solution, + target_version="3.2.2", + expected_package_input_sha256=package_input_identity(self.solution)["sha256"], + ) + with mock.patch.object(workflow_module, "package_solution_v3_1", side_effect=fake_package): + package_workflow_v3_1( + self.solution, + requested_version_bump="none", + requested_target_version="3.2.2", + ) + + base_hash = package_input_identity(self.solution)["sha256"] + with mock.patch.object( + release_notes_module, + "default_release_date", + return_value="19-04-2026", + ): + refresh = plan_release_notes_refresh( + self.solution, + change_history="Default date release notes entry.", + expected_package_input_sha256=base_hash, + ) + self.assertEqual("19-04-2026", refresh["releaseDate"]) + + expected_hash = "d" * 64 + with mock.patch.object(cli_module, "configure_logging"), mock.patch.object( + cli_module, + "plan_release_notes_refresh", + return_value={"releaseDate": "19-04-2026", "packageInputSha256Before": expected_hash}, + ) as cli_refresh: + rc = cli_module.main([ + "workflow-plan-release-notes-refresh", + "--solution", str(self.solution), + "--run-id", run_id, + "--change-history", "CLI omitted release date.", + "--expected-package-input-sha256", expected_hash, + ]) + self.assertEqual(0, rc) + cli_refresh.assert_called_once_with( + str(self.solution), + release_date=None, + change_history="CLI omitted release date.", + expected_package_input_sha256=expected_hash, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/SentinelToXDRMigration/tests/test_workbook_dependencies.py b/Tools/SentinelToXDRMigration/tests/test_workbook_dependencies.py index 882e6983033..d5161a4049d 100644 --- a/Tools/SentinelToXDRMigration/tests/test_workbook_dependencies.py +++ b/Tools/SentinelToXDRMigration/tests/test_workbook_dependencies.py @@ -211,6 +211,22 @@ def test_function_prerequisite_failures_block_before_candidate_writes(solution, with pytest.raises(ValueError): convert(solution, settings) assert not authored(solution).exists() + + +def test_standard_builtin_asim_parsers_do_not_require_workspace_function_prerequisites(solution): + write_query( + solution, + "_ASim_NetworkSession | union _Im_Dns(starttime=ago(1d)) | count", + ) + plan = dependencies.plan_workbook_dependencies(solution) + row = plan["queries"][0] + assert row["workspaceFunctions"] == [] + assert row["functionPrerequisites"] == [] + assert not any( + "Unresolved workspace functions" in reason + for reason in row["proposal"]["reviewReasons"] + ) + assert not (solution / "Logs").exists() diff --git a/Tools/SentinelToXDRMigration/tests/test_workbook_workflow_integration.py b/Tools/SentinelToXDRMigration/tests/test_workbook_workflow_integration.py index f0672ca21ef..fa205fd369a 100644 --- a/Tools/SentinelToXDRMigration/tests/test_workbook_workflow_integration.py +++ b/Tools/SentinelToXDRMigration/tests/test_workbook_workflow_integration.py @@ -788,3 +788,233 @@ def test_workbook_report_keeps_excluded_inventory_outside_rule_coverage(solution assert report["rules"] == [] assert report["workbooks"]["inventoryCount"] == 1 assert report["workbooks"]["sources"][0]["selected"] is True + + +def test_lab_deployment_preflight_prompt_and_expected_content_counts(solution): + rules = solution / "Analytic Rules" + rules.mkdir() + (rules / "rule1.yaml").write_text(RULE) + state = initialize(solution) + content_pass(solution) + data_path = solution / "Data/Solution_Fixture.json" + data = json.loads(data_path.read_bytes()) + data["Analytic Rules"] = ["Analytic Rules/rule1.yaml"] + data["XDR Detections"] = [ + p.relative_to(solution).as_posix() + for p in sorted((solution / "XDR Detections").glob("*.yaml")) + ] + data_path.write_text(json.dumps(data)) + + counts = integration.expected_content_counts(solution, state) + assert counts["sentinelRulesCount"] == 1 + assert counts["sentinelWorkbooksCount"] == 1 + assert counts["defenderWorkbooksCount"] == 1 + assert counts["workbooksCount"] == 2 + assert counts["workbooksBreakdown"] == {"sentinel": 1, "defender": 1} + assert counts["customDetectionsCount"] == 1 + + preflight_info = integration.plan_lab_deployment_preflight(solution) + assert preflight_info["armValidationMethod"] == "lab-deployment" + assert preflight_info["defaultArmValidationMethod"] == "lab-deployment" + assert preflight_info["skipsArmTtk"] is True + assert preflight_info["functionalQualificationVerified"] is False + assert preflight_info["alertParityVerified"] is False + assert preflight_info["labDeploymentPreflightPrompt"] == ( + "Requesting preflight preapproval to deploy the solution ARM templates to the " + "selected non-production lab to verify ARM template deployment validity " + "(expected content items: 1 Sentinel rules, 2 workbooks, and 1 custom detections). " + "Deployment verification alone does not establish functional/runtime qualification or alert parity." + ) + persisted = json.loads((report_directory(solution) / "arm-deployment-preflight.json").read_bytes()) + assert persisted["labDeploymentPreflightPrompt"] == preflight_info["labDeploymentPreflightPrompt"] + assert persisted["expectedContentCounts"] == counts + status = workflow.workflow_status(solution) + assert status["workbookSummary"]["expectedContentCounts"] == counts + assert status["workbookSummary"]["labDeploymentPreflightPrompt"] == preflight_info["labDeploymentPreflightPrompt"] + + +def test_default_lab_deployment_vs_explicit_arm_ttk_and_durable_deployment_findings(ready_candidate): + root, package, candidate = ready_candidate + assert integration.default_arm_validation_method("authoring") == "lab-deployment" + assert integration.default_arm_validation_method("qualification") == "lab-deployment" + assert candidate["expectedContentCounts"]["workbooksCount"] == 2 + assert "expected content items: 0 Sentinel rules, 2 workbooks, and 0 custom detections" in candidate["labDeploymentPreflightPrompt"] + + plan = integration.plan_candidate(root, target=TARGET, selected_flags={"DeployDefenderWorkbook": True}) + assert (report_directory(root) / "arm-deployment-preflight.json").is_file() + integration.review_scope( + root, actor="owner", reference="scope-review", approved=True, + expected_plan_sha256=plan["planSha256"], + ) + failing_runner = mock.Mock( + return_value=( + 1, + {"id": "fixture-failed-deployment", "properties": {"provisioningState": "Failed"}}, + "DeploymentFailed: InvalidTemplateDeployment", + ) + ) + failed_result = integration.execute_candidate( + root, + expected_plan_sha256=plan["planSha256"], + actor="owner", + reference="execute-failure-test", + confirmation=validation.LAB_ARM_CONFIRMATION, + identity_check=lambda target: ACCESS, + command_runner=failing_runner, + ) + assert failed_result["status"] == "failed" + assert failed_result["functionalQualificationVerified"] is False + assert failed_result["alertParityVerified"] is False + assert failed_result["artifactBindings"]["candidateSha256"] == candidate["candidateSha256"] + assert failed_result["artifactBindings"]["planSha256"] == plan["planSha256"] + assert failed_result["artifactBindings"]["mainTemplateSha256"] == plan["mainTemplateSha256"] + assert any("DeploymentFailed" in item["message"] for item in failed_result["findings"]) + + evidence_path = report_directory(root) / "arm-deployment-validation-evidence.json" + assert evidence_path.is_file() + durable = json.loads(evidence_path.read_bytes()) + assert durable["outcome"] == "failed" + assert durable["functionalQualificationVerified"] is False + assert durable["alertParityVerified"] is False + assert durable["target"] == plan["target"] + assert durable["artifactBindings"]["packageArtifactsSha256"] == package["packageArtifactsSha256"] + state = workflow._load(root)[1] + assert state["stages"]["packaging"]["artifacts"]["labArmDeploymentOutcome"] == "failed" + assert state["stages"]["packaging"]["artifacts"]["functionalQualificationVerified"] == "false" + assert state["stages"]["packaging"]["artifacts"]["alertParityVerified"] == "false" + + +def test_assess_qualification_readiness_separates_deployment_and_ingestion(solution): + state = workflow.initialize_workflow( + solution, + workflow_profile="qualification", + workbook_capability_version=1, + version_bump="none", + tenant_id=TARGET["tenantId"], + subscription_id=TARGET["subscriptionId"], + workspace_resource_id=TARGET["workspaceResourceId"], + workspace_customer_id=TARGET["workspaceCustomerId"], + ) + run_dir = report_directory(solution) + # Initial state without consent or preflight receipts + initial_readiness = integration.assess_qualification_readiness(solution, run_id=state["runId"]) + assert initial_readiness["status"] == "blocked" + assert initial_readiness["deploymentAndIngestionSeparated"] is True + assert initial_readiness["authorizationChecks"]["targetLocked"]["status"] == "ready" + assert initial_readiness["authorizationChecks"]["runtimeQueryConsent"]["status"] == "missing" + assert initial_readiness["authorizationChecks"]["armPreflightReadConsent"]["status"] == "missing" + assert initial_readiness["authorizationChecks"]["deploymentConsent"]["status"] == "missing" + assert initial_readiness["deploymentPermissionReadiness"]["status"] == "not-evaluated" + assert initial_readiness["ingestionPermissionReadiness"]["status"] == "not-evaluated" + + # Populate consents and ready ARM preflight, but leave DCR/ingestion blocked + (run_dir / "runtime-query-consent.json").write_text(json.dumps({ + "status": "approved", + "withdrawn": False, + "approvedFamilies": ["sentinelSourceQueryFamily", "defenderXdrQueryFamily"], + })) + (run_dir / "arm-preflight-consent.json").write_text(json.dumps({ + "status": "approved", + "revoked": False, + })) + (run_dir / "arm-validation-consent.json").write_text(json.dumps({ + "status": "approved", + "withdrawn": False, + "deploymentOnly": True, + })) + (run_dir / "arm-preflight-result.json").write_text(json.dumps({ + "status": "ready", + "tenantMatches": True, + "subscriptionMatches": True, + "workspaceMatches": True, + "nonProduction": True, + "armDeploymentAllowed": True, + "disabledContentVerified": True, + "ownershipVerified": True, + "extensionResourceWriteAllowed": False, + "checks": { + "extensionResourceWriteAllowed": {"status": "not-required", "required": False}, + }, + "reasons": [], + })) + (run_dir / "qualification-dcr-preflight.json").write_text(json.dumps({ + "status": "blocked", + "summary": {"total": 1, "ready": 0, "blocked": 1}, + "candidates": [{ + "table": "Syslog", + "tableSchemaCompatibility": {"status": "compatible"}, + "resourceProvisioningAuthorization": {"status": "ready"}, + "ingestionAuthorization": {"status": "denied", "requiredAction": "Microsoft.Insights/Telemetry/Write"}, + }], + "blockers": ["Logs Ingestion permission Microsoft.Insights/Telemetry/Write is denied"], + })) + + split_readiness = integration.assess_qualification_readiness(solution, run_id=state["runId"]) + assert split_readiness["authorizationChecks"]["status"] == "ready" + assert split_readiness["deploymentPermissionReadiness"]["status"] == "ready" + assert split_readiness["deploymentPermissionReadiness"]["ready"] is True + assert split_readiness["deploymentPermissionReadiness"]["impliesIngestionReadiness"] is False + assert split_readiness["ingestionPermissionReadiness"]["status"] == "blocked" + assert split_readiness["ingestionPermissionReadiness"]["ready"] is False + assert split_readiness["ingestionPermissionReadiness"]["dcrAndDceReadiness"]["status"] == "ready" + assert split_readiness["ingestionPermissionReadiness"]["logsIngestionReadiness"]["status"] == "blocked" + assert split_readiness["status"] == "blocked" + + # Now mark Logs Ingestion ready and verify overall readiness + (run_dir / "qualification-dcr-preflight.json").write_text(json.dumps({ + "status": "ready", + "summary": {"total": 1, "ready": 1, "blocked": 0}, + "candidates": [{ + "table": "Syslog", + "tableSchemaCompatibility": {"status": "compatible"}, + "resourceProvisioningAuthorization": {"status": "ready"}, + "ingestionAuthorization": {"status": "ready", "requiredAction": "Microsoft.Insights/Telemetry/Write"}, + }], + "blockers": [], + })) + full_readiness = integration.assess_qualification_readiness(solution, run_id=state["runId"]) + assert full_readiness["status"] == "ready" + assert full_readiness["deploymentPermissionReadiness"]["ready"] is True + assert full_readiness["ingestionPermissionReadiness"]["ready"] is True + + +def test_audit_private_config_and_support_export(solution): + from sentinel_xdr_migration.evidence import export_evidence + + (solution / ".env").write_text( + f"AZURE_TENANT_ID={TARGET['tenantId']}\n" + f"SENTINEL_WORKSPACE_RESOURCE_ID={TARGET['workspaceResourceId']}\n", + encoding="utf-8", + ) + state = workflow.initialize_workflow( + solution, + workflow_profile="qualification", + workbook_capability_version=1, + version_bump="none", + tenant_id=TARGET["tenantId"], + subscription_id=TARGET["subscriptionId"], + workspace_resource_id=TARGET["workspaceResourceId"], + workspace_customer_id=TARGET["workspaceCustomerId"], + ) + export_evidence(solution) + + clean_audit = integration.audit_private_config_and_support_export(solution, run_id=state["runId"]) + assert clean_audit["status"] == "isolated" + assert clean_audit["privateConfigIsolatedToLocalUnexportedConfig"] is True + assert clean_audit["supportExportsRedacted"] is True + assert clean_audit["exposureClaimVerified"] is False + assert clean_audit["localConfig"]["envFilePresent"] is True + assert ".env" in clean_audit["localConfig"]["localUnexportedArtifacts"] + assert clean_audit["supportExports"]["checkedFiles"] + assert clean_audit["supportExports"]["exposures"] == [] + + # Simulate an unredacted private identifier leak in support export + summary_md = solution / "Evidence" / state["runId"] / "summary.md" + summary_md.write_text(summary_md.read_text(encoding="utf-8") + f"\nLeaked tenant: {TARGET['tenantId']}\n", encoding="utf-8") + leaked_audit = integration.audit_private_config_and_support_export(solution, run_id=state["runId"]) + assert leaked_audit["status"] == "exposed-in-support-export" + assert leaked_audit["privateConfigIsolatedToLocalUnexportedConfig"] is False + assert leaked_audit["supportExportsRedacted"] is False + assert leaked_audit["exposureClaimVerified"] is True + assert any(item["identifierKind"] == "tenantId" for item in leaked_audit["supportExports"]["exposures"]) + diff --git a/Tools/SentinelToXDRMigration/tests/test_workflow.py b/Tools/SentinelToXDRMigration/tests/test_workflow.py index bd89f574744..b38ca87588d 100644 --- a/Tools/SentinelToXDRMigration/tests/test_workflow.py +++ b/Tools/SentinelToXDRMigration/tests/test_workflow.py @@ -4000,6 +4000,486 @@ def test_cli_yolo_is_explicit_and_requires_an_exact_run_id(self) -> None: self.assertTrue(export.call_args.kwargs["authoring_contract"]["inventory"]["complete"]) self.assertEqual({"exported": True}, json.loads(stdout.getvalue())) + def test_workflow_status_and_reports_surface_next_step_blocker_recommendations(self) -> None: + from sentinel_xdr_migration.report import ( + render_runtime_validation_report, + render_transformation_report, + summarize_report_blockers, + ) + + 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="next-step-campaign", + ) + with using_run(self.solution, initialized["runId"]): + run_yolo_authoring(self.solution) + status = workflow_status(self.solution) + next_stage = next_workflow_stage(self.solution) + + self.assertIn("nextStepRecommendation", status) + self.assertIn("nextBlockerReview", status) + self.assertEqual(status["nextStepRecommendation"], status["nextBlockerReview"]) + self.assertEqual( + status["nextStepRecommendation"]["summary"], + next_stage["nextStepRecommendation"]["summary"], + ) + rec = status["nextStepRecommendation"] + self.assertGreaterEqual(rec["blockerCount"], 1) + self.assertTrue(rec["topBlockers"]) + self.assertIn("Next migration step: review blocker", rec["summary"]) + self.assertTrue( + any(b["category"] == "detectionReview" for b in rec["topBlockers"]) + ) + + # Verify report.py blocker summary and HTML rendering + manifest = { + "solution": str(self.solution), + "total": 2, + "converted": 1, + "needsReview": 1, + "reviewRequired": 1, + "conflicts": 1, + "results": [ + { + "ruleId": str(source["id"]), + "displayName": source["name"], + "status": "needsReview", + "reviewRequired": True, + "source": "Analytic Rules/Nested/Rule0.yaml", + "output": "XDR Detections/Nested/Rule0.yaml", + "reviewReasons": ["Multi-entity alert grouping assumption requires review"], + "warnings": [], + "errors": [], + }, + { + "ruleId": "rule-2", + "displayName": "Second Rule", + "status": "conflict", + "reviewRequired": False, + "source": "Analytic Rules/Nested/Rule1.yaml", + "output": "XDR Detections/Nested/Rule1.yaml", + "reviewReasons": [], + "warnings": [], + "errors": ["Missing required property"], + }, + ], + } + report_summary = summarize_report_blockers(manifest, kind="transformation") + self.assertEqual(2, report_summary["blockerCount"]) + self.assertIn("Next migration step: review blockers", report_summary["summary"]) + html = render_transformation_report(manifest) + self.assertIn("next-step-banner", html) + self.assertIn("Next migration step: review blockers", html) + + runtime_html = render_runtime_validation_report({ + "solution": str(self.solution), + "platform": "Microsoft Graph Security Hunting API", + "provider": "graph", + "total": 1, + "valid": 0, + "invalid": 1, + "passed": 0, + "failed": 1, + "blocked": 0, + "results": [ + { + "detection": "XDR Detections/Nested/Rule0.yaml", + "status": "failed", + "error": "Semantic error in query", + } + ], + }) + self.assertIn("next-step-banner", runtime_html) + self.assertIn("Next migration step: review blocker", runtime_html) + + def test_consolidated_review_packet_inventories_detections_and_workbooks_and_cli(self) -> None: + from sentinel_xdr_migration.workflow import build_consolidated_review_packet + + self._ensure_solution_release() + source_path = self._create_source_rules()[0] + source = yaml.safe_load(source_path.read_text()) + source["description"] = "A" * 620 + " truncated description tail." + source["queryFrequency"] = "5m" + source["queryPeriod"] = "2d" + source["query"] = ( + "CustomUnsupportedTable_CL | 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") + + # Add a workbook with an unsupported query table so workbookQueryGaps are populated + workbooks_dir = self.solution / "Workbooks" + workbooks_dir.mkdir(parents=True, exist_ok=True) + (workbooks_dir / "SampleWorkbook.json").write_text( + json.dumps({ + "version": "Notebook/1.0", + "items": [ + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "CustomWorkbookTable_CL | take 10", + "size": 0, + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + }, + "name": "query - 0", + } + ], + }), + encoding="utf-8", + ) + + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + yolo=True, + campaign_id="review-packet-campaign", + ) + # Write a migration-config.yaml with a prior rule override decision inside the run directory + with using_run(self.solution, initialized["runId"]): + run_yolo_authoring(self.solution) + config_path = artifact_path(self.solution, "migration-config.yaml") + config_path.write_text( + yaml.safe_dump({ + "ruleOverrides": { + str(source["id"]): { + "description": "Reviewed concise description.", + } + } + }), + encoding="utf-8", + ) + packet = build_consolidated_review_packet(self.solution) + + self.assertEqual("1.0.0", packet["schemaVersion"]) + self.assertEqual("consolidated-review-packet", packet["kind"]) + self.assertEqual(64, len(packet["packetSha256"])) + self.assertGreaterEqual(packet["summary"]["totalItems"], 1) + self.assertTrue(packet["categories"]["timing"]) + self.assertTrue(packet["categories"]["entityMappings"]) + self.assertTrue(packet["categories"]["truncatedDescriptions"]) + self.assertTrue(packet["categories"]["dependencyGaps"]) + self.assertTrue(packet["categories"]["workbookQueryGaps"]) + self.assertTrue(packet["groupedFindings"]) + self.assertIn("nextStepRecommendation", packet) + self.assertIn("Next migration step: review blocker", packet["nextStepRecommendation"]["summary"]) + + # Verify preserved prior decision on the truncated description item + trunc_item = packet["categories"]["truncatedDescriptions"][0] + self.assertEqual("decided", trunc_item["decisionState"]) + self.assertEqual( + "Reviewed concise description.", + trunc_item["priorDecision"]["description"], + ) + + # Verify CLI review-packet and consolidated-review-plan subcommands + output_file = self.solution / "review-packet-out.json" + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + rc = cli_module.main([ + "review-packet", + "--solution", str(self.solution), + "--run-id", initialized["runId"], + "--output", str(output_file), + ]) + self.assertEqual(0, rc, stderr.getvalue()) + cli_packet = json.loads(stdout.getvalue()) + self.assertEqual(packet["packetSha256"], cli_packet["packetSha256"]) + self.assertTrue(output_file.is_file()) + written_packet = json.loads(output_file.read_text(encoding="utf-8")) + self.assertEqual(packet["packetSha256"], written_packet["packetSha256"]) + + def test_status_reminder_emits_diagram_batch_explanation_and_suppresses_no_change(self) -> None: + from sentinel_xdr_migration.workflow import build_migration_status_reminder + + self._ensure_solution_release() + self._create_source_rules(count=15) + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + ) + run_id = initialized["runId"] + with using_run(self.solution, run_id): + start_workflow_stage(self.solution, "discovery") + complete_workflow_stage(self.solution, "discovery", status="passed") + start_workflow_stage(self.solution, "conversion") + + first = build_migration_status_reminder(self.solution, run_id=run_id) + self.assertFalse(first["suppressedNoChange"]) + self.assertIsNone(first["previousStateFingerprint"]) + self.assertEqual(64, len(first["stateFingerprint"])) + self.assertIn( + "Applying reviewed decisions or converting detections before validation", + first["currentActivity"], + ) + self.assertIn("[> Conversion <]", first["workflowDiagram"]) + self.assertIn("[Discovery]", first["workflowDiagram"]) + self.assertIsNotNone(first["durationExplanation"]) + self.assertIn("15 source analytic rules", first["durationExplanation"]) + self.assertEqual( + { + "readOnlyObserver": True, + "createsExecutor": False, + "singleOwnerRespected": True, + }, + first["watchdogCoordination"], + ) + self.assertEqual(15, first["itemCounts"]["analyticRules"]) + + # Repeat call without state change suppresses repetitive notice + second = build_migration_status_reminder(self.solution, run_id=run_id) + self.assertTrue(second["suppressedNoChange"]) + self.assertEqual(first["stateFingerprint"], second["stateFingerprint"]) + self.assertEqual(first["stateFingerprint"], second["previousStateFingerprint"]) + + # State change unsuppresses reminder and updates diagram + complete_workflow_stage(self.solution, "conversion", status="passed") + start_workflow_stage(self.solution, "validation") + third = build_migration_status_reminder(self.solution, run_id=run_id) + self.assertFalse(third["suppressedNoChange"]) + self.assertNotEqual(first["stateFingerprint"], third["stateFingerprint"]) + self.assertEqual(first["stateFingerprint"], third["previousStateFingerprint"]) + self.assertIn("[> Validation <]", third["workflowDiagram"]) + + # Verify CLI workflow-status-reminder subcommand + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + rc = cli_module.main([ + "workflow-status-reminder", + "--solution", str(self.solution), + "--run-id", run_id, + ]) + self.assertEqual(0, rc, stderr.getvalue()) + cli_reminder = json.loads(stdout.getvalue()) + self.assertTrue(cli_reminder["suppressedNoChange"]) + self.assertEqual(third["stateFingerprint"], cli_reminder["stateFingerprint"]) + + def test_execution_ownership_enforces_single_owner_and_cli(self) -> None: + from sentinel_xdr_migration.workflow import ( + claim_execution_ownership, + release_execution_ownership, + ) + + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + ) + run_id = initialized["runId"] + + claimed = claim_execution_ownership( + self.solution, + owner_id="coordinator-alpha", + operation="convert-and-validate", + scope={"rules": 7, "workbooks": 1}, + run_id=run_id, + ) + self.assertEqual("active", claimed["status"]) + self.assertTrue(claimed["singleOwnerEnforced"]) + self.assertEqual("coordinator-alpha", claimed["ownerId"]) + self.assertEqual(0, claimed["renewedCount"]) + + # Same owner renewing is permitted + renewed = claim_execution_ownership( + self.solution, + owner_id="coordinator-alpha", + operation="convert-and-validate", + scope={"rules": 7, "workbooks": 1}, + run_id=run_id, + ) + self.assertEqual("active", renewed["status"]) + self.assertEqual(1, renewed["renewedCount"]) + + # Conflicting second owner attempting to claim or release the same operation is blocked + with self.assertRaisesRegex(ValueError, "duplicate_executor_blocked"): + claim_execution_ownership( + self.solution, + owner_id="duplicate-executor-beta", + operation="convert-and-validate", + run_id=run_id, + ) + with self.assertRaisesRegex(ValueError, "duplicate_executor_blocked"): + release_execution_ownership( + self.solution, + owner_id="duplicate-executor-beta", + operation="convert-and-validate", + run_id=run_id, + ) + + # Original owner releases lease; another owner can now claim via CLI + released = release_execution_ownership( + self.solution, + owner_id="coordinator-alpha", + operation="convert-and-validate", + run_id=run_id, + ) + self.assertEqual("released", released["status"]) + self.assertIsNotNone(released["releasedAtUtc"]) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + rc = cli_module.main([ + "workflow-execution-owner", + "--solution", str(self.solution), + "--run-id", run_id, + "--action", "claim", + "--owner-id", "coordinator-beta", + "--operation", "convert-and-validate", + "--scope", json.dumps({"rules": 7}), + ]) + self.assertEqual(0, rc, stderr.getvalue()) + cli_claimed = json.loads(stdout.getvalue()) + self.assertEqual("active", cli_claimed["status"]) + self.assertEqual("coordinator-beta", cli_claimed["ownerId"]) + + def test_forward_and_verify_explicit_approval_rejects_tampering_and_wrong_owner(self) -> None: + from sentinel_xdr_migration.workflow import ( + forward_explicit_approval, + verify_forwarded_approval, + ) + + initialized = initialize_workflow( + self.solution, + workflow_profile="authoring", + ) + run_id = initialized["runId"] + exact_scope = { + "validationMode": "lab-deployment", + "sentinelRuleCount": 7, + "workbookCount": 1, + "customDetectionCount": 7, + "packageInputSha256": "a" * 64, + } + + forwarded = forward_explicit_approval( + self.solution, + operation="preflight-lab-deployment", + approved_by="operator-user", + forwarded_to_owner_id="solution-owner-1", + exact_scope=exact_scope, + run_id=run_id, + reference="chat-turn-42", + ) + self.assertEqual("active", forwarded["status"]) + approval_sha = forwarded["approvalSha256"] + self.assertEqual(64, len(approval_sha)) + + # Exact match passes verification + verified = verify_forwarded_approval( + self.solution, + owner_id="solution-owner-1", + operation="preflight-lab-deployment", + exact_scope=exact_scope, + approval_sha256=approval_sha, + run_id=run_id, + ) + self.assertTrue(verified["verified"]) + self.assertEqual(approval_sha, verified["approvalSha256"]) + + # Wrong owner rejected + with self.assertRaisesRegex(ValueError, "forwarded_approval_owner_mismatch"): + verify_forwarded_approval( + self.solution, + owner_id="wrong-owner-2", + operation="preflight-lab-deployment", + exact_scope=exact_scope, + approval_sha256=approval_sha, + run_id=run_id, + ) + + # Scope broadening / tampering rejected + tampered_scope = dict(exact_scope, customDetectionCount=8) + with self.assertRaisesRegex(ValueError, "forwarded_approval_scope_mismatch"): + verify_forwarded_approval( + self.solution, + owner_id="solution-owner-1", + operation="preflight-lab-deployment", + exact_scope=tampered_scope, + approval_sha256=approval_sha, + run_id=run_id, + ) + + # Altered hash rejected + with self.assertRaisesRegex(ValueError, "forwarded_approval_hash_mismatch"): + verify_forwarded_approval( + self.solution, + owner_id="solution-owner-1", + operation="preflight-lab-deployment", + exact_scope=exact_scope, + approval_sha256="f" * 64, + run_id=run_id, + ) + + # CLI forward + verify round-trip + cli_scope = {"target": "workbook-review", "count": 1} + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + rc_fwd = cli_module.main([ + "workflow-forward-approval", + "--solution", str(self.solution), + "--run-id", run_id, + "--action", "forward", + "--operation", "workbook-dependency-apply", + "--approved-by", "operator-user", + "--owner-id", "solution-owner-1", + "--exact-scope", json.dumps(cli_scope), + ]) + self.assertEqual(0, rc_fwd, stderr.getvalue()) + cli_fwd_payload = json.loads(stdout.getvalue()) + + stdout, stderr = io.StringIO(), io.StringIO() + with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): + rc_ver = cli_module.main([ + "workflow-forward-approval", + "--solution", str(self.solution), + "--run-id", run_id, + "--action", "verify", + "--operation", "workbook-dependency-apply", + "--owner-id", "solution-owner-1", + "--exact-scope", json.dumps(cli_scope), + "--approval-sha256", cli_fwd_payload["approvalSha256"], + ]) + self.assertEqual(0, rc_ver, stderr.getvalue()) + self.assertTrue(json.loads(stdout.getvalue())["verified"]) + if __name__ == "__main__": unittest.main()