Support validated custom audit profile files through the shared engine (#416)

* Support validated operator-owned advanced audit profile files

* Reject lenient custom profile JSON and protect report output aliases

* Link custom audit profile changelog to PR 416

* Make custom JSON rejection fixtures portable across PowerShell versions
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-20 18:09:52 +09:00
1 parent 4431533535
commit 83b2ddd526
14 files changed
+715 -9

No files matched your search

@@ -0,0 +1,29 @@
name: Custom audit profile regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
custom-profiles:
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Strict input and mocked configuration on Windows PowerShell 5.1
shell: powershell
run: ./tests/CustomAuditProfiles.Tests.ps1
- name: Native read-only custom audit on Windows PowerShell 5.1
shell: powershell
run: ./tests/CustomAuditProfiles.Windows.Tests.ps1
- name: Strict input and mocked configuration on PowerShell 7
shell: pwsh
run: ./tests/CustomAuditProfiles.Tests.ps1
- name: Native read-only custom audit on PowerShell 7
shell: pwsh
run: ./tests/CustomAuditProfiles.Windows.Tests.ps1
+2
View File
@@ -4,6 +4,8 @@
**改善:**
- `-ProfileFile`で管理者のJSON詳細監査プロファイルを一覧・計画・監査・設定に使用できるようにしました。標準GUID、役割、設定モード、出典ハッシュを厳密に検証し、組み込みプロファイルを保持します。厳密なJSON字句検証で重複キー検出の回避を防ぎ、レポートは新規ファイルに限ることで別名リンク経由でも入力と既存の証拠を保持します。共通の優先設定、復旧記録、変更直前のファイル確認と最終検証を使用し、イベント生成やSigma検知可能性は別途検証とします。 (#416) (@Shirofune-Security)
- ネイティブ Security 4688 の無害な固定プローブを収集する明示的な `native-validation` を追加。型付き前提条件、イベントの厳密な照合、前後の状態、ハッシュ付き証拠を新規の非公開ディレクトリに記録します。不完全・上限到達・曖昧・ドリフトの結果は未検証のままです。監査ポリシー変更や Sigma 利用可能ルール数の加算は行いません。使い捨て Server 2022/2025 テストで実イベントとポリシー復元を確認し、Windows 11/DC/ADCS とバックエンドの検証は別途必要です。 (#413) (@Shirofune-Security)
- ローカル監査権限と`CrashOnAuditFail`を監査・計画・出典別に設定する任意実行の`audit-integrity`を追加しました。実際のクライアント・メンバーサーバー・DCを区別し、Microsoft SCTで省略された設定は保持します。対象SIDの一覧、権限削除の明示指定、権限単位のLSA更新、完全な復旧記録と変更直前・変更後の検証により、無関係な権限を保持します。復旧状態では変更せず、ネイティブCIは読み取りだけを行います。トークン・GPO・サービス・イベントの検証は別途ラボで必要となり、Sigma検知範囲には加算しません。 (#412) (@Shirofune-Security)
- 読み取り専用の`retention-health`を追加し、送信元/収集サーバーの標準ログバッファ、確認した先頭レコードの経過日数、申告されたアーカイブ方針、件数を制限したローカルEVTX/ACL一覧、各言語のWEF・時刻情報とログ消失・消去・満杯の兆候をJSONと単独で表示できるHTMLに分けて記録します。保持されたイベントの時刻に基づく件数率とUTF-8 XMLバイト数の試算には上限・前提を明示し、容量・完全な保持・実効読み取り権限・時刻同期・転送成功の証明とは扱いません。複数ホストでのロールオーバー・復旧・到着検証は別途必要です。 (#410) (@Shirofune-Security)
+2
View File
@@ -4,6 +4,8 @@
**Improvements:**
- Added `-ProfileFile` for strictly validated custom advanced audit profiles in listing, planning, auditing and configuration. Canonical GUIDs, roles, modes and source hashes remain explicit; built-in profiles are preserved. Strict JSON tokens prevent duplicate-key bypasses, and new report files preserve inputs and prior evidence even through file aliases. Shared precedence, recovery journals, pre-write file checks and final verification protect configuration, without claiming event or Sigma readiness. (#416) (@Shirofune-Security)
- Added opt-in `native-validation` to collect a fixed benign Security 4688 probe with typed prerequisites, exact native event matching, before/after state and hashed components in a new private directory. Partial, capped, ambiguous and drifted results remain unverified; the collector changes no audit policy and grants no Sigma readiness credit. Disposable Server 2022/2025 tests exercise real events with verified policy restoration; Windows 11/DC/ADCS and backend acceptance remain separate. (#413) (@Shirofune-Security)
- Added opt-in `audit-integrity` audit, plan and source-profile configuration for local audit privileges and `CrashOnAuditFail`, with separate actual client/member/DC scope and preserved Microsoft SCT omissions. Exact affected SIDs, explicit privilege-removal consent, per-right LSA updates, complete recovery journals and fresh/readback checks preserve unrelated privileges. Recovery states are blocked; native CI reads policy only, while token, GPO, service and event validation remains a lab requirement without Sigma credit. (#412) (@Shirofune-Security)
- Added read-only `retention-health` source/collector JSON and self-contained HTML reports separating native buffers, observed record-boundary ages, declared archive policy, bounded local EVTX/ACL inventory, localized WEF/time evidence and loss/clear/full indicators. Retained-event timestamp rates and UTF-8 XML-byte scenarios expose caps and assumptions; none establish archive capacity, complete retention, effective reader access, synchronized time or successful forwarding. Multi-host rollover/recovery/arrival validation remains pending. (#410) (@Shirofune-Security)
+56 -5
View File
@@ -4,6 +4,7 @@
[switch]$Debug,
[string]$Baseline,
[string]$Profile,
[string]$ProfileFile,
[string]$LogProfile,
[switch]$ResizeLogs,
[switch]$ApplyLogMode,
@@ -403,6 +404,28 @@ function Invoke-WelaProfileCommand {
param([string]$Command)
if ($script:Baseline) { throw "Use -Profile or -Baseline, not both. Versioned profiles cover advanced audit policy and its precedence prerequisite." }
if (-not $script:Profile) { throw "Specify -Profile. Use './WELA.ps1 profiles' to list versioned profiles." }
$planArguments = @{}
if ($script:ProfileFile) {
# Complete strict file/identifier/source validation before Windows reads.
$custom = Import-WelaCustomAuditProfiles -Path $script:ProfileFile
if ($script:Profile -cnotin @($custom.profiles.id)) { throw 'Selected profile is not present in the custom file; built-in fallback is disabled.' }
foreach ($output in @($script:PlanPath,$script:ResultsPath,$script:BackupPath)) {
if (-not $output) { continue }
$full = [IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($output))
if ($full -ieq $custom.customSource.Path -or $full -ieq $custom.customSource.CanonicalPath) { throw 'Profile input/catalog and output/backup paths must differ.' }
}
$reportPaths=@()
foreach ($output in @($script:PlanPath,$script:ResultsPath)) {
if (-not $output) { continue }
$full=Get-WelaCustomReportPath $output
if ($full -iin $reportPaths) { throw 'Custom PlanPath and ResultsPath require distinct new report files.' }
$reportPaths+=$full
}
$planArguments = @{Path=$custom.customSource.Path;CustomFile=$true}
if ($script:Role -and $script:Build) {
$null = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $script:Role -Build $script:Build @planArguments
}
}
$context = Get-WelaSelectedContext
$current = @{}
$saclLive = $false
@@ -413,7 +436,11 @@ function Invoke-WelaProfileCommand {
else { Write-Host "Planning for another role/build: effective state remains Unknown." }
}
elseif ($Command -ne 'plan') { throw "Audit and configure require Windows. Offline planning requires explicit -Role and -Build." }
$plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional
$plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional @planArguments
if ($script:ProfileFile) {
Assert-WelaCustomProfileSource $custom.customSource
if ($plan.CustomProfileSource.Sha256 -cne $custom.customSource.Sha256) { throw 'Custom profile changed during host assessment.' }
}
$precedence = Get-WelaAuditPrecedenceState -Offline:($current.Count -eq 0)
$plan | Add-Member NoteProperty AuditPrecedence $precedence
$saclPlan = Get-WelaTargetedSaclPlan -AuditPlan $plan -Mode $script:SaclMode -Live:$saclLive
@@ -431,15 +458,26 @@ function Invoke-WelaProfileCommand {
Assert-WelaAuditProfileTarget -Plan $plan -Context $actual -Current $current
$configurationContext = New-WelaConfigurationContext -Auto:$script:Auto -DryRun:$script:DryRun -BackupPath $script:BackupPath
Set-WelaProfileAuditControls -Context $configurationContext -Plan $plan
$result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan -Scope advanced-audit-policy-and-precedence
$sharedResultsPath=if ($script:ProfileFile) { $null } else { $script:ResultsPath }
$result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $sharedResultsPath -Plan $plan -Scope advanced-audit-policy-and-precedence
$result | Add-Member NoteProperty SaclPrerequisites $saclPlan
if ($script:ResultsPath) { $result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:ResultsPath -Encoding UTF8 -ErrorAction Stop }
if ($script:ResultsPath) {
if ($script:ProfileFile) { Write-WelaCustomProfileReport $result $script:ResultsPath }
else { $result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:ResultsPath -Encoding UTF8 -ErrorAction Stop }
}
$result.Results | Format-Table Id, Before, Desired, After, Status -AutoSize
} else {
$plan.policies | Format-Table id, mode, currentMask, requiredMask, action -AutoSize
if ($script:ProfileFile -and $script:ResultsPath) {
Assert-WelaCustomProfileSource $custom.customSource
Write-WelaCustomProfileReport $plan $script:ResultsPath
}
}
if ($script:PlanPath) {
$result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:PlanPath -Encoding UTF8 -ErrorAction Stop
if ($script:ProfileFile) {
if ($Command -ne 'configure') { Assert-WelaCustomProfileSource $custom.customSource }
Write-WelaCustomProfileReport $result $script:PlanPath
} else { $result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:PlanPath -Encoding UTF8 -ErrorAction Stop }
Write-Host "Machine-readable result: $($script:PlanPath)"
}
if ($Command -eq 'configure' -and $result.ExitCode -ne 0) { throw "One or more advanced audit policies failed. See the effective-state results." }
@@ -1797,6 +1835,8 @@ Usage:
# SMB auditing is opt-in and never changes signing/encryption requirements or guest access.
./WELA.ps1 ad-object-sacl -AdSaclAction Plan -AdServer dc01.example.test -AdSaclProfile MdiDomain
./WELA.ps1 profiles # List versioned advanced audit-policy profiles
./WELA.ps1 profiles -ProfileFile config/custom-audit-profile.example.json
./WELA.ps1 plan -Profile custom-example -ProfileFile config/custom-audit-profile.example.json -Role Client -Build 26100
./WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json
./WELA.ps1 audit-settings -Profile microsoft-sct-win11-24h2 -PlanPath audit.json
./WELA.ps1 configure -Profile asd-native-2021-10 -PlanPath result.json -Auto
@@ -1830,6 +1870,14 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
if ($PSBoundParameters.ContainsKey('ProfileFile')) {
if ([string]::IsNullOrWhiteSpace($ProfileFile) -or $Cmd -notin @('profiles','plan','audit','audit-settings','configure')) { throw '-ProfileFile requires profiles, plan, audit, audit-settings or configure. No command was run.' }
if ($Baseline -or ($Cmd -ne 'profiles' -and -not $Profile)) { throw '-ProfileFile requires an explicit -Profile and cannot be combined with -Baseline (profiles lists the file). No command was run.' }
$allowed = @('Cmd','Profile','ProfileFile','Role','Build','PlanPath','IncludeOptional','SaclMode','Auto','DryRun','BackupPath','ResultsPath','Help')
if (@($PSBoundParameters.Keys | Where-Object { $_ -notin $allowed }).Count) { throw 'Unsupported option for custom audit profiles. No command was run.' }
if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' }
}
if ($Cmd -ne 'native-validation' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('ProbeAction','ProbeOutputPath','ProbeTimeoutSeconds') }).Count) {
throw 'Probe options require native-validation. No command was run.'
}
@@ -2129,7 +2177,9 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode -ne 0) { throw 'AppLocker assessment/import failed; see structured results.' }
}
"profiles" {
(Import-WelaAuditProfiles).profiles | Select-Object id, version, scope, appliesTo | Format-List
$data = if ($ProfileFile) { Import-WelaCustomAuditProfiles -Path $ProfileFile } else { Import-WelaAuditProfiles }
if ($ProfileFile) { $data.customSource | Format-List }
$data.profiles | Select-Object id, version, scope, appliesTo | Format-List
}
"plan" { Invoke-WelaProfileCommand -Command 'plan' }
"audit" { Invoke-WelaProfileCommand -Command 'audit' }
@@ -2196,6 +2246,7 @@ switch ($Cmd.ToLower()) {
Write-Host ""
Write-Host "Options:"
Write-Host " -Profile Configure advanced audit policy and precedence from a versioned profile; list IDs with profiles"
Write-Host " -ProfileFile Select a validated custom JSON profile file; requires an explicit -Profile"
Write-Host " -Auto Automatically configure without prompts"
Write-Host " -OutgoingNtlmMode PreserveOrAudit (default): audit, preserving existing deny; Audit: explicitly replace deny; Deny: opt into enforcement"
Write-Host " -DryRun Read live state and report proposed changes without writing Windows settings"
+81
View File
@@ -0,0 +1,81 @@
{
"schemaVersion": 1,
"kind": "WelaCustomAuditProfiles",
"catalog": [
{
"id": "Process Creation",
"guid": "0CCE922B-69AE-11D9-BED3-505054503030",
"category": "Detailed Tracking"
},
{
"id": "Process Termination",
"guid": "0CCE922C-69AE-11D9-BED3-505054503030",
"category": "Detailed Tracking"
},
{
"id": "Detailed File Share",
"guid": "0CCE9244-69AE-11D9-BED3-505054503030",
"category": "Object Access"
},
{
"id": "File System",
"guid": "0CCE921D-69AE-11D9-BED3-505054503030",
"category": "Object Access"
}
],
"sources": {
"organization": {
"title": "Example organization audit standard (replace with your reviewed source)",
"version": "1.0",
"url": "https://example.invalid/security/audit-standard"
}
},
"profiles": [
{
"id": "custom-example",
"version": "1.0",
"sourceIds": [
"organization"
],
"omitted": "unchanged",
"scope": "advanced-audit-policy-only",
"appliesTo": [
{
"roles": [
"Client"
],
"minBuild": 26100,
"maxBuild": 26200
},
{
"roles": [
"MemberServer",
"DomainController",
"ADCS"
],
"minBuild": 20348,
"maxBuild": 26100
}
],
"controls": {
"Process Creation": {
"mode": "minimum",
"mask": 1
},
"File System": {
"mode": "optional",
"mask": 3
},
"Detailed File Share": {
"mode": "not-configured"
},
"Process Termination": {
"mode": "exact",
"mask": 1
}
},
"roleOverrides": {},
"note": "Example only: review before configuring. File System needs matching SACLs."
}
]
}
+2
View File
@@ -4,6 +4,8 @@
**Profile definitions cover advanced audit policy.** Configuration also verifies and enables its `SCENoApplyLegacyAuditPolicy=1` DWORD prerequisite before applying subcategories. Selecting Microsoft, CIS or ASD does not configure their PowerShell settings, command-line capture, channel buffers, NTLM policy, firewall logs, SACLs, CA AuditFilter, forwarding or retention. This is not a claim of full baseline compliance or detection coverage. Sysmon and external sensors are outside this feature. Ordinary `configure` without `-Profile` continues the existing broader WELA setup, with its advanced audit portion supplied by the shared profile.
For operator-owned settings, see [custom profile files](custom-audit-profiles.md). `-ProfileFile` selects a strictly validated file without editing or overriding built-in profiles.
## Commands
```powershell
+116
View File
@@ -0,0 +1,116 @@
# Custom advanced audit profiles
`-ProfileFile` selects an operator-owned JSON file for `profiles`, `plan`,
`audit-settings` (also `audit`) and `configure`. Built-in files and legacy baseline
outputs are unchanged. Custom profiles cover advanced Security audit policy and
its precedence prerequisite only. Sysmon is excluded; channel settings, SACL
writes, command-line capture, forwarding and other native settings retain their
separate commands. A matching configuration does not establish Sigma readiness.
```powershell
./WELA.ps1 profiles -ProfileFile config/custom-audit-profile.example.json
./WELA.ps1 plan -Profile custom-example -ProfileFile config/custom-audit-profile.example.json -Role Client -Build 26100 -PlanPath custom-plan.json
./WELA.ps1 audit-settings -Profile custom-example -ProfileFile C:\Policy\organization.json -PlanPath custom-audit.json
./WELA.ps1 configure -Profile custom-example -ProfileFile C:\Policy\organization.json -DryRun -ResultsPath preview.json
./WELA.ps1 configure -Profile custom-example -ProfileFile C:\Policy\organization.json -BackupPath C:\Policy\new-recovery-directory -ResultsPath result.json
```
Copy and review the example before configuration. Its placeholder source URL is
not a real standard. The example requests minimum Process Creation Success,
exact Process Termination Success, optional File System Success/Failure, and
preserves Detailed File Share. Exact settings may remove existing auditing;
minimum settings preserve extra enabled flags. File System remains unchanged
unless `-IncludeOptional` is selected and requires matching object SACLs for useful
events. Every omitted subcategory remains explicitly unchanged or role-inapplicable.
`-Profile` is mandatory except when listing the file. The selected ID must belong
to that file; WELA does not fall back to a built-in profile or merge definitions.
Built-in IDs cannot be redefined. `-Baseline` and unrelated command options cannot
be combined with `-ProfileFile`. Supply both role/build for offline plans or omit
both for native detection. Explicit custom applicability is operator-declared;
it is not a claim that WELA or Microsoft tested that build. Live application still
requires the actual role/build to match and refuses unreadable current state.
## File format
Use UTF-8 strict JSON, at most 1 MiB and 20 nesting levels, with:
- `schemaVersion: 1` and `kind: "WelaCustomAuditProfiles"`.
- `catalog`: 1–59 references, each containing exact canonical `id` (subcategory
name), `guid` and `category`. List every control used anywhere in the file.
GUID casing is immaterial; names/category spelling must be exact. The full
authoritative catalog supplies supported roles and prerequisites: custom files
cannot replace those fields or introduce arbitrary GUIDs.
- `sources`: a nonempty object keyed by lowercase source IDs. Each source requires
nonempty `title`, `version` and an absolute HTTPS `url`. URLs are references only;
WELA never fetches them. Source identity remains operator-declared.
- `profiles`: 1–128 objects with unique lowercase IDs, `version`, `sourceIds`,
`omitted: "unchanged"`, `scope: "advanced-audit-policy-only"`, `appliesTo`,
`controls`, and `roleOverrides`. Optional `note` is text; optional `referenceOnly`
is boolean and prevents configuration when true.
Each applicability range declares `roles` and integer `minBuild`/`maxBuild` within
1–999999. Roles are Client, MemberServer, DomainController and ADCS (a member-server
CA). Combined DC/CA detection remains unsupported. Each `controls` entry uses a
catalog name and `{ "mode": ..., "mask": ... }`; source IDs, evidence and notes can
also be attached to a control. Role overrides use the same control schema.
| Mode | Mask | Behavior |
|---|---|---|
| exact | Integer 0–3 | Exact required success/failure flags; may remove existing flags |
| minimum | Integer 0–3 | Enable only required flags, preserving additional auditing |
| optional | Integer 0–3 | Preserve unless `-IncludeOptional`, then apply the exact mask |
| unchanged | Omitted | Preserve current policy |
| not-configured | Omitted | Preserve effective policy; does not remove GPO/MDM configuration |
| not-applicable | Omitted | Skip this control |
Success is 1, Failure is 2, both is 3, neither is 0. Duplicate/case-colliding JSON
properties, duplicated IDs/GUIDs, unknown fields, invalid source references,
undeclared controls, coercible string/boolean masks and unsupported types are
rejected. Input is parsed as data; strings containing script syntax are never
executed. Executable hooks and custom command strings are not supported.
JavaScript extensions such as single-quoted or unquoted property names are also
rejected before duplicate-key checks; they cannot hide a second audit mask.
For canonical identifiers, inspect `config/audit_profiles.json` or run:
```powershell
Import-Module ./modules/AuditProfiles.psm1
(Import-WelaAuditProfiles).catalog | Select-Object id, guid, category, roles, prerequisites
```
## Verification and recovery
Strict file validation runs before host reads. An explicitly supplied unsupported
role/build is rejected at that stage; otherwise native detection supplies context.
Plans and results record the exact selected-file SHA-256, canonical-catalog
SHA-256, profile version and declared sources. Output/backup paths cannot equal the
selected input or canonical catalog. No built-in profile file is written.
For custom profiles, `-ResultsPath` and `-PlanPath` must name distinct **new local
files under existing directories**. Existing outputs are preserved, including
hard-link or symlink aliases of an input. Reparse-point directory ancestry is
refused, and final output uses `CreateNew` rather than overwriting a file created
after the initial check. Choose fresh names for repeated runs. Both successful
and failed configuration results can be written without altering the input;
the result's source fingerprint describes the policy that was assessed.
The shared configuration engine checks file fingerprints and actual role/build
before each control, after confirmation/journaling but before each write, and at
read-back/final verification. Precedence must be verified before dependent audit
writes. Every pre-change journal entry records input provenance. Minimum writes
only enable required native flags, and verification accepts compliant supersets.
Changed, deleted or unreadable input stops subsequent writes and reports failure;
already applied changes remain recorded for recovery. Checks are observations,
not atomic protection against a privileged writer replacing files between checks.
Keep the policy directory controlled during the operation.
Review `before.jsonl` and restore only the recorded preceding precedence value/type
and audit flags through your approved recovery process. WELA does not automatically
undo partially applied changes, restore a GPO, or invoke policy refresh. Re-run the
assessment after GPO/MDM refresh to verify effective state.
Tests exercise malformed files, preservation modes, validation ordering, mocked
writes, prompt-time file changes and final drift. Windows CI performs real read-only
custom-profile audits on Server 2022/2025 with PowerShell 5.1/7. Configuration and
benign event/backend acceptance on Windows 11, DC and AD CS labs remain separate;
no clean-install or detection-coverage claim is made.
+21 -4
View File
@@ -1,5 +1,6 @@
# Requires Windows PowerShell 5.1 or PowerShell 7. No Windows dependency for schema/planning.
Set-StrictMode -Version 2.0
. (Join-Path $PSScriptRoot '../scripts/CustomAuditProfiles.ps1')
function Get-WelaProperty {
param($Object, [string]$Name, $Default = $null)
@@ -68,9 +69,10 @@ function Get-WelaAuditProfilePlan {
[Parameter(Mandatory)][ValidateSet('Client', 'MemberServer', 'DomainController', 'ADCS')][string]$Role,
[Parameter(Mandatory)][ValidateRange(1, 999999)][int]$Build,
[hashtable]$Current = @{}, [switch]$IncludeOptional,
[string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json')
[string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json'),
[switch]$CustomFile
)
$data = Import-WelaAuditProfiles -Path $Path
$data = if ($CustomFile) { Import-WelaCustomAuditProfiles -Path $Path } else { Import-WelaAuditProfiles -Path $Path }
$selected = @($data.profiles | Where-Object { $_.id -eq $Profile })
if ($selected.Count -ne 1) { throw "Unknown audit profile '$Profile'. Use -Cmd profiles to list profiles." }
$selected = $selected[0]
@@ -111,13 +113,19 @@ function Get-WelaAuditProfilePlan {
}
$sourceIds = @($rows | ForEach-Object { $_.sourceIds } | Select-Object -Unique)
$sources = foreach ($id in $sourceIds) { [pscustomobject]@{ id = $id; source = $data.sources.$id } }
[pscustomobject][ordered]@{
$plan = [pscustomobject][ordered]@{
schemaVersion = 1; profile = $selected.id; version = $selected.version
scope = $selected.scope; role = $Role; build = $Build; includeOptional = [bool]$IncludeOptional
referenceOnly = [bool](Get-WelaProperty $selected 'referenceOnly' $false)
generatedUtc = [DateTime]::UtcNow.ToString('o'); schemaSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
note = Get-WelaProperty $selected 'note' ''; provenance = @($sources); policies = @($rows)
}
if ($CustomFile) {
$plan.schemaSha256 = $data.customSource.Sha256
$plan | Add-Member NoteProperty CustomProfileSource $data.customSource
Assert-WelaCustomProfileSource $data.customSource
}
return $plan
}
function Get-WelaEffectiveAuditPolicy {
@@ -244,6 +252,7 @@ function Get-WelaHostContext {
function Assert-WelaAuditProfileTarget {
[CmdletBinding()]
param([Parameter(Mandatory)]$Plan, [Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$Current)
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
if ($Plan.referenceOnly) { throw 'Windows defaults are a reference, not an apply/restore profile.' }
if ($Context.Role -ne $Plan.role -or $Context.Build -ne $Plan.build) { throw 'Plan role/build does not match the actual Windows host.' }
if ($Current -isnot [hashtable]) { throw 'Effective policy reader did not return a GUID-to-mask map.' }
@@ -261,6 +270,7 @@ function Invoke-WelaAuditProfilePlan {
[scriptblock]$WritePolicy,
[scriptblock]$ReadContext = { Get-WelaHostContext }
)
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
$hostContext = & $ReadContext
$before = & $ReadPolicy
Assert-WelaAuditProfileTarget -Plan $Plan -Context $hostContext -Current $before
@@ -268,6 +278,7 @@ function Invoke-WelaAuditProfilePlan {
$results = foreach ($policy in $selected) {
$initial = $null; $effective = $null; $target = $null; $errorText = $null; $status = 'No change'
try {
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
# Whole-plan preflight is not a current-state cache: re-read immediately before each control.
$fresh = & $ReadPolicy
if ($fresh -isnot [hashtable] -or -not $fresh.ContainsKey($policy.guid) -or $null -eq $fresh[$policy.guid] -or $fresh[$policy.guid] -notin @(0, 1, 2, 3)) { throw 'Current audit policy became unknown before application.' }
@@ -276,6 +287,11 @@ function Invoke-WelaAuditProfilePlan {
$target = if ($isMinimum) { [int]$initial -bor [int]$policy.requiredMask } else { [int]$policy.requiredMask }
if ($initial -ne $target) {
if ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) {
if ($Plan.PSObject.Properties['CustomProfileSource']) {
Assert-WelaCustomProfileSource $Plan.CustomProfileSource
$freshContext = & $ReadContext
if ($freshContext.Role -ne $Plan.role -or $freshContext.Build -ne $Plan.build) { throw 'Custom profile target changed before application.' }
}
$writeMode = if ($isMinimum) { 'minimum' } else { 'exact' }
if ($WritePolicy) {
# Existing two-argument test providers retain their merged-mask contract.
@@ -286,6 +302,7 @@ function Invoke-WelaAuditProfilePlan {
Set-WelaEffectiveAuditPolicy -Guid $policy.guid -Mask $writeMask -Mode $writeMode
}
$verified = & $ReadPolicy
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
$effective = if ($verified -is [hashtable] -and $verified.ContainsKey($policy.guid)) { $verified[$policy.guid] } else { $null }
if ($null -eq $effective -or $effective -notin @(0, 1, 2, 3)) { throw 'Effective policy is unknown after application.' }
$matches = if ($isMinimum) { ([int]$effective -band [int]$policy.requiredMask) -eq [int]$policy.requiredMask } else { $effective -eq $target }
@@ -308,4 +325,4 @@ function Invoke-WelaAuditProfilePlan {
}
}
Export-ModuleMember -Function Import-WelaAuditProfiles, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan
Export-ModuleMember -Function Import-WelaAuditProfiles, Import-WelaCustomAuditProfiles, Assert-WelaCustomProfileSource, Get-WelaCustomReportPath, Write-WelaCustomProfileReport, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan
+24
View File
@@ -33,6 +33,16 @@ function New-WelaConfigurationContext {
}
}
function Assert-WelaConfigurationProfileGuard {
param($Context)
if ($Context.PSObject.Properties['CustomProfileGuard']) {
$guard = $Context.CustomProfileGuard
Assert-WelaCustomProfileSource $guard.Source
$actual = Get-WelaHostContext
if ($actual.Role -ne $guard.Role -or $actual.Build -ne $guard.Build) { throw 'Custom profile target role/build changed; no further configuration is authorized.' }
}
}
function Invoke-WelaConfigurationControl {
param($Context, [string]$Id, [string]$Kind, $Target, $Desired,
[scriptblock]$Read, [scriptblock]$Compliant, [scriptblock]$Apply,
@@ -42,6 +52,7 @@ function Invoke-WelaConfigurationControl {
Before = $null; After = $null; Status = 'Failed'; Diagnostic = ''
}
try {
Assert-WelaConfigurationProfileGuard $Context
$result.Before = & $Read $CallbackState
$preserveReason = if ($PreserveWhen) { & $PreserveWhen $result.Before } else { $null }
if ($preserveReason) {
@@ -68,12 +79,15 @@ function Invoke-WelaConfigurationControl {
Id = $Id; Kind = $Kind; Target = $Target
Before = $result.Before; Desired = $Desired
}
if ($Context.PSObject.Properties['CustomProfileGuard']) { $entry.CustomProfileSource = $Context.CustomProfileGuard.Source }
$entry | ConvertTo-Json -Depth 12 -Compress |
Add-Content -LiteralPath (Join-Path $Context.BackupPath 'before.jsonl') -Encoding UTF8 -ErrorAction Stop
Assert-WelaConfigurationProfileGuard $Context
$applied = @(& $Apply $CallbackState)
$result.Diagnostic = ($applied | ForEach-Object {
if ($_.PSObject.Properties['Diagnostic']) { $_.Diagnostic } else { $_.ToString() }
}) -join [Environment]::NewLine
Assert-WelaConfigurationProfileGuard $Context
$result.After = & $Read $CallbackState
if (-not (& $Compliant $result.After $CallbackState)) {
throw "Post-apply verification did not match the requested state. $($result.Diagnostic)"
@@ -97,10 +111,15 @@ function Complete-WelaConfiguration {
[ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
if ($Context.PSObject.Properties['CustomProfileGuard']) {
try { Assert-WelaConfigurationProfileGuard $Context }
catch { $Context.Results.Add([pscustomobject]@{Id='CustomProfile/FinalValidation';Kind='ProfileSource';Target=$Context.CustomProfileGuard.Source;Desired='Unchanged file and target';Before=$null;After=$null;Status='Failed';Diagnostic=$_.ToString()}) }
}
# A second read detects a value that was compliant earlier but changed during
# this run. It does not establish whether GPO or another writer caused drift.
foreach ($check in $Context.Checks) {
try {
Assert-WelaConfigurationProfileGuard $Context
$check.Result.After = & $check.Read $check.CallbackState
if (-not (& $check.Compliant $check.Result.After $check.CallbackState)) {
$check.Result.Status = 'Overridden'
@@ -126,6 +145,7 @@ function Complete-WelaConfiguration {
$report | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256
$report | Add-Member NoteProperty Provenance $Plan.provenance
$report | Add-Member NoteProperty ProfileScope $Plan.scope
if ($Plan.PSObject.Properties['CustomProfileSource']) { $report | Add-Member NoteProperty CustomProfileSource $Plan.CustomProfileSource }
}
if ($ResultsPath) {
try { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
@@ -299,6 +319,10 @@ function Set-WelaAuditPolicyControl {
function Set-WelaProfileAuditControls {
param($Context, $Plan)
if ($Plan.PSObject.Properties['CustomProfileSource']) {
$Context | Add-Member NoteProperty CustomProfileGuard ([pscustomobject]@{Source=$Plan.CustomProfileSource;Role=$Plan.role;Build=$Plan.build}) -Force
Assert-WelaConfigurationProfileGuard $Context
}
# The caller must complete Assert-WelaAuditProfileTarget before any mutations.
$selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) })
if ($selected.Count -eq 0) { return }
+172
View File
@@ -0,0 +1,172 @@
# Loaded inside AuditProfiles.psm1. Custom profiles are data, never scripts.
function Assert-WelaCustomObject {
param($Object,[string[]]$Allowed,[string[]]$Required=@())
if ($Object -isnot [pscustomobject]) { throw 'Expected a custom-profile JSON object.' }
$keys=@($Object.PSObject.Properties | ForEach-Object { $_.Name })
foreach ($key in $keys) { if ($key -cnotin $Allowed) { throw "Unknown custom-profile property: $key" } }
foreach ($key in $Required) { if ($key -cnotin $keys) { throw "Missing custom-profile property: $key" } }
}
function Assert-WelaCustomText {
param($Value,[string]$Field)
if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt 2048) { throw "Invalid custom-profile text: $Field" }
}
function Assert-WelaCustomStringArray {
param($Values,[string[]]$Allowed,[switch]$AllowEmpty)
if ($Values -isnot [array] -or (-not $AllowEmpty -and $Values.Count -eq 0)) { throw 'Expected a nonempty custom-profile array.' }
$seen=@{}
foreach ($value in $Values) {
if ($value -isnot [string] -or $value -cnotin $Allowed -or $seen.ContainsKey($value)) { throw "Unknown or duplicate custom-profile array value: $value" }
$seen[$value]=$true
}
}
function ConvertFrom-WelaCustomProfileJson {
param([string]$Text)
# ConvertFrom-Json accepts some JavaScript extensions (including single-quoted
# and bare property names). Validate the entire JSON token stream first, so
# those forms cannot bypass duplicate-property tracking below.
$lexical=[regex]'\G(?:[ \t\r\n]+|"(?:\\["\\/bfnrt]|\\u[0-9A-Fa-f]{4}|[^"\\\x00-\x1f])*"|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?(?![A-Za-z0-9_.+-])|(?:true|false|null)(?![A-Za-z0-9_])|[{}\[\]:,])'
$position=0
while ($position -lt $Text.Length) {
$match=$lexical.Match($Text,$position)
if (-not $match.Success -or $match.Index -ne $position) { throw 'Custom profiles require strict JSON tokens; JavaScript extensions and invalid escapes are not supported.' }
$position+=$match.Length
}
# Match JSON strings first; braces/property-looking text inside strings is inert.
$withoutStrings=[regex]::Replace($Text,'"(?:\\.|[^"\\])*"','""')
if ($withoutStrings -match '//|/\*|,\s*[}\]]') { throw 'Custom profiles require strict JSON without comments or trailing commas.' }
$tokens=[regex]::Matches($Text,'"(?:\\.|[^"\\])*"|[{}\[\]:,]')
$stack=New-Object 'System.Collections.Generic.Stack[object]'
for ($i=0;$i -lt $tokens.Count;$i++) {
$token=$tokens[$i].Value
if ($token -eq '{') { $stack.Push(@{}) }
elseif ($token -eq '[') { $stack.Push($null) }
elseif ($token -in @('}',']')) { if (-not $stack.Count) { throw 'Unbalanced custom-profile JSON.' }; $null=$stack.Pop() }
elseif ($token.StartsWith('"') -and $i+1 -lt $tokens.Count -and $tokens[$i+1].Value -eq ':') {
if (-not $stack.Count -or $null -eq $stack.Peek()) { throw 'JSON property outside object.' }
$holder=ConvertFrom-Json -InputObject ('{'+$token+':null}') -ErrorAction Stop
$name=@($holder.PSObject.Properties.Name)[0]
if ($stack.Peek().ContainsKey($name)) { throw "Duplicate or case-colliding custom-profile property: $name" }
$stack.Peek()[$name]=$true
}
if ($stack.Count -gt 20) { throw 'Custom-profile nesting exceeds 20 levels.' }
}
ConvertFrom-Json -InputObject $Text -ErrorAction Stop
}
function Get-WelaCustomFileHash {
param([byte[]]$Bytes)
$algorithm=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant() } finally { $algorithm.Dispose() }
}
function Import-WelaCustomAuditProfiles {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Path)
$ErrorActionPreference='Stop'
$file=Get-Item -LiteralPath $Path -ErrorAction Stop
if ($file -isnot [IO.FileInfo] -or $file.Length -lt 1 -or $file.Length -gt 1048576) { throw 'Custom profile must be a nonempty JSON file no larger than 1 MiB.' }
$bytes=[IO.File]::ReadAllBytes($file.FullName)
if ($bytes.Length -gt 1048576) { throw 'Custom profile grew beyond 1 MiB.' }
$utf8=New-Object Text.UTF8Encoding($false,$true)
$data=ConvertFrom-WelaCustomProfileJson ($utf8.GetString($bytes).TrimStart([char]0xFEFF))
Assert-WelaCustomObject $data @('schemaVersion','kind','catalog','sources','profiles') @('schemaVersion','kind','catalog','sources','profiles')
if (($data.schemaVersion -isnot [int] -and $data.schemaVersion -isnot [long]) -or $data.schemaVersion -ne 1 -or $data.kind -cne 'WelaCustomAuditProfiles') { throw 'Unsupported custom-profile kind/schema version.' }
$canonicalPath=Join-Path $PSScriptRoot '../config/audit_profiles.json'
$canonicalHash=(Get-FileHash -LiteralPath $canonicalPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
$builtin=Import-WelaAuditProfiles
if ((Get-FileHash -LiteralPath $canonicalPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $canonicalHash) { throw 'Canonical profile catalog changed during validation.' }
$canonical=@{}; foreach ($entry in $builtin.catalog) { $canonical[$entry.id]=$entry }
if ($data.catalog -isnot [array] -or $data.catalog.Count -lt 1 -or $data.catalog.Count -gt 59) { throw 'Declare 1..59 canonical custom catalog references.' }
$declared=@{}; $guids=@{}
foreach ($entry in $data.catalog) {
Assert-WelaCustomObject $entry @('id','guid','category') @('id','guid','category')
Assert-WelaCustomText $entry.id 'catalog id'
if (-not $canonical.ContainsKey($entry.id) -or $canonical[$entry.id].id -cne $entry.id -or $entry.guid -isnot [string] -or
$entry.guid -ine $canonical[$entry.id].guid -or $entry.category -cne $canonical[$entry.id].category) { throw "Canonical name/GUID/category mismatch: $($entry.id)" }
if ($declared.ContainsKey($entry.id) -or $guids.ContainsKey($entry.guid)) { throw 'Duplicate custom catalog name/GUID.' }
$declared[$entry.id]=$true; $guids[$entry.guid]=$true
}
if ($data.sources -isnot [pscustomobject] -or @($data.sources.PSObject.Properties).Count -eq 0) { throw 'Custom sources are required.' }
$sourceIds=@($data.sources.PSObject.Properties.Name)
foreach ($source in $data.sources.PSObject.Properties) {
if ($source.Name -cnotmatch '\A[a-z][a-z0-9-]{0,63}\z') { throw 'Invalid custom source id.' }
Assert-WelaCustomObject $source.Value @('title','version','url') @('title','version','url')
foreach ($field in @('title','version','url')) { Assert-WelaCustomText $source.Value.$field $field }
$uri=$null
if (-not [Uri]::TryCreate($source.Value.url,[UriKind]::Absolute,[ref]$uri) -or $uri.Scheme -ne 'https') { throw 'Source URL must be an absolute HTTPS reference; it is not fetched.' }
}
if ($data.profiles -isnot [array] -or $data.profiles.Count -lt 1 -or $data.profiles.Count -gt 128) { throw 'Custom file requires 1..128 profiles.' }
$ids=@{}; $roles=@('Client','MemberServer','DomainController','ADCS')
foreach ($profile in $data.profiles) {
Assert-WelaCustomObject $profile @('id','version','sourceIds','omitted','scope','appliesTo','controls','roleOverrides','note','referenceOnly') @('id','version','sourceIds','omitted','scope','appliesTo','controls','roleOverrides')
Assert-WelaCustomText $profile.id 'profile id'; Assert-WelaCustomText $profile.version 'profile version'
if ($profile.id -cnotmatch '\A[a-z][a-z0-9-]{0,127}\z' -or $ids.ContainsKey($profile.id) -or $profile.id -in @($builtin.profiles.id)) { throw 'Duplicate, invalid or built-in custom profile id.' }
$ids[$profile.id]=$true
if ($profile.scope -cne 'advanced-audit-policy-only' -or $profile.omitted -cne 'unchanged') { throw 'Custom scope must be advanced-audit-policy-only with omitted unchanged.' }
if ($profile.PSObject.Properties['referenceOnly'] -and $profile.referenceOnly -isnot [bool]) { throw 'referenceOnly must be boolean.' }
if ($profile.PSObject.Properties['note'] -and $profile.note -isnot [string]) { throw 'Profile note must be text.' }
Assert-WelaCustomStringArray $profile.sourceIds $sourceIds
if ($profile.appliesTo -isnot [array] -or -not $profile.appliesTo.Count) { throw 'Custom profile applicability array is required.' }
foreach ($range in $profile.appliesTo) {
Assert-WelaCustomObject $range @('roles','minBuild','maxBuild') @('roles','minBuild','maxBuild')
Assert-WelaCustomStringArray $range.roles $roles
foreach ($field in @('minBuild','maxBuild')) { if (($range.$field -isnot [int] -and $range.$field -isnot [long]) -or $range.$field -lt 1 -or $range.$field -gt 999999) { throw 'Custom build bounds must be integers in 1..999999.' } }
if ($range.maxBuild -lt $range.minBuild) { throw 'Reversed custom build range.' }
}
Assert-WelaCustomObject $profile.roleOverrides $roles
$sets=@($profile.controls)+@($profile.roleOverrides.PSObject.Properties | ForEach-Object { $_.Value })
foreach ($set in $sets) {
Assert-WelaCustomObject $set @($declared.Keys)
foreach ($control in $set.PSObject.Properties) {
$value=$control.Value
Assert-WelaCustomObject $value @('mode','mask','note','evidence','sourceIds') @('mode')
if ($value.mode -cnotin @('exact','minimum','optional','unchanged','not-configured','not-applicable')) { throw 'Invalid custom policy mode.' }
$hasMask=$null -ne $value.PSObject.Properties['mask']
if ($value.mode -in @('exact','minimum','optional')) {
if (-not $hasMask -or ($value.mask -isnot [int] -and $value.mask -isnot [long]) -or $value.mask -notin @(0,1,2,3)) { throw 'Custom audit mask must be an integer 0..3.' }
} elseif ($hasMask) { throw 'Preserve/non-applicable modes must not specify a mask.' }
if ($value.PSObject.Properties['sourceIds']) { Assert-WelaCustomStringArray $value.sourceIds $sourceIds }
foreach ($field in @('note','evidence')) { if ($value.PSObject.Properties[$field] -and $value.$field -isnot [string]) { throw 'Control note/evidence must be text.' } }
}
}
}
# Roles and prerequisites come only from the authoritative bundled catalog.
$data.catalog=$builtin.catalog
$source=[pscustomobject]@{Path=$file.FullName;Sha256=(Get-WelaCustomFileHash $bytes);CanonicalPath=[IO.Path]::GetFullPath($canonicalPath);CanonicalSha256=$canonicalHash;Kind='OperatorCustomFile';Provenance='Operator-declared policy; not a Microsoft/CIS/ASD endorsement.'}
$data | Add-Member NoteProperty customSource $source
Assert-WelaCustomProfileSource $source
return $data
}
function Assert-WelaCustomProfileSource {
[CmdletBinding()]
param([Parameter(Mandatory)]$Source)
foreach ($entry in @(@($Source.Path,$Source.Sha256),@($Source.CanonicalPath,$Source.CanonicalSha256))) {
if ((Get-FileHash -LiteralPath $entry[0] -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $entry[1]) { throw 'Custom profile or canonical catalog changed since validation; no further configuration is authorized by this plan.' }
}
}
function Get-WelaCustomReportPath {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Path)
$provider=$null;$drive=$null
$full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
if ($provider.Name -ne 'FileSystem' -or $full -match '^[\\/]{2}') { throw 'Custom profile reports require a local filesystem path.' }
# New output files also prevent hard-link aliases from overwriting a source or
# the canonical catalog. A final CreateNew open closes the file-existence race.
if (Test-Path -LiteralPath $full -ErrorAction Stop) { throw 'Custom profile report output already exists; select a new file to preserve inputs and prior evidence.' }
$parent=[IO.DirectoryInfo]([IO.Path]::GetDirectoryName($full))
if (-not $parent.Exists) { throw 'Custom profile report parent directory must exist.' }
while ($parent) {
if ($parent.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Custom profile reports cannot traverse symlink or reparse-point directories.' }
$parent=$parent.Parent
}
return $full
}
function Write-WelaCustomProfileReport {
[CmdletBinding()]
param([Parameter(Mandatory)]$Report,[Parameter(Mandatory)][string]$Path)
$full=Get-WelaCustomReportPath $Path
$text=$Report | ConvertTo-Json -Depth 20
$bytes=(New-Object Text.UTF8Encoding($false)).GetBytes($text)
$stream=[IO.File]::Open($full,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try { $stream.Write($bytes,0,$bytes.Length);$stream.Flush($true) } finally { $stream.Dispose() }
}
+188
View File
@@ -0,0 +1,188 @@
$ErrorActionPreference='Stop'
$root=Split-Path $PSScriptRoot -Parent
$script:ScriptRoot=$root
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
. (Join-Path $root 'scripts/Configuration.ps1')
$script:count=0
function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ }
function Throws($Action,$Pattern) { $message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message }; Assert ($message -match $Pattern) "Expected $Pattern; got $message" }
function Copy-Fixture($Object) { $Object | ConvertTo-Json -Depth 20 | ConvertFrom-Json }
$sample=Get-Content -LiteralPath (Join-Path $root 'config/custom-audit-profile.example.json') -Raw | ConvertFrom-Json
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-custom-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $temp
$script:file=Join-Path $temp 'profile.json'
function Save($Value=$sample) { $Value | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:file -Encoding UTF8 }
function Bad($Edit,$Pattern) { $value=Copy-Fixture $sample; & $Edit $value; Save $value; Throws {Import-WelaCustomAuditProfiles $script:file} $Pattern }
try {
Save
$data=Import-WelaCustomAuditProfiles $script:file
Assert ($data.catalog.Count -eq 59 -and $data.customSource.Sha256.Length -eq 64 -and $data.customSource.Provenance -match 'Operator-declared') 'Custom references resolve to full authoritative catalog and exact-byte provenance.'
Assert (($data.catalog | Where-Object id -eq 'File System').prerequisites -match 'SACL') 'Custom file cannot erase canonical SACL prerequisites.'
$script:zero=@{}; foreach ($row in $data.catalog) {$script:zero[$row.guid]=0}
$process=($data.catalog | Where-Object id -eq 'Process Creation').guid
$termination=($data.catalog | Where-Object id -eq 'Process Termination').guid
$fileSystem=($data.catalog | Where-Object id -eq 'File System').guid
function Plan { Get-WelaAuditProfilePlan -Profile custom-example -Role Client -Build 26100 -Path $script:file -CustomFile -Current $script:state }
$script:state=$script:zero.Clone();$script:state[$process]=2
$plan=Plan
Assert (($plan.policies | Where-Object id -eq 'Process Creation').targetMask -eq 3) 'Minimum preserves an existing Failure flag.'
Assert (($plan.policies | Where-Object id -eq 'File System').action -eq 'Optional (not selected)') 'Unselected optional policy is preserved.'
Assert (($plan.policies | Where-Object id -eq 'Detailed File Share').action -eq 'Preserve') 'Not configured is never disabled.'
Assert (($plan.policies | Where-Object id -eq 'Kerberos Authentication Service').mode -eq 'not-applicable') 'Canonical DC applicability cannot be expanded by client source.'
$optional=Get-WelaAuditProfilePlan -Profile custom-example -Role Client -Build 26100 -Path $script:file -CustomFile -Current $script:state -IncludeOptional
Assert (($optional.policies | Where-Object id -eq 'File System').targetMask -eq 3) 'Explicit IncludeOptional selects the exact object-audit mask, retaining its SACL prerequisite.'
$override=Copy-Fixture $sample
$override.profiles[0].roleOverrides | Add-Member MemberServer ([pscustomobject]@{'Process Creation'=[pscustomobject]@{mode='exact';mask=2}})
Save $override
$member=Get-WelaAuditProfilePlan -Profile custom-example -Role MemberServer -Build 20348 -Path $script:file -CustomFile -Current $script:state
Assert (($member.policies | Where-Object id -eq 'Process Creation').requiredMask -eq 2 -and (Plan | Select-Object -ExpandProperty policies | Where-Object id -eq 'Process Creation').requiredMask -eq 1) 'Role override changes only its selected role.'
$reference=Copy-Fixture $sample; $reference.profiles[0] | Add-Member referenceOnly $true; Save $reference
$referencePlan=Plan
Throws {Assert-WelaAuditProfileTarget -Plan $referencePlan -Context ([pscustomobject]@{Role='Client';Build=26100}) -Current $script:state} 'reference'
Save
Bad {param($x) $x.schemaVersion='1'} 'schema'
Bad {param($x) $x | Add-Member command 'whoami'} 'Unknown'
Bad {param($x) $x.catalog[0].guid='0CCE922E-69AE-11D9-BED3-505054503030'} 'mismatch'
Bad {param($x) $x.catalog[0].id='process creation'} 'mismatch'
Bad {param($x) $x.catalog[0].category='Other'} 'mismatch'
Bad {param($x) $x.catalog[0] | Add-Member prerequisites ''} 'Unknown'
Bad {param($x) $x.catalog+=@($x.catalog[0])} 'Duplicate'
Bad {param($x) $x.profiles+=@($x.profiles[0])} 'Duplicate'
Bad {param($x) $x.profiles[0].id='wela-2.2.0'} 'built-in'
Bad {param($x) $x.profiles[0].sourceIds=@('missing')} 'Unknown'
Bad {param($x) $x.sources.organization.version=$null} 'text'
Bad {param($x) $x.sources.organization.url='file:///tmp/script.ps1'} 'HTTPS'
Bad {param($x) $x.profiles[0].appliesTo[0].minBuild='26100'} 'integers'
Bad {param($x) $x.profiles[0].appliesTo[0].maxBuild=1} 'Reversed'
Bad {param($x) $x.profiles[0].appliesTo[0].roles=@('Client','Client')} 'duplicate'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mask='1'} 'integer'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mask=$true} 'integer'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mask=4} 'integer'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mode='enable'} 'mode'
Bad {param($x) $x.profiles[0].controls.'Detailed File Share' | Add-Member mask 0} 'must not'
Bad {param($x) $x.profiles[0].controls | Add-Member 'RPC Events' ([pscustomobject]@{mode='exact';mask=3})} 'Unknown'
Bad {param($x) $x.profiles[0].controls.'Process Creation' | Add-Member script 'Write-Host bad'} 'Unknown'
Bad {param($x) $x.profiles[0] | Add-Member referenceOnly 'false'} 'boolean'
Save
# Pretty-print spacing differs between Windows PowerShell 5.1 and PowerShell 7.
# Compact JSON gives these lexical mutations stable tokens on both runtimes.
$text=$sample | ConvertTo-Json -Depth 20 -Compress
foreach ($badText in @($text.Replace('"mask":1','"mask":1,"MASK":2'),$text.Replace('"mask":1','"mask":1,"m\u0061sk":2'),$text.Replace('"schemaVersion":1','"schemaVersion":1,// comment'),$text.Replace('"mask":1','"mask":1,'))) {
Assert ($badText -cne $text) 'Malformed JSON fixture must change its input before rejection is tested.'
$badText | Set-Content -LiteralPath $script:file -Encoding UTF8
Throws {Import-WelaCustomAuditProfiles $script:file} 'Duplicate|strict JSON'
}
foreach ($badText in @($text.Replace('"schemaVersion"',"'schemaVersion'"),$text.Replace('"schemaVersion"','schemaVersion'),$text.Replace('"mask":1',"`"mask`":1,'mask':3"),$text.Replace('"mask":1','"mask":01'),$text.Replace('"mask":1','"mask":+1'))) {
Assert ($badText -cne $text) 'Invalid lexical JSON fixture must change its input before rejection is tested.'
$badText | Set-Content -LiteralPath $script:file -Encoding UTF8
Throws {Import-WelaCustomAuditProfiles $script:file} 'strict JSON'
}
$literal=Copy-Fixture $sample; $literal.profiles[0].note='$(throw "Never execute source data")'; Save $literal
Assert ((Import-WelaCustomAuditProfiles $script:file).profiles[0].note -ceq $literal.profiles[0].note) 'Executable-looking text stays literal inert metadata.'
Save; $source=(Import-WelaCustomAuditProfiles $script:file).customSource
Add-Content -LiteralPath $script:file -Value ' '
Throws {Assert-WelaCustomProfileSource $source} 'changed'
Save; $source=(Import-WelaCustomAuditProfiles $script:file).customSource; $source.CanonicalSha256='0'*64
Throws {Assert-WelaCustomProfileSource $source} 'changed'
$tokens=$null;$errors=$null
$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $root 'WELA.ps1'),[ref]$tokens,[ref]$errors)
Assert ($errors.Count -eq 0) 'Public CLI parses.'
foreach ($name in @('Get-WelaSelectedContext','Show-WelaAuditProfilePrerequisites','Invoke-WelaProfileCommand')) {
$node=$ast.Find({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true)
. ([scriptblock]::Create($node.Extent.Text))
}
$script:nativeReads=0; $script:writes=0; $script:hostBuild=26100; $script:precedence=1; $script:mutateAtPrompt=$false
function TestWindows {$true}
function TestAdministrator {$true}
function Get-WelaHostContext {$script:nativeReads++;[pscustomobject]@{Role='Client';Build=$script:hostBuild}}
function Get-WelaEffectiveAuditPolicy {$script:nativeReads++;$script:state.Clone()}
function Get-WelaNativeAuditPolicy {param($Guid) $script:state[$Guid]}
function Get-WelaAuditPrecedenceSource {[pscustomobject]@{Description='mock';ConflictsWithRequiredValue=$false}}
function Get-WelaRegistryState {param($Path,$Name) [pscustomobject]@{ValueExists=$true;KeyExists=$true;Value=$script:precedence;Type='DWord'}}
function New-WelaRegistryKey {param($Path)}
function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) $script:writes++;$script:precedence=$Value}
function Get-WelaTargetedSaclPlan {param($AuditPlan,$Mode,$Live) [pscustomobject]@{Mode='Skip';Targets=@();TelemetryGap='SACL proof absent'}}
function Invoke-WelaNative {
param($FilePath,$Arguments)
if ($FilePath -ne 'auditpol.exe') {throw 'Unexpected native command'}
$guid=($Arguments | Where-Object {$_ -like '/subcategory:*'}) -replace '^/subcategory:\{','' -replace '\}$',''
if ($Arguments -contains '/success:enable') {$script:state[$guid]=$script:state[$guid] -bor 1}
if ($Arguments -contains '/failure:enable') {$script:state[$guid]=$script:state[$guid] -bor 2}
if ($Arguments -contains '/success:disable') {$script:state[$guid]=$script:state[$guid] -band 2}
if ($Arguments -contains '/failure:disable') {$script:state[$guid]=$script:state[$guid] -band 1}
$script:writes++
}
function Read-Host {param($Prompt) if ($script:mutateAtPrompt) { Add-Content -LiteralPath $script:file -Value ' ';$script:mutateAtPrompt=$false };'y'}
$script:ProfileFile=$script:file;$script:Profile='custom-example';$script:Role='Client';$script:Build=26100
$script:Baseline=$null;$script:IncludeOptional=$false;$script:SaclMode='Skip';$script:Auto=$true;$script:DryRun=$true
$script:PlanPath=$null;$script:ResultsPath=$null;$script:BackupPath=$null
Save; $script:state=$script:zero.Clone()
$script:ResultsPath=Join-Path $temp 'readonly.json'
Invoke-WelaProfileCommand audit-settings | Out-Null
$readonly=Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($readonly.policies.Count -eq 59 -and $readonly.CustomProfileSource.Sha256) 'Custom read-only audit exports effective masks and selected source via ResultsPath.'
$script:ResultsPath=$null
Invoke-WelaProfileCommand configure | Out-Null
Assert ($script:writes -eq 0) 'Public custom configure DryRun performs no registry/audit mutation.'
Bad {param($x) $x.catalog[0].guid='bad'} 'mismatch'
$script:nativeReads=0
Throws {Invoke-WelaProfileCommand configure} 'mismatch'
Assert ($script:nativeReads -eq 0 -and $script:writes -eq 0) 'Malformed custom file is refused before host reads or configuration.'
Save;$script:Profile='wela-2.2.0'
Throws {Invoke-WelaProfileCommand configure} 'fallback'
Assert ($script:nativeReads -eq 0) 'Selected built-in profile cannot silently override file selection.'
$script:Profile='custom-example';$script:Build=17763
Throws {Invoke-WelaProfileCommand configure} 'does not support'
Assert ($script:nativeReads -eq 0) 'Explicit unsupported target is refused before host reads.'
$script:Build=26100;$script:ResultsPath=Join-Path $temp './profile.json'
Throws {Invoke-WelaProfileCommand configure} 'paths must differ'
$script:ResultsPath=$null
# Existing hard links are distinct names for the same source bytes. Neither
# output aliases nor input aliases may evade source protection before reads.
$alias=Join-Path $temp 'source-alias.json'
$null=New-Item -ItemType HardLink -Path $alias -Value $script:file
$sourceHash=(Get-FileHash $script:file).Hash;$script:nativeReads=0
$script:ResultsPath=$alias
Throws {Invoke-WelaProfileCommand plan} 'output already exists'
$script:ProfileFile=$alias;$script:ResultsPath=$script:file
Throws {Invoke-WelaProfileCommand plan} 'output already exists'
Assert ($script:nativeReads -eq 0 -and (Get-FileHash $script:file).Hash -ceq $sourceHash) 'Alias collisions preserve source bytes and fail before host reads.'
$script:ProfileFile=$script:file;$script:ResultsPath=$null
Remove-Item -LiteralPath $alias
$script:PlanPath=Join-Path $temp 'same-output.json';$script:ResultsPath=$script:PlanPath
Throws {Invoke-WelaProfileCommand plan} 'distinct new report files'
$script:PlanPath=$null;$script:ResultsPath=$null
$reportTarget=Join-Path $temp 'protected-report.json'
Write-WelaCustomProfileReport ([pscustomobject]@{status='original'}) $reportTarget
Throws {Write-WelaCustomProfileReport ([pscustomobject]@{status='replacement'}) $reportTarget} 'output already exists'
Assert ((Get-Content $reportTarget -Raw|ConvertFrom-Json).status -eq 'original') 'Final report writer preserves existing artifacts instead of overwriting aliases.'
$script:state=$script:zero.Clone();$script:state[$process]=2;$script:state[$termination]=3;$script:precedence=0
$script:BackupPath=Join-Path $temp 'applied';$script:ResultsPath=Join-Path $temp 'applied.json';$script:DryRun=$false
Invoke-WelaProfileCommand configure | Out-Null
$report=Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($report.ExitCode -eq 0 -and $script:state[$process] -eq 3 -and $script:state[$termination] -eq 1 -and $script:state[$fileSystem] -eq 0 -and $script:precedence -eq 1) 'Shared engine preserves minimum bits, applies exact bits and precedence, and omits optional SACL policy.'
Assert ($report.CustomProfileSource.Sha256 -ceq $report.SchemaSha256) 'Applied result retains selected input provenance.'
$entries=@(Get-Content -LiteralPath (Join-Path $script:BackupPath 'before.jsonl') | ForEach-Object {$_ | ConvertFrom-Json})
Assert ($entries.Count -eq 3 -and @($entries | Where-Object {$_.CustomProfileSource.Sha256 -cne $report.SchemaSha256}).Count -eq 0) 'Every prerequisite/audit journal entry records the selected file hash.'
$script:BackupPath=Join-Path $temp 'prompt-race';$script:ResultsPath=Join-Path $temp 'race.json';$script:Auto=$false;$script:mutateAtPrompt=$true
$script:state=$script:zero.Clone();$script:precedence=0;$script:writes=0
Throws {Invoke-WelaProfileCommand configure} 'failed'
Assert ($script:writes -eq 0) 'File replacement during confirmation refuses precedence and dependent writes.'
Save;$plan=Plan;$ctx=New-WelaConfigurationContext -DryRun
$script:hostBuild=26200
Throws {Set-WelaProfileAuditControls -Context $ctx -Plan $plan} 'target role/build changed'
$script:hostBuild=26100;$script:state[$process]=1;$script:state[$termination]=1;$script:precedence=1;$plan=Plan
$ctx=New-WelaConfigurationContext -DryRun
Set-WelaProfileAuditControls $ctx $plan
Add-Content -LiteralPath $script:file -Value ' '
$final=Complete-WelaConfiguration -Context $ctx -Plan $plan
Assert ($final.ExitCode -eq 1 -and $final.Failed -ge 1) 'Final verification detects input drift after initially compliant controls.'
Save
$exe=(Get-Process -Id $PID).Path
foreach ($arguments in @(@('configure','-ProfileFile',$script:file),@('configure-sacl','-ProfileFile',$script:file),@('configure','-Profile','custom-example','-ProfileFile',$script:file,'-OutgoingNtlmMode','Deny'))) {
$ErrorActionPreference='Continue';try {$output=& $exe -NoProfile -File (Join-Path $root 'WELA.ps1') @arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
Assert ($code -ne 0 -and ($output -join "`n") -match 'ProfileFile|Unsupported option') 'Wrong/missing custom profile options stop public dispatch.'
}
Write-Host "PASS: $script:count custom-profile assertions; all native mutations were mocked."
} finally {Remove-Item -LiteralPath $temp -Recurse -Force}
$global:LASTEXITCODE=0
@@ -0,0 +1,18 @@
$ErrorActionPreference='Stop'
$root=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
$context=Get-WelaHostContext
$before=Get-WelaEffectiveAuditPolicy
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-custom-native-'+[guid]::NewGuid().ToString('N')+'.json')
try {
$exe=(Get-Process -Id $PID).Path
& $exe -NoProfile -File (Join-Path $root 'WELA.ps1') audit-settings -Profile custom-example -ProfileFile (Join-Path $root 'config/custom-audit-profile.example.json') -SaclMode Skip -PlanPath $path
$code=$LASTEXITCODE
if ($code -ne 0) { throw "Native read-only custom audit failed: $code" }
$report=Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
if ($report.role -ne $context.Role -or $report.build -ne $context.Build -or $report.policies.Count -ne 59 -or -not $report.CustomProfileSource.Sha256) { throw 'Custom file/context/provenance was not retained.' }
$after=Get-WelaEffectiveAuditPolicy
foreach ($guid in $before.Keys) { if ($after[$guid] -ne $before[$guid]) { throw "Audit policy changed during read-only smoke: $guid" } }
Write-Host 'PASS: custom file public audit uses actual Windows context and 59 effective masks; all masks unchanged. No setting writes or event-generation claims.'
} finally { if (Test-Path -LiteralPath $path) {Remove-Item -LiteralPath $path -Force} }
$global:LASTEXITCODE=0
+2
View File
@@ -7,6 +7,8 @@
**改善:**
- `-ProfileFile`で管理者のJSON詳細監査プロファイルを一覧・計画・監査・設定に使用できるようにしました。標準GUID、役割、設定モード、出典ハッシュを厳密に検証し、組み込みプロファイルを保持します。厳密なJSON字句検証で重複キー検出の回避を防ぎ、レポートは新規ファイルに限ることで別名リンク経由でも入力と既存の証拠を保持します。共通の優先設定、復旧記録、変更直前のファイル確認と最終検証を使用し、イベント生成やSigma検知可能性は別途検証とします。 (#416) (@Shirofune-Security)
- ネイティブ Security 4688 の無害な固定プローブを収集する明示的な `native-validation` を追加。型付き前提条件、イベントの厳密な照合、前後の状態、ハッシュ付き証拠を新規の非公開ディレクトリに記録します。不完全・上限到達・曖昧・ドリフトの結果は未検証のままです。監査ポリシー変更や Sigma 利用可能ルール数の加算は行いません。使い捨て Server 2022/2025 テストで実イベントとポリシー復元を確認し、Windows 11/DC/ADCS とバックエンドの検証は別途必要です。 (#413) (@Shirofune-Security)
- ローカル監査権限と`CrashOnAuditFail`を監査・計画・出典別に設定する任意実行の`audit-integrity`を追加しました。実際のクライアント・メンバーサーバー・DCを区別し、Microsoft SCTで省略された設定は保持します。対象SIDの一覧、権限削除の明示指定、権限単位のLSA更新、完全な復旧記録と変更直前・変更後の検証により、無関係な権限を保持します。復旧状態では変更せず、ネイティブCIは読み取りだけを行います。トークン・GPO・サービス・イベントの検証は別途ラボで必要となり、Sigma検知範囲には加算しません。 (#412) (@Shirofune-Security)
- 読み取り専用の`retention-health`を追加し、送信元/収集サーバーの標準ログバッファ、確認した先頭レコードの経過日数、申告されたアーカイブ方針、件数を制限したローカルEVTX/ACL一覧、各言語のWEF・時刻情報とログ消失・消去・満杯の兆候をJSONと単独で表示できるHTMLに分けて記録します。保持されたイベントの時刻に基づく件数率とUTF-8 XMLバイト数の試算には上限・前提を明示し、容量・完全な保持・実効読み取り権限・時刻同期・転送成功の証明とは扱いません。複数ホストでのロールオーバー・復旧・到着検証は別途必要です。 (#410) (@Shirofune-Security)
+2
View File
@@ -7,6 +7,8 @@
**Improvements:**
- Added `-ProfileFile` for strictly validated custom advanced audit profiles in listing, planning, auditing and configuration. Canonical GUIDs, roles, modes and source hashes remain explicit; built-in profiles are preserved. Strict JSON tokens prevent duplicate-key bypasses, and new report files preserve inputs and prior evidence even through file aliases. Shared precedence, recovery journals, pre-write file checks and final verification protect configuration, without claiming event or Sigma readiness. (#416) (@Shirofune-Security)
- Added opt-in `native-validation` to collect a fixed benign Security 4688 probe with typed prerequisites, exact native event matching, before/after state and hashed components in a new private directory. Partial, capped, ambiguous and drifted results remain unverified; the collector changes no audit policy and grants no Sigma readiness credit. Disposable Server 2022/2025 tests exercise real events with verified policy restoration; Windows 11/DC/ADCS and backend acceptance remain separate. (#413) (@Shirofune-Security)
- Added opt-in `audit-integrity` audit, plan and source-profile configuration for local audit privileges and `CrashOnAuditFail`, with separate actual client/member/DC scope and preserved Microsoft SCT omissions. Exact affected SIDs, explicit privilege-removal consent, per-right LSA updates, complete recovery journals and fresh/readback checks preserve unrelated privileges. Recovery states are blocked; native CI reads policy only, while token, GPO, service and event validation remains a lab requirement without Sigma credit. (#412) (@Shirofune-Security)
- Added read-only `retention-health` source/collector JSON and self-contained HTML reports separating native buffers, observed record-boundary ages, declared archive policy, bounded local EVTX/ACL inventory, localized WEF/time evidence and loss/clear/full indicators. Retained-event timestamp rates and UTF-8 XML-byte scenarios expose caps and assumptions; none establish archive capacity, complete retention, effective reader access, synchronized time or successful forwarding. Multi-host rollover/recovery/arrival validation remains pending. (#410) (@Shirofune-Security)