diff --git a/.github/workflows/retention-health.yml b/.github/workflows/retention-health.yml new file mode 100644 index 00000000..ef81bffc --- /dev/null +++ b/.github/workflows/retention-health.yml @@ -0,0 +1,42 @@ +name: Native retention health regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + retention-health: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Safe report fixtures in Windows PowerShell 5.1 + shell: powershell + timeout-minutes: 3 + run: ./tests/RetentionHealth.Tests.ps1 + - name: Safe report fixtures in PowerShell 7 + shell: pwsh + timeout-minutes: 3 + run: ./tests/RetentionHealth.Tests.ps1 + - name: Public CLI guards in Windows PowerShell 5.1 + shell: powershell + timeout-minutes: 3 + run: ./tests/RetentionHealth.Cli.Tests.ps1 + - name: Public CLI guards in PowerShell 7 + shell: pwsh + timeout-minutes: 3 + run: ./tests/RetentionHealth.Cli.Tests.ps1 + - name: Actual read-only native adapters in Windows PowerShell 5.1 + shell: powershell + timeout-minutes: 3 + run: ./tests/RetentionHealth.Windows.Tests.ps1 + - name: Actual read-only native adapters in PowerShell 7 + shell: pwsh + timeout-minutes: 3 + run: ./tests/RetentionHealth.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 78e81488..e836a384 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 読み取り専用の`retention-health`を追加し、送信元/収集サーバーの標準ログバッファ、確認した先頭レコードの経過日数、申告されたアーカイブ方針、件数を制限したローカルEVTX/ACL一覧、各言語のWEF・時刻情報とログ消失・消去・満杯の兆候をJSONと単独で表示できるHTMLに分けて記録します。保持されたイベントの時刻に基づく件数率とUTF-8 XMLバイト数の試算には上限・前提を明示し、容量・完全な保持・実効読み取り権限・時刻同期・転送成功の証明とは扱いません。複数ホストでのロールオーバー・復旧・到着検証は別途必要です。 (#410) (@Shirofune-Security) - 読み取り専用の`control-applicability`を追加し、旧CISのApplication Guard監査要件を保持しつつ、Windows 11 24H2以降では削除済み・対象外と表示します。`default-evidence`で正確なビルド・パッチ・ドメイン参加・役割を含む現状を記録し、出典とレビュー情報が一致する参照環境と比較できます。旧ベースラインの既定値は履歴情報として保持し、未検証の既定値はUnknownと表示します。合成テストと読み取り専用CIからクリーンインストールやイベント生成の証明は行いません。 (#409) (@Shirofune-Security) - Windows標準のDNS Client/Server、CAPI2、WinRM、RDP Clientについて、明示的に選択するprovider-packsの監査・計画とチャネル設定を追加しました。固定した完全なルール定義と実際のプロバイダー・チャネル・イベントスキーマを確認し、DNSのチャネル名不一致や不明な前提条件を保持します。復旧記録付きの変更では大きいバッファ・保持方式・ACLを維持し、Analyticalと従来のDNSログは手動確認のみとします。イベント生成・バックエンド検証や検知範囲の向上は未確認です。 (#411) (@Shirofune-Security) - 任意実行の`audit-notifications`を追加し、OneSettings監査とSecurityログ警告しきい値の監査・計画・設定に対応しました。対象とチャネル変更の明示指定、OS・ADMX確認、型付き復旧記録と変更検出を行い、既存の低いしきい値とチャネルACL・保存方式を保持します。ポリシー一致と実イベント生成を分け、Windowsラボの証拠とSigma利用可能性は未検証と表示します。 (#408) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27169238..6c46bc66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- 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) - Added read-only `control-applicability` for the historical CIS Application Guard audit requirement, reporting removal on Windows 11 24H2+ without remediation. Added exact build/patch/join/role native snapshots and provenance-bound reference comparison through `default-evidence`. Legacy baseline default strings are retained as historical hints while public defaults remain Unknown without reviewed scenario evidence. Synthetic fixtures and native read-only CI do not claim clean-install or event-generation proof. (#409) (@Shirofune-Security) - Added explicit native DNS Client/Server, CAPI2, WinRM and RDP Client provider-pack inventory and selective channel configuration. Pinned full rule definitions and live provider/channel/event schemas retain DNS channel mismatches and unknown prerequisites; journaled opt-in changes preserve larger buffers, retention and ACLs. Analytical/classic DNS remain manual-only, and no event/backend readiness uplift is claimed. (#411) (@Shirofune-Security) - Added opt-in `audit-notifications` audit/plan/configure for OneSettings auditing and Security log warning thresholds, with explicit control/channel selections, reviewed host and ADMX gates, typed recovery journals and drift checks. Earlier warning thresholds and channel ACL/retention are preserved. Reports separate policy matches from warning/event generation; Windows lab evidence and Sigma eligibility remain unverified. (#408) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 333c6f19..31da5002 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -34,6 +34,8 @@ [switch]$GrantEventLogReaders, [ValidateSet('Audit', 'Plan', 'Configure')][string]$WefAction = 'Audit', [string]$WefConfigPath, + [string]$RetentionConfigPath, + [string]$RetentionPreviousPath, [ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit', [string]$AppLockerPolicyPath, [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List', @@ -91,6 +93,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorA . (Join-Path $ScriptRoot "scripts/NativeProviderPacks.ps1") Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/WefDeployment.ps1") +. (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 @@ -1760,6 +1763,9 @@ Usage: ./WELA.ps1 wef-source -WefAction Plan -WefConfigPath source.json -ResultsPath source-plan.json ./WELA.ps1 wec-collector -WefAction Configure -WefConfigPath collector.json -DryRun + + ./WELA.ps1 retention-health -ResultsPath source-retention.json + ./WELA.ps1 retention-health -RetentionConfigPath collector-health.json -HtmlPath retention.html # Native channels only; ACL changes require -GrantEventLogReaders. Forwarding identity access needs a separate test. ./WELA.ps1 wmi-auditing -WmiAction List ./WELA.ps1 wmi-auditing -WmiAction Plan -WmiNamespace root\cimv2 -ResultsPath wmi-plan.json @@ -1833,6 +1839,12 @@ if (($PSBoundParameters.ContainsKey('AppLockerAction') -or $AppLockerPolicyPath) if (($PSBoundParameters.ContainsKey('WefAction') -or $PSBoundParameters.ContainsKey('WefConfigPath')) -and $Cmd -notin @('wef-source','wec-collector')) { throw '-WefAction and -WefConfigPath require wef-source or wec-collector. No command was run.' } +if (($PSBoundParameters.ContainsKey('RetentionConfigPath') -or $PSBoundParameters.ContainsKey('RetentionPreviousPath')) -and $Cmd -ne 'retention-health') { + throw 'Retention options require retention-health. No command was run.' +} +if ($Cmd -eq 'retention-health' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','RetentionConfigPath','RetentionPreviousPath','ResultsPath','HtmlPath','Help') }).Count) { + throw 'retention-health is read-only and accepts only its config/previous report paths, ResultsPath, HtmlPath and Help. No command was run.' +} if ($Cmd -eq 'applocker-readiness' -and ($Profile -or $Baseline)) { throw 'applocker-readiness uses its own operator-supplied policy, not -Profile or -Baseline. No command was run.' } @@ -1894,6 +1906,18 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi } switch ($Cmd.ToLower()) { + 'retention-health' { + if ($Help) { + Write-Host 'Usage: ./WELA.ps1 retention-health [-RetentionConfigPath operator.json] [-RetentionPreviousPath prior-local-report.json] [-ResultsPath report.json] [-HtmlPath report.html]' + Write-Host 'Read-only local native source/collector buffer, event-age, bounded XML rate, archive declaration/inventory, WEF and time evidence. Default: Source with Security/System/Application and no archive declaration. No retention-compliance or delivery claim. See docs/retention-health.md.' + return + } + try { + $report=Invoke-WelaRetentionHealth -ConfigPath $RetentionConfigPath -PreviousPath $RetentionPreviousPath -ResultsPath $ResultsPath -HtmlPath $HtmlPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } catch { Write-Host "[Failed] Retention health: $_" -ForegroundColor Red; exit 1 } + } 'control-applicability' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 control-applicability [-ResultsPath report.json]. Read-only historical feature/build assessment; see docs/control-applicability.md.'; return } if ($Profile -or $Baseline -or $Role -or $Build -or $HtmlPath -or $Auto -or $BackupPath -or $PlanPath) { throw 'control-applicability reads actual local context and only accepts -ResultsPath; configuration and context overrides are unsupported.' } diff --git a/config/retention-collector.example.json b/config/retention-collector.example.json new file mode 100644 index 00000000..ea3a56df --- /dev/null +++ b/config/retention-collector.example.json @@ -0,0 +1,17 @@ +{ + "SchemaVersion": 1, + "Role": "Collector", + "Channels": ["ForwardedEvents"], + "SampleWindowMinutes": 60, + "MaxEventsPerChannel": 1000, + "StaleAfterMinutes": 60, + "ProjectionDays": 30, + "SubscriptionIds": ["WELA Native Security Example"], + "Archive": { + "DeclaredRetentionMonths": 18, + "PolicyEvidence": "Example declaration only; replace with the approved policy and external archive evidence reference.", + "Directory": null, + "MaxFiles": 10, + "ReaderSids": [] + } +} diff --git a/config/retention-source.example.json b/config/retention-source.example.json new file mode 100644 index 00000000..728d55dd --- /dev/null +++ b/config/retention-source.example.json @@ -0,0 +1,9 @@ +{ + "SchemaVersion": 1, + "Role": "Source", + "Channels": ["Security", "System", "Application"], + "SampleWindowMinutes": 60, + "MaxEventsPerChannel": 1000, + "StaleAfterMinutes": 60, + "ProjectionDays": 30 +} diff --git a/docs/retention-health.md b/docs/retention-health.md new file mode 100644 index 00000000..67f8df11 --- /dev/null +++ b/docs/retention-health.md @@ -0,0 +1,77 @@ +# Retention, archive and forwarding health evidence + +`retention-health` reads local native Windows event logs and exports a console summary, JSON and self-contained HTML. It separates source/collector buffers, observed event boundaries, archive policy declarations, local archive evidence and collection/time diagnostics. It does not configure Windows, contact remote hosts, upload logs, clear logs, trigger rollover or test recovery. Sysmon and external telemetry channels are excluded. + +```powershell +# No configuration or archive declaration is required for a local source report. +./WELA.ps1 retention-health -ResultsPath source-health.json -HtmlPath source-health.html + +# Copy and edit config/retention-collector.example.json for a collector. +./WELA.ps1 retention-health -RetentionConfigPath collector-config.json -ResultsPath collector-health.json -HtmlPath collector-health.html + +# Compare only with a trusted earlier report from this same computer and role. +./WELA.ps1 retention-health -RetentionConfigPath collector-config.json -RetentionPreviousPath previous-health.json -ResultsPath latest-health.json +``` + +Use distinct configuration and report filenames; report outputs are intentionally written only to the explicitly selected paths. Run with read access to the selected logs. Access denied, missing logs and native query failures remain visible; they do not erase successful observations from other channels. Exit 0 means the requested observations were collected, **not** that retention or forwarding is healthy. Exit 1 reports partial/unavailable evidence or an export/input failure. `-DryRun`, configuration switches and unrelated command options are rejected because this command is already read-only. + +## Select the assessment + +The optional JSON uses `SchemaVersion: 1`, `Role: "Source"` or `"Collector"`, and 1–32 unique exact native `Channels`. The default is Source with Security, System and Application. The role is an operator declaration about this local assessment, not a discovery of remote topology or proof that the host is a dedicated collector. ForwardedEvents should be selected explicitly in a collector config. + +| Setting | Default and limits | Meaning | +|---|---|---| +| `SampleWindowMinutes` | 60; 1–1440 | Historical TimeCreated window ending at the report's UTC timestamp. | +| `MaxEventsPerChannel` | 1000; 1–10000 | Maximum retained records used per rate/signal sample. One extra sentinel detects truncation. | +| `StaleAfterMinutes` | 60; 1–10080 | Review threshold for the last readable record's timestamp. This does not diagnose a backlog. | +| `ProjectionDays` | 30; 1–3660 | Explicit horizon for a conditional XML-byte scenario. | +| `SubscriptionIds` | Empty; up to 32 | Exact subscriptions to query locally on a Collector. No subscription is created or changed. | +| `Archive` | Omitted | Optional declaration and bounded local EVTX directory inventory; see below. | + +## What the measurements establish + +Each channel retains maximum buffer bytes, current logical log-file size, record count, oldest record number, full/enabled flags, mode and channel security descriptor where readable. These properties are local buffer settings/state, not archive capacity or proof of central retention. In particular, a preallocated log file's length is not the amount of retained event content. + +Age uses the **first readable record in log order**, with the first/last native record, UTC TimeCreated, XML and localized message retained for review. It is not a scan for the global minimum timestamp. Clocks can change and forwarded events can arrive out of timestamp order. Future boundary timestamps produce an anomaly rather than a negative age; absent/denied data stays empty/unknown. Even an 800-day-old record does not prove a complete 18-month history, coverage of all required hosts/events, or recoverability. + +The rate is the count of retained records in the specified **TimeCreated** window divided by that whole window in seconds. This is a timestamp density of retained records, not measured collector arrival throughput or a loss-free source generation rate. A query that reaches the cap reports a lower bound for the sampled window. Queries with no records report that observation; they cannot establish zero event loss or zero required storage. Clears, overwrites, disabled channels, clock errors and filters can all hide records. + +The byte basis is `UTF8.GetByteCount(event.ToXml())`, excluding the separately rendered Message. The report includes measured XML bytes, average XML bytes per sampled record, the window, cap, sample count and projection days. The scenario is `sample XML bytes / window seconds × projection days × 86400`, assuming the retained-event rate/mix persists. Capped projections keep their lower-bound qualification under that assumption. Missing XML or timestamps prevent extrapolation. These bytes are **not** EVTX binary storage, filesystem allocation, compressed archive size, indexing/replication overhead, or available capacity. WELA does not divide buffer size by this estimate to claim a retention duration. + +An optional previous report must have an earlier timestamp, matching computer name, schema, scope and declared role. Its values are imported operator evidence, not independently authenticated. Comparing readable oldest-record numbers can flag an advance/reset for review; it cannot establish whether rollover, clearing or another change caused it, how many events were lost, or whether they were forwarded first. An unchanged boundary also cannot prove continuity. + +## Archive declarations and local evidence + +The cited [ASD October 2021 guidance](https://www.cyber.gov.au/business-government/detecting-responding-to-threats/event-logging/windows-event-logging-and-forwarding) recommends at least 18 months of event retention and consistent accurate time across devices. The report records this source-specific reference separately from the operator's declaration and observed event data. It never claims achieved 18-month compliance. + +```json +"Archive": { + "DeclaredRetentionMonths": 18, + "PolicyEvidence": "Approved policy/archive evidence reference; operator supplied", + "Directory": "D:\\ReviewedEventArchives", + "MaxFiles": 10, + "ReaderSids": ["S-1-5-21-111-222-333-1234"] +} +``` + +`DeclaredRetentionMonths` can be null or 1–120. `PolicyEvidence` is descriptive, unverified evidence (maximum 4000 characters); it is not downloaded or executed. Omit/null `Directory` to record an external archive policy without inspecting storage. For local inspection, choose an existing regular directory on a fixed local drive. UNC paths, mapped network drives, device paths, wildcards, streams and reparse-point ancestry are refused. WELA does not mount external storage. External systems can integrate by supplying their policy/evidence reference and placing selected exported native EVTX files in a reviewed local directory outside this command. + +Inventory is nonrecursive, filesystem-order and limited to `MaxFiles` (1–100); a sentinel reports truncation. Reparse files are skipped. Each selected file reports its logical length, file last-write timestamp and readable event boundaries separately. File timestamps do not substitute for event timestamps. Unsupported/non-native boundary channels are excluded; inspecting two records does not validate the complete content of an archive. Files with unreadable events retain the read errors; an inventory is not a restoration test or a proof of immutability. + +Directory SDDL/ACEs are recorded, and exact ACEs naming each intended `ReaderSids` entry are shown. These are observations, **not** effective token access checks: group membership, deny precedence, privileges and individual file ACLs are not evaluated. Reader authorization, tamper resistance, external retention enforcement, complete archive coverage and recovery all remain unverified. No ACL, ownership, share or retention setting is changed. + +## Forwarding, loss and time diagnostics + +Selected collector subscriptions use the merged WEF XML reader and native `wecutil gs /f:xml` / `gr` read operations. Definitions, explicit enabled state, native QueryList filters and localized runtime output/errors are preserved. Unknown/unrecognized query scope remains unknown. The reporting command does not infer successful delivery from an enabled subscription or successful native exit. Source/collector operational logs are sampled for critical/error/warning records in the declared window; raw XML/messages retain their context. + +Separate native Eventlog-provider samples retain Security 1101/1102/1104/1105/1108 transport-drop, clear, full, automatic-backup and processing-error indicators, plus System 104 clear indicators. A full/clear/error is useful review evidence; these samples do not calculate a complete loss total. Missing or capped diagnostics and zero observed indicators cannot establish absence of loss. Stale forwarded event timestamps can reflect source clocks, quiet sources or replay, so backlog and actual arrival latency remain Unknown pending correlated source/collector evidence. + +Time evidence is limited to local `w32tm /query /status /verbose`, `/query /source` and `/query /configuration`. Native localized output and failures are retained without parsing English labels. These read operations do not change time sources or resynchronize clocks. Successful commands or an enabled service do not prove accurate time, source health or agreement between hosts; those statuses remain Unknown. + +## Validation and remaining acceptance + +Safe fixtures cover the public JSON/HTML path, cap arithmetic, exact XML-byte projections, denied/empty/future data, independent failure preservation, declared versus achieved archive retention, reader ACE uncertainty, native-only scope and previous-boundary comparisons. Windows PowerShell 5.1/PowerShell 7 CI exercises actual local source/collector reads, owned-directory ACL observation and self-contained exports without modifying Windows settings. New files are included by the existing release packaging of `scripts` and `config`. + +Before closing issue #382, use an isolated multi-host lab to compare source/collector timestamps and actual event arrival, demonstrate the intended reader tokens can read/recover archived events, test rollover and recovery with known event sequences, measure real ingestion/storage growth under representative load and verify the external archive enforces its retention policy. Record missing-event, denied-read, time-skew, disabled-subscription, restart and recovery cases. This PR provides evidence collection; it does not supply those deployment acceptance results or increase Sigma coverage. + +Primary references: [Microsoft WEF operational behavior and delivery formats](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), [native source-initiated subscription validation](https://learn.microsoft.com/en-us/windows/win32/wec/setting-up-a-source-initiated-subscription), [Windows Time query tools](https://learn.microsoft.com/en-us/windows-server/networking/windows-time-service/windows-time-service-tools-and-settings), [Get-WinEvent ordering/query controls](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.diagnostics/get-winevent), [Security log clear 1102](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-1102), [Security log full 1104](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-1104). diff --git a/scripts/RetentionHealth.ps1 b/scripts/RetentionHealth.ps1 new file mode 100644 index 00000000..d9994ada --- /dev/null +++ b/scripts/RetentionHealth.ps1 @@ -0,0 +1,276 @@ +# Read-only local retention/collection evidence. No configuration or transport writes. +function Import-WelaRetentionConfig { + param([string]$Path) + $config=if ($Path) { Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } else { [pscustomobject]@{ SchemaVersion=1; Role='Source'; Channels=@('Security','System','Application') } } + $known=@('SchemaVersion','Role','Channels','SampleWindowMinutes','MaxEventsPerChannel','StaleAfterMinutes','ProjectionDays','SubscriptionIds','Archive') + foreach ($property in $config.PSObject.Properties) { if ($property.Name -cnotin $known) { throw "Unknown retention configuration field: $($property.Name)" } } + if ($config.SchemaVersion -ne 1 -or $config.Role -cnotin @('Source','Collector')) { throw 'Retention config requires schema 1 and explicit Source or Collector role.' } + if (@($config.Channels).Count -lt 1 -or @($config.Channels).Count -gt 32) { throw 'Select 1..32 exact native Windows channels.' } + $seen=@{} + foreach ($channel in $config.Channels) { + if (-not (Test-WelaRetentionChannel $channel) -or $seen.ContainsKey($channel)) { throw "Unsupported or duplicate retention channel: $channel" }; $seen[$channel]=$true + } + $limits=@{ SampleWindowMinutes=@(60,1,1440); MaxEventsPerChannel=@(1000,1,10000); StaleAfterMinutes=@(60,1,10080); ProjectionDays=@(30,1,3660) } + foreach ($name in $limits.Keys) { + if (-not $config.PSObject.Properties[$name]) { $config | Add-Member NoteProperty $name $limits[$name][0] } + $value=$config.$name + if (($value -isnot [int] -and $value -isnot [long]) -or $value -lt $limits[$name][1] -or $value -gt $limits[$name][2]) { throw "Invalid integer $name." } + } + if (-not $config.PSObject.Properties['SubscriptionIds']) { $config | Add-Member NoteProperty SubscriptionIds @() } + if (@($config.SubscriptionIds).Count -gt 32 -or ($config.Role -eq 'Source' -and @($config.SubscriptionIds).Count -gt 0)) { throw 'At most 32 explicit subscription IDs may be assessed on a Collector.' } + foreach ($id in $config.SubscriptionIds) { if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$') { throw 'Unsupported subscription ID.' } } + if ($config.Archive) { + foreach ($property in $config.Archive.PSObject.Properties) { if ($property.Name -cnotin @('DeclaredRetentionMonths','PolicyEvidence','Directory','MaxFiles','ReaderSids')) { throw "Unknown archive declaration field: $($property.Name)" } } + if ($null -ne $config.Archive.DeclaredRetentionMonths -and (($config.Archive.DeclaredRetentionMonths -isnot [int] -and $config.Archive.DeclaredRetentionMonths -isnot [long]) -or $config.Archive.DeclaredRetentionMonths -lt 1 -or $config.Archive.DeclaredRetentionMonths -gt 120)) { throw 'Declared archive retention must be 1..120 months or null.' } + if ($config.Archive.PolicyEvidence -and ([string]$config.Archive.PolicyEvidence).Length -gt 4000) { throw 'Archive policy evidence exceeds 4000 characters.' } + if (-not $config.Archive.PSObject.Properties['MaxFiles']) { $config.Archive | Add-Member NoteProperty MaxFiles 10 } + if (($config.Archive.MaxFiles -isnot [int] -and $config.Archive.MaxFiles -isnot [long]) -or $config.Archive.MaxFiles -lt 1 -or $config.Archive.MaxFiles -gt 100) { throw 'Archive MaxFiles must be an integer from 1 to 100.' } + if (@($config.Archive.ReaderSids).Count -gt 32) { throw 'Select at most 32 intended archive reader SIDs.' } + foreach ($sid in $config.Archive.ReaderSids) { if ($sid -notmatch '^S-1-\d+(-\d+)+$') { throw 'Expected intended reader SID, not an account name.' } } + } + return $config +} + +function Test-WelaRetentionChannel { + param([string]$Name) + return $Name -and $Name -notmatch '(?i)Sysmon|\bEMET\b' -and ($Name -in @('Security','System','Application','ForwardedEvents','Windows PowerShell') -or $Name -match '^Microsoft-Windows-[A-Za-z0-9 -]+/[A-Za-z0-9 -]+$') +} + +function ConvertTo-WelaRetentionEvent { + param($Event) + $xml=$null; $xmlBytes=$null; $diagnostic=''; $message=$null + try { $xml=[string]::Concat($Event.ToXml()); $xmlBytes=[Text.Encoding]::UTF8.GetByteCount($xml) } catch { $diagnostic='XML read: ' + $_.ToString() } + try { $message=[string]::Concat($Event.Message) } catch { $diagnostic += ' Message unavailable: ' + $_.ToString() } + $utc=$null + try { if ($null -ne $Event.TimeCreated) { $utc=([datetime]$Event.TimeCreated).ToUniversalTime().ToString('o') } } catch { $diagnostic += ' Timestamp unavailable: ' + $_.ToString() } + [pscustomobject]@{ RecordId=$Event.RecordId; Id=$Event.Id; Provider=[string]$Event.ProviderName; Channel=[string]$Event.LogName; MachineName=[string]$Event.MachineName; Level=$Event.Level; TimeCreatedUtc=$utc; XmlUtf8Bytes=$xmlBytes; Xml=$xml; Message=$message; Diagnostic=$diagnostic } +} + +function Read-WelaRetentionEvents { + param([string]$Channel,[string]$Path,[datetime]$StartUtc,[datetime]$EndUtc,[int]$Limit=1,[switch]$Oldest,[int[]]$Ids,[int[]]$Levels,[string]$Provider) + $records=@(); $status='Observed'; $diagnostic='' + try { + $arguments=@{ MaxEvents=$Limit; ErrorAction='Stop' } + if ($Oldest) { $arguments.Oldest=$true } + if ($Path) { $arguments.Path=$Path } + elseif ($PSBoundParameters.ContainsKey('StartUtc')) { + $filter=@{ LogName=$Channel; StartTime=$StartUtc; EndTime=$EndUtc } + if ($Ids) { $filter.Id=$Ids }; if ($Levels) { $filter.Level=$Levels }; if ($Provider) { $filter.ProviderName=$Provider } + $arguments.FilterHashtable=$filter + } else { $arguments.LogName=$Channel } + foreach ($event in @(Get-WinEvent @arguments)) { + try { + if ($Path -and -not (Test-WelaRetentionChannel ([string]$event.LogName))) { throw 'Archive boundary contains an unsupported/non-native channel. Its payload and retention age are excluded.' } + $records += ConvertTo-WelaRetentionEvent $event + } + finally { if ($event -is [IDisposable]) { $event.Dispose() } } + } + if (-not $records.Count) { $status='NoRecordsObserved' } + } catch { + if ($_.FullyQualifiedErrorId -match '^NoMatchingEventsFound(,|$)') { $status='NoRecordsObserved' } + else { $status='Unknown'; $diagnostic=$_.ToString() } + } + [pscustomobject]@{ Status=$status; Records=$records; Diagnostic=$diagnostic; RequestedLimit=$Limit } +} + +function Get-WelaRetentionBuffer { + param([string]$Channel) + $buffer=[ordered]@{ Channel=$Channel; Status='Unknown'; MaximumBytes=$null; FileBytes=$null; RecordCount=$null; OldestRecordNumber=$null; IsLogFull=$null; Enabled=$null; Mode=$null; SecurityDescriptor=$null; Diagnostic='' } + try { + $logs=@(Get-WinEvent -ListLog $Channel -ErrorAction Stop | Where-Object LogName -eq $Channel) + if ($logs.Count -ne 1) { throw 'A unique exact channel registration was not returned.' } + $log=$logs[0] + $mapping=@{ MaximumBytes='MaximumSizeInBytes'; FileBytes='FileSize'; RecordCount='RecordCount'; OldestRecordNumber='OldestRecordNumber'; IsLogFull='IsLogFull'; Enabled='IsEnabled'; Mode='LogMode'; SecurityDescriptor='SecurityDescriptor' } + $errors=@() + foreach ($name in $mapping.Keys) { + try { $value=$log.($mapping[$name]); if ($null -eq $value) { throw 'Property is unavailable.' }; $buffer[$name]=if ($name -in @('Mode','SecurityDescriptor')) { [string]::Concat($value) } else { $value } } + catch { $errors += "$name : $_" } + } + $buffer.Status=if ($errors.Count) { 'Partial' } else { 'Observed' }; $buffer.Diagnostic=$errors -join '; ' + } catch { + if ($_.FullyQualifiedErrorId -match '^NoMatchingLogsFound(,|$)') { $buffer.Status='NotInstalled' } + $buffer.Diagnostic=$_.ToString() + } + [pscustomobject]$buffer +} + +function Get-WelaRetentionAge { + param($First,$Last,[datetime]$NowUtc) + $age=$null; $status='Unknown'; $diagnostic='' + if ($First.Status -eq 'NoRecordsObserved' -and $Last.Status -eq 'NoRecordsObserved') { $status='NoRecordsObserved' } + elseif ($First.Status -eq 'Observed' -and $Last.Status -eq 'Observed' -and $First.Records.Count -eq 1 -and $Last.Records.Count -eq 1) { + try { + $created=[datetime]::Parse($First.Records[0].TimeCreatedUtc,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() + if ($created -gt $NowUtc) { $status='ClockOrTimestampAnomaly'; $diagnostic='Oldest readable record has a future timestamp; age cannot be established.' } + else { $age=($NowUtc-$created).TotalDays; $status='BoundaryObserved' } + } catch { $diagnostic=$_.ToString() } + } + [pscustomobject]@{ Status=$status; OldestReadableRecordAgeDays=$age; OldestRecord=$First; NewestRecord=$Last; CompleteEventCoverage='Unknown'; AchievedRetentionCompliance='Not established'; Diagnostic=$diagnostic; Basis='First and last readable records in log order. Timestamps may be nonmonotonic, especially forwarded events; boundaries do not prove a continuous history.' } +} + +function Get-WelaRetentionRate { + param($Read,[datetime]$StartUtc,[datetime]$EndUtc,[int]$Cap,[int]$ProjectionDays) + $records=@($Read.Records | Select-Object -First $Cap); $capped=$Read.Records.Count -gt $Cap + $seconds=($EndUtc-$StartUtc).TotalSeconds; $xmlBytes=[long]0; $completeBytes=$true; $timestampValid=$true + foreach ($event in $records) { + if ($null -eq $event.XmlUtf8Bytes) { $completeBytes=$false } else { $xmlBytes += $event.XmlUtf8Bytes } + try { $utc=[datetime]::Parse($event.TimeCreatedUtc,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime(); if ($utc -lt $StartUtc -or $utc -gt $EndUtc) { $timestampValid=$false } } + catch { $timestampValid=$false } + } + $valid=$Read.Status -in @('Observed','NoRecordsObserved') -and $seconds -gt 0 -and $timestampValid + $observedRate=if ($valid) { $records.Count/$seconds } else { $null } + $projection=if ($valid -and $completeBytes -and $records.Count -gt 0) { $xmlBytes/$seconds * $ProjectionDays*86400 } else { $null } + [pscustomobject]@{ + Status=$(if (-not $valid) { 'Unknown' } elseif ($capped) { 'CappedLowerBound' } elseif (-not $records.Count) { 'NoRecordsObserved' } else { 'ObservedRetainedRecords' }) + WindowStartUtc=$StartUtc.ToString('o'); WindowEndUtc=$EndUtc.ToString('o'); WindowSeconds=$seconds; RecordCap=$Cap; SampleCount=$records.Count; Capped=$capped + RetainedRecordsPerSecond=$observedRate; SampleXmlUtf8Bytes=$(if ($completeBytes -and $valid) { $xmlBytes } else { $null }); AverageXmlUtf8BytesPerRecord=$(if ($completeBytes -and $valid -and $records.Count) { $xmlBytes/$records.Count } else { $null }) + ProjectionDays=$ProjectionDays; ProjectedXmlUtf8Bytes=$projection; ProjectionKind=$(if ($null -eq $projection) { 'Unknown' } elseif ($capped) { 'Lower-bound scenario' } else { 'Conditional scenario' }) + ByteBasis='UTF-8 encoded event XML, excluding rendered Message. Not native EVTX bytes, compressed archive size, index/replica overhead or available capacity.' + RateBasis='Retained records whose TimeCreated falls in the declared window; not measured arrival throughput. Collector timestamps originate at sources. Clears, overwrites, disabled logging, clock error and sampling can omit events.' + Assumptions='Extrapolation assumes the sampled retained-event mix/rate persists. No retention capacity or loss-free coverage is inferred from buffers or a zero sample.'; Diagnostic=$Read.Diagnostic + } +} + +function Get-WelaRetentionNativeEvidence { + param([string]$File,[string[]]$Arguments) + try { $result=Invoke-WelaNative -FilePath $File -Arguments $Arguments; [pscustomobject]@{ Status='CommandSucceeded'; Command=$File; Arguments=$Arguments; Raw=[string]::Concat($result.Diagnostic); Diagnostic='Raw localized evidence; command success alone does not establish health.' } } + catch { [pscustomobject]@{ Status='Unknown'; Command=$File; Arguments=$Arguments; Raw=$null; Diagnostic=$_.ToString() } } +} + +function Get-WelaRetentionTime { + $results=@() + foreach ($arguments in @(@('/query','/status','/verbose'),@('/query','/source'),@('/query','/configuration'))) { $results += Get-WelaRetentionNativeEvidence -File 'w32tm.exe' -Arguments $arguments } + [pscustomobject]@{ Observations=$results; SynchronizationHealth='Unknown'; CrossHostClockAgreement='Not tested'; Basis='Local read-only w32tm queries; localized text is retained without interpreting English labels or inferring accurate shared time.' } +} + +function Get-WelaRetentionSubscriptions { + param([string[]]$Ids) + foreach ($id in $Ids) { + $definition=Get-WelaRetentionNativeEvidence 'wecutil.exe' @('gs',$id,'/f:xml') + $runtime=Get-WelaRetentionNativeEvidence 'wecutil.exe' @('gr',$id) + $enabled=$null; $query=$null; $scope='Unknown'; $diagnostic='' + if ($definition.Status -eq 'CommandSucceeded') { + try { + $doc=Read-WelaWefXml $definition.Raw + $ns=New-Object Xml.XmlNamespaceManager($doc.NameTable); $ns.AddNamespace('s','http://schemas.microsoft.com/2006/03/windows/events/subscription') + $enabledNodes=@($doc.SelectNodes('/s:Subscription/s:Enabled',$ns)); $queryNodes=@($doc.SelectNodes('/s:Subscription/s:Query',$ns)) + if ($enabledNodes.Count -ne 1 -or $queryNodes.Count -ne 1 -or $enabledNodes[0].InnerText -cnotin @('true','false')) { throw 'Subscription definition lacks unique explicit Enabled/Query fields.' } + $query=ConvertFrom-WelaWefQuery $queryNodes[0].InnerText; $enabled=$enabledNodes[0].InnerText -eq 'true'; $scope='NativeQueryObserved' + } catch { $diagnostic=$_.ToString() } + } + [pscustomobject]@{ Id=$id; Enabled=$enabled; QueryScope=$scope; Query=$query; Definition=$definition; Runtime=$runtime; Diagnostic=$diagnostic; DeliveryHealth='Unknown'; Backlog='Unknown'; ActualArrival='Not tested' } + } +} + +function Get-WelaRetentionSignal { + param([string]$Channel,[datetime]$StartUtc,[datetime]$EndUtc,[int]$Cap,[int[]]$Ids,[int[]]$Levels,[string]$Provider,[string]$Meaning) + $read=Read-WelaRetentionEvents -Channel $Channel -StartUtc $StartUtc -EndUtc $EndUtc -Limit ($Cap+1) -Ids $Ids -Levels $Levels -Provider $Provider + [pscustomobject]@{ Channel=$Channel; Status=$read.Status; Records=@($read.Records | Select-Object -First $Cap); Capped=($read.Records.Count -gt $Cap); Meaning=$Meaning; Diagnostic=$read.Diagnostic; AbsenceOfLoss='Not established'; WindowStartUtc=$StartUtc.ToString('o'); WindowEndUtc=$EndUtc.ToString('o') } +} + +function Get-WelaRetentionArchiveDirectory { + param([string]$Path) + if ($Path -notmatch '^[A-Za-z]:\\' -or $Path -match '[*?\[\]]|(^|\\)\.\.?(\\|$)' -or $Path.Substring(2).Contains(':')) { throw 'Archive inventory requires an exact local drive directory; UNC, wildcards, relative/device paths and streams are unsupported.' } + $directory=Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not $directory.PSIsContainer -or ([int]$directory.Attributes -band [int][IO.FileAttributes]::ReparsePoint)) { throw 'Archive directory must be a regular existing local directory.' } + $drive=New-Object IO.DriveInfo([IO.Path]::GetPathRoot($directory.FullName)) + if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Archive inventory supports a local fixed drive only; remote/mapped storage requires separately supplied local evidence.' } + $parent=$directory.Parent + while ($parent) { if ([int]$parent.Attributes -band [int][IO.FileAttributes]::ReparsePoint) { throw 'Archive ancestry contains a reparse point.' }; $parent=$parent.Parent } + return [string]$directory.FullName +} + +function Get-WelaRetentionArchive { + param($Declaration,[datetime]$NowUtc) + $result=[ordered]@{ Declaration=$Declaration; DeclarationVerified=$false; ReferenceMinimumMonths=18; Reference='ASD Windows event logging and forwarding, October 2021'; ObservedDirectory=$null; Files=@(); InventoryCapped=$null; ObservedEvtxFileBytes=$null; IntendedReaders=@(); EffectiveReaderAccess='Not tested'; AchievedRetentionCompliance='Not established'; Status='NotDeclared'; Diagnostic='' } + if (-not $Declaration) { return [pscustomobject]$result } + $result.Status='DeclarationOnly' + if (-not $Declaration.Directory) { return [pscustomobject]$result } + try { + $directory=Get-WelaRetentionArchiveDirectory $Declaration.Directory + $acl=Get-Acl -LiteralPath $directory -ErrorAction Stop + $sddl=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group) + $aces=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | ForEach-Object { [pscustomobject]@{ Sid=$_.IdentityReference.Value; Rights=[string]$_.FileSystemRights; Type=[string]$_.AccessControlType; Inherited=$_.IsInherited; InheritanceFlags=[string]$_.InheritanceFlags; PropagationFlags=[string]$_.PropagationFlags } }) + $result.ObservedDirectory=[pscustomobject]@{ Path=$directory; SecurityDescriptor=[string]$sddl; Aces=$aces; ReaderAuthorization='Not tested'; ArchiveProtection='Not established' } + foreach ($sid in $Declaration.ReaderSids) { $result.IntendedReaders += [pscustomobject]@{ Sid=$sid; DirectDirectoryAces=@($aces | Where-Object Sid -eq $sid); EffectiveReadAccess='Not tested'; Basis='Observed directory ACEs do not resolve group membership, denies, privileges, individual file ACLs or the reader token.' } } + $files=@(Get-ChildItem -LiteralPath $directory -Filter '*.evtx' -File -Force -ErrorAction Stop | Select-Object -First ($Declaration.MaxFiles+1)) + $result.InventoryCapped=$files.Count -gt $Declaration.MaxFiles; $bytes=[long]0 + foreach ($file in @($files | Select-Object -First $Declaration.MaxFiles)) { + if ([int]$file.Attributes -band [int][IO.FileAttributes]::ReparsePoint) { $result.Files += [pscustomobject]@{ Path=[string]$file.FullName; Status='SkippedReparsePoint'; FileBytes=$null; Age=$null }; continue } + $first=Read-WelaRetentionEvents -Path $file.FullName -Limit 1 -Oldest; $last=Read-WelaRetentionEvents -Path $file.FullName -Limit 1 + $bytes += $file.Length + $result.Files += [pscustomobject]@{ Path=[string]$file.FullName; Status=$(if ($first.Status -eq 'Unknown' -or $last.Status -eq 'Unknown') { 'UnknownEventBoundaries' } else { 'Inventoried' }); FileBytes=[long]$file.Length; LastWriteUtc=$file.LastWriteTimeUtc.ToString('o'); Age=(Get-WelaRetentionAge $first $last $NowUtc); ContentScope='Selected local EVTX boundaries only; file contents and complete source coverage are not validated.'; ReaderAccess='Not tested' } + } + $result.ObservedEvtxFileBytes=$bytes; $result.Status=if (@($result.Files | Where-Object Status -ne 'Inventoried').Count) { 'PartialInventory' } else { 'LocalInventoryObserved' } + $result.Diagnostic='Nonrecursive bounded directory enumeration; selection order is filesystem-dependent. File bytes are logical lengths of inventoried files, not allocated-on-disk size or verified usable archive capacity. External policy, storage security and recovery remain unverified.' + } catch { $result.Status='Unknown'; $result.Diagnostic=$_.ToString() } + [pscustomobject]$result +} + +function Compare-WelaRetentionBoundary { + param($Current,$Previous) + if (-not $Previous) { return [pscustomobject]@{ Status='NoPreviousReport'; Cause='Unknown' } } + $before=$Previous.Buffer.OldestRecordNumber; $after=$Current.Buffer.OldestRecordNumber + $valid=($before -is [int] -or $before -is [long]) -and ($after -is [int] -or $after -is [long]) -and $before -ge 0 -and $after -ge 0 + $status=if (-not $valid) { 'Unknown' } elseif ($after -gt $before) { 'OldestRecordBoundaryAdvanced' } elseif ($after -lt $before) { 'OldestRecordBoundaryReset' } else { 'NoBoundaryChangeObserved' } + [pscustomobject]@{ Status=$status; PreviousOldestRecordNumber=$before; CurrentOldestRecordNumber=$after; Cause='Unknown'; LostEventCount=$null; Basis='A boundary change is a rollover/clear/other-change signal, not proof of event loss or successful forwarding. An unchanged boundary also cannot prove continuity.' } +} + +function Invoke-WelaRetentionHealth { + param([string]$ConfigPath,[string]$PreviousPath,[string]$ResultsPath,[string]$HtmlPath) + $config=Import-WelaRetentionConfig $ConfigPath + foreach ($output in @($ResultsPath,$HtmlPath) | Where-Object { $_ }) { + foreach ($inputPath in @($ConfigPath,$PreviousPath) | Where-Object { $_ }) { + if ([IO.Path]::GetFullPath($output) -ieq [IO.Path]::GetFullPath($inputPath)) { throw 'Retention output must not overwrite the configuration or previous evidence input.' } + } + } + if ($ResultsPath -and $HtmlPath -and [IO.Path]::GetFullPath($ResultsPath) -ieq [IO.Path]::GetFullPath($HtmlPath)) { throw 'JSON and HTML outputs require separate paths.' } + if ($env:OS -ne 'Windows_NT') { throw 'Retention health reads native local Windows event logs.' } + $now=[DateTime]::UtcNow; $start=$now.AddMinutes(-$config.SampleWindowMinutes); $previous=$null + if ($PreviousPath) { + $previous=Get-Content -LiteralPath $PreviousPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($previous.SchemaVersion -ne 1 -or $previous.Scope -cne 'native-local-retention-health' -or $previous.ComputerName -ine $env:COMPUTERNAME -or $previous.RoleDeclaration -cne $config.Role -or -not $previous.RecordedUtc -or [datetime]$previous.RecordedUtc -ge $now) { throw 'Previous evidence must be an earlier retention report from the same computer and declared role.' } + } + $channels=@() + foreach ($channel in $config.Channels) { + $buffer=Get-WelaRetentionBuffer $channel + $first=Read-WelaRetentionEvents -Channel $channel -Limit 1 -Oldest; $last=Read-WelaRetentionEvents -Channel $channel -Limit 1 + $sample=Read-WelaRetentionEvents -Channel $channel -StartUtc $start -EndUtc $now -Limit ($config.MaxEventsPerChannel+1) + $age=Get-WelaRetentionAge $first $last $now + $row=[pscustomobject]@{ Channel=$channel; Buffer=$buffer; Age=$age; Rate=(Get-WelaRetentionRate $sample $start $now $config.MaxEventsPerChannel $config.ProjectionDays); CollectionRoleDeclaration=$config.Role; ObservedLatestRecordStale=$null; Backlog='Unknown' } + if ($last.Status -eq 'Observed' -and $last.Records[0].TimeCreatedUtc) { + try { $latest=[datetime]::Parse($last.Records[0].TimeCreatedUtc,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime(); if ($latest -le $now) { $row.ObservedLatestRecordStale=($now-$latest).TotalMinutes -gt $config.StaleAfterMinutes } } catch { } + } + $prior=@(if ($previous) { $previous.Channels | Where-Object Channel -eq $channel | Select-Object -First 1 }) + $row | Add-Member NoteProperty BoundaryComparison (Compare-WelaRetentionBoundary $row $(if ($prior.Count) { $prior[0] } else { $null })) + $channels += $row + } + $signals=@(Get-WelaRetentionSignal -Channel Security -StartUtc $start -EndUtc $now -Cap $config.MaxEventsPerChannel -Ids @(1101,1102,1104,1105,1108) -Provider 'Microsoft-Windows-Eventlog' -Meaning 'Audit transport drop, clear, full, automatic backup or processing-error indicators; inspect event ID/XML/message. No loss total is inferred.') + $signals+=Get-WelaRetentionSignal -Channel System -StartUtc $start -EndUtc $now -Cap $config.MaxEventsPerChannel -Ids @(104) -Provider 'Microsoft-Windows-Eventlog' -Meaning 'Event-log clear indicator; affected channel remains in event XML.' + $forwardChannel=if ($config.Role -eq 'Collector') { 'Microsoft-Windows-EventCollector/Operational' } else { 'Microsoft-Windows-Forwarding/Operational' } + $signals+=Get-WelaRetentionSignal -Channel $forwardChannel -StartUtc $start -EndUtc $now -Cap $config.MaxEventsPerChannel -Levels @(1,2,3) -Meaning 'Recent critical/error/warning forwarding or collection indicators. Their absence does not establish healthy delivery.' + $subscriptions=@(Get-WelaRetentionSubscriptions @($config.SubscriptionIds)) + $archive=Get-WelaRetentionArchive $config.Archive $now + $time=Get-WelaRetentionTime + $unknown=@($channels | Where-Object { $_.Buffer.Status -in @('Unknown','NotInstalled','Partial') -or $_.Age.Status -eq 'Unknown' -or $_.Rate.Status -eq 'Unknown' }).Count -gt 0 -or $archive.Status -in @('Unknown','PartialInventory') -or @($signals | Where-Object Status -eq 'Unknown').Count -gt 0 -or @($time.Observations | Where-Object Status -eq 'Unknown').Count -gt 0 -or @($subscriptions | Where-Object { $_.QueryScope -eq 'Unknown' -or $_.Runtime.Status -eq 'Unknown' }).Count -gt 0 + $report=[pscustomobject][ordered]@{ SchemaVersion=1; Scope='native-local-retention-health'; RecordedUtc=$now.ToString('o'); ComputerName=$env:COMPUTERNAME; RoleDeclaration=$config.Role; ExitCode=$(if ($unknown) { 1 } else { 0 }); AssessmentStatus=$(if ($unknown) { 'PartialEvidence' } else { 'ObservationsCollected' }); Config=$config; Channels=$channels; Archive=$archive; Signals=$signals; Subscriptions=$subscriptions; Time=$time; EndToEndDelivery='Not tested'; RetentionCompliance='Not established'; SigmaCoverage='Not assessed'; Limits=@('Local source/collector roles are declared; remote host state is not observed.','Boundary ages, buffer sizes and declarations do not prove complete event coverage or 18-month compliance.','Stale source timestamps can reflect quiet periods, source clocks or replay; backlog and arrival latency remain unknown.','Reader token authorization, multi-host clock comparison, rollover/recovery and representative event arrivals require an isolated lab.') } + Write-Host 'Retention evidence collected. Complete coverage, achieved archive compliance, shared time and end-to-end delivery remain unverified.' -ForegroundColor Yellow + $channels | Select-Object Channel,@{n='BufferBytes';e={$_.Buffer.MaximumBytes}},@{n='Mode';e={$_.Buffer.Mode}},@{n='BoundaryAgeDays';e={$_.Age.OldestReadableRecordAgeDays}},@{n='Sample';e={$_.Rate.SampleCount}},@{n='RateStatus';e={$_.Rate.Status}} | Format-Table -AutoSize | Out-Host + Write-Host "Archive: $($archive.Status); declared months: $($archive.Declaration.DeclaredRetentionMonths); achieved compliance: not established. Time synchronization and forwarding health: unknown." + if ($HtmlPath) { try { Export-WelaRetentionHtml $report $HtmlPath } catch { $report.ExitCode=1; Write-Host "[Failed] Retention HTML export: $_" -ForegroundColor Red } } + if ($ResultsPath) { try { $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } catch { $report.ExitCode=1; Write-Host "[Failed] Retention JSON export: $_" -ForegroundColor Red } } + return $report +} + +function Export-WelaRetentionHtml { + param($Report,[string]$Path) + $encode={ param($value) [Net.WebUtility]::HtmlEncode([string]$value) } + $rows=@() + foreach ($row in $Report.Channels) { $rows += '' + ((@($row.Channel,$row.Buffer.Status,$row.Buffer.MaximumBytes,$row.Buffer.Mode,$row.Age.OldestReadableRecordAgeDays,$row.Rate.Status,$row.Rate.RetainedRecordsPerSecond,$row.Rate.ProjectedXmlUtf8Bytes) | ForEach-Object { '' + (& $encode $_) + '' }) -join '') + '' } + $signalRows=@() + foreach ($signal in $Report.Signals) { $signalRows += '' + (& $encode $signal.Channel) + '' + (& $encode $signal.Status) + '' + (& $encode $signal.Records.Count) + '' + (& $encode $signal.Capped) + '' } + $summary='

Archive and health evidence

Archive inventory: ' + (& $encode $Report.Archive.Status) + '. Declared retention months: ' + (& $encode $Report.Archive.Declaration.DeclaredRetentionMonths) + '. ASD October 2021 reference: at least 18 months. Achieved retention and effective reader access are not established.

Selected collector subscriptions: ' + (& $encode $Report.Subscriptions.Count) + '. Delivery/backlog and shared time remain unknown; native localized results are preserved below.

' + ($signalRows -join '') + '
Signal channelRead statusObserved indicatorsCapped

Zero observed indicators does not prove no loss or healthy forwarding.

' + $json=& $encode ($Report | ConvertTo-Json -Depth 20) + $html='WELA retention evidence

WELA retention and collection evidence

' + (& $encode $Report.ComputerName) + ' · declared ' + (& $encode $Report.RoleDeclaration) + ' · ' + (& $encode $Report.RecordedUtc) + '

Local buffers, recorded event boundaries, declared archive policy and actual complete retention are separate. Complete coverage, delivery, synchronized time and 18-month compliance are not established.

' + ($rows -join '') + '
ChannelRead stateBuffer bytesModeOldest-record age (days)Sample statusRetained records/secProjected XML bytes

Rates use the declared timestamp window and cap. Projected bytes are UTF-8 XML scenarios, not EVTX size, archive capacity or measured collector arrivals. See the complete evidence for assumptions, raw localized diagnostics, archive ACLs and unknown states.

' + $summary + '

Complete evidence

' + $json + '
' + [IO.File]::WriteAllText($Path,$html,(New-Object Text.UTF8Encoding($false))) +} diff --git a/tests/RetentionHealth.Cli.Tests.ps1 b/tests/RetentionHealth.Cli.Tests.ps1 new file mode 100644 index 00000000..54c2f582 --- /dev/null +++ b/tests/RetentionHealth.Cli.Tests.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +$shell=(Get-Process -Id $PID).Path +$script:checks=0 +function Assert-Cli([string[]]$Arguments,[int]$Expected,[string]$Text) { + $ErrorActionPreference='Continue' + $out=(& $shell -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1 | Out-String) + $code=$LASTEXITCODE + $ErrorActionPreference='Stop' + if ($code -ne $Expected -or $out -notmatch [regex]::Escape($Text)) { throw "CLI check failed: $($Arguments -join ' ')`n$out" } + $script:checks++ +} +Assert-Cli @('retention-health','-Help') 0 'Read-only local native' +Assert-Cli @('configure','-RetentionConfigPath','operator.json') 1 'Retention options require' +Assert-Cli @('version','-RetentionPreviousPath','previous.json') 1 'Retention options require' +foreach ($option in @('DryRun','Auto')) { Assert-Cli @('retention-health',('-'+$option)) 1 'retention-health is read-only' } +Assert-Cli @('retention-health','-Profile','wela') 1 'retention-health is read-only' +Assert-Cli @('retention-health','-BackupPath','new-backup') 1 'retention-health is read-only' +Assert-Cli @('retention-health','-WefAction','Configure') 1 'require wef-source' +Assert-Cli @('retention-health','-RetentionConfigPath','missing-retention-fixture.json') 1 '[Failed] Retention health' +Write-Host "RetentionHealth.Cli.Tests: $script:checks public CLI checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/RetentionHealth.Tests.ps1 b/tests/RetentionHealth.Tests.ps1 new file mode 100644 index 00000000..70c75cf0 --- /dev/null +++ b/tests/RetentionHealth.Tests.ps1 @@ -0,0 +1,145 @@ +# Bounded public-report fixtures. Native reads are mocked; no Windows mutations. +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -Force +. (Join-Path $repo 'scripts/RetentionHealth.ps1') +$script:count=0 +function Assert($Value,[string]$Message) { if (-not $Value) { throw "FAIL: $Message" }; $script:count++ } +function Assert-Throws([scriptblock]$Code,[string]$Message) { $caught=$false; try { & $Code | Out-Null } catch { $caught=$true }; Assert $caught $Message } +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-retention-' + [guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory $temp +$configPath=Join-Path $temp 'config.json'; $jsonPath=Join-Path $temp 'report.json'; $htmlPath=Join-Path $temp 'report.html' +$savedOS=$env:OS; $savedComputer=$env:COMPUTERNAME +function Reset-Fixture { + $script:fixture=@{ Denied=''; Empty=''; Capped=$false; Future=$false; MissingXml=$false; OldestId=20; TimeFailure=$false; SubscriptionFailure=$false; ArchiveFailure=$false; Reads=(New-Object 'System.Collections.Generic.List[object]') } + $script:config=[pscustomobject]@{ SchemaVersion=1; Role='Source'; Channels=@('Security','System'); SampleWindowMinutes=60; MaxEventsPerChannel=2; StaleAfterMinutes=60; ProjectionDays=30 } + Save-Config +} +function Save-Config { $script:config | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $configPath -Encoding UTF8 } +function New-FixtureEvent([string]$Channel,[datetime]$Time,[long]$RecordId=30) { + $event=[pscustomobject]@{ RecordId=$RecordId; Id=1104; ProviderName='Microsoft-Windows-Eventlog'; LogName=$Channel; MachineName='fixture-source'; Level=2; TimeCreated=$Time; Message='Localized message' } + $event | Add-Member ScriptMethod ToXml { if ($script:fixture.MissingXml) { throw 'Fixture XML access failure' }; return '1104' } + return $event +} +function Get-WinEvent { + param($ListLog,$LogName,$FilterHashtable,$Path,$MaxEvents,[switch]$Oldest) + $channel=if ($ListLog) { $ListLog } elseif ($FilterHashtable) { $FilterHashtable.LogName } elseif ($Path) { 'Security' } else { $LogName } + $script:fixture.Reads.Add([pscustomobject]@{ Channel=$channel; MaxEvents=$MaxEvents; Path=$Path; Filter=$FilterHashtable; Oldest=[bool]$Oldest }) + if ($script:fixture.Denied -eq $channel) { throw 'Fixture access denied' } + if ($ListLog) { return [pscustomobject]@{ LogName=$channel; MaximumSizeInBytes=[long]2147483648; FileSize=[long]65536; RecordCount=100; OldestRecordNumber=$script:fixture.OldestId; IsLogFull=$false; IsEnabled=$true; LogMode='Circular'; SecurityDescriptor='O:BAG:SYD:(A;;0x1;;;SY)' } } + if ($script:fixture.Empty -eq $channel) { return } + if ($Oldest) { return New-FixtureEvent $channel ([datetime]::UtcNow.AddDays($(if ($script:fixture.Future) { 1 } else { -800 }))) 20 } + if ($FilterHashtable -and ($FilterHashtable.Id -or $FilterHashtable.Level)) { return New-FixtureEvent $channel $FilterHashtable.StartTime.AddMinutes(5) } + if ($FilterHashtable) { + New-FixtureEvent $channel $FilterHashtable.StartTime.AddMinutes(30) + if ($script:fixture.Capped) { New-FixtureEvent $channel $FilterHashtable.StartTime.AddMinutes(20); New-FixtureEvent $channel $FilterHashtable.StartTime.AddMinutes(10) } + return + } + return New-FixtureEvent $channel ([datetime]::UtcNow.AddMinutes(-120)) +} +function Invoke-WelaNative { + param($FilePath,$Arguments) + Assert ($FilePath -in @('w32tm.exe','wecutil.exe')) 'Only selected native read utilities are invoked' + if ($FilePath -eq 'w32tm.exe') { + Assert ($Arguments[0] -eq '/query' -and ($Arguments -join ' ') -notmatch '/computer|/resync|/config\s') 'Time evidence uses local query operations only' + if ($script:fixture.TimeFailure) { throw 'Localized native time query failure, exit 5' } + return [pscustomobject]@{ Diagnostic='Zeitquelle: Quelle ; letzter Status unbekannt'; ExitCode=0 } + } + Assert ($Arguments[0] -in @('gs','gr')) 'WEF reporting cannot create/change/retry subscriptions' + if ($script:fixture.SubscriptionFailure) { throw 'Native subscription query access denied' } + $text=if ($Arguments[0] -eq 'gr') { 'Localized runtime status: fixture' } else { 'false]]>' } + return [pscustomobject]@{ Diagnostic=$text; ExitCode=0 } +} +function Get-WelaRetentionArchiveDirectory { + param($Path) + if ($script:fixture.ArchiveFailure) { throw 'Fixture directory access denied' }; return 'C:\Archive' +} +function Get-Acl { + param($LiteralPath) + $acl=[pscustomobject]@{} + $acl | Add-Member ScriptMethod GetSecurityDescriptorSddlForm { param($Sections) 'O:BAG:SYD:(A;;FR;;;S-1-5-21-1-2-3-1000)' } + $acl | Add-Member ScriptMethod GetAccessRules { param($Explicit,$Inherited,$Type) [pscustomobject]@{ IdentityReference=[pscustomobject]@{ Value='S-1-5-21-1-2-3-1000' }; FileSystemRights='Read'; AccessControlType='Allow'; IsInherited=$false; InheritanceFlags='ContainerInherit, ObjectInherit'; PropagationFlags='None' } } + return $acl +} +function Get-ChildItem { + param($LiteralPath,$Filter,[switch]$File,[switch]$Force) + Assert ($Filter -eq '*.evtx' -and $File) 'Archive inventory selects only local EVTX files' + foreach ($number in 1..3) { [pscustomobject]@{ FullName="C:\Archive\file$number.evtx"; Attributes=[IO.FileAttributes]::Normal; Length=[long]65536; LastWriteTimeUtc=[datetime]::UtcNow } } +} +function Run-Report { Invoke-WelaRetentionHealth -ConfigPath $configPath -ResultsPath $jsonPath -HtmlPath $htmlPath } +try { + $env:OS='Windows_NT'; $env:COMPUTERNAME='fixture-host ' + $default=Import-WelaRetentionConfig + Assert ($default.Role -eq 'Source' -and $default.Channels.Count -eq 3 -and -not $default.Archive) 'Default assessment needs no archive declaration/config file' + Reset-Fixture + $report=Run-Report + $json=Get-Content $jsonPath -Raw | ConvertFrom-Json + Assert ($json.Channels.Count -eq 2 -and $json.Channels[0].Buffer.MaximumBytes -eq 2147483648) 'Public report retains separate selected buffer observations' + Assert ($json.Channels[0].Age.OldestReadableRecordAgeDays -gt 799 -and $json.RetentionCompliance -eq 'Not established') 'An 800-day-old record does not prove 18-month retention compliance' + Assert ($json.Channels[0].Age.CompleteEventCoverage -eq 'Unknown') 'Observed boundary age never implies continuous event coverage' + Assert ($json.Channels[0].Rate.SampleCount -eq 1 -and [math]::Abs($json.Channels[0].Rate.RetainedRecordsPerSecond-(1/3600)) -lt 0.00000001) 'Rate denominator is the explicit 3600-second timestamp window' + $expectedBytes=[Text.Encoding]::UTF8.GetByteCount('1104') + Assert ($json.Channels[0].Rate.SampleXmlUtf8Bytes -eq $expectedBytes -and $json.Channels[0].Rate.ProjectedXmlUtf8Bytes -eq $expectedBytes*24*30) 'XML byte projection uses measured serialized bytes and declared horizon' + Assert ($json.Channels[0].Rate.ByteBasis -like '*Not native EVTX bytes*') 'Storage byte assumptions are explicit' + Assert ($json.Channels[0].ObservedLatestRecordStale -and $json.Channels[0].Backlog -eq 'Unknown') 'Old source timestamps are a stale signal rather than invented backlog' + Assert ($json.Time.SynchronizationHealth -eq 'Unknown' -and $json.Time.Observations[0].Raw -like 'Zeitquelle*') 'Localized time output is preserved without English-field parsing or synchronization claims' + Assert ($json.Signals.Count -eq 3 -and $json.Signals[0].AbsenceOfLoss -eq 'Not established') 'Loss/clear/forwarding evidence is reported independently' + Assert ($json.Archive.Status -eq 'NotDeclared') 'Archive declaration remains distinct from large source buffers' + $html=Get-Content $htmlPath -Raw + Assert ($html -notmatch '([\s\S]*?)') + Assert $embeddedMatch.Success 'Self-contained HTML retains the complete JSON evidence' + $embedded=[Net.WebUtility]::HtmlDecode($embeddedMatch.Groups[1].Value) | ConvertFrom-Json + Assert ($embedded.Signals[0].Records[0].Message -ceq 'Localized message') 'Escaped event evidence round-trips without losing or activating its text' + Assert (@($script:fixture.Reads | Where-Object { $_.MaxEvents -gt 3 }).Count -eq 0) 'All fixture queries obey cap plus one sentinel' + $oldReport=Join-Path $temp 'previous.json'; Copy-Item $jsonPath $oldReport + $script:fixture.OldestId=40 + $report=Invoke-WelaRetentionHealth -ConfigPath $configPath -PreviousPath $oldReport + Assert ($report.Channels[0].BoundaryComparison.Status -eq 'OldestRecordBoundaryAdvanced' -and $null -eq $report.Channels[0].BoundaryComparison.LostEventCount) 'Boundary advance flags rollover/clear uncertainty without inventing loss count' + Reset-Fixture; $script:fixture.Capped=$true + $report=Run-Report + Assert ($report.Channels[0].Rate.Capped -and $report.Channels[0].Rate.SampleCount -eq 2 -and $report.Channels[0].Rate.Status -eq 'CappedLowerBound') 'A cap never produces an uncapped EPS claim' + Assert ($report.Channels[0].Rate.ProjectionKind -eq 'Lower-bound scenario') 'Capped projections retain their lower-bound qualification' + Reset-Fixture; $script:fixture.Empty='Security' + $report=Run-Report + Assert ($report.Channels[0].Age.Status -eq 'NoRecordsObserved' -and $null -eq $report.Channels[0].Rate.ProjectedXmlUtf8Bytes) 'Empty observations do not imply zero required capacity or a retained history' + Reset-Fixture; $script:fixture.Denied='Security'; $script:fixture.TimeFailure=$true + $report=Run-Report + Assert ($report.ExitCode -eq 1 -and $report.Channels[0].Buffer.Status -eq 'Unknown' -and $report.Channels[1].Buffer.Status -eq 'Observed') 'Denied channels/native queries do not erase independent channel observations' + Assert ($null -eq $report.Channels[0].Rate.RetainedRecordsPerSecond -and $report.Signals[0].Status -eq 'Unknown') 'Access denied never becomes zero event/loss evidence' + Reset-Fixture; $script:fixture.Future=$true + $report=Run-Report + Assert ($report.Channels[0].Age.Status -eq 'ClockOrTimestampAnomaly' -and $null -eq $report.Channels[0].Age.OldestReadableRecordAgeDays) 'Future record timestamps cannot produce negative achieved retention' + Reset-Fixture; $script:fixture.MissingXml=$true + $report=Run-Report + Assert ($null -eq $report.Channels[0].Rate.ProjectedXmlUtf8Bytes) 'Missing XML bytes prevent storage extrapolation' + Reset-Fixture + $config.Role='Collector'; $config.Channels=@('ForwardedEvents'); $config | Add-Member NoteProperty SubscriptionIds @('Reviewed Subscription') + $config | Add-Member NoteProperty Archive ([pscustomobject]@{ DeclaredRetentionMonths=18; PolicyEvidence='External immutable archive policy (declared)'; Directory='C:\Archive'; MaxFiles=2; ReaderSids=@('S-1-5-21-1-2-3-1000') }); Save-Config + $report=Run-Report + Assert ($report.Archive.Declaration.DeclaredRetentionMonths -eq 18 -and -not $report.Archive.DeclarationVerified -and $report.Archive.AchievedRetentionCompliance -eq 'Not established') 'Archive policy declaration is never promoted to achieved compliance' + Assert ($report.Archive.InventoryCapped -and $report.Archive.Files.Count -eq 2 -and $report.Archive.ObservedEvtxFileBytes -eq 131072) 'Local EVTX inventory has a separate bounded logical-byte basis' + Assert ($report.Archive.IntendedReaders[0].DirectDirectoryAces.Count -eq 1 -and $report.Archive.IntendedReaders[0].EffectiveReadAccess -eq 'Not tested') 'An observed reader ACE does not prove effective archive/file access' + Assert ($report.Subscriptions[0].Enabled -eq $false -and $report.Subscriptions[0].Runtime.Raw -like '*fixture*' -and $report.Subscriptions[0].DeliveryHealth -eq 'Unknown') 'Disabled subscription and raw runtime evidence stay distinct from healthy delivery' + Assert ($report.Channels[0].Rate.RateBasis -like '*not measured arrival throughput*') 'Forwarded-event timestamp density is not arrival EPS' + $script:fixture.ArchiveFailure=$true; $script:fixture.SubscriptionFailure=$true + $report=Run-Report + Assert ($report.ExitCode -eq 1 -and $report.Archive.Status -eq 'Unknown' -and $report.Channels[0].Buffer.Status -eq 'Observed') 'Archive and WEF failures preserve collector buffer evidence' + Reset-Fixture; $config.Channels=@('Microsoft-Windows-Sysmon/Operational'); Save-Config + Assert-Throws { Import-WelaRetentionConfig $configPath } 'Sysmon channel input is excluded' + Reset-Fixture; $config.Channels=@('Security*'); Save-Config + Assert-Throws { Import-WelaRetentionConfig $configPath } 'Wildcard channels cannot expand the read scope' + Reset-Fixture; $config.MaxEventsPerChannel=10001; Save-Config + Assert-Throws { Import-WelaRetentionConfig $configPath } 'Sampling limits are bounded before native reads' + Reset-Fixture; $config | Add-Member NoteProperty EnableArchive $true; Save-Config + Assert-Throws { Import-WelaRetentionConfig $configPath } 'Unknown input fields cannot silently authorize archive changes' + Reset-Fixture + $prior=Get-Content $oldReport -Raw | ConvertFrom-Json; $prior.ComputerName='another-host'; $prior | ConvertTo-Json -Depth 20 | Set-Content $oldReport + Assert-Throws { Invoke-WelaRetentionHealth -ConfigPath $configPath -PreviousPath $oldReport } 'Cross-host snapshots cannot imply local rollover continuity' + Assert-Throws { Invoke-WelaRetentionHealth -ConfigPath $configPath -ResultsPath $configPath } 'A report cannot overwrite its configuration input' + Assert-Throws { Invoke-WelaRetentionHealth -ConfigPath $configPath -ResultsPath $jsonPath -HtmlPath $jsonPath } 'JSON and HTML cannot overwrite each other' + Write-Host "RetentionHealth.Tests: $script:count assertions passed. No Windows configuration changed." +} finally { $env:OS=$savedOS; $env:COMPUTERNAME=$savedComputer; Remove-Item -LiteralPath $temp -Recurse -Force } +$global:LASTEXITCODE=0 diff --git a/tests/RetentionHealth.Windows.Tests.ps1 b/tests/RetentionHealth.Windows.Tests.ps1 new file mode 100644 index 00000000..6f9de0e7 --- /dev/null +++ b/tests/RetentionHealth.Windows.Tests.ps1 @@ -0,0 +1,34 @@ +# Actual local Windows reads only. No subscriptions, clocks, policies or ACLs changed. +$ErrorActionPreference='Stop' +if ($env:OS -ne 'Windows_NT') { throw 'Windows retention smoke requires Windows.' } +$repo=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -Force +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/RetentionHealth.ps1') +function Assert($Value,[string]$Message) { if (-not $Value) { throw "FAIL: $Message" } } +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-retention-readonly-' + [guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory $temp +$before=@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='W32Time'" | Select-Object Name,StartMode,State | Sort-Object Name) +try { + $config=[pscustomobject]@{ SchemaVersion=1; Role='Source'; Channels=@('System','Application'); MaxEventsPerChannel=5; SampleWindowMinutes=5; Archive=[pscustomobject]@{ DeclaredRetentionMonths=18; PolicyEvidence='Read-only smoke declaration, not compliance evidence'; Directory=$temp; MaxFiles=1; ReaderSids=@([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) } } + $configPath=Join-Path $temp 'config.json'; $config | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $configPath -Encoding UTF8 + $report=Invoke-WelaRetentionHealth -ConfigPath $configPath -ResultsPath (Join-Path $temp 'result.json') -HtmlPath (Join-Path $temp 'result.html') + $json=Get-Content (Join-Path $temp 'result.json') -Raw -Encoding UTF8 | ConvertFrom-Json + Assert ($json.Channels.Count -eq 2 -and $json.Channels[0].Buffer.MaximumBytes -gt 0) 'Actual local channel metadata survives public JSON export' + Assert ($json.Channels[0].Rate.RecordCap -eq 5 -and $json.Channels[0].Rate.SampleCount -le 5) 'Native sampling obeys the declared cap' + Assert ($json.RetentionCompliance -eq 'Not established' -and $json.Time.SynchronizationHealth -eq 'Unknown') 'Real read success does not promote compliance/time claims' + Assert ($json.Archive.Status -eq 'LocalInventoryObserved' -and $json.Archive.ObservedDirectory.SecurityDescriptor) 'Actual owned local directory ACL is inventoried' + Assert ($json.Archive.IntendedReaders[0].EffectiveReadAccess -eq 'Not tested') 'Directory ACE evidence remains distinct from reader-token access' + $config.Role='Collector'; $config.Channels=@('ForwardedEvents'); $config | Add-Member NoteProperty SubscriptionIds @('WELA ReadOnly Smoke Missing Subscription') + $config | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $configPath -Encoding UTF8 + $collector=Invoke-WelaRetentionHealth -ConfigPath $configPath + Assert ($collector.Subscriptions.Count -eq 1 -and $collector.Subscriptions[0].DeliveryHealth -eq 'Unknown') 'Missing collector subscription remains unknown and does not trigger provisioning' + foreach ($bad in @('\\server\share','C:\..\archive','C:relative','\\?\C:\archive')) { + $caught=$false; try { Get-WelaRetentionArchiveDirectory $bad | Out-Null } catch { $caught=$true }; Assert $caught 'Remote/device/ambiguous archive paths are rejected before inventory' + } + $after=@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='W32Time'" | Select-Object Name,StartMode,State | Sort-Object Name) + Assert (($before | ConvertTo-Json -Compress) -ceq ($after | ConvertTo-Json -Compress)) 'Read-only reports preserve the observed service states and start modes' + Write-Host 'RetentionHealth.Windows.Tests: actual native source/collector reads, archive ACL and export smoke passed; no arrival/rollover/time-sync lab claim.' +} finally { Remove-Item -LiteralPath $temp -Recurse -Force } +# Handled native query failures are report evidence, not failed script assertions. +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index f21cfd57..4583de09 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 読み取り専用の`retention-health`を追加し、送信元/収集サーバーの標準ログバッファ、確認した先頭レコードの経過日数、申告されたアーカイブ方針、件数を制限したローカルEVTX/ACL一覧、各言語のWEF・時刻情報とログ消失・消去・満杯の兆候をJSONと単独で表示できるHTMLに分けて記録します。保持されたイベントの時刻に基づく件数率とUTF-8 XMLバイト数の試算には上限・前提を明示し、容量・完全な保持・実効読み取り権限・時刻同期・転送成功の証明とは扱いません。複数ホストでのロールオーバー・復旧・到着検証は別途必要です。 (#410) (@Shirofune-Security) - 読み取り専用の`control-applicability`を追加し、旧CISのApplication Guard監査要件を保持しつつ、Windows 11 24H2以降では削除済み・対象外と表示します。`default-evidence`で正確なビルド・パッチ・ドメイン参加・役割を含む現状を記録し、出典とレビュー情報が一致する参照環境と比較できます。旧ベースラインの既定値は履歴情報として保持し、未検証の既定値はUnknownと表示します。合成テストと読み取り専用CIからクリーンインストールやイベント生成の証明は行いません。 (#409) (@Shirofune-Security) - Windows標準のDNS Client/Server、CAPI2、WinRM、RDP Clientについて、明示的に選択するprovider-packsの監査・計画とチャネル設定を追加しました。固定した完全なルール定義と実際のプロバイダー・チャネル・イベントスキーマを確認し、DNSのチャネル名不一致や不明な前提条件を保持します。復旧記録付きの変更では大きいバッファ・保持方式・ACLを維持し、Analyticalと従来のDNSログは手動確認のみとします。イベント生成・バックエンド検証や検知範囲の向上は未確認です。 (#411) (@Shirofune-Security) - 任意実行の`audit-notifications`を追加し、OneSettings監査とSecurityログ警告しきい値の監査・計画・設定に対応しました。対象とチャネル変更の明示指定、OS・ADMX確認、型付き復旧記録と変更検出を行い、既存の低いしきい値とチャネルACL・保存方式を保持します。ポリシー一致と実イベント生成を分け、Windowsラボの証拠とSigma利用可能性は未検証と表示します。 (#408) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index b2434509..49cab3ba 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- 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) - Added read-only `control-applicability` for the historical CIS Application Guard audit requirement, reporting removal on Windows 11 24H2+ without remediation. Added exact build/patch/join/role native snapshots and provenance-bound reference comparison through `default-evidence`. Legacy baseline default strings are retained as historical hints while public defaults remain Unknown without reviewed scenario evidence. Synthetic fixtures and native read-only CI do not claim clean-install or event-generation proof. (#409) (@Shirofune-Security) - Added explicit native DNS Client/Server, CAPI2, WinRM and RDP Client provider-pack inventory and selective channel configuration. Pinned full rule definitions and live provider/channel/event schemas retain DNS channel mismatches and unknown prerequisites; journaled opt-in changes preserve larger buffers, retention and ACLs. Analytical/classic DNS remain manual-only, and no event/backend readiness uplift is claimed. (#411) (@Shirofune-Security) - Added opt-in `audit-notifications` audit/plan/configure for OneSettings auditing and Security log warning thresholds, with explicit control/channel selections, reviewed host and ADMX gates, typed recovery journals and drift checks. Earlier warning thresholds and channel ACL/retention are preserved. Reports separate policy matches from warning/event generation; Windows lab evidence and Sigma eligibility remain unverified. (#408) (@Shirofune-Security)