mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
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:
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user