diff --git a/.gitattributes b/.gitattributes index b167511f..b8b89ff2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,14 @@ /config/security_rules.json text eol=lf /config/eid_subcategory_mapping.csv text eol=lf /config/rule_eligibility_manifest.json text eol=lf + +# Exact-context default evidence pins these source/collector bytes. +/config/baselines.json text eol=lf +/config/audit_profiles.json text eol=lf +/config/control_applicability.json text eol=lf +/scripts/ControlApplicability.ps1 text eol=lf +/scripts/Configuration.ps1 text eol=lf +/modules/NativeProviders.psm1 text eol=lf +/modules/AuditProfiles.psm1 text eol=lf # Full upstream rule artifacts retain their exact pinned bytes on every platform. /config/provider_rule_sources/*.yml -text whitespace=-blank-at-eol diff --git a/.github/workflows/control-applicability.yml b/.github/workflows/control-applicability.yml new file mode 100644 index 00000000..f53fba1f --- /dev/null +++ b/.github/workflows/control-applicability.yml @@ -0,0 +1,29 @@ +name: Control applicability and default evidence +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + control-applicability: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Fixtures on Windows PowerShell 5.1 + shell: powershell + run: ./tests/ControlApplicability.Tests.ps1 + - name: Native read-only capture on Windows PowerShell 5.1 + shell: powershell + run: ./tests/ControlApplicability.Windows.Tests.ps1 + - name: Fixtures on PowerShell 7 + shell: pwsh + run: ./tests/ControlApplicability.Tests.ps1 + - name: Native read-only capture on PowerShell 7 + shell: pwsh + run: ./tests/ControlApplicability.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 29fbd4a9..78e81488 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 読み取り専用の`control-applicability`を追加し、旧CISのApplication Guard監査要件を保持しつつ、Windows 11 24H2以降では削除済み・対象外と表示します。`default-evidence`で正確なビルド・パッチ・ドメイン参加・役割を含む現状を記録し、出典とレビュー情報が一致する参照環境と比較できます。旧ベースラインの既定値は履歴情報として保持し、未検証の既定値はUnknownと表示します。合成テストと読み取り専用CIからクリーンインストールやイベント生成の証明は行いません。 (#409) (@Shirofune-Security) - Windows標準のDNS Client/Server、CAPI2、WinRM、RDP Clientについて、明示的に選択するprovider-packsの監査・計画とチャネル設定を追加しました。固定した完全なルール定義と実際のプロバイダー・チャネル・イベントスキーマを確認し、DNSのチャネル名不一致や不明な前提条件を保持します。復旧記録付きの変更では大きいバッファ・保持方式・ACLを維持し、Analyticalと従来のDNSログは手動確認のみとします。イベント生成・バックエンド検証や検知範囲の向上は未確認です。 (#411) (@Shirofune-Security) - 任意実行の`audit-notifications`を追加し、OneSettings監査とSecurityログ警告しきい値の監査・計画・設定に対応しました。対象とチャネル変更の明示指定、OS・ADMX確認、型付き復旧記録と変更検出を行い、既存の低いしきい値とチャネルACL・保存方式を保持します。ポリシー一致と実イベント生成を分け、Windowsラボの証拠とSigma利用可能性は未検証と表示します。 (#408) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6311d16..27169238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added read-only `control-applicability` for the historical CIS Application Guard audit requirement, reporting removal on Windows 11 24H2+ without remediation. Added exact build/patch/join/role native snapshots and provenance-bound reference comparison through `default-evidence`. Legacy baseline default strings are retained as historical hints while public defaults remain Unknown without reviewed scenario evidence. Synthetic fixtures and native read-only CI do not claim clean-install or event-generation proof. (#409) (@Shirofune-Security) - Added explicit native DNS Client/Server, CAPI2, WinRM and RDP Client provider-pack inventory and selective channel configuration. Pinned full rule definitions and live provider/channel/event schemas retain DNS channel mismatches and unknown prerequisites; journaled opt-in changes preserve larger buffers, retention and ACLs. Analytical/classic DNS remain manual-only, and no event/backend readiness uplift is claimed. (#411) (@Shirofune-Security) - Added opt-in `audit-notifications` audit/plan/configure for OneSettings auditing and Security log warning thresholds, with explicit control/channel selections, reviewed host and ADMX gates, typed recovery journals and drift checks. Earlier warning thresholds and channel ACL/retention are preserved. Reports separate policy matches from warning/event generation; Windows lab evidence and Sigma eligibility remain unverified. (#408) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 377da37c..333c6f19 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -49,6 +49,8 @@ [ValidateRange(1,2147483647)][int]$LdapSearchTimeMs, [ValidateRange(1,2147483647)][int]$LdapExpensiveThreshold, [ValidateRange(1,2147483647)][int]$LdapInefficientThreshold, + [ValidateSet('Capture','Compare')][string]$DefaultEvidenceAction = 'Capture', + [string]$DefaultEvidencePath, [ValidateSet('List','Audit','Plan','Configure')][string]$ProviderAction = 'List', [string[]]$ProviderPack, [ValidateSet('Audit','Plan','Configure')][string]$NotificationAction = 'Audit', @@ -72,6 +74,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") . (Join-Path $ScriptRoot "scripts/LdapDiagnostics.ps1") +. (Join-Path $ScriptRoot "scripts/ControlApplicability.ps1") . (Join-Path $ScriptRoot "scripts/AuditNotifications.ps1") . (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") @@ -107,7 +110,9 @@ class WELA { [array] $NativeSources = @() [array] $Rules [hashtable] $RulesCount - [string] $DefaultSetting = "" + [string] $DefaultSetting = "Unknown" + [string] $LegacyDefaultHint = "" + [string] $DefaultEvidence = "No exact-context reviewed default evidence; historical hints are not host defaults." [string] $RecommendedSetting = "" [string] $Volume = "" [string] $Note = "" @@ -126,7 +131,8 @@ class WELA { $this.SubCategory = $SubCategory $this.CurrentSetting = $CurrentSetting $this.Rules = $Rules - $this.DefaultSetting = $DefaultSetting + $this.LegacyDefaultHint = $DefaultSetting + $this.DefaultSetting = "Unknown" $this.RecommendedSetting = $RecommendedSetting $this.Volume = $Volume $this.Note = $Note @@ -175,6 +181,7 @@ class WELA { } if ($this.DefaultSetting) { Write-Host " - Default Setting: $($this.DefaultSetting)" + Write-Host " - Default Evidence: $($this.DefaultEvidence)" } if ($this.CurrentSetting) { Write-Host " - Current Setting: $($this.CurrentSetting)" @@ -749,7 +756,7 @@ function AuditLogSetting { Write-Host "" } } elseif ($outType -eq "table") { - $auditResult | Select-Object -Property Category, SubCategory, RuleCount, DefaultSetting, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume | Format-Table + $auditResult | Select-Object -Property Category, SubCategory, RuleCount, DefaultSetting, DefaultEvidence, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume | Format-Table } # 1つのルールが複数カテゴリに属するため、集計とCSVはルールID単位で重複排除する @@ -764,7 +771,7 @@ function AuditLogSetting { $currentJson = Join-Path $script:ScriptRoot "mitre-ttp-navigator-current.json" $idealJson = Join-Path $script:ScriptRoot "mitre-ttp-navigator-ideal.json" - $auditResult | Select-Object -Property Category, SubCategory, RuleCount, RuleCountByLevel, DefaultSetting, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume, Note, + $auditResult | Select-Object -Property Category, SubCategory, RuleCount, RuleCountByLevel, DefaultSetting, DefaultEvidence, LegacyDefaultHint, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume, Note, @{ Name = 'NativeSourceEvidence'; Expression = { if ($_.NativeSources.Count) { ConvertTo-Json -InputObject $_.NativeSources -Depth 12 -Compress } else { '' } } } | Export-Csv -Path $auditCsv -NoTypeInformation $usableRules | Select-Object title, level, service, category, description, id, EligibilityState, EligibilityReasons | Export-Csv -Path $usableCsv -NoTypeInformation @@ -778,7 +785,7 @@ function AuditLogSetting { if ($outType -eq "gui") { $usableRules | Select-Object title, level, service, category, description, id | Out-GridView -Title "Usable Detection Rules" $unUsableRules | Select-Object title, level, service, category, description, id | Out-GridView -Title "Unusable Detection Rules" - $auditResult | Select-Object -Property Category, SubCategory, RuleCount, RuleCountByLevel, DefaultSetting, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume, Note | Out-GridView -Title "WELA Audit Result" + $auditResult | Select-Object -Property Category, SubCategory, RuleCount, RuleCountByLevel, DefaultSetting, DefaultEvidence, LegacyDefaultHint, CurrentSetting, ChannelState, GenerationReadiness, RecommendedSetting, Volume, Note | Out-GridView -Title "WELA Audit Result" } Write-Output "Audit check result saved to: $auditCsv" @@ -1791,6 +1798,8 @@ Usage: ./WELA.ps1 configure-sacl # Add targeted File System/Registry audit SACLs (ASEP keys + sensitive files) needed by the rules, without global auditing ./WELA.ps1 configure-sacl -Auto # ...automatically without prompts ./WELA.ps1 update-rules # Update rule config files from https://github.com/Yamato-Security/WELA + ./WELA.ps1 control-applicability # Read-only historical native feature/build assessment + ./WELA.ps1 default-evidence -Help # Exact-context observed snapshots and reviewed reference comparison ./WELA.ps1 audit-notifications -Help # OneSettings audit and Security warning policy ./WELA.ps1 version # Show the WELA version ./WELA.ps1 help # Show this help @@ -1803,6 +1812,10 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +if ($Cmd -ne 'default-evidence' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('DefaultEvidenceAction','DefaultEvidencePath') }).Count) { + throw 'Default evidence options require default-evidence. No command was run.' +} + if ($Cmd -eq 'audit-notifications' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','NotificationAction','NotificationControl','WarningPercent','EnablePrivacyChannel','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) { throw 'audit-notifications accepts only notification, consent/dry-run, recovery and JSON output options. No command was run.' } @@ -1881,6 +1894,23 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi } switch ($Cmd.ToLower()) { + 'control-applicability' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 control-applicability [-ResultsPath report.json]. Read-only historical feature/build assessment; see docs/control-applicability.md.'; return } + if ($Profile -or $Baseline -or $Role -or $Build -or $HtmlPath -or $Auto -or $BackupPath -or $PlanPath) { throw 'control-applicability reads actual local context and only accepts -ResultsPath; configuration and context overrides are unsupported.' } + $context=Get-WelaDefaultContext + $controls=@(Get-WelaHistoricalControls -Context $context) + $report=[pscustomobject]@{Scope='Native historical controls; Sysmon excluded';Context=$context;Controls=$controls;Catalog=(Get-WelaControlCatalog)} + if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } + $report + if (@($controls | Where-Object {$_.Applicability.Status -eq 'Unknown' -or $_.PolicyState -eq 'Unknown'}).Count) { exit 1 } + } + 'default-evidence' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 default-evidence [-DefaultEvidenceAction Capture|Compare] [-DefaultEvidencePath reviewed-snapshot.json] [-ResultsPath report.json]. Capture observes current settings and never labels them as defaults. See docs/control-applicability.md.'; return } + if ($Profile -or $Baseline -or $Role -or $Build -or $HtmlPath -or $Auto -or $BackupPath -or $PlanPath) { throw 'default-evidence reads actual local context and accepts only evidence/output options.' } + $report=Invoke-WelaDefaultEvidenceCommand -Action $DefaultEvidenceAction -ReferencePath $DefaultEvidencePath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } 'audit-notifications' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 audit-notifications [-NotificationAction Audit|Plan|Configure] [-NotificationControl OneSettings,SecurityWarning] [-WarningPercent 1..90] [-EnablePrivacyChannel] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. See docs/audit-notifications.md.'; return } if ($Profile -or $Baseline -or $Role -or $Build -or $HtmlPath) { throw 'audit-notifications uses actual host context and -ResultsPath; profile/role/build overrides and HTML are unsupported.' } diff --git a/config/control_applicability.json b/config/control_applicability.json new file mode 100644 index 00000000..1503adbe --- /dev/null +++ b/config/control_applicability.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "reviewedOn": "2026-09-19", + "scope": "Historical native controls; not a replacement for command-specific applicability checks", + "controls": [ + { + "id": "application-guard-auditing", + "title": "Application Guard audit events", + "productTypes": [1], + "minBuild": 22000, + "maxBuild": 26099, + "reviewedBuilds": [22000, 22621, 22631], + "editions": ["Professional", "ProfessionalN", "ProfessionalEducation", "ProfessionalEducationN", "Enterprise", "EnterpriseN", "Education", "EducationN"], + "feature": "Windows-Defender-ApplicationGuard", + "registryPath": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\AppHVSI", + "valueName": "AuditApplicationGuard", + "requiredType": "DWord", + "requiredValue": 1, + "source": { + "title": "CIS Microsoft Windows 11 Enterprise Benchmark", + "version": "4.0.0 (historical)", + "control": "18.10.44.1", + "requirement": "Enabled", + "url": "https://www.cisecurity.org/benchmark/microsoft_windows_desktop" + }, + "removal": { + "firstBuild": 26100, + "release": "Windows 11 24H2", + "url": "https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/microsoft-defender-application-guard/md-app-guard-overview" + } + } + ] +} diff --git a/docs/control-applicability.md b/docs/control-applicability.md new file mode 100644 index 00000000..a3921e8e --- /dev/null +++ b/docs/control-applicability.md @@ -0,0 +1,122 @@ +# Historical controls and Windows default evidence + +These read-only commands address issue #385. Sysmon is out of scope. They do not +install optional features, modify registry/audit policy or grant Sigma eligibility. + +```powershell +./WELA.ps1 control-applicability -ResultsPath applicability.json +./WELA.ps1 default-evidence -ResultsPath observed.json +./WELA.ps1 default-evidence -DefaultEvidenceAction Compare -DefaultEvidencePath reviewed-clean-install.json -ResultsPath comparison.json +``` + +Run in 64-bit PowerShell. Complete native role/feature and effective audit-policy +reads can require elevation. Context is always observed locally; `-Role`/`-Build` +overrides and configuration options are rejected. A partial capture preserves +successful observations and reports failed ones as Unknown, returning a nonzero +exit code. Failed reference qualification also returns nonzero. + +## Application Guard + +The versioned `config/control_applicability.json` retains CIS Windows 11 Enterprise +v4.0.0 control 18.10.44.1 and its historical `AuditApplicationGuard=1` requirement. +It records a Windows 11 minimum build (22000), maximum pre-removal build (26099), +reviewed releases (22000/22621/22631), editions, optional feature and source/removal +metadata. It is an initial historical-control catalog; existing advanced audit +profiles and SMB/provider commands keep their own applicability checks. + +Microsoft removed Application Guard starting Windows 11 24H2 (build 26100). +24H2/25H2 and later client builds therefore report NotApplicable without querying +or attempting to reinstall it, even if stale registry or feature metadata remains. +Servers, including DC/AD CS, are outside this historical client source scope. +On reviewed older builds, an enabled `Windows-Defender-ApplicationGuard` feature +allows read-only auditing of the typed registry value. Disabled/absent features +are NotApplicable; unreviewed builds/editions, inaccessible or pending feature +states remain Unknown. The original source requirement is always retained. +There is no automatic historical remediation, and an enabled feature is not proof +of application readiness or event generation. + +## Default column correction + +The existing baseline JSON contains historical default strings without sufficient +build/patch/role provenance. Public audit assessments now show `DefaultSetting` +as Unknown and retain those strings separately as `LegacyDefaultHint` in CSV, +JSON, HTML and GUI output. `DefaultEvidence` explains the distinction. Standard +output likewise explains why the default is Unknown. Recommendations/current +observations are unchanged. The versioned `windows-defaults-*` advanced-policy +profiles remain explicitly documentary reference profiles, not clean-machine +snapshots. This PR does not replace those reference profiles or fabricate lab data. + +To use a reviewed scenario snapshot, run the dedicated `default-evidence Compare` +command. It does not silently rewrite normal audit columns or baseline metadata. +Use a separate `ResultsPath`: a comparison refuses an output path that resolves +to its `DefaultEvidencePath`, preserving the reviewed input artifact. +The comparison reports a per-control default/reference and match/difference only +when reference provenance and exact context qualify; other defaults stay Unknown. +Channel observations retain individual channel names and states; registry records +retain each path, type, value and absence. They are not collapsed to assumed defaults. + +## Capturing and reviewing reference scenarios + +Capture records the exact Windows build and UBR patch, edition, localized OS +architecture and independently observed `Win32_Processor.Architecture` platform +code (x64 = 9, ARM64 = 12), +product type, domain role, join state/domain, and a complete installed feature/role +inventory. A CA role or DC promotion creates a distinct scenario rather than a +universal "Server default". It includes current typed registry state, native channel +metadata and all 59 canonical advanced audit subcategories. Individual failures +remain Unknown. It also records source versions and SHA-256 fingerprints for the +baseline/profile/applicability catalogs, this collector script and its native +reader helpers (`Configuration.ps1`, `NativeProviders.psm1`, `AuditProfiles.psm1`); +these inputs use LF checkouts so byte fingerprints are portable. Missing, +unsupported or inconsistent processor codes leave context Unknown; the localized +OS architecture string alone cannot qualify a reference. + +Every capture is `EvidenceKind: ObservedState`. WELA cannot prove that a machine is +a clean installation. A domain-joined machine's effective state may include domain +policy; a CI runner is customized. Neither is automatically a Windows default. + +An independent reviewer should preserve the original capture and create a reviewed +copy only after checking the clean image, provisioning and effective GPO/MDM policy +evidence. Change `EvidenceKind` to `ReviewedCleanInstall`, and populate `Review`: + +| Field | Required evidence | +|---|---| +| `Reviewer`, `ReviewedUtc` | Identified reviewer and explicit ISO-8601 UTC review time at/after capture, ending in `Z` | +| `ImageSha256` | SHA-256 of the clean installation image | +| `SnapshotId` | Reproducible VM/image snapshot identifier | +| `PolicyEvidenceSha256` | SHA-256 of retained effective GPO/MDM/provisioning evidence | +| `ProvisioningNotes` | Patch installation, join/promotion/CA steps, policy scope and deviations | + +Do not remove observed Unknown entries or rewrite observations to match desired +recommendations. Keep the hashed image/policy artifacts with the review record. +`CapturedUtc` and `ReviewedUtc` must use `YYYY-MM-DDTHH:mm:ss[.fffffff]Z` (optional +one to seven fractional digits). Localized dates, missing timezone suffixes, +offset spellings and future timestamps are rejected; there is no clock grace +period. Preserve their original strings when editing the JSON artifact. +PowerShell 7.5 and later preserve timestamp strings with `-DateKind String`; +Windows PowerShell 5.1 preserves strings without that option. Earlier PowerShell 7 +releases deserialize timestamps automatically, so an already deserialized +`DateTime` is accepted only with `Kind=Utc`, then subjected to the same ordering +and assessment-time checks. Local and unspecified `DateTime` values are rejected; +original lexical spelling cannot be revalidated after an older engine normalizes it. +The importer validates structure, fingerprints and exact scenario context; +it does not authenticate the reviewer or inspect external artifacts. Its report +explicitly labels that provenance as operator-declared. Duplicate IDs, missing +review fields, source/collector drift or any context mismatch prevent defaults +from being used. Unknown/missing individual observations remain Unknown even if +the reference otherwise qualifies. Hashes must be refreshed through a new review +when source or collector files change; do not relabel old observations blindly. + +Before closing #385, capture independently reviewed clean Windows 11 24H2/25H2, +Server 2022/2025 member, promoted DC and AD CS scenarios with exact patches and +policy evidence. Include historical Application Guard-enabled/disabled clients +where still available. Synthetic fixtures test the comparison boundaries; native +Windows CI demonstrates read-only collection only. This PR includes no measured +clean-install snapshots or end-to-end event/ingestion evidence. + +Sources: [Microsoft Application Guard removal](https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/microsoft-defender-application-guard/md-app-guard-overview), +[Win32 processor platform codes](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-processor), +[localized OS architecture property](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-operatingsystem), +[CIS Windows benchmarks](https://www.cisecurity.org/benchmark/microsoft_windows_desktop) +(reviewed Windows 11 Enterprise v4.0.0, 18.10.44.1), and the versioned source records +in `config/audit_profiles.json`. diff --git a/modules/NativeProviders.psm1 b/modules/NativeProviders.psm1 index 64b4aad8..c69bf3c3 100644 --- a/modules/NativeProviders.psm1 +++ b/modules/NativeProviders.psm1 @@ -187,7 +187,7 @@ function Export-WelaAuditAssessment { Note = 'Only evidence-qualified Ready rules are usable. Policy/channel matches are configuration estimates; missing full rule logic, fields, outcomes, SACL, ingestion or query evidence remains Conditional.' } Eligibility = $Eligibility - Results = @($Rows | Select-Object Category, SubCategory, CurrentSetting, DefaultSetting, RecommendedSetting, RuleCount, ChannelState, GenerationReadiness, NativeSources, Note) + Results = @($Rows | Select-Object Category, SubCategory, CurrentSetting, DefaultSetting, DefaultEvidence, LegacyDefaultHint, RecommendedSetting, RuleCount, ChannelState, GenerationReadiness, NativeSources, Note) } if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } if ($HtmlPath) { diff --git a/scripts/ControlApplicability.ps1 b/scripts/ControlApplicability.ps1 new file mode 100644 index 00000000..e6058b65 --- /dev/null +++ b/scripts/ControlApplicability.ps1 @@ -0,0 +1,254 @@ +# Read-only historical control applicability and provenance-bound default evidence. +function Get-WelaDefaultProcessorArchitecture { + # Win32_OperatingSystem.OSArchitecture is localized; retain an independent, + # documented platform code rather than inferring x64/ARM64 from that string. + $processors=@(Get-CimInstance Win32_Processor -ErrorAction Stop) + if (-not $processors.Count) { throw 'Processor architecture evidence is missing.' } + $codes=@(foreach ($processor in $processors) { + if ($processor.Architecture -isnot [ValueType] -or $processor.Architecture -notin @(9,12)) { + throw 'Processor architecture is missing or outside the reviewed x64/ARM64 scope.' + } + [int]$processor.Architecture + }) + $unique=@($codes | Sort-Object -Unique) + if ($unique.Count -ne 1) { throw 'Conflicting processor architecture evidence.' } + return $unique[0] +} + +function Get-WelaDefaultContext { + $result=[ordered]@{Status='Unknown';Build=$null;UBR=$null;Edition=$null;ProductType=$null;DomainRole=$null;DomainJoined=$null;Domain=$null;Architecture=$null;ProcessorArchitecture=$null;InstalledRoles=@();RolesStatus='Unknown';Diagnostic=''} + try { + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows is required for a native default snapshot.' } + if (-not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell for exact native registry and role observations.' } + $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $version=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop + if ([string]$os.BuildNumber -notmatch '^\d+$' -or $null -eq $version.UBR -or -not $version.EditionID -or + $os.ProductType -notin @(1,2,3) -or $computer.PartOfDomain -isnot [bool]) { throw 'Incomplete host build, patch, edition or join evidence.' } + if (($os.ProductType -eq 1 -and $computer.DomainRole -notin @(0,1)) -or + ($os.ProductType -eq 2 -and $computer.DomainRole -notin @(4,5)) -or + ($os.ProductType -eq 3 -and $computer.DomainRole -notin @(2,3))) { throw 'Conflicting host role observations.' } + $result.Build=[int]$os.BuildNumber; $result.UBR=[int]$version.UBR; $result.Edition=[string]$version.EditionID + $result.ProductType=[int]$os.ProductType; $result.DomainRole=[int]$computer.DomainRole + $result.DomainJoined=[bool]$computer.PartOfDomain; $result.Domain=[string]$computer.Domain; $result.Architecture=[string]$os.OSArchitecture + $result.ProcessorArchitecture=Get-WelaDefaultProcessorArchitecture + if ($os.ProductType -eq 1) { + $result.InstalledRoles=@(Get-WindowsOptionalFeature -Online -ErrorAction Stop | Where-Object State -eq 'Enabled' | ForEach-Object { [string]$_.FeatureName } | Sort-Object -Unique) + } else { + $result.InstalledRoles=@(Get-WindowsFeature -ErrorAction Stop | Where-Object Installed -eq $true | ForEach-Object { [string]$_.Name } | Sort-Object -Unique) + } + $result.RolesStatus='Observed'; $result.Status='Observed' + } catch { $result.Diagnostic=$_.Exception.Message } + [pscustomobject]$result +} + +function Get-WelaControlCatalog { + $catalog=Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/control_applicability.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($catalog.schemaVersion -ne 1) { throw 'Unsupported control applicability catalog.' } + return $catalog +} + +function Resolve-WelaControlApplicability { + param($Definition,$Context,[string]$FeatureState='Unknown') + $status='Unknown'; $reason='Host build/product type could not be established.' + if ($Context.ProductType -in @(1,2,3) -and $Context.Build -is [ValueType] -and $Context.Build -gt 0) { + if ($Context.ProductType -notin $Definition.productTypes) { $status='NotApplicable'; $reason='Historical source requirement applies to Windows 11 clients only.' } + elseif ($Context.Build -ge $Definition.removal.firstBuild) { $status='NotApplicable'; $reason="Removed starting $($Definition.removal.release); no remediation is proposed." } + elseif ($Context.Build -lt $Definition.minBuild) { $status='NotApplicable'; $reason='Outside the Windows 11 source scope.' } + elseif ($Context.Build -gt $Definition.maxBuild -or $Context.Build -notin $Definition.reviewedBuilds) { $reason='Build not reviewed; no inferred support.' } + elseif ($Context.Edition -notin $Definition.editions) { $reason='Edition is not in the reviewed historical support set.' } + elseif ($FeatureState -in @('Disabled','DisabledWithPayloadRemoved','NotPresent')) { $status='NotApplicable'; $reason='Optional feature is unavailable or disabled; retained source requirement is conditional on its presence.' } + elseif ($FeatureState -eq 'Enabled') { $status='Applicable'; $reason='Historical build and enabled feature observed; application/runtime event generation remains unverified.' } + else { $reason='Optional feature state is unknown or pending; no remediation is proposed.' } + } + [pscustomobject]@{Status=$status;Reason=$reason;FeatureState=$FeatureState;Remediation=$null;SourceRequirement=$Definition.source;MinBuild=$Definition.minBuild;MaxBuild=$Definition.maxBuild;Removal=$Definition.removal} +} + +function Get-WelaHistoricalControls { + param($Context=(Get-WelaDefaultContext)) + foreach ($definition in (Get-WelaControlCatalog).controls) { + $feature='Unknown'; $featureError=$null; $policy=$null; $policyState='Not assessed' + $gate=Resolve-WelaControlApplicability $definition $Context + # Never query DISM or propose reinstalling a feature on removed/server builds. + if ($gate.Status -eq 'Unknown' -and $Context.Build -in $definition.reviewedBuilds -and $Context.ProductType -in $definition.productTypes -and $Context.Edition -in $definition.editions) { + try { + $observed=@(Get-WindowsOptionalFeature -Online -FeatureName $definition.feature -ErrorAction Stop) + if ($observed.Count -ne 1 -or $observed[0].FeatureName -ne $definition.feature) { throw 'Exact optional feature result missing or ambiguous.' } + $feature=[string]$observed[0].State + } catch { $featureError=$_.Exception.Message } + } + $gate=Resolve-WelaControlApplicability $definition $Context $feature + if ($gate.Status -eq 'Applicable') { + try { + $policy=Get-WelaRegistryState -Path $definition.registryPath -Name $definition.valueName + $policyState=if (-not $policy.ValueExists) {'Not configured'} elseif ($policy.Type -ne $definition.requiredType) {'Unknown'} elseif ($policy.Value -eq $definition.requiredValue) {'Policy matches'} else {'Policy differs'} + } catch { $policyState='Unknown'; $featureError=$_.Exception.Message } + } + [pscustomobject]@{Id=$definition.id;Title=$definition.title;Applicability=$gate;FeatureDiagnostic=$featureError;Policy=$policy;PolicyState=$policyState;AutomaticConfiguration='Unavailable: read-only historical assessment';EventGeneration='Unverified';DefaultSetting='Unknown'} + } +} + +function Get-WelaDefaultSourceFingerprints { + foreach ($path in @('config/baselines.json','config/audit_profiles.json','config/control_applicability.json','scripts/ControlApplicability.ps1', + 'scripts/Configuration.ps1','modules/NativeProviders.psm1','modules/AuditProfiles.psm1')) { + [pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot "../$path") -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + } +} + +function ConvertFrom-WelaDefaultEvidenceJson { + param([string]$Text) + $arguments=@{InputObject=$Text;ErrorAction='Stop'} + # Preserve the timestamp's exact source spelling for strict UTC validation. + if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) { $arguments.DateKind='String' } + ConvertFrom-Json @arguments +} + +function Get-WelaDefaultControlDefinitions { + $config=Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/baselines.json') -Raw -ErrorAction Stop | ConvertFrom-Json + $seen=@{} + foreach ($item in $config.catalog) { + if ($item.currentSetting.type -eq 'auditpol') { continue } + $seen[$item.id]=$true + [pscustomobject]@{Id=$item.id;Kind=$item.currentSetting.type;Definition=$item.currentSetting} + } + $profiles=Import-WelaAuditProfiles + foreach ($item in $profiles.catalog) { + $id='audit:'+([string]$item.guid).ToLowerInvariant() + if ($seen.ContainsKey($id)) { throw 'Duplicate default evidence audit GUID.' }; $seen[$id]=$true + [pscustomobject]@{Id=$id;Kind='auditpol';Definition=$item} + } +} + +function Get-WelaDefaultObservation { + param($Control) + $state='Unknown'; $value=$null; $raw=@(); $diagnostic='' + try { + switch ($Control.Kind) { + 'auditpol' { + $mask=Get-WelaAuditPolicyMask -Guid $Control.Definition.guid + $value=Format-WelaAuditMask $mask; $raw=@([pscustomobject]@{Guid=$Control.Definition.guid;Mask=$mask}); $state='Observed' + } + 'native-channel' { + $raw=@(foreach ($channel in $Control.Definition.channels) { Get-WelaNativeChannel -Name $channel }) + if (-not $raw.Count -or @($raw | Where-Object State -notin @('Enabled','Disabled','Not installed')).Count) { throw 'One or more channel observations are unknown.' } + $value=(@($raw | ForEach-Object { "$($_.Name)=$($_.State)" }) -join '; '); $state='Observed' + } + 'registry' { + $raw=@(foreach ($path in $Control.Definition.paths) { [pscustomobject]@{Path=$path;Name=$Control.Definition.name;State=(Get-WelaRegistryState -Path $path -Name $Control.Definition.name)} }) + if (-not $raw.Count) { throw 'Registry definition has no paths.' } + $value=(@($raw | ForEach-Object { if (-not $_.State.ValueExists) {"$($_.Path)=Not configured"} else {"$($_.Path)=$($_.State.Type):$($_.State.Value)"} }) -join '; '); $state='Observed' + } + default { throw 'Default observation kind is not supported.' } + } + } catch { $state='Unknown'; $value=$null; $diagnostic=$_.Exception.Message } + [pscustomobject]@{Id=$Control.Id;Kind=$Control.Kind;Status=$state;Value=$value;Raw=$raw;Diagnostic=$diagnostic} +} + +function New-WelaDefaultSnapshot { + [pscustomobject][ordered]@{ + SchemaVersion=1;EvidenceKind='ObservedState';CapturedUtc=[DateTime]::UtcNow.ToString('o');ComputerName=$env:COMPUTERNAME + Context=(Get-WelaDefaultContext);Sources=@(Get-WelaDefaultSourceFingerprints) + SourceVersions=(Import-WelaAuditProfiles).sources + Review=[pscustomobject]@{Reviewer=$null;ReviewedUtc=$null;ImageSha256=$null;SnapshotId=$null;PolicyEvidenceSha256=$null;ProvisioningNotes=$null} + Observations=@(Get-WelaDefaultControlDefinitions | ForEach-Object { Get-WelaDefaultObservation $_ }) + Warning='Observed settings are not Windows defaults. Only independently reviewed clean-install provenance can qualify a reference; domain join, promotion and CA installation define distinct scenarios.' + } +} + +function Test-WelaDefaultContextComplete { + param($Context) + $consistent=($Context.ProductType -eq 1 -and $Context.DomainRole -in @(0,1)) -or + ($Context.ProductType -eq 2 -and $Context.DomainRole -in @(4,5)) -or ($Context.ProductType -eq 3 -and $Context.DomainRole -in @(2,3)) + $joined=$Context.DomainRole -in @(1,3,4,5) + return $consistent -and $Context.DomainJoined -eq $joined -and $Context.Status -eq 'Observed' -and $Context.RolesStatus -eq 'Observed' -and + ($Context.Build -is [int] -or $Context.Build -is [long]) -and $Context.Build -gt 0 -and + ($Context.UBR -is [int] -or $Context.UBR -is [long]) -and $Context.UBR -ge 0 -and + -not [string]::IsNullOrWhiteSpace($Context.Edition) -and $Context.ProductType -in @(1,2,3) -and + $Context.DomainRole -in @(0,1,2,3,4,5) -and $Context.DomainJoined -is [bool] -and + -not [string]::IsNullOrWhiteSpace($Context.Domain) -and -not [string]::IsNullOrWhiteSpace($Context.Architecture) -and + ($Context.ProcessorArchitecture -is [int] -or $Context.ProcessorArchitecture -is [long]) -and $Context.ProcessorArchitecture -in @(9,12) -and + $null -ne $Context.InstalledRoles +} + +function Get-WelaDefaultContextKey { + param($Context) + # Fixed property order; host name intentionally excluded so matching lab peers can compare. + [ordered]@{Build=$Context.Build;UBR=$Context.UBR;Edition=$Context.Edition;ProductType=$Context.ProductType;DomainRole=$Context.DomainRole;DomainJoined=$Context.DomainJoined;Domain=$Context.Domain;Architecture=$Context.Architecture;ProcessorArchitecture=$Context.ProcessorArchitecture;InstalledRoles=@($Context.InstalledRoles | Sort-Object -Unique)} | ConvertTo-Json -Depth 5 -Compress +} + +function Test-WelaReviewedDefaultEvidence { + param($Evidence,$Context,$Sources,[DateTimeOffset]$AssessmentUtc=[DateTimeOffset]::UtcNow) + $reason='' + if (-not $Evidence -or $Evidence.SchemaVersion -ne 1 -or $Evidence.EvidenceKind -ne 'ReviewedCleanInstall') { $reason='Reference is not a reviewed clean-install scenario.' } + elseif (-not (Test-WelaDefaultContextComplete $Context) -or -not (Test-WelaDefaultContextComplete $Evidence.Context)) { $reason='Exact host/role/patch evidence is incomplete.' } + elseif ((Get-WelaDefaultContextKey $Context) -cne (Get-WelaDefaultContextKey $Evidence.Context)) { $reason='Build, patch, edition, architecture, domain/join or installed-role context differs.' } + else { + $review=$Evidence.Review + $captured=[DateTimeOffset]::MinValue; $reviewed=[DateTimeOffset]::MinValue + $capturedText=$Evidence.CapturedUtc; $reviewedText=$review.ReviewedUtc + # PowerShell 6 through 7.4 deserialize Z timestamps as UTC DateTime and + # have no -DateKind String option. Local/Unspecified values remain invalid. + if ($capturedText -is [DateTime] -and $capturedText.Kind -eq [DateTimeKind]::Utc) { $capturedText=$capturedText.ToString('o') } + if ($reviewedText -is [DateTime] -and $reviewedText.Kind -eq [DateTimeKind]::Utc) { $reviewedText=$reviewedText.ToString('o') } + $utcPattern='\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?Z\z' + if ($capturedText -isnot [string] -or $reviewedText -isnot [string] -or + $capturedText -cnotmatch $utcPattern -or $reviewedText -cnotmatch $utcPattern -or + -not [DateTimeOffset]::TryParse($capturedText,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::AssumeUniversal,[ref]$captured) -or + -not [DateTimeOffset]::TryParse($reviewedText,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::AssumeUniversal,[ref]$reviewed) -or + $reviewed -lt $captured -or $captured -gt $AssessmentUtc -or $reviewed -gt $AssessmentUtc -or + [string]::IsNullOrWhiteSpace($review.Reviewer) -or [string]::IsNullOrWhiteSpace($review.SnapshotId) -or + [string]::IsNullOrWhiteSpace($review.ProvisioningNotes) -or $review.ImageSha256 -notmatch '^[a-fA-F0-9]{64}$' -or + $review.PolicyEvidenceSha256 -notmatch '^[a-fA-F0-9]{64}$') { $reason='Clean-install review/image/snapshot/policy provenance is incomplete.' } + elseif (@($Sources).Count -ne @($Evidence.Sources).Count) { $reason='Source fingerprint set differs.' } + else { + foreach ($source in $Sources) { + $matches=@($Evidence.Sources | Where-Object Path -eq $source.Path) + if ($matches.Count -ne 1 -or $matches[0].Sha256 -cne $source.Sha256) { $reason='Source or collector version fingerprint differs.'; break } + } + $ids=@{} + foreach ($observation in $Evidence.Observations) { + if (-not $observation.Id -or $ids.ContainsKey($observation.Id)) { $reason='Missing or duplicated observation ID.'; break } + $ids[$observation.Id]=$true + } + } + } + [pscustomobject]@{Accepted=($reason -eq '');Reason=$(if ($reason) {$reason} else {'Operator-reviewed scenario reference; provenance is declared, not independently authenticated by WELA.'})} +} + +function Get-WelaDefaultComparison { + param($Snapshot,$Reference) + $review=Test-WelaReviewedDefaultEvidence -Evidence $Reference -Context $Snapshot.Context -Sources $Snapshot.Sources + $rows=foreach ($current in $Snapshot.Observations) { + $match=@($Reference.Observations | Where-Object Id -eq $current.Id) + $default='Unknown'; $reason=$review.Reason; $comparison='Unknown' + if ($review.Accepted -and $match.Count -eq 1 -and $match[0].Status -eq 'Observed' -and + $match[0].Value -is [string] -and $match[0].Value.Length -gt 0 -and $match[0].Kind -eq $current.Kind) { + $default=$match[0].Value + if ($current.Status -eq 'Observed') { $comparison=if ($current.Value -ceq $default) {'Matches reference'} else {'Differs from reference'} } + } elseif ($review.Accepted) { $reason='Reference control is missing, unknown or malformed.' } + [pscustomobject]@{Id=$current.Id;Current=$current;DefaultSetting=$default;DefaultEvidence=$reason;Comparison=$comparison} + } + [pscustomobject]@{ReferenceReview=$review;Results=@($rows);Scope='Scenario-specific native settings only; no event generation or Sigma eligibility claim.'} +} + +function Invoke-WelaDefaultEvidenceCommand { + param([ValidateSet('Capture','Compare')][string]$Action='Capture',[string]$ReferencePath,[string]$ResultsPath) + if ($Action -eq 'Compare' -and -not $ReferencePath) { throw 'Compare requires DefaultEvidencePath.' } + if ($Action -eq 'Capture' -and $ReferencePath) { throw 'DefaultEvidencePath requires Compare.' } + if ($Action -eq 'Compare' -and $ResultsPath) { + $referenceFull=[IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ReferencePath)) + $resultsFull=[IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ResultsPath)) + if ([string]::Equals($referenceFull,$resultsFull,[StringComparison]::OrdinalIgnoreCase)) { + throw 'ResultsPath must differ from DefaultEvidencePath; comparison output cannot overwrite its reference artifact.' + } + } + $snapshot=New-WelaDefaultSnapshot + $report=if ($Action -eq 'Compare') { + $reference=ConvertFrom-WelaDefaultEvidenceJson (Get-Content -LiteralPath $ReferencePath -Raw -ErrorAction Stop) + $comparison=Get-WelaDefaultComparison -Snapshot $snapshot -Reference $reference + [pscustomobject]@{Action=$Action;Snapshot=$snapshot;Comparison=$comparison;ExitCode=$(if ($comparison.ReferenceReview.Accepted -and @($snapshot.Observations | Where-Object Status -eq 'Unknown').Count -eq 0) {0} else {1})} + } else { [pscustomobject]@{Action=$Action;Snapshot=$snapshot;ExitCode=$(if ((Test-WelaDefaultContextComplete $snapshot.Context) -and @($snapshot.Observations | Where-Object Status -eq 'Unknown').Count -eq 0) {0} else {1})} } + # Capture exports the snapshot directly so its exact artifact can be reviewed. + if ($ResultsPath) { $(if ($Action -eq 'Capture') {$snapshot} else {$report}) | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } + return $report +} diff --git a/tests/ControlApplicability.Tests.ps1 b/tests/ControlApplicability.Tests.ps1 new file mode 100644 index 00000000..9b785ff6 --- /dev/null +++ b/tests/ControlApplicability.Tests.ps1 @@ -0,0 +1,160 @@ +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent +. (Join-Path $root 'scripts/ControlApplicability.ps1') +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') +$script:count=0 +function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ } +function Copy-Object($Value) { ConvertFrom-WelaDefaultEvidenceJson ($Value | ConvertTo-Json -Depth 20) } +$context=[pscustomobject]@{Status='Observed';Build=22631;UBR=1234;Edition='Enterprise';ProductType=1;DomainRole=1;DomainJoined=$true;Domain='lab.test';Architecture='64-bit';ProcessorArchitecture=9;InstalledRoles=@('FeatureB','FeatureA');RolesStatus='Observed';Diagnostic='fixture'} +$script:processorRows=@() +function Get-CimInstance { param($ClassName,$ErrorAction) if ($ClassName -ne 'Win32_Processor') { throw 'Unexpected fixture CIM class.' }; $script:processorRows } +foreach ($code in @(9,12)) { + $script:processorRows=@([pscustomobject]@{Architecture=[uint16]$code},[pscustomobject]@{Architecture=[uint16]$code}) + Assert ((Get-WelaDefaultProcessorArchitecture) -eq $code) 'Native processor platform code distinguishes x64 and ARM64 independently of OS string/process emulation.' +} +foreach ($codes in @(@(),@(9,12),@($null),@(99),@('9'))) { + $script:processorRows=@($codes | ForEach-Object { [pscustomobject]@{Architecture=$_} }) + $rejected=$false; try { Get-WelaDefaultProcessorArchitecture | Out-Null } catch { $rejected=$true } + Assert $rejected 'Missing, conflicting, unsupported or malformed processor architecture cannot qualify exact context.' +} +$definition=(Get-WelaControlCatalog).controls[0] +foreach ($build in @(26100,26200,30000)) { + $ctx=Copy-Object $context; $ctx.Build=$build + foreach ($feature in @('Enabled','Disabled','Unknown')) { + $row=Resolve-WelaControlApplicability $definition $ctx $feature + Assert ($row.Status -eq 'NotApplicable' -and $null -eq $row.Remediation -and $row.Reason -match 'Removed') 'Removed Application Guard has no misleading remediation, even if stale feature metadata says Enabled.' + } +} +foreach ($product in @(2,3)) { + $ctx=Copy-Object $context; $ctx.ProductType=$product + Assert ((Resolve-WelaControlApplicability $definition $ctx Enabled).Status -eq 'NotApplicable') 'Client historical source does not apply to a server/DC/CA.' +} +foreach ($state in @('Disabled','DisabledWithPayloadRemoved','NotPresent')) { + Assert ((Resolve-WelaControlApplicability $definition $context $state).Status -eq 'NotApplicable') 'Absent/disabled feature is distinguished from an auditing mismatch.' +} +foreach ($state in @('Unknown','EnablePending','DisablePending')) { + Assert ((Resolve-WelaControlApplicability $definition $context $state).Status -eq 'Unknown') 'Unknown/pending feature cannot claim applicability.' +} +Assert ((Resolve-WelaControlApplicability $definition $context Enabled).Status -eq 'Applicable') 'Reviewed historical enabled feature is applicable.' +$ctx=Copy-Object $context; $ctx.Build=25000 +Assert ((Resolve-WelaControlApplicability $definition $ctx Enabled).Status -eq 'Unknown') 'Unreviewed insider build remains unknown.' +$ctx=Copy-Object $context; $ctx.Edition='Core' +Assert ((Resolve-WelaControlApplicability $definition $ctx Enabled).Status -eq 'Unknown') 'Unreviewed edition is never given remediation.' +$script:featureReads=0; $script:registryReads=0 +function Get-WindowsOptionalFeature { param([switch]$Online,$FeatureName,$ErrorAction) $script:featureReads++; [pscustomobject]@{FeatureName=$FeatureName;State='Enabled'} } +function Get-WelaRegistryState { param($Path,$Name) $script:registryReads++; [pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='DWord';Value=1} } +$ctx=Copy-Object $context; $ctx.Build=26100 +$row=@(Get-WelaHistoricalControls $ctx)[0] +Assert ($row.Applicability.Status -eq 'NotApplicable' -and $script:featureReads -eq 0 -and $script:registryReads -eq 0) 'Removed feature is not queried or remediated.' +$row=@(Get-WelaHistoricalControls $context)[0] +Assert ($row.PolicyState -eq 'Policy matches' -and $row.EventGeneration -eq 'Unverified') 'Actual policy observation stays separate from event proof.' +function Get-WelaDefaultContext { $context } +function Get-WelaNativeChannel { param($Name) [pscustomobject]@{Name=$Name;State='Enabled';IsEnabled=$true;LogMode='Circular'} } +function Get-WelaAuditPolicyMask { param($Guid) 1 } +$snapshot=New-WelaDefaultSnapshot +Assert ($snapshot.EvidenceKind -eq 'ObservedState' -and $snapshot.Observations.Count -gt 59) 'Capture always labels current observations, including canonical audit categories.' +$reference=Copy-Object $snapshot +$compare=Get-WelaDefaultComparison $snapshot $reference +Assert (-not $compare.ReferenceReview.Accepted -and @($compare.Results | Where-Object DefaultSetting -ne 'Unknown').Count -eq 0) 'Observed current settings are never promoted to defaults.' +$reference.EvidenceKind='ReviewedCleanInstall' +$reference.Review=[pscustomobject]@{Reviewer='Fixture reviewer';ReviewedUtc=[DateTime]::UtcNow.ToString('o');ImageSha256=('a'*64);SnapshotId='test-only';PolicyEvidenceSha256=('b'*64);ProvisioningNotes='Synthetic validation fixture, not Windows lab evidence'} +$reference.Context.InstalledRoles=@('FeatureA','FeatureB') +$compare=Get-WelaDefaultComparison $snapshot $reference +Assert ($compare.ReferenceReview.Accepted -and @($compare.Results | Where-Object Comparison -ne 'Matches reference').Count -eq 0) 'Exact reviewed context can provide a scenario reference, with order-independent role inventory.' +if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind') -or $PSVersionTable.PSVersion.Major -le 5) { + Assert ($reference.CapturedUtc -is [string] -and $reference.Review.ReviewedUtc -is [string]) 'JSON import retains exact timestamp strings instead of silently localizing them.' +} else { + Assert ($reference.CapturedUtc -is [DateTime] -and $reference.CapturedUtc.Kind -eq [DateTimeKind]::Utc) 'Earlier PowerShell 7 imports explicit Z timestamps as UTC, not Local or Unspecified.' +} +$candidate=Copy-Object $reference +$candidate.CapturedUtc=([DateTimeOffset]$candidate.CapturedUtc).UtcDateTime +$candidate.Review.ReviewedUtc=([DateTimeOffset]$candidate.Review.ReviewedUtc).UtcDateTime +Assert ((Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Earlier PowerShell 7 deserialized UTC DateTime values retain their explicit UTC semantics.' +foreach ($kind in @([DateTimeKind]::Local,[DateTimeKind]::Unspecified)) { + foreach ($field in @('Capture','Review')) { + $candidate=Copy-Object $reference + if ($field -eq 'Capture') { $candidate.CapturedUtc=[DateTime]::SpecifyKind(([DateTimeOffset]$candidate.CapturedUtc).UtcDateTime,$kind) } + else { $candidate.Review.ReviewedUtc=[DateTime]::SpecifyKind(([DateTimeOffset]$candidate.Review.ReviewedUtc).UtcDateTime,$kind) } + Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Local or Unspecified DateTime cannot qualify UTC provenance even on a UTC-configured host.' + } +} +$candidate=Copy-Object $reference; $candidate.Review.ReviewedUtc=[DateTime]::UtcNow.AddDays(1) +Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Deserialized UTC DateTime retains the nonfuture validation boundary.' +foreach ($time in @('2040-01-01T00:00:00Z','09/19/2026 01:00:00','2026-09-19T01:00:00','2026-09-19T01:00:00+00:00','2026-99-99T01:00:00Z',"2020-01-01T00:00:00Z`n")) { + foreach ($field in @('Capture','Review')) { + $candidate=Copy-Object $reference + if ($field -eq 'Capture') {$candidate.CapturedUtc=$time} else {$candidate.Review.ReviewedUtc=$time} + Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Future, malformed, localized or non-Z timestamps cannot qualify a clean-install reference.' + } +} +$candidate=Copy-Object $reference; $candidate.CapturedUtc='2040-01-01T00:00:00Z'; $candidate.Review.ReviewedUtc='2040-01-01T00:00:01Z' +Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources ([DateTimeOffset]'2039-12-31T23:59:59Z')).Accepted) 'Chronologically ordered future capture and review still fail the assessment-time boundary.' +foreach ($helper in @('scripts/Configuration.ps1','modules/NativeProviders.psm1','modules/AuditProfiles.psm1')) { + $candidate=Copy-Object $reference + $source=@($candidate.Sources | Where-Object Path -eq $helper) + Assert ($source.Count -eq 1) "Actual collector helper is fingerprinted: $helper" + $source[0].Sha256=('d'*64) + Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) "Changed native reader semantics invalidate reference provenance: $helper" +} +foreach ($field in @('Build','UBR','Edition','ProductType','DomainRole','DomainJoined','Domain','Architecture','ProcessorArchitecture','InstalledRoles','Status','RolesStatus')) { + $candidate=Copy-Object $reference + switch ($field) { + 'Build' {$candidate.Context.Build=26100} + 'UBR' {$candidate.Context.UBR=1235} + 'ProductType' {$candidate.Context.ProductType=3} + 'DomainRole' {$candidate.Context.DomainRole=3} + 'DomainJoined' {$candidate.Context.DomainJoined=$false} + 'ProcessorArchitecture' {$candidate.Context.ProcessorArchitecture=12} + 'InstalledRoles' {$candidate.Context.InstalledRoles=@('ADCS-Cert-Authority')} + default {$candidate.Context.$field='Different'} + } + $compare=Get-WelaDefaultComparison $snapshot $candidate + Assert (-not $compare.ReferenceReview.Accepted -and @($compare.Results | Where-Object DefaultSetting -ne 'Unknown').Count -eq 0) "Context mismatch $field cannot leak a default." +} +$candidate=Copy-Object $reference; $candidate.Context.PSObject.Properties.Remove('ProcessorArchitecture') +Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Legacy evidence without native processor architecture remains Unknown, even when localized OS architecture matches.' +foreach ($field in @('Reviewer','ReviewedUtc','ImageSha256','SnapshotId','PolicyEvidenceSha256','ProvisioningNotes')) { + $candidate=Copy-Object $reference; $candidate.Review.$field=$null + Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) "Missing $field fails review." +} +$candidate=Copy-Object $reference; $candidate.Sources[0].Sha256=('c'*64) +Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Changed source/catalog/collector bytes invalidate the reference.' +$candidate=Copy-Object $reference; $candidate.Observations+=@($candidate.Observations[0]) +Assert (-not (Test-WelaReviewedDefaultEvidence $candidate $snapshot.Context $snapshot.Sources).Accepted) 'Duplicate IDs invalidate reference.' +$candidate=Copy-Object $reference; $candidate.Observations[0].Status='Unknown' +$compare=Get-WelaDefaultComparison $snapshot $candidate +Assert ($compare.ReferenceReview.Accepted -and $compare.Results[0].DefaultSetting -eq 'Unknown') 'Unknown individual control remains unknown in an otherwise reviewed artifact.' +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-default-reference-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $temp +try { + $referencePath=Join-Path $temp 'reference.json' + $reference | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $referencePath -Encoding UTF8 + $beforeHash=(Get-FileHash -LiteralPath $referencePath -Algorithm SHA256).Hash + foreach ($output in @($referencePath,(Join-Path $temp './reference.json'))) { + $rejected=$false + try { Invoke-WelaDefaultEvidenceCommand -Action Compare -ReferencePath $referencePath -ResultsPath $output | Out-Null } + catch { $rejected=$_.Exception.Message -match 'cannot overwrite its reference artifact' } + Assert $rejected 'Equivalent input/output paths are rejected before exporting over reviewed evidence.' + Assert ((Get-FileHash -LiteralPath $referencePath -Algorithm SHA256).Hash -ceq $beforeHash) 'Rejected reference/output collision preserves exact evidence bytes.' + } + $output=Join-Path $temp 'comparison.json' + $report=Invoke-WelaDefaultEvidenceCommand -Action Compare -ReferencePath $referencePath -ResultsPath $output + Assert ($report.Comparison.ReferenceReview.Accepted -and (Test-Path -LiteralPath $output) -and (Get-FileHash -LiteralPath $referencePath -Algorithm SHA256).Hash -ceq $beforeHash) 'Distinct comparison output imports strict timestamps and preserves its reviewed input.' +} finally { Remove-Item -LiteralPath $temp -Recurse -Force } +function Get-WelaNativeChannel { param($Name) throw 'channel read denied' } +$partial=New-WelaDefaultSnapshot +Assert (@($partial.Observations | Where-Object {$_.Kind -eq 'native-channel' -and $_.Status -eq 'Unknown'}).Count -gt 0 -and @($partial.Observations | Where-Object {$_.Kind -eq 'auditpol' -and $_.Status -eq 'Observed'}).Count -eq 59) 'One failed source does not discard independent successful observations.' +# The real renderer keeps source-less legacy strings explicitly separate in all assessment rows. +$tokens=$null; $errors=$null +$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $root 'WELA.ps1'),[ref]$tokens,[ref]$errors) +Assert ($errors.Count -eq 0) 'Public script parses.' +$class=$ast.Find({param($node) $node -is [System.Management.Automation.Language.TypeDefinitionAst] -and $node.Name -eq 'WELA'},$true) +. ([scriptblock]::Create($class.Extent.Text)) +$row=[WELA]::New('Test','Test','Enabled',@(),'Enabled','Enabled','','') +Assert ($row.DefaultSetting -eq 'Unknown' -and $row.LegacyDefaultHint -eq 'Enabled' -and $row.DefaultEvidence -match 'No exact-context') 'Legacy default hints cannot be shown as current host defaults.' +$exe=(Get-Process -Id $PID).Path +$ErrorActionPreference='Continue' +try { $out=& $exe -NoProfile -File (Join-Path $root 'WELA.ps1') configure -Profile wela-2.2.0 -DefaultEvidenceAction Capture 2>&1; $code=$LASTEXITCODE } finally { $ErrorActionPreference='Stop' } +Assert ($code -ne 0 -and ($out -join "`n") -match 'Default evidence options require') 'Wrong-command evidence options fail before profile configuration.' +Write-Host "PASS: $script:count applicability/default evidence assertions (synthetic fixtures, no default claims)." +$global:LASTEXITCODE=0 diff --git a/tests/ControlApplicability.Windows.Tests.ps1 b/tests/ControlApplicability.Windows.Tests.ps1 new file mode 100644 index 00000000..c04a633c --- /dev/null +++ b/tests/ControlApplicability.Windows.Tests.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent +. (Join-Path $root 'scripts/Configuration.ps1') +. (Join-Path $root 'scripts/ControlApplicability.ps1') +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') +Import-Module (Join-Path $root 'modules/NativeProviders.psm1') +$report=Invoke-WelaDefaultEvidenceCommand -Action Capture +$snapshot=$report.Snapshot +if (-not (Test-WelaDefaultContextComplete $snapshot.Context)) { throw "Native snapshot context incomplete: $($snapshot.Context.Diagnostic)" } +if ($snapshot.EvidenceKind -ne 'ObservedState' -or $snapshot.Review.Reviewer) { throw 'Native observed state was incorrectly promoted to a reviewed default.' } +if (@($snapshot.Observations | Where-Object Kind -eq 'auditpol').Count -ne 59) { throw 'Canonical audit inventory incomplete.' } +$comparison=Get-WelaDefaultComparison $snapshot $snapshot +if ($comparison.ReferenceReview.Accepted -or @($comparison.Results | Where-Object DefaultSetting -ne 'Unknown').Count) { throw 'Observed state cannot establish defaults.' } +$controls=@(Get-WelaHistoricalControls -Context $snapshot.Context) +if ($controls[0].Applicability.Status -ne 'NotApplicable' -or $controls[0].Applicability.Remediation) { throw 'Server must not receive historical client Application Guard remediation.' } +$snapshot.Context | ConvertTo-Json -Depth 5 | Write-Host +$snapshot.Observations | Select-Object Id,Status,Value,Diagnostic | Format-Table -AutoSize | Out-Host +Write-Host 'PASS: native build/patch/join/role and policy/channel capture is read-only. This runner is not a clean-install reference.' +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 3aae6b0d..f21cfd57 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 読み取り専用の`control-applicability`を追加し、旧CISのApplication Guard監査要件を保持しつつ、Windows 11 24H2以降では削除済み・対象外と表示します。`default-evidence`で正確なビルド・パッチ・ドメイン参加・役割を含む現状を記録し、出典とレビュー情報が一致する参照環境と比較できます。旧ベースラインの既定値は履歴情報として保持し、未検証の既定値はUnknownと表示します。合成テストと読み取り専用CIからクリーンインストールやイベント生成の証明は行いません。 (#409) (@Shirofune-Security) - Windows標準のDNS Client/Server、CAPI2、WinRM、RDP Clientについて、明示的に選択するprovider-packsの監査・計画とチャネル設定を追加しました。固定した完全なルール定義と実際のプロバイダー・チャネル・イベントスキーマを確認し、DNSのチャネル名不一致や不明な前提条件を保持します。復旧記録付きの変更では大きいバッファ・保持方式・ACLを維持し、Analyticalと従来のDNSログは手動確認のみとします。イベント生成・バックエンド検証や検知範囲の向上は未確認です。 (#411) (@Shirofune-Security) - 任意実行の`audit-notifications`を追加し、OneSettings監査とSecurityログ警告しきい値の監査・計画・設定に対応しました。対象とチャネル変更の明示指定、OS・ADMX確認、型付き復旧記録と変更検出を行い、既存の低いしきい値とチャネルACL・保存方式を保持します。ポリシー一致と実イベント生成を分け、Windowsラボの証拠とSigma利用可能性は未検証と表示します。 (#408) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 1978b99a..b2434509 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added read-only `control-applicability` for the historical CIS Application Guard audit requirement, reporting removal on Windows 11 24H2+ without remediation. Added exact build/patch/join/role native snapshots and provenance-bound reference comparison through `default-evidence`. Legacy baseline default strings are retained as historical hints while public defaults remain Unknown without reviewed scenario evidence. Synthetic fixtures and native read-only CI do not claim clean-install or event-generation proof. (#409) (@Shirofune-Security) - Added explicit native DNS Client/Server, CAPI2, WinRM and RDP Client provider-pack inventory and selective channel configuration. Pinned full rule definitions and live provider/channel/event schemas retain DNS channel mismatches and unknown prerequisites; journaled opt-in changes preserve larger buffers, retention and ACLs. Analytical/classic DNS remain manual-only, and no event/backend readiness uplift is claimed. (#411) (@Shirofune-Security) - Added opt-in `audit-notifications` audit/plan/configure for OneSettings auditing and Security log warning thresholds, with explicit control/channel selections, reviewed host and ADMX gates, typed recovery journals and drift checks. Earlier warning thresholds and channel ACL/retention are preserved. Reports separate policy matches from warning/event generation; Windows lab evidence and Sigma eligibility remain unverified. (#408) (@Shirofune-Security)