diff --git a/.gitattributes b/.gitattributes index 7f4524b7..c8781566 100644 --- a/.gitattributes +++ b/.gitattributes @@ -96,3 +96,7 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf /modules/WecSubscriptionInventory.cs text eol=lf /tests/WecCollectorObservation* text eol=lf /tests/WecSubscriptionInventory* text eol=lf + +# Scoped NTLM source and native evidence retain stable bytes. +/scripts/NtlmAudit.ps1 text eol=lf +/tests/NtlmAudit* text eol=lf diff --git a/.github/workflows/ntlm-auditing.yml b/.github/workflows/ntlm-auditing.yml new file mode 100644 index 00000000..c76057a9 --- /dev/null +++ b/.github/workflows/ntlm-auditing.yml @@ -0,0 +1,46 @@ +name: Scoped incoming and domain NTLM auditing +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/NtlmAudit.ps1' + - 'scripts/Configuration.ps1' + - 'tests/NtlmAudit*' + - '.github/workflows/ntlm-auditing.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + ntlm-auditing: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Scoped audit tests in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/NtlmAudit.Tests.ps1 + ./tests/NtlmAudit.Cli.Tests.ps1 + ./tests/NtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Scoped audit tests in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/NtlmAudit.Tests.ps1 + ./tests/NtlmAudit.Cli.Tests.ps1 + ./tests/NtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Retain typed originals, results and cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: ntlm-auditing-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-incoming-domain-audit-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52989c99..d3cd2ef9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/ntlm-auditing.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 3b6874eb..75989969 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 受信・ドメイン監査を明示的に選択する `ntlm-auditing` Audit/Plan/Configure を追加しました。受信監査 DWORD2 と実際のDC上のドメイン監査 DWORD7 のみを設定し、旧値2の意味を推測せず識別します。不明な値・ホストや設定の変化を拒否し、型付き変更前記録・再読取・部分失敗を区別します。ネイティブテストで受信設定、非DCのスキップ、無関係な設定の保持と復元を検証します。(関連 #363) (@Shirofune-Security) + - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) - Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7129758..8bad02c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Add `ntlm-auditing` Audit/Plan/Configure with explicit Incoming/Domain/Both selection. Configure only incoming audit DWORD2 and actual-DC domain audit DWORD7, identify legacy domain2 without invented semantics, refuse unknown values and host/state drift, and retain separate typed journals/readback/partial outcomes. Native tests verify incoming changes, non-DC skips and exact preservation of unrelated settings. (Related #363) (@Shirofune-Security) + - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) - Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 9aad3df0..b28d4184 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -17,6 +17,8 @@ [ValidateSet("PreserveOrAudit", "Audit", "Deny")] [string]$OutgoingNtlmMode = "PreserveOrAudit", [ValidateSet("Audit","Plan","Configure")][string]$NtlmAction = "Audit", + [ValidateSet("Audit","Plan","Configure")][string]$NtlmAuditAction = "Audit", + [ValidateSet("Incoming","Domain","Both")][string]$NtlmAuditScope = "Both", [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath, @@ -237,6 +239,7 @@ $AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt" $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/OutgoingNtlmAudit.ps1") +. (Join-Path $ScriptRoot "scripts/NtlmAudit.ps1") . (Join-Path $ScriptRoot "scripts/AdcsAuditing.ps1") . (Join-Path $ScriptRoot "scripts/AdcsRestartResume.ps1") . (Join-Path $ScriptRoot "scripts/AuditIntegrity.ps1") @@ -2078,6 +2081,7 @@ Usage: ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation + ./WELA.ps1 ntlm-auditing -Help # Configure selected incoming/domain NTLM auditing ./WELA.ps1 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription @@ -2182,6 +2186,12 @@ if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -l if ($Cmd -eq 'wec-listener' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecListenerAction','WecListenerComputerName','WecListenerLocalAddress','WecListenerPlanPath','WecListenerPlanHash','WecListenerOutputPath','Help')}).Count)) {throw 'wec-listener accepts only dedicated options.'} if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'} +if ($Cmd -ne 'ntlm-auditing' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('NtlmAuditAction','NtlmAuditScope')}).Count) {throw 'NtlmAudit options require ntlm-auditing.'} +if ($Cmd -eq 'ntlm-auditing') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAuditAction','NtlmAuditScope','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'ntlm-auditing accepts only its dedicated options.'} + if ($NtlmAuditAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAuditAction Configure.'} + if ($NtlmAuditAction -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('NtlmAuditScope')) {throw 'Configure requires explicit NtlmAuditScope Incoming, Domain or Both.'} +} if ($Cmd -ne 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} if ($Cmd -eq 'outgoing-ntlm') { if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} @@ -2276,7 +2286,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and @@ -2462,6 +2472,13 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaWecIngress @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'ntlm-auditing' { + if ($Help) {Write-Host 'Usage: ntlm-auditing [-NtlmAuditAction Audit|Plan|Configure] [-NtlmAuditScope Incoming|Domain|Both] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Configure requires explicit scope. Writes only incoming audit DWORD2 and/or actual-DC domain audit DWORD7; preserves all authentication restrictions. See docs/ntlm-auditing.md.';return} + if ($NtlmAuditAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'NTLM audit configuration requires Administrator privileges.'} + $report=Invoke-WelaNtlmAuditCommand -Action $NtlmAuditAction -Selection $NtlmAuditScope -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report|Format-List + exit $report.ExitCode + } 'outgoing-ntlm' { if ($Help) {Write-Host 'Usage: outgoing-ntlm [-NtlmAction Audit|Plan|Configure] [-OutgoingNtlmMode PreserveOrAudit|Audit] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Changes only the outgoing audit DWORD. Existing deny is preserved by default; explicit Audit authorizes replacing it. See docs/outgoing-ntlm.md.';return} if ($NtlmAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'Outgoing NTLM configuration requires Administrator privileges.'} diff --git a/docs/ntlm-auditing.md b/docs/ntlm-auditing.md new file mode 100644 index 00000000..68ca59e1 --- /dev/null +++ b/docs/ntlm-auditing.md @@ -0,0 +1,30 @@ +# Scoped incoming and domain NTLM auditing + +`ntlm-auditing` reads or configures two distinct audit values without invoking the broad `configure` workflow. It never writes an NTLM restriction or exception. Configure requires an explicit selection and elevated native 64-bit PowerShell on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Product type, domain role and join state must agree; role/build overrides are refused. + +| Selection | Exact value | Requested setting | Applicability | +| --- | --- | --- | --- | +| Incoming | `HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0\AuditReceivingNTLMTraffic` | DWORD 2, audit all accounts | Reviewed client and server roles | +| Domain | `HKLM\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters\AuditNTLMInDomain` | DWORD 7, Enable all | Actual domain controller only | +| Both | Both rows above | Each applicable audit setting | Domain row remains NotApplicable on a non-DC | + +```powershell +./WELA.ps1 ntlm-auditing -NtlmAuditAction Audit -ResultsPath audit.json +./WELA.ps1 ntlm-auditing -NtlmAuditAction Plan -NtlmAuditScope Incoming -ResultsPath plan.json +./WELA.ps1 ntlm-auditing -NtlmAuditAction Configure -NtlmAuditScope Incoming -DryRun -ResultsPath preview.json +./WELA.ps1 ntlm-auditing -NtlmAuditAction Configure -NtlmAuditScope Incoming -Auto -BackupPath ./before-incoming -ResultsPath incoming.json +# Run on the reviewed domain controller itself: +./WELA.ps1 ntlm-auditing -NtlmAuditAction Configure -NtlmAuditScope Domain -BackupPath ./before-domain -ResultsPath domain.json +``` + +Incoming accepts native DWORD 0/1/2 or an absent value. Domain accepts absent or DWORD 0/1/3/5/7, plus historical WELA DWORD 2 for migration to7. The report labels2 `LegacyValue2`; it does not invent its undocumented meaning or credit it as full auditing. All other values/types are refused. Existing parent keys are required. An absent audit value does not imply a measured clean-image default. + +Audit and Plan are live read-only assessments. Plan is not an importable authorization file. Configure re-reads the actual host and typed selected state, writes an original `before.jsonl` receipt before mutation, checks for drift after consent, and verifies immediate and final readback. Applied, AlreadyCompliant, Skipped, Failed and Overridden remain distinct. Partial failures return nonzero even if another selected row succeeded. A skipped non-DC domain row can coexist with exit0; this means domain configuration was not applicable, not that domain auditing was enabled. RSoP matches are last-applied observations from `RSOP_RegistryValue`, may be stale or incomplete, and do not prove current ownership or persistence. The registry write is not atomic with GPO or another administrator. + +Outgoing policy is managed separately by [outgoing-ntlm](outgoing-ntlm.md). This command preserves outgoing/incoming/domain restrictions, NTLM exceptions, channels, services and advanced audit masks. It does not authenticate, create domain objects, restart services, refresh GPO, or generate NTLM events. Registry compliance alone supplies no forwarding or Sigma credit. Sysmon is out of scope. + +For manual recovery, retain the successful selected result and original journal. Review `Before.Policy` and current ownership/drift before restoring that exact typed value, or removing only that value if originally absent. Never remove the parent key, replay another control's receipt, or treat a failed/partial attempt as a confirmed configuration. No automatic rollback occurs. + +Native acceptance uses disposable unjoined Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. It exercises actual incoming absence/disabled/domain-account-only→all-account auditing, dry run, original journals, repeated Configure, actual non-DC Domain/Both skips, and independent preservation/cleanup. Portable tests exercise DC transitions including historical2, unknown values/types, conflicting hosts, prompt drift, write/readback errors and partial outcomes. Windows 11, joined member/CA, actual DC application, domain authentication/events, policy persistence and collector delivery remain separate acceptance work for #363. + +Microsoft documents [incoming values0/1/2](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-localpoliciessecurityoptions#networksecurity_restrictntlm_auditincomingntlmtraffic) and the [domain audit policy's DC applicability and separation from blocking](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-audit-ntlm-authentication-in-this-domain). Domain 7 follows the already reviewed WELA domain-audit correction and its pinned baseline evidence; this addition isolates that setting into a dedicated command. diff --git a/docs/outgoing-ntlm.md b/docs/outgoing-ntlm.md index 8ba701c7..17e42ace 100644 --- a/docs/outgoing-ntlm.md +++ b/docs/outgoing-ntlm.md @@ -20,3 +20,5 @@ For manual recovery, inspect the selected successful result and its original `be Native acceptance uses disposable unjoined Server2022/2025 hosts under PowerShell5.1/7, exercises actual absence/allow→audit, original journals, dry run, repeat, readback and exact cleanup. Existing enforcement and malformed values are never installed on a native runner merely for testing; portable regressions verify those preservation/refusal paths, prompt-time drift and failures. Native tests preserve incoming/domain policy, siblings/access descriptor, channels, service and all59 audit masks. Windows11/DC/ADCS acceptance, authentication behavior, representative NTLM events, GPO persistence and collector delivery remain separate work for #362. No Sigma credit is inferred. Built-in Windows only; Sysmon is excluded. Microsoft distinguishes outgoing audit from deny, describes GPO precedence and identifies the NTLM Operational log for validation: [outgoing NTLM policy](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-outgoing-ntlm-traffic-to-remote-servers). + +For separate incoming and actual-DC domain audit configuration, use [ntlm-auditing](ntlm-auditing.md). diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index c44d2de1..5202f504 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "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", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "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", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/NtlmAudit.ps1 b/scripts/NtlmAudit.ps1 new file mode 100644 index 00000000..10eadea9 --- /dev/null +++ b/scripts/NtlmAudit.ps1 @@ -0,0 +1,100 @@ +# Explicit incoming/domain audit values. Authentication restrictions are separate controls. +function Get-WelaNtlmAuditHost { + if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Use native64-bit PowerShell on Windows.'} + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop + if([string]$os.BuildNumber -notmatch '^\d+$' -or $os.ProductType -notin @(1,2,3) -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5) -or [string]::IsNullOrWhiteSpace($computer.Name)){throw 'Incomplete Windows role/build identity.'} + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType;$role=[int]$computer.DomainRole;$joined=$computer.PartOfDomain + $coherent=($product -eq 1 -and (($role -eq 0 -and -not $joined) -or ($role -eq 1 -and $joined))) -or ($product -eq 3 -and (($role -eq 2 -and -not $joined) -or ($role -eq 3 -and $joined))) -or ($product -eq 2 -and $role -in @(4,5) -and $joined) + if(-not $coherent){throw 'Conflicting native product/domain-role/join observations.'} + if(-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))){throw 'This Windows role/build has not been reviewed.'} + [pscustomobject][ordered]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;ProductType=$product;DomainRole=$role;PartOfDomain=$joined} +} +function Get-WelaNtlmAuditDefinition { + param([ValidateSet('Incoming','Domain')][string]$Selection) + if($Selection -eq 'Incoming'){return [pscustomobject]@{Selection='Incoming';Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';Name='AuditReceivingNTLMTraffic';Value=2;Known=@(0,1,2);Meaning='Enable auditing for all accounts'}} + [pscustomobject]@{Selection='Domain';Path='HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters';Name='AuditNTLMInDomain';Value=7;Known=@(0,1,2,3,5,7);Meaning='Enable all domain NTLM auditing on the observed domain controller'} +} +function Get-WelaNtlmAuditSnapshot { + param([ValidateSet('Incoming','Domain')][string]$Selection) + $observedHost=Get-WelaNtlmAuditHost + if($Selection -eq 'Domain' -and $observedHost.ProductType -ne 2){return [pscustomobject][ordered]@{Host=$observedHost;Applicable=$false;Policy=$null}} + $definition=Get-WelaNtlmAuditDefinition $Selection + $policy=Get-WelaRegistryState $definition.Path $definition.Name + if(-not $policy.KeyExists){throw 'The existing native policy key is required; no parent key will be created.'} + [pscustomobject][ordered]@{Host=$observedHost;Applicable=$true;Policy=$policy} +} +function Get-WelaNtlmAuditDisposition { + param($Snapshot,$Definition) + if(-not $Snapshot.Applicable){return 'NotApplicable'} + $p=$Snapshot.Policy + if($p.ValueExists -and ($p.Type -cne 'DWord' -or ($p.Value -isnot [int] -and $p.Value -isnot [long] -and $p.Value -isnot [uint32]) -or $p.Value -notin $Definition.Known)){return 'Unknown'} + if($p.ValueExists -and $p.Value -eq $Definition.Value){return 'AlreadyCompliant'} + if($Definition.Selection -eq 'Domain' -and $p.ValueExists -and $p.Value -eq 2){return 'LegacyValue2'} + return 'ChangeRequired' +} +function Get-WelaNtlmAuditPolicySource { + param($Definition) + $key='MACHINE\'+$Definition.Path.Substring(6) + try{ + $rows=@(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName RSOP_RegistryValue -ErrorAction Stop|Where-Object {$_.KeyName -ieq $key -and $_.ValueName -ieq $Definition.Name}|Sort-Object precedence) + [pscustomobject]@{Status=$(if($rows.Count){'Observed'}else{'NotObserved'});Class='RSOP_RegistryValue';Matches=@($rows|Select-Object KeyName,ValueName,Type,Data,GPOID,precedence);Diagnostic='Last-applied RSoP may be stale or incomplete. This does not establish the current registry writer, local ownership or policy persistence.'} + }catch{[pscustomobject]@{Status='Unknown';Class='RSOP_RegistryValue';Matches=@();Diagnostic=$_.Exception.Message+' RSoP is potentially stale and is not current policy ownership evidence.'}} +} +function Get-WelaNtlmAuditPlan { + param([ValidateSet('Incoming','Domain','Both')][string]$Selection='Both') + $rows=@() + foreach($selected in @($(if($Selection -eq 'Both'){'Incoming';'Domain'}else{$Selection}))){ + $definition=Get-WelaNtlmAuditDefinition $selected + try{ + $snapshot=Get-WelaNtlmAuditSnapshot $selected;$status=Get-WelaNtlmAuditDisposition $snapshot $definition + $diagnostic=switch($status){ + NotApplicable {'Domain NTLM auditing is not applicable to this observed non-DC host. No domain policy value was read or selected for writing.'} + Unknown {'Unknown registry type/value is preserved. Inspect it before configuration.'} + LegacyValue2 {'Historical WELA value2 is not credited as Enable all. Its undocumented meaning is not inferred; selected Configure requests DWORD7.'} + AlreadyCompliant {'The requested audit value is configured. Actual authentication events and policy persistence are unverified.'} + default {$definition.Meaning} + } + $rows+=[pscustomobject]@{Selection=$selected;Definition=$definition;Status=$status;Before=$snapshot;Diagnostic=$diagnostic;PolicySource=$(if($snapshot.Applicable){Get-WelaNtlmAuditPolicySource $definition}else{$null})} + }catch{$rows+=[pscustomobject]@{Selection=$selected;Definition=$definition;Status='Unknown';Before=$null;Diagnostic=$_.ToString();PolicySource=$null}} + } + [pscustomobject]@{Selection=$Selection;Controls=$rows;Mode='Audit only';PlanKind='Live assessment; not an importable authorization file'} +} +function Invoke-WelaNtlmAuditCommand { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[ValidateSet('Incoming','Domain','Both')][string]$Selection='Both',[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + if($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)){throw 'Consent, dry-run and backup options require Configure.'} + if($Action -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('Selection')){throw 'Configure requires an explicit Incoming, Domain or Both selection.'} + $plan=Get-WelaNtlmAuditPlan $Selection + if($Action -eq 'Configure'){ + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + foreach($row in $plan.Controls){ + $definition=$row.Definition;$target=@{Path=$definition.Path;Name=$definition.Name};$desired=@{Value=$definition.Value;Type='DWord'};$id="Registry/$($definition.Path)/$($definition.Name)" + if($row.Status -in @('Unknown','NotApplicable')){ + $context.Results.Add([pscustomobject]@{Id=$id;Kind='Registry';Target=$target;Desired=$desired;Before=$row.Before;After=$null;Status=$(if($row.Status -eq 'Unknown'){'Failed'}else{'Skipped'});Diagnostic=$row.Diagnostic}) + continue + } + $state=@{Observed=$null;PlannedHost=($row.Before.Host|ConvertTo-Json -Compress);Definition=$definition} + $read={param($s) + $snapshot=Get-WelaNtlmAuditSnapshot $s.Definition.Selection + if(($snapshot.Host|ConvertTo-Json -Compress) -cne $s.PlannedHost -or -not $snapshot.Applicable){throw 'Native host role/context changed; review a new plan.'} + if((Get-WelaNtlmAuditDisposition $snapshot $s.Definition) -eq 'Unknown'){throw 'Unknown registry type/value is preserved.'} + $s.Observed=$snapshot;return $snapshot + } + $test={param($snapshot,$s) $snapshot.Applicable -and $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq $s.Definition.Value} + $apply={param($s) + $fresh=Get-WelaNtlmAuditSnapshot $s.Definition.Selection + if(($fresh|ConvertTo-Json -Depth 8 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 8 -Compress)){throw 'NTLM audit state changed after the original journal snapshot; no write attempted.'} + if((Get-WelaNtlmAuditDisposition $fresh $s.Definition) -notin @('ChangeRequired','LegacyValue2')){throw 'The current state no longer authorizes this write.'} + Set-ItemProperty -LiteralPath $s.Definition.Path -Name $s.Definition.Name -Value $s.Definition.Value -Type DWord -ErrorAction Stop + 'Only the selected NTLM audit DWORD was requested. Authentication restrictions and exceptions were not changed.' + } + Invoke-WelaConfigurationControl -Context $context -Id $id -Kind Registry -Target $target -Desired $desired -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description $row.Diagnostic + } + $report=Complete-WelaConfiguration -Context $context -Scope 'incoming-domain-ntlm-audit-policy-only' -SuccessMessage 'Selected NTLM audit results recorded; inspect failed/skipped controls separately.' + $report|Add-Member NoteProperty Plan $plan + }else{$report=[pscustomobject]@{ExitCode=$(if(@($plan.Controls|Where-Object Status -eq 'Unknown').Count){1}else{0});Scope='incoming-domain-ntlm-audit-policy-only';Action=$Action;Plan=$plan}} + $report|Add-Member NoteProperty EventGeneration 'Unverified. Audit registry values do not prove authentication, NTLM events, GPO persistence, forwarding or Sigma readiness.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if($ResultsPath){try{$report|ConvertTo-Json -Depth 18|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop}catch{$report.ExitCode=1;Write-Host "[Failed] Writing NTLM audit results: $_" -ForegroundColor Red}} + return $report +} diff --git a/tests/NtlmAudit.Cli.Tests.ps1 b/tests/NtlmAudit.Cli.Tests.ps1 new file mode 100644 index 00000000..14ae3b50 --- /dev/null +++ b/tests/NtlmAudit.Cli.Tests.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-audit-cli-'+[guid]::NewGuid().ToString('N')) +$cases=@( + @{Args=@('ntlm-auditing','-Help');Code=0;Pattern='preserves all authentication restrictions'}, + @{Args=@('configure','-NtlmAuditAction','Configure');Code=1;Pattern='require ntlm-auditing'}, + @{Args=@('audit','-NtlmAuditScope','Incoming');Code=1;Pattern='require ntlm-auditing'}, + @{Args=@('ntlm-auditing','-NtlmAuditAction','Configure');Code=1;Pattern='requires explicit NtlmAuditScope'}, + @{Args=@('ntlm-auditing','-OutgoingNtlmMode','Deny');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Role','DomainController');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Build','26100');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Auto');Code=1;Pattern='require NtlmAuditAction Configure'}, + @{Args=@('ntlm-auditing','-DryRun');Code=1;Pattern='require NtlmAuditAction Configure'}, + @{Args=@('ntlm-auditing','-BackupPath',$root);Code=1;Pattern='require NtlmAuditAction Configure'}, + @{Args=@('ntlm-auditing','-Help','-NtlmAction','Configure');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-NtlmAuditScope','Incoming','-NtlmAuditAction','Configure','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++} +if(Test-Path $root){throw 'Refused CLI input created unexpected output.'} +Write-Host "PASS: $count scoped NTLM CLI guards." +exit 0 diff --git a/tests/NtlmAudit.Tests.ps1 b/tests/NtlmAudit.Tests.ps1 new file mode 100644 index 00000000..2f66238b --- /dev/null +++ b/tests/NtlmAudit.Tests.ps1 @@ -0,0 +1,97 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/NtlmAudit.ps1') +$hostValidator=${function:Get-WelaNtlmAuditHost} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-audit-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$count=0;$sequence=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 18 -Compress} +function Copy-Fixture($Value){Key $Value|ConvertFrom-Json} +function Reset($Incoming=0,$Domain=0,[switch]$Dc){ + $script:hostState=[pscustomobject][ordered]@{Computer='fixture';Domain=$(if($Dc){'fixture.test'}else{'WORKGROUP'});Build=26100;ProductType=$(if($Dc){2}else{3});DomainRole=$(if($Dc){4}else{2});PartOfDomain=[bool]$Dc} + $script:policies=@{};foreach($pair in @(@('Incoming',$Incoming),@('Domain',$Domain))){$script:policies[$pair[0]]=[pscustomobject][ordered]@{KeyExists=$true;ValueExists=($null -ne $pair[1]);Value=$pair[1];Type=$(if($null -eq $pair[1]){$null}else{'DWord'})}} + $script:writes=@();$script:reads=@{Incoming=0;Domain=0};$script:readFail='';$script:writeFail='';$script:ignore='';$script:promptChange=$null;$script:onRead=$null +} +function Get-WelaNtlmAuditHost {Copy-Fixture $script:hostState} +function Get-WelaNtlmAuditPolicySource {[pscustomobject]@{Status='Unknown';Diagnostic='Fixture has no policy ownership evidence.'}} +function Get-WelaRegistryState {param($Path,$Name) + $selection=switch($Name){AuditReceivingNTLMTraffic{'Incoming'} AuditNTLMInDomain{'Domain'} default {throw 'Unexpected read'}} + $definition=Get-WelaNtlmAuditDefinition $selection;if($Path -cne $definition.Path){throw 'Unexpected registry path'} + $script:reads[$selection]++;if($script:onRead){& $script:onRead $selection} + if($script:readFail -eq $selection){throw 'Injected read denied'} + Copy-Fixture $script:policies[$selection] +} +function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + $selection=switch($Name){AuditReceivingNTLMTraffic{'Incoming'} AuditNTLMInDomain{'Domain'} default {throw 'Unexpected mutation'}} + $definition=Get-WelaNtlmAuditDefinition $selection + Assert ($LiteralPath -ceq $definition.Path -and $Value -eq $definition.Value -and $Type -ceq 'DWord') 'Only the selected exact audit value may be changed.' + $script:writes+=@($selection);if($script:writeFail -eq $selection){throw 'Injected write denied'} + if($script:ignore -ne $selection){$script:policies[$selection].ValueExists=$true;$script:policies[$selection].Value=$Value;$script:policies[$selection].Type='DWord'} +} +function Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'} +function Configure($Selection='Incoming',[switch]$DryRun,[switch]$Prompt){ + $script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence) + Invoke-WelaNtlmAuditCommand -Action Configure -Selection $Selection -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $script:backup +} +try{ + foreach($initial in @($null,0,1,2)){ + Reset -Incoming $initial;$old=Key $script:policies.Incoming;$r=Configure + Assert ($r.ExitCode -eq 0 -and $r.Scope -ceq 'incoming-domain-ntlm-audit-policy-only' -and $r.ReadyRuleCredit -eq 0) 'Success is explicitly limited to selected audit policies.' + Assert ($script:policies.Incoming.Value -eq 2 -and $script:writes.Count -eq $(if($initial -eq 2){0}else{1}) -and $script:reads.Domain -eq 0) 'Incoming scope preserves domain policy and enables only auditing.' + if($initial -ne 2){$j=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json);Assert ($j.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq $old) 'Journal retains exact typed original before one write.'} + else{Assert ($r.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $backup 'before.jsonl'))) 'Existing all-account auditing is idempotent.'} + } + foreach($initial in @($null,0,1,2,3,5,7)){ + Reset -Domain $initial -Dc;$r=Configure Domain + Assert ($r.ExitCode -eq 0 -and $script:policies.Domain.Value -eq 7 -and $script:reads.Incoming -eq 0) 'Actual-DC domain selection requests only full domain auditing.' + if($initial -eq 2){Assert ($r.Plan.Controls[0].Status -ceq 'LegacyValue2' -and $r.Plan.Controls[0].Diagnostic -match 'undocumented') 'Legacy2 is identified without assigning it invented semantics.'} + } + foreach($selection in @('Incoming','Domain')){ + $values=if($selection -eq 'Incoming'){@(3,42,'1',$true)}else{@(4,6,8,'7',$true)} + foreach($invalid in $values){ + Reset -Dc;$script:policies[$selection].Value=$invalid;$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed' -and $script:writes.Count -eq 0) 'Unknown numeric values and coerced strings/bools fail without mutation.' + } + Reset -Dc;$script:policies[$selection].Type='String';$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Unknown registry type fails without mutation.' + Reset -Dc;$script:policies[$selection].KeyExists=$false;$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Missing policy parents are never created.' + Reset -Dc;$script:readFail=$selection;$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Access-denied is not fabricated absence.' + } + foreach($selection in @('Domain','Both')){ + Reset;$r=Configure $selection + $domain=@($r.Results|Where-Object {$_.Target.Name -eq 'AuditNTLMInDomain'}) + Assert ($r.ExitCode -eq 0 -and $domain.Count -eq 1 -and $domain[0].Status -ceq 'Skipped' -and $script:reads.Domain -eq 0 -and $script:writes -notcontains 'Domain') 'Non-DC domain audit is explicitly not applicable with no read or write.' + } + Reset -Dc;$r=Configure Both;Assert ($r.ExitCode -eq 0 -and $script:writes.Count -eq 2 -and @($r.Results|Where-Object Status -ne 'Applied').Count -eq 0) 'Both scopes produce separate applied rows on a coherent DC.' + Reset -Dc;$script:writeFail='Domain';$r=Configure Both;Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Applied' -and $r.Results[1].Status -ceq 'Failed') 'A partial failure preserves each distinct result and nonzero status.' + Reset -Dc;$r=Configure Both -DryRun;Assert ($script:writes.Count -eq 0 -and $r.DryRun -and -not(Test-Path $backup)) 'Dry-run creates neither policy mutations nor journal directory.' + Reset;$script:ignore='Incoming';$r=Configure;Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed') 'An ignored native write fails immediate verification.' + foreach($changed in @(1,2,42)){ + Reset;$script:changed=$changed;$script:promptChange={$script:policies.Incoming.Value=$script:changed};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Prompt-time value drift refuses mutation after preserving the original snapshot.' + } + Reset;$script:promptChange={$script:hostState.Computer='different-host'};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Prompt-time host drift refuses mutation.' + Reset;$script:onRead={param($s)if($s -eq 'Incoming' -and $script:reads.Incoming -eq 5){$script:policies.Incoming.Value=0}};$r=Configure + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Overridden') 'Later policy override fails final readback.' + Reset;$r=Invoke-WelaNtlmAuditCommand -Action Plan;Assert ($r.Plan.Controls.Count -eq 2 -and $script:writes.Count -eq 0) 'Default assessment reports both distinct controls without mutation.' + $refused=$false;try{Invoke-WelaNtlmAuditCommand -Action Configure}catch{$refused=$true};Assert $refused 'The library requires explicit Configure selection.' + foreach($action in @('Audit','Plan')){foreach($option in @('Auto','DryRun','BackupPath')){ + $args=@{Action=$action};$args[$option]=$(if($option -eq 'BackupPath'){'unused'}else{$true});$refused=$false;try{Invoke-WelaNtlmAuditCommand @args}catch{$refused=$true};Assert $refused 'Read-only actions reject mutation-only options.' + }} + # Exercise actual CIM role validation independently of the policy fixture. + $savedOs=$env:OS;$env:OS='Windows_NT' + function Get-CimInstance {param($ClassName,$Property,$ErrorAction)if($ClassName -eq 'Win32_OperatingSystem'){$script:osFixture}else{$script:computerFixture}} + try{ + foreach($case in @(@(1,0,$false,26100),@(1,1,$true,26200),@(3,2,$false,20348),@(3,3,$true,26100),@(2,4,$true,20348),@(2,5,$true,26100))){ + $script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$h=&$hostValidator;Assert ($h.ProductType -eq $case[0]) 'Coherent native role/build accepted.' + } + foreach($case in @(@(2,2,$false,26100),@(3,4,$true,26100),@(1,1,$false,26100),@(3,3,$false,26100),@(2,5,$true,99999))){ + $script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$refused=$false;try{&$hostValidator}catch{$refused=$true};Assert $refused 'Conflicting or unsupported observed host is refused.' + } + }finally{$env:OS=$savedOs} +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count scoped incoming/domain NTLM assertions." +exit 0 diff --git a/tests/NtlmAudit.Windows.Tests.ps1 b/tests/NtlmAudit.Windows.Tests.ps1 new file mode 100644 index 00000000..2d5bba86 --- /dev/null +++ b/tests/NtlmAudit.Windows.Tests.ps1 @@ -0,0 +1,80 @@ +param([switch]$AllowDisposableAuditWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/NtlmAudit.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@() +$root=Join-Path $env:RUNNER_TEMP ('wela-incoming-domain-audit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='AuditReceivingNTLMTraffic' +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +function Other { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null + try{ + $k=$base.OpenSubKey('SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0');if(-not $k){throw 'Existing MSV1_0 key required.'} + $values=@($k.GetValueNames()|Sort-Object|Where-Object {$_ -ine $name}|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}) + $children=@($k.GetSubKeyNames()|Sort-Object) + $security=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()} + $acl=$security.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group) + }finally{if($k){$k.Dispose()};$base.Dispose()} + [pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status} +} +function Public([string]$Label,[string[]]$Arguments){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') ntlm-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($code -eq 0) "Public $Label exited $code : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json +} +$original=Get-WelaNtlmAuditSnapshot Incoming +Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.' +Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in @(0,1,2))) 'Fixture refuses unknown audit values/types and preserves all authentication restrictions.' +$other=Other;$masks=Masks +Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA;Sources=@(foreach($file in @('WELA.ps1','scripts/NtlmAudit.ps1','scripts/Configuration.ps1')){[pscustomobject]@{Name=$file;Sha256=(Get-FileHash (Join-Path $repo $file)).Hash.ToLowerInvariant()}})} +try{ + foreach($case in @('absent','disabled','domain-accounts')){ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($case -ne 'absent'){$null=New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value $(if($case -eq 'disabled'){0}else{1})} + $prepared=Get-WelaNtlmAuditSnapshot Incoming + $plan=Public ($case+'-plan') @('-NtlmAuditScope','Both','-NtlmAuditAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json'))) + Assert ($plan.Plan.Controls[0].Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Controls[0].Before) -ceq (Key $prepared)) 'Public plan retains the exact native absence/allow state and actual host.' + $dryBackup=Join-Path $root ($case+'-dry-backup') + $dry=Public ($case+'-dry') @('-NtlmAuditScope','Both','-NtlmAuditAction','Configure','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root ($case+'-dry.json'))) + Assert ($dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and $dry.Results[1].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup) -and (Key (Get-WelaNtlmAuditSnapshot Incoming)) -ceq (Key $prepared)) 'Dry run preserves policy and creates no journal directory.' + $backup=Join-Path $root ($case+'-backup') + $report=Public $case @('-NtlmAuditScope','Both','-NtlmAuditAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json'))) + $after=Get-WelaNtlmAuditSnapshot Incoming + Assert ($report.Scope -ceq 'incoming-domain-ntlm-audit-policy-only' -and $report.Results.Count -eq 2 -and $report.Results[0].Status -ceq 'Applied' -and $report.Results[1].Status -ceq 'Skipped' -and $report.Plan.Controls[1].Status -ceq 'NotApplicable') 'Exactly one native incoming audit value is applied; non-DC domain policy is skipped through the public CLI.' + Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 2 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Native audit-only readback matches the public result.' + $journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and (Key $journal[0].Before) -ceq (Key $prepared) -and $journal[0].Target.Path -ceq $path -and $journal[0].Target.Name -ceq $name) 'One original journal retains the actual typed policy and native context.' + $repeatBackup=Join-Path $root ($case+'-repeat-backup') + $repeat=Public ($case+'-repeat') @('-NtlmAuditScope','Both','-NtlmAuditAction','Configure','-Auto','-BackupPath',$repeatBackup,'-ResultsPath',(Join-Path $root ($case+'-repeat.json'))) + Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $repeatBackup 'before.jsonl'))) 'Repeated configuration is idempotent without another original journal.' + $audit=Public ($case+'-audit') @('-NtlmAuditScope','Both','-NtlmAuditAction','Audit','-ResultsPath',(Join-Path $root ($case+'-audit.json'))) + Assert ($audit.Plan.Controls[0].Status -ceq 'AlreadyCompliant' -and $audit.ReadyRuleCredit -eq 0 -and $audit.EventGeneration -like 'Unverified*') 'Audit distinguishes registry compliance from event or authentication proof.' + Assert ((Key (Other)) -ceq (Key $other) -and (Masks) -ceq $masks) 'Outgoing/domain policies, siblings, access descriptor, channels, service and all59 masks remain unchanged.' + } + $domainBackup=Join-Path $root 'domain-only-backup' + $domainOnly=Public 'domain-only' @('-NtlmAuditScope','Domain','-NtlmAuditAction','Configure','-Auto','-BackupPath',$domainBackup,'-ResultsPath',(Join-Path $root 'domain-only.json')) + Assert ($domainOnly.Results.Count -eq 1 -and $domainOnly.Results[0].Status -ceq 'Skipped' -and $domainOnly.Plan.Controls[0].Status -ceq 'NotApplicable' -and -not(Test-Path (Join-Path $domainBackup 'before.jsonl'))) 'Domain-only Configure has no original write journal on actual non-DC.' + Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=3;Scope='Only incoming audit DWORD2; non-DC domain policy skipped. No network authentication attempt, enforcement, event generation, GPO refresh or Sigma proof.'} +}catch{$failure=$_.ToString();throw}finally{ + try{ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($original.Policy.ValueExists){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $original.Policy.Value -PropertyType $original.Policy.Type} + }catch{$errors+=$_.ToString()} + $checks=[ordered]@{} + foreach($pair in @(@('Policy',{(Key (Get-WelaNtlmAuditSnapshot Incoming)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + Save 'artifact-hashes.json' @(Get-ChildItem -LiteralPath $root -Recurse -File|Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object{[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash.ToLowerInvariant()}}) + if(-not $complete){throw 'Incoming/domain NTLM native fixture cleanup failed.'} +} +Write-Host "PASS: $count native public incoming/domain NTLM assertions and exact cleanup." +exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 854679ee..2316cb62 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 受信・ドメイン監査を明示的に選択する `ntlm-auditing` Audit/Plan/Configure を追加しました。受信監査 DWORD2 と実際のDC上のドメイン監査 DWORD7 のみを設定し、旧値2の意味を推測せず識別します。不明な値・ホストや設定の変化を拒否し、型付き変更前記録・再読取・部分失敗を区別します。ネイティブテストで受信設定、非DCのスキップ、無関係な設定の保持と復元を検証します。(関連 #363) (@Shirofune-Security) + - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) - Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 6b533916..77990ffd 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Add `ntlm-auditing` Audit/Plan/Configure with explicit Incoming/Domain/Both selection. Configure only incoming audit DWORD2 and actual-DC domain audit DWORD7, identify legacy domain2 without invented semantics, refuse unknown values and host/state drift, and retain separate typed journals/readback/partial outcomes. Native tests verify incoming changes, non-DC skips and exact preservation of unrelated settings. (Related #363) (@Shirofune-Security) + - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) - Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security)